import { ReadonlySignal, Signal } from '@preact/signals-core'; import type { CommandSchema, CommandRegistry, PromptEvent } from '@/utils/command'; import type { MutableSignal } from '@/utils/mutable-signal'; import { createGameContext } from './game'; export type GameHostStatus = 'created' | 'running' | 'disposed'; export interface GameModule> { registry: CommandRegistry>; createInitialState: () => TState; } export class GameHost> { readonly state: ReadonlySignal; readonly commands: ReturnType>['commands']; readonly status: ReadonlySignal; readonly activePromptSchema: ReadonlySignal; readonly activePromptPlayer: ReadonlySignal; private _state: MutableSignal; private _status: Signal; private _activePromptSchema: Signal; private _activePromptPlayer: Signal; private _createInitialState: () => TState; private _eventListeners: Map<'setup' | 'dispose', Set<() => void>>; private _isDisposed = false; constructor( registry: CommandRegistry>, createInitialState: () => TState, ) { this._createInitialState = createInitialState; 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._activePromptPlayer = new Signal(null); this.activePromptPlayer = this._activePromptPlayer; this.state = this._state; this._setupPromptTracking(); } private _setupPromptTracking() { let currentPromptEvent: PromptEvent | null = null; this.commands.on('prompt', (e) => { currentPromptEvent = e as PromptEvent; this._activePromptSchema.value = currentPromptEvent.schema; this._activePromptPlayer.value = currentPromptEvent.currentPlayer; }); this.commands.on('promptEnd', () => { currentPromptEvent = null; this._activePromptSchema.value = null; this._activePromptPlayer.value = null; }); // Initial state this._activePromptSchema.value = null; this._activePromptPlayer.value = null; } 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: GameModule, ): GameHost { return new GameHost( module.registry, module.createInitialState, ); }