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
214 lines
7.8 KiB
TypeScript
214 lines
7.8 KiB
TypeScript
/**
|
|
* 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<MatchDetail> {
|
|
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<MatchDetail, 'carte'|'denari'|'settebello'|'primiera'>) =>
|
|
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);
|