import { AIChooseMoveOptions, AIDecisionProgress, AIMove, chooseMove } from './ai'; import { AIWorkerErrorMessage, AIWorkerRequestMessage, AIWorkerResponseMessage, } from './ai-worker-protocol'; import { CardTracker } from './card-tracker'; import type { Difficulty, GameState, PlayerIndex } from './types'; export interface AIWorkerClientLike { chooseMove( state: GameState, playerIdx: PlayerIndex, difficulty?: Difficulty, tracker?: CardTracker, onProgress?: (progress: AIDecisionProgress) => void, options?: AIChooseMoveOptions, ): Promise; dispose(): void; } export type AIWorkerFactory = () => Worker; interface PendingRequest { state: GameState; playerIdx: PlayerIndex; difficulty: Difficulty; tracker?: CardTracker; onProgress?: (progress: AIDecisionProgress) => void; options?: AIChooseMoveOptions; resolve: (move: AIMove) => void; reject: (error: Error) => void; } function defaultWorkerFactory(): Worker { return new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' }); } function toError(message: string, cause?: unknown): Error { if (cause instanceof Error) { return cause; } return new Error(message); } function fromWorkerError(message: AIWorkerErrorMessage): Error { const error = new Error(message.error.message); error.name = message.error.name; if (message.error.stack) { error.stack = message.error.stack; } return error; } export class AIWorkerClient implements AIWorkerClientLike { private worker: Worker | null = null; private disposed = false; private workerUnavailable = false; private requestCounter = 0; private readonly pending = new Map(); constructor(private readonly workerFactory: AIWorkerFactory = defaultWorkerFactory) {} async chooseMove( state: GameState, playerIdx: PlayerIndex, difficulty: Difficulty = 'advanced', tracker?: CardTracker, onProgress?: (progress: AIDecisionProgress) => void, options?: AIChooseMoveOptions, ): Promise { if (this.disposed) { throw new Error('AIWorkerClient has been disposed'); } const worker = this.getOrCreateWorker(); if (!worker) { return chooseMove(state, playerIdx, difficulty, tracker, onProgress); } const requestId = `ai-request-${this.requestCounter++}`; return new Promise((resolve, reject) => { const pending: PendingRequest = { state, playerIdx, difficulty, tracker, onProgress, options, resolve, reject, }; this.pending.set(requestId, pending); const message: AIWorkerRequestMessage = { type: 'choose-move', requestId, state, playerIdx, difficulty, trackerSnapshot: tracker ? tracker.toSnapshot() : null, inferenceSnapshot: options?.inference?.toSnapshot() ?? null, }; try { worker.postMessage(message); } catch (error) { this.pending.delete(requestId); this.disableWorker(); void this.runFallback(pending); } }); } dispose(): void { if (this.disposed) { return; } this.disposed = true; if (this.worker) { this.worker.removeEventListener('message', this.handleWorkerMessage); this.worker.removeEventListener('error', this.handleWorkerFailure); this.worker.removeEventListener('messageerror', this.handleWorkerMessageError); this.worker.terminate(); this.worker = null; } const error = new Error('AIWorkerClient has been disposed'); for (const pending of this.pending.values()) { pending.reject(error); } this.pending.clear(); } private getOrCreateWorker(): Worker | null { if (this.workerUnavailable) { return null; } if (this.worker) { return this.worker; } if (typeof Worker === 'undefined') { this.workerUnavailable = true; return null; } try { this.worker = this.workerFactory(); this.worker.addEventListener('message', this.handleWorkerMessage); this.worker.addEventListener('error', this.handleWorkerFailure); this.worker.addEventListener('messageerror', this.handleWorkerMessageError); return this.worker; } catch { this.workerUnavailable = true; this.worker = null; return null; } } private disableWorker(): void { if (this.worker) { this.worker.removeEventListener('message', this.handleWorkerMessage); this.worker.removeEventListener('error', this.handleWorkerFailure); this.worker.removeEventListener('messageerror', this.handleWorkerMessageError); this.worker.terminate(); this.worker = null; } this.workerUnavailable = true; } private async runFallback(pending: PendingRequest): Promise { try { const move = await chooseMove( pending.state, pending.playerIdx, pending.difficulty, pending.tracker, pending.onProgress, pending.options, ); pending.resolve(move); } catch (error) { pending.reject(toError('Fallback AI move failed', error)); } } private readonly handleWorkerMessage = (event: MessageEvent): void => { const message = event.data; const pending = this.pending.get(message.requestId); if (!pending) { return; } if (message.type === 'progress') { pending.onProgress?.(message.progress); return; } this.pending.delete(message.requestId); if (message.type === 'result') { pending.resolve(message.move); return; } pending.reject(fromWorkerError(message)); }; private readonly handleWorkerFailure = (_event: Event): void => { this.failoverPendingRequests(new Error('AI worker failed')); }; private readonly handleWorkerMessageError = (_event: MessageEvent): void => { this.failoverPendingRequests(new Error('AI worker could not deserialize a message')); }; private failoverPendingRequests(_error: Error): void { const pendingRequests = [...this.pending.values()]; this.pending.clear(); this.disableWorker(); for (const pending of pendingRequests) { void this.runFallback(pending); } } }