feat: add currentPlayer to prompt
This commit is contained in:
parent
d1264f3b9e
commit
be4ff7ae08
|
|
@ -1,5 +1,5 @@
|
||||||
import { ReadonlySignal, signal, Signal } from '@preact/signals-core';
|
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 type { MutableSignal } from '@/utils/mutable-signal';
|
||||||
import { createGameContext } from './game';
|
import { createGameContext } from './game';
|
||||||
|
|
||||||
|
|
@ -14,10 +14,12 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
readonly commands: ReturnType<typeof createGameContext<TState>>['commands'];
|
readonly commands: ReturnType<typeof createGameContext<TState>>['commands'];
|
||||||
readonly status: ReadonlySignal<GameHostStatus>;
|
readonly status: ReadonlySignal<GameHostStatus>;
|
||||||
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
|
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
|
||||||
|
readonly activePromptPlayer: ReadonlySignal<string | null>;
|
||||||
|
|
||||||
private _state: MutableSignal<TState>;
|
private _state: MutableSignal<TState>;
|
||||||
private _status: Signal<GameHostStatus>;
|
private _status: Signal<GameHostStatus>;
|
||||||
private _activePromptSchema: Signal<CommandSchema | null>;
|
private _activePromptSchema: Signal<CommandSchema | null>;
|
||||||
|
private _activePromptPlayer: Signal<string | null>;
|
||||||
private _createInitialState: () => TState;
|
private _createInitialState: () => TState;
|
||||||
private _setupCommand: string;
|
private _setupCommand: string;
|
||||||
private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>;
|
private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>;
|
||||||
|
|
@ -45,6 +47,9 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
this._activePromptSchema = new Signal<CommandSchema | null>(null);
|
this._activePromptSchema = new Signal<CommandSchema | null>(null);
|
||||||
this.activePromptSchema = this._activePromptSchema;
|
this.activePromptSchema = this._activePromptSchema;
|
||||||
|
|
||||||
|
this._activePromptPlayer = new Signal<string | null>(null);
|
||||||
|
this.activePromptPlayer = this._activePromptPlayer;
|
||||||
|
|
||||||
this.state = this._state;
|
this.state = this._state;
|
||||||
|
|
||||||
this._setupPromptTracking();
|
this._setupPromptTracking();
|
||||||
|
|
@ -55,20 +60,23 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setupPromptTracking() {
|
private _setupPromptTracking() {
|
||||||
const updateSchema = () => {
|
let currentPromptEvent: PromptEvent | null = null;
|
||||||
const activePrompt = (this.commands as any)._activePrompt as { schema?: CommandSchema } | null;
|
|
||||||
this._activePromptSchema.value = activePrompt?.schema ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.commands.on('prompt', () => {
|
this.commands.on('prompt', (e) => {
|
||||||
updateSchema();
|
currentPromptEvent = e as PromptEvent;
|
||||||
|
this._activePromptSchema.value = currentPromptEvent.schema;
|
||||||
|
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.commands.on('promptEnd', () => {
|
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 {
|
onInput(input: string): string | null {
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,8 @@ registration.add('turn <player>', async function(cmd) {
|
||||||
return `No ${pieceType}s left in ${player}'s supply.`;
|
return `No ${pieceType}s left in ${player}'s supply.`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
},
|
||||||
|
this.context.value.currentPlayer
|
||||||
);
|
);
|
||||||
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
|
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
|
||||||
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
const pieceType = type === 'cat' ? 'cat' : 'kitten';
|
||||||
|
|
@ -141,7 +142,8 @@ registration.add('turn <player>', async function(cmd) {
|
||||||
const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey);
|
const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey);
|
||||||
if (!part) return `No kitten at (${row}, ${col}).`;
|
if (!part) return `No kitten at (${row}, ${col}).`;
|
||||||
return null;
|
return null;
|
||||||
}
|
},
|
||||||
|
this.context.value.currentPlayer
|
||||||
);
|
);
|
||||||
const [row, col] = graduateCmd.params as [number, number];
|
const [row, col] = graduateCmd.params as [number, number];
|
||||||
const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!;
|
const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!;
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,8 @@ registration.add('turn <player> <turn:number>', async function(cmd) {
|
||||||
return `Cell (${row}, ${col}) is already occupied.`;
|
return `Cell (${row}, ${col}) is already occupied.`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
},
|
||||||
|
this.context.value.currentPlayer
|
||||||
);
|
);
|
||||||
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,8 @@ export function createCommandRunnerContext<TContext>(
|
||||||
|
|
||||||
const prompt = (
|
const prompt = (
|
||||||
schema: CommandSchema | string,
|
schema: CommandSchema | string,
|
||||||
validator?: (command: Command) => string | null
|
validator?: (command: Command) => string | null,
|
||||||
|
currentPlayer?: string | null
|
||||||
): Promise<Command> => {
|
): Promise<Command> => {
|
||||||
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -120,10 +121,12 @@ export function createCommandRunnerContext<TContext>(
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
const cancel = (reason?: string) => {
|
const cancel = (reason?: string) => {
|
||||||
|
activePrompt = null;
|
||||||
|
emitPromptEnd();
|
||||||
reject(new Error(reason ?? 'Cancelled'));
|
reject(new Error(reason ?? 'Cancelled'));
|
||||||
};
|
};
|
||||||
activePrompt = { schema: resolvedSchema, tryCommit, cancel };
|
activePrompt = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel };
|
||||||
const event: PromptEvent = { schema: resolvedSchema, tryCommit, cancel };
|
const event: PromptEvent = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel };
|
||||||
for (const listener of promptListeners) {
|
for (const listener of promptListeners) {
|
||||||
listener(event);
|
listener(event);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { applyCommandSchema } from './command-validate';
|
||||||
|
|
||||||
export type PromptEvent = {
|
export type PromptEvent = {
|
||||||
schema: CommandSchema;
|
schema: CommandSchema;
|
||||||
|
/** 当前等待输入的玩家 */
|
||||||
|
currentPlayer: string | null;
|
||||||
/**
|
/**
|
||||||
* 尝试提交命令
|
* 尝试提交命令
|
||||||
* @param commandOrInput Command 对象或命令字符串
|
* @param commandOrInput Command 对象或命令字符串
|
||||||
|
|
@ -33,7 +35,7 @@ export type CommandRunnerContext<TContext> = {
|
||||||
context: TContext;
|
context: TContext;
|
||||||
run: <T=unknown>(input: string) => Promise<CommandResult<T>>;
|
run: <T=unknown>(input: string) => Promise<CommandResult<T>>;
|
||||||
runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
|
||||||
prompt: (schema: CommandSchema | string, validator?: (command: Command) => string | null) => Promise<Command>;
|
prompt: (schema: CommandSchema | string, validator?: (command: Command) => string | null, currentPlayer?: string | null) => Promise<Command>;
|
||||||
on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
||||||
off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -410,4 +410,50 @@ describe('GameHost', () => {
|
||||||
expect(host.status.value).toBe('disposed');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue