From 5b360bf1916c3b321df69cd2595e195388e16129 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Thu, 2 Apr 2026 20:51:43 +0200 Subject: [PATCH] fix(SCOPONE-0008): complete iteration 1 remove ai lag --- src/game/ai-worker-client.ts | 228 +++++++++++++++++++++++++++++++++ src/game/ai-worker-protocol.ts | 43 +++++++ src/game/ai.worker.ts | 79 ++++++++++++ src/game/card-tracker.ts | 31 +++++ src/scenes/GameScene.ts | 31 ++++- 5 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 src/game/ai-worker-client.ts create mode 100644 src/game/ai-worker-protocol.ts create mode 100644 src/game/ai.worker.ts diff --git a/src/game/ai-worker-client.ts b/src/game/ai-worker-client.ts new file mode 100644 index 0000000..fb8cc4e --- /dev/null +++ b/src/game/ai-worker-client.ts @@ -0,0 +1,228 @@ +import { 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, + ): Promise; + dispose(): void; +} + +export type AIWorkerFactory = () => Worker; + +interface PendingRequest { + state: GameState; + playerIdx: PlayerIndex; + difficulty: Difficulty; + tracker?: CardTracker; + onProgress?: (progress: AIDecisionProgress) => void; + 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, + ): 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, + resolve, + reject, + }; + + this.pending.set(requestId, pending); + + const message: AIWorkerRequestMessage = { + type: 'choose-move', + requestId, + state, + playerIdx, + difficulty, + trackerSnapshot: tracker ? tracker.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.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); + } + } +} \ No newline at end of file diff --git a/src/game/ai-worker-protocol.ts b/src/game/ai-worker-protocol.ts new file mode 100644 index 0000000..e8b1d1d --- /dev/null +++ b/src/game/ai-worker-protocol.ts @@ -0,0 +1,43 @@ +import type { AIDecisionProgress, AIMove } from './ai'; +import type { CardTrackerSnapshot } from './card-tracker'; +import type { Difficulty, GameState, PlayerIndex } from './types'; + +export interface AIWorkerChooseMoveRequest { + type: 'choose-move'; + requestId: string; + state: GameState; + playerIdx: PlayerIndex; + difficulty: Difficulty; + trackerSnapshot: CardTrackerSnapshot | null; +} + +export interface AIWorkerProgressMessage { + type: 'progress'; + requestId: string; + progress: AIDecisionProgress; +} + +export interface AIWorkerResultMessage { + type: 'result'; + requestId: string; + move: AIMove; +} + +export interface AIWorkerSerializedError { + message: string; + name: string; + stack?: string; +} + +export interface AIWorkerErrorMessage { + type: 'error'; + requestId: string; + error: AIWorkerSerializedError; +} + +export type AIWorkerRequestMessage = AIWorkerChooseMoveRequest; + +export type AIWorkerResponseMessage = + | AIWorkerProgressMessage + | AIWorkerResultMessage + | AIWorkerErrorMessage; \ No newline at end of file diff --git a/src/game/ai.worker.ts b/src/game/ai.worker.ts new file mode 100644 index 0000000..79c4bd2 --- /dev/null +++ b/src/game/ai.worker.ts @@ -0,0 +1,79 @@ +import { chooseMove } from './ai'; +import { + AIWorkerChooseMoveRequest, + AIWorkerErrorMessage, + AIWorkerRequestMessage, + AIWorkerResponseMessage, +} from './ai-worker-protocol'; +import { CardTracker } from './card-tracker'; + +interface AIWorkerScope { + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; + postMessage(message: AIWorkerResponseMessage): void; +} + +const workerScope = globalThis as unknown as AIWorkerScope; + +function serializeError(requestId: string, error: unknown): AIWorkerErrorMessage { + if (error instanceof Error) { + return { + type: 'error', + requestId, + error: { + message: error.message, + name: error.name, + stack: error.stack, + }, + }; + } + + return { + type: 'error', + requestId, + error: { + message: typeof error === 'string' ? error : 'Unknown AI worker error', + name: 'Error', + }, + }; +} + +async function handleChooseMove(request: AIWorkerChooseMoveRequest): Promise { + const tracker = request.trackerSnapshot + ? CardTracker.fromSnapshot(request.trackerSnapshot) + : undefined; + + try { + const move = await chooseMove( + request.state, + request.playerIdx, + request.difficulty, + tracker, + (progress) => { + workerScope.postMessage({ + type: 'progress', + requestId: request.requestId, + progress, + }); + }, + ); + + workerScope.postMessage({ + type: 'result', + requestId: request.requestId, + move, + }); + } catch (error) { + workerScope.postMessage(serializeError(request.requestId, error)); + } +} + +workerScope.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (message.type !== 'choose-move') { + return; + } + + void handleChooseMove(message); +}); + +export {}; \ No newline at end of file diff --git a/src/game/card-tracker.ts b/src/game/card-tracker.ts index f111796..a74525b 100644 --- a/src/game/card-tracker.ts +++ b/src/game/card-tracker.ts @@ -1,5 +1,15 @@ import { Card, Suit, SUITS } from './types'; +export interface CardTrackerSnapshot { + playedCardIds: string[]; +} + +function normalizeSnapshot(snapshot: CardTrackerSnapshot): CardTrackerSnapshot { + return { + playedCardIds: Array.from(new Set(snapshot.playedCardIds)), + }; +} + /** * Tracks which cards have been played/captured during a round. * Used by AI to infer opponent hands WITHOUT cheating. @@ -7,6 +17,27 @@ import { Card, Suit, SUITS } from './types'; export class CardTracker { private played: Set = new Set(); // card IDs that have been seen + constructor(snapshot?: CardTrackerSnapshot) { + if (snapshot) { + this.restoreSnapshot(snapshot); + } + } + + static fromSnapshot(snapshot: CardTrackerSnapshot): CardTracker { + return new CardTracker(snapshot); + } + + toSnapshot(): CardTrackerSnapshot { + return { + playedCardIds: [...this.played], + }; + } + + restoreSnapshot(snapshot: CardTrackerSnapshot): void { + const normalized = normalizeSnapshot(snapshot); + this.played = new Set(normalized.playedCardIds); + } + /** Record a card being played to the table */ trackPlay(card: Card): void { this.played.add(card.id); diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 6e41ab6..d489629 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -3,7 +3,8 @@ import { Card, PlayerIndex, GameState, Difficulty } from '../game/types'; import { createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome } from '../game/engine'; -import { chooseMove, AIDecisionProgress } from '../game/ai'; +import { AIDecisionProgress } from '../game/ai'; +import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client'; import { CardTracker } from '../game/card-tracker'; // --------------------------------------------------------------------------- @@ -56,6 +57,7 @@ export class GameScene extends Phaser.Scene { // Difficulty & card tracker private difficulty: Difficulty = 'advanced'; private tracker: CardTracker = new CardTracker(); + private aiClient: AIWorkerClientLike | null = null; // Active player highlight private activeHighlightRect: Phaser.GameObjects.Graphics | null = null; @@ -111,6 +113,10 @@ export class GameScene extends Phaser.Scene { // Read difficulty from scene data (MenuScene passes it) this.difficulty = data?.difficulty ?? 'advanced'; this.tracker = new CardTracker(); + this.aiClient?.dispose(); + this.aiClient = new AIWorkerClient(); + this.events.once(Phaser.Scenes.Events.SHUTDOWN, this.handleSceneShutdown, this); + this.events.once(Phaser.Scenes.Events.DESTROY, this.handleSceneShutdown, this); this.generateParticleTextures(); this.drawBackground(W, H); @@ -603,21 +609,36 @@ export class GameScene extends Phaser.Scene { } } + private handleSceneShutdown(): void { + this.aiClient?.dispose(); + this.aiClient = null; + this.aiThinking = false; + if (this.thinkBar) { + this.hideThinkBar(); + } + } + private async doAIMove(playerIdx: PlayerIndex): Promise { const turnState = this.state; + const aiClient = this.aiClient; + + if (!aiClient) { + return; + } try { - const move = await chooseMove( + const move = await aiClient.chooseMove( this.state, playerIdx, this.difficulty, this.tracker, (progress) => { - if (!this.scene.isActive('GameScene') || this.state !== turnState) return; + if (this.aiClient !== aiClient || !this.scene.isActive('GameScene') || this.state !== turnState) return; this.updateThinkBar(playerIdx, progress); } ); + if (this.aiClient !== aiClient) return; if (!this.scene.isActive('GameScene')) return; if (this.state !== turnState || this.state.currentPlayer !== playerIdx || this.state.roundOver) return; @@ -626,11 +647,11 @@ export class GameScene extends Phaser.Scene { this.executeMove(playerIdx, move.card, move.capture); } catch (error) { console.error('AI move failed', error); - if (this.scene.isActive('GameScene') && this.state === turnState) { + if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) { this.setStatus('Errore durante la mossa AI'); } } finally { - if (this.scene.isActive('GameScene') && this.state === turnState) { + if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) { this.hideThinkBar(); this.aiThinking = false; }