Compare commits

..

No commits in common. "6c8d6e0790905e5eb15ac800b486b15179f9317d" and "50c146964d9c2589979549fd9fb4fac40ce6f7c8" have entirely different histories.

6 changed files with 10 additions and 531 deletions

View File

@ -1,150 +0,0 @@
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
);
}

View File

@ -66,7 +66,4 @@ export function createGameCommand<TState extends Record<string, unknown> = {} ,
schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema, schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema,
run, run,
}); });
} }
export { GameHost, createGameHost } from './game-host';
export type { GameHostStatus, GameHostOptions } from './game-host';

View File

@ -7,9 +7,6 @@
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 { createGameHost } from './core/game-host';
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';

View File

@ -1,5 +1,5 @@
import type { Command, CommandSchema } from './types'; import type { Command, CommandSchema } from './types';
import type {CommandResult, CommandRunner, CommandRunnerContext, CommandRunnerEvents, PromptEvent} from './command-runner'; import type {CommandResult, CommandRunner, CommandRunnerContext, PromptEvent} from './command-runner';
import { parseCommand } from './command-parse'; import { parseCommand } from './command-parse';
import { applyCommandSchema } from './command-validate'; import { applyCommandSchema } from './command-validate';
import { parseCommandSchema } from './schema-parse'; import { parseCommandSchema } from './schema-parse';
@ -39,8 +39,7 @@ export function getCommand<TContext>(
return registry.get(name); return registry.get(name);
} }
type PromptListener = (e: PromptEvent) => void; type Listener = (e: PromptEvent) => void;
type PromptEndListener = () => void;
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & { export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
registry: CommandRegistry<TContext>; registry: CommandRegistry<TContext>;
@ -55,29 +54,14 @@ export function createCommandRunnerContext<TContext>(
registry: CommandRegistry<TContext>, registry: CommandRegistry<TContext>,
context: TContext context: TContext
): CommandRunnerContextExport<TContext> { ): CommandRunnerContextExport<TContext> {
const promptListeners = new Set<PromptListener>(); const listeners = new Set<Listener>();
const promptEndListeners = new Set<PromptEndListener>();
const emitPromptEnd = () => { const on = (_event: 'prompt', listener: Listener) => {
for (const listener of promptEndListeners) { listeners.add(listener);
listener();
}
}; };
const on = <T extends keyof CommandRunnerEvents>(_event: T, listener: (e: CommandRunnerEvents[T]) => void) => { const off = (_event: 'prompt', listener: Listener) => {
if (_event === 'prompt') { listeners.delete(listener);
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; let activePrompt: PromptEvent | null = null;
@ -87,7 +71,6 @@ export function createCommandRunnerContext<TContext>(
const result = activePrompt.tryCommit(commandOrInput); const result = activePrompt.tryCommit(commandOrInput);
if (result === null) { if (result === null) {
activePrompt = null; activePrompt = null;
emitPromptEnd();
} }
return result; return result;
} }
@ -98,7 +81,6 @@ export function createCommandRunnerContext<TContext>(
if (activePrompt) { if (activePrompt) {
activePrompt.cancel(reason); activePrompt.cancel(reason);
activePrompt = null; activePrompt = null;
emitPromptEnd();
} }
}; };
@ -124,7 +106,7 @@ export function createCommandRunnerContext<TContext>(
}; };
activePrompt = { schema: resolvedSchema, tryCommit, cancel }; activePrompt = { schema: resolvedSchema, tryCommit, cancel };
const event: PromptEvent = { schema: resolvedSchema, tryCommit, cancel }; const event: PromptEvent = { schema: resolvedSchema, tryCommit, cancel };
for (const listener of promptListeners) { for (const listener of listeners) {
listener(event); listener(event);
} }
}); });
@ -154,7 +136,7 @@ export function createCommandRunnerContext<TContext>(
get(){ get(){
if (!promptQueue) { if (!promptQueue) {
promptQueue = new AsyncQueue(); promptQueue = new AsyncQueue();
promptListeners.add(async (event) => { listeners.add(async (event) => {
promptQueue.push(event); promptQueue.push(event);
}); });
} }

View File

@ -17,8 +17,6 @@ export type PromptEvent = {
export type CommandRunnerEvents = { export type CommandRunnerEvents = {
prompt: PromptEvent; prompt: PromptEvent;
/** 当 prompt 结束tryCommit 成功或 cancel时触发 */
promptEnd: void;
}; };
export type CommandResult<T=unknown> = { export type CommandResult<T=unknown> = {

View File

@ -1,345 +0,0 @@
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();
});
});
});