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();
|
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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue