diff --git a/src/core/game-host.ts b/src/core/game-host.ts index bf371de..92e9138 100644 --- a/src/core/game-host.ts +++ b/src/core/game-host.ts @@ -1,5 +1,5 @@ import { ReadonlySignal, signal, Signal } from '@preact/signals-core'; -import type { CommandSchema, CommandRegistry, CommandResult } from '@/utils/command'; +import type { CommandSchema, CommandRegistry, CommandResult, PromptEvent } from '@/utils/command'; import type { MutableSignal } from '@/utils/mutable-signal'; import { createGameContext } from './game'; @@ -14,10 +14,12 @@ export class GameHost> { readonly commands: ReturnType>['commands']; readonly status: ReadonlySignal; readonly activePromptSchema: ReadonlySignal; + readonly activePromptPlayer: ReadonlySignal; private _state: MutableSignal; private _status: Signal; private _activePromptSchema: Signal; + private _activePromptPlayer: Signal; private _createInitialState: () => TState; private _setupCommand: string; private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>; @@ -45,6 +47,9 @@ export class GameHost> { this._activePromptSchema = new Signal(null); this.activePromptSchema = this._activePromptSchema; + this._activePromptPlayer = new Signal(null); + this.activePromptPlayer = this._activePromptPlayer; + this.state = this._state; this._setupPromptTracking(); @@ -55,20 +60,23 @@ export class GameHost> { } private _setupPromptTracking() { - const updateSchema = () => { - const activePrompt = (this.commands as any)._activePrompt as { schema?: CommandSchema } | null; - this._activePromptSchema.value = activePrompt?.schema ?? null; - }; + let currentPromptEvent: PromptEvent | null = null; - this.commands.on('prompt', () => { - updateSchema(); + this.commands.on('prompt', (e) => { + currentPromptEvent = e as PromptEvent; + this._activePromptSchema.value = currentPromptEvent.schema; + this._activePromptPlayer.value = currentPromptEvent.currentPlayer; }); this.commands.on('promptEnd', () => { - updateSchema(); + currentPromptEvent = null; + this._activePromptSchema.value = null; + this._activePromptPlayer.value = null; }); - updateSchema(); + // Initial state + this._activePromptSchema.value = null; + this._activePromptPlayer.value = null; } onInput(input: string): string | null { diff --git a/src/samples/boop/index.ts b/src/samples/boop/index.ts index 94d7dc9..b967b15 100644 --- a/src/samples/boop/index.ts +++ b/src/samples/boop/index.ts @@ -113,7 +113,8 @@ registration.add('turn ', async function(cmd) { return `No ${pieceType}s left in ${player}'s supply.`; } return null; - } + }, + this.context.value.currentPlayer ); const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?]; const pieceType = type === 'cat' ? 'cat' : 'kitten'; @@ -141,7 +142,8 @@ registration.add('turn ', async function(cmd) { const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey); if (!part) return `No kitten at (${row}, ${col}).`; return null; - } + }, + this.context.value.currentPlayer ); const [row, col] = graduateCmd.params as [number, number]; const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!; diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index d77c513..8231889 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -73,7 +73,8 @@ registration.add('turn ', async function(cmd) { return `Cell (${row}, ${col}) is already occupied.`; } return null; - } + }, + this.context.value.currentPlayer ); const [player, row, col] = playCmd.params as [PlayerType, number, number]; diff --git a/src/utils/command/command-registry.ts b/src/utils/command/command-registry.ts index 2b3d62a..d93817d 100644 --- a/src/utils/command/command-registry.ts +++ b/src/utils/command/command-registry.ts @@ -104,7 +104,8 @@ export function createCommandRunnerContext( const prompt = ( schema: CommandSchema | string, - validator?: (command: Command) => string | null + validator?: (command: Command) => string | null, + currentPlayer?: string | null ): Promise => { const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema; return new Promise((resolve, reject) => { @@ -120,10 +121,12 @@ export function createCommandRunnerContext( return null; }; const cancel = (reason?: string) => { + activePrompt = null; + emitPromptEnd(); reject(new Error(reason ?? 'Cancelled')); }; - activePrompt = { schema: resolvedSchema, tryCommit, cancel }; - const event: PromptEvent = { schema: resolvedSchema, tryCommit, cancel }; + activePrompt = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel }; + const event: PromptEvent = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel }; for (const listener of promptListeners) { listener(event); } diff --git a/src/utils/command/command-runner.ts b/src/utils/command/command-runner.ts index 77c1d44..82b514d 100644 --- a/src/utils/command/command-runner.ts +++ b/src/utils/command/command-runner.ts @@ -4,6 +4,8 @@ import { applyCommandSchema } from './command-validate'; export type PromptEvent = { schema: CommandSchema; + /** 当前等待输入的玩家 */ + currentPlayer: string | null; /** * 尝试提交命令 * @param commandOrInput Command 对象或命令字符串 @@ -33,7 +35,7 @@ export type CommandRunnerContext = { context: TContext; run: (input: string) => Promise>; runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>; - prompt: (schema: CommandSchema | string, validator?: (command: Command) => string | null) => Promise; + prompt: (schema: CommandSchema | string, validator?: (command: Command) => string | null, currentPlayer?: string | null) => Promise; on: (event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; off: (event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; }; diff --git a/tests/core/game-host.test.ts b/tests/core/game-host.test.ts index 4d250ec..ff4fce7 100644 --- a/tests/core/game-host.test.ts +++ b/tests/core/game-host.test.ts @@ -410,4 +410,50 @@ describe('GameHost', () => { expect(host.status.value).toBe('disposed'); }); }); + + describe('currentPlayer in prompt', () => { + it('should have currentPlayer in PromptEvent', async () => { + const { host } = createTestHost(); + + const promptPromise = waitForPromptEvent(host); + const runPromise = host.commands.run('setup'); + + const promptEvent = await promptPromise; + expect(promptEvent.currentPlayer).toBe('X'); + expect(host.activePromptPlayer.value).toBe('X'); + + promptEvent.cancel('test cleanup'); + await runPromise; + }); + + it('should update activePromptPlayer reactively', async () => { + const { host } = createTestHost(); + + // Initially null + expect(host.activePromptPlayer.value).toBeNull(); + + // First prompt - X's turn + let promptPromise = waitForPromptEvent(host); + let runPromise = host.commands.run('setup'); + let promptEvent = await promptPromise; + expect(promptEvent.currentPlayer).toBe('X'); + expect(host.activePromptPlayer.value).toBe('X'); + + // Make a move + promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); + + // Second prompt - O's turn + promptPromise = waitForPromptEvent(host); + promptEvent = await promptPromise; + expect(promptEvent.currentPlayer).toBe('O'); + expect(host.activePromptPlayer.value).toBe('O'); + + // Cancel + promptEvent.cancel('test cleanup'); + await runPromise; + + // After prompt ends, player should be null + expect(host.activePromptPlayer.value).toBeNull(); + }); + }); });