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