fix(SCOPONE-0008): complete iteration 1 remove ai lag
This commit is contained in:
228
src/game/ai-worker-client.ts
Normal file
228
src/game/ai-worker-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/game/ai-worker-protocol.ts
Normal file
43
src/game/ai-worker-protocol.ts
Normal 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
79
src/game/ai.worker.ts
Normal 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 {};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user