feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline
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
This commit is contained in:
Giancarmine Salucci
2026-05-24 16:29:04 +02:00
parent 17f371d5ee
commit 3f74c57665
14 changed files with 6412 additions and 3938 deletions

View File

@@ -1,5 +1,7 @@
import { applyMove, cloneState, createInitialState, getMatchOutcome, nextPlayer, teamOf } from './engine';
import { AITimingSource, AIMove, AISearchProfileOverride, chooseMove } from './ai';
import { chooseMove as chooseMoveOld } from './ai-legacy';
import { CardInferenceEngine } from './card-inference';
import {
AI_BENCHMARK_FIXTURES,
AIBenchmarkCriticalConcept,
@@ -199,6 +201,11 @@ const SELF_PLAY_SEAT_SWAPS = [0, 1] as const;
const SELF_PLAY_MATCH_SEEDS = Array.from({ length: 250 }, (_, index) => 1000 + index);
const MAX_SELF_PLAY_ROUNDS = 20;
const HEAD_TO_HEAD_SEEDS = Array.from({ length: 100 }, (_, i) => 2000 + i);
const HEAD_TO_HEAD_SEAT_SWAPS = [0, 1] as const;
const HEAD_TO_HEAD_MASTER_TARGET_WIN_RATE = 0.60;
const HEAD_TO_HEAD_ADVANCED_TARGET_WIN_RATE = 0.55;
interface SelfPlaySuiteConfig {
id: SelfPlaySuiteId;
label: string;
@@ -855,10 +862,195 @@ export async function runAIBenchmark(): Promise<AIBenchmarkSummary> {
};
}
interface HeadToHeadMatchResult {
suite: 'head-to-head-master' | 'head-to-head-advanced';
seed: number;
dealer: PlayerIndex;
newAITeam: 0 | 1;
newAIDifficulty: Difficulty;
winner: 0 | 1 | null;
newAIResult: 'win' | 'loss' | 'draw';
rounds: number;
totalPoints: [number, number];
}
interface HeadToHeadSuiteSummary {
suite: 'head-to-head-master' | 'head-to-head-advanced';
newAIDifficulty: Difficulty;
matches: number;
wins: number;
losses: number;
draws: number;
winRate: number;
targetWinRate: number;
passed: boolean;
results: HeadToHeadMatchResult[];
}
const HEAD_TO_HEAD_SUITE_SEED_KEYS: Record<'head-to-head-master' | 'head-to-head-advanced', number> = {
'head-to-head-master': 0x4d42,
'head-to-head-advanced': 0x4142,
};
async function simulateHeadToHeadMatch(
suite: 'head-to-head-master' | 'head-to-head-advanced',
difficulty: Difficulty,
seed: number,
newAITeam: 0 | 1,
): Promise<HeadToHeadMatchResult> {
const suiteSeedKey = HEAD_TO_HEAD_SUITE_SEED_KEYS[suite];
const initialDealer = (seed % 4) as PlayerIndex;
let state = createInitialState(initialDealer, createMulberry32(seedFromParts(suiteSeedKey, seed, 1, 0)));
const matchStartingPlayer = state.matchStartingPlayer;
const tracker = new CardTracker();
const inference = new CardInferenceEngine(tracker);
let rounds = 1;
let truncated = false;
let turnCount = 0;
while (rounds <= MAX_SELF_PLAY_ROUNDS) {
while (!state.roundOver) {
const playerIdx = state.currentPlayer;
const actingTeam = teamOf(playerIdx);
const isNewAI = actingTeam === newAITeam;
const timingSource = createSimulatedBenchmarkTimingSource();
const rng = createMulberry32(seedFromParts(suiteSeedKey, seed, rounds, turnCount, playerIdx));
let move: AIMove;
if (isNewAI) {
move = await chooseMove(state, playerIdx, difficulty, tracker, undefined, {
rng, timingSource, inference,
});
} else {
move = await chooseMoveOld(state, playerIdx, difficulty, tracker, undefined, {
rng, timingSource,
});
}
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;
turnCount++;
}
const outcome = getMatchOutcome(state.teamScores);
if (!outcome.continueMatch) {
break;
}
if (rounds === MAX_SELF_PLAY_ROUNDS) {
truncated = true;
break;
}
rounds++;
const totals: [number, number] = [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints];
const nextDealer = nextPlayer(state.dealer);
tracker.reset();
inference.reset();
state = createInitialState(nextDealer, createMulberry32(seedFromParts(suiteSeedKey, 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);
const winner = outcome.winner;
const newAIResult = winner === null ? 'draw' : winner === newAITeam ? 'win' : 'loss';
void truncated; // tracked internally; not surfaced in the result interface
return {
suite,
seed,
dealer: initialDealer,
newAITeam,
newAIDifficulty: difficulty,
winner,
newAIResult,
rounds,
totalPoints: [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints],
};
}
export async function runHeadToHeadBenchmark(): Promise<HeadToHeadSuiteSummary[]> {
const configs: Array<{
suite: 'head-to-head-master' | 'head-to-head-advanced';
difficulty: Difficulty;
targetWinRate: number;
}> = [
{ suite: 'head-to-head-master', difficulty: 'master', targetWinRate: HEAD_TO_HEAD_MASTER_TARGET_WIN_RATE },
{ suite: 'head-to-head-advanced', difficulty: 'advanced', targetWinRate: HEAD_TO_HEAD_ADVANCED_TARGET_WIN_RATE },
];
const summaries: HeadToHeadSuiteSummary[] = [];
for (const { suite, difficulty, targetWinRate } of configs) {
const results: HeadToHeadMatchResult[] = [];
const totalMatches = HEAD_TO_HEAD_SEEDS.length * HEAD_TO_HEAD_SEAT_SWAPS.length;
let completedMatches = 0;
logBenchmarkProgress(`Starting ${suite} (${totalMatches} matches: ${HEAD_TO_HEAD_SEEDS.length} seeds × ${HEAD_TO_HEAD_SEAT_SWAPS.length} seat swaps).`);
for (const seed of HEAD_TO_HEAD_SEEDS) {
for (const newAITeam of HEAD_TO_HEAD_SEAT_SWAPS) {
const result = await simulateHeadToHeadMatch(suite, difficulty, seed, newAITeam);
results.push(result);
completedMatches++;
if (completedMatches === 1 || completedMatches % 25 === 0 || completedMatches === totalMatches) {
logBenchmarkProgress(
`${suite} ${completedMatches}/${totalMatches}: seed ${seed}, newAITeam ${newAITeam}, result ${result.newAIResult}, rounds ${result.rounds}.`,
);
}
}
}
const wins = results.filter(r => r.newAIResult === 'win').length;
const losses = results.filter(r => r.newAIResult === 'loss').length;
const draws = results.filter(r => r.newAIResult === 'draw').length;
const winRate = results.length === 0 ? 0 : wins / results.length;
summaries.push({
suite,
newAIDifficulty: difficulty,
matches: results.length,
wins,
losses,
draws,
winRate,
targetWinRate,
passed: winRate >= targetWinRate,
results,
});
}
return summaries;
}
async function runBenchmarkCli(): Promise<void> {
const summary = await runAIBenchmark();
logBenchmarkProgress('Benchmark complete. Emitting summary with iteration 6 gate results.');
printReadableSummary(summary);
logBenchmarkProgress('Starting HEAD_TO_HEAD benchmark (new AI vs legacy AI)...');
const h2hSuites = await runHeadToHeadBenchmark();
for (const h2h of h2hSuites) {
console.log(`\nHEAD_TO_HEAD: ${h2h.suite} (${h2h.matches} games)`);
console.log(`New AI wins: ${h2h.wins} (${formatPercentage(h2h.winRate)})`);
console.log(`Legacy AI wins: ${h2h.losses} (${formatPercentage(h2h.matches === 0 ? 0 : h2h.losses / h2h.matches)})`);
console.log(`Ties: ${h2h.draws}`);
console.log(`Target win rate: ${formatPercentage(h2h.targetWinRate)}${h2h.passed ? 'PASS' : 'FAIL'}`);
}
}
if (typeof window === 'undefined') {