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
138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
/**
|
||
* 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);
|