666 lines
20 KiB
TypeScript
666 lines
20 KiB
TypeScript
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<string>();
|
|
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<string>();
|
|
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); |