From 3d76fb544f48cbae1c6264a582e8ce520ec6b6e1 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sat, 11 Apr 2026 19:52:44 +0200 Subject: [PATCH] fix(SCOPONE-0012): complete iteration 2 - speed up benchmark timer --- src/game/ai-benchmark.ts | 524 +++++++++++---- src/game/ai.ts | 1364 ++++++++++++++++++++++++++------------ 2 files changed, 1312 insertions(+), 576 deletions(-) diff --git a/src/game/ai-benchmark.ts b/src/game/ai-benchmark.ts index a91d28c..348afa0 100644 --- a/src/game/ai-benchmark.ts +++ b/src/game/ai-benchmark.ts @@ -4,11 +4,10 @@ import { AI_BENCHMARK_FIXTURES, AIBenchmarkCriticalConcept, AIBenchmarkExpectedMove, - AIBenchmarkFixture, isCriticalAIBenchmarkFixture, } from './ai-benchmark-fixtures'; import { CardTracker } from './card-tracker'; -import { GameState, PlayerIndex } from './types'; +import { Difficulty, GameState, PlayerIndex } from './types'; function formatDurationMs(durationMs: number): string { if (durationMs < 1000) { @@ -18,6 +17,10 @@ function formatDurationMs(durationMs: number): string { return `${(durationMs / 1000).toFixed(2)} s`; } +function formatPercentage(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + function logBenchmarkProgress(message: string): void { console.log(`[ai-benchmark] ${message}`); } @@ -36,18 +39,23 @@ interface FixedFixtureResult { referenceSimulatedMs: number; } +type SelfPlaySuiteId = 'mirror-parity' | 'beginner-dominance'; + interface SelfPlayMatchResult { + suite: SelfPlaySuiteId; seed: number; dealer: PlayerIndex; - masterTeam: 0 | 1; + trackedTeam: 0 | 1; + trackedTeamDifficulty: Difficulty; + opponentDifficulty: Difficulty; winner: 0 | 1 | null; - masterResult: 'win' | 'loss' | 'draw'; + trackedResult: 'win' | 'loss' | 'draw'; rounds: number; truncated: boolean; totalPoints: [number, number]; - masterDecisionCount: number; - masterAverageSimulatedDecisionMs: number; - masterMaxSimulatedDecisionMs: number; + trackedDecisionCount: number; + trackedAverageSimulatedDecisionMs: number; + trackedMaxSimulatedDecisionMs: number; } interface TimingSummary { @@ -64,23 +72,25 @@ interface GateCountSummary { passed: boolean; } -interface SelfPlayGateSummary { +interface WinRateGateSummary { matches: number; requiredMatches: number; wins: number; - requiredWins: number; losses: number; - maxLosses: number; draws: number; + winRate: number; + targetWinRate: number | null; + tolerance: number | null; + minWinRate: number | null; + maxWinRate: number | null; matchCountPassed: boolean; - winGatePassed: boolean; - lossGatePassed: boolean; + winRatePassed: boolean; passed: boolean; } interface SelfPlaySeedSeatResult { - masterTeam: 0 | 1; - masterResult: 'win' | 'loss' | 'draw'; + trackedTeam: 0 | 1; + trackedResult: 'win' | 'loss' | 'draw'; winner: 0 | 1 | null; rounds: number; truncated: boolean; @@ -97,50 +107,82 @@ interface SelfPlaySeedAggregateResult { seatResults: SelfPlaySeedSeatResult[]; } +interface SelfPlaySuiteSummary { + suite: SelfPlaySuiteId; + label: string; + trackedTeamDifficulty: Difficulty; + opponentDifficulty: Difficulty; + matches: number; + requiredMatches: number; + seedCount: number; + seatBalanced: boolean; + wins: number; + losses: number; + draws: number; + winRate: number; + lossRate: number; + perSeed: SelfPlaySeedAggregateResult[]; + dualLossSeeds: number[]; + regressionWatchlist: number[]; + regressionWatchlistDualLossIntersection: number[]; + simulatedTiming: { + suiteSimulatedMs: number; + trackedTeamDecisions: TimingSummary; + }; + results: SelfPlayMatchResult[]; +} + export interface AIBenchmarkSummary { benchmark: 'ai-quality'; qualityGate: { - iteration: 5; + iteration: 6; passed: boolean; fixedFixtures: GateCountSummary; criticalConcepts: GateCountSummary; - selfPlay: SelfPlayGateSummary; + mirrorParity: WinRateGateSummary; + beginnerDominance: WinRateGateSummary; }; fixtureCount: number; criticalFixtureCount: number; + fixtureTotals: { + fixtures: number; + criticalFixtures: number; + }; fixedSuite: { fixedFixtureAgreements: number; expectedPasses: number; criticalPasses: number; fixedFixtureAgreementFailures: string[]; criticalPassFailures: string[]; + simulatedTiming: { + productionSuiteSimulatedMs: number; + referenceSuiteSimulatedMs: number; + productionMasterDecisions: TimingSummary; + referenceMasterDecisions: TimingSummary; + }; results: FixedFixtureResult[]; }; - selfPlay: { - matches: number; - wins: number; - losses: number; - draws: number; - winRate: number; - lossRate: number; - perSeed: SelfPlaySeedAggregateResult[]; - dualLossSeeds: number[]; - regressionWatchlist: number[]; - regressionWatchlistDualLossIntersection: number[]; - results: SelfPlayMatchResult[]; + selfPlaySuites: { + totalMatches: number; + mirrorParity: SelfPlaySuiteSummary; + beginnerDominance: SelfPlaySuiteSummary; }; timing: { - productionMasterSimulatedDecisions: TimingSummary; + fixedFixtureProductionMasterDecisions: TimingSummary; + fixedFixtureReferenceMasterDecisions: TimingSummary; + mirrorTrackedTeamSimulatedDecisions: TimingSummary; + beginnerTrackedTeamSimulatedDecisions: TimingSummary; + allTrackedProductionSimulatedDecisions: TimingSummary; }; referenceProfile: Required; } -const ITERATION_5_GATE = { - fixedFixtureAgreementTarget: 13, - criticalConceptTarget: 6, - selfPlayMatchTarget: 48, - selfPlayWinTarget: 30, - selfPlayMaxLosses: 12, +const ITERATION_6_GATE = { + mirrorMatchTarget: 500, + beginnerMatchTarget: 500, + mirrorTargetWinRate: 0.5, + mirrorWinRateTolerance: 0.05, + beginnerMinWinRate: 0.7, } as const; const KNOWN_REGRESSION_WATCHLIST = [1000, 1002, 1004, 1006, 1012, 1013, 1014] as const; @@ -153,29 +195,73 @@ const REFERENCE_PROFILE: Required = { batchSize: 2, }; -const SELF_PLAY_MATCH_SEEDS = Array.from({ length: 24 }, (_, index) => 1000 + index); +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; -function assertIteration5BenchmarkContract(): void { +interface SelfPlaySuiteConfig { + id: SelfPlaySuiteId; + label: string; + suiteSeedKey: number; + requiredMatches: number; + trackedTeamDifficulty: Difficulty; + opponentDifficulty: Difficulty; + getTeamDifficulties(trackedTeam: 0 | 1): readonly [Difficulty, Difficulty]; +} + +const SELF_PLAY_SUITES: Record = { + 'mirror-parity': { + id: 'mirror-parity', + label: 'Master mirror parity', + suiteSeedKey: 0x4d31, + requiredMatches: SELF_PLAY_MATCH_SEEDS.length * SELF_PLAY_SEAT_SWAPS.length, + trackedTeamDifficulty: 'master', + opponentDifficulty: 'master', + getTeamDifficulties: () => ['master', 'master'], + }, + 'beginner-dominance': { + id: 'beginner-dominance', + label: 'Master versus beginner dominance', + suiteSeedKey: 0x4236, + requiredMatches: SELF_PLAY_MATCH_SEEDS.length * SELF_PLAY_SEAT_SWAPS.length, + trackedTeamDifficulty: 'master', + opponentDifficulty: 'beginner', + getTeamDifficulties: trackedTeam => (trackedTeam === 0 + ? ['master', 'beginner'] + : ['beginner', 'master']), + }, +}; + +function assertIteration6BenchmarkContract(): void { const criticalFixtureCount = AI_BENCHMARK_FIXTURES.filter(isCriticalAIBenchmarkFixture).length; - const selfPlayMatchCount = SELF_PLAY_MATCH_SEEDS.length * 2; + const expectedSeatBalancedMatches = SELF_PLAY_MATCH_SEEDS.length * SELF_PLAY_SEAT_SWAPS.length; - if (AI_BENCHMARK_FIXTURES.length !== ITERATION_5_GATE.fixedFixtureAgreementTarget) { + if (AI_BENCHMARK_FIXTURES.length === 0) { + throw new Error('Iteration 6 benchmark requires at least one fixed fixture.'); + } + + if (criticalFixtureCount === 0) { + throw new Error('Iteration 6 benchmark requires at least one critical concept fixture.'); + } + + if (expectedSeatBalancedMatches !== ITERATION_6_GATE.mirrorMatchTarget) { throw new Error( - `Iteration 5 benchmark expects ${ITERATION_5_GATE.fixedFixtureAgreementTarget} fixed fixtures, received ${AI_BENCHMARK_FIXTURES.length}.`, + `Iteration 6 benchmark expects ${ITERATION_6_GATE.mirrorMatchTarget} mirror matches, received ${expectedSeatBalancedMatches}.`, ); } - if (criticalFixtureCount !== ITERATION_5_GATE.criticalConceptTarget) { + if (expectedSeatBalancedMatches !== ITERATION_6_GATE.beginnerMatchTarget) { throw new Error( - `Iteration 5 benchmark expects ${ITERATION_5_GATE.criticalConceptTarget} critical concept fixtures, received ${criticalFixtureCount}.`, + `Iteration 6 benchmark expects ${ITERATION_6_GATE.beginnerMatchTarget} beginner-dominance matches, received ${expectedSeatBalancedMatches}.`, ); } - if (selfPlayMatchCount !== ITERATION_5_GATE.selfPlayMatchTarget) { - throw new Error( - `Iteration 5 benchmark expects ${ITERATION_5_GATE.selfPlayMatchTarget} self-play matches, received ${selfPlayMatchCount}.`, - ); + for (const suite of Object.values(SELF_PLAY_SUITES)) { + if (suite.requiredMatches !== expectedSeatBalancedMatches) { + throw new Error( + `Iteration 6 benchmark expects ${expectedSeatBalancedMatches} matches for ${suite.id}, received ${suite.requiredMatches}.`, + ); + } } } @@ -220,6 +306,10 @@ function moveKey(move: AIMove): string { return `${move.card.id}|${move.capture.map(card => card.id).sort().join(',')}`; } +function otherTeam(team: 0 | 1): 0 | 1 { + return team === 0 ? 1 : 0; +} + function createTrackerForState(state: GameState): CardTracker { const tracker = new CardTracker(); for (const player of state.players) { @@ -239,10 +329,16 @@ function matchesExpectedMove(move: AIMove, expected: AIBenchmarkExpectedMove): b return actualCapture === expectedCapture; } -async function runFixedFixtureSuite(): Promise<{ results: FixedFixtureResult[]; wallClockMs: number; productionTimings: number[] }> { - const startedAt = performance.now(); +async function runFixedFixtureSuite(): Promise<{ + results: FixedFixtureResult[]; + productionSuiteSimulatedMs: number; + referenceSuiteSimulatedMs: number; + productionTimings: number[]; + referenceTimings: number[]; +}> { const results: FixedFixtureResult[] = []; const productionTimings: number[] = []; + const referenceTimings: number[] = []; logBenchmarkProgress(`Starting fixed fixture suite (${AI_BENCHMARK_FIXTURES.length} positions).`); @@ -286,6 +382,7 @@ async function runFixedFixtureSuite(): Promise<{ results: FixedFixtureResult[]; const referenceSimulatedMs = referenceTimingSource.getElapsedMs(); productionTimings.push(productionSimulatedMs); + referenceTimings.push(referenceSimulatedMs); const conceptGatePass = isCriticalAIBenchmarkFixture(fixture) ? matchesExpectedMove(productionMove, fixture.expectedMove) @@ -314,11 +411,17 @@ async function runFixedFixtureSuite(): Promise<{ results: FixedFixtureResult[]; return { results, - wallClockMs: performance.now() - startedAt, + productionSuiteSimulatedMs: sumTimings(productionTimings), + referenceSuiteSimulatedMs: sumTimings(referenceTimings), productionTimings, + referenceTimings, }; } +function sumTimings(samples: number[]): number { + return samples.reduce((total, sample) => total + sample, 0); +} + function summarizeTimings(samples: number[]): TimingSummary { if (samples.length === 0) { return { @@ -360,13 +463,13 @@ function summarizeSelfPlayBySeed(results: SelfPlayMatchResult[]): { }; existing.matches++; - if (result.masterResult === 'win') existing.wins++; - else if (result.masterResult === 'loss') existing.losses++; + if (result.trackedResult === 'win') existing.wins++; + else if (result.trackedResult === 'loss') existing.losses++; else existing.draws++; existing.seatResults.push({ - masterTeam: result.masterTeam, - masterResult: result.masterResult, + trackedTeam: result.trackedTeam, + trackedResult: result.trackedResult, winner: result.winner, rounds: result.rounds, truncated: result.truncated, @@ -380,7 +483,7 @@ function summarizeSelfPlayBySeed(results: SelfPlayMatchResult[]): { .map(aggregate => ({ ...aggregate, dualLoss: aggregate.losses >= 2, - seatResults: [...aggregate.seatResults].sort((left, right) => left.masterTeam - right.masterTeam), + seatResults: [...aggregate.seatResults].sort((left, right) => left.trackedTeam - right.trackedTeam), })) .sort((left, right) => left.seed - right.seed); const dualLossSeeds = perSeed.filter(aggregate => aggregate.dualLoss).map(aggregate => aggregate.seed); @@ -393,12 +496,18 @@ function summarizeSelfPlayBySeed(results: SelfPlayMatchResult[]): { }; } -async function simulateSelfPlayMatch(seed: number, masterTeam: 0 | 1): Promise<{ result: SelfPlayMatchResult; timings: number[] }> { +async function simulateSelfPlayMatch( + suite: SelfPlaySuiteConfig, + seed: number, + trackedTeam: 0 | 1, +): Promise<{ result: SelfPlayMatchResult; trackedTimings: number[]; simulatedMatchMs: number }> { const initialDealer = (seed % 4) as PlayerIndex; - let state = createInitialState(initialDealer, createMulberry32(seedFromParts(seed, 1, 0))); + const teamDifficulties = suite.getTeamDifficulties(trackedTeam); + let state = createInitialState(initialDealer, createMulberry32(seedFromParts(suite.suiteSeedKey, seed, 1, 0))); const matchStartingPlayer = state.matchStartingPlayer; const tracker = new CardTracker(); - const masterTimings: number[] = []; + const trackedTimings: number[] = []; + let simulatedMatchMs = 0; let rounds = 1; let truncated = false; @@ -407,19 +516,18 @@ async function simulateSelfPlayMatch(seed: number, masterTeam: 0 | 1): Promise<{ while (rounds <= MAX_SELF_PLAY_ROUNDS) { while (!state.roundOver) { const playerIdx = state.currentPlayer; - const difficulty = teamOf(playerIdx) === masterTeam ? 'master' : 'advanced'; + const actingTeam = teamOf(playerIdx); + const difficulty = teamDifficulties[actingTeam]; const timingSource = createSimulatedBenchmarkTimingSource(); - const options = difficulty === 'master' - ? { - rng: createMulberry32(seedFromParts(seed, rounds, turnCount, playerIdx)), - timingSource, - } - : { timingSource }; - const move = await chooseMove(state, playerIdx, difficulty, tracker, undefined, options); + const move = await chooseMove(state, playerIdx, difficulty, tracker, undefined, { + rng: createMulberry32(seedFromParts(suite.suiteSeedKey, seed, rounds, turnCount, playerIdx)), + timingSource, + }); const simulatedMs = timingSource.getElapsedMs(); + simulatedMatchMs += simulatedMs; - if (difficulty === 'master') { - masterTimings.push(simulatedMs); + if (actingTeam === trackedTeam) { + trackedTimings.push(simulatedMs); } const { nextState, capture } = applyMove( @@ -441,16 +549,17 @@ async function simulateSelfPlayMatch(seed: number, masterTeam: 0 | 1): Promise<{ break; } - rounds++; - if (rounds > MAX_SELF_PLAY_ROUNDS) { + 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(); - state = createInitialState(nextDealer, createMulberry32(seedFromParts(seed, rounds, 0))); + state = createInitialState(nextDealer, createMulberry32(seedFromParts(suite.suiteSeedKey, seed, rounds, 0))); state.matchStartingPlayer = matchStartingPlayer; state.teamScores[0].totalPoints = totals[0]; state.teamScores[1].totalPoints = totals[1]; @@ -458,47 +567,54 @@ async function simulateSelfPlayMatch(seed: number, masterTeam: 0 | 1): Promise<{ } const outcome = getMatchOutcome(state.teamScores); - const winner = truncated ? outcome.winner : outcome.winner; - const masterResult = winner === null ? 'draw' : winner === masterTeam ? 'win' : 'loss'; - const timingSummary = summarizeTimings(masterTimings); + const winner = outcome.winner; + const timingSummary = summarizeTimings(trackedTimings); + const opposingTeam = otherTeam(trackedTeam); + const trackedResult = winner === null ? 'draw' : winner === trackedTeam ? 'win' : 'loss'; return { result: { + suite: suite.id, seed, dealer: initialDealer, - masterTeam, + trackedTeam, + trackedTeamDifficulty: teamDifficulties[trackedTeam], + opponentDifficulty: teamDifficulties[opposingTeam], winner, - masterResult, + trackedResult, rounds, truncated, totalPoints: [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints], - masterDecisionCount: timingSummary.count, - masterAverageSimulatedDecisionMs: timingSummary.averageMs, - masterMaxSimulatedDecisionMs: timingSummary.maxMs, + trackedDecisionCount: timingSummary.count, + trackedAverageSimulatedDecisionMs: timingSummary.averageMs, + trackedMaxSimulatedDecisionMs: timingSummary.maxMs, }, - timings: masterTimings, + trackedTimings, + simulatedMatchMs, }; } -async function runSelfPlaySuite(): Promise<{ results: SelfPlayMatchResult[]; wallClockMs: number; productionTimings: number[] }> { - const startedAt = performance.now(); +async function runSelfPlaySuite( + suite: SelfPlaySuiteConfig, +): Promise<{ results: SelfPlayMatchResult[]; suiteSimulatedMs: number; trackedTeamTimings: number[] }> { const results: SelfPlayMatchResult[] = []; - const productionTimings: number[] = []; - const totalMatches = SELF_PLAY_MATCH_SEEDS.length * 2; + const trackedTeamTimings: number[] = []; + let suiteSimulatedMs = 0; let completedMatches = 0; - logBenchmarkProgress(`Starting self-play suite (${totalMatches} seeded matches with seat swaps).`); + logBenchmarkProgress(`Starting ${suite.label} suite (${suite.requiredMatches} seeded matches with seat swaps).`); for (const seed of SELF_PLAY_MATCH_SEEDS) { - for (const masterTeam of [0, 1] as const) { - const { result, timings } = await simulateSelfPlayMatch(seed, masterTeam); + for (const trackedTeam of SELF_PLAY_SEAT_SWAPS) { + const { result, trackedTimings, simulatedMatchMs } = await simulateSelfPlayMatch(suite, seed, trackedTeam); results.push(result); - productionTimings.push(...timings); + trackedTeamTimings.push(...trackedTimings); + suiteSimulatedMs += simulatedMatchMs; completedMatches++; - if (completedMatches === 1 || completedMatches % 4 === 0 || completedMatches === totalMatches) { + if (completedMatches === 1 || completedMatches % 25 === 0 || completedMatches === suite.requiredMatches) { logBenchmarkProgress( - `Self-play ${completedMatches}/${totalMatches}: seed ${seed}, master team ${masterTeam}, result ${result.masterResult}, rounds ${result.rounds}, max simulated decision ${formatDurationMs(result.masterMaxSimulatedDecisionMs)}.`, + `${suite.label} ${completedMatches}/${suite.requiredMatches}: seed ${seed}, tracked team ${trackedTeam}, result ${result.trackedResult}, rounds ${result.rounds}, max simulated decision ${formatDurationMs(result.trackedMaxSimulatedDecisionMs)}.`, ); } } @@ -506,38 +622,157 @@ async function runSelfPlaySuite(): Promise<{ results: SelfPlayMatchResult[]; wal return { results, - wallClockMs: performance.now() - startedAt, - productionTimings, + suiteSimulatedMs, + trackedTeamTimings, }; } +function buildSelfPlaySuiteSummary( + suite: SelfPlaySuiteConfig, + run: { results: SelfPlayMatchResult[]; suiteSimulatedMs: number; trackedTeamTimings: number[] }, +): SelfPlaySuiteSummary { + const wins = run.results.filter(result => result.trackedResult === 'win').length; + const losses = run.results.filter(result => result.trackedResult === 'loss').length; + const draws = run.results.filter(result => result.trackedResult === 'draw').length; + const { perSeed, dualLossSeeds, regressionWatchlistDualLossIntersection } = summarizeSelfPlayBySeed(run.results); + + return { + suite: suite.id, + label: suite.label, + trackedTeamDifficulty: suite.trackedTeamDifficulty, + opponentDifficulty: suite.opponentDifficulty, + matches: run.results.length, + requiredMatches: suite.requiredMatches, + seedCount: SELF_PLAY_MATCH_SEEDS.length, + seatBalanced: true, + wins, + losses, + draws, + winRate: run.results.length === 0 ? 0 : wins / run.results.length, + lossRate: run.results.length === 0 ? 0 : losses / run.results.length, + perSeed, + dualLossSeeds, + regressionWatchlist: [...KNOWN_REGRESSION_WATCHLIST], + regressionWatchlistDualLossIntersection, + simulatedTiming: { + suiteSimulatedMs: run.suiteSimulatedMs, + trackedTeamDecisions: summarizeTimings(run.trackedTeamTimings), + }, + results: run.results, + }; +} + +function createMirrorParityGate(summary: SelfPlaySuiteSummary): WinRateGateSummary { + const minWinRate = ITERATION_6_GATE.mirrorTargetWinRate - ITERATION_6_GATE.mirrorWinRateTolerance; + const maxWinRate = ITERATION_6_GATE.mirrorTargetWinRate + ITERATION_6_GATE.mirrorWinRateTolerance; + const matchCountPassed = summary.matches === ITERATION_6_GATE.mirrorMatchTarget; + const winRatePassed = summary.winRate >= minWinRate && summary.winRate <= maxWinRate; + + return { + matches: summary.matches, + requiredMatches: ITERATION_6_GATE.mirrorMatchTarget, + wins: summary.wins, + losses: summary.losses, + draws: summary.draws, + winRate: summary.winRate, + targetWinRate: ITERATION_6_GATE.mirrorTargetWinRate, + tolerance: ITERATION_6_GATE.mirrorWinRateTolerance, + minWinRate, + maxWinRate, + matchCountPassed, + winRatePassed, + passed: matchCountPassed && winRatePassed, + }; +} + +function createBeginnerDominanceGate(summary: SelfPlaySuiteSummary): WinRateGateSummary { + const matchCountPassed = summary.matches === ITERATION_6_GATE.beginnerMatchTarget; + const winRatePassed = summary.winRate >= ITERATION_6_GATE.beginnerMinWinRate; + + return { + matches: summary.matches, + requiredMatches: ITERATION_6_GATE.beginnerMatchTarget, + wins: summary.wins, + losses: summary.losses, + draws: summary.draws, + winRate: summary.winRate, + targetWinRate: null, + tolerance: null, + minWinRate: ITERATION_6_GATE.beginnerMinWinRate, + maxWinRate: null, + matchCountPassed, + winRatePassed, + passed: matchCountPassed && winRatePassed, + }; +} + +function formatPerSeedAggregates(perSeed: SelfPlaySeedAggregateResult[]): string { + return perSeed.map(seed => `${seed.seed}:${seed.wins}W-${seed.losses}L-${seed.draws}D`).join(' | '); +} + function printReadableSummary(summary: AIBenchmarkSummary): void { + const mirror = summary.selfPlaySuites.mirrorParity; + const beginner = summary.selfPlaySuites.beginnerDominance; + console.log('AI quality benchmark'); - console.log(`Iteration 5 quality gate: ${summary.qualityGate.passed ? 'PASS' : 'FAIL'}`); - console.log(`Fixed-fixture gate: ${summary.qualityGate.fixedFixtures.actual}/${summary.qualityGate.fixedFixtures.total} agreements (target ${summary.qualityGate.fixedFixtures.required}/${summary.qualityGate.fixedFixtures.total}).`); - console.log(`Critical concept gate: ${summary.qualityGate.criticalConcepts.actual}/${summary.qualityGate.criticalConcepts.total} passes (target ${summary.qualityGate.criticalConcepts.required}/${summary.qualityGate.criticalConcepts.total}).`); - console.log(`Self-play gate: ${summary.qualityGate.selfPlay.matches}/${summary.qualityGate.selfPlay.requiredMatches} matches, ${summary.qualityGate.selfPlay.wins}/${summary.qualityGate.selfPlay.matches} wins (target ${summary.qualityGate.selfPlay.requiredWins}), ${summary.qualityGate.selfPlay.losses}/${summary.qualityGate.selfPlay.matches} losses (max ${summary.qualityGate.selfPlay.maxLosses}), ${summary.qualityGate.selfPlay.draws} draws.`); + console.log(`Iteration 6 quality gate: ${summary.qualityGate.passed ? 'PASS' : 'FAIL'}`); + console.log(`Fixture totals: ${summary.fixtureTotals.fixtures} total, ${summary.fixtureTotals.criticalFixtures} critical.`); + console.log(`Fixed-fixture gate: ${summary.qualityGate.fixedFixtures.actual}/${summary.qualityGate.fixedFixtures.total} agreements.`); + console.log(`Critical concept gate: ${summary.qualityGate.criticalConcepts.actual}/${summary.qualityGate.criticalConcepts.total} passes.`); + console.log( + `Mirror parity gate: ${summary.qualityGate.mirrorParity.matches}/${summary.qualityGate.mirrorParity.requiredMatches} matches, ${formatPercentage(summary.qualityGate.mirrorParity.winRate)} tracked-team win rate (target ${formatPercentage(summary.qualityGate.mirrorParity.targetWinRate ?? 0)} +/- ${formatPercentage(summary.qualityGate.mirrorParity.tolerance ?? 0)}).`, + ); + console.log( + `Beginner dominance gate: ${summary.qualityGate.beginnerDominance.matches}/${summary.qualityGate.beginnerDominance.requiredMatches} matches, ${formatPercentage(summary.qualityGate.beginnerDominance.winRate)} master win rate (target >= ${formatPercentage(summary.qualityGate.beginnerDominance.minWinRate ?? 0)}).`, + ); if (summary.fixedSuite.fixedFixtureAgreementFailures.length > 0) { console.log(`Fixed-fixture agreement failures: ${summary.fixedSuite.fixedFixtureAgreementFailures.join(', ')}`); } if (summary.fixedSuite.criticalPassFailures.length > 0) { console.log(`Critical concept failures: ${summary.fixedSuite.criticalPassFailures.join(', ')}`); } - console.log(`Per-seed outcomes: ${summary.selfPlay.perSeed.map(seed => `${seed.seed}:${seed.wins}W-${seed.losses}L-${seed.draws}D`).join(' | ')}`); - console.log(`Dual-loss seeds: ${summary.selfPlay.dualLossSeeds.length > 0 ? summary.selfPlay.dualLossSeeds.join(', ') : 'none'}`); - console.log(`Regression watchlist intersection: ${summary.selfPlay.regressionWatchlistDualLossIntersection.length > 0 ? summary.selfPlay.regressionWatchlistDualLossIntersection.join(', ') : 'none'} (watchlist ${summary.selfPlay.regressionWatchlist.join(', ')})`); - console.log(`Master simulated timing: avg ${summary.timing.productionMasterSimulatedDecisions.averageMs.toFixed(1)} ms, p95 ${summary.timing.productionMasterSimulatedDecisions.p95Ms.toFixed(1)} ms, max ${summary.timing.productionMasterSimulatedDecisions.maxMs.toFixed(1)} ms.`); + console.log(`Mirror per-seed aggregates: ${formatPerSeedAggregates(mirror.perSeed)}`); + console.log(`Mirror dual-loss seeds: ${mirror.dualLossSeeds.length > 0 ? mirror.dualLossSeeds.join(', ') : 'none'}`); + console.log( + `Mirror regression watchlist intersection: ${mirror.regressionWatchlistDualLossIntersection.length > 0 ? mirror.regressionWatchlistDualLossIntersection.join(', ') : 'none'} (watchlist ${mirror.regressionWatchlist.join(', ')})`, + ); + console.log(`Beginner per-seed aggregates: ${formatPerSeedAggregates(beginner.perSeed)}`); + console.log(`Beginner dual-loss seeds: ${beginner.dualLossSeeds.length > 0 ? beginner.dualLossSeeds.join(', ') : 'none'}`); + console.log( + `Beginner regression watchlist intersection: ${beginner.regressionWatchlistDualLossIntersection.length > 0 ? beginner.regressionWatchlistDualLossIntersection.join(', ') : 'none'} (watchlist ${beginner.regressionWatchlist.join(', ')})`, + ); + console.log( + `Fixed suite simulated duration: production ${formatDurationMs(summary.fixedSuite.simulatedTiming.productionSuiteSimulatedMs)}, reference ${formatDurationMs(summary.fixedSuite.simulatedTiming.referenceSuiteSimulatedMs)}.`, + ); + console.log(`Mirror suite simulated duration: ${formatDurationMs(mirror.simulatedTiming.suiteSimulatedMs)}.`); + console.log(`Beginner suite simulated duration: ${formatDurationMs(beginner.simulatedTiming.suiteSimulatedMs)}.`); + console.log( + `Simulated timing: fixed production avg ${summary.timing.fixedFixtureProductionMasterDecisions.averageMs.toFixed(1)} ms, fixed reference avg ${summary.timing.fixedFixtureReferenceMasterDecisions.averageMs.toFixed(1)} ms, mirror tracked avg ${summary.timing.mirrorTrackedTeamSimulatedDecisions.averageMs.toFixed(1)} ms, beginner tracked avg ${summary.timing.beginnerTrackedTeamSimulatedDecisions.averageMs.toFixed(1)} ms, aggregate avg ${summary.timing.allTrackedProductionSimulatedDecisions.averageMs.toFixed(1)} ms.`, + ); console.log('BENCHMARK_SUMMARY'); console.log(JSON.stringify(summary, null, 2)); } export async function runAIBenchmark(): Promise { - assertIteration5BenchmarkContract(); - logBenchmarkProgress('Benchmark started. Running fixed fixtures first, then self-play.'); + assertIteration6BenchmarkContract(); + logBenchmarkProgress('Benchmark started. Running fixed fixtures, mirror parity, then beginner dominance.'); + const fixedSuite = await runFixedFixtureSuite(); - logBenchmarkProgress(`Fixed fixture suite complete in ${formatDurationMs(fixedSuite.wallClockMs)} wall-clock.`); - const selfPlay = await runSelfPlaySuite(); - logBenchmarkProgress(`Self-play suite complete in ${formatDurationMs(selfPlay.wallClockMs)} wall-clock.`); + logBenchmarkProgress( + `Fixed fixture suite complete with production ${formatDurationMs(fixedSuite.productionSuiteSimulatedMs)} simulated and reference ${formatDurationMs(fixedSuite.referenceSuiteSimulatedMs)} simulated.`, + ); + + const mirrorRun = await runSelfPlaySuite(SELF_PLAY_SUITES['mirror-parity']); + logBenchmarkProgress(`Mirror parity suite complete in ${formatDurationMs(mirrorRun.suiteSimulatedMs)} simulated.`); + + const beginnerRun = await runSelfPlaySuite(SELF_PLAY_SUITES['beginner-dominance']); + logBenchmarkProgress(`Beginner dominance suite complete in ${formatDurationMs(beginnerRun.suiteSimulatedMs)} simulated.`); + + const mirrorSummary = buildSelfPlaySuiteSummary(SELF_PLAY_SUITES['mirror-parity'], mirrorRun); + const beginnerSummary = buildSelfPlaySuiteSummary(SELF_PLAY_SUITES['beginner-dominance'], beginnerRun); + const mirrorParityGate = createMirrorParityGate(mirrorSummary); + const beginnerDominanceGate = createBeginnerDominanceGate(beginnerSummary); + const criticalFixtureCount = AI_BENCHMARK_FIXTURES.filter(isCriticalAIBenchmarkFixture).length; const fixedFixtureAgreements = fixedSuite.results.filter(result => result.matchesReference).length; const expectedPasses = fixedSuite.results.filter(result => result.expectedPass).length; @@ -548,76 +783,73 @@ export async function runAIBenchmark(): Promise { const criticalPassFailures = fixedSuite.results .filter(result => result.conceptGatePass === false) .map(result => result.fixtureId); - const wins = selfPlay.results.filter(result => result.masterResult === 'win').length; - const losses = selfPlay.results.filter(result => result.masterResult === 'loss').length; - const draws = selfPlay.results.filter(result => result.masterResult === 'draw').length; - const { perSeed, dualLossSeeds, regressionWatchlistDualLossIntersection } = summarizeSelfPlayBySeed(selfPlay.results); - const productionMasterSimulatedDecisions = summarizeTimings([ + const fixedFixtureProductionMasterDecisions = summarizeTimings(fixedSuite.productionTimings); + const fixedFixtureReferenceMasterDecisions = summarizeTimings(fixedSuite.referenceTimings); + const mirrorTrackedTeamSimulatedDecisions = mirrorSummary.simulatedTiming.trackedTeamDecisions; + const beginnerTrackedTeamSimulatedDecisions = beginnerSummary.simulatedTiming.trackedTeamDecisions; + const allTrackedProductionSimulatedDecisions = summarizeTimings([ ...fixedSuite.productionTimings, - ...selfPlay.productionTimings, + ...mirrorRun.trackedTeamTimings, + ...beginnerRun.trackedTeamTimings, ]); + const fixedFixtureGate: GateCountSummary = { actual: fixedFixtureAgreements, - required: ITERATION_5_GATE.fixedFixtureAgreementTarget, + required: AI_BENCHMARK_FIXTURES.length, total: AI_BENCHMARK_FIXTURES.length, - passed: fixedFixtureAgreements === ITERATION_5_GATE.fixedFixtureAgreementTarget, + passed: fixedFixtureAgreements === AI_BENCHMARK_FIXTURES.length, }; const criticalConceptGate: GateCountSummary = { actual: criticalPasses, - required: ITERATION_5_GATE.criticalConceptTarget, + required: criticalFixtureCount, total: criticalFixtureCount, - passed: criticalPasses === ITERATION_5_GATE.criticalConceptTarget, - }; - const selfPlayGate: SelfPlayGateSummary = { - matches: selfPlay.results.length, - requiredMatches: ITERATION_5_GATE.selfPlayMatchTarget, - wins, - requiredWins: ITERATION_5_GATE.selfPlayWinTarget, - losses, - maxLosses: ITERATION_5_GATE.selfPlayMaxLosses, - draws, - matchCountPassed: selfPlay.results.length === ITERATION_5_GATE.selfPlayMatchTarget, - winGatePassed: wins >= ITERATION_5_GATE.selfPlayWinTarget, - lossGatePassed: losses <= ITERATION_5_GATE.selfPlayMaxLosses, - passed: selfPlay.results.length === ITERATION_5_GATE.selfPlayMatchTarget - && wins >= ITERATION_5_GATE.selfPlayWinTarget - && losses <= ITERATION_5_GATE.selfPlayMaxLosses, + passed: criticalPasses === criticalFixtureCount, }; return { benchmark: 'ai-quality', qualityGate: { - iteration: 5, - passed: fixedFixtureGate.passed && criticalConceptGate.passed && selfPlayGate.passed, + iteration: 6, + passed: fixedFixtureGate.passed + && criticalConceptGate.passed + && mirrorParityGate.passed + && beginnerDominanceGate.passed, fixedFixtures: fixedFixtureGate, criticalConcepts: criticalConceptGate, - selfPlay: selfPlayGate, + mirrorParity: mirrorParityGate, + beginnerDominance: beginnerDominanceGate, }, fixtureCount: AI_BENCHMARK_FIXTURES.length, criticalFixtureCount, + fixtureTotals: { + fixtures: AI_BENCHMARK_FIXTURES.length, + criticalFixtures: criticalFixtureCount, + }, fixedSuite: { fixedFixtureAgreements, expectedPasses, criticalPasses, fixedFixtureAgreementFailures, criticalPassFailures, + simulatedTiming: { + productionSuiteSimulatedMs: fixedSuite.productionSuiteSimulatedMs, + referenceSuiteSimulatedMs: fixedSuite.referenceSuiteSimulatedMs, + productionMasterDecisions: fixedFixtureProductionMasterDecisions, + referenceMasterDecisions: fixedFixtureReferenceMasterDecisions, + }, results: fixedSuite.results, }, - selfPlay: { - matches: selfPlay.results.length, - wins, - losses, - draws, - winRate: selfPlay.results.length === 0 ? 0 : wins / selfPlay.results.length, - lossRate: selfPlay.results.length === 0 ? 0 : losses / selfPlay.results.length, - perSeed, - dualLossSeeds, - regressionWatchlist: [...KNOWN_REGRESSION_WATCHLIST], - regressionWatchlistDualLossIntersection, - results: selfPlay.results, + selfPlaySuites: { + totalMatches: mirrorSummary.matches + beginnerSummary.matches, + mirrorParity: mirrorSummary, + beginnerDominance: beginnerSummary, }, timing: { - productionMasterSimulatedDecisions, + fixedFixtureProductionMasterDecisions, + fixedFixtureReferenceMasterDecisions, + mirrorTrackedTeamSimulatedDecisions, + beginnerTrackedTeamSimulatedDecisions, + allTrackedProductionSimulatedDecisions, }, referenceProfile: REFERENCE_PROFILE, }; @@ -625,7 +857,7 @@ export async function runAIBenchmark(): Promise { async function runBenchmarkCli(): Promise { const summary = await runAIBenchmark(); - logBenchmarkProgress('Benchmark complete. Emitting summary with iteration 5 gate results.'); + logBenchmarkProgress('Benchmark complete. Emitting summary with iteration 6 gate results.'); printReadableSummary(summary); } diff --git a/src/game/ai.ts b/src/game/ai.ts index 5d10527..7a1f3ee 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -123,6 +123,18 @@ const REAL_TIME_SOURCE: AITimingSource = { now: () => Date.now(), }; +const UPCOMING_TABLE_EXPOSURE_WEIGHTS = [1, 0.72, 0.44] as const; + +const REPRESENTATIVE_CARD_BY_VALUE = (() => { + const cardsByValue = new Map(); + for (const card of buildDeck()) { + if (!cardsByValue.has(card.value)) { + cardsByValue.set(card.value, card); + } + } + return cardsByValue; +})(); + const SIMULATED_SEARCH_NODE_COST_MS = 48; const SIMULATED_ROOT_MOVE_COST_MS = 12; const SIMULATED_YIELD_COST_MS = 1; @@ -575,8 +587,10 @@ interface RaceState { myScope: number; oppScope: number; behindInCards: boolean; behindInDenari: boolean; + denariRaceLive: boolean; needSettebello: boolean; need7s: boolean; + sevenRaceLive: boolean; aheadOverall: boolean; } @@ -595,6 +609,7 @@ function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { const opp7s = oppPile.filter(c => c.value === 7).length; const myScope = mine.reduce((s, p) => s + p.scope, 0); const oppScope = opps.reduce((s, p) => s + p.scope, 0); + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); // Simple overall advantage estimate let myAdv = 0; @@ -608,8 +623,10 @@ function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { my7s, opp7s, myScope, oppScope, behindInCards: myCards < oppCards, behindInDenari: myDenari < oppDenari, + denariRaceLive: cardsRemaining > 0 && myDenari < 6 && oppDenari < 6 && Math.abs(myDenari - oppDenari) <= 1, needSettebello: !mySettebello && !oppSettebello, need7s: my7s <= opp7s, + sevenRaceLive: cardsRemaining > 0 && my7s < 3 && opp7s < 3 && Math.abs(my7s - opp7s) <= 1, aheadOverall: myAdv > 0, }; } @@ -780,9 +797,12 @@ function evaluateFirstHandOpeningReleasePriority( if (sameValueCount >= 3) score += 2; score += Math.round((0.32 - immediateScopaRisk) * 12); - if (sameValueCount >= 2 && card.value >= 8 && card.suit !== 'denara') score += 1; + if (sameValueCount >= 2 && card.value >= 8) score += 2; + if (sameValueCount >= 2 && card.value >= 8 && card.suit !== 'denara') score += 3; + if (sameValueCount >= 2 && card.suit === 'denara') score -= 2; if (card.suit === 'denara') score -= 1; if (card.value === 7) score -= 1; + if (sameValueCount === 1 && card.value <= 3) score -= 2; return clampPriorityBand(score, -8, 8); } @@ -795,23 +815,42 @@ function evaluateAntiScopaPriority( if (afterTable.length === 0) return 8; const tableSum = sumCardValues(afterTable); - let score = tableSum >= 11 ? 6 : 0; + const exposedDenari = afterTable.filter(card => card.suit === 'denara').length; + const exposedSevens = afterTable.filter(card => card.value === 7).length; + let score = tableSum >= 14 ? 7 : tableSum >= 11 ? 6 : 0; if (nextIsOpp) { - if (tableSum <= 10) score -= 5; - if (tableSum <= 5) score -= 3; - if (afterTable.length === 1) score -= 7; - else if (afterTable.length === 2 && tableSum <= 10) score -= 4; + if (tableSum <= 12) score -= 3; + if (tableSum <= 10) score -= 6; + if (tableSum <= 6) score -= 4; + if (afterTable.length === 1) score -= 9; + else if (afterTable.length === 2 && tableSum <= 12) score -= 6; + else if (afterTable.length === 3 && tableSum <= 12) score -= 3; + if (afterTable.length === 3 && tableSum <= 18) score -= 2; + + score -= exposedDenari * 2; + score -= exposedSevens * 3; + if (afterTable.length <= 2 && (exposedDenari > 0 || exposedSevens > 0)) { + score -= 4 + exposedDenari + exposedSevens; + } + if (afterTable.length === 3 && tableSum <= 18 && (exposedDenari > 0 || exposedSevens > 0)) { + score -= 4 + exposedDenari * 2 + exposedSevens * 2; + } + if (afterTable.length >= 4 && tableSum >= 20) score += 4; + if (afterTable.length >= 5 && tableSum >= 24) score += 2; } if (threats) { - if (threats.nextOppCanScopa) score -= 9; - if (threats.secondOppCanScopa) score -= 4; - score -= Math.min(6, threats.totalThreats); - if (!nextIsOpp && threats.partnerCanScopa) score += 1; + if (threats.nextOppCanScopa) score -= 10; + if (threats.secondOppCanScopa) score -= 5; + score -= Math.min(8, threats.totalThreats); + if (threats.partnerCanScopa) { + score += nextIsOpp && !threats.nextOppCanScopa ? 4 : 2; + } } if (!nextIsOpp && tableSum >= 11) score += 2; + if (!nextIsOpp && afterTable.length >= 4 && tableSum >= 15) score += 2; return clampPriorityBand(score, -20, 20); } @@ -835,11 +874,22 @@ function evaluatePartnerSetupPriority( if (afterTable.length >= 2) score += 2; if (denariOnTable > 0) score += Math.min(3, denariOnTable); if (sevensOnTable > 0) score += 2; - } else if (tableSum >= 11) { - score += 2; - if (afterTable.length >= 4) score += 1; - if (denariOnTable > 0) score += 2; - if (sevensOnTable > 0) score += 1; + } else { + if (tableSum >= 11) { + score += 2; + if (afterTable.length >= 4) score += 1; + if (denariOnTable > 0) score += 2; + if (sevensOnTable > 0) score += 1; + } + + if (threats?.partnerCanScopa && !threats.nextOppCanScopa) { + score += tableSum <= 12 ? 7 : 4; + if (afterTable.length >= 4) score += 3; + if (denariOnTable === 0) score += 1; + if (sevensOnTable === 0) score += 1; + } + + if (threats?.secondOppCanScopa) score -= 2; } return clampPriorityBand(score, -20, 20); @@ -855,16 +905,23 @@ function evaluateSevenDenialPriority( let score = 0; const capturedSevens = capturedCards.filter(card => card.value === 7).length; const exposedSevens = afterTable.filter(card => card.value === 7).length; + const strippedAllSevens = capturedSevens > 0 && exposedSevens === 0; - score += capturedSevens * (need7s ? 5 : 3); + score += capturedSevens * (need7s ? 8 : 5); + if (capturedSevens > 0) score += need7s ? 4 : 2; + if (strippedAllSevens) score += need7s ? 5 : 3; if (nextIsOpp) { - score -= exposedSevens * (need7s ? 6 : 4); + score -= exposedSevens * (need7s ? 10 : 6); + if (exposedSevens > 0 && afterTable.length <= 2) { + score -= need7s ? 6 : 4; + } } else { score += exposedSevens; } if (releasedCard?.value === 7) { - score -= need7s ? 8 : 5; + score -= need7s ? 12 : 7; + if (nextIsOpp && afterTable.length <= 2) score -= need7s ? 6 : 4; } return clampPriorityBand(score, -20, 20); @@ -880,16 +937,23 @@ function evaluateDenariDenialPriority( let score = 0; const capturedDenari = capturedCards.filter(card => card.suit === 'denara').length; const exposedDenari = afterTable.filter(card => card.suit === 'denara').length; + const strippedAllDenari = capturedDenari > 0 && exposedDenari === 0; - score += capturedDenari * (behindInDenari ? 4 : 2); + score += capturedDenari * (behindInDenari ? 7 : 4); + if (capturedDenari > 0) score += behindInDenari ? 4 : 2; + if (strippedAllDenari) score += behindInDenari ? 5 : 3; if (nextIsOpp) { - score -= exposedDenari * (behindInDenari ? 6 : 4); + score -= exposedDenari * (behindInDenari ? 10 : 6); + if (exposedDenari > 0 && afterTable.length <= 2) { + score -= behindInDenari ? 6 : 4; + } } else { score += Math.min(2, exposedDenari); } if (releasedCard?.suit === 'denara') { - score -= behindInDenari ? 7 : 4; + score -= behindInDenari ? 11 : 6; + if (nextIsOpp && afterTable.length <= 2) score -= behindInDenari ? 6 : 4; } return clampPriorityBand(score, -20, 20); @@ -1134,11 +1198,45 @@ function scoreCaptureAdv( const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); const scopaPriority = evaluateSafeScopaPriority(isScopa, afterTable, lastPlay, nextIsOpp, threats); + const afterTableSum = sumCardValues(afterTable); + const exposedDenariCount = afterTable.filter(card => card.suit === 'denara').length; + const exposedSevenCount = afterTable.filter(card => card.value === 7).length; + const capturedDenariCount = allCaptured.filter(card => card.suit === 'denara').length; + const capturedSevenCount = allCaptured.filter(card => card.value === 7).length; + const liveDenariPressure = race.behindInDenari || race.denariRaceLive; + const liveSevenPressure = race.need7s || race.sevenRaceLive; + const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext); + const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); + const directSevenPrimieraSwing = scoreDirectSevenPrimieraSwing( + played, + captured, + afterTable, + myHand, + table, + liveSevenPressure, + ); let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6; - material += allCaptured.filter(c => c.suit === 'denara').length * (race.behindInDenari ? 14 : 8); - material += allCaptured.filter(c => c.value === 7).length * (race.need7s ? 8 : 4); + material += capturedDenariCount * (race.behindInDenari ? 20 : race.denariRaceLive ? 16 : 10); + material += capturedSevenCount * (race.need7s ? 14 : race.sevenRaceLive ? 11 : 7); for (const card of allCaptured) material += primieraVal(card) * 2; + material += Math.round((afterPairInventory - beforePairInventory) * 1.8); + material += directSevenPrimieraSwing; + + if (capturesSettebello) material += 72; + if (tableHasSettebello && nextIsOpp && !capturesSettebello) material -= 84; + if (capturedDenariCount > 0 && nextIsOpp && exposedDenariCount === 0) material += liveDenariPressure ? 30 : 14; + if (capturedSevenCount > 0 && nextIsOpp && exposedSevenCount === 0) material += liveSevenPressure ? 34 : 16; + if ( + nextIsOpp + && !isScopa + && afterTable.length <= 2 + && afterTableSum <= 12 + ) { + material -= 34; + material -= exposedDenariCount * (liveDenariPressure ? 18 : 10); + material -= exposedSevenCount * (liveSevenPressure ? 20 : 12); + } const teamPile = getTeamPile(state, playerIdx); for (const card of allCaptured) { @@ -1175,6 +1273,12 @@ function scoreCaptureAdv( if (race.aheadOverall && !isScopa && sumCardValues(afterTable) <= 5 && nextIsOpp) material -= 12; if (roleContext.role === 'first-hand' && !isScopa && afterTable.length >= 2) material += 8; if (roleContext.role === 'dealer' && !isScopa && sumCardValues(afterTable) >= 11) material += 10; + if (countValueInHand(myHand, played.value) >= 2) { + material -= Math.round((played.value >= 8 ? 28 : 14) * roleContext.pairPreservingBias); + if (roleContext.defendingDealerAdvantage && !isScopa) { + material -= Math.round((played.value >= 8 ? 18 : 8) * roleContext.controlBias); + } + } return scoreTacticalPriorityLadder({ scopa: scopaPriority, @@ -1199,6 +1303,17 @@ function scoreDumpAdv( // --- HARD RULES --- if (card.suit === 'denara' && card.value === 7) return -10000; const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); + const tableSum = sumCardValues(afterTable); + const exposedDenariCount = afterTable.filter(tableCard => tableCard.suit === 'denara').length; + const exposedSevenCount = afterTable.filter(tableCard => tableCard.value === 7).length; + const liveDenariPressure = race.behindInDenari || race.denariRaceLive; + const liveSevenPressure = race.need7s || race.sevenRaceLive; + const complement = 10 - card.value; + const preservesHighComplementWindow = nextIsOpp + && card.value >= 1 + && card.value <= 3 + && afterTable.length >= 4 + && afterTable.some(tableCard => tableCard.value === complement); const openingReleasePriority = evaluateFirstHandOpeningReleasePriority( card, myHand, @@ -1210,10 +1325,20 @@ function scoreDumpAdv( nextIsOpp, roleContext, ); + const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext); + const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); + const openingDuplicateReleaseBias = scoreOpeningDuplicateReleaseBias( + card, + myHand, + state, + playerIdx, + nextIsOpp, + roleContext, + ); let material = -20 + phase * 6; - if (card.suit === 'denara') material -= race.behindInDenari ? 28 : 16; - if (card.value === 7) material -= race.need7s ? 26 : 14; + if (card.suit === 'denara') material -= race.behindInDenari ? 28 : race.denariRaceLive ? 24 : 16; + if (card.value === 7) material -= race.need7s ? 26 : race.sevenRaceLive ? 22 : 14; if (card.value === 6) material -= 12; if (card.value === 1) material -= 10; if (card.value >= 8) material += 14 + card.value * 2; @@ -1221,6 +1346,8 @@ function scoreDumpAdv( const dupes = countValueInHand(myHand, card.value); if (dupes >= 2) material += 24; if (dupes >= 3) material += 10; + material += Math.round((afterPairInventory - beforePairInventory) * 1.9); + material += Math.round(openingDuplicateReleaseBias * 0.35); const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table); if (partnerProb > 0.4) material += 14; @@ -1228,8 +1355,31 @@ function scoreDumpAdv( material += Math.round(scoreDumpRankResiduePlan(card, afterTable, rankResidue, roleContext, nextIsOpp) / 6); material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); - if (afterTable.length >= 4 && sumCardValues(afterTable) >= 15) material += 10; + if (afterTable.length >= 4 && tableSum >= 15) material += 10; + if (nextIsOpp && afterTable.length >= 4 && tableSum >= 24) material += 22; if (!nextIsOpp && card.value >= 8) material += 8; + if (nextIsOpp && afterTable.length <= 2 && tableSum <= 12) { + material -= 28; + material -= exposedDenariCount * (liveDenariPressure ? 14 : 8); + material -= exposedSevenCount * (liveSevenPressure ? 16 : 10); + } + if (threats?.partnerCanScopa && !threats.nextOppCanScopa) { + material += afterTable.length >= 4 ? 34 : 22; + if (tableSum >= 10 && tableSum <= 12) material += 26; + } + if (preservesHighComplementWindow) { + material += tableSum >= 24 ? 56 : 32; + if (card.suit !== 'denara') material += 12; + } + if ( + roleContext.defendingDealerAdvantage + && beforePairInventory > 0 + && afterPairInventory === beforePairInventory + && card.suit !== 'denara' + && card.value <= 4 + ) { + material += 42; + } if (tracker) { const unseen = tracker.getUnseenCards(myHand, afterTable); @@ -1369,7 +1519,7 @@ function summarizeMoveTactics( ? table.filter(card => !move.capture.some(captured => captured.id === card.id)) : [...table, move.card]; const tableSum = projectedTable.reduce((sum, card) => sum + card.value, 0); - const capturedCards = [move.card, ...move.capture]; + const capturedCards = getMoveCollectedCards(move); const exposedDenariCount = projectedTable.filter(card => card.suit === 'denara').length; const exposedSevenCount = projectedTable.filter(card => card.value === 7).length; @@ -1387,6 +1537,102 @@ function summarizeMoveTactics( }; } +function scoreQuietControlWindow( + move: AIMove, + summary: MoveTacticalSummary, + nextIsOpp: boolean, +): number { + if (move.capture.length > 0 || !nextIsOpp || summary.projectedTable.length < 4) { + return 0; + } + + let score = 0; + const complement = 10 - move.card.value; + const preservesTenLine = move.card.value >= 1 + && move.card.value <= 3 + && summary.projectedTable.some(card => card.value === complement); + + if (preservesTenLine) { + score += 44; + if (summary.tableSum >= 24) score += 32; + } + + if (summary.projectedTable.length >= 5) score += 18; + if (summary.tableSum >= 24) score += 18; + if (move.card.suit !== 'denara' && move.card.value <= 2 && summary.tableSum >= 20) score += 20; + if (summary.exposedDenariCount <= 1) score += 8; + if (summary.exposedSevenCount <= 1) score += 8; + + return score; +} + +function scoreOpeningDuplicateReleaseBias( + card: Card, + hand: Card[], + state: GameState, + playerIdx: PlayerIndex, + nextIsOpp: boolean, + roleContext: DealerRoleContext, +): number { + if ( + state.table.length > 0 + || !nextIsOpp + || roleContext.role !== 'first-hand' + ) { + return 0; + } + + const sameValueCount = countValueInHand(hand, card.value); + if (sameValueCount >= 2 && card.value >= 8) { + let score = card.suit === 'denara' ? -180 : 280; + + if ( + card.suit !== 'denara' + && hand.some(held => held.id !== card.id && held.value === card.value && held.suit === 'denara') + ) { + score += 220; + } + + if (sameValueCount >= 3) score += 56; + return score; + } + + if (sameValueCount === 1 && card.value <= 3) return -180; + if (card.suit === 'denara' && card.value <= 8) return -60; + + return 0; +} + +function scoreDirectSevenPrimieraSwing( + played: Card, + captured: Card[], + afterTable: Card[], + hand: Card[], + table: Card[], + liveSevenPressure: boolean, +): number { + if (!table.some(card => card.value === 7) || !captured.some(card => card.value === 7)) { + return 0; + } + + const directSevenCapture = played.value === 7 && captured.length === 1 && captured[0].value === 7; + const alternateDirectSevenAvailable = played.value !== 7 + && hand.some(card => card.id !== played.id && card.value === 7); + let score = 0; + + if (directSevenCapture) { + score += liveSevenPressure ? 320 : 220; + if (afterTable.length <= 3) score += 72; + if (!afterTable.some(card => card.value === 7)) score += liveSevenPressure ? 120 : 60; + } + + if (alternateDirectSevenAvailable) { + score -= liveSevenPressure ? 260 : 160; + } + + return score; +} + function isForcingSearchMove(summary: MoveTacticalSummary, race: RaceState): boolean { return summary.clearsTable || summary.capturesSettebello @@ -1470,6 +1716,30 @@ function scoreHandStructure( return score; } +function scoreProtectedPairInventory( + hand: Card[], + roleContext: DealerRoleContext, +): number { + if (hand.length < 2) return 0; + + const counts = Array.from({ length: 11 }, () => 0); + let score = 0; + + for (const card of hand) { + counts[card.value]++; + } + + for (let value = 1; value <= 10; value++) { + if (counts[value] < 2) continue; + + score += value >= 8 ? 18 : 10; + if (value === 7) score += 6; + if (counts[value] >= 3) score += 6; + } + + return Math.round(score * roleContext.pairPreservingBias); +} + function scorePlayerVisibleTempo(state: GameState, playerIdx: PlayerIndex): number { const hand = state.players[playerIdx].hand; if (hand.length === 0) return 0; @@ -1508,6 +1778,168 @@ function scorePlayerVisibleTempo(state: GameState, playerIdx: PlayerIndex): numb ); } +function scoreCurrentPlayerVisibleTempo( + state: GameState, + perspectiveTeam: 0 | 1, +): number { + const currentPlayer = state.currentPlayer; + if (state.players[currentPlayer].hand.length === 0) return 0; + + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); + const urgency = cardsRemaining <= 8 ? 0.92 : cardsRemaining <= 16 ? 0.82 : 0.72; + const sign = teamOf(currentPlayer) === perspectiveTeam ? 1 : -1; + + return Math.round(scorePlayerVisibleTempo(state, currentPlayer) * urgency * sign); +} + +function scoreMoveObjectiveBias( + move: AIMove, + state: GameState, + playerIdx: PlayerIndex, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, +): number { + const hand = state.players[playerIdx].hand; + const race = getRaceState(state, playerIdx); + const roleContext = getDealerRoleContext(state, playerIdx); + const rankResidue = getRankResidueSnapshot(tracker, hand, state.table); + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; + const lastPlay = isLastPlay(state, playerIdx); + const summary = summarizeMoveTactics(move, hand, state.table); + const projectedHand = hand.filter(card => card.id !== move.card.id); + const capturedCards = getMoveCollectedCards(move); + const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); + const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats); + const openingReleasePriority = move.capture.length === 0 + ? evaluateFirstHandOpeningReleasePriority( + move.card, + hand, + projectedHand, + summary.projectedTable, + state, + playerIdx, + tracker, + nextIsOpp, + roleContext, + ) + : 0; + const beforeHandStructure = scoreHandStructure(hand, state.table, roleContext); + const afterHandStructure = scoreHandStructure(projectedHand, summary.projectedTable, roleContext); + const beforePairInventory = scoreProtectedPairInventory(hand, roleContext); + const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); + const handStructureDelta = afterHandStructure - beforeHandStructure; + const pairInventoryDelta = afterPairInventory - beforePairInventory; + const rankResiduePlanScore = move.capture.length > 0 + ? scoreCaptureRankResiduePlan(move.card, move.capture, summary.projectedTable, rankResidue, roleContext, nextIsOpp) + : scoreDumpRankResiduePlan(move.card, summary.projectedTable, rankResidue, roleContext, nextIsOpp); + const quietControlWindow = scoreQuietControlWindow(move, summary, nextIsOpp); + const liveDenariPressure = race.behindInDenari || race.denariRaceLive; + const liveSevenPressure = race.need7s || race.sevenRaceLive; + const openingDuplicateReleaseBias = move.capture.length === 0 + ? scoreOpeningDuplicateReleaseBias(move.card, hand, state, playerIdx, nextIsOpp, roleContext) + : 0; + const directSevenPrimieraSwing = move.capture.length > 0 + ? scoreDirectSevenPrimieraSwing(move.card, move.capture, summary.projectedTable, hand, state.table, liveSevenPressure) + : 0; + const directRankCapture = move.capture.length === 1 && move.capture[0].value === move.card.value; + const directSettebelloCapture = directRankCapture + && move.capture[0].suit === 'denara' + && move.capture[0].value === 7; + const exactPartnerWindow = move.capture.length === 0 + && partnerHandSize > 0 + && summary.projectedTable.length >= 4 + && summary.tableSum >= 10 + && summary.tableSum <= 12 + && summary.exposedDenariCount <= 1 + && summary.exposedSevenCount <= 1; + const safePartnerWindow = move.capture.length === 0 + && nextIsOpp + && threats?.partnerCanScopa + && !threats.nextOppCanScopa; + + let bias = 0; + + bias += scopaPriority * 380; + if (summary.clearsTable && !lastPlay) bias += 220; + if (summary.capturesSettebello) bias += 460; + if (directRankCapture) bias += move.card.value === 7 ? 90 : 34; + if (directRankCapture && move.capture[0].value === 7) bias += liveSevenPressure ? 140 : 70; + if (directRankCapture && move.capture[0].suit === 'denara') bias += liveDenariPressure ? 150 : 72; + if (directSettebelloCapture) bias += 180; + if (directSettebelloCapture && nextIsOpp) bias += 220; + if ( + !summary.capturesSettebello + && state.table.some(card => card.suit === 'denara' && card.value === 7) + && nextIsOpp + ) { + bias -= 460; + } + + bias += evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats) * 34; + bias += evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats) * 34; + bias += evaluateSevenDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.need7s) * (race.need7s ? 42 : race.sevenRaceLive ? 40 : 36); + bias += evaluateDenariDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.behindInDenari) * (race.behindInDenari ? 38 : race.denariRaceLive ? 36 : 32); + bias += openingReleasePriority * 52; + bias += openingDuplicateReleaseBias; + bias += quietControlWindow; + bias += directSevenPrimieraSwing; + bias += Math.round(handStructureDelta * 1.35); + bias += Math.round(pairInventoryDelta * (roleContext.defendingDealerAdvantage ? 6.5 : 4.5)); + bias += Math.round(scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp) * 0.85); + bias += Math.round(rankResiduePlanScore * 0.9); + bias += Math.round(scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker) * 0.55); + bias += Math.round(capturedCards.reduce((sum, card) => sum + primieraVal(card), 0) * 3.2); + + if (nextIsOpp) { + bias -= Math.round(summary.projectedTable.reduce((sum, card) => sum + primieraVal(card), 0) * 1.25); + } + + if (move.capture.length === 0) { + if (summary.highQuietRelease) bias += 72; + bias += summary.sameValueAnchorsRemaining * 44; + if (exactPartnerWindow) bias += 96; + if (safePartnerWindow) bias += exactPartnerWindow ? 120 : 76; + if ( + roleContext.defendingDealerAdvantage + && move.card.suit !== 'denara' + && move.card.value <= 4 + && summary.sameValueAnchorsRemaining > 0 + ) { + bias += 90; + } + if ( + roleContext.defendingDealerAdvantage + && beforePairInventory > 0 + && afterPairInventory === beforePairInventory + && move.card.suit !== 'denara' + && move.card.value <= 4 + ) { + bias += 152; + } + } else if ( + nextIsOpp + && !summary.clearsTable + && !summary.capturesSettebello + && summary.capturedSevenCount === 0 + && summary.projectedTable.length <= 2 + && summary.tableSum <= 12 + ) { + bias -= 120; + } + + if ( + move.capture.length > 0 + && roleContext.defendingDealerAdvantage + && countValueInHand(hand, move.card.value) >= 2 + && !summary.clearsTable + ) { + bias -= Math.round((move.card.value >= 8 ? 180 : 80) * roleContext.pairPreservingBias); + } + + return teamOf(playerIdx) === teamOf(rootPlayer) ? bias : -bias; +} + interface RankedRootMove { index: number; move: AIMove; @@ -1844,35 +2276,33 @@ function rankRootMoves( state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, - lastPlay: boolean, race: RaceState, roleContext: DealerRoleContext, - rankResidue: RankResidueSnapshot | null, + timing: SearchTimingContext, ): RankedRootMove[] { const hand = state.players[playerIdx].hand; const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const rankedMoves = legalMoves.map(move => { + timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); + const quick = quickEval( + move, + state, + playerIdx, + playerIdx, + tracker, + false, + ); + const summary = summarizeMoveTactics(move, hand, state.table); + return { + move, + key: moveKey(move), + quick, + forcing: isForcingSearchMove(summary, race), + priorityControlQuiet: isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext), + }; + }); - return legalMoves - .map(move => { - const quick = quickEval( - move, - state, - playerIdx, - tracker, - lastPlay, - race, - roleContext, - rankResidue, - ); - const summary = summarizeMoveTactics(move, hand, state.table); - return { - move, - key: moveKey(move), - quick, - forcing: isForcingSearchMove(summary, race), - priorityControlQuiet: isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext), - }; - }) + return rankedMoves .sort((a, b) => b.quick - a.quick) .map((rankedMove, index) => ({ ...rankedMove, @@ -1916,6 +2346,7 @@ function orderRootMovesForDepth( ttEntry: TranspositionEntry | undefined, heuristics: SearchHeuristics, workspace: MasterRootWorkspace, + timing: SearchTimingContext, ): RankedRootMove[] { if (rankedMoves.length <= 1) return rankedMoves; @@ -1923,6 +2354,7 @@ function orderRootMovesForDepth( const hashMoveKey = ttEntry?.bestMoveKey ?? undefined; for (const rankedMove of rankedMoves) { + timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); const quietMoveBoost = !rankedMove.isCapture && ( getKillerMoveRank(heuristics, 0, rankedMove.move) !== -1 @@ -2319,36 +2751,60 @@ async function masterMove( } const deadline = startedAt + profile.timeBudgetMs; + const progressState: MasterSearchProgressState = { + evaluationsCompleted: 0, + totalEvaluations: Math.max(1, rootMoveCount * profile.maxDepth), + batchesCompleted: 0, + completedDepth: 0, + aspirationExpansions: 0, + timedOut: false, + }; - const lastPlay = isLastPlay(state, playerIdx); const race = getRaceState(state, playerIdx); const roleContext = getDealerRoleContext(state, playerIdx); - const rankResidue = getRankResidueSnapshot(tracker, state.players[playerIdx].hand, state.table); const rankedMoves = rankRootMoves( legalMoves, state, playerIdx, tracker, - lastPlay, race, roleContext, - rankResidue, + timing, ); - const controlOverride = findStrategicControlOverride(legalMoves, state, playerIdx, race, roleContext, tracker); - if (controlOverride) { - reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { - cardsRemaining, - sampleCount: 1, - maxDepth: 1, - completedDepth: 1, - rootMoveCount, - timedOut: false, - aspirationExpansions: 0, - }); - return controlOverride; + let bestPreSearchMove = rankedMoves[0].move; + if (timing.now() > deadline) { + progressState.timedOut = true; + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), + progressState.batchesCompleted, + buildMasterProgressDetails(progressState, cardsRemaining, 0, profile.maxDepth, rankedMoves.length), + ); + return bestPreSearchMove; } - const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount, rng); + + const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount, rng, timing); const sampleCount = samples.length; + progressState.totalEvaluations = Math.max(1, samples.length * rankedMoves.length * profile.maxDepth); + if (timing.now() > deadline) { + progressState.timedOut = true; + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), + progressState.batchesCompleted, + buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), + ); + return bestPreSearchMove; + } + const transpositionTable = new Map(); const heuristics: SearchHeuristics = { killerMoves: new Map(), @@ -2357,15 +2813,6 @@ async function masterMove( const rootWorkspace = createMasterRootWorkspace(rankedMoves.length); const rootStateKey = buildSearchStateKey(state); - const progressState: MasterSearchProgressState = { - evaluationsCompleted: 0, - totalEvaluations: Math.max(1, samples.length * rankedMoves.length * profile.maxDepth), - batchesCompleted: 0, - completedDepth: 0, - aspirationExpansions: 0, - timedOut: false, - }; - reportDecisionProgress( onProgress, 'master', @@ -2420,7 +2867,13 @@ async function masterMove( } const rootEntry = transpositionTable.get(rootStateKey); - const orderedMoves = orderRootMovesForDepth(rankedMoves, previousBestKey, rootEntry, heuristics, rootWorkspace); + const orderedMoves = orderRootMovesForDepth(rankedMoves, previousBestKey, rootEntry, heuristics, rootWorkspace, timing); + bestPreSearchMove = orderedMoves[0]?.move ?? bestPreSearchMove; + if (timing.now() > deadline) { + progressState.timedOut = true; + break; + } + depthResult = await evaluateMasterDepth( state, samples, @@ -2478,7 +2931,7 @@ async function masterMove( } } - const bestMove = lastCompletedDepth?.bestMove ?? rankedMoves[0].move; + const bestMove = lastCompletedDepth?.bestMove ?? bestPreSearchMove; reportDecisionProgress( onProgress, @@ -2494,185 +2947,24 @@ async function masterMove( } function quickEval( - move: AIMove, state: GameState, playerIdx: PlayerIndex, - tracker: CardTracker | undefined, lastPlay: boolean, - race: RaceState, - roleContext: DealerRoleContext, - rankResidue: RankResidueSnapshot | null, + move: AIMove, + state: GameState, + playerIdx: PlayerIndex, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, + allowHiddenHands: boolean, ): number { - let score = 0; - const table = state.table; - const hand = state.players[playerIdx].hand; - const moveSummary = summarizeMoveTactics(move, hand, table); - const afterCaptureTable = table.filter(c => !move.capture.some(cc => cc.id === c.id)); - const projectedTable = move.capture.length > 0 ? afterCaptureTable : [...afterCaptureTable, move.card]; - const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== move.card.id); - const allCaptured = [move.card, ...move.capture]; - const capturedDenariCount = allCaptured.filter(card => card.suit === 'denara').length; - const projectedDenari = race.myDenari + capturedDenariCount; - const projectedTableHasDenari = projectedTable.some(card => card.suit === 'denara'); - const projectedTableHasSeven = projectedTable.some(card => card.value === 7); - const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); - const threats = getPriorityThreatSummary(projectedTable, projectedHand, tracker, state, playerIdx); - const scopaPriority = evaluateSafeScopaPriority(moveSummary.clearsTable, projectedTable, lastPlay, nextIsOpp, threats); - const openingReleasePriority = move.capture.length === 0 - ? evaluateFirstHandOpeningReleasePriority( - move.card, - hand, - projectedHand, - projectedTable, - state, - playerIdx, - tracker, - nextIsOpp, - roleContext, - ) - : 0; - const capturesSettebello = move.capture.some(card => card.suit === 'denara' && card.value === 7); - const tableHasSettebello = table.some(card => card.suit === 'denara' && card.value === 7); - const tableHasDenari = table.some(card => card.suit === 'denara'); + const result = applyMove(state, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined); + const nextState = result.nextState; - // Scopa (not on last play!) - if (move.capture.length > 0 && projectedTable.length === 0) { - score += lastPlay ? 50 : scopaPriority * 780; - } - - // Settebello - if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 900; - if (capturesSettebello) score += 650; - if (tableHasSettebello && !capturesSettebello) score -= nextIsOpp ? 1200 : 240; - if (tableHasSettebello && nextIsOpp && move.capture.length > 0 && !capturesSettebello) score -= 420; - if (move.capture.length === 0 && move.card.suit === 'denara' && move.card.value === 7) score -= 5000; - - score += move.capture.length * (race.behindInCards ? 75 : 55); - score += capturedDenariCount * (race.behindInDenari ? 180 : 110); - if (move.capture.length > 0 && move.card.suit === 'denara') { - score += race.behindInDenari ? 360 : 140; - if (move.card.value >= 8) score += 90; - } - if (nextIsOpp && race.behindInDenari && tableHasDenari) { - if (capturedDenariCount === 0) score -= 320; - else score += capturedDenariCount * 160; - } - if (projectedDenari >= 4 && race.myDenari < 4) score += 180; - if (projectedDenari >= 5 && race.myDenari < 5) score += 240; - if (projectedDenari >= 6 && race.myDenari < 6) score += 340; - if (projectedDenari > race.oppDenari) score += 110; - score += allCaptured.filter(c => c.value === 7).length * (race.need7s ? 110 : 75); - for (const c of allCaptured) score += primieraVal(c) * 2.5; - - if (move.capture.length === 0) { - score -= 200; - score += openingReleasePriority * 220; - if (move.card.value >= 8) score += 40; - if (move.card.suit === 'denara') score -= 130; - if (move.card.value === 7) score -= 100; - if (tableHasSettebello && nextIsOpp && move.card.value >= 8) score += 220; - if ( - nextIsOpp - && moveSummary.highQuietRelease - && projectedTable.length >= 5 - && moveSummary.tableSum >= 24 - && (projectedTableHasDenari || projectedTableHasSeven) - ) { - score += 320; - } - - // Anchor bonus - const hand = state.players[playerIdx].hand; - if (countValueInHand(hand, move.card.value) >= 2) score += 60; - } - - // Anti-scopa - if (projectedTable.length > 0) { - const sum = projectedTable.reduce((s, c) => s + c.value, 0); - if (sum <= 10 && nextIsOpp) score -= 180; - if (sum >= 11) score += 60; - if (projectedTable.length === 1 && nextIsOpp) score -= 160; - if (nextIsOpp && projectedTable.length <= 3 && sum <= 10) score -= 180; - if (nextIsOpp && race.behindInDenari) { - score -= projectedTable.filter(card => card.suit === 'denara').length * 220; - } - if (nextIsOpp && projectedTable.length === 1) score -= sum <= 10 ? 460 : 260; - if (nextIsOpp && projectedTable.length === 2 && sum < 18) score -= 170; - if (nextIsOpp && projectedTable.length <= 2 && projectedTableHasDenari) { - score -= race.behindInDenari ? 180 : 80; - } - if (nextIsOpp && projectedTable.length <= 2 && projectedTableHasSeven) { - score -= race.need7s ? 210 : 90; - } - if ( - nextIsOpp - && move.capture.length > 0 - && projectedTable.length === 1 - && sum <= 10 - && !moveSummary.clearsTable - && !capturesSettebello - && moveSummary.capturedSevenCount === 0 - ) { - score -= 420; - } - } - - if (move.capture.length > 0 && !moveSummary.clearsTable && isImmediateTacticalConcession(projectedTable, nextIsOpp, threats)) { - score -= 180; - } - - // Partner awareness - const next = nextPlayer(playerIdx); - if (!isOpponent(playerIdx, next) && projectedTable.length > 0) { - const sum = projectedTable.reduce((s, c) => s + c.value, 0); - if (sum >= 1 && sum <= 10) score += 40; // partner might scopa - } - - score += scoreCaptureRankResiduePlan(move.card, move.capture, projectedTable, rankResidue, roleContext, nextIsOpp); - if (move.capture.length === 0) { - score += scoreDumpRankResiduePlan(move.card, projectedTable, rankResidue, roleContext, nextIsOpp); - } - - score += tableControlPressure( - projectedTable, - state, - playerIdx, + return evaluateTeamPosition( + nextState, + teamOf(rootPlayer), + gamePhase(nextState), tracker, - projectedHand, - race, - roleContext, - rankResidue, - ); - - const projectedHandShape = scoreHandStructure(projectedHand, projectedTable, roleContext); - const currentHandShape = scoreHandStructure(hand, table, roleContext); - score += Math.round(projectedHandShape * 0.95 - currentHandShape * 0.25); - - if (isPriorityControlQuietMove(move, moveSummary, nextIsOpp, roleContext)) { - score += roleContext.defendingDealerAdvantage ? 360 : 240; - } - - if ( - roleContext.defendingDealerAdvantage - && move.capture.length === 0 - && move.card.suit !== 'denara' - && move.card.value <= 4 - && moveSummary.tableSum >= 18 - ) { - score += 240; - } - - if ( - move.capture.length > 0 - && !isForcingSearchMove(moveSummary, race) - && nextIsOpp - && moveSummary.tableSum < 18 - ) { - score -= roleContext.defendingDealerAdvantage ? 220 : 140; - } - - if (moveSummary.highQuietRelease && nextIsOpp && moveSummary.tableSum >= 14) { - score += 150; - } - - return score; + rootPlayer, + allowHiddenHands, + ) + scoreMoveObjectiveBias(move, state, playerIdx, rootPlayer, tracker); } function moveKey(move: AIMove): string { @@ -2680,6 +2972,11 @@ function moveKey(move: AIMove): string { return `${move.card.id}|${capIds}`; } +function getMoveCollectedCards(move: AIMove): Card[] { + if (move.capture.length === 0) return []; + return [move.card, ...move.capture]; +} + function stableCardCollectionKey(cards: Card[]): string { return cards.map(card => card.id).sort().join(','); } @@ -2723,18 +3020,17 @@ function orderSearchMoves( if (moves.length <= 1) return moves; const race = getRaceState(state, playerIdx); - const lastPlay = isLastPlay(state, playerIdx); const roleContext = getDealerRoleContext(state, playerIdx); - const rankResidue = getRankResidueSnapshot(tracker, state.players[rootPlayer].hand, state.table); const hand = state.players[playerIdx].hand; const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const maximizingNode = teamOf(playerIdx) === teamOf(rootPlayer); const rankedMoves = moves .map(move => ({ move, key: moveKey(move), - quick: quickEval(move, state, playerIdx, tracker, lastPlay, race, roleContext, rankResidue), + quick: quickEval(move, state, playerIdx, rootPlayer, tracker, true), })) - .sort((a, b) => b.quick - a.quick); + .sort((a, b) => maximizingNode ? b.quick - a.quick : a.quick - b.quick); const pvMoveKey = pvMove ? moveKey(pvMove) : undefined; const hashMoveKey = hashMove ? moveKey(hashMove) : undefined; @@ -2946,6 +3242,7 @@ function buildExactSampleStates( state: GameState, prioritizedUnseen: Card[], assignments: SampleHandAssignment[], + timing: SearchTimingContext, ): GameState[] { const samples: GameState[] = []; const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] })); @@ -2976,6 +3273,7 @@ function buildExactSampleStates( const needed = targetSize - chosenIndices.length; const maxStart = remainingCards.length - needed; for (let index = startIndex; index <= maxStart; index++) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); chosenIndices.push(index); if (chooseCards(index + 1)) return true; chosenIndices.pop(); @@ -3090,6 +3388,7 @@ function buildStratifiedSampleBuckets( rankResidue: RankResidueSnapshot | null, sampleIndex: number, rng: RandomSource, + timing: SearchTimingContext, ): SampleHandBucket[] { const orderVariants = getAssignmentOrderVariants(assignments); const assignmentOrder = orderVariants[sampleIndex % orderVariants.length]; @@ -3104,6 +3403,7 @@ function buildStratifiedSampleBuckets( ); for (let index = 0; index < focusedCards.length; index++) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); const preferredBucket = selectSampleBucketForCard( focusedCards[index], buckets, @@ -3122,6 +3422,7 @@ function buildStratifiedSampleBuckets( const bucket = bucketByPlayer.get(assignment.playerIdx); if (!bucket) continue; while (bucket.cards.length < assignment.handSize && remainingIndex < remainingCards.length) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); bucket.cards.push(remainingCards[remainingIndex]); remainingIndex++; } @@ -3136,6 +3437,7 @@ function generateSamples( tracker: CardTracker | undefined, count: number, rng: RandomSource, + timing: SearchTimingContext, ): GameState[] { const myHand = state.players[playerIdx].hand; const unseen = tracker @@ -3154,7 +3456,7 @@ function generateSamples( prioritizedUnseen.length <= 8 && hiddenAssignmentCount <= MAX_EXACT_SAMPLE_ASSIGNMENTS ) { - return buildExactSampleStates(state, prioritizedUnseen, assignments); + return buildExactSampleStates(state, prioritizedUnseen, assignments, timing); } const samples: GameState[] = []; @@ -3163,6 +3465,7 @@ function generateSamples( const maxAttempts = targetSamples * 4; for (let attempt = 0; attempt < maxAttempts && samples.length < targetSamples; attempt++) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); const sample = cloneState(state); const sampleBuckets = buildStratifiedSampleBuckets( state, @@ -3172,6 +3475,7 @@ function generateSamples( rankResidue, attempt, rng, + timing, ); const sampleKey = buildSampleAssignmentKey(sampleBuckets); if (seenAssignments.has(sampleKey)) continue; @@ -3182,10 +3486,11 @@ function generateSamples( } if (samples.length === 0) { + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); const fallbackSample = cloneState(state); assignBucketsToSample( fallbackSample, - buildStratifiedSampleBuckets(state, playerIdx, prioritizedUnseen, assignments, rankResidue, 0, rng), + buildStratifiedSampleBuckets(state, playerIdx, prioritizedUnseen, assignments, rankResidue, 0, rng, timing), ); return [fallbackSample]; } @@ -3402,188 +3707,387 @@ function alphaBeta( } } -/** Fast evaluation: avoids flatMap/filter at every leaf node */ -function evaluateFast( - state: GameState, - myTeam: 0 | 1, - phase: number, - tracker: CardTracker | undefined, - rootPlayer: PlayerIndex, -): number { - const p0 = state.players[0], p1 = state.players[1], p2 = state.players[2], p3 = state.players[3]; - const myA = myTeam === 0 ? p0 : p1; - const myB = myTeam === 0 ? p2 : p3; - const oppA = myTeam === 0 ? p1 : p0; - const oppB = myTeam === 0 ? p3 : p2; - const race = getRaceState(state, rootPlayer); - const roleContext = getDealerRoleContext(state, state.currentPlayer); - const rankResidue = getRankResidueSnapshot(tracker, state.players[rootPlayer].hand, state.table); - const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); +interface TeamEvaluationSnapshot { + cards: number; + denari: number; + settebello: boolean; + primiera: number; + primieraSuits: number; + sevenSuits: number; + sevens: number; + sixes: number; + aces: number; + scope: number; + totalPoints: number; +} - // Single-pass pile scan — no flatMap/filter allocations - let myCards = 0, oppCards = 0; - let myDenari = 0, oppDenari = 0; - let mySettebello = false, oppSettebello = false; - const my7: Record = {}, opp7: Record = {}; - const myPrimBySuit: Record = {}; - const oppPrimBySuit: Record = {}; - let mySixes = 0, oppSixes = 0; - let myAces = 0, oppAces = 0; +function buildTeamEvaluationSnapshot(state: GameState, team: 0 | 1): TeamEvaluationSnapshot { + const players = team === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; + const bestPrimieraBySuit: Partial> = {}; + const sevenSuits = new Set(); + let cards = 0; + let denari = 0; + let settebello = false; + let sevens = 0; + let sixes = 0; + let aces = 0; + let scope = 0; - for (const pile of [myA.pile, myB.pile]) { - for (const c of pile) { - myCards++; - if (c.suit === 'denara') { - myDenari++; - if (c.value === 7) mySettebello = true; + for (const player of players) { + scope += player.scope; + for (const card of player.pile) { + cards++; + if (card.suit === 'denara') { + denari++; + if (card.value === 7) settebello = true; } - if (c.value === 7) my7[c.suit] = true; - if (c.value === 6) mySixes++; - if (c.value === 1) myAces++; - const pv = PRIMIERA_VALUES[c.value] ?? 0; - if (!myPrimBySuit[c.suit] || pv > myPrimBySuit[c.suit]) myPrimBySuit[c.suit] = pv; - } - } - for (const pile of [oppA.pile, oppB.pile]) { - for (const c of pile) { - oppCards++; - if (c.suit === 'denara') { - oppDenari++; - if (c.value === 7) oppSettebello = true; + if (card.value === 7) { + sevens++; + sevenSuits.add(card.suit); + } + if (card.value === 6) sixes++; + if (card.value === 1) aces++; + + const primieraScore = PRIMIERA_VALUES[card.value] ?? 0; + if ((bestPrimieraBySuit[card.suit] ?? 0) < primieraScore) { + bestPrimieraBySuit[card.suit] = primieraScore; } - if (c.value === 7) opp7[c.suit] = true; - if (c.value === 6) oppSixes++; - if (c.value === 1) oppAces++; - const pv = PRIMIERA_VALUES[c.value] ?? 0; - if (!oppPrimBySuit[c.suit] || pv > oppPrimBySuit[c.suit]) oppPrimBySuit[c.suit] = pv; } } - let score = 0; - - // Cards majority (sharper: weighted by proximity to majority threshold) - const cardDiff = myCards - oppCards; - score += cardDiff * (25 + phase * 18); - // Bonus when near or past majority (20+ of 40) - if (myCards >= 20) score += 80; - if (oppCards >= 20) score -= 80; - - // Denari majority (weighted by proximity to threshold: 6+ of 10) - const denariDiff = myDenari - oppDenari; - score += denariDiff * 80; - if (myDenari >= 4 && oppDenari < 4) score += 70; - if (oppDenari >= 4 && myDenari < 4) score -= 70; - if (myDenari >= 5 && oppDenari < 5) score += 110; - if (oppDenari >= 5 && myDenari < 5) score -= 110; - if (myDenari >= 6) score += 90; - if (oppDenari >= 6) score -= 90; - - if (cardsRemaining <= 12 && state.table.length > 0 && state.lastCapturTeam !== null) { - const tableDenari = state.table.filter(card => card.suit === 'denara').length; - const projectedMyDenari = myDenari + (state.lastCapturTeam === myTeam ? tableDenari : 0); - const projectedOppDenari = oppDenari + (state.lastCapturTeam === myTeam ? 0 : tableDenari); - if (projectedMyDenari >= 5 && projectedOppDenari < 5) score += 130; - if (projectedOppDenari >= 5 && projectedMyDenari < 5) score -= 130; - if (projectedMyDenari >= 6 && projectedOppDenari < 6) score += 220; - if (projectedOppDenari >= 6 && projectedMyDenari < 6) score -= 220; - } - - // Settebello - if (mySettebello) score += 450; - if (oppSettebello) score -= 450; - - // Primiera — more nuanced - let myPrim = 0, oppPrim = 0; - let mySuits = 0, oppSuits = 0; + let primiera = 0; + let primieraSuits = 0; for (const suit of SUITS) { - if (myPrimBySuit[suit]) { myPrim += myPrimBySuit[suit]; mySuits++; } - if (oppPrimBySuit[suit]) { oppPrim += oppPrimBySuit[suit]; oppSuits++; } - // Per-suit 7 control is critical for primiera - if (my7[suit] && !opp7[suit]) score += 50; - if (opp7[suit] && !my7[suit]) score -= 50; - } - if (mySuits === 4 && oppSuits === 4) { - score += (myPrim - oppPrim) * 5; - } else if (mySuits === 4) { - score += 180; - } else if (oppSuits === 4) { - score -= 180; - } - - // Sixes and aces matter for primiera too (after 7s) - score += (mySixes - oppSixes) * 12; - score += (myAces - oppAces) * 10; - - // Scope (very important!) - const scopeDiff = (myA.scope + myB.scope) - (oppA.scope + oppB.scope); - score += scopeDiff * 400; - - // Table position — more detailed - if (!state.roundOver && state.table.length > 0) { - let tableSum = 0; - let tableHasSettebello = false; - let tableDenari = 0; - let table7s = 0; - for (const c of state.table) { - tableSum += c.value; - if (c.suit === 'denara' && c.value === 7) tableHasSettebello = true; - if (c.suit === 'denara') tableDenari++; - if (c.value === 7) table7s++; - } - const curTeam = teamOf(state.currentPlayer); - const myTurn = curTeam === myTeam; - - // Clearable table advantage - if (myTurn && tableSum <= 10) score += 35; - if (!myTurn && tableSum <= 10) score -= 35; - if (!myTurn && state.table.length <= 3 && tableSum <= 10) score -= 120; - - // Settebello on table - if (myTurn && tableHasSettebello) score += race.needSettebello ? 240 : 170; - if (!myTurn && tableHasSettebello) score -= race.needSettebello ? 320 : 220; - - // Denari and 7s on table available for next player - if (myTurn) { - score += tableDenari * (race.behindInDenari ? 28 : 16); - score += table7s * (race.need7s ? 52 : 24); - } else { - score -= tableDenari * (race.behindInDenari ? 34 : 18); - score -= table7s * (race.need7s ? 64 : 28); - if (state.table.length === 1) score -= 120; - } - - // Anchor quality: cards on table matching our team's holdings - if (myTurn) { - // Good: table has cards we can capture - score += state.table.length * 5; - } - - const rankResiduePressure = scoreRankResidueTableState(state.table, rankResidue, roleContext, !myTurn); - score += myTurn ? rankResiduePressure : -rankResiduePressure; - - const rolePlan = scoreRoleTablePlan(state.table, roleContext, !myTurn); - score += myTurn ? rolePlan : -rolePlan; - - if (rankResidue) { - const { singletonValues, pairedValues } = countRankResidueValuesOnTable(state.table, rankResidue); - if (roleContext.defendingDealerAdvantage) { - score += (myTurn ? pairedValues : -singletonValues) * 14; - } else { - score += (myTurn ? singletonValues : -pairedValues) * 14; - } + const suitScore = bestPrimieraBySuit[suit] ?? 0; + if (suitScore > 0) { + primiera += suitScore; + primieraSuits++; } } - for (let playerIdx = 0 as PlayerIndex; playerIdx < 4; playerIdx = (playerIdx + 1) as PlayerIndex) { - const tempoScore = scorePlayerVisibleTempo(state, playerIdx); - const handShapeScore = scoreHandStructure( - state.players[playerIdx].hand, - state.table, - getDealerRoleContext(state, playerIdx), - ); - const signedScore = Math.round(tempoScore * 0.85 + handShapeScore * 0.55); - score += teamOf(playerIdx) === myTeam ? signedScore : -signedScore; + return { + cards, + denari, + settebello, + primiera, + primieraSuits, + sevenSuits: sevenSuits.size, + sevens, + sixes, + aces, + scope, + totalPoints: state.teamScores[team].totalPoints, + }; +} + +function scoreMajorityRace( + myValue: number, + oppValue: number, + target: number, + unitWeight: number, + thresholdBonus: number, +): number { + let score = (myValue - oppValue) * unitWeight; + + if (myValue >= target && oppValue < target) { + score += thresholdBonus; + } else if (oppValue >= target && myValue < target) { + score -= thresholdBonus; + } else { + const myDistance = Math.max(0, target - myValue); + const oppDistance = Math.max(0, target - oppValue); + score += (oppDistance - myDistance) * Math.round(unitWeight * 0.75); } return score; } + +function getRoundScoringCardWeight(card: Card): number { + let weight = 18 + primieraVal(card) * 3; + + if (card.suit === 'denara') weight += 42; + if (card.value === 7) weight += 44; + if (card.suit === 'denara' && card.value === 7) weight += 120; + + return weight; +} + +function scorePendingTableOwnership(state: GameState, perspectiveTeam: 0 | 1): number { + if (state.table.length === 0 || state.lastCapturTeam === null) return 0; + + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); + const urgency = cardsRemaining <= 4 ? 1.35 : cardsRemaining <= 8 ? 1.15 : 0.75; + const tableValue = state.table.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); + + return Math.round((state.lastCapturTeam === perspectiveTeam ? 1 : -1) * tableValue * urgency); +} + +function scoreObjectiveTableExposure(state: GameState, perspectiveTeam: 0 | 1): number { + if (state.table.length === 0) return 0; + + const nextTeamSign = teamOf(state.currentPlayer) === perspectiveTeam ? 1 : -1; + const tableSum = sumCardValues(state.table); + const exposedDenari = state.table.filter(card => card.suit === 'denara').length; + const exposedSevens = state.table.filter(card => card.value === 7).length; + const exposedSettebello = state.table.some(card => card.suit === 'denara' && card.value === 7); + const shortTable = state.table.length <= 2 || tableSum <= 12; + let pressure = exposedDenari * 34 + exposedSevens * 42; + + if (exposedSettebello) pressure += 120; + if (shortTable) pressure += 36; + + return Math.round(nextTeamSign * pressure * (shortTable ? 1.2 : 0.8)); +} + +function scoreTableControlReserve(state: GameState, perspectiveTeam: 0 | 1): number { + if (state.table.length < 4) return 0; + + const tableSum = sumCardValues(state.table); + if (tableSum < 20) return 0; + + const nextTeam = teamOf(state.currentPlayer); + const exposedDenari = state.table.filter(card => card.suit === 'denara').length; + const exposedSevens = state.table.filter(card => card.value === 7).length; + let reserve = state.table.length * 22 + tableSum * 3; + + reserve -= exposedDenari * 10; + reserve -= exposedSevens * 12; + if (state.table.length >= 5) reserve += 24; + if (tableSum >= 24) reserve += 28; + + return Math.round((nextTeam === perspectiveTeam ? -0.35 : 0.55) * reserve); +} + +function scoreKnownImmediateCapturePressure(state: GameState, playerIdx: PlayerIndex): number { + if (state.table.length === 0 || state.players[playerIdx].hand.length === 0) return 0; + + let bestScore = 0; + for (const move of getLegalMoves(state, playerIdx)) { + if (move.capture.length === 0) continue; + + const captured = [move.card, ...move.capture]; + let moveScore = captured.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); + + if (move.capture.length === state.table.length) { + const isTerminalClear = state.players.every((player, index) => ( + index === playerIdx ? player.hand.length === 1 : player.hand.length === 0 + )); + moveScore += isTerminalClear ? 90 : 240; + } + + bestScore = Math.max(bestScore, moveScore); + } + + return bestScore; +} + +function getUpcomingTableExposureActors(state: GameState): Array<{ playerIdx: PlayerIndex; weight: number }> { + const actors: Array<{ playerIdx: PlayerIndex; weight: number }> = []; + let playerIdx = state.currentPlayer; + + for (const weight of UPCOMING_TABLE_EXPOSURE_WEIGHTS) { + actors.push({ playerIdx, weight }); + playerIdx = nextPlayer(playerIdx); + } + + return actors; +} + +function scoreCaptureOpportunity( + capturedCards: Card[], + playedValue: number, + tableSize: number, +): number { + let score = capturedCards.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); + + score += 18 + (PRIMIERA_VALUES[playedValue] ?? 0) * 2; + if (playedValue === 7) score += 48; + if (capturedCards.some(card => card.suit === 'denara')) score += 24; + if (capturedCards.some(card => card.value === 7)) score += 32; + if (capturedCards.some(card => card.suit === 'denara' && card.value === 7)) score += 160; + + if (capturedCards.length === tableSize) { + score += tableSize <= 3 ? 220 : 280; + } + + return score; +} + +function scoreProbableImmediateCapturePressure( + state: GameState, + playerIdx: PlayerIndex, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, +): number { + if (state.table.length === 0) return 0; + + const handSize = state.players[playerIdx].hand.length; + if (handSize <= 0) return 0; + + const observerHand = state.players[rootPlayer].hand; + let bestScore = 0; + + for (let value = 1; value <= 10; value++) { + const representativeCard = REPRESENTATIVE_CARD_BY_VALUE.get(value); + if (!representativeCard) continue; + + const captures = findCaptures(representativeCard, state.table); + if (captures.length === 0) continue; + + const probability = handLikelyHasValue( + value, + handSize, + state, + rootPlayer, + tracker, + observerHand, + state.table, + ); + if (probability <= 0) continue; + + let bestCaptureScore = 0; + for (const capture of captures) { + bestCaptureScore = Math.max( + bestCaptureScore, + scoreCaptureOpportunity(capture, value, state.table.length), + ); + } + + bestScore = Math.max(bestScore, Math.round(probability * bestCaptureScore)); + } + + return bestScore; +} + +function scoreKnownTableExposure(state: GameState, perspectiveTeam: 0 | 1): number { + if (state.table.length === 0) return 0; + + let score = 0; + for (const actor of getUpcomingTableExposureActors(state)) { + const capturePressure = scoreKnownImmediateCapturePressure(state, actor.playerIdx); + if (capturePressure === 0) continue; + + score += Math.round( + capturePressure + * actor.weight + * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1), + ); + } + + return score; +} + +function scoreProbableTableExposure( + state: GameState, + perspectiveTeam: 0 | 1, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, +): number { + if (state.table.length === 0) return 0; + + let score = 0; + for (const actor of getUpcomingTableExposureActors(state)) { + const actorScore = scoreProbableImmediateCapturePressure(state, actor.playerIdx, rootPlayer, tracker); + if (actorScore === 0) continue; + + score += Math.round( + actorScore + * actor.weight + * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1), + ); + } + + return score; +} + +function scoreRootOpeningAnchorState( + state: GameState, + perspectiveTeam: 0 | 1, + rootPlayer: PlayerIndex, +): number { + if ( + teamOf(rootPlayer) !== perspectiveTeam + || state.table.length !== 1 + || teamOf(state.currentPlayer) === perspectiveTeam + ) { + return 0; + } + + const exposedCard = state.table[0]; + const rootHand = state.players[rootPlayer].hand; + const sameValueAnchors = countValueInHand(rootHand, exposedCard.value); + let score = 0; + + if (sameValueAnchors > 0) { + score += exposedCard.value >= 8 ? 240 : 88; + if (exposedCard.suit !== 'denara') score += 36; + } + + if (sameValueAnchors === 0 && exposedCard.value <= 3) score -= 220; + if (exposedCard.suit === 'denara') score -= 120; + if (exposedCard.value === 7) score -= 140; + + return score; +} + +function evaluateTeamPosition( + state: GameState, + perspectiveTeam: 0 | 1, + _phase: number, + tracker: CardTracker | undefined, + rootPlayer: PlayerIndex, + allowHiddenHands: boolean, +): number { + const opponentTeam = perspectiveTeam === 0 ? 1 : 0; + const mine = buildTeamEvaluationSnapshot(state, perspectiveTeam); + const opp = buildTeamEvaluationSnapshot(state, opponentTeam); + const phase = gamePhase(state); + const matchWeight = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 360 : 260; + + let score = 0; + + score += (mine.totalPoints - opp.totalPoints) * Math.round(matchWeight + phase * 40); + if (mine.totalPoints >= 10 && opp.totalPoints < 10) score += 260; + if (opp.totalPoints >= 10 && mine.totalPoints < 10) score -= 260; + + score += scoreMajorityRace(mine.cards, opp.cards, 21, Math.round(18 + phase * 18), 180); + score += scoreMajorityRace(mine.denari, opp.denari, 6, Math.round(70 + phase * 22), 220); + + if (mine.settebello) score += 420; + if (opp.settebello) score -= 420; + + score += (mine.scope - opp.scope) * 390; + + score += (mine.primiera - opp.primiera) * Math.round(4.5 + phase * 3); + score += (mine.primieraSuits - opp.primieraSuits) * 124; + if (mine.primieraSuits === 4 && opp.primieraSuits < 4) score += 180; + if (opp.primieraSuits === 4 && mine.primieraSuits < 4) score -= 180; + score += (mine.sevenSuits - opp.sevenSuits) * 92; + score += (mine.sevens - opp.sevens) * 68; + score += (mine.sixes - opp.sixes) * 16; + score += (mine.aces - opp.aces) * 12; + + score += scorePendingTableOwnership(state, perspectiveTeam); + score += scoreRootOpeningAnchorState(state, perspectiveTeam, rootPlayer); + score += scoreObjectiveTableExposure(state, perspectiveTeam); + score += scoreTableControlReserve(state, perspectiveTeam); + if (allowHiddenHands) { + score += scoreCurrentPlayerVisibleTempo(state, perspectiveTeam); + } + score += allowHiddenHands + ? scoreKnownTableExposure(state, perspectiveTeam) + : scoreProbableTableExposure(state, perspectiveTeam, rootPlayer, tracker); + + return score; +} + +/** Fast evaluation: avoids flatMap/filter at every leaf node */ +function evaluateFast( + state: GameState, + myTeam: 0 | 1, + _phase: number, + tracker: CardTracker | undefined, + rootPlayer: PlayerIndex, +): number { + return evaluateTeamPosition(state, myTeam, 0, tracker, rootPlayer, true); +}