Files
scopone/src/game/ai-h2h-quick.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

138 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Quick HEAD_TO_HEAD: new AI vs legacy AI — 20 seeds × 2 seat swaps = 40 matches each.
* Run with: tsx src/game/ai-h2h-quick.ts
*/
import { chooseMove } from './ai';
import { chooseMove as chooseMoveOld } from './ai-legacy';
import { CardTracker } from './card-tracker';
import { CardInferenceEngine } from './card-inference';
import { applyMove, teamOf, nextPlayer, createInitialState, getMatchOutcome } from './engine';
import { AIMove, Difficulty, GameState, PlayerIndex } from './types';
// ---- seeded RNG -------------------------------------------------------
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = Math.imul(s ^ (s >>> 15), s | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seedFromParts(...parts: number[]): number {
let h = 2166136261;
for (const p of parts) { h ^= p >>> 0; h = Math.imul(h, 16777619); }
return h >>> 0;
}
// ---- simulated timing (same as benchmark) ----------------------------
function simulatedTiming() {
let t = 0;
return {
now: () => t,
advance: (ms: number) => { t += ms; return t; },
isSimulated: true as const,
};
}
// ---- single match ----------------------------------------------------
async function runMatch(
difficulty: Difficulty,
seed: number,
newAITeam: 0 | 1,
): Promise<'new' | 'old' | 'draw'> {
const SUITE_KEY = 0xabcd1234;
const MAX_ROUNDS = 20;
const initialDealer = (seed % 4) as PlayerIndex;
let state = createInitialState(
initialDealer,
mulberry32(seedFromParts(SUITE_KEY, seed, 1, 0)),
);
const matchStartingPlayer = state.matchStartingPlayer;
const tracker = new CardTracker();
const inference = new CardInferenceEngine(tracker);
let rounds = 1;
let turn = 0;
while (rounds <= MAX_ROUNDS) {
while (!state.roundOver) {
const playerIdx = state.currentPlayer;
const isNew = teamOf(playerIdx) === newAITeam;
const timing = simulatedTiming();
const rng = mulberry32(seedFromParts(SUITE_KEY, seed, rounds, turn, playerIdx));
const move: AIMove = isNew
? await chooseMove(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing, inference })
: await chooseMoveOld(state, playerIdx, difficulty, tracker, undefined, { rng, timingSource: timing });
const tableBeforeMove = [...state.table];
const { nextState, capture } = applyMove(
state, playerIdx, move.card,
move.capture.length > 0 ? move.capture : undefined,
);
tracker.trackPlay(move.card);
if (capture) tracker.trackCapture(capture.captured);
inference.onMove(playerIdx, move, tableBeforeMove);
state = nextState;
turn++;
}
const outcome = getMatchOutcome(state.teamScores);
if (!outcome.continueMatch) break;
if (rounds === MAX_ROUNDS) break;
rounds++;
const totals: [number, number] = [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints];
tracker.reset();
inference.reset();
state = createInitialState(
nextPlayer(state.dealer),
mulberry32(seedFromParts(SUITE_KEY, seed, rounds, 0)),
);
state.matchStartingPlayer = matchStartingPlayer;
state.teamScores[0].totalPoints = totals[0];
state.teamScores[1].totalPoints = totals[1];
state.roundNumber = rounds;
}
const outcome = getMatchOutcome(state.teamScores);
if (outcome.winner === null) return 'draw';
return outcome.winner === newAITeam ? 'new' : 'old';
}
// ---- main ------------------------------------------------------------
async function main() {
const SEEDS = Array.from({ length: 20 }, (_, i) => 3000 + i);
const SWAPS = [0, 1] as const;
for (const difficulty of ['master', 'advanced'] as Difficulty[]) {
let wins = 0, losses = 0, draws = 0;
const total = SEEDS.length * SWAPS.length;
console.log(`\nH2H ${difficulty.toUpperCase()}${total} matches`);
for (const seed of SEEDS) {
for (const newAITeam of SWAPS) {
const result = await runMatch(difficulty, seed, newAITeam);
if (result === 'new') wins++;
else if (result === 'old') losses++;
else draws++;
const done = wins + losses + draws;
if (done === 1 || done % 10 === 0 || done === total) {
const pct = ((wins / done) * 100).toFixed(1);
console.log(` [${done}/${total}] new=${wins} old=${losses} draw=${draws} new-win%=${pct}%`);
}
}
}
const target = difficulty === 'master' ? 60 : 55;
const winRate = (wins / total) * 100;
const pass = winRate >= target;
console.log(`RESULT: new AI wins ${winRate.toFixed(1)}% — target ≥${target}% — ${pass ? '✓ PASS' : '✗ FAIL'}`);
}
}
main().catch(console.error);