refactor: add rng & seeding

This commit is contained in:
2026-04-07 15:32:06 +08:00
parent 6b736ab083
commit 10393f45b6
6 changed files with 98 additions and 46 deletions
+3 -1
View File
@@ -108,7 +108,7 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
this._context._state.clearInterruptions();
}
start(): Promise<TResult> {
start(seed?: number): Promise<TResult> {
if (this._isDisposed) {
throw new Error('GameHost is disposed');
}
@@ -118,6 +118,8 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
const initialState = this._createInitialState();
this._context._state.value = initialState as any;
seed = seed || Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
this._context._rng.setSeed(seed);
const promise = this._start(this._context);
this._status.value = 'running';
+7
View File
@@ -8,9 +8,11 @@ import {
createCommandRunnerContext, parseCommandSchema,
} from "@/utils/command";
import {PromptValidator} from "@/utils/command/command-runner";
import {Mulberry32RNG, ReadonlyRNG, RNG} from "@/utils/rng";
export interface IGameContext<TState extends Record<string, unknown> = {} > {
get value(): TState;
get rng(): ReadonlyRNG;
produce(fn: (draft: TState) => void): void;
produceAsync(fn: (draft: TState) => void): Promise<void>;
run<T>(input: string): Promise<CommandResult<T>>;
@@ -20,6 +22,7 @@ export interface IGameContext<TState extends Record<string, unknown> = {} > {
// test only
_state: MutableSignal<TState>;
_commands: CommandRunnerContextExport<IGameContext<TState>>;
_rng: RNG;
}
export function createGameContext<TState extends Record<string, unknown> = {} >(
@@ -34,6 +37,9 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
get value(): TState {
return state.value;
},
get rng() {
return this._rng;
},
produce(fn) {
return state.produce(fn);
},
@@ -52,6 +58,7 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
_state: state,
_commands: commands,
_rng: new Mulberry32RNG(),
};
context._commands = commands = createCommandRunnerContext(commandRegistry, context);
+7 -3
View File
@@ -2,7 +2,8 @@ import {
OnitamaGame,
OnitamaState,
PlayerType,
prompts
prompts,
initializeCards
} from "./types";
import {createGameCommandRegistry} from "@/core/game";
import {moveToRegion} from "@/core/region";
@@ -342,14 +343,17 @@ const turn = registry.register({
* 开始游戏主循环
*/
export async function start(game: OnitamaGame) {
// Initialize cards with RNG at game start
initializeCards(game);
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer);
if (turnOutput.winner) {
break;
}
}
return game.value;
}
+51 -26
View File
@@ -125,27 +125,13 @@ export function createInitialState() {
const regions = createRegions();
const pawns = createPawns();
const cards = createCards();
// Distribute cards: 2 to each player, 1 spare
const cardNames = Object.keys(cards);
const shuffled = [...cardNames].sort(() => Math.random() - 0.5);
const redCards = shuffled.slice(0, 2);
const blackCards = shuffled.slice(2, 4);
const spareCard = shuffled[4];
// Set card regions
for(const cardName of redCards){
cards[cardName].regionId = 'red';
regions.red.childIds.push(cardName);
}
for(const cardName of blackCards){
cards[cardName].regionId = 'black';
regions.black.childIds.push(cardName);
}
cards[spareCard].regionId = 'spare';
regions.spare.childIds.push(spareCard);
// Cards will be shuffled and distributed in start()
// Initialize with empty card lists
const redCards: string[] = [];
const blackCards: string[] = [];
const spareCard = '';
// Populate board region childIds
for(const pawn of Object.values(pawns)){
if(pawn.regionId === 'board'){
@@ -153,15 +139,12 @@ export function createInitialState() {
regions.board.partMap[pawn.position.join(',')] = pawn.id;
}
}
// Determine starting player from spare card
const startingPlayer = cards[spareCard].startingPlayer;
return {
regions,
pawns,
cards,
currentPlayer: startingPlayer,
currentPlayer: 'red' as PlayerType,
winner: null as PlayerType | null,
spareCard,
redCards,
@@ -170,5 +153,47 @@ export function createInitialState() {
};
}
/**
* 洗牌并分配卡牌,设置起始玩家
*/
export function initializeCards(game: OnitamaGame) {
const state = game.value;
const rng = game.rng;
const cardNames = Object.keys(state.cards);
// Fisher-Yates shuffle using game.rng
const shuffled = [...cardNames];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const redCards = shuffled.slice(0, 2);
const blackCards = shuffled.slice(2, 4);
const spareCard = shuffled[4];
game.produce(state => {
// Set card regions
for(const cardName of redCards){
state.cards[cardName].regionId = 'red';
state.regions.red.childIds.push(cardName);
}
for(const cardName of blackCards){
state.cards[cardName].regionId = 'black';
state.regions.black.childIds.push(cardName);
}
state.cards[spareCard].regionId = 'spare';
state.regions.spare.childIds.push(spareCard);
// Set card lists
state.redCards = redCards;
state.blackCards = blackCards;
state.spareCard = spareCard;
// Determine starting player from spare card
state.currentPlayer = state.cards[spareCard].startingPlayer;
});
}
export type OnitamaState = ReturnType<typeof createInitialState>;
export type OnitamaGame = IGameContext<OnitamaState>;
+3 -1
View File
@@ -1,9 +1,11 @@
export interface RNG {
export interface RNG extends ReadonlyRNG{
/** 设置随机数种子 */
setSeed(seed: number): void;
getSeed(): number;
}
export interface ReadonlyRNG {
/** 获取一个 [0,1) 随机数 */
next(max?: number): number;