Compare commits
2 Commits
d1264f3b9e
...
ae98e0735a
| Author | SHA1 | Date |
|---|---|---|
|
|
ae98e0735a | |
|
|
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';
|
||||||
|
|
||||||
|
|
@ -9,15 +9,22 @@ export interface GameHostOptions {
|
||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameModule<TState extends Record<string, unknown>> {
|
||||||
|
registry: CommandRegistry<MutableSignal<TState>>;
|
||||||
|
createInitialState: () => TState;
|
||||||
|
}
|
||||||
|
|
||||||
export class GameHost<TState extends Record<string, unknown>> {
|
export class GameHost<TState extends Record<string, unknown>> {
|
||||||
readonly state: ReadonlySignal<TState>;
|
readonly state: ReadonlySignal<TState>;
|
||||||
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 +52,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 +65,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 {
|
||||||
|
|
@ -134,10 +147,7 @@ export class GameHost<TState extends Record<string, unknown>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGameHost<TState extends Record<string, unknown>>(
|
export function createGameHost<TState extends Record<string, unknown>>(
|
||||||
module: {
|
module: GameModule<TState>,
|
||||||
registry: CommandRegistry<MutableSignal<TState>>;
|
|
||||||
createInitialState: () => TState;
|
|
||||||
},
|
|
||||||
setupCommand: string,
|
setupCommand: string,
|
||||||
options?: GameHostOptions
|
options?: GameHostOptions
|
||||||
): GameHost<TState> {
|
): GameHost<TState> {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
parseCommandSchema,
|
parseCommandSchema,
|
||||||
registerCommand
|
registerCommand
|
||||||
} from "@/utils/command";
|
} from "@/utils/command";
|
||||||
|
import type { GameModule } from './game-host';
|
||||||
|
|
||||||
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
||||||
state: MutableSignal<TState>;
|
state: MutableSignal<TState>;
|
||||||
|
|
@ -69,4 +70,10 @@ export function createGameCommand<TState extends Record<string, unknown> = {} ,
|
||||||
}
|
}
|
||||||
|
|
||||||
export { GameHost, createGameHost } from './game-host';
|
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;
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
export type { IGameContext } from './core/game';
|
export type { IGameContext } from './core/game';
|
||||||
export { createGameContext, createGameCommandRegistry } from './core/game';
|
export { createGameContext, createGameCommandRegistry } from './core/game';
|
||||||
|
|
||||||
export type { GameHost, GameHostStatus, GameHostOptions } from './core/game-host';
|
export type { GameHost, GameHostStatus, GameHostOptions, GameModule } from './core/game';
|
||||||
export { createGameHost } from './core/game-host';
|
export { createGameHost, createGameModule } from './core/game';
|
||||||
|
|
||||||
export type { Part } from './core/part';
|
export type { Part } from './core/part';
|
||||||
export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion } from './core/part';
|
export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion } from './core/part';
|
||||||
|
|
|
||||||
|
|
@ -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