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';
|
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.
|
* Tracks which cards have been played/captured during a round.
|
||||||
* Used by AI to infer opponent hands WITHOUT cheating.
|
* Used by AI to infer opponent hands WITHOUT cheating.
|
||||||
@@ -7,6 +17,27 @@ import { Card, Suit, SUITS } from './types';
|
|||||||
export class CardTracker {
|
export class CardTracker {
|
||||||
private played: Set<string> = new Set(); // card IDs that have been seen
|
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 */
|
/** Record a card being played to the table */
|
||||||
trackPlay(card: Card): void {
|
trackPlay(card: Card): void {
|
||||||
this.played.add(card.id);
|
this.played.add(card.id);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Card, PlayerIndex, GameState, Difficulty } from '../game/types';
|
|||||||
import {
|
import {
|
||||||
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome
|
createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome
|
||||||
} from '../game/engine';
|
} 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';
|
import { CardTracker } from '../game/card-tracker';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -56,6 +57,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Difficulty & card tracker
|
// Difficulty & card tracker
|
||||||
private difficulty: Difficulty = 'advanced';
|
private difficulty: Difficulty = 'advanced';
|
||||||
private tracker: CardTracker = new CardTracker();
|
private tracker: CardTracker = new CardTracker();
|
||||||
|
private aiClient: AIWorkerClientLike | null = null;
|
||||||
|
|
||||||
// Active player highlight
|
// Active player highlight
|
||||||
private activeHighlightRect: Phaser.GameObjects.Graphics | null = null;
|
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)
|
// Read difficulty from scene data (MenuScene passes it)
|
||||||
this.difficulty = data?.difficulty ?? 'advanced';
|
this.difficulty = data?.difficulty ?? 'advanced';
|
||||||
this.tracker = new CardTracker();
|
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.generateParticleTextures();
|
||||||
this.drawBackground(W, H);
|
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> {
|
private async doAIMove(playerIdx: PlayerIndex): Promise<void> {
|
||||||
const turnState = this.state;
|
const turnState = this.state;
|
||||||
|
const aiClient = this.aiClient;
|
||||||
|
|
||||||
|
if (!aiClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const move = await chooseMove(
|
const move = await aiClient.chooseMove(
|
||||||
this.state,
|
this.state,
|
||||||
playerIdx,
|
playerIdx,
|
||||||
this.difficulty,
|
this.difficulty,
|
||||||
this.tracker,
|
this.tracker,
|
||||||
(progress) => {
|
(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);
|
this.updateThinkBar(playerIdx, progress);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.aiClient !== aiClient) return;
|
||||||
if (!this.scene.isActive('GameScene')) return;
|
if (!this.scene.isActive('GameScene')) return;
|
||||||
if (this.state !== turnState || this.state.currentPlayer !== playerIdx || this.state.roundOver) 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);
|
this.executeMove(playerIdx, move.card, move.capture);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI move failed', 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');
|
this.setStatus('Errore durante la mossa AI');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (this.scene.isActive('GameScene') && this.state === turnState) {
|
if (this.aiClient === aiClient && this.scene.isActive('GameScene') && this.state === turnState) {
|
||||||
this.hideThinkBar();
|
this.hideThinkBar();
|
||||||
this.aiThinking = false;
|
this.aiThinking = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user