Compare commits

...

2 Commits

Author SHA1 Message Date
hypercross ae98e0735a chore: add GameModule type 2026-04-04 11:06:41 +08:00
hypercross be4ff7ae08 feat: add currentPlayer to prompt 2026-04-04 11:01:25 +08:00
8 changed files with 94 additions and 23 deletions

View File

@ -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';
@ -9,15 +9,22 @@ export interface GameHostOptions {
autoStart?: boolean;
}
export interface GameModule<TState extends Record<string, unknown>> {
registry: CommandRegistry<MutableSignal<TState>>;
createInitialState: () => TState;
}
export class GameHost<TState extends Record<string, unknown>> {
readonly state: ReadonlySignal<TState>;
readonly commands: ReturnType<typeof createGameContext<TState>>['commands'];
readonly status: ReadonlySignal<GameHostStatus>;
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
readonly activePromptPlayer: ReadonlySignal<string | null>;
private _state: MutableSignal<TState>;
private _status: Signal<GameHostStatus>;
private _activePromptSchema: Signal<CommandSchema | null>;
private _activePromptPlayer: Signal<string | null>;
private _createInitialState: () => TState;
private _setupCommand: string;
private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>;
@ -45,6 +52,9 @@ export class GameHost<TState extends Record<string, unknown>> {
this._activePromptSchema = new Signal<CommandSchema | null>(null);
this.activePromptSchema = this._activePromptSchema;
this._activePromptPlayer = new Signal<string | null>(null);
this.activePromptPlayer = this._activePromptPlayer;
this.state = this._state;
this._setupPromptTracking();
@ -55,20 +65,23 @@ export class GameHost<TState extends Record<string, unknown>> {
}
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 {
@ -134,10 +147,7 @@ export class GameHost<TState extends Record<string, unknown>> {
}
export function createGameHost<TState extends Record<string, unknown>>(
module: {
registry: CommandRegistry<MutableSignal<TState>>;
createInitialState: () => TState;
},
module: GameModule<TState>,
setupCommand: string,
options?: GameHostOptions
): GameHost<TState> {

View File

@ -10,6 +10,7 @@ import {
parseCommandSchema,
registerCommand
} from "@/utils/command";
import type { GameModule } from './game-host';
export interface IGameContext<TState extends Record<string, unknown> = {} > {
state: MutableSignal<TState>;
@ -69,4 +70,10 @@ export function createGameCommand<TState extends Record<string, unknown> = {} ,
}
export { GameHost, createGameHost } from './game-host';
export type { GameHostStatus, GameHostOptions } from './game-host';
export type { GameHostStatus, GameHostOptions, GameModule } from './game-host';
export function createGameModule<TState extends Record<string, unknown>>(
module: GameModule<TState>
): GameModule<TState> {
return module;
}

View File

@ -7,8 +7,8 @@
export type { IGameContext } from './core/game';
export { createGameContext, createGameCommandRegistry } from './core/game';
export type { GameHost, GameHostStatus, GameHostOptions } from './core/game-host';
export { createGameHost } from './core/game-host';
export type { GameHost, GameHostStatus, GameHostOptions, GameModule } from './core/game';
export { createGameHost, createGameModule } from './core/game';
export type { Part } from './core/part';
export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion } from './core/part';

View File

@ -113,7 +113,8 @@ registration.add('turn <player>', 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 <player>', 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)!;

View File

@ -73,7 +73,8 @@ registration.add('turn <player> <turn:number>', 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];

View File

@ -104,7 +104,8 @@ export function createCommandRunnerContext<TContext>(
const prompt = (
schema: CommandSchema | string,
validator?: (command: Command) => string | null
validator?: (command: Command) => string | null,
currentPlayer?: string | null
): Promise<Command> => {
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
return new Promise((resolve, reject) => {
@ -120,10 +121,12 @@ export function createCommandRunnerContext<TContext>(
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);
}

View File

@ -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<TContext> = {
context: TContext;
run: <T=unknown>(input: string) => Promise<CommandResult<T>>;
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;
off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
};

View File

@ -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();
});
});
});