refactor: add rng & seeding
This commit is contained in:
parent
6b736ab083
commit
10393f45b6
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof createTestContext>['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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue