Compare commits
2 Commits
50c146964d
...
6c8d6e0790
| Author | SHA1 | Date |
|---|---|---|
|
|
6c8d6e0790 | |
|
|
697d23e932 |
|
|
@ -0,0 +1,150 @@
|
|||
import { ReadonlySignal, signal, Signal } from '@preact/signals-core';
|
||||
import type { CommandSchema, CommandRegistry, CommandResult } from '@/utils/command';
|
||||
import type { MutableSignal } from '@/utils/mutable-signal';
|
||||
import { createGameContext } from './game';
|
||||
|
||||
export type GameHostStatus = 'created' | 'running' | 'disposed';
|
||||
|
||||
export interface GameHostOptions {
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
private _state: MutableSignal<TState>;
|
||||
private _status: Signal<GameHostStatus>;
|
||||
private _activePromptSchema: Signal<CommandSchema | null>;
|
||||
private _createInitialState: () => TState;
|
||||
private _setupCommand: string;
|
||||
private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>;
|
||||
private _isDisposed = false;
|
||||
|
||||
constructor(
|
||||
registry: CommandRegistry<MutableSignal<TState>>,
|
||||
createInitialState: () => TState,
|
||||
setupCommand: string,
|
||||
options?: GameHostOptions
|
||||
) {
|
||||
this._createInitialState = createInitialState;
|
||||
this._setupCommand = setupCommand;
|
||||
this._eventListeners = new Map();
|
||||
|
||||
const initialState = createInitialState();
|
||||
const context = createGameContext(registry, initialState);
|
||||
|
||||
this._state = context.state;
|
||||
this.commands = context.commands;
|
||||
|
||||
this._status = new Signal<GameHostStatus>('created');
|
||||
this.status = this._status;
|
||||
|
||||
this._activePromptSchema = new Signal<CommandSchema | null>(null);
|
||||
this.activePromptSchema = this._activePromptSchema;
|
||||
|
||||
this.state = this._state;
|
||||
|
||||
this._setupPromptTracking();
|
||||
|
||||
if (options?.autoStart !== false) {
|
||||
this._status.value = 'running';
|
||||
}
|
||||
}
|
||||
|
||||
private _setupPromptTracking() {
|
||||
const updateSchema = () => {
|
||||
const activePrompt = (this.commands as any)._activePrompt as { schema?: CommandSchema } | null;
|
||||
this._activePromptSchema.value = activePrompt?.schema ?? null;
|
||||
};
|
||||
|
||||
this.commands.on('prompt', () => {
|
||||
updateSchema();
|
||||
});
|
||||
|
||||
this.commands.on('promptEnd', () => {
|
||||
updateSchema();
|
||||
});
|
||||
|
||||
updateSchema();
|
||||
}
|
||||
|
||||
onInput(input: string): string | null {
|
||||
if (this._isDisposed) {
|
||||
return 'GameHost is disposed';
|
||||
}
|
||||
return this.commands._tryCommit(input);
|
||||
}
|
||||
|
||||
async setup(setupCommand: string): Promise<void> {
|
||||
if (this._isDisposed) {
|
||||
throw new Error('GameHost is disposed');
|
||||
}
|
||||
|
||||
this.commands._cancel();
|
||||
|
||||
const initialState = this._createInitialState();
|
||||
this._state.value = initialState as any;
|
||||
|
||||
// Start the setup command but don't wait for it to complete
|
||||
// The command will run in the background and prompt for input
|
||||
this.commands.run(setupCommand).catch(() => {
|
||||
// Command may be cancelled or fail, which is expected
|
||||
});
|
||||
|
||||
this._status.value = 'running';
|
||||
this._emitEvent('setup');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isDisposed = true;
|
||||
this.commands._cancel();
|
||||
this._status.value = 'disposed';
|
||||
|
||||
// Emit dispose event BEFORE clearing listeners
|
||||
this._emitEvent('dispose');
|
||||
this._eventListeners.clear();
|
||||
}
|
||||
|
||||
on(event: 'setup' | 'dispose', listener: () => void): () => void {
|
||||
if (!this._eventListeners.has(event)) {
|
||||
this._eventListeners.set(event, new Set());
|
||||
}
|
||||
this._eventListeners.get(event)!.add(listener);
|
||||
|
||||
return () => {
|
||||
this._eventListeners.get(event)?.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private _emitEvent(event: 'setup' | 'dispose') {
|
||||
const listeners = this._eventListeners.get(event);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createGameHost<TState extends Record<string, unknown>>(
|
||||
module: {
|
||||
registry: CommandRegistry<MutableSignal<TState>>;
|
||||
createInitialState: () => TState;
|
||||
},
|
||||
setupCommand: string,
|
||||
options?: GameHostOptions
|
||||
): GameHost<TState> {
|
||||
return new GameHost(
|
||||
module.registry,
|
||||
module.createInitialState,
|
||||
setupCommand,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
|
@ -67,3 +67,6 @@ export function createGameCommand<TState extends Record<string, unknown> = {} ,
|
|||
run,
|
||||
});
|
||||
}
|
||||
|
||||
export { GameHost, createGameHost } from './game-host';
|
||||
export type { GameHostStatus, GameHostOptions } from './game-host';
|
||||
|
|
@ -7,6 +7,9 @@
|
|||
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 { Part } from './core/part';
|
||||
export { flip, flipTo, roll, findPartById, isCellOccupied, getPartAtPosition, isCellOccupiedByRegion, getPartAtPositionInRegion } from './core/part';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Command, CommandSchema } from './types';
|
||||
import type {CommandResult, CommandRunner, CommandRunnerContext, PromptEvent} from './command-runner';
|
||||
import type {CommandResult, CommandRunner, CommandRunnerContext, CommandRunnerEvents, PromptEvent} from './command-runner';
|
||||
import { parseCommand } from './command-parse';
|
||||
import { applyCommandSchema } from './command-validate';
|
||||
import { parseCommandSchema } from './schema-parse';
|
||||
|
|
@ -39,7 +39,8 @@ export function getCommand<TContext>(
|
|||
return registry.get(name);
|
||||
}
|
||||
|
||||
type Listener = (e: PromptEvent) => void;
|
||||
type PromptListener = (e: PromptEvent) => void;
|
||||
type PromptEndListener = () => void;
|
||||
|
||||
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
|
||||
registry: CommandRegistry<TContext>;
|
||||
|
|
@ -54,14 +55,29 @@ export function createCommandRunnerContext<TContext>(
|
|||
registry: CommandRegistry<TContext>,
|
||||
context: TContext
|
||||
): CommandRunnerContextExport<TContext> {
|
||||
const listeners = new Set<Listener>();
|
||||
const promptListeners = new Set<PromptListener>();
|
||||
const promptEndListeners = new Set<PromptEndListener>();
|
||||
|
||||
const on = (_event: 'prompt', listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
const emitPromptEnd = () => {
|
||||
for (const listener of promptEndListeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const off = (_event: 'prompt', listener: Listener) => {
|
||||
listeners.delete(listener);
|
||||
const on = <T extends keyof CommandRunnerEvents>(_event: T, listener: (e: CommandRunnerEvents[T]) => void) => {
|
||||
if (_event === 'prompt') {
|
||||
promptListeners.add(listener as PromptListener);
|
||||
} else {
|
||||
promptEndListeners.add(listener as PromptEndListener);
|
||||
}
|
||||
};
|
||||
|
||||
const off = <T extends keyof CommandRunnerEvents>(_event: T, listener: (e: CommandRunnerEvents[T]) => void) => {
|
||||
if (_event === 'prompt') {
|
||||
promptListeners.delete(listener as PromptListener);
|
||||
} else {
|
||||
promptEndListeners.delete(listener as PromptEndListener);
|
||||
}
|
||||
};
|
||||
|
||||
let activePrompt: PromptEvent | null = null;
|
||||
|
|
@ -71,6 +87,7 @@ export function createCommandRunnerContext<TContext>(
|
|||
const result = activePrompt.tryCommit(commandOrInput);
|
||||
if (result === null) {
|
||||
activePrompt = null;
|
||||
emitPromptEnd();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -81,6 +98,7 @@ export function createCommandRunnerContext<TContext>(
|
|||
if (activePrompt) {
|
||||
activePrompt.cancel(reason);
|
||||
activePrompt = null;
|
||||
emitPromptEnd();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -106,7 +124,7 @@ export function createCommandRunnerContext<TContext>(
|
|||
};
|
||||
activePrompt = { schema: resolvedSchema, tryCommit, cancel };
|
||||
const event: PromptEvent = { schema: resolvedSchema, tryCommit, cancel };
|
||||
for (const listener of listeners) {
|
||||
for (const listener of promptListeners) {
|
||||
listener(event);
|
||||
}
|
||||
});
|
||||
|
|
@ -136,7 +154,7 @@ export function createCommandRunnerContext<TContext>(
|
|||
get(){
|
||||
if (!promptQueue) {
|
||||
promptQueue = new AsyncQueue();
|
||||
listeners.add(async (event) => {
|
||||
promptListeners.add(async (event) => {
|
||||
promptQueue.push(event);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type PromptEvent = {
|
|||
|
||||
export type CommandRunnerEvents = {
|
||||
prompt: PromptEvent;
|
||||
/** 当 prompt 结束(tryCommit 成功或 cancel)时触发 */
|
||||
promptEnd: void;
|
||||
};
|
||||
|
||||
export type CommandResult<T=unknown> = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
registry,
|
||||
createInitialState,
|
||||
TicTacToeState,
|
||||
WinnerType,
|
||||
PlayerType
|
||||
} from '@/samples/tic-tac-toe';
|
||||
import { createGameHost, GameHost } from '@/core/game-host';
|
||||
import type { PromptEvent } from '@/utils/command';
|
||||
import { MutableSignal } from '@/utils/mutable-signal';
|
||||
|
||||
function createTestHost() {
|
||||
const host = createGameHost(
|
||||
{ registry, createInitialState },
|
||||
'setup'
|
||||
);
|
||||
return { host };
|
||||
}
|
||||
|
||||
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
|
||||
return new Promise(resolve => {
|
||||
host.commands.on('prompt', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
describe('GameHost', () => {
|
||||
describe('creation', () => {
|
||||
it('should create host with initial state', () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
expect(host.state.value.currentPlayer).toBe('X');
|
||||
expect(host.state.value.winner).toBeNull();
|
||||
expect(host.state.value.turn).toBe(0);
|
||||
expect(Object.keys(host.state.value.parts).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should have status "running" by default', () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
expect(host.status.value).toBe('running');
|
||||
});
|
||||
|
||||
it('should have null activePromptSchema initially', () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
expect(host.activePromptSchema.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onInput', () => {
|
||||
it('should return "No active prompt" when no prompt is active', () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
const result = host.onInput('play X 1 1');
|
||||
expect(result).toBe('No active prompt');
|
||||
});
|
||||
|
||||
it('should accept valid input when prompt is active', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
const promptPromise = waitForPromptEvent(host);
|
||||
const runPromise = host.commands.run('setup');
|
||||
|
||||
const promptEvent = await promptPromise;
|
||||
expect(promptEvent.schema.name).toBe('play');
|
||||
expect(host.activePromptSchema.value?.name).toBe('play');
|
||||
|
||||
const error = host.onInput('play X 1 1');
|
||||
expect(error).toBeNull();
|
||||
|
||||
// Cancel to end the game since setup runs until game over
|
||||
const nextPromptPromise = waitForPromptEvent(host);
|
||||
const nextPrompt = await nextPromptPromise;
|
||||
nextPrompt.cancel('test cleanup');
|
||||
|
||||
const result = await runPromise;
|
||||
expect(result.success).toBe(false); // Cancelled
|
||||
});
|
||||
|
||||
it('should reject invalid input', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
const promptPromise = waitForPromptEvent(host);
|
||||
const runPromise = host.commands.run('setup');
|
||||
|
||||
const promptEvent = await promptPromise;
|
||||
|
||||
const error = host.onInput('invalid command');
|
||||
expect(error).not.toBeNull();
|
||||
|
||||
promptEvent.cancel('test cleanup');
|
||||
await runPromise;
|
||||
});
|
||||
|
||||
it('should return error when disposed', () => {
|
||||
const { host } = createTestHost();
|
||||
host.dispose();
|
||||
|
||||
const result = host.onInput('play X 1 1');
|
||||
expect(result).toBe('GameHost is disposed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActivePromptSchema', () => {
|
||||
it('should return schema when prompt is active', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
const promptPromise = waitForPromptEvent(host);
|
||||
const runPromise = host.commands.run('setup');
|
||||
|
||||
const promptEvent = await promptPromise;
|
||||
const schema = host.activePromptSchema.value;
|
||||
|
||||
expect(schema).not.toBeNull();
|
||||
expect(schema?.name).toBe('play');
|
||||
|
||||
promptEvent.cancel('test cleanup');
|
||||
await runPromise;
|
||||
});
|
||||
|
||||
it('should return null when no prompt is active', () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
expect(host.activePromptSchema.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup', () => {
|
||||
it('should reset state and run setup command', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
// First setup - make one move
|
||||
let promptPromise = waitForPromptEvent(host);
|
||||
let runPromise = host.commands.run('setup');
|
||||
let promptEvent = await promptPromise;
|
||||
|
||||
// Make a move
|
||||
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
||||
|
||||
// Wait for next prompt (next turn) and cancel
|
||||
promptPromise = waitForPromptEvent(host);
|
||||
promptEvent = await promptPromise;
|
||||
promptEvent.cancel('test end');
|
||||
|
||||
let result = await runPromise;
|
||||
expect(result.success).toBe(false); // Cancelled
|
||||
expect(Object.keys(host.state.value.parts).length).toBe(1);
|
||||
|
||||
// Setup listener before calling setup
|
||||
const newPromptPromise = waitForPromptEvent(host);
|
||||
|
||||
// Reset - should reset state and start new game
|
||||
await host.setup('setup');
|
||||
|
||||
// State should be back to initial
|
||||
expect(host.state.value.currentPlayer).toBe('X');
|
||||
expect(host.state.value.winner).toBeNull();
|
||||
expect(host.state.value.turn).toBe(0);
|
||||
expect(Object.keys(host.state.value.parts).length).toBe(0);
|
||||
|
||||
// New game should be running and prompting
|
||||
const newPrompt = await newPromptPromise;
|
||||
expect(newPrompt.schema.name).toBe('play');
|
||||
newPrompt.cancel('test end');
|
||||
});
|
||||
|
||||
it('should cancel active prompt during setup', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
const promptPromise = waitForPromptEvent(host);
|
||||
const runPromise = host.commands.run('setup');
|
||||
|
||||
await promptPromise;
|
||||
|
||||
// Setup should cancel the active prompt and reset state
|
||||
await host.setup('setup');
|
||||
|
||||
// The original runPromise should be rejected due to cancellation
|
||||
try {
|
||||
await runPromise;
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
expect(error.message).toContain('Cancelled');
|
||||
}
|
||||
|
||||
// State should be reset
|
||||
expect(host.state.value.currentPlayer).toBe('X');
|
||||
expect(host.state.value.turn).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw error when disposed', async () => {
|
||||
const { host } = createTestHost();
|
||||
host.dispose();
|
||||
|
||||
await expect(host.setup('setup')).rejects.toThrow('GameHost is disposed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('should change status to disposed', () => {
|
||||
const { host } = createTestHost();
|
||||
host.dispose();
|
||||
|
||||
expect(host.status.value).toBe('disposed');
|
||||
});
|
||||
|
||||
it('should cancel active prompt on dispose', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
const promptPromise = waitForPromptEvent(host);
|
||||
const runPromise = host.commands.run('setup');
|
||||
|
||||
await promptPromise;
|
||||
|
||||
host.dispose();
|
||||
|
||||
// The runPromise should be rejected due to cancellation
|
||||
try {
|
||||
await runPromise;
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
expect(error.message).toContain('Cancelled');
|
||||
}
|
||||
});
|
||||
|
||||
it('should be idempotent', () => {
|
||||
const { host } = createTestHost();
|
||||
host.dispose();
|
||||
host.dispose(); // Should not throw
|
||||
|
||||
expect(host.status.value).toBe('disposed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit setup event', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
let setupCount = 0;
|
||||
host.on('setup', () => {
|
||||
setupCount++;
|
||||
});
|
||||
|
||||
// Setup listener before calling setup
|
||||
const promptPromise = waitForPromptEvent(host);
|
||||
|
||||
// Initial setup via reset
|
||||
await host.setup('setup');
|
||||
expect(setupCount).toBe(1);
|
||||
|
||||
// State should be running
|
||||
expect(host.status.value).toBe('running');
|
||||
|
||||
// Cancel the background setup command
|
||||
const prompt = await promptPromise;
|
||||
prompt.cancel('test end');
|
||||
});
|
||||
|
||||
it('should emit dispose event', () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
let disposeCount = 0;
|
||||
host.on('dispose', () => {
|
||||
disposeCount++;
|
||||
});
|
||||
|
||||
host.dispose();
|
||||
expect(disposeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow unsubscribing from events', () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
let setupCount = 0;
|
||||
const unsubscribe = host.on('setup', () => {
|
||||
setupCount++;
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
|
||||
// No event should be emitted
|
||||
// (we can't easily test this without triggering setup, but we verify unsubscribe works)
|
||||
expect(typeof unsubscribe).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reactive state', () => {
|
||||
it('should have state that reflects game progress', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
// Initial state
|
||||
expect(host.state.value.currentPlayer).toBe('X');
|
||||
expect(host.state.value.turn).toBe(0);
|
||||
|
||||
// Make a move
|
||||
const promptPromise = waitForPromptEvent(host);
|
||||
const runPromise = host.commands.run('setup');
|
||||
|
||||
const promptEvent = await promptPromise;
|
||||
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
||||
|
||||
// Wait for next prompt and cancel
|
||||
const nextPromptPromise = waitForPromptEvent(host);
|
||||
const nextPrompt = await nextPromptPromise;
|
||||
nextPrompt.cancel('test end');
|
||||
|
||||
const result = await runPromise;
|
||||
expect(result.success).toBe(false); // Cancelled
|
||||
|
||||
expect(host.state.value.currentPlayer).toBe('O');
|
||||
expect(host.state.value.turn).toBe(1);
|
||||
expect(Object.keys(host.state.value.parts).length).toBe(1);
|
||||
});
|
||||
|
||||
it('should update activePromptSchema reactively', async () => {
|
||||
const { host } = createTestHost();
|
||||
|
||||
// Initially null
|
||||
expect(host.activePromptSchema.value).toBeNull();
|
||||
|
||||
// Start a command that triggers prompt
|
||||
const promptPromise = waitForPromptEvent(host);
|
||||
const runPromise = host.commands.run('setup');
|
||||
|
||||
await promptPromise;
|
||||
|
||||
// Now schema should be set
|
||||
expect(host.activePromptSchema.value).not.toBeNull();
|
||||
expect(host.activePromptSchema.value?.name).toBe('play');
|
||||
|
||||
// Cancel and wait
|
||||
const cancelEvent = host.activePromptSchema.value;
|
||||
host.commands._cancel();
|
||||
try {
|
||||
await runPromise;
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Schema should be null again
|
||||
expect(host.activePromptSchema.value).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue