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' | 'cards-majority-conversion' | 'denari-denial' | 'primiera-denial' | 'partner-preserving-quiet-release' | '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 take the clean five capture that removes the only cheap concession, rather than floating a tenth card into the same anti-scopa race.', 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: 'denara_5', captureIds: ['coppe_5'], }, }, { 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 three into the heavy 6+8+8 table instead of breaking higher-rank control.', tags: ['critical-dealer-rank-residue', 'dealer-side-control'], criticalConcept: 'dealer-rank-residue-preservation', dealer: 3, currentPlayer: 3, handSizes: [5, 5, 5, 5], hands: [[ 'bastoni_1', 'bastoni_2', 'bastoni_4', 'bastoni_5', 'spade_5', ], undefined, undefined, [ 'spade_3', 'denara_9', 'coppe_9', 'bastoni_10', 'denara_5', ]], table: ['bastoni_6', 'spade_8', 'coppe_8'], pileCardCounts: [5, 4, 4, 4], 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 Pressure Setup', description: 'Instead of floating a sterile ten, the root player should take the low five capture that strips the loose 1+4 total and leaves a heavy 7+8 table the next opponent cannot immediately cash, preserving team pressure into the partner rotation.', 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', 'spade_3'], 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: 'denara_5', captureIds: ['denara_1', 'coppe_4'], }, }, { 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-scopa-conversion', name: 'Safe Scopa Conversion', description: 'When a clean sweep is available, the search should take the safe scopa instead of settling for a smaller direct capture.', tags: ['safe-scopa', 'scopa-window'], dealer: 3, currentPlayer: 0, handSizes: [5, 5, 5, 5], hands: [[ 'bastoni_10', 'denara_6', 'coppe_9', 'bastoni_3', 'spade_2', ], undefined, undefined, undefined], table: ['coppe_4', 'spade_6'], pileCardCounts: [5, 4, 5, 4], totalPoints: [8, 8], expectedMove: { cardId: 'bastoni_10', captureIds: ['coppe_4', 'spade_6'], }, }, { 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: 'duplicate-rank-opening-release', name: 'Duplicate Rank Opening Release', description: 'On an empty opening table, the search should release the non-denari duplicate high rank instead of opening with weaker denari or singleton alternatives.', tags: ['opening-release', 'duplicate-rank'], dealer: 3, currentPlayer: 0, handSizes: [5, 5, 5, 5], hands: [[ 'coppe_8', 'denara_8', 'denara_7', 'denara_6', 'bastoni_2', ], undefined, undefined, undefined], table: [], pileCardCounts: [5, 5, 5, 5], totalPoints: [7, 7], expectedMove: { cardId: 'coppe_8', }, }, { id: 'cards-majority-conversion', name: 'Cards Majority Conversion', description: 'With the cards race on a knife edge, the search should take the two-card conversion that secures team majority instead of the single-card direct grab.', tags: ['critical-cards-majority-conversion', 'team-race', 'material-margin'], criticalConcept: 'cards-majority-conversion', dealer: 1, currentPlayer: 0, handSizes: [5, 5, 5, 5], hands: [[ 'bastoni_9', 'spade_8', 'denara_2', 'coppe_3', 'spade_6', ], undefined, undefined, undefined], table: ['coppe_4', 'spade_5', 'denara_8', 'bastoni_10'], pileCardCounts: [5, 3, 5, 3], scopes: [1, 0, 1, 0], totalPoints: [9, 9], expectedMove: { cardId: 'bastoni_9', captureIds: ['coppe_4', 'spade_5'], }, }, { id: 'denari-denial-window', name: 'Denari Denial Window', description: 'The root player should strip the exposed denari immediately, before the heavier table turns into a generic control race, because the team denari count is still live.', tags: ['critical-denari-denial', 'denari-race', 'team-defense'], criticalConcept: 'denari-denial', dealer: 2, currentPlayer: 1, handSizes: [5, 5, 5, 5], hands: [undefined, [ 'spade_6', 'bastoni_5', 'coppe_3', 'spade_10', 'spade_2', ], undefined, undefined], table: ['denara_6', 'coppe_7', 'bastoni_8', 'spade_9'], pileCardCounts: [4, 4, 4, 4], totalPoints: [9, 9], expectedMove: { cardId: 'spade_6', captureIds: ['denara_6'], }, }, { id: 'primiera-denial-window', name: 'Primiera Denial Window', description: 'The benchmark should prefer removing the exposed seven that swings primiera control instead of banking the larger but strategically softer material capture.', tags: ['critical-primiera-denial', 'primiera-pressure', 'team-defense'], criticalConcept: 'primiera-denial', dealer: 3, currentPlayer: 2, handSizes: [5, 5, 5, 5], hands: [undefined, undefined, [ 'spade_7', 'coppe_8', 'denara_2', 'bastoni_6', 'spade_9', ], undefined], table: ['coppe_7', 'denara_4', 'spade_1', 'bastoni_3'], pileCardCounts: [4, 4, 4, 4], totalPoints: [8, 8], expectedMove: { cardId: 'spade_7', captureIds: ['coppe_7'], }, }, { id: 'partner-preserving-quiet-release', name: 'Partner Preserving Quiet Release', description: 'Rather than breaking the paired sevens or the denari structure, the root player should release the quiet two that keeps the 2+8 ten line alive for the partner while the intervening opponent still has no immediate capture.', tags: ['critical-partner-preserving-quiet-release', 'partner-window', 'quiet-release', 'table-control'], criticalConcept: 'partner-preserving-quiet-release', dealer: 0, currentPlayer: 0, handSizes: [5, 5, 5, 5], hands: [[ 'coppe_2', 'denara_7', 'coppe_7', 'denara_5', 'bastoni_4', ], [ 'bastoni_1', 'bastoni_3', 'bastoni_5', 'bastoni_7', 'coppe_1', ], [ 'spade_10', 'denara_8', 'coppe_9', 'bastoni_2', 'spade_6', ], undefined], table: ['bastoni_8', 'spade_9', 'coppe_6'], pileCardCounts: [5, 4, 4, 4], scopes: [0, 1, 0, 1], totalPoints: [8, 9], expectedMove: { cardId: 'coppe_2', }, }, ]; 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);