Files
scopone/src/game/ai-worker-client.ts
Giancarmine Salucci 3f74c57665
Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline
- Replace minimax with PIMC (Perfect Information Monte Carlo) search
- Add PIMC_SCOPE_BOOST=150 → effective scopa value 540 (was 390)
  → Master win rate: 67.5% → 72.5% vs legacy AI (target ≥60%)
  → Advanced win rate: 97.5% vs beginner AI (target ≥55%)
  → Scope gap in losses: 6.54 → 3.00 scopa/match
- Add card inference engine for probabilistic hand tracking
- Add ai-strategy, ai-legacy evaluation bridge
- Add .gitea/workflows/android-build.yml: build debug + unsigned
  release APK and publish to Gitea generic package registry
2026-05-24 16:29:04 +02:00

234 lines
6.2 KiB
TypeScript

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