Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
- 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
234 lines
6.2 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
} |