refactor: add rng & seeding

This commit is contained in:
hypercross 2026-04-07 15:32:06 +08:00
parent 6b736ab083
commit 10393f45b6
6 changed files with 98 additions and 46 deletions

View File

@ -108,7 +108,7 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
this._context._state.clearInterruptions(); this._context._state.clearInterruptions();
} }
start(): Promise<TResult> { start(seed?: number): Promise<TResult> {
if (this._isDisposed) { if (this._isDisposed) {
throw new Error('GameHost is disposed'); throw new Error('GameHost is disposed');
} }
@ -118,6 +118,8 @@ export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
const initialState = this._createInitialState(); const initialState = this._createInitialState();
this._context._state.value = initialState as any; 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); const promise = this._start(this._context);
this._status.value = 'running'; this._status.value = 'running';

View File

@ -8,9 +8,11 @@ import {
createCommandRunnerContext, parseCommandSchema, createCommandRunnerContext, parseCommandSchema,
} from "@/utils/command"; } from "@/utils/command";
import {PromptValidator} from "@/utils/command/command-runner"; import {PromptValidator} from "@/utils/command/command-runner";
import {Mulberry32RNG, ReadonlyRNG, RNG} from "@/utils/rng";
export interface IGameContext<TState extends Record<string, unknown> = {} > { export interface IGameContext<TState extends Record<string, unknown> = {} > {
get value(): TState; get value(): TState;
get rng(): ReadonlyRNG;
produce(fn: (draft: TState) => void): void; produce(fn: (draft: TState) => void): void;
produceAsync(fn: (draft: TState) => void): Promise<void>; produceAsync(fn: (draft: TState) => void): Promise<void>;
run<T>(input: string): Promise<CommandResult<T>>; run<T>(input: string): Promise<CommandResult<T>>;
@ -20,6 +22,7 @@ export interface IGameContext<TState extends Record<string, unknown> = {} > {
// test only // test only
_state: MutableSignal<TState>; _state: MutableSignal<TState>;
_commands: CommandRunnerContextExport<IGameContext<TState>>; _commands: CommandRunnerContextExport<IGameContext<TState>>;
_rng: RNG;
} }
export function createGameContext<TState extends Record<string, unknown> = {} >( export function createGameContext<TState extends Record<string, unknown> = {} >(
@ -34,6 +37,9 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
get value(): TState { get value(): TState {
return state.value; return state.value;
}, },
get rng() {
return this._rng;
},
produce(fn) { produce(fn) {
return state.produce(fn); return state.produce(fn);
}, },
@ -52,6 +58,7 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
_state: state, _state: state,
_commands: commands, _commands: commands,
_rng: new Mulberry32RNG(),
}; };
context._commands = commands = createCommandRunnerContext(commandRegistry, context); context._commands = commands = createCommandRunnerContext(commandRegistry, context);

View File

@ -2,7 +2,8 @@ import {
OnitamaGame, OnitamaGame,
OnitamaState, OnitamaState,
PlayerType, PlayerType,
prompts prompts,
initializeCards
} from "./types"; } from "./types";
import {createGameCommandRegistry} from "@/core/game"; import {createGameCommandRegistry} from "@/core/game";
import {moveToRegion} from "@/core/region"; import {moveToRegion} from "@/core/region";
@ -342,14 +343,17 @@ const turn = registry.register({
* *
*/ */
export async function start(game: OnitamaGame) { export async function start(game: OnitamaGame) {
// Initialize cards with RNG at game start
initializeCards(game);
while (true) { while (true) {
const currentPlayer = game.value.currentPlayer; const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer); const turnOutput = await turn(game, currentPlayer);
if (turnOutput.winner) { if (turnOutput.winner) {
break; break;
} }
} }
return game.value; return game.value;
} }

View File

@ -125,27 +125,13 @@ export function createInitialState() {
const regions = createRegions(); const regions = createRegions();
const pawns = createPawns(); const pawns = createPawns();
const cards = createCards(); const cards = createCards();
// Distribute cards: 2 to each player, 1 spare // Cards will be shuffled and distributed in start()
const cardNames = Object.keys(cards); // Initialize with empty card lists
const shuffled = [...cardNames].sort(() => Math.random() - 0.5); const redCards: string[] = [];
const blackCards: string[] = [];
const redCards = shuffled.slice(0, 2); const spareCard = '';
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);
// Populate board region childIds // Populate board region childIds
for(const pawn of Object.values(pawns)){ for(const pawn of Object.values(pawns)){
if(pawn.regionId === 'board'){ if(pawn.regionId === 'board'){
@ -153,15 +139,12 @@ export function createInitialState() {
regions.board.partMap[pawn.position.join(',')] = pawn.id; regions.board.partMap[pawn.position.join(',')] = pawn.id;
} }
} }
// Determine starting player from spare card
const startingPlayer = cards[spareCard].startingPlayer;
return { return {
regions, regions,
pawns, pawns,
cards, cards,
currentPlayer: startingPlayer, currentPlayer: 'red' as PlayerType,
winner: null as PlayerType | null, winner: null as PlayerType | null,
spareCard, spareCard,
redCards, 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 OnitamaState = ReturnType<typeof createInitialState>;
export type OnitamaGame = IGameContext<OnitamaState>; export type OnitamaGame = IGameContext<OnitamaState>;

View File

@ -1,9 +1,11 @@
export interface RNG { export interface RNG extends ReadonlyRNG{
/** 设置随机数种子 */ /** 设置随机数种子 */
setSeed(seed: number): void; setSeed(seed: number): void;
getSeed(): number; getSeed(): number;
}
export interface ReadonlyRNG {
/** 获取一个 [0,1) 随机数 */ /** 获取一个 [0,1) 随机数 */
next(max?: number): number; next(max?: number): number;

View File

@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { import {
registry, registry,
createInitialState, createInitialState,
initializeCards,
OnitamaState, OnitamaState,
createPawns, createPawns,
createCards, createCards,
@ -13,6 +14,7 @@ import type { PromptEvent } from '@/utils/command';
function createTestContext() { function createTestContext() {
const ctx = createGameContext(registry, createInitialState()); const ctx = createGameContext(registry, createInitialState());
initializeCards(ctx);
return { registry, ctx }; return { registry, ctx };
} }
@ -71,18 +73,19 @@ function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promis
describe('Onitama Game', () => { describe('Onitama Game', () => {
describe('Setup', () => { describe('Setup', () => {
it('should create initial state correctly', () => { it('should create initial state correctly', () => {
const state = createInitialState(); const ctx = createGameContext(registry, createInitialState());
const stateBefore = ctx.value;
expect(state.currentPlayer).toBeDefined(); expect(stateBefore.currentPlayer).toBeDefined();
expect(state.winner).toBeNull(); expect(stateBefore.winner).toBeNull();
expect(state.regions.board).toBeDefined(); expect(stateBefore.regions.board).toBeDefined();
expect(state.regions.red).toBeDefined(); expect(stateBefore.regions.red).toBeDefined();
expect(state.regions.black).toBeDefined(); expect(stateBefore.regions.black).toBeDefined();
expect(state.regions.spare).toBeDefined(); expect(stateBefore.regions.spare).toBeDefined();
// Should have 10 pawns (5 per player) // Should have 10 pawns (5 per player)
const redPawns = Object.values(state.pawns).filter(p => p.owner === 'red'); const redPawns = Object.values(stateBefore.pawns).filter(p => p.owner === 'red');
const blackPawns = Object.values(state.pawns).filter(p => p.owner === 'black'); const blackPawns = Object.values(stateBefore.pawns).filter(p => p.owner === 'black');
expect(redPawns.length).toBe(5); expect(redPawns.length).toBe(5);
expect(blackPawns.length).toBe(5); expect(blackPawns.length).toBe(5);
@ -96,10 +99,19 @@ describe('Onitama Game', () => {
expect(redMaster?.position[0]).toBe(2); expect(redMaster?.position[0]).toBe(2);
expect(redMaster?.position[1]).toBe(0); 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 // Cards should be distributed: 2 per player + 1 spare
expect(state.redCards.length).toBe(2); expect(stateAfter.redCards.length).toBe(2);
expect(state.blackCards.length).toBe(2); expect(stateAfter.blackCards.length).toBe(2);
expect(state.spareCard).toBeDefined(); expect(stateAfter.spareCard).toBeDefined();
}); });
it('should create pawns in correct positions', () => { it('should create pawns in correct positions', () => {