fix(SCOPONE-0008): complete iteration 1 remove ai lag

This commit is contained in:
Giancarmine Salucci
2026-04-02 20:51:43 +02:00
parent 019c4380be
commit 5b360bf191
5 changed files with 407 additions and 5 deletions

View File

@@ -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<AIMove>;
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<string, PendingRequest>();
constructor(private readonly workerFactory: AIWorkerFactory = defaultWorkerFactory) {}
async chooseMove(
state: GameState,
playerIdx: PlayerIndex,
difficulty: Difficulty = 'advanced',
tracker?: CardTracker,
onProgress?: (progress: AIDecisionProgress) => void,
): Promise<AIMove> {
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<AIMove>((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<void> {
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<AIWorkerResponseMessage>): 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);
}
}
}

View File

@@ -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;

79
src/game/ai.worker.ts Normal file
View File

@@ -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<AIWorkerRequestMessage>) => 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<void> {
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<AIWorkerRequestMessage>) => {
const message = event.data;
if (message.type !== 'choose-move') {
return;
}
void handleChooseMove(message);
});
export {};

View File

@@ -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<string> = 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);

View File

@@ -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<void> {
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;
}