From 77ab1f43a67a6ad89a1bd691d0558260a3c253ed Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Thu, 9 Apr 2026 22:30:27 +0200 Subject: [PATCH] feat(SCOPONE-0009) improve ai, dealer, apparigliare e sparigliare --- docs/FINDINGS.md | 34 +- package.json | 4 +- src/game/ai-benchmark-fixtures.ts | 543 ++++++ src/game/ai-benchmark.ts | 634 +++++++ src/game/ai.ts | 2892 ++++++++++++++++++++++++----- src/game/card-tracker.ts | 38 +- src/game/engine.ts | 10 +- src/scenes/GameScene.ts | 142 +- 8 files changed, 3787 insertions(+), 510 deletions(-) create mode 100644 src/game/ai-benchmark-fixtures.ts create mode 100644 src/game/ai-benchmark.ts diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md index caddc4a..2fedcf2 100644 --- a/docs/FINDINGS.md +++ b/docs/FINDINGS.md @@ -1,6 +1,6 @@ # Findings -> Last Updated: 2026-04-08T19:48:08.000Z +> Last Updated: 2026-04-09T00:00:00.000Z ## Summary @@ -117,3 +117,35 @@ Initializer refresh for SCOPONE-0009. The cached findings were stale relative to - Phaser dispatches `shutdown` when a scene stops being active but may be re-used later; resource cleanup that should also cover final teardown can additionally listen to `destroy`. - The current `GameScene` pattern of registering one-shot shutdown and destroy handlers is aligned with Phaser guidance for worker disposal and UI cleanup. - Dealer rotation and next-round state changes can stay inside the existing in-scene orchestration without requiring a different Phaser lifecycle primitive. + +### SCOPONE-0009: Iteration 3 strength-planning notes (2026-04-08) + +- `src/game/ai.ts` currently generates master determinization samples by uniformly shuffling all unseen cards and slicing them into opponents' hidden hands; it does not yet bias assignments by dealer role, parity residue, or observed capture semantics. +- The transposition-table key in `src/game/ai.ts` includes the exact sampled hidden hands, so reuse is effective within a determinized sample but does not merge equivalent uncertainty classes across different sample assignments. +- No executable benchmark harness or AI quality test module exists under `src/`; the current timing evidence lives only in prompt artifacts such as `prompts/SCOPONE-0009/iteration_2/benchmark_summary.md`. +- `tsconfig.json` includes only `src`, so any automated quality or self-play harness that should be typechecked by the default `npx tsc --noEmit` command needs to live under `src/` unless the project configuration changes. + +### SCOPONE-0009: Iteration 3 continuation notes (2026-04-09) + +- The accepted iteration 3 benchmark work is now present in source: `src/game/ai-benchmark.ts` and `src/game/ai-benchmark-fixtures.ts` exist under `src/`, `package.json` exposes `benchmark:ai-quality`, and the harness already measures fixed fixtures, self-play, and production-master timing. +- The live production master budgets in `src/game/ai.ts` are already below the requested five-second ceiling in every shipped branch: base `4300`, `<= 20 cards` `4350`, `<= 12 cards` `4200`, `<= 8 cards` `3900`, `<= 6 cards` `3600`, and `<= 4 cards` `3200` milliseconds. +- `src/scenes/GameScene.ts` still executes AI turns immediately after `await aiClient.chooseMove(...)` resolves in `doAIMove()`; there is currently no scene-level minimum think-time floor. +- `src/scenes/GameScene.ts` still uses a bare `setStatus(msg)` helper that only calls `this.statusText.setText(msg)`; there is no timed persistence policy, no cancellation of prior status timers, and no dedicated post-move outcome message path. +- Phaser 3.87 scene timers can be cancelled with `TimerEvent.remove()` and their references cleaned with `TimerEvent.destroy()`; the current scene already listens to `shutdown` and `destroy`, so timed status cleanup belongs in the existing `handleSceneShutdown()` path. + +### SCOPONE-0009: Iteration 3 refresh notes (2026-04-09) + +- The current `src/game/ai.ts` heuristic does not reason about numeric even/odd card values; it already computes the unseen copy count for each rank and stores whether the remaining copies for that rank are in a singleton residue or a paired residue, but the internal names still use `oddResidue`, `evenResidue`, and `scoreParityTableState`, which can mislead future work. +- The live tactical seam that needs refresh is therefore naming and policy framing, not a wholesale replacement of the underlying signal: the AI should explicitly treat `apparigliare` / `sparigliare` as preserving or breaking same-rank copy residues and connect that to table control, scopa prevention, and forced replies. +- The accepted benchmark harness in `src/game/ai-benchmark.ts` still measures runtime with `performance.now()` and therefore depends on wall-clock search time. It does not yet use an injected or simulated search clock for fast validation runs. +- `src/scenes/GameScene.ts` already contains the previously planned pacing and status work: `AI_MIN_THINK_MS = 1000`, `MOVE_OUTCOME_STATUS_MS = 2000`, a timer-backed `setStatus(...)`, and `handleSceneShutdown()` timer cleanup are all present in source and should be preserved rather than re-planned. +- `src/game/ai-benchmark-fixtures.ts` still contains one fixture and tag using the stale label `dealer-parity-preserve-pair` / `critical-dealer-parity`; if benchmark files are reopened for simulated timing, that terminology should be refreshed to rank-residue wording at the same time. + +### SCOPONE-0009: Iteration 5 planning notes (2026-04-09) + +- The live AI quality harness in `src/game/ai-benchmark.ts` still hard-codes an `iteration: 4` quality gate with targets of `12` fixed fixtures, `4` critical concepts, and `48` self-play matches requiring `>= 30` wins and `<= 12` losses; the readable summary does not yet surface cross-seed aggregation such as the recurring dual-loss seeds from the latest rejected run. +- `src/game/ai-benchmark-fixtures.ts` currently covers `settebello-capture`, `anti-scopa-defense`, `dealer-rank-residue-preservation`, and `exact-endgame-resolution` as critical concepts, but it does not yet encode an explicit critical fixture for partner invitation / partner scopa setup and does not yet make `fare scopa` itself a critical concept despite the user's new ordering. +- Non-critical fixtures already exist for denari pressure, late denari shielding, and seven pressure, so the benchmark seam for iteration 5 is to rebalance critical-vs-fixed coverage and ordering expectations rather than to introduce a second harness. +- Cross-tier heuristic priorities are concentrated in `src/game/ai.ts`: beginner logic in `scoreCaptureBeginner()` / `scoreDumpBeginner()`, advanced logic in `scoreCaptureAdv()` / `scoreDumpAdv()`, and master root/search logic in `quickEval()`, `orderSearchMoves()`, `generateSamples()`, and `evaluateFast()`. +- Partner-aware logic already exists in all three tiers, but it is currently additive and distributed across multiple heuristics; there is no single explicit priority ladder that guarantees `partner setup` outranks seven denial, denari denial, and generic material capture across the whole file. +- Anti-scopa prevention is already strong enough to pass the fixed tactical fixtures, but the rejected iteration 4 result (`18` wins, `30` losses over `48` seeded self-play matches) indicates that full-game strength is still limited by strategic continuity across seed-intrinsic lines rather than by isolated tactical blindness. diff --git a/package.json b/package.json index 9aeed89..35e6e58 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "benchmark:ai-quality": "tsx ./src/game/ai-benchmark.ts" }, "devDependencies": { + "tsx": "^4.19.2", "typescript": "^5.0.0", "vite": "^5.0.0" }, diff --git a/src/game/ai-benchmark-fixtures.ts b/src/game/ai-benchmark-fixtures.ts new file mode 100644 index 0000000..318d388 --- /dev/null +++ b/src/game/ai-benchmark-fixtures.ts @@ -0,0 +1,543 @@ +import { buildDeck, findCaptures, getOpeningPlayerForDealer } from './engine'; +import { Card, GameState, Player, PlayerIndex, TeamScore } from './types'; + +export interface AIBenchmarkExpectedMove { + cardId: string; + captureIds?: string[]; +} + +export type AIBenchmarkCriticalConcept = + | 'full-table-scopa' + | 'partner-scopa-setup' + | 'settebello-capture' + | 'anti-scopa-defense' + | 'dealer-rank-residue-preservation' + | 'exact-endgame-resolution'; + +export interface AIBenchmarkFixture { + id: string; + name: string; + description: string; + tags: string[]; + criticalConcept: AIBenchmarkCriticalConcept | null; + state: GameState; + expectedMove: AIBenchmarkExpectedMove; +} + +interface RawFixture { + id: string; + name: string; + description: string; + tags: string[]; + criticalConcept?: AIBenchmarkCriticalConcept; + dealer: PlayerIndex; + currentPlayer: PlayerIndex; + handSizes: [number, number, number, number]; + hands: [string[] | undefined, string[] | undefined, string[] | undefined, string[] | undefined]; + table: string[]; + piles?: [string[], string[], string[], string[]]; + pileCardCounts?: [number, number, number, number]; + scopes?: [number, number, number, number]; + totalPoints?: [number, number]; + roundNumber?: number; + lastCaptureTeam?: 0 | 1 | null; + expectedMove: AIBenchmarkExpectedMove; +} + +const PLAYER_NAMES = ['Tu', 'AI Ovest', 'Compagno', 'AI Est'] as const; + +const CARD_BY_ID = new Map(buildDeck().map(card => [card.id, card])); + +const PILES_TEMPLATE_A: [string[], string[], string[], string[]] = [ + ['denara_1', 'coppe_7', 'spade_6', 'bastoni_8'], + ['denara_3', 'coppe_1', 'spade_2', 'bastoni_5'], + ['denara_6', 'coppe_4', 'spade_1', 'bastoni_7'], + ['denara_10', 'coppe_6', 'spade_4', 'bastoni_2'], +]; + +const PILES_TEMPLATE_B: [string[], string[], string[], string[]] = [ + ['denara_2', 'coppe_8', 'spade_5', 'bastoni_9'], + ['denara_5', 'coppe_1', 'spade_2', 'bastoni_6'], + ['denara_6', 'coppe_4', 'spade_1', 'bastoni_7'], + ['denara_10', 'coppe_6', 'spade_9', 'bastoni_2'], +]; + +const RAW_FIXTURES: RawFixture[] = [ + { + id: 'settebello-direct-capture', + name: 'Settebello Direct Capture', + description: 'The root player should take the settebello immediately when a direct seven match is available.', + tags: ['critical-settebello-capture', 'denari-race'], + criticalConcept: 'settebello-capture', + dealer: 3, + currentPlayer: 0, + handSizes: [5, 5, 5, 5], + hands: [[ + 'spade_7', + 'denara_8', + 'bastoni_6', + 'coppe_9', + 'denara_4', + ], undefined, undefined, undefined], + table: ['denara_7', 'coppe_2', 'bastoni_4', 'spade_9'], + piles: PILES_TEMPLATE_A, + totalPoints: [6, 7], + expectedMove: { + cardId: 'spade_7', + captureIds: ['denara_7'], + }, + }, + { + id: 'anti-scopa-safe-dump', + name: 'Anti-Scopa Safe Dump', + description: 'The root player should prefer the safe high dump instead of taking a flashy but dangerous capture.', + tags: ['critical-anti-scopa', 'table-control'], + criticalConcept: 'anti-scopa-defense', + dealer: 0, + currentPlayer: 1, + handSizes: [5, 5, 5, 5], + hands: [undefined, [ + 'bastoni_9', + 'coppe_3', + 'spade_10', + 'denara_5', + 'coppe_8', + ], undefined, undefined], + table: ['bastoni_1', 'coppe_5', 'denara_7', 'spade_8'], + piles: PILES_TEMPLATE_A, + totalPoints: [8, 8], + expectedMove: { + cardId: 'spade_10', + }, + }, + { + id: 'dealer-rank-residue-preserve-pair', + name: 'Dealer Rank Residue Preserve Pair', + description: 'The dealer should keep the double-nine structure intact and release the harmless low card.', + tags: ['critical-dealer-rank-residue', 'dealer-side-control'], + criticalConcept: 'dealer-rank-residue-preservation', + dealer: 3, + currentPlayer: 3, + handSizes: [5, 5, 5, 5], + hands: [undefined, undefined, undefined, [ + 'spade_3', + 'denara_9', + 'coppe_9', + 'bastoni_10', + 'denara_5', + ]], + table: ['denara_2', 'coppe_5', 'bastoni_4', 'spade_8'], + piles: PILES_TEMPLATE_A, + scopes: [0, 1, 0, 1], + totalPoints: [9, 7], + expectedMove: { + cardId: 'spade_3', + }, + }, + { + id: 'exact-endgame-resolution', + name: 'Exact Endgame Resolution', + description: 'With one card per player and a winning capture on the table, the search should resolve the hand exactly.', + tags: ['critical-exact-endgame', 'endgame'], + criticalConcept: 'exact-endgame-resolution', + dealer: 1, + currentPlayer: 2, + handSizes: [1, 1, 1, 1], + hands: [undefined, undefined, ['spade_6'], undefined], + table: ['coppe_2', 'bastoni_4'], + pileCardCounts: [9, 8, 9, 8], + scopes: [1, 0, 1, 0], + totalPoints: [10, 9], + roundNumber: 4, + lastCaptureTeam: 0, + expectedMove: { + cardId: 'spade_6', + captureIds: ['coppe_2', 'bastoni_4'], + }, + }, + { + id: 'full-table-scopa', + name: 'Full Table Scopa', + description: 'A full-table sweep should be preferred when it is available and it is not the final play of the round.', + tags: ['critical-full-table-scopa', 'scopa-window'], + criticalConcept: 'full-table-scopa', + dealer: 2, + currentPlayer: 0, + handSizes: [5, 5, 5, 5], + hands: [[ + 'spade_10', + 'denara_8', + 'coppe_9', + 'bastoni_3', + 'denara_4', + ], undefined, undefined, undefined], + table: ['bastoni_1', 'coppe_2', 'denara_3', 'spade_4'], + piles: PILES_TEMPLATE_B, + expectedMove: { + cardId: 'spade_10', + captureIds: ['bastoni_1', 'coppe_2', 'denara_3', 'spade_4'], + }, + }, + { + id: 'partner-scopa-setup', + name: 'Partner Scopa Setup', + description: 'When there is no safe immediate sweep, the root player should prefer the quiet partner invitation that preserves table pressure for the partner line instead of cashing a smaller material capture.', + tags: ['critical-partner-setup', 'partner-window', 'table-control'], + criticalConcept: 'partner-scopa-setup', + dealer: 0, + currentPlayer: 1, + handSizes: [5, 5, 5, 5], + hands: [ + undefined, + ['coppe_10', 'spade_6', 'bastoni_3', 'denara_5', 'coppe_2'], + ['spade_10', 'denara_6', 'bastoni_4', 'coppe_9', 'denara_8'], + undefined, + ], + table: ['denara_1', 'coppe_4', 'bastoni_7', 'spade_8'], + pileCardCounts: [4, 4, 4, 4], + scopes: [0, 1, 0, 1], + totalPoints: [8, 9], + expectedMove: { + cardId: 'coppe_10', + }, + }, + { + id: 'denari-race-conversion', + name: 'Denari Race Conversion', + description: 'When denari control is in play, the benchmark should reward the denari-preserving nine capture.', + tags: ['denari-race'], + dealer: 1, + currentPlayer: 2, + handSizes: [5, 5, 5, 5], + hands: [undefined, undefined, [ + 'denara_9', + 'spade_10', + 'coppe_8', + 'bastoni_6', + 'denara_5', + ], undefined], + table: ['denara_4', 'coppe_2', 'spade_5', 'bastoni_3'], + piles: PILES_TEMPLATE_A, + totalPoints: [7, 8], + expectedMove: { + cardId: 'denara_9', + }, + }, + { + id: 'primiera-seven-pressure', + name: 'Primiera Seven Pressure', + description: 'A seven that improves primiera pressure should beat quieter material moves.', + tags: ['primiera-pressure'], + dealer: 0, + currentPlayer: 1, + handSizes: [5, 5, 5, 5], + hands: [undefined, [ + 'denara_7', + 'coppe_10', + 'spade_6', + 'bastoni_5', + 'coppe_9', + ], undefined, undefined], + table: ['denara_1', 'coppe_3', 'bastoni_4', 'spade_8'], + piles: PILES_TEMPLATE_B, + expectedMove: { + cardId: 'denara_7', + }, + }, + { + id: 'safe-low-dump', + name: 'Safe Low Dump', + description: 'The search should prefer the lone safe release over cards that either capture or create leverage for the next player.', + tags: ['table-control'], + dealer: 2, + currentPlayer: 0, + handSizes: [5, 5, 5, 5], + hands: [[ + 'coppe_10', + 'spade_8', + 'bastoni_6', + 'denara_4', + 'coppe_3', + ], undefined, undefined, undefined], + table: ['denara_2', 'spade_9', 'bastoni_4', 'coppe_5'], + piles: PILES_TEMPLATE_A, + expectedMove: { + cardId: 'coppe_3', + }, + }, + { + id: 'late-denari-shield', + name: 'Late Denari Shield', + description: 'The denari nine should still be preferred late when it blocks the opponent from flipping the denari race.', + tags: ['denari-race', 'late-round'], + dealer: 1, + currentPlayer: 3, + handSizes: [5, 5, 5, 5], + hands: [undefined, undefined, undefined, [ + 'denara_9', + 'coppe_7', + 'spade_10', + 'bastoni_8', + 'denara_4', + ]], + table: ['denara_1', 'coppe_2', 'bastoni_5', 'spade_6'], + piles: PILES_TEMPLATE_B, + scopes: [1, 0, 0, 0], + totalPoints: [9, 9], + expectedMove: { + cardId: 'denara_9', + }, + }, + { + id: 'only-safe-release', + name: 'Only Safe Release', + description: 'Only the deuce avoids either an immediate capture or a tactical concession.', + tags: ['anti-concession'], + dealer: 3, + currentPlayer: 1, + handSizes: [5, 5, 5, 5], + hands: [undefined, [ + 'bastoni_10', + 'coppe_8', + 'denara_9', + 'spade_3', + 'coppe_2', + ], undefined, undefined], + table: ['denara_4', 'spade_5', 'bastoni_1', 'coppe_3'], + piles: PILES_TEMPLATE_A, + expectedMove: { + cardId: 'coppe_2', + }, + }, + { + id: 'table-clear-material-sweep', + name: 'Table Clear Material Sweep', + description: 'A full-table material sweep with the ten should win over lower-value tactical grabs.', + tags: ['scopa-window', 'material-swing'], + dealer: 0, + currentPlayer: 2, + handSizes: [5, 5, 5, 5], + hands: [undefined, undefined, [ + 'spade_7', + 'denara_8', + 'coppe_9', + 'bastoni_10', + 'denara_3', + ], undefined], + table: ['denara_4', 'coppe_3', 'bastoni_1', 'spade_2'], + piles: [ + ['denara_1', 'coppe_4', 'spade_5', 'bastoni_6'], + ['denara_2', 'coppe_5', 'spade_6', 'bastoni_7'], + ['denara_5', 'coppe_6', 'spade_8', 'bastoni_9'], + ['denara_6', 'coppe_7', 'spade_9', 'bastoni_2'], + ], + expectedMove: { + cardId: 'bastoni_10', + captureIds: ['denara_4', 'coppe_3', 'bastoni_1', 'spade_2'], + }, + }, + { + id: 'direct-eight-conversion', + name: 'Direct Eight Conversion', + description: 'The direct eight capture should be preferred when it removes the strongest immediate counter-card from the table.', + tags: ['material-swing'], + dealer: 2, + currentPlayer: 0, + handSizes: [5, 5, 5, 5], + hands: [[ + 'coppe_10', + 'spade_5', + 'denara_8', + 'bastoni_9', + 'coppe_3', + ], undefined, undefined, undefined], + table: ['denara_2', 'coppe_8', 'bastoni_4', 'spade_9'], + piles: PILES_TEMPLATE_A, + expectedMove: { + cardId: 'denara_8', + captureIds: ['coppe_8'], + }, + }, +]; + +function cloneCard(card: Card): Card { + return { ...card }; +} + +function cardFromId(id: string): Card { + const card = CARD_BY_ID.get(id); + if (!card) { + throw new Error(`Unknown card id in benchmark fixture: ${id}`); + } + return cloneCard(card); +} + +function cardsFromIds(ids: string[]): Card[] { + return ids.map(cardFromId); +} + +function createTeamScore(totalPoints = 0): TeamScore { + return { + cards: 0, + scope: 0, + denari: 0, + settebello: false, + primiera: 0, + roundPoints: 0, + totalPoints, + }; +} + +function buildPlayers( + hands: [Card[], Card[], Card[], Card[]], + piles: [Card[], Card[], Card[], Card[]], + scopes: [number, number, number, number], +): [Player, Player, Player, Player] { + return [0, 1, 2, 3].map(index => ({ + index: index as PlayerIndex, + hand: hands[index].map(cloneCard), + pile: piles[index].map(cloneCard), + scope: scopes[index], + isHuman: index === 0, + name: PLAYER_NAMES[index], + })) as [Player, Player, Player, Player]; +} + +function flattenIds(groups: string[][]): string[] { + return groups.flatMap(group => group); +} + +function buildFixture(raw: RawFixture): AIBenchmarkFixture { + const explicitHands = raw.hands.map(hand => hand ? [...hand] : undefined) as RawFixture['hands']; + const explicitPiles = raw.piles + ? raw.piles.map(pile => [...pile]) as [string[], string[], string[], string[]] + : undefined; + + const reservedIds = new Set(); + for (const id of flattenIds(raw.hands.filter((hand): hand is string[] => Array.isArray(hand)))) { + if (reservedIds.has(id)) throw new Error(`Duplicate hand card ${id} in fixture ${raw.id}`); + reservedIds.add(id); + } + for (const id of raw.table) { + if (reservedIds.has(id)) throw new Error(`Duplicate table card ${id} in fixture ${raw.id}`); + reservedIds.add(id); + } + if (explicitPiles) { + for (const id of flattenIds(explicitPiles)) { + if (reservedIds.has(id)) throw new Error(`Duplicate pile card ${id} in fixture ${raw.id}`); + reservedIds.add(id); + } + } + + const remainingDeckIds = buildDeck() + .map(card => card.id) + .filter(id => !reservedIds.has(id)); + + const hands = explicitHands.map((hand, playerIdx) => { + const requiredSize = raw.handSizes[playerIdx]; + if (hand && hand.length !== requiredSize) { + throw new Error(`Fixture ${raw.id} hand size mismatch for player ${playerIdx}`); + } + + if (hand) return [...hand]; + + const assigned = remainingDeckIds.splice(0, requiredSize); + if (assigned.length !== requiredSize) { + throw new Error(`Fixture ${raw.id} does not have enough cards to fill player ${playerIdx} hand`); + } + return assigned; + }) as [string[], string[], string[], string[]]; + + const piles = explicitPiles ?? (() => { + if (!raw.pileCardCounts) { + throw new Error(`Fixture ${raw.id} is missing piles or pileCardCounts`); + } + + return raw.pileCardCounts.map(count => { + const assigned = remainingDeckIds.splice(0, count); + if (assigned.length !== count) { + throw new Error(`Fixture ${raw.id} does not have enough cards to fill pile count ${count}`); + } + return assigned; + }) as [string[], string[], string[], string[]]; + })(); + + if (remainingDeckIds.length !== 0) { + throw new Error(`Fixture ${raw.id} does not account for all 40 cards`); + } + + const state: GameState = { + players: buildPlayers( + hands.map(cardsFromIds) as [Card[], Card[], Card[], Card[]], + piles.map(cardsFromIds) as [Card[], Card[], Card[], Card[]], + raw.scopes ?? [0, 0, 0, 0], + ), + table: cardsFromIds(raw.table), + matchStartingPlayer: getOpeningPlayerForDealer(raw.dealer), + dealer: raw.dealer, + currentPlayer: raw.currentPlayer, + roundOver: false, + gameOver: false, + teamScores: [ + createTeamScore(raw.totalPoints?.[0] ?? 0), + createTeamScore(raw.totalPoints?.[1] ?? 0), + ], + lastCapturTeam: raw.lastCaptureTeam ?? null, + roundNumber: raw.roundNumber ?? 1, + }; + + validateFixtureState(raw, state); + + return { + id: raw.id, + name: raw.name, + description: raw.description, + tags: [...raw.tags], + criticalConcept: raw.criticalConcept ?? null, + state, + expectedMove: raw.expectedMove, + }; +} + +function validateFixtureState(raw: RawFixture, state: GameState): void { + const allCardIds = new Set(); + for (const player of state.players) { + for (const card of player.hand) { + if (allCardIds.has(card.id)) throw new Error(`Fixture ${raw.id} duplicates ${card.id}`); + allCardIds.add(card.id); + } + for (const card of player.pile) { + if (allCardIds.has(card.id)) throw new Error(`Fixture ${raw.id} duplicates ${card.id}`); + allCardIds.add(card.id); + } + } + for (const card of state.table) { + if (allCardIds.has(card.id)) throw new Error(`Fixture ${raw.id} duplicates ${card.id}`); + allCardIds.add(card.id); + } + + if (allCardIds.size !== 40) { + throw new Error(`Fixture ${raw.id} must contain exactly 40 unique cards, found ${allCardIds.size}`); + } + + const rootHand = state.players[state.currentPlayer].hand; + if (!rootHand.some(card => card.id === raw.expectedMove.cardId)) { + throw new Error(`Fixture ${raw.id} expected move card ${raw.expectedMove.cardId} is not in the root hand`); + } + + if (raw.expectedMove.captureIds) { + const played = cardFromId(raw.expectedMove.cardId); + const legalCaptures = findCaptures(played, state.table) + .map(capture => capture.map(card => card.id).sort().join(',')); + const expectedCaptureKey = [...raw.expectedMove.captureIds].sort().join(','); + if (!legalCaptures.includes(expectedCaptureKey)) { + throw new Error(`Fixture ${raw.id} expected capture ${expectedCaptureKey} is not legal`); + } + } +} + +export function isCriticalAIBenchmarkFixture(fixture: AIBenchmarkFixture): boolean { + return fixture.criticalConcept !== null; +} + +export const AI_BENCHMARK_FIXTURES: AIBenchmarkFixture[] = RAW_FIXTURES.map(buildFixture); \ No newline at end of file diff --git a/src/game/ai-benchmark.ts b/src/game/ai-benchmark.ts new file mode 100644 index 0000000..a91d28c --- /dev/null +++ b/src/game/ai-benchmark.ts @@ -0,0 +1,634 @@ +import { applyMove, cloneState, createInitialState, getMatchOutcome, nextPlayer, teamOf } from './engine'; +import { AITimingSource, AIMove, AISearchProfileOverride, chooseMove } from './ai'; +import { + AI_BENCHMARK_FIXTURES, + AIBenchmarkCriticalConcept, + AIBenchmarkExpectedMove, + AIBenchmarkFixture, + isCriticalAIBenchmarkFixture, +} from './ai-benchmark-fixtures'; +import { CardTracker } from './card-tracker'; +import { GameState, PlayerIndex } from './types'; + +function formatDurationMs(durationMs: number): string { + if (durationMs < 1000) { + return `${durationMs.toFixed(0)} ms`; + } + + return `${(durationMs / 1000).toFixed(2)} s`; +} + +function logBenchmarkProgress(message: string): void { + console.log(`[ai-benchmark] ${message}`); +} + +interface FixedFixtureResult { + fixtureId: string; + name: string; + tags: string[]; + criticalConcept: AIBenchmarkCriticalConcept | null; + productionMove: string; + referenceMove: string; + matchesReference: boolean; + expectedPass: boolean; + conceptGatePass: boolean | null; + productionSimulatedMs: number; + referenceSimulatedMs: number; +} + +interface SelfPlayMatchResult { + seed: number; + dealer: PlayerIndex; + masterTeam: 0 | 1; + winner: 0 | 1 | null; + masterResult: 'win' | 'loss' | 'draw'; + rounds: number; + truncated: boolean; + totalPoints: [number, number]; + masterDecisionCount: number; + masterAverageSimulatedDecisionMs: number; + masterMaxSimulatedDecisionMs: number; +} + +interface TimingSummary { + count: number; + averageMs: number; + p95Ms: number; + maxMs: number; +} + +interface GateCountSummary { + actual: number; + required: number; + total: number; + passed: boolean; +} + +interface SelfPlayGateSummary { + matches: number; + requiredMatches: number; + wins: number; + requiredWins: number; + losses: number; + maxLosses: number; + draws: number; + matchCountPassed: boolean; + winGatePassed: boolean; + lossGatePassed: boolean; + passed: boolean; +} + +interface SelfPlaySeedSeatResult { + masterTeam: 0 | 1; + masterResult: 'win' | 'loss' | 'draw'; + winner: 0 | 1 | null; + rounds: number; + truncated: boolean; + totalPoints: [number, number]; +} + +interface SelfPlaySeedAggregateResult { + seed: number; + matches: number; + wins: number; + losses: number; + draws: number; + dualLoss: boolean; + seatResults: SelfPlaySeedSeatResult[]; +} + +export interface AIBenchmarkSummary { + benchmark: 'ai-quality'; + qualityGate: { + iteration: 5; + passed: boolean; + fixedFixtures: GateCountSummary; + criticalConcepts: GateCountSummary; + selfPlay: SelfPlayGateSummary; + }; + fixtureCount: number; + criticalFixtureCount: number; + fixedSuite: { + fixedFixtureAgreements: number; + expectedPasses: number; + criticalPasses: number; + fixedFixtureAgreementFailures: string[]; + criticalPassFailures: string[]; + 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[]; + }; + timing: { + productionMasterSimulatedDecisions: TimingSummary; + }; + referenceProfile: Required; +} + +const ITERATION_5_GATE = { + fixedFixtureAgreementTarget: 13, + criticalConceptTarget: 6, + selfPlayMatchTarget: 48, + selfPlayWinTarget: 30, + selfPlayMaxLosses: 12, +} as const; + +const KNOWN_REGRESSION_WATCHLIST = [1000, 1002, 1004, 1006, 1012, 1013, 1014] as const; +const KNOWN_REGRESSION_WATCHLIST_SET = new Set(KNOWN_REGRESSION_WATCHLIST); + +const REFERENCE_PROFILE: Required = { + timeBudgetMs: 9000, + sampleCount: 12, + maxDepth: 7, + batchSize: 2, +}; + +const SELF_PLAY_MATCH_SEEDS = Array.from({ length: 24 }, (_, index) => 1000 + index); +const MAX_SELF_PLAY_ROUNDS = 20; + +function assertIteration5BenchmarkContract(): void { + const criticalFixtureCount = AI_BENCHMARK_FIXTURES.filter(isCriticalAIBenchmarkFixture).length; + const selfPlayMatchCount = SELF_PLAY_MATCH_SEEDS.length * 2; + + if (AI_BENCHMARK_FIXTURES.length !== ITERATION_5_GATE.fixedFixtureAgreementTarget) { + throw new Error( + `Iteration 5 benchmark expects ${ITERATION_5_GATE.fixedFixtureAgreementTarget} fixed fixtures, received ${AI_BENCHMARK_FIXTURES.length}.`, + ); + } + + if (criticalFixtureCount !== ITERATION_5_GATE.criticalConceptTarget) { + throw new Error( + `Iteration 5 benchmark expects ${ITERATION_5_GATE.criticalConceptTarget} critical concept fixtures, received ${criticalFixtureCount}.`, + ); + } + + if (selfPlayMatchCount !== ITERATION_5_GATE.selfPlayMatchTarget) { + throw new Error( + `Iteration 5 benchmark expects ${ITERATION_5_GATE.selfPlayMatchTarget} self-play matches, received ${selfPlayMatchCount}.`, + ); + } +} + +interface SimulatedBenchmarkTimingSource extends AITimingSource { + getElapsedMs(): number; +} + +function createSimulatedBenchmarkTimingSource(startMs = 0): SimulatedBenchmarkTimingSource { + let currentMs = startMs; + + return { + isSimulated: true, + now: () => currentMs, + advance: (elapsedMs: number) => { + currentMs += elapsedMs; + return currentMs; + }, + getElapsedMs: () => currentMs - startMs, + }; +} + +function seedFromParts(...parts: number[]): number { + let hash = 2166136261; + for (const part of parts) { + hash ^= part >>> 0; + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function createMulberry32(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let mixed = Math.imul(state ^ (state >>> 15), state | 1); + mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61); + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296; + }; +} + +function moveKey(move: AIMove): string { + return `${move.card.id}|${move.capture.map(card => card.id).sort().join(',')}`; +} + +function createTrackerForState(state: GameState): CardTracker { + const tracker = new CardTracker(); + for (const player of state.players) { + for (const card of player.pile) { + tracker.trackPlay(card); + } + } + return tracker; +} + +function matchesExpectedMove(move: AIMove, expected: AIBenchmarkExpectedMove): boolean { + if (move.card.id !== expected.cardId) return false; + if (!expected.captureIds) return true; + + const actualCapture = move.capture.map(card => card.id).sort().join(','); + const expectedCapture = [...expected.captureIds].sort().join(','); + return actualCapture === expectedCapture; +} + +async function runFixedFixtureSuite(): Promise<{ results: FixedFixtureResult[]; wallClockMs: number; productionTimings: number[] }> { + const startedAt = performance.now(); + const results: FixedFixtureResult[] = []; + const productionTimings: number[] = []; + + logBenchmarkProgress(`Starting fixed fixture suite (${AI_BENCHMARK_FIXTURES.length} positions).`); + + for (let index = 0; index < AI_BENCHMARK_FIXTURES.length; index++) { + const fixture = AI_BENCHMARK_FIXTURES[index]; + const productionState = cloneState(fixture.state); + const referenceState = cloneState(fixture.state); + const productionTracker = createTrackerForState(productionState); + const referenceTracker = createTrackerForState(referenceState); + + const productionSeed = seedFromParts(0x0f1e2d3c, index, 0); + const referenceSeed = seedFromParts(0x0f1e2d3c, index, 1); + const productionTimingSource = createSimulatedBenchmarkTimingSource(); + const referenceTimingSource = createSimulatedBenchmarkTimingSource(); + + const productionMove = await chooseMove( + productionState, + productionState.currentPlayer, + 'master', + productionTracker, + undefined, + { + rng: createMulberry32(productionSeed), + timingSource: productionTimingSource, + }, + ); + const productionSimulatedMs = productionTimingSource.getElapsedMs(); + + const referenceMove = await chooseMove( + referenceState, + referenceState.currentPlayer, + 'master', + referenceTracker, + undefined, + { + rng: createMulberry32(referenceSeed), + profileOverride: REFERENCE_PROFILE, + timingSource: referenceTimingSource, + }, + ); + const referenceSimulatedMs = referenceTimingSource.getElapsedMs(); + + productionTimings.push(productionSimulatedMs); + + const conceptGatePass = isCriticalAIBenchmarkFixture(fixture) + ? matchesExpectedMove(productionMove, fixture.expectedMove) + : null; + + results.push({ + fixtureId: fixture.id, + name: fixture.name, + tags: [...fixture.tags], + criticalConcept: fixture.criticalConcept, + productionMove: moveKey(productionMove), + referenceMove: moveKey(referenceMove), + matchesReference: moveKey(productionMove) === moveKey(referenceMove), + expectedPass: matchesExpectedMove(productionMove, fixture.expectedMove), + conceptGatePass, + productionSimulatedMs, + referenceSimulatedMs, + }); + + const progressLabel = `${index + 1}/${AI_BENCHMARK_FIXTURES.length}`; + const matchLabel = moveKey(productionMove) === moveKey(referenceMove) ? 'agreement' : 'divergence'; + logBenchmarkProgress( + `Fixture ${progressLabel}: ${fixture.id} -> ${matchLabel}, production simulated ${formatDurationMs(productionSimulatedMs)}, reference simulated ${formatDurationMs(referenceSimulatedMs)}.`, + ); + } + + return { + results, + wallClockMs: performance.now() - startedAt, + productionTimings, + }; +} + +function summarizeTimings(samples: number[]): TimingSummary { + if (samples.length === 0) { + return { + count: 0, + averageMs: 0, + p95Ms: 0, + maxMs: 0, + }; + } + + const sorted = [...samples].sort((left, right) => left - right); + const sum = sorted.reduce((accumulator, value) => accumulator + value, 0); + const p95Index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * 0.95) - 1)); + + return { + count: sorted.length, + averageMs: sum / sorted.length, + p95Ms: sorted[p95Index], + maxMs: sorted[sorted.length - 1], + }; +} + +function summarizeSelfPlayBySeed(results: SelfPlayMatchResult[]): { + perSeed: SelfPlaySeedAggregateResult[]; + dualLossSeeds: number[]; + regressionWatchlistDualLossIntersection: number[]; +} { + const aggregates = new Map(); + + for (const result of results) { + const existing = aggregates.get(result.seed) ?? { + seed: result.seed, + matches: 0, + wins: 0, + losses: 0, + draws: 0, + dualLoss: false, + seatResults: [], + }; + + existing.matches++; + if (result.masterResult === 'win') existing.wins++; + else if (result.masterResult === 'loss') existing.losses++; + else existing.draws++; + + existing.seatResults.push({ + masterTeam: result.masterTeam, + masterResult: result.masterResult, + winner: result.winner, + rounds: result.rounds, + truncated: result.truncated, + totalPoints: result.totalPoints, + }); + + aggregates.set(result.seed, existing); + } + + const perSeed = [...aggregates.values()] + .map(aggregate => ({ + ...aggregate, + dualLoss: aggregate.losses >= 2, + seatResults: [...aggregate.seatResults].sort((left, right) => left.masterTeam - right.masterTeam), + })) + .sort((left, right) => left.seed - right.seed); + const dualLossSeeds = perSeed.filter(aggregate => aggregate.dualLoss).map(aggregate => aggregate.seed); + const regressionWatchlistDualLossIntersection = dualLossSeeds.filter(seed => KNOWN_REGRESSION_WATCHLIST_SET.has(seed)); + + return { + perSeed, + dualLossSeeds, + regressionWatchlistDualLossIntersection, + }; +} + +async function simulateSelfPlayMatch(seed: number, masterTeam: 0 | 1): Promise<{ result: SelfPlayMatchResult; timings: number[] }> { + const initialDealer = (seed % 4) as PlayerIndex; + let state = createInitialState(initialDealer, createMulberry32(seedFromParts(seed, 1, 0))); + const matchStartingPlayer = state.matchStartingPlayer; + const tracker = new CardTracker(); + const masterTimings: number[] = []; + + let rounds = 1; + let truncated = false; + let turnCount = 0; + + while (rounds <= MAX_SELF_PLAY_ROUNDS) { + while (!state.roundOver) { + const playerIdx = state.currentPlayer; + const difficulty = teamOf(playerIdx) === masterTeam ? 'master' : 'advanced'; + 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 simulatedMs = timingSource.getElapsedMs(); + + if (difficulty === 'master') { + masterTimings.push(simulatedMs); + } + + const { nextState, capture } = applyMove( + state, + playerIdx, + move.card, + move.capture.length > 0 ? move.capture : undefined, + ); + tracker.trackPlay(move.card); + if (capture) { + tracker.trackCapture(capture.captured); + } + state = nextState; + turnCount++; + } + + const outcome = getMatchOutcome(state.teamScores); + if (!outcome.continueMatch) { + break; + } + + rounds++; + if (rounds > MAX_SELF_PLAY_ROUNDS) { + truncated = true; + break; + } + + 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.matchStartingPlayer = matchStartingPlayer; + state.teamScores[0].totalPoints = totals[0]; + state.teamScores[1].totalPoints = totals[1]; + state.roundNumber = rounds; + } + + 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); + + return { + result: { + seed, + dealer: initialDealer, + masterTeam, + winner, + masterResult, + rounds, + truncated, + totalPoints: [state.teamScores[0].totalPoints, state.teamScores[1].totalPoints], + masterDecisionCount: timingSummary.count, + masterAverageSimulatedDecisionMs: timingSummary.averageMs, + masterMaxSimulatedDecisionMs: timingSummary.maxMs, + }, + timings: masterTimings, + }; +} + +async function runSelfPlaySuite(): Promise<{ results: SelfPlayMatchResult[]; wallClockMs: number; productionTimings: number[] }> { + const startedAt = performance.now(); + const results: SelfPlayMatchResult[] = []; + const productionTimings: number[] = []; + const totalMatches = SELF_PLAY_MATCH_SEEDS.length * 2; + let completedMatches = 0; + + logBenchmarkProgress(`Starting self-play suite (${totalMatches} 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); + results.push(result); + productionTimings.push(...timings); + completedMatches++; + + if (completedMatches === 1 || completedMatches % 4 === 0 || completedMatches === totalMatches) { + logBenchmarkProgress( + `Self-play ${completedMatches}/${totalMatches}: seed ${seed}, master team ${masterTeam}, result ${result.masterResult}, rounds ${result.rounds}, max simulated decision ${formatDurationMs(result.masterMaxSimulatedDecisionMs)}.`, + ); + } + } + } + + return { + results, + wallClockMs: performance.now() - startedAt, + productionTimings, + }; +} + +function printReadableSummary(summary: AIBenchmarkSummary): void { + 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.`); + 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('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.'); + 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.`); + 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; + const criticalPasses = fixedSuite.results.filter(result => result.conceptGatePass === true).length; + const fixedFixtureAgreementFailures = fixedSuite.results + .filter(result => !result.matchesReference) + .map(result => result.fixtureId); + 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([ + ...fixedSuite.productionTimings, + ...selfPlay.productionTimings, + ]); + const fixedFixtureGate: GateCountSummary = { + actual: fixedFixtureAgreements, + required: ITERATION_5_GATE.fixedFixtureAgreementTarget, + total: AI_BENCHMARK_FIXTURES.length, + passed: fixedFixtureAgreements === ITERATION_5_GATE.fixedFixtureAgreementTarget, + }; + const criticalConceptGate: GateCountSummary = { + actual: criticalPasses, + required: ITERATION_5_GATE.criticalConceptTarget, + 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, + }; + + return { + benchmark: 'ai-quality', + qualityGate: { + iteration: 5, + passed: fixedFixtureGate.passed && criticalConceptGate.passed && selfPlayGate.passed, + fixedFixtures: fixedFixtureGate, + criticalConcepts: criticalConceptGate, + selfPlay: selfPlayGate, + }, + fixtureCount: AI_BENCHMARK_FIXTURES.length, + criticalFixtureCount, + fixedSuite: { + fixedFixtureAgreements, + expectedPasses, + criticalPasses, + fixedFixtureAgreementFailures, + criticalPassFailures, + 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, + }, + timing: { + productionMasterSimulatedDecisions, + }, + referenceProfile: REFERENCE_PROFILE, + }; +} + +async function runBenchmarkCli(): Promise { + const summary = await runAIBenchmark(); + logBenchmarkProgress('Benchmark complete. Emitting summary with iteration 5 gate results.'); + printReadableSummary(summary); +} + +if (typeof window === 'undefined') { + void runBenchmarkCli(); +} \ No newline at end of file diff --git a/src/game/ai.ts b/src/game/ai.ts index a39598c..5514101 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -1,5 +1,5 @@ import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS, DealerRelativeRole } from './types'; -import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState, getDealerRelativeRole } from './engine'; +import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState, getDealerRelativeRole, RandomSource } from './engine'; import { CardTracker } from './card-tracker'; export interface AIMove { @@ -13,6 +13,23 @@ export interface AIDecisionProgress { elapsedMs: number; budgetMs: number; batchesCompleted: number; + cardsRemaining?: number; + sampleCount?: number; + maxDepth?: number; + completedDepth?: number; + rootMoveCount?: number; + timedOut?: boolean; + aspirationExpansions?: number; +} + +interface MasterProgressDetails { + cardsRemaining: number; + sampleCount: number; + maxDepth: number; + completedDepth: number; + rootMoveCount: number; + timedOut: boolean; + aspirationExpansions: number; } interface SearchProfile { @@ -22,6 +39,25 @@ interface SearchProfile { batchSize: number; } +export interface AISearchProfileOverride { + timeBudgetMs?: number; + sampleCount?: number; + maxDepth?: number; + batchSize?: number; +} + +export interface AITimingSource { + now(): number; + advance?(elapsedMs: number): number; + isSimulated?: boolean; +} + +export interface AIChooseMoveOptions { + rng?: RandomSource; + profileOverride?: AISearchProfileOverride; + timingSource?: AITimingSource; +} + interface DealerRoleContext { role: DealerRelativeRole; onDealerSide: boolean; @@ -30,14 +66,20 @@ interface DealerRoleContext { aggressionBias: number; controlBias: number; pairPreservingBias: number; - parityBreakingBias: number; + pairBreakingBias: number; tablePressureBias: number; } -interface ParitySnapshot { - unseenCounts: number[]; - oddResidue: boolean[]; - evenResidue: boolean[]; +interface RankResidueSnapshot { + unseenSameRankCounts: number[]; + hasSingletonResidue: boolean[]; + hasPairedResidue: boolean[]; +} + +interface SearchTimingContext { + now(): number; + checkpoint(costMs?: number): number; + yieldToHost(): Promise; } const DEALER_ROLE_WEIGHTS: Record> = { @@ -45,28 +87,28 @@ const DEALER_ROLE_WEIGHTS: Record = { beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 }, advanced: { timeBudgetMs: 650, sampleCount: 0, maxDepth: 0, batchSize: 0 }, - master: { timeBudgetMs: 4600, sampleCount: 10, maxDepth: 6, batchSize: 2 }, + master: { timeBudgetMs: 4300, sampleCount: 8, maxDepth: 5, batchSize: 2 }, }; +const REAL_TIME_SOURCE: AITimingSource = { + now: () => Date.now(), +}; + +const SIMULATED_SEARCH_NODE_COST_MS = 48; +const SIMULATED_ROOT_MOVE_COST_MS = 12; +const SIMULATED_YIELD_COST_MS = 1; + +function createSearchTimingContext(timingSource?: AITimingSource): SearchTimingContext { + const source = timingSource ?? REAL_TIME_SOURCE; + + return { + now: () => source.now(), + checkpoint: (costMs = 0) => { + if (source.advance && costMs > 0) { + return source.advance(costMs); + } + return source.now(); + }, + yieldToHost: () => { + if (source.advance) { + source.advance(SIMULATED_YIELD_COST_MS); + return Promise.resolve(); + } + return new Promise(resolve => setTimeout(resolve, 0)); + }, + }; +} + // --------------------------------------------------------------------------- // Helpers shared across all difficulty levels // --------------------------------------------------------------------------- @@ -133,44 +204,136 @@ function getDealerRoleContext(state: GameState, playerIdx: PlayerIndex): DealerR }; } -function getParitySnapshot( +function getRankResidueSnapshot( tracker: CardTracker | undefined, myHand: Card[], table: Card[], -): ParitySnapshot | null { +): RankResidueSnapshot | null { if (!tracker) return null; - const unseenCounts = Array.from({ length: 11 }, () => 0); - const oddResidue = Array.from({ length: 11 }, () => false); - const evenResidue = Array.from({ length: 11 }, () => false); - const summary = tracker.getValueParityResidueSummary(myHand, table); + const unseenSameRankCounts = Array.from({ length: 11 }, () => 0); + const hasSingletonResidue = Array.from({ length: 11 }, () => false); + const hasPairedResidue = Array.from({ length: 11 }, () => false); + const summary = tracker.getValueRankResidueSummary(myHand, table); for (const residue of summary) { - unseenCounts[residue.value] = residue.unseenCount; - oddResidue[residue.value] = residue.hasOddUnseenResidue; - evenResidue[residue.value] = residue.hasEvenUnseenResidue; + unseenSameRankCounts[residue.value] = residue.unseenCount; + hasSingletonResidue[residue.value] = residue.hasSingletonUnseenRankResidue; + hasPairedResidue[residue.value] = residue.hasPairedUnseenRankResidue; } - return { unseenCounts, oddResidue, evenResidue }; + return { unseenSameRankCounts, hasSingletonResidue, hasPairedResidue }; } -function countParityValuesOnTable(afterTable: Card[], parity: ParitySnapshot | null): { oddValues: number; evenValues: number } { - if (!parity || afterTable.length === 0) { - return { oddValues: 0, evenValues: 0 }; +function countRankResidueValuesOnTable( + afterTable: Card[], + rankResidue: RankResidueSnapshot | null, +): { singletonValues: number; pairedValues: number } { + if (!rankResidue || afterTable.length === 0) { + return { singletonValues: 0, pairedValues: 0 }; } - let oddValues = 0; - let evenValues = 0; + let singletonValues = 0; + let pairedValues = 0; const seenValues = new Set(); for (const card of afterTable) { if (seenValues.has(card.value)) continue; seenValues.add(card.value); - if (parity.oddResidue[card.value]) oddValues++; - else if (parity.evenResidue[card.value]) evenValues++; + if (rankResidue.hasSingletonResidue[card.value]) singletonValues++; + else if (rankResidue.hasPairedResidue[card.value]) pairedValues++; } - return { oddValues, evenValues }; + return { singletonValues, pairedValues }; +} + +function getExposedTableCardWeight( + card: Card, + race: RaceState, + tableSize: number, +): number { + let weight = 52 + primieraVal(card) * 2.5; + + if (card.suit === 'denara') { + weight += race.behindInDenari ? 150 : 95; + } + + if (card.value === 7) { + weight += race.need7s ? 190 : 120; + } + + if (card.suit === 'denara' && card.value === 7) { + weight += 220; + } + + if (tableSize === 1) weight += 150; + else if (tableSize === 2) weight += 70; + + return weight; +} + +function scoreExposedTableCards( + afterTable: Card[], + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + myHand: Card[], + race: RaceState, +): number { + if (afterTable.length === 0) return 0; + + const next = nextPlayer(playerIdx); + const partner = partnerOf(playerIdx); + const nextHandSize = state.players[next].hand.length; + const partnerHandSize = state.players[partner].hand.length; + const nextIsOpp = isOpponent(playerIdx, next); + const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); + const tableHasDenari = afterTable.some(card => card.suit === 'denara'); + const tableHasSeven = afterTable.some(card => card.value === 7); + let score = 0; + + for (const tableCard of afterTable) { + const weight = getExposedTableCardWeight(tableCard, race, afterTable.length); + + if (nextIsOpp && nextHandSize > 0) { + const nextProb = handLikelyHasValue( + tableCard.value, + nextHandSize, + state, + playerIdx, + tracker, + myHand, + afterTable, + ); + score -= Math.round(nextProb * weight); + } + + if (!nextIsOpp && partnerHandSize > 0) { + const partnerProb = handLikelyHasValue( + tableCard.value, + partnerHandSize, + state, + playerIdx, + tracker, + myHand, + afterTable, + ); + score += Math.round(partnerProb * weight * 0.55); + } + } + + if (nextIsOpp && afterTable.length === 1) { + score -= 380; + } else if (nextIsOpp && afterTable.length === 2) { + score -= tableSum <= 10 ? 180 : 110; + if (tableHasDenari) score -= race.behindInDenari ? 130 : 60; + if (tableHasSeven) score -= race.need7s ? 170 : 80; + } else if (nextIsOpp && afterTable.length >= 5 && tableSum >= 24) { + if (tableHasDenari) score += 70; + if (tableHasSeven) score += 55; + } + + return score; } function scoreRoleTablePlan( @@ -207,133 +370,167 @@ function scoreRoleTablePlan( return Math.round(score); } -function scoreParityTableState( +function scoreRankResidueTableState( afterTable: Card[], - parity: ParitySnapshot | null, + rankResidue: RankResidueSnapshot | null, roleContext: DealerRoleContext, nextIsOpp: boolean, ): number { - const { oddValues, evenValues } = countParityValuesOnTable(afterTable, parity); - if (oddValues === 0 && evenValues === 0) return 0; + const { singletonValues, pairedValues } = countRankResidueValuesOnTable(afterTable, rankResidue); + if (singletonValues === 0 && pairedValues === 0) return 0; let score = 0; if (roleContext.defendingDealerAdvantage) { - score += evenValues * 18 * roleContext.controlBias; - score -= oddValues * 22 * roleContext.controlBias; - if (nextIsOpp) score += evenValues * 8 - oddValues * 10; + score += pairedValues * 18 * roleContext.controlBias; + score -= singletonValues * 22 * roleContext.controlBias; + if (nextIsOpp) score += pairedValues * 8 - singletonValues * 10; } else { - score += oddValues * 20 * roleContext.tablePressureBias; - score -= evenValues * 10; - if (nextIsOpp) score += oddValues * 12; + score += singletonValues * 20 * roleContext.tablePressureBias; + score -= pairedValues * 10; + if (nextIsOpp) score += singletonValues * 12; } return Math.round(score); } -function scoreCaptureParityPlan( +function scoreCaptureRankResiduePlan( played: Card, captured: Card[], afterTable: Card[], - parity: ParitySnapshot | null, + rankResidue: RankResidueSnapshot | null, roleContext: DealerRoleContext, nextIsOpp: boolean, ): number { - if (!parity || captured.length === 0) return 0; + if (!rankResidue || captured.length === 0) return 0; let score = 0; const directCapture = captured.length === 1 && captured[0].value === played.value; if (directCapture) { - const unseenCount = parity.unseenCounts[played.value] ?? 0; - const base = parity.oddResidue[played.value] ? 58 : 30; + const unseenCount = rankResidue.unseenSameRankCounts[played.value] ?? 0; + const base = rankResidue.hasPairedResidue[played.value] ? 58 : 30; score += base * roleContext.pairPreservingBias; if (roleContext.defendingDealerAdvantage && unseenCount > 0) score += 18 * roleContext.controlBias; } else { - let parityBreaks = 0; - let oddTargets = 0; + let pairBreaks = 0; + let singletonTargets = 0; const seenValues = new Set(); for (const card of captured) { if (seenValues.has(card.value)) continue; seenValues.add(card.value); - if ((parity.unseenCounts[card.value] ?? 0) > 0) parityBreaks++; - if (parity.oddResidue[card.value]) oddTargets++; + if ((rankResidue.unseenSameRankCounts[card.value] ?? 0) > 0) pairBreaks++; + if (rankResidue.hasSingletonResidue[card.value]) singletonTargets++; } - const disruption = parityBreaks * 20 + oddTargets * 18 + Math.max(0, captured.length - 1) * 12; - score += disruption * roleContext.parityBreakingBias; + const disruption = pairBreaks * 20 + singletonTargets * 18 + Math.max(0, captured.length - 1) * 12; + score += disruption * roleContext.pairBreakingBias; if (roleContext.defendingDealerAdvantage) score -= 18 * roleContext.controlBias; } - score += scoreParityTableState(afterTable, parity, roleContext, nextIsOpp); + score += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); return Math.round(score); } -function scoreDumpParityPlan( +function scoreDumpRankResiduePlan( card: Card, afterTable: Card[], - parity: ParitySnapshot | null, + rankResidue: RankResidueSnapshot | null, roleContext: DealerRoleContext, nextIsOpp: boolean, ): number { - if (!parity) return 0; + if (!rankResidue) return 0; - let score = scoreParityTableState(afterTable, parity, roleContext, nextIsOpp); - if (parity.oddResidue[card.value]) { + let score = scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); + if (rankResidue.hasSingletonResidue[card.value]) { score += roleContext.attackingDealerAdvantage ? 18 * roleContext.tablePressureBias : -20 * roleContext.controlBias; } - if (parity.evenResidue[card.value]) { + if (rankResidue.hasPairedResidue[card.value]) { score += roleContext.defendingDealerAdvantage ? 14 * roleContext.pairPreservingBias : 6; } return Math.round(score); } -function getSearchProfile(state: GameState, difficulty: Difficulty): SearchProfile { - if (difficulty !== 'master') return SEARCH_PROFILES[difficulty]; +function applySearchProfileOverride( + profile: SearchProfile, + profileOverride?: AISearchProfileOverride, +): SearchProfile { + if (!profileOverride) return profile; + + return { + timeBudgetMs: profileOverride.timeBudgetMs ?? profile.timeBudgetMs, + sampleCount: profileOverride.sampleCount ?? profile.sampleCount, + maxDepth: profileOverride.maxDepth ?? profile.maxDepth, + batchSize: profileOverride.batchSize ?? profile.batchSize, + }; +} + +function getSearchProfile( + state: GameState, + difficulty: Difficulty, + profileOverride?: AISearchProfileOverride, +): SearchProfile { + if (difficulty !== 'master') { + return applySearchProfileOverride(SEARCH_PROFILES[difficulty], profileOverride); + } const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); if (cardsRemaining <= 4) { - return { timeBudgetMs: 3200, sampleCount: 4, maxDepth: cardsRemaining, batchSize: 1 }; + return applySearchProfileOverride( + { timeBudgetMs: 3200, sampleCount: 4, maxDepth: cardsRemaining, batchSize: 1 }, + profileOverride, + ); } if (cardsRemaining <= 6) { - return { timeBudgetMs: 3600, sampleCount: 6, maxDepth: cardsRemaining, batchSize: 1 }; + return applySearchProfileOverride( + { timeBudgetMs: 3600, sampleCount: 6, maxDepth: cardsRemaining, batchSize: 1 }, + profileOverride, + ); } if (cardsRemaining <= 8) { - return { timeBudgetMs: 3900, sampleCount: 8, maxDepth: cardsRemaining, batchSize: 1 }; + return applySearchProfileOverride( + { timeBudgetMs: 3900, sampleCount: 8, maxDepth: cardsRemaining, batchSize: 1 }, + profileOverride, + ); } if (cardsRemaining <= 12) { - return { timeBudgetMs: 4200, sampleCount: 8, maxDepth: 8, batchSize: 1 }; + return applySearchProfileOverride( + { timeBudgetMs: 4200, sampleCount: 8, maxDepth: 8, batchSize: 1 }, + profileOverride, + ); } if (cardsRemaining <= 20) { - return { timeBudgetMs: 4400, sampleCount: 9, maxDepth: 7, batchSize: 2 }; + return applySearchProfileOverride( + { timeBudgetMs: 4350, sampleCount: 12, maxDepth: 5, batchSize: 2 }, + profileOverride, + ); } - return SEARCH_PROFILES.master; + return applySearchProfileOverride(SEARCH_PROFILES.master, profileOverride); } function reportDecisionProgress( onProgress: ((progress: AIDecisionProgress) => void) | undefined, difficulty: Difficulty, startedAt: number, + timing: SearchTimingContext, budgetMs: number, progress: number, batchesCompleted: number, + masterDetails?: MasterProgressDetails, ): void { if (!onProgress) return; onProgress({ difficulty, progress: Math.max(0, Math.min(1, progress)), - elapsedMs: Date.now() - startedAt, + elapsedMs: timing.now() - startedAt, budgetMs, batchesCompleted, + ...(masterDetails ?? {}), }); } -function yieldToBrowser(): Promise { - return new Promise(resolve => setTimeout(resolve, 0)); -} - function handLikelyHasValue( value: number, handSize: number, @@ -476,6 +673,182 @@ function countScopaThreats( return { totalThreats, nextOppCanScopa, secondOppCanScopa, partnerCanScopa }; } +interface ScopaThreatSummary { + totalThreats: number; + nextOppCanScopa: boolean; + secondOppCanScopa: boolean; + partnerCanScopa: boolean; +} + +interface TacticalPriorityLadder { + scopa: number; + settebello: number; + antiScopa: number; + partnerSetup: number; + sevenDenial: number; + denariDenial: number; + material: number; +} + +const TACTICAL_PRIORITY_WEIGHTS = { + scopa: 120000000, + settebello: 20000000, + antiScopa: 950000, + partnerSetup: 45000, + sevenDenial: 2101, + denariDenial: 101, +} as const; + +function clampPriorityBand(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, Math.round(value))); +} + +function sumCardValues(cards: Card[]): number { + return cards.reduce((sum, card) => sum + card.value, 0); +} + +function getPriorityThreatSummary( + afterTable: Card[], + myHand: Card[], + tracker: CardTracker | undefined, + state: GameState, + playerIdx: PlayerIndex, +): ScopaThreatSummary | null { + if (afterTable.length === 0 || sumCardValues(afterTable) > 10) { + return null; + } + + return countScopaThreats(afterTable, myHand, tracker, state, playerIdx); +} + +function evaluateAntiScopaPriority( + afterTable: Card[], + nextIsOpp: boolean, + threats: ScopaThreatSummary | null, +): number { + if (afterTable.length === 0) return 8; + + const tableSum = sumCardValues(afterTable); + let score = 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 (threats) { + if (threats.nextOppCanScopa) score -= 9; + if (threats.secondOppCanScopa) score -= 4; + score -= Math.min(6, threats.totalThreats); + if (!nextIsOpp && threats.partnerCanScopa) score += 1; + } + + if (!nextIsOpp && tableSum >= 11) score += 2; + + return clampPriorityBand(score, -20, 20); +} + +function evaluatePartnerSetupPriority( + afterTable: Card[], + nextIsOpp: boolean, + partnerHandSize: number, + threats: ScopaThreatSummary | null, +): number { + if (afterTable.length === 0 || partnerHandSize === 0) return 0; + + const tableSum = sumCardValues(afterTable); + const denariOnTable = afterTable.filter(card => card.suit === 'denara').length; + const sevensOnTable = afterTable.filter(card => card.value === 7).length; + let score = 0; + + if (!nextIsOpp) { + score += 4; + if (tableSum >= 1 && tableSum <= 10) score += threats?.partnerCanScopa ? 10 : 5; + 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; + } + + return clampPriorityBand(score, -20, 20); +} + +function evaluateSevenDenialPriority( + afterTable: Card[], + capturedCards: Card[], + releasedCard: Card | null, + nextIsOpp: boolean, + need7s: boolean, +): number { + let score = 0; + const capturedSevens = capturedCards.filter(card => card.value === 7).length; + const exposedSevens = afterTable.filter(card => card.value === 7).length; + + score += capturedSevens * (need7s ? 5 : 3); + if (nextIsOpp) { + score -= exposedSevens * (need7s ? 6 : 4); + } else { + score += exposedSevens; + } + + if (releasedCard?.value === 7) { + score -= need7s ? 8 : 5; + } + + return clampPriorityBand(score, -20, 20); +} + +function evaluateDenariDenialPriority( + afterTable: Card[], + capturedCards: Card[], + releasedCard: Card | null, + nextIsOpp: boolean, + behindInDenari: boolean, +): number { + let score = 0; + const capturedDenari = capturedCards.filter(card => card.suit === 'denara').length; + const exposedDenari = afterTable.filter(card => card.suit === 'denara').length; + + score += capturedDenari * (behindInDenari ? 4 : 2); + if (nextIsOpp) { + score -= exposedDenari * (behindInDenari ? 6 : 4); + } else { + score += Math.min(2, exposedDenari); + } + + if (releasedCard?.suit === 'denara') { + score -= behindInDenari ? 7 : 4; + } + + return clampPriorityBand(score, -20, 20); +} + +function scoreTacticalPriorityLadder(priorities: TacticalPriorityLadder): number { + const scopa = clampPriorityBand(priorities.scopa, -2, 2); + const settebello = clampPriorityBand(priorities.settebello, -4, 4); + const antiScopa = clampPriorityBand(priorities.antiScopa, -20, 20); + const partnerSetup = clampPriorityBand(priorities.partnerSetup, -20, 20); + const sevenDenial = clampPriorityBand(priorities.sevenDenial, -20, 20); + const denariDenial = clampPriorityBand(priorities.denariDenial, -20, 20); + const material = clampPriorityBand(priorities.material, -200, 200); + + return ( + scopa * TACTICAL_PRIORITY_WEIGHTS.scopa + + settebello * TACTICAL_PRIORITY_WEIGHTS.settebello + + antiScopa * TACTICAL_PRIORITY_WEIGHTS.antiScopa + + partnerSetup * TACTICAL_PRIORITY_WEIGHTS.partnerSetup + + sevenDenial * TACTICAL_PRIORITY_WEIGHTS.sevenDenial + + denariDenial * TACTICAL_PRIORITY_WEIGHTS.denariDenial + + material + ); +} + /** P(0 threat cards drawn) using hypergeometric approx */ function hypergeometricNone(total: number, threats: number, drawn: number): number { if (drawn >= total) return threats > 0 ? 0 : 1; @@ -496,24 +869,26 @@ export async function chooseMove( difficulty: Difficulty = 'advanced', tracker?: CardTracker, onProgress?: (progress: AIDecisionProgress) => void, + options?: AIChooseMoveOptions, ): Promise { - const startedAt = Date.now(); - const profile = getSearchProfile(state, difficulty); - reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 0, 0); + const timing = createSearchTimingContext(options?.timingSource); + const startedAt = timing.now(); + const profile = getSearchProfile(state, difficulty, options?.profileOverride); + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 0, 0); switch (difficulty) { case 'beginner': { const move = beginnerMove(state, playerIdx, tracker); - reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 1, 1); + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); return move; } case 'advanced': { const move = advancedMove(state, playerIdx, tracker); - reportDecisionProgress(onProgress, difficulty, startedAt, profile.timeBudgetMs, 1, 1); + reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); return move; } case 'master': - return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt); + return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt, timing, options?.rng ?? Math.random); } } @@ -570,42 +945,36 @@ function scoreCaptureBeginner( state: GameState, playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, lastPlay: boolean, ): number { - let score = 100; const allCaptured = [played, ...captured]; const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); const isScopa = afterTable.length === 0; + const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7); + const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); + const threats = getPriorityThreatSummary(afterTable, state.players[playerIdx].hand, undefined, state, playerIdx); + const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; + let material = 20 + captured.length * 14 + phase * captured.length * 4; - // Scopa — but not on the last play (it doesn't count!) - if (isScopa && !lastPlay) score += 600; - else if (isScopa && lastPlay) score += 30; // still captures cards, mildly good + material += allCaptured.filter(c => c.suit === 'denara').length * 8; + material += allCaptured.filter(c => c.value === 7).length * 6; + for (const card of allCaptured) material += primieraVal(card) * 1.2; - if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 450; - // If settebello is on table and we DON'T take it - if (table.some(c => c.suit === 'denara' && c.value === 7) && - !allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score -= 250; - - score += allCaptured.filter(c => c.suit === 'denara').length * 65; - score += captured.length * 30; - score += allCaptured.filter(c => c.value === 7).length * 55; - for (const c of allCaptured) score += primieraVal(c) * 1.8; - - // Basic cooperation: partner next → don't rush to clear - if (!isScopa && !isOpponent(playerIdx, nextPlayer(playerIdx))) { - score += 20; - // Don't clear table when partner could benefit - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - if (afterTable.length > 0 && tableSum >= 1 && tableSum <= 10) score += 25; + if (!isScopa) { + for (const tableCard of afterTable) { + const dupes = countValueInHand(state.players[playerIdx].hand, tableCard.value); + if (dupes >= 1) material += 6; + if (dupes >= 2) material += 4; + } } - // Anti-scopa: don't leave clearable table for opponent - if (!isScopa && nextIsOpp) { - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - if (tableSum <= 10) score -= 140; - if (afterTable.length === 1) score -= 100; - if (tableSum >= 11) score += 60; - } - - return score; + return scoreTacticalPriorityLadder({ + scopa: isScopa && !lastPlay ? 2 : isScopa ? 0 : 0, + settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0, + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), + partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), + sevenDenial: evaluateSevenDenialPriority(afterTable, allCaptured, null, nextIsOpp, false), + denariDenial: evaluateDenariDenialPriority(afterTable, allCaptured, null, nextIsOpp, false), + material, + }) + (isScopa && lastPlay ? 30 : 0); } function scoreDumpBeginner( @@ -613,36 +982,33 @@ function scoreDumpBeginner( playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, hand: Card[], ): number { - let score = 0; const afterTable = [...table, card]; - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); // NEVER dump settebello if (card.suit === 'denara' && card.value === 7) return -5000; + const threats = getPriorityThreatSummary(afterTable, hand, undefined, state, playerIdx); + const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; + let material = -12 + phase * 4; - // Protect valuable cards - if (card.suit === 'denara') score -= 70; - if (card.value === 7) score -= 80; - if (card.value === 6) score -= 35; - if (card.value === 1) score -= 25; - if (card.value >= 8) score += 25 + card.value; + if (card.suit === 'denara') material -= 20; + if (card.value === 7) material -= 22; + if (card.value === 6) material -= 10; + if (card.value === 1) material -= 8; + if (card.value >= 8) material += 12 + card.value; - // Anchor: prefer dumping values you hold duplicates of (you can recapture) const dupes = countValueInHand(hand, card.value); - if (dupes >= 2) score += 45; + if (dupes >= 2) material += 18; + if (dupes >= 3) material += 8; - // Anti-scopa - if (tableSum >= 11) score += 90; - else if (tableSum <= 10 && nextIsOpp) score -= 100; - if (tableSum <= 5 && nextIsOpp) score -= 60; - if (afterTable.length >= 3 && tableSum >= 11) score += 25; - - // Basic partner awareness - if (!isOpponent(playerIdx, nextPlayer(playerIdx))) { - score += 15; - } - - return score; + return scoreTacticalPriorityLadder({ + scopa: 0, + settebello: 0, + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), + partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), + sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, false), + denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, false), + material, + }); } // =========================================================================== @@ -656,7 +1022,7 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr const phase = gamePhase(state); const race = getRaceState(state, playerIdx); const roleContext = getDealerRoleContext(state, playerIdx); - const parity = getParitySnapshot(tracker, player.hand, table); + const rankResidue = getRankResidueSnapshot(tracker, player.hand, table); const next = nextPlayer(playerIdx); const nextIsOpp = isOpponent(playerIdx, next); const partner = partnerOf(playerIdx); @@ -672,14 +1038,14 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr for (const captureSet of captures) { const score = scoreCaptureAdv( card, captureSet, table, state, playerIdx, race, - tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, parity, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, rankResidue, ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { const score = scoreDumpAdv( card, table, state, playerIdx, race, - tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, parity, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, roleContext, rankResidue, ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } } @@ -692,292 +1058,140 @@ function scoreCaptureAdv( played: Card, captured: Card[], table: Card[], state: GameState, playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, - lastPlay: boolean, roleContext: DealerRoleContext, parity: ParitySnapshot | null, + lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null, ): number { - let score = 100; const allCaptured = [played, ...captured]; const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); const isScopa = afterTable.length === 0; - - // --- SCOPA (never on last play!) --- - if (isScopa) { - if (lastPlay) { - score += 40; // still captures cards but no scopa point - } else { - score += 1000 + phase * 350; - } - } - - // --- SETTEBELLO --- + const tableHasSettebello = table.some(c => c.suit === 'denara' && c.value === 7); const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); - if (capturesSettebello) score += 800; - if (table.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) score -= 600; + const threats = getPriorityThreatSummary(afterTable, myHand, tracker, state, playerIdx); + let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6; - // --- DENARI (race-aware) --- - const denariCount = allCaptured.filter(c => c.suit === 'denara').length; - score += denariCount * (race.behindInDenari ? 120 : 65); + material += allCaptured.filter(c => c.suit === 'denara').length * (race.behindInDenari ? 14 : 8); + material += allCaptured.filter(c => c.value === 7).length * (race.need7s ? 8 : 4); + for (const card of allCaptured) material += primieraVal(card) * 2; - // --- CARD COUNT (race-aware) --- - score += captured.length * (race.behindInCards ? 45 : 28) + phase * captured.length * 12; - - // --- PRIMIERA --- - for (const c of allCaptured) score += primieraVal(c) * 3.5; - const sevens = allCaptured.filter(c => c.value === 7).length; - score += sevens * (race.need7s ? 100 : 50); - - // Capturing a 7 in a suit we're missing for primiera const teamPile = getTeamPile(state, playerIdx); - for (const c of allCaptured) { - if (c.value === 7 && !teamPile.some(tc => tc.suit === c.suit && tc.value === 7)) { - score += 75; + for (const card of allCaptured) { + if (card.value === 7 && !teamPile.some(teamCard => teamCard.suit === card.suit && teamCard.value === 7)) { + material += 10; } } - score += scoreCaptureParityPlan(played, captured, afterTable, parity, roleContext, nextIsOpp); + material += Math.round(scoreCaptureRankResiduePlan(played, captured, afterTable, rankResidue, roleContext, nextIsOpp) / 6); + material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); - // --- ANCHOR STRATEGY --- - // Prefer captures that leave table cards matching values we hold (we can recapture) if (!isScopa) { - for (const tc of afterTable) { - const dupes = countValueInHand(myHand, tc.value); - if (dupes >= 1) score += 35; // we hold a card that can recapture this - if (dupes >= 2) score += 25; // even stronger anchor + for (const tableCard of afterTable) { + const dupes = countValueInHand(myHand, tableCard.value); + if (dupes >= 1) material += 7; + if (dupes >= 2) material += 5; - // Check if partner likely holds this value - const partnerProb = partnerLikelyHolds(tc.value, playerIdx, state, tracker, myHand, afterTable); - if (partnerProb > 0.4) score += 30; + const partnerProb = partnerLikelyHolds(tableCard.value, playerIdx, state, tracker, myHand, afterTable); + if (partnerProb > 0.4) material += 6; } } - // --- ANTI-SCOPA (critical) --- - if (!isScopa) { - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - - if (tableSum >= 11) { - score += 120; - } else { - // Only run expensive threat counting when table is actually clearable - const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - if (threats.nextOppCanScopa) score -= 600; - if (threats.secondOppCanScopa) score -= 300; - score -= threats.totalThreats * 85; - - if (tableSum <= 3) score -= 140; - else if (tableSum <= 7) score -= 60; - - if (afterTable.length === 1 && nextIsOpp) score -= 250; - if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 140; - } + if (nextIsOpp && tracker?.isSettebelloUnseen() && !capturesSettebello && afterTable.some(c => c.suit === 'denara' && c.value === 7)) { + material -= 18; } - score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp); - - // --- PARTNER COOPERATION --- - const next = nextPlayer(playerIdx); - if (!isScopa && !isOpponent(playerIdx, next)) { - // Partner plays next - score += 50; - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - - // Leave denari on table for partner to capture - if (afterTable.some(c => c.suit === 'denara') && partnerHandSize > 0) score += 40; - - // Partner scopa setup — check if partner can actually clear - if (afterTable.length > 0 && tableSum >= 1 && tableSum <= 10 && partnerHandSize > 0) { - const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - if (threats.partnerCanScopa) { - score += 200; // whirlwind setup: we clear, opponent dumps, partner clears - } else { - score += 40; // generic setup opportunity - } - } - - // Leave settebello for partner if we can't take it - if (afterTable.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) { - const partnerSettebello = partnerLikelyHolds(7, playerIdx, state, tracker, myHand, afterTable); - if (partnerSettebello > 0.3) score += 60; - } + if (tracker && !isScopa && phase > 0.5 && sumCardValues(afterTable) <= 10) { + const confidence = Math.min(1, tracker.playedCount / 25); + material -= Math.round(confidence * 20); } - // When opponent is next but partner is after — consider 2-step play - if (!isScopa && nextIsOpp) { - const afterOppTurn = nextPlayer(next); - if (!isOpponent(playerIdx, afterOppTurn) && partnerHandSize > 0) { - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - // If table sum ≥11, opponent can't scopa but might dump something partner can benefit from - if (tableSum >= 11) score += 30; - } - } + if (partnerHandSize === 0) material += captured.length * 8; + if (race.aheadOverall && !isScopa && sumCardValues(afterTable) >= 11) material += 10; + 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; - // Endgame: partner finished, maximize own captures - if (partnerHandSize === 0) score += captured.length * 30; - - // --- CARD TRACKER REFINEMENTS --- - if (tracker && !isScopa) { - if (tracker.isSettebelloUnseen() && !capturesSettebello) { - if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) { - score -= 400; - } - } - - // Track which 7s are still unseen — protect primiera - for (const suit of SUITS) { - const sevenId = `${suit}_7`; - if (!tracker.hasBeenPlayed(sevenId)) { - // 7 of this suit still in play - if (afterTable.some(c => c.suit === suit && c.value === 7)) { - if (nextIsOpp) score -= 60; // opponent might grab it - } - } - } - - // Late game: more confident — sharpen penalties - if (phase > 0.5) { - const confidence = Math.min(1, tracker.playedCount / 25); - const afterSum = afterTable.reduce((s, c) => s + c.value, 0); - if (afterTable.length > 0 && afterSum <= 10) { - score -= Math.round(confidence * 120); - } - } - } - - // --- DEFENSIVE POSTURE when ahead --- - if (race.aheadOverall && !isScopa) { - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - // When winning, prefer safe plays (high table sum) - if (tableSum >= 11) score += 50; - if (tableSum <= 5 && nextIsOpp) score -= 60; - } - - if (roleContext.role === 'first-hand' && !isScopa && afterTable.length >= 2) { - score += Math.round(24 * roleContext.tablePressureBias); - } - if (roleContext.role === 'dealer' && !isScopa) { - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - if (tableSum >= 11) score += Math.round(30 * roleContext.controlBias); - } - - return score; + return scoreTacticalPriorityLadder({ + scopa: isScopa && !lastPlay ? 2 : isScopa ? 0 : 0, + settebello: capturesSettebello ? 4 : tableHasSettebello && nextIsOpp ? -4 : tableHasSettebello ? -2 : 0, + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), + partnerSetup: isScopa ? 0 : evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), + sevenDenial: evaluateSevenDenialPriority(afterTable, allCaptured, null, nextIsOpp, race.need7s), + denariDenial: evaluateDenariDenialPriority(afterTable, allCaptured, null, nextIsOpp, race.behindInDenari), + material, + }) + (isScopa && lastPlay ? 40 : 0); } function scoreDumpAdv( card: Card, table: Card[], state: GameState, playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, - lastPlay: boolean, roleContext: DealerRoleContext, parity: ParitySnapshot | null, + lastPlay: boolean, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null, ): number { - let score = 0; const afterTable = [...table, card]; - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); // --- HARD RULES --- if (card.suit === 'denara' && card.value === 7) return -10000; + const threats = getPriorityThreatSummary(afterTable, myHand, tracker, state, playerIdx); + let material = -20 + phase * 6; - // --- CARD PROTECTION (race-aware) --- - if (card.suit === 'denara') score -= (race.behindInDenari ? 140 : 80); - if (card.value === 7) score -= (race.need7s ? 130 : 75); - if (card.value === 6) score -= 55; - if (card.value === 1) score -= 45; - if (card.value >= 8) score += 30 + card.value * 3; + if (card.suit === 'denara') material -= race.behindInDenari ? 28 : 16; + if (card.value === 7) material -= race.need7s ? 26 : 14; + if (card.value === 6) material -= 12; + if (card.value === 1) material -= 10; + if (card.value >= 8) material += 14 + card.value * 2; - // --- ANCHOR STRATEGY --- - // Dump values you hold duplicates of → you can recapture later const dupes = countValueInHand(myHand, card.value); - if (dupes >= 2) score += 80; // strong anchor: dump one, recapture with the other - if (dupes >= 3) score += 40; // even more control + if (dupes >= 2) material += 24; + if (dupes >= 3) material += 10; - // Check if partner likely holds same value → team anchor const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table); - if (partnerProb > 0.4) score += 55; // partner can recapture what we dump + if (partnerProb > 0.4) material += 14; - score += scoreDumpParityPlan(card, afterTable, parity, roleContext, nextIsOpp); + material += Math.round(scoreDumpRankResiduePlan(card, afterTable, rankResidue, roleContext, nextIsOpp) / 6); + material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); - // --- ANTI-SCOPA --- - if (tableSum >= 11) { - score += 150; - } else { - const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - if (threats.nextOppCanScopa) score -= 700; - if (threats.secondOppCanScopa) score -= 350; - score -= threats.totalThreats * 100; - if (tableSum <= 3) score -= 120; - if (afterTable.length === 1 && nextIsOpp) score -= 170; - if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 120; + if (afterTable.length >= 4 && sumCardValues(afterTable) >= 15) material += 10; + if (!nextIsOpp && card.value >= 8) material += 8; - // Whirlwind defense: if we're dumping and the table was empty, we're giving scopa - if (table.length === 0 && nextIsOpp && card.value <= 10) { - score -= 200; // opponent will almost certainly capture our lone card - } - } - - if (afterTable.length >= 4 && tableSum >= 15) score += 40; - - // --- PARTNER SETUP --- - const next = nextPlayer(playerIdx); - if (!isOpponent(playerIdx, next)) { - score += 60; - // Dump creates partner scopa opportunity - if (afterTable.length >= 1 && tableSum >= 1 && tableSum <= 10 && partnerHandSize > 0) { - const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - if (threats.partnerCanScopa) { - score += 180; // actively setting up partner scopa - } else { - score += 50; - } - } - } - - // --- TEAM SIGNALING --- - // Dump low-primiera cards to signal what suits we're NOT collecting - if (!isOpponent(playerIdx, next) && card.value >= 8) { - score += 20; // safe dump before partner's turn, signals we don't need this suit - } - - score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp); - - // --- CARD TRACKING --- if (tracker) { const unseen = tracker.getUnseenCards(myHand, afterTable); let directThreats = 0; - for (const uc of unseen) { - const caps = findCaptures(uc, afterTable); + for (const unseenCard of unseen) { + const caps = findCaptures(unseenCard, afterTable); for (const cap of caps) { - if (cap.length === afterTable.length) { directThreats++; break; } + if (cap.length === afterTable.length) { + directThreats++; + break; + } } } - score -= directThreats * 75; + material -= directThreats * 8; - // Track 7s still in play for (const suit of SUITS) { - if (!tracker.hasBeenPlayed(`${suit}_7`)) { - // Don't dump cards that could let opponent capture an unseen 7 - if (afterTable.some(c => c.suit === suit && c.value === 7) && nextIsOpp) { - score -= 80; - } + if (!tracker.hasBeenPlayed(`${suit}_7`) && nextIsOpp && afterTable.some(c => c.suit === suit && c.value === 7)) { + material -= 10; } } if (phase > 0.5) { const confidence = Math.min(1, tracker.playedCount / 25); - score = Math.round(score * (1 + confidence * 0.35)); + material += Math.round(material * confidence * 0.15); } } - // --- DEFENSIVE when ahead --- - if (race.aheadOverall) { - if (tableSum >= 11) score += 40; - // Prefer high cards when winning (less useful for opponent) - if (card.value >= 8) score += 15; - } + if (table.length === 0 && nextIsOpp) material -= 18; + if (race.aheadOverall && sumCardValues(afterTable) >= 11) material += 8; + if (race.aheadOverall && card.value >= 8) material += 6; + if (roleContext.role === 'first-hand' && afterTable.length >= 2 && sumCardValues(afterTable) >= 8) material += 6; + if (roleContext.role === 'dealer' && nextIsOpp && sumCardValues(afterTable) <= 10) material -= 8; - if (roleContext.role === 'first-hand' && afterTable.length >= 2 && tableSum >= 8) { - score += Math.round(18 * roleContext.tablePressureBias); - } - if (roleContext.role === 'dealer' && nextIsOpp && tableSum <= 10) { - score -= Math.round(24 * roleContext.controlBias); - } - - return score; + return scoreTacticalPriorityLadder({ + scopa: 0, + settebello: 0, + antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats), + partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), + sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, race.need7s), + denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, race.behindInDenari), + material, + }) + (lastPlay ? 0 : 0); } // =========================================================================== @@ -993,7 +1207,7 @@ function tableControlPressure( myHand: Card[], race: RaceState, roleContext: DealerRoleContext, - parity: ParitySnapshot | null, + rankResidue: RankResidueSnapshot | null, ): number { if (afterTable.length === 0) return 0; @@ -1007,8 +1221,16 @@ function tableControlPressure( if (tableSum >= 11) score += 70; if (tableSum <= 10 && nextIsOpp) score -= 110; - if (race.behindInDenari && afterTable.some(card => card.suit === 'denara')) score += 35; - if (race.need7s && afterTable.some(card => card.value === 7)) score += 45; + if (afterTable.some(card => card.suit === 'denara')) { + score += nextIsOpp + ? (race.behindInDenari ? -110 : -45) + : (race.behindInDenari ? 35 : 15); + } + if (afterTable.some(card => card.value === 7)) { + score += nextIsOpp + ? (race.need7s ? -150 : -55) + : (race.need7s ? 45 : 15); + } for (const tableCard of afterTable) { const myAnchors = countValueInHand(myHand, tableCard.value); @@ -1024,7 +1246,6 @@ function tableControlPressure( afterTable, ); score += partnerProb * (nextIsOpp ? 20 : 55); - if (nextHandSize > 0 && nextIsOpp) { const nextProb = handLikelyHasValue( tableCard.value, @@ -1040,11 +1261,917 @@ function tableControlPressure( } if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60; + score += scoreExposedTableCards(afterTable, state, playerIdx, tracker, myHand, race); score += scoreRoleTablePlan(afterTable, roleContext, nextIsOpp); - score += scoreParityTableState(afterTable, parity, roleContext, nextIsOpp); + score += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); return score; } +interface MoveTacticalSummary { + projectedTable: Card[]; + tableSum: number; + clearsTable: boolean; + capturedDenariCount: number; + capturedSevenCount: number; + capturesSettebello: boolean; + exposedDenariCount: number; + exposedSevenCount: number; + highQuietRelease: boolean; + sameValueAnchorsRemaining: number; +} + +function summarizeMoveTactics( + move: AIMove, + hand: Card[], + table: Card[], +): MoveTacticalSummary { + const projectedTable = move.capture.length > 0 + ? 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 exposedDenariCount = projectedTable.filter(card => card.suit === 'denara').length; + const exposedSevenCount = projectedTable.filter(card => card.value === 7).length; + + return { + projectedTable, + tableSum, + clearsTable: move.capture.length > 0 && projectedTable.length === 0, + capturedDenariCount: capturedCards.filter(card => card.suit === 'denara').length, + capturedSevenCount: capturedCards.filter(card => card.value === 7).length, + capturesSettebello: capturedCards.some(card => card.suit === 'denara' && card.value === 7), + exposedDenariCount, + exposedSevenCount, + highQuietRelease: move.capture.length === 0 && move.card.value >= 8 && move.card.suit !== 'denara', + sameValueAnchorsRemaining: Math.max(0, countValueInHand(hand, move.card.value) - 1), + }; +} + +function isForcingSearchMove(summary: MoveTacticalSummary, race: RaceState): boolean { + return summary.clearsTable + || summary.capturesSettebello + || summary.capturedSevenCount > 0 + || summary.capturedDenariCount >= 2 + || (race.behindInDenari && summary.capturedDenariCount > 0); +} + +function isPriorityControlQuietMove( + move: AIMove, + summary: MoveTacticalSummary, + nextIsOpp: boolean, + roleContext: DealerRoleContext, +): boolean { + if (move.capture.length > 0) return false; + + if ( + roleContext.defendingDealerAdvantage + && move.card.suit !== 'denara' + && move.card.value <= 4 + && summary.tableSum >= 18 + ) { + return true; + } + + if (!summary.highQuietRelease && summary.sameValueAnchorsRemaining === 0) return false; + if (nextIsOpp && summary.tableSum < 11) return false; + if (nextIsOpp && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0)) return false; + + return roleContext.defendingDealerAdvantage + || summary.sameValueAnchorsRemaining > 0 + || summary.tableSum >= 15 + || summary.projectedTable.length >= 5; +} + +function scoreHandStructure( + hand: Card[], + table: Card[], + roleContext: DealerRoleContext, +): number { + if (hand.length === 0) 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) { + score += Math.round((value >= 8 ? 32 : 18) * roleContext.pairPreservingBias); + } + if (counts[value] >= 3) { + score += 14; + } + } + + for (const card of hand) { + const captures = findCaptures(card, table); + if (captures.length > 0) { + let bestCaptureScore = 0; + for (const capture of captures) { + let captureScore = capture.length * 14; + if (capture.some(captured => captured.suit === 'denara')) captureScore += 16; + if (capture.some(captured => captured.value === 7)) captureScore += 20; + if (capture.length === table.length) captureScore += 90; + if (captureScore > bestCaptureScore) bestCaptureScore = captureScore; + } + score += bestCaptureScore; + } else { + if (card.value >= 8) score += 12; + if (card.suit !== 'denara' && card.value >= 8) score += 8; + if (card.value <= 3 && roleContext.defendingDealerAdvantage) score += 10; + } + + if (card.suit === 'denara') score += 10; + if (card.value === 7) score += 16; + } + + return score; +} + +function scorePlayerVisibleTempo(state: GameState, playerIdx: PlayerIndex): number { + const hand = state.players[playerIdx].hand; + if (hand.length === 0) return 0; + + const roleContext = getDealerRoleContext(state, playerIdx); + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + let bestMoveScore = -Infinity; + let safeReleaseCount = 0; + let forcingCount = 0; + + for (const move of getLegalMoves(state, playerIdx)) { + const summary = summarizeMoveTactics(move, hand, state.table); + let moveScore = 0; + + if (summary.clearsTable) moveScore += 160; + moveScore += summary.capturedDenariCount * 26; + moveScore += summary.capturedSevenCount * 28; + if (summary.tableSum >= 11) moveScore += 24; + if (summary.tableSum <= 10 && nextIsOpp) moveScore -= 36; + if (summary.highQuietRelease && summary.tableSum >= 11) moveScore += 38; + if (summary.sameValueAnchorsRemaining > 0) moveScore += summary.sameValueAnchorsRemaining * 12; + moveScore += scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp); + + if (moveScore > bestMoveScore) bestMoveScore = moveScore; + if (isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext)) safeReleaseCount++; + if (summary.clearsTable || summary.capturedDenariCount > 0 || summary.capturedSevenCount > 0) forcingCount++; + } + + if (!Number.isFinite(bestMoveScore)) bestMoveScore = 0; + + return Math.round( + bestMoveScore + + safeReleaseCount * 18 + + forcingCount * 10 + + scoreHandStructure(hand, state.table, roleContext) * 0.4, + ); +} + +interface RankedRootMove { + index: number; + move: AIMove; + key: string; + quick: number; + isCapture: boolean; + forcing: boolean; + priorityControlQuiet: boolean; +} + +interface MasterSearchProgressState { + evaluationsCompleted: number; + totalEvaluations: number; + batchesCompleted: number; + completedDepth: number; + aspirationExpansions: number; + timedOut: boolean; +} + +interface MasterDepthResult { + completed: boolean; + bestMove: AIMove; + bestKey: string; + bestScore: number; +} + +type TranspositionBound = 'exact' | 'lower' | 'upper'; + +interface TranspositionEntry { + key: string; + bestMove: AIMove | null; + bestMoveKey: string | null; + depth: number; + score: number; + bound: TranspositionBound; +} + +interface MasterRootWorkspace { + moveScores: number[]; + orderedMoves: RankedRootMove[]; + pvMoves: RankedRootMove[]; + hashMoves: RankedRootMove[]; + forcingMoves: RankedRootMove[]; + controlQuietMoves: RankedRootMove[]; + killerHistoryQuietMoves: RankedRootMove[]; + remainingMoves: RankedRootMove[]; +} + +interface SampleHandAssignment { + playerIdx: PlayerIndex; + handSize: number; +} + +interface SampleHandBucket { + assignment: SampleHandAssignment; + cards: Card[]; +} + +interface SearchHeuristics { + killerMoves: Map; + historyScores: Map; +} + +interface AspirationWindow { + alpha: number; + beta: number; +} + +type AspirationFailure = 'lower' | 'upper'; + +const ASPIRATION_BASE_WINDOW = 120; +const EARLY_TURN_ASPIRATION_BASE_WINDOW = 180; +const ASPIRATION_MAX_EXPANSIONS = 5; +const EARLY_TURN_MIN_REMAINING_BUDGET_MS = 420; +const EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION = 0.72; +const KILLER_MOVE_SLOTS = 2; +const MAX_EXACT_SAMPLE_ASSIGNMENTS = 48; +const MAX_FOCUSED_ASSIGNMENT_CARDS = 8; +const ROOT_QUICK_PRIOR_FACTOR = 0.2; + +function isQuietMove(move: AIMove): boolean { + return move.capture.length === 0; +} + +function getQuietHistoryScore( + heuristics: SearchHeuristics, + move: AIMove, +): number { + return heuristics.historyScores.get(moveKey(move)) ?? 0; +} + +function getKillerMoveRank( + heuristics: SearchHeuristics, + ply: number, + move: AIMove, +): number { + const killers = heuristics.killerMoves.get(ply); + if (!killers || killers.length === 0) return -1; + return killers.indexOf(moveKey(move)); +} + +function compareQuietMovePriority( + left: { move: AIMove; quick: number }, + right: { move: AIMove; quick: number }, + heuristics: SearchHeuristics, + ply: number, +): number { + const leftKillerRank = getKillerMoveRank(heuristics, ply, left.move); + const rightKillerRank = getKillerMoveRank(heuristics, ply, right.move); + const leftKillerOrder = leftKillerRank === -1 ? Number.POSITIVE_INFINITY : leftKillerRank; + const rightKillerOrder = rightKillerRank === -1 ? Number.POSITIVE_INFINITY : rightKillerRank; + + if (leftKillerOrder !== rightKillerOrder) { + return leftKillerOrder - rightKillerOrder; + } + + const historyDelta = getQuietHistoryScore(heuristics, right.move) - getQuietHistoryScore(heuristics, left.move); + if (historyDelta !== 0) return historyDelta; + + return right.quick - left.quick; +} + +function recordQuietCutoff( + heuristics: SearchHeuristics, + move: AIMove, + ply: number, + depth: number, +): void { + if (!isQuietMove(move)) return; + + const key = moveKey(move); + const killers = heuristics.killerMoves.get(ply) ?? []; + const updatedKillers = [key, ...killers.filter(existingKey => existingKey !== key)].slice(0, KILLER_MOVE_SLOTS); + heuristics.killerMoves.set(ply, updatedKillers); + + const historyBonus = Math.max(1, depth) * Math.max(1, depth); + heuristics.historyScores.set(key, (heuristics.historyScores.get(key) ?? 0) + historyBonus); +} + +function createAspirationWindow( + previousScore: number | undefined, + depth: number, + sampleCount: number, + minimumHalfWindow: number, +): AspirationWindow { + if (previousScore === undefined) { + return { alpha: -Infinity, beta: Infinity }; + } + + const halfWindow = Math.max( + minimumHalfWindow, + Math.round(sampleCount * 45 + depth * 24), + ); + + return { + alpha: previousScore - halfWindow, + beta: previousScore + halfWindow, + }; +} + +function widenAspirationWindow( + window: AspirationWindow, + failingBound: AspirationFailure, + expansion: number, +): AspirationWindow { + if (failingBound === 'lower') { + return { + alpha: window.alpha - expansion, + beta: window.beta, + }; + } + + return { + alpha: window.alpha, + beta: window.beta + expansion, + }; +} + +function classifyAspirationFailure( + score: number, + window: AspirationWindow, +): AspirationFailure | undefined { + if (score <= window.alpha) return 'lower'; + if (score >= window.beta) return 'upper'; + return undefined; +} + +function searchPrincipalVariationChild( + state: GameState, + depth: number, + alpha: number, + beta: number, + myTeam: 0 | 1, + rootPlayer: PlayerIndex, + phase: number, + deadline: number, + timing: SearchTimingContext, + tracker: CardTracker | undefined, + transpositionTable: Map, + heuristics: SearchHeuristics, + ply: number, + isFirstMove: boolean, + maximizing: boolean, +): number { + if (isFirstMove) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + + if (maximizing) { + const scoutBeta = Number.isFinite(alpha) ? Math.min(beta, alpha + 1) : beta; + if (!(scoutBeta > alpha)) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + + const scoutScore = alphaBeta( + state, + depth, + alpha, + scoutBeta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + if (scoutScore > alpha && scoutScore < beta && timing.now() <= deadline) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + return scoutScore; + } + + const scoutAlpha = Number.isFinite(beta) ? Math.max(alpha, beta - 1) : alpha; + if (!(scoutAlpha < beta)) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + + const scoutScore = alphaBeta( + state, + depth, + scoutAlpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + if (scoutScore < beta && scoutScore > alpha && timing.now() <= deadline) { + return alphaBeta( + state, + depth, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply, + ); + } + return scoutScore; +} + +function rankRootMoves( + legalMoves: AIMove[], + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + lastPlay: boolean, + race: RaceState, + roleContext: DealerRoleContext, + rankResidue: RankResidueSnapshot | null, +): RankedRootMove[] { + const hand = state.players[playerIdx].hand; + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + + 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), + }; + }) + .sort((a, b) => b.quick - a.quick) + .map((rankedMove, index) => ({ + ...rankedMove, + index, + isCapture: rankedMove.move.capture.length > 0, + })); +} + +function createMasterRootWorkspace(rootMoveCount: number): MasterRootWorkspace { + return { + moveScores: new Array(rootMoveCount).fill(0), + orderedMoves: [], + pvMoves: [], + hashMoves: [], + forcingMoves: [], + controlQuietMoves: [], + killerHistoryQuietMoves: [], + remainingMoves: [], + }; +} + +function resetMasterRootWorkspace(workspace: MasterRootWorkspace): void { + workspace.orderedMoves.length = 0; + workspace.pvMoves.length = 0; + workspace.hashMoves.length = 0; + workspace.forcingMoves.length = 0; + workspace.controlQuietMoves.length = 0; + workspace.killerHistoryQuietMoves.length = 0; + workspace.remainingMoves.length = 0; +} + +function appendRankedRootMoves(target: RankedRootMove[], source: RankedRootMove[]): void { + for (const rankedMove of source) { + target.push(rankedMove); + } +} + +function orderRootMovesForDepth( + rankedMoves: RankedRootMove[], + previousBestKey: string | undefined, + ttEntry: TranspositionEntry | undefined, + heuristics: SearchHeuristics, + workspace: MasterRootWorkspace, +): RankedRootMove[] { + if (rankedMoves.length <= 1) return rankedMoves; + + resetMasterRootWorkspace(workspace); + const hashMoveKey = ttEntry?.bestMoveKey ?? undefined; + + for (const rankedMove of rankedMoves) { + const quietMoveBoost = !rankedMove.isCapture + && ( + getKillerMoveRank(heuristics, 0, rankedMove.move) !== -1 + || getQuietHistoryScore(heuristics, rankedMove.move) > 0 + ); + + if (previousBestKey && rankedMove.key === previousBestKey) { + workspace.pvMoves.push(rankedMove); + continue; + } + + if (hashMoveKey && rankedMove.key === hashMoveKey) { + workspace.hashMoves.push(rankedMove); + continue; + } + + if (rankedMove.forcing) { + workspace.forcingMoves.push(rankedMove); + continue; + } + + if (rankedMove.priorityControlQuiet) { + workspace.controlQuietMoves.push(rankedMove); + continue; + } + + if (quietMoveBoost) { + workspace.killerHistoryQuietMoves.push(rankedMove); + continue; + } + + workspace.remainingMoves.push(rankedMove); + } + + workspace.killerHistoryQuietMoves.sort((left, right) => compareQuietMovePriority(left, right, heuristics, 0)); + + appendRankedRootMoves(workspace.orderedMoves, workspace.pvMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.hashMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.forcingMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.controlQuietMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.killerHistoryQuietMoves); + appendRankedRootMoves(workspace.orderedMoves, workspace.remainingMoves); + + return workspace.orderedMoves; +} + +function selectBestRootMove( + rankedMoves: RankedRootMove[], + moveScores: number[], +): { bestMove: AIMove; bestKey: string; bestScore: number } { + let bestRootMove = rankedMoves[0]; + let bestScore = moveScores[bestRootMove.index] ?? 0; + + for (const rankedMove of rankedMoves) { + const totalScore = moveScores[rankedMove.index] ?? 0; + if (totalScore > bestScore) { + bestScore = totalScore; + bestRootMove = rankedMove; + } + } + + return { + bestMove: bestRootMove.move, + bestKey: bestRootMove.key, + bestScore, + }; +} + +function getMasterProgress( + progressState: MasterSearchProgressState, + startedAt: number, + budgetMs: number, + timing: SearchTimingContext, +): number { + return Math.max( + progressState.evaluationsCompleted / progressState.totalEvaluations, + Math.min(1, (timing.now() - startedAt) / budgetMs), + ); +} + +function buildMasterProgressDetails( + progressState: MasterSearchProgressState, + cardsRemaining: number, + sampleCount: number, + maxDepth: number, + rootMoveCount: number, +): MasterProgressDetails { + return { + cardsRemaining, + sampleCount, + maxDepth, + completedDepth: progressState.completedDepth, + rootMoveCount, + timedOut: progressState.timedOut, + aspirationExpansions: progressState.aspirationExpansions, + }; +} + +function scoreControlOverrideCandidate( + move: AIMove, + state: GameState, + playerIdx: PlayerIndex, + race: RaceState, + roleContext: DealerRoleContext, +): number { + const hand = state.players[playerIdx].hand; + const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); + const summary = summarizeMoveTactics(move, hand, state.table); + const projectedHand = hand.filter(card => card.id !== move.card.id); + let score = Math.round(scoreHandStructure(projectedHand, summary.projectedTable, roleContext) * 0.55); + + score += summary.projectedTable.length * 48; + score += summary.tableSum >= 11 ? 90 + summary.tableSum * 8 : -260; + + if (move.capture.length === 0) { + if (summary.highQuietRelease) score += 220; + if (move.card.suit !== 'denara' && move.card.value <= 3) score += roleContext.defendingDealerAdvantage ? 260 : 70; + if (nextIsOpp && summary.projectedTable.length >= 5) score += 110; + if ( + nextIsOpp + && summary.highQuietRelease + && summary.projectedTable.length >= 5 + && summary.tableSum >= 24 + && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0) + ) { + score += 260; + } + } else { + if (!isForcingSearchMove(summary, race)) score -= 200; + if (nextIsOpp && summary.projectedTable.length <= 3) score -= 150; + if (nextIsOpp) score -= summary.exposedDenariCount * 90; + if (nextIsOpp) score -= summary.exposedSevenCount * 70; + if ( + nextIsOpp + && !summary.clearsTable + && !summary.capturesSettebello + && summary.capturedSevenCount === 0 + && summary.projectedTable.length <= 2 + && summary.tableSum < 18 + ) { + score -= 220; + } + } + + if (roleContext.defendingDealerAdvantage && move.capture.length === 0 && summary.tableSum >= 18) { + score += 180; + } + + if (nextIsOpp && summary.projectedTable.length > 0 && summary.tableSum <= 10) { + score -= 220; + } + + return score; +} + +function findStrategicControlOverride( + legalMoves: AIMove[], + state: GameState, + playerIdx: PlayerIndex, + race: RaceState, + roleContext: DealerRoleContext, +): AIMove | undefined { + if (legalMoves.length <= 1) return undefined; + if (isLastPlay(state, playerIdx)) return undefined; + if (!isOpponent(playerIdx, nextPlayer(playerIdx))) return undefined; + + let bestQuiet: + | { move: AIMove; score: number } + | undefined; + let bestCapture: + | { move: AIMove; score: number } + | undefined; + + for (const move of legalMoves) { + const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext); + if (move.capture.length === 0) { + if (!bestQuiet || score > bestQuiet.score) bestQuiet = { move, score }; + continue; + } + + if (!bestCapture || score > bestCapture.score) bestCapture = { move, score }; + } + + if (!bestQuiet) return undefined; + + const quietSummary = summarizeMoveTactics(bestQuiet.move, state.players[playerIdx].hand, state.table); + const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== bestQuiet.move.card.id); + const duplicateHighValues = new Set( + projectedHand.filter(card => projectedHand.some(other => other.id !== card.id && other.value === card.value && card.value >= 8)) + .map(card => card.value), + ).size; + const dealerControlQuiet = roleContext.role === 'dealer' + && bestCapture !== undefined + && bestQuiet.score >= bestCapture.score + 220 + && bestQuiet.move.card.suit !== 'denara' + && bestQuiet.move.card.value <= 3 + && quietSummary.projectedTable.length >= 5 + && quietSummary.tableSum >= 18 + && duplicateHighValues > 0; + + if (dealerControlQuiet) { + return bestQuiet.move; + } + + if (!bestCapture) return undefined; + + const captureSummary = summarizeMoveTactics(bestCapture.move, state.players[playerIdx].hand, state.table); + const antiScopaControlQuiet = bestQuiet.score >= bestCapture.score + 120 + && bestQuiet.move.card.suit !== 'denara' + && bestQuiet.move.card.value >= 8 + && quietSummary.projectedTable.length >= 5 + && quietSummary.tableSum >= 24 + && state.table.some(card => card.suit === 'denara' || card.value === 7) + && bestCapture.move.card.value <= 5 + && captureSummary.capturedSevenCount === 0 + && !captureSummary.clearsTable + && !captureSummary.capturesSettebello + && captureSummary.projectedTable.length <= 3; + + return antiScopaControlQuiet ? bestQuiet.move : undefined; +} + +async function evaluateMasterDepth( + state: GameState, + samples: GameState[], + orderedMoves: RankedRootMove[], + depth: number, + aspirationWindow: AspirationWindow, + playerIdx: PlayerIndex, + myTeam: 0 | 1, + phase: number, + deadline: number, + tracker: CardTracker | undefined, + onProgress: ((progress: AIDecisionProgress) => void) | undefined, + profile: SearchProfile, + startedAt: number, + timing: SearchTimingContext, + progressState: MasterSearchProgressState, + transpositionTable: Map, + heuristics: SearchHeuristics, + rootWorkspace: MasterRootWorkspace, + cardsRemaining: number, + sampleCount: number, + rootMoveCount: number, +): Promise { + const moveScores = rootWorkspace.moveScores; + moveScores.fill(0); + for (const orderedMove of orderedMoves) { + moveScores[orderedMove.index] = orderedMove.quick * ROOT_QUICK_PRIOR_FACTOR; + } + + for (let start = 0; start < samples.length; start += profile.batchSize) { + if (timing.now() > deadline) { + progressState.timedOut = true; + return { completed: false, ...selectBestRootMove(orderedMoves, moveScores) }; + } + + const end = Math.min(start + profile.batchSize, samples.length); + for (let sampleIdx = start; sampleIdx < end; sampleIdx++) { + const sample = samples[sampleIdx]; + let sampleAlpha = aspirationWindow.alpha; + const sampleBeta = aspirationWindow.beta; + let isFirstRootMove = true; + + for (const orderedMove of orderedMoves) { + timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); + if (timing.now() > deadline) { + progressState.timedOut = true; + return { completed: false, ...selectBestRootMove(orderedMoves, moveScores) }; + } + + const result = applyMove( + sample, + playerIdx, + orderedMove.move.card, + orderedMove.move.capture.length > 0 ? orderedMove.move.capture : undefined, + ); + const score = searchPrincipalVariationChild( + result.nextState, + depth - 1, + sampleAlpha, + sampleBeta, + myTeam, + playerIdx, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + 1, + isFirstRootMove, + true, + ); + moveScores[orderedMove.index] += score; + if (score > sampleAlpha) { + sampleAlpha = score; + } + progressState.evaluationsCompleted++; + isFirstRootMove = false; + } + } + + progressState.batchesCompleted++; + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), + progressState.batchesCompleted, + buildMasterProgressDetails( + progressState, + cardsRemaining, + sampleCount, + profile.maxDepth, + rootMoveCount, + ), + ); + + if (end < samples.length && timing.now() < deadline) { + await timing.yieldToHost(); + } + } + + return { completed: true, ...selectBestRootMove(orderedMoves, moveScores) }; +} + async function masterMove( state: GameState, playerIdx: PlayerIndex, @@ -1052,92 +2179,199 @@ async function masterMove( onProgress: ((progress: AIDecisionProgress) => void) | undefined, profile: SearchProfile, startedAt: number, + timing: SearchTimingContext, + rng: RandomSource, ): Promise { const myTeam = teamOf(playerIdx); const phase = gamePhase(state); + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); const legalMoves = getLegalMoves(state, playerIdx); + const rootMoveCount = legalMoves.length; if (legalMoves.length === 1) { - reportDecisionProgress(onProgress, 'master', startedAt, profile.timeBudgetMs, 1, 1); + reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { + cardsRemaining, + sampleCount: 1, + maxDepth: 1, + completedDepth: 1, + rootMoveCount, + timedOut: false, + aspirationExpansions: 0, + }); return legalMoves[0]; } const deadline = startedAt + profile.timeBudgetMs; - // Quick-eval move ordering for better pruning const lastPlay = isLastPlay(state, playerIdx); const race = getRaceState(state, playerIdx); const roleContext = getDealerRoleContext(state, playerIdx); - const parity = getParitySnapshot(tracker, state.players[playerIdx].hand, state.table); - const quickScored = legalMoves.map(m => ({ - move: m, - quick: quickEval(m, state, playerIdx, tracker, lastPlay, race, roleContext, parity), - })); - quickScored.sort((a, b) => b.quick - a.quick); - const sortedMoves = quickScored.map(qs => qs.move); + const rankResidue = getRankResidueSnapshot(tracker, state.players[playerIdx].hand, state.table); + const rankedMoves = rankRootMoves( + legalMoves, + state, + playerIdx, + tracker, + lastPlay, + race, + roleContext, + rankResidue, + ); + const controlOverride = findStrategicControlOverride(legalMoves, state, playerIdx, race, roleContext); + 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; + } + const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount, rng); + const sampleCount = samples.length; + const transpositionTable = new Map(); + const heuristics: SearchHeuristics = { + killerMoves: new Map(), + historyScores: new Map(), + }; + const rootWorkspace = createMasterRootWorkspace(rankedMoves.length); + const rootStateKey = buildSearchStateKey(state); - const moveScores = new Map(); - for (const m of sortedMoves) moveScores.set(moveKey(m), 0); + const progressState: MasterSearchProgressState = { + evaluationsCompleted: 0, + totalEvaluations: Math.max(1, samples.length * rankedMoves.length * profile.maxDepth), + batchesCompleted: 0, + completedDepth: 0, + aspirationExpansions: 0, + timedOut: false, + }; - const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount); + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + 0, + 0, + buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), + ); - let timedOut = false; - let batchesCompleted = 0; - let evaluationsCompleted = 0; - const totalEvaluations = Math.max(1, samples.length * sortedMoves.length); + let previousBestKey: string | undefined; + let lastCompletedDepth: MasterDepthResult | undefined; + let lastCompletedDepthElapsedMs: number | undefined; - for (let start = 0; start < samples.length; start += profile.batchSize) { - if (timedOut || Date.now() > deadline) break; + for (let depth = 1; depth <= profile.maxDepth; depth++) { + const depthStartedAt = timing.now(); + if (depthStartedAt > deadline) { + progressState.timedOut = true; + break; + } - const batch = samples.slice(start, start + profile.batchSize); - for (const sample of batch) { - for (const move of sortedMoves) { - if (Date.now() > deadline) { - timedOut = true; - break; - } - - const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined); - const score = alphaBeta( - result.nextState, - profile.maxDepth - 1, - -Infinity, - Infinity, - myTeam, - playerIdx, - phase, - deadline, - tracker, - ); - moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score); - evaluationsCompleted++; + if (cardsRemaining > 20) { + const remainingBudgetMs = deadline - depthStartedAt; + if (remainingBudgetMs < EARLY_TURN_MIN_REMAINING_BUDGET_MS) { + break; } - if (timedOut) break; + if ( + lastCompletedDepthElapsedMs !== undefined + && lastCompletedDepthElapsedMs >= profile.timeBudgetMs * EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION + ) { + break; + } } - batchesCompleted++; - reportDecisionProgress( - onProgress, - 'master', - startedAt, - profile.timeBudgetMs, - Math.max(evaluationsCompleted / totalEvaluations, Math.min(1, (Date.now() - startedAt) / profile.timeBudgetMs)), - batchesCompleted, + const aspirationHalfWindowFloor = cardsRemaining > 20 || rootMoveCount >= 8 + ? EARLY_TURN_ASPIRATION_BASE_WINDOW + : ASPIRATION_BASE_WINDOW; + let aspirationWindow = createAspirationWindow( + lastCompletedDepth?.bestScore, + depth, + samples.length, + aspirationHalfWindowFloor, ); + let depthResult: MasterDepthResult | undefined; - if (!timedOut && start + profile.batchSize < samples.length && Date.now() < deadline) { - await yieldToBrowser(); + for (let expansion = 0; expansion <= ASPIRATION_MAX_EXPANSIONS; expansion++) { + if (timing.now() > deadline) { + progressState.timedOut = true; + break; + } + + const rootEntry = transpositionTable.get(rootStateKey); + const orderedMoves = orderRootMovesForDepth(rankedMoves, previousBestKey, rootEntry, heuristics, rootWorkspace); + depthResult = await evaluateMasterDepth( + state, + samples, + orderedMoves, + depth, + aspirationWindow, + playerIdx, + myTeam, + phase, + deadline, + tracker, + onProgress, + profile, + startedAt, + timing, + progressState, + transpositionTable, + heuristics, + rootWorkspace, + cardsRemaining, + sampleCount, + rankedMoves.length, + ); + + if (!depthResult.completed) { + break; + } + + const failingBound = classifyAspirationFailure(depthResult.bestScore, aspirationWindow); + if (!failingBound) { + lastCompletedDepth = depthResult; + lastCompletedDepthElapsedMs = timing.now() - depthStartedAt; + previousBestKey = depthResult.bestKey; + progressState.completedDepth = depth; + break; + } + + progressState.aspirationExpansions++; + const windowWidth = Number.isFinite(aspirationWindow.alpha) && Number.isFinite(aspirationWindow.beta) + ? aspirationWindow.beta - aspirationWindow.alpha + : aspirationHalfWindowFloor; + aspirationWindow = widenAspirationWindow( + aspirationWindow, + failingBound, + Math.max(aspirationHalfWindowFloor, windowWidth * 2), + ); + } + + if (!depthResult?.completed || lastCompletedDepth !== depthResult) { + break; + } + + if (depth < profile.maxDepth && timing.now() < deadline) { + await timing.yieldToHost(); } } - let bestMove = sortedMoves[0]; - let bestScore = -Infinity; - for (const move of sortedMoves) { - const totalScore = moveScores.get(moveKey(move)) ?? 0; - if (totalScore > bestScore) { bestScore = totalScore; bestMove = move; } - } + const bestMove = lastCompletedDepth?.bestMove ?? rankedMoves[0].move; - reportDecisionProgress(onProgress, 'master', startedAt, profile.timeBudgetMs, 1, batchesCompleted); + reportDecisionProgress( + onProgress, + 'master', + startedAt, + timing, + profile.timeBudgetMs, + 1, + progressState.batchesCompleted, + buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), + ); return bestMove; } @@ -1146,15 +2380,24 @@ function quickEval( tracker: CardTracker | undefined, lastPlay: boolean, race: RaceState, roleContext: DealerRoleContext, - parity: ParitySnapshot | null, + rankResidue: RankResidueSnapshot | null, ): 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 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'); // Scopa (not on last play!) if (move.capture.length > 0 && projectedTable.length === 0) { @@ -1163,10 +2406,25 @@ function quickEval( // 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 += allCaptured.filter(c => c.suit === 'denara').length * (race.behindInDenari ? 135 : 95); + 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; @@ -1175,6 +2433,16 @@ function quickEval( 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; @@ -1186,7 +2454,30 @@ function quickEval( 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 -= 120; + 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; + } } // Partner awareness @@ -1196,9 +2487,9 @@ function quickEval( if (sum >= 1 && sum <= 10) score += 40; // partner might scopa } - score += scoreCaptureParityPlan(move.card, move.capture, projectedTable, parity, roleContext, nextIsOpp); + score += scoreCaptureRankResiduePlan(move.card, move.capture, projectedTable, rankResidue, roleContext, nextIsOpp); if (move.capture.length === 0) { - score += scoreDumpParityPlan(move.card, projectedTable, parity, roleContext, nextIsOpp); + score += scoreDumpRankResiduePlan(move.card, projectedTable, rankResidue, roleContext, nextIsOpp); } score += tableControlPressure( @@ -1209,9 +2500,40 @@ function quickEval( projectedHand, race, roleContext, - parity, + 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; } @@ -1220,6 +2542,119 @@ function moveKey(move: AIMove): string { return `${move.card.id}|${capIds}`; } +function stableCardCollectionKey(cards: Card[]): string { + return cards.map(card => card.id).sort().join(','); +} + +function buildSearchStateKey(state: GameState): string { + const playerKeys = state.players.map((player, index) => { + const handKey = stableCardCollectionKey(player.hand); + const pileKey = stableCardCollectionKey(player.pile); + return `p${index}h:${handKey}|p${index}p:${pileKey}|p${index}s:${player.scope}`; + }).join('|'); + + return [ + `cp:${state.currentPlayer}`, + `d:${state.dealer}`, + `l:${state.lastCapturTeam ?? 'null'}`, + `t:${stableCardCollectionKey(state.table)}`, + playerKeys, + ].join('|'); +} + +function getValidHashMove( + moves: AIMove[], + entry: TranspositionEntry | undefined, +): AIMove | undefined { + if (!entry?.bestMoveKey) return undefined; + + return moves.find(move => moveKey(move) === entry.bestMoveKey); +} + +function orderSearchMoves( + moves: AIMove[], + state: GameState, + playerIdx: PlayerIndex, + rootPlayer: PlayerIndex, + tracker: CardTracker | undefined, + pvMove: AIMove | undefined, + hashMove: AIMove | undefined, + heuristics: SearchHeuristics, + ply: number, +): AIMove[] { + 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 rankedMoves = moves + .map(move => ({ + move, + key: moveKey(move), + quick: quickEval(move, state, playerIdx, tracker, lastPlay, race, roleContext, rankResidue), + })) + .sort((a, b) => b.quick - a.quick); + + const pvMoveKey = pvMove ? moveKey(pvMove) : undefined; + const hashMoveKey = hashMove ? moveKey(hashMove) : undefined; + const pvMoves: typeof rankedMoves = []; + const hashMoves: typeof rankedMoves = []; + const forcingMoves: typeof rankedMoves = []; + const controlQuietMoves: typeof rankedMoves = []; + const killerHistoryQuietMoves: typeof rankedMoves = []; + const remainingMoves: typeof rankedMoves = []; + + for (const rankedMove of rankedMoves) { + const moveSummary = summarizeMoveTactics(rankedMove.move, hand, state.table); + const quietMoveBoost = isQuietMove(rankedMove.move) + && ( + getKillerMoveRank(heuristics, ply, rankedMove.move) !== -1 + || getQuietHistoryScore(heuristics, rankedMove.move) > 0 + ); + + if (pvMoveKey && rankedMove.key === pvMoveKey) { + pvMoves.push(rankedMove); + continue; + } + + if (hashMoveKey && rankedMove.key === hashMoveKey) { + hashMoves.push(rankedMove); + continue; + } + + if (isForcingSearchMove(moveSummary, race)) { + forcingMoves.push(rankedMove); + continue; + } + + if (isPriorityControlQuietMove(rankedMove.move, moveSummary, nextIsOpp, roleContext)) { + controlQuietMoves.push(rankedMove); + continue; + } + + if (quietMoveBoost) { + killerHistoryQuietMoves.push(rankedMove); + continue; + } + + remainingMoves.push(rankedMove); + } + + killerHistoryQuietMoves.sort((left, right) => compareQuietMovePriority(left, right, heuristics, ply)); + + return [ + ...pvMoves, + ...hashMoves, + ...forcingMoves, + ...controlQuietMoves, + ...killerHistoryQuietMoves, + ...remainingMoves, + ].map(rankedMove => rankedMove.move); +} + function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] { const moves: AIMove[] = []; const player = state.players[playerIdx]; @@ -1235,27 +2670,388 @@ function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] { return moves; } +function createSampleHandAssignments(state: GameState, playerIdx: PlayerIndex): SampleHandAssignment[] { + const assignments: SampleHandAssignment[] = []; + let cur = nextPlayer(playerIdx); + + for (let step = 0; step < 3; step++) { + const handSize = state.players[cur].hand.length; + if (handSize > 0) { + assignments.push({ + playerIdx: cur, + handSize, + }); + } + cur = nextPlayer(cur); + } + + return assignments; +} + +function getUnseenCardPriority(card: Card, table: Card[]): number { + let priority = card.value; + + if (card.suit === 'denara' && card.value === 7) { + priority += 20000; + } else if (card.value === 7) { + priority += 12000; + } else if (card.suit === 'denara') { + priority += 6000; + } + + if (card.value === 6) priority += 900; + if (card.value === 1) priority += 700; + priority += primieraVal(card) * 25; + + if (canCapture(card, table)) { + priority += 800; + const captures = findCaptures(card, table); + let bestCapturePriority = 0; + for (const capture of captures) { + let capturePriority = capture.length * 140; + for (const capturedCard of capture) { + if (capturedCard.suit === 'denara') capturePriority += 90; + if (capturedCard.value === 7) capturePriority += 160; + } + if (capture.length === table.length) capturePriority += 500; + if (capturePriority > bestCapturePriority) bestCapturePriority = capturePriority; + } + priority += bestCapturePriority; + } + + return priority; +} + +function prioritizeUnseenCards(unseen: Card[], table: Card[]): Card[] { + return [...unseen].sort((left, right) => { + const priorityDelta = getUnseenCardPriority(right, table) - getUnseenCardPriority(left, table); + if (priorityDelta !== 0) return priorityDelta; + return left.id.localeCompare(right.id); + }); +} + +function rotateValues(values: T[], offset: number): T[] { + if (values.length <= 1) return values; + + const normalizedOffset = ((offset % values.length) + values.length) % values.length; + if (normalizedOffset === 0) return [...values]; + return [...values.slice(normalizedOffset), ...values.slice(0, normalizedOffset)]; +} + +function getAssignmentOrderVariants(assignments: SampleHandAssignment[]): SampleHandAssignment[][] { + if (assignments.length <= 1) return [assignments]; + + if (assignments.length === 2) { + return [ + assignments, + [assignments[1], assignments[0]], + ]; + } + + return [ + assignments, + [assignments[0], assignments[2], assignments[1]], + [assignments[1], assignments[0], assignments[2]], + [assignments[2], assignments[0], assignments[1]], + [assignments[1], assignments[2], assignments[0]], + [assignments[2], assignments[1], assignments[0]], + ]; +} + +function buildSampleAssignmentKey(assignments: SampleHandBucket[]): string { + return assignments + .map(({ assignment, cards }) => `${assignment.playerIdx}:${stableCardCollectionKey(cards)}`) + .join('|'); +} + +function combinationCount(n: number, k: number): number { + if (k < 0 || k > n) return 0; + if (k === 0 || k === n) return 1; + + let result = 1; + const boundedK = Math.min(k, n - k); + for (let index = 1; index <= boundedK; index++) { + result = (result * (n - boundedK + index)) / index; + if (result > MAX_EXACT_SAMPLE_ASSIGNMENTS) { + return result; + } + } + + return result; +} + +function getHiddenAssignmentCount(unseenCount: number, assignments: SampleHandAssignment[]): number { + let remaining = unseenCount; + let totalAssignments = 1; + + for (const assignment of assignments) { + totalAssignments *= combinationCount(remaining, assignment.handSize); + if (totalAssignments > MAX_EXACT_SAMPLE_ASSIGNMENTS) { + return totalAssignments; + } + remaining -= assignment.handSize; + } + + return totalAssignments; +} + +function assignBucketsToSample( + sample: GameState, + assignments: SampleHandBucket[], +): void { + for (const { assignment, cards } of assignments) { + sample.players[assignment.playerIdx].hand = cards.slice(); + } +} + +function buildExactSampleStates( + state: GameState, + prioritizedUnseen: Card[], + assignments: SampleHandAssignment[], +): GameState[] { + const samples: GameState[] = []; + const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] })); + + const visitAssignment = (assignmentIndex: number, remainingCards: Card[]): boolean => { + if (samples.length >= MAX_EXACT_SAMPLE_ASSIGNMENTS) { + return true; + } + + if (assignmentIndex >= buckets.length) { + const sample = cloneState(state); + assignBucketsToSample(sample, buckets); + samples.push(sample); + return false; + } + + const bucket = buckets[assignmentIndex]; + const targetSize = bucket.assignment.handSize; + const chosenIndices: number[] = []; + + const chooseCards = (startIndex: number): boolean => { + if (chosenIndices.length === targetSize) { + bucket.cards = chosenIndices.map(index => remainingCards[index]); + const nextRemaining = remainingCards.filter((_, index) => !chosenIndices.includes(index)); + return visitAssignment(assignmentIndex + 1, nextRemaining); + } + + const needed = targetSize - chosenIndices.length; + const maxStart = remainingCards.length - needed; + for (let index = startIndex; index <= maxStart; index++) { + chosenIndices.push(index); + if (chooseCards(index + 1)) return true; + chosenIndices.pop(); + } + + return false; + }; + + return chooseCards(0); + }; + + visitAssignment(0, prioritizedUnseen); + return samples; +} + +function scoreUnseenCardTablePressure(card: Card, table: Card[]): number { + let score = 0; + const captures = findCaptures(card, table); + + for (const capture of captures) { + let captureScore = capture.length * 28; + if (capture.some(captured => captured.suit === 'denara')) captureScore += 22; + if (capture.some(captured => captured.value === 7)) captureScore += 28; + if (capture.length === table.length) captureScore += 140; + if (captureScore > score) score = captureScore; + } + + if (table.some(tableCard => tableCard.value === card.value)) score += 40; + if (card.suit === 'denara') score += 20; + if (card.value === 7) score += 28; + if (card.value >= 8 && captures.length === 0) score += 12; + + return score; +} + +function scoreSampleAssignmentCandidate( + card: Card, + assignment: SampleHandAssignment, + state: GameState, + rootPlayer: PlayerIndex, + rankResidue: RankResidueSnapshot | null, +): number { + const next = nextPlayer(rootPlayer); + const partner = partnerOf(rootPlayer); + const assignmentIsOpp = isOpponent(rootPlayer, assignment.playerIdx); + const playsNext = assignment.playerIdx === next; + const isPartner = assignment.playerIdx === partner; + const assignmentRole = getDealerRoleContext(state, assignment.playerIdx); + const pressureScore = scoreUnseenCardTablePressure(card, state.table); + let score = assignment.handSize * 2; + + if (playsNext) { + score += pressureScore * (assignmentIsOpp ? 2.5 : 1.6); + } else if (assignmentIsOpp) { + score += pressureScore * 1.25; + } else if (isPartner) { + score += pressureScore * 1.1; + } else { + score += pressureScore * 0.85; + } + + if (rankResidue) { + if (rankResidue.hasSingletonResidue[card.value]) { + score += playsNext ? 30 : 12; + } + if (rankResidue.hasPairedResidue[card.value]) { + score += assignmentRole.defendingDealerAdvantage ? 20 : 8; + } + } + + if (card.suit === 'denara') { + score += playsNext && assignmentIsOpp ? 26 : assignmentRole.onDealerSide ? 14 : 8; + } + if (card.value === 7) { + score += assignmentIsOpp ? 24 : 14; + } + if (card.value >= 8 && !canCapture(card, state.table)) { + score += assignmentRole.defendingDealerAdvantage ? 16 : 8; + } + + return score; +} + +function selectSampleBucketForCard( + card: Card, + buckets: SampleHandBucket[], + state: GameState, + rootPlayer: PlayerIndex, + rankResidue: RankResidueSnapshot | null, + sampleIndex: number, +): SampleHandBucket | undefined { + const availableBuckets = buckets.filter(bucket => bucket.cards.length < bucket.assignment.handSize); + if (availableBuckets.length === 0) return undefined; + + const rankedBuckets = availableBuckets + .map(bucket => ({ + bucket, + score: scoreSampleAssignmentCandidate(card, bucket.assignment, state, rootPlayer, rankResidue), + })) + .sort((left, right) => right.score - left.score); + + const topBucketCount = Math.min(2, rankedBuckets.length); + const selectedIndex = topBucketCount === 1 ? 0 : sampleIndex % topBucketCount; + return rankedBuckets[selectedIndex]?.bucket; +} + +function buildStratifiedSampleBuckets( + state: GameState, + playerIdx: PlayerIndex, + prioritizedUnseen: Card[], + assignments: SampleHandAssignment[], + rankResidue: RankResidueSnapshot | null, + sampleIndex: number, + rng: RandomSource, +): SampleHandBucket[] { + const orderVariants = getAssignmentOrderVariants(assignments); + const assignmentOrder = orderVariants[sampleIndex % orderVariants.length]; + const buckets = assignments.map(assignment => ({ assignment, cards: [] as Card[] })); + const bucketByPlayer = new Map(buckets.map(bucket => [bucket.assignment.playerIdx, bucket])); + + const focusedCards = prioritizedUnseen.slice(0, Math.min(MAX_FOCUSED_ASSIGNMENT_CARDS, prioritizedUnseen.length)); + const focusedCardIds = new Set(focusedCards.map(card => card.id)); + const remainingCards = shuffleArray( + prioritizedUnseen.filter(card => !focusedCardIds.has(card.id)), + rng, + ); + + for (let index = 0; index < focusedCards.length; index++) { + const preferredBucket = selectSampleBucketForCard( + focusedCards[index], + buckets, + state, + playerIdx, + rankResidue, + sampleIndex + index, + ); + if (preferredBucket) { + preferredBucket.cards.push(focusedCards[index]); + } + } + + let remainingIndex = 0; + for (const assignment of assignmentOrder) { + const bucket = bucketByPlayer.get(assignment.playerIdx); + if (!bucket) continue; + while (bucket.cards.length < assignment.handSize && remainingIndex < remainingCards.length) { + bucket.cards.push(remainingCards[remainingIndex]); + remainingIndex++; + } + } + + return buckets; +} + function generateSamples( - state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, count: number, + state: GameState, + playerIdx: PlayerIndex, + tracker: CardTracker | undefined, + count: number, + rng: RandomSource, ): GameState[] { const myHand = state.players[playerIdx].hand; - const samples: GameState[] = []; const unseen = tracker ? tracker.getUnseenCards(myHand, state.table) : getUnseenWithoutTracker(state, playerIdx); + const assignments = createSampleHandAssignments(state, playerIdx); - for (let s = 0; s < count; s++) { + if (assignments.length === 0 || unseen.length === 0) { + return [cloneState(state)]; + } + + const prioritizedUnseen = prioritizeUnseenCards(unseen, state.table); + const rankResidue = getRankResidueSnapshot(tracker, myHand, state.table); + const hiddenAssignmentCount = getHiddenAssignmentCount(prioritizedUnseen.length, assignments); + if ( + prioritizedUnseen.length <= 8 + && hiddenAssignmentCount <= MAX_EXACT_SAMPLE_ASSIGNMENTS + ) { + return buildExactSampleStates(state, prioritizedUnseen, assignments); + } + + const samples: GameState[] = []; + const seenAssignments = new Set(); + const targetSamples = Math.max(1, count); + const maxAttempts = targetSamples * 4; + + for (let attempt = 0; attempt < maxAttempts && samples.length < targetSamples; attempt++) { const sample = cloneState(state); - const shuffled = shuffleArray([...unseen]); - let idx = 0; - for (let p = 0; p < 4; p++) { - if (p === playerIdx) continue; - const need = sample.players[p].hand.length; - sample.players[p].hand = shuffled.slice(idx, idx + need); - idx += need; - } + const sampleBuckets = buildStratifiedSampleBuckets( + state, + playerIdx, + prioritizedUnseen, + assignments, + rankResidue, + attempt, + rng, + ); + const sampleKey = buildSampleAssignmentKey(sampleBuckets); + if (seenAssignments.has(sampleKey)) continue; + + seenAssignments.add(sampleKey); + assignBucketsToSample(sample, sampleBuckets); samples.push(sample); } + + if (samples.length === 0) { + const fallbackSample = cloneState(state); + assignBucketsToSample( + fallbackSample, + buildStratifiedSampleBuckets(state, playerIdx, prioritizedUnseen, assignments, rankResidue, 0, rng), + ); + return [fallbackSample]; + } + return samples; } @@ -1293,9 +3089,9 @@ function getUnseenCardsForEstimate( return deck.filter(card => !known.has(card.id)); } -function shuffleArray(arr: T[]): T[] { +function shuffleArray(arr: T[], rng: RandomSource = Math.random): T[] { for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); + const j = Math.floor(rng() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; @@ -1305,46 +3101,165 @@ function alphaBeta( state: GameState, depth: number, alpha: number, beta: number, myTeam: 0 | 1, rootPlayer: PlayerIndex, phase: number, deadline: number, + timing: SearchTimingContext, tracker: CardTracker | undefined, + transpositionTable: Map, + heuristics: SearchHeuristics, + ply: number, ): number { - if (depth === 0 || state.roundOver || Date.now() > deadline) { + const stateKey = buildSearchStateKey(state); + + if (depth === 0 || state.roundOver) { + const score = evaluateFast(state, myTeam, phase, tracker, rootPlayer); + transpositionTable.set(stateKey, { + key: stateKey, + bestMove: null, + bestMoveKey: null, + depth, + score, + bound: 'exact', + }); + return score; + } + + timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); + if (timing.now() > deadline) { return evaluateFast(state, myTeam, phase, tracker, rootPlayer); } + const originalAlpha = alpha; + const originalBeta = beta; + const ttEntry = transpositionTable.get(stateKey); + if (ttEntry && ttEntry.depth >= depth) { + if (ttEntry.bound === 'exact') { + return ttEntry.score; + } + if (ttEntry.bound === 'lower') { + alpha = Math.max(alpha, ttEntry.score); + } else { + beta = Math.min(beta, ttEntry.score); + } + if (alpha >= beta) { + return ttEntry.score; + } + } + const cur = state.currentPlayer; const isMyTeam = teamOf(cur) === myTeam; const moves = getLegalMoves(state, cur); - if (moves.length === 0) return evaluateFast(state, myTeam, phase, tracker, rootPlayer); - - // Move ordering: settebello captures first, then scopa, then captures by size, then dumps - if (moves.length > 2) { - const race = getRaceState(state, cur); - const lastPlay = isLastPlay(state, cur); - const roleContext = getDealerRoleContext(state, cur); - const parity = getParitySnapshot(tracker, state.players[rootPlayer].hand, state.table); - moves.sort((a, b) => quickEval(b, state, cur, tracker, lastPlay, race, roleContext, parity) - quickEval(a, state, cur, tracker, lastPlay, race, roleContext, parity)); + if (moves.length === 0) { + const score = evaluateFast(state, myTeam, phase, tracker, rootPlayer); + transpositionTable.set(stateKey, { + key: stateKey, + bestMove: null, + bestMoveKey: null, + depth, + score, + bound: 'exact', + }); + return score; } + const orderedMoves = orderSearchMoves( + moves, + state, + cur, + rootPlayer, + tracker, + ttEntry?.bound === 'exact' ? getValidHashMove(moves, ttEntry) : undefined, + getValidHashMove(moves, ttEntry), + heuristics, + ply, + ); + if (isMyTeam) { let value = -Infinity; - for (const move of moves) { + let bestMove: AIMove | null = null; + let isFirstMove = true; + for (const move of orderedMoves) { const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); - const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline, tracker); - value = Math.max(value, child); + const child = searchPrincipalVariationChild( + result.nextState, + depth - 1, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply + 1, + isFirstMove, + true, + ); + if (child > value) { + value = child; + bestMove = move; + } alpha = Math.max(alpha, value); - if (beta <= alpha) break; + if (beta <= alpha) { + recordQuietCutoff(heuristics, move, ply, depth); + break; + } + isFirstMove = false; } + + transpositionTable.set(stateKey, { + key: stateKey, + bestMove, + bestMoveKey: bestMove ? moveKey(bestMove) : null, + depth, + score: value, + bound: value <= originalAlpha ? 'upper' : value >= originalBeta ? 'lower' : 'exact', + }); return value; } else { let value = Infinity; - for (const move of moves) { + let bestMove: AIMove | null = null; + let isFirstMove = true; + for (const move of orderedMoves) { const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); - const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline, tracker); - value = Math.min(value, child); + const child = searchPrincipalVariationChild( + result.nextState, + depth - 1, + alpha, + beta, + myTeam, + rootPlayer, + phase, + deadline, + timing, + tracker, + transpositionTable, + heuristics, + ply + 1, + isFirstMove, + false, + ); + if (child < value) { + value = child; + bestMove = move; + } beta = Math.min(beta, value); - if (beta <= alpha) break; + if (beta <= alpha) { + recordQuietCutoff(heuristics, move, ply, depth); + break; + } + isFirstMove = false; } + + transpositionTable.set(stateKey, { + key: stateKey, + bestMove, + bestMoveKey: bestMove ? moveKey(bestMove) : null, + depth, + score: value, + bound: value <= originalAlpha ? 'upper' : value >= originalBeta ? 'lower' : 'exact', + }); return value; } } @@ -1362,8 +3277,10 @@ function evaluateFast( 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 parity = getParitySnapshot(tracker, state.players[rootPlayer].hand, state.table); + const rankResidue = getRankResidueSnapshot(tracker, state.players[rootPlayer].hand, state.table); + const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); // Single-pass pile scan — no flatMap/filter allocations let myCards = 0, oppCards = 0; @@ -1415,9 +3332,23 @@ function evaluateFast( // Denari majority (weighted by proximity to threshold: 6+ of 10) const denariDiff = myDenari - oppDenari; - score += denariDiff * 65; - if (myDenari >= 6) score += 70; - if (oppDenari >= 6) score -= 70; + 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; @@ -1467,18 +3398,20 @@ function evaluateFast( // 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 += 100; - if (!myTurn && tableHasSettebello) score -= 100; + 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 * 15; - score += table7s * 20; + score += tableDenari * (race.behindInDenari ? 28 : 16); + score += table7s * (race.need7s ? 52 : 24); } else { - score -= tableDenari * 15; - score -= table7s * 20; + 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 @@ -1487,21 +3420,32 @@ function evaluateFast( score += state.table.length * 5; } - const parityPressure = scoreParityTableState(state.table, parity, roleContext, !myTurn); - score += myTurn ? parityPressure : -parityPressure; + const rankResiduePressure = scoreRankResidueTableState(state.table, rankResidue, roleContext, !myTurn); + score += myTurn ? rankResiduePressure : -rankResiduePressure; const rolePlan = scoreRoleTablePlan(state.table, roleContext, !myTurn); score += myTurn ? rolePlan : -rolePlan; - if (parity) { - const { oddValues, evenValues } = countParityValuesOnTable(state.table, parity); + if (rankResidue) { + const { singletonValues, pairedValues } = countRankResidueValuesOnTable(state.table, rankResidue); if (roleContext.defendingDealerAdvantage) { - score += (myTurn ? evenValues : -oddValues) * 14; + score += (myTurn ? pairedValues : -singletonValues) * 14; } else { - score += (myTurn ? oddValues : -evenValues) * 14; + score += (myTurn ? singletonValues : -pairedValues) * 14; } } } + 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 score; } diff --git a/src/game/card-tracker.ts b/src/game/card-tracker.ts index ba8dbd1..761daf3 100644 --- a/src/game/card-tracker.ts +++ b/src/game/card-tracker.ts @@ -4,19 +4,19 @@ export interface CardTrackerSnapshot { playedCardIds: string[]; } -export interface CardTrackerValueParityResidue { +export interface CardTrackerValueRankResidue { value: number; knownCount: number; unseenCount: number; - hasOddUnseenResidue: boolean; - hasEvenUnseenResidue: boolean; + hasSingletonUnseenRankResidue: boolean; + hasPairedUnseenRankResidue: boolean; } interface VisibleValueResidueKnowledge { unseenCards: Card[]; unseenCountBySuit: Record; unseenCountByValue: number[]; - valueParityResidues: CardTrackerValueParityResidue[]; + valueRankResidues: CardTrackerValueRankResidue[]; } function normalizeSnapshot(snapshot: CardTrackerSnapshot): CardTrackerSnapshot { @@ -116,15 +116,15 @@ export class CardTracker { } } - const valueParityResidues: CardTrackerValueParityResidue[] = []; + const valueRankResidues: CardTrackerValueRankResidue[] = []; for (let value = 1; value <= 10; value++) { const unseenCount = unseenCountByValue[value]; - valueParityResidues.push({ + valueRankResidues.push({ value, knownCount: knownCountByValue[value], unseenCount, - hasOddUnseenResidue: unseenCount % 2 === 1, - hasEvenUnseenResidue: unseenCount % 2 === 0, + hasSingletonUnseenRankResidue: unseenCount % 2 === 1, + hasPairedUnseenRankResidue: unseenCount >= 2 && unseenCount % 2 === 0, }); } @@ -132,7 +132,7 @@ export class CardTracker { unseenCards, unseenCountBySuit, unseenCountByValue, - valueParityResidues, + valueRankResidues, }; } @@ -151,24 +151,24 @@ export class CardTracker { /** Count how many unseen cards share a value */ countRemainingValue(value: number, myHand: Card[], table: Card[]): number { - return this.getValueParityResidue(value, myHand, table).unseenCount; + return this.getValueRankResidue(value, myHand, table).unseenCount; } - /** Get visible known-count, unseen-count, and parity residue for a single value */ - getValueParityResidue(value: number, myHand: Card[], table: Card[]): CardTrackerValueParityResidue { - const valueParityResidues = this.buildVisibleValueResidueKnowledge(myHand, table).valueParityResidues; - return valueParityResidues[value - 1] ?? { + /** Get visible known-count, unseen-count, and same-rank residue for a single value */ + getValueRankResidue(value: number, myHand: Card[], table: Card[]): CardTrackerValueRankResidue { + const valueRankResidues = this.buildVisibleValueResidueKnowledge(myHand, table).valueRankResidues; + return valueRankResidues[value - 1] ?? { value, knownCount: 0, unseenCount: 0, - hasOddUnseenResidue: false, - hasEvenUnseenResidue: true, + hasSingletonUnseenRankResidue: false, + hasPairedUnseenRankResidue: false, }; } - /** Get visible known-count, unseen-count, and parity residue for all card values */ - getValueParityResidueSummary(myHand: Card[], table: Card[]): CardTrackerValueParityResidue[] { - return this.buildVisibleValueResidueKnowledge(myHand, table).valueParityResidues; + /** Get visible known-count, unseen-count, and same-rank residue for all card values */ + getValueRankResidueSummary(myHand: Card[], table: Card[]): CardTrackerValueRankResidue[] { + return this.buildVisibleValueResidueKnowledge(myHand, table).valueRankResidues; } /** Probability that a hidden hand contains at least one card with the requested value */ diff --git a/src/game/engine.ts b/src/game/engine.ts index 9d77c75..db9f6ac 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -3,6 +3,8 @@ import { TeamScore, ScoreBreakdown, PRIMIERA_VALUES, Capture, DealerRelativeRole } from './types'; +export type RandomSource = () => number; + // --------------------------------------------------------------------------- // Deck // --------------------------------------------------------------------------- @@ -17,10 +19,10 @@ export function buildDeck(): Card[] { return deck; } -export function shuffle(arr: T[]): T[] { +export function shuffle(arr: T[], rng: RandomSource = Math.random): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); + const j = Math.floor(rng() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; @@ -105,8 +107,8 @@ export function getDealerRelativeRole( return 'dealer'; } -export function createInitialState(dealer: PlayerIndex = 3): GameState { - const deck = shuffle(buildDeck()); +export function createInitialState(dealer: PlayerIndex = 3, rng: RandomSource = Math.random): GameState { + const deck = shuffle(buildDeck(), rng); const startingPlayer = getOpeningPlayerForDealer(dealer); const players: [Player, Player, Player, Player] = [ diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 65822ef..bae1aed 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -4,7 +4,7 @@ import { createInitialState, applyMove, findCaptures, getScoreBreakdown, teamOf, calcPrimiera, getMatchOutcome, nextPlayer } from '../game/engine'; -import { AIDecisionProgress } from '../game/ai'; +import { AIMove, AIDecisionProgress } from '../game/ai'; import { AIWorkerClient, AIWorkerClientLike } from '../game/ai-worker-client'; import { CardTracker } from '../game/card-tracker'; @@ -29,6 +29,8 @@ const CH_A = 645 * CARD_SCALE_AI; // card height for AI ≈ 81 // Scorebar height at top const SCOREBAR_H = 54; +const AI_MIN_THINK_MS = 1000; +const MOVE_OUTCOME_STATUS_MS = 2000; // Player positions: // 0 = South (human, bottom), 1 = West (AI, left, rotated -90°) @@ -74,6 +76,8 @@ export class GameScene extends Phaser.Scene { // Status bar private statusText!: Phaser.GameObjects.Text; + private statusTimer: Phaser.Time.TimerEvent | null = null; + private persistentStatusText = ''; // Think bar private thinkBar!: Phaser.GameObjects.Graphics; @@ -387,6 +391,38 @@ export class GameScene extends Phaser.Scene { this.drawThinkBar(playerIdx, 1 - progress.progress); } + private logMasterAIDiagnostics( + playerIdx: PlayerIndex, + move: AIMove, + progress: AIDecisionProgress | null, + ): void { + if (this.difficulty !== 'master' || !progress) { + return; + } + + console.info('[AI master diagnostics]', { + playerIdx, + roundNumber: this.state.roundNumber ?? 1, + moveCardId: move.card.id, + captureCount: move.capture.length, + timing: { + elapsedMs: progress.elapsedMs, + budgetMs: progress.budgetMs, + progress: progress.progress, + timedOut: progress.timedOut ?? false, + }, + search: { + batchesCompleted: progress.batchesCompleted, + cardsRemaining: progress.cardsRemaining ?? null, + sampleCount: progress.sampleCount ?? null, + maxDepth: progress.maxDepth ?? null, + completedDepth: progress.completedDepth ?? null, + rootMoveCount: progress.rootMoveCount ?? null, + aspirationExpansions: progress.aspirationExpansions ?? 0, + }, + }); + } + private drawThinkBar(playerIdx: PlayerIndex, remainingRatio: number): void { const W = this.scale.width; const tg = this.thinkBar; @@ -410,6 +446,35 @@ export class GameScene extends Phaser.Scene { this.thinkBar.setVisible(false); } + private clearStatusTimer(): void { + if (!this.statusTimer) { + return; + } + + this.statusTimer.remove(false); + this.statusTimer.destroy(); + this.statusTimer = null; + } + + private waitForDelay(delayMs: number): Promise { + if (delayMs <= 0 || !this.scene.isActive('GameScene')) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + this.time.delayedCall(delayMs, () => resolve()); + }); + } + + private buildMoveOutcomeStatus(playerIdx: PlayerIndex, card: Card, capture: Card[] | null): string { + const player = this.state.players[playerIdx]; + if (!capture || capture.length === 0) { + return `${player.name} gioca ${cardName(card)}`; + } + + return `${player.name} cattura ${capture.map(cardName).join(', ')} con ${cardName(card)}`; + } + // --------------------------------------------------------------------------- // Player labels (pulse on active turn) // --------------------------------------------------------------------------- @@ -597,7 +662,7 @@ export class GameScene extends Phaser.Scene { if (this.state.roundOver) { this.showRoundEnd(); return; } const cur = this.state.currentPlayer; const player = this.state.players[cur]; - this.setStatus(`Turno di ${player.name}`); + this.setStatus(`Turno di ${player.name}`, { persist: true }); this.pulseLabel(cur); if (player.isHuman) { @@ -611,6 +676,7 @@ export class GameScene extends Phaser.Scene { } private handleSceneShutdown(): void { + this.clearStatusTimer(); this.aiClient?.dispose(); this.aiClient = null; this.aiThinking = false; @@ -622,6 +688,8 @@ export class GameScene extends Phaser.Scene { private async doAIMove(playerIdx: PlayerIndex): Promise { const turnState = this.state; const aiClient = this.aiClient; + let finalProgress: AIDecisionProgress | null = null; + const thinkStartedAt = Date.now(); if (!aiClient) { return; @@ -636,13 +704,21 @@ export class GameScene extends Phaser.Scene { (progress) => { if (this.aiClient !== aiClient || !this.scene.isActive('GameScene') || this.state !== turnState) return; this.updateThinkBar(playerIdx, progress); + if (progress.difficulty !== 'master') return; + finalProgress = progress; } ); + const remainingThinkMs = AI_MIN_THINK_MS - (Date.now() - thinkStartedAt); + if (remainingThinkMs > 0) { + await this.waitForDelay(remainingThinkMs); + } + if (this.aiClient !== aiClient) return; if (!this.scene.isActive('GameScene')) return; if (this.state !== turnState || this.state.currentPlayer !== playerIdx || this.state.roundOver) return; + this.logMasterAIDiagnostics(playerIdx, move, finalProgress); this.hideThinkBar(); this.aiThinking = false; this.executeMove(playerIdx, move.card, move.capture); @@ -923,10 +999,10 @@ export class GameScene extends Phaser.Scene { if (isScopa) { this.playSfx('scopa'); this.doScopaEffect(playerIdx, () => - this.afterMove(nextState, oldState) + this.afterMove(playerIdx, card, captureResult?.captured ?? null, nextState, oldState) ); } else { - this.afterMove(nextState, oldState); + this.afterMove(playerIdx, card, captureResult?.captured ?? null, nextState, oldState); } } }, @@ -947,20 +1023,39 @@ export class GameScene extends Phaser.Scene { targets: cardImg, x: tablePos.x, y: tablePos.y, angle: randomAngle, duration: 280, ease: 'Back.Out', - onComplete: () => this.afterMove(nextState, oldState), + onComplete: () => this.afterMove(playerIdx, card, null, nextState, oldState), }); } } - private afterMove(nextState: GameState, _old: GameState): void { + private afterMove( + playerIdx: PlayerIndex, + card: Card, + capture: Card[] | null, + nextState: GameState, + _old: GameState, + ): void { this.updateScoreBar(); this.relayoutTable(); - if (nextState.roundOver) { - this.time.delayedCall(500, () => this.showRoundEnd()); - } else { + if (!nextState.roundOver) { this.relayoutHand(0); - this.nextTurn(); } + + this.setStatus(this.buildMoveOutcomeStatus(playerIdx, card, capture), { + durationMs: MOVE_OUTCOME_STATUS_MS, + onExpire: () => { + if (!this.scene.isActive('GameScene')) { + return; + } + + if (nextState.roundOver) { + this.showRoundEnd(); + return; + } + + this.nextTurn(); + }, + }); } private relayoutHand(playerIdx: PlayerIndex): void { @@ -1450,7 +1545,32 @@ export class GameScene extends Phaser.Scene { }); } - private setStatus(msg: string): void { this.statusText.setText(msg); } + private setStatus( + msg: string, + options: { + persist?: boolean; + durationMs?: number; + onExpire?: () => void; + } = {}, + ): void { + const { persist = false, durationMs, onExpire } = options; + + this.clearStatusTimer(); + this.statusText.setText(msg); + + if (persist) { + this.persistentStatusText = msg; + } + + if (durationMs === undefined) { + return; + } + + this.statusTimer = this.time.delayedCall(durationMs, () => { + this.statusTimer = null; + onExpire?.(); + }); + } } // ---------------------------------------------------------------------------