diff --git a/src/core/game-host.ts b/src/core/game-host.ts new file mode 100644 index 0000000..d469089 --- /dev/null +++ b/src/core/game-host.ts @@ -0,0 +1,161 @@ +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> { + readonly state: ReadonlySignal; + readonly commands: ReturnType>['commands']; + readonly status: ReadonlySignal; + readonly activePromptSchema: ReadonlySignal; + + private _state: MutableSignal; + private _status: Signal; + private _activePromptSchema: Signal; + private _createInitialState: () => TState; + private _setupCommand: string; + private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>; + private _isDisposed = false; + + constructor( + registry: CommandRegistry>, + 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('created'); + this.status = this._status; + + this._activePromptSchema = new Signal(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; + }; + + // Wrap _tryCommit to update schema after commit + const originalTryCommit = this.commands._tryCommit.bind(this.commands); + (this.commands as any)._tryCommit = (input: string) => { + const result = originalTryCommit(input); + updateSchema(); + return result; + }; + + // Wrap _cancel to update schema after cancel + const originalCancel = this.commands._cancel.bind(this.commands); + (this.commands as any)._cancel = (reason?: string) => { + originalCancel(reason); + updateSchema(); + }; + + this.commands.on('prompt', () => { + updateSchema(); + }); + + updateSchema(); + } + + onInput(input: string): string | null { + if (this._isDisposed) { + return 'GameHost is disposed'; + } + return this.commands._tryCommit(input); + } + + async setup(setupCommand: string): Promise { + 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>( + module: { + registry: CommandRegistry>; + createInitialState: () => TState; + }, + setupCommand: string, + options?: GameHostOptions +): GameHost { + return new GameHost( + module.registry, + module.createInitialState, + setupCommand, + options + ); +} diff --git a/src/core/game.ts b/src/core/game.ts index 9902e43..55690f0 100644 --- a/src/core/game.ts +++ b/src/core/game.ts @@ -66,4 +66,7 @@ export function createGameCommand = {} , schema: typeof schema === 'string' ? parseCommandSchema(schema) : schema, run, }); -} \ No newline at end of file +} + +export { GameHost, createGameHost } from './game-host'; +export type { GameHostStatus, GameHostOptions } from './game-host'; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f1e5f73..b01b8b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/tests/core/game-host.test.ts b/tests/core/game-host.test.ts new file mode 100644 index 0000000..f365a78 --- /dev/null +++ b/tests/core/game-host.test.ts @@ -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): Promise { + 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(); + }); + }); +});