/** * Diagnostic H2H: logs category breakdown for every LOSS (master difficulty). * Run with: npx tsx src/game/ai-h2h-diagnose.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'; 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; } function simulatedTiming() { let t = 0; return { now: () => t, advance: (ms: number) => { t += ms; return t; }, isSimulated: true as const }; } interface MatchDetail { seed: number; newAITeam: 0 | 1; result: 'new' | 'old' | 'draw'; newPts: number; oldPts: number; // cumulative per-match category wins: +1 new won, -1 old won, 0 tied carte: number; // +1 = new won denari: number; settebello: number; primiera: number; scopeNew: number; scopeOld: number; } async function runMatch( difficulty: Difficulty, seed: number, newAITeam: 0 | 1, ): Promise { 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; // Cumulative category wins across all rounds let carte = 0, denari = 0, settebello = 0, primiera = 0; let scopeNew = 0, scopeOld = 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++; } // Accumulate per-round category outcomes const ts = state.teamScores; const newT = newAITeam; const oldT = (1 - newAITeam) as 0 | 1; // Cards const newCards = ts[newT].cards, oldCards = ts[oldT].cards; if (newCards > 20) carte += 1; else if (oldCards > 20) carte -= 1; // Denari const newDen = ts[newT].denari, oldDen = ts[oldT].denari; if (newDen >= 6) denari += 1; else if (oldDen >= 6) denari -= 1; // Settebello settebello += ts[newT].settebello ? 1 : -1; // Primiera const newPrim = ts[newT].primiera, oldPrim = ts[oldT].primiera; if (newPrim > oldPrim) primiera += 1; else if (oldPrim > newPrim) primiera -= 1; // Scope this round scopeNew += ts[newT].scope; scopeOld += ts[oldT].scope; 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); const result = outcome.winner === null ? 'draw' : outcome.winner === newAITeam ? 'new' : 'old'; return { seed, newAITeam, result, newPts: state.teamScores[newAITeam].totalPoints, oldPts: state.teamScores[1 - newAITeam as 0 | 1].totalPoints, carte, denari, settebello, primiera, scopeNew, scopeOld, }; } async function main() { const SEEDS = Array.from({ length: 20 }, (_, i) => 3000 + i); const SWAPS = [0, 1] as const; const difficulty: Difficulty = 'master'; const losses: MatchDetail[] = []; const wins: MatchDetail[] = []; let done = 0; const total = SEEDS.length * SWAPS.length; console.log(`\nDIAGNOSTIC ${difficulty.toUpperCase()} — ${total} matches\n`); for (const seed of SEEDS) { for (const newAITeam of SWAPS) { const d = await runMatch(difficulty, seed, newAITeam); if (d.result === 'old') losses.push(d); else wins.push(d); done++; if (done % 10 === 0 || done === total) { console.log(` [${done}/${total}] wins=${wins.length} losses=${losses.length}`); } } } // --- Per-match loss report --- console.log(`\n=== LOSSES (${losses.length}) ===`); console.log(`${'seed'.padEnd(6)} ${'team'.padEnd(5)} ${'score'.padEnd(8)} ${'carte'.padEnd(7)} ${'denari'.padEnd(8)} ${'sette'.padEnd(7)} ${'prim'.padEnd(6)} ${'scopeN'.padEnd(8)} ${'scopeO'}`); for (const d of losses) { const score = `${d.newPts}-${d.oldPts}`; const sign = (n: number) => n > 0 ? '+new' : n < 0 ? '+old' : 'tie'; console.log( `${String(d.seed).padEnd(6)} t${d.newAITeam} ${score.padEnd(8)} ` + `${sign(d.carte).padEnd(7)} ${sign(d.denari).padEnd(8)} ${sign(d.settebello).padEnd(7)} ` + `${sign(d.primiera).padEnd(6)} ${String(d.scopeNew).padEnd(8)} ${d.scopeOld}`, ); } // --- Aggregate: across all losses, how often did legacy win each category? --- console.log('\n=== CATEGORY LOSS FREQUENCY (across all lost matches) ==='); const countOldWon = (arr: MatchDetail[], key: keyof Pick) => arr.filter(d => (d[key] as number) < 0).length; const categories = ['carte', 'denari', 'settebello', 'primiera'] as const; for (const cat of categories) { const oldWins = countOldWon(losses, cat); const newWins = losses.filter(d => (d[cat] as number) > 0).length; const tied = losses.length - oldWins - newWins; console.log(` ${cat.padEnd(12)}: old won ${oldWins}/${losses.length}, new won ${newWins}/${losses.length}, tied ${tied}/${losses.length}`); } const avgScopeGap = losses.reduce((s, d) => s + (d.scopeOld - d.scopeNew), 0) / (losses.length || 1); console.log(` ${'scope gap'.padEnd(12)}: avg old advantage ${avgScopeGap.toFixed(2)} scopa/match`); // --- Same for wins, to compare --- console.log('\n=== CATEGORY WIN FREQUENCY (across all won matches) ==='); for (const cat of categories) { const newWins = wins.filter(d => (d[cat] as number) > 0).length; const oldWins = wins.filter(d => (d[cat] as number) < 0).length; const tied = wins.length - newWins - oldWins; console.log(` ${cat.padEnd(12)}: new won ${newWins}/${wins.length}, old won ${oldWins}/${wins.length}, tied ${tied}/${wins.length}`); } const avgScopeGapW = wins.reduce((s, d) => s + (d.scopeNew - d.scopeOld), 0) / (wins.length || 1); console.log(` ${'scope gap'.padEnd(12)}: avg new advantage ${avgScopeGapW.toFixed(2)} scopa/match`); } main().catch(console.error);