From 10393f45b6fc2d4b09a75f31f8ba514a7d4c3e5a Mon Sep 17 00:00:00 2001 From: hypercross Date: Tue, 7 Apr 2026 15:32:06 +0800 Subject: [PATCH] refactor: add rng & seeding --- src/core/game-host.ts | 4 +- src/core/game.ts | 7 +++ src/samples/onitama/commands.ts | 10 +++-- src/samples/onitama/types.ts | 77 ++++++++++++++++++++++----------- src/utils/rng.ts | 4 +- tests/samples/onitama.test.ts | 42 +++++++++++------- 6 files changed, 98 insertions(+), 46 deletions(-) diff --git a/src/core/game-host.ts b/src/core/game-host.ts index 8d1c99f..646b983 100644 --- a/src/core/game-host.ts +++ b/src/core/game-host.ts @@ -108,7 +108,7 @@ export class GameHost, TResult=unknown> { this._context._state.clearInterruptions(); } - start(): Promise { + start(seed?: number): Promise { if (this._isDisposed) { throw new Error('GameHost is disposed'); } @@ -118,6 +118,8 @@ export class GameHost, 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'; diff --git a/src/core/game.ts b/src/core/game.ts index 009421b..677d211 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -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 = {} > { get value(): TState; + get rng(): ReadonlyRNG; produce(fn: (draft: TState) => void): void; produceAsync(fn: (draft: TState) => void): Promise; run(input: string): Promise>; @@ -20,6 +22,7 @@ export interface IGameContext = {} > { // test only _state: MutableSignal; _commands: CommandRunnerContextExport>; + _rng: RNG; } export function createGameContext = {} >( @@ -34,6 +37,9 @@ export function createGameContext = {} >( get value(): TState { return state.value; }, + get rng() { + return this._rng; + }, produce(fn) { return state.produce(fn); }, @@ -52,6 +58,7 @@ export function createGameContext = {} >( _state: state, _commands: commands, + _rng: new Mulberry32RNG(), }; context._commands = commands = createCommandRunnerContext(commandRegistry, context); diff --git a/src/samples/onitama/commands.ts b/src/samples/onitama/commands.ts index 24d2213..1e71464 100644 --- a/src/samples/onitama/commands.ts +++ b/src/samples/onitama/commands.ts @@ -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; } diff --git a/src/samples/onitama/types.ts b/src/samples/onitama/types.ts index 1022998..32df334 100644 --- a/src/samples/onitama/types.ts +++ b/src/samples/onitama/types.ts @@ -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; export type OnitamaGame = IGameContext; \ No newline at end of file diff --git a/src/utils/rng.ts b/src/utils/rng.ts index c1a6fc1..4721585 100644 --- a/src/utils/rng.ts +++ b/src/utils/rng.ts @@ -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; diff --git a/tests/samples/onitama.test.ts b/tests/samples/onitama.test.ts index bab4442..a66f4d5 100644 --- a/tests/samples/onitama.test.ts +++ b/tests/samples/onitama.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { - registry, - createInitialState, +import { + registry, + createInitialState, + initializeCards, OnitamaState, createPawns, createCards, @@ -13,6 +14,7 @@ import type { PromptEvent } from '@/utils/command'; function createTestContext() { const ctx = createGameContext(registry, createInitialState()); + initializeCards(ctx); return { registry, ctx }; } @@ -71,18 +73,19 @@ function waitForPrompt(ctx: ReturnType['ctx']): Promis describe('Onitama Game', () => { describe('Setup', () => { it('should create initial state correctly', () => { - const state = createInitialState(); + const ctx = createGameContext(registry, createInitialState()); + const stateBefore = ctx.value; - expect(state.currentPlayer).toBeDefined(); - expect(state.winner).toBeNull(); - expect(state.regions.board).toBeDefined(); - expect(state.regions.red).toBeDefined(); - expect(state.regions.black).toBeDefined(); - expect(state.regions.spare).toBeDefined(); + expect(stateBefore.currentPlayer).toBeDefined(); + expect(stateBefore.winner).toBeNull(); + expect(stateBefore.regions.board).toBeDefined(); + expect(stateBefore.regions.red).toBeDefined(); + expect(stateBefore.regions.black).toBeDefined(); + expect(stateBefore.regions.spare).toBeDefined(); // Should have 10 pawns (5 per player) - const redPawns = Object.values(state.pawns).filter(p => p.owner === 'red'); - const blackPawns = Object.values(state.pawns).filter(p => p.owner === 'black'); + const redPawns = Object.values(stateBefore.pawns).filter(p => p.owner === 'red'); + const blackPawns = Object.values(stateBefore.pawns).filter(p => p.owner === 'black'); expect(redPawns.length).toBe(5); expect(blackPawns.length).toBe(5); @@ -96,10 +99,19 @@ describe('Onitama Game', () => { expect(redMaster?.position[0]).toBe(2); expect(redMaster?.position[1]).toBe(0); + // Cards should not be distributed yet + expect(stateBefore.redCards.length).toBe(0); + expect(stateBefore.blackCards.length).toBe(0); + expect(stateBefore.spareCard).toBe(''); + + // Initialize cards + initializeCards(ctx); + const stateAfter = ctx.value; + // Cards should be distributed: 2 per player + 1 spare - expect(state.redCards.length).toBe(2); - expect(state.blackCards.length).toBe(2); - expect(state.spareCard).toBeDefined(); + expect(stateAfter.redCards.length).toBe(2); + expect(stateAfter.blackCards.length).toBe(2); + expect(stateAfter.spareCard).toBeDefined(); }); it('should create pawns in correct positions', () => {