// --------------------------------------------------------------------------- // AI Strategy — table parity, spariglio, mulinello, category states, endgame // --------------------------------------------------------------------------- import { Card, GameState, PlayerIndex, Suit, SUITS, PRIMIERA_VALUES } from './types'; import { AIMove } from './types'; import { teamOf } from './engine'; import { CardInferenceEngine } from './card-inference'; // --------------------------------------------------------------------------- // Exported interfaces // --------------------------------------------------------------------------- export interface ParityState { pairedRanks: number[]; unpairedRanks: number[]; spariglioDegree: number; isEvenParity: boolean; } export interface SpariglioPotential { card: Card; spariglioDelta: number; // how spariglioDegree changes if this card is dumped isSpariglio3Card: boolean; // true if this is a 3-card spariglio (highest priority) } export interface MulinelloState { active: boolean; favorableFor: 'us' | 'them' | null; breakingMoves: AIMove[]; } export interface CategoryEntry { state: 'secured' | 'lost' | 'contested'; closeness: number; // 0-1, higher = closer to winning } export interface PrimieraCategoryEntry { perSuit: Record; overallCloseness: number; } export interface CategoryStates { denari: CategoryEntry; carte: CategoryEntry; primiera: PrimieraCategoryEntry; scope: 'always_contested'; settebello: 'always_contested'; } export interface PrimieraRaceState { teamLeadsBySuit: Record; // true=we lead, false=they lead, null=tied/unknown contestedSuits: Suit[]; unseenPrimieraCards: Card[]; // unseen 7s, 6s, 1s (in primiera value order) } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function canCardCapture(card: Card, table: Card[]): boolean { // Direct match if (table.some(c => c.value === card.value)) return true; // Sum capture (subsets of 2+) if (table.length < 2) return false; for (let mask = 1; mask < (1 << table.length); mask++) { const subset = table.filter((_, i) => mask & (1 << i)); if (subset.length >= 2 && subset.reduce((s, c) => s + c.value, 0) === card.value) return true; } return false; } function bestPrimieraValue(pile: Card[], suit: Suit): number { const cards = pile.filter(c => c.suit === suit); if (cards.length === 0) return 0; return Math.max(...cards.map(c => PRIMIERA_VALUES[c.value] ?? 10)); } function buildPrimieraCategoryEntry( state: GameState, team: 0 | 1, myPile: Card[], oppPile: Card[], ): PrimieraCategoryEntry { const perSuit: Record = {} as Record; let totalCloseness = 0; for (const suit of SUITS) { const myBest = bestPrimieraValue(myPile, suit); const oppBest = bestPrimieraValue(oppPile, suit); let entry: CategoryEntry; if (myBest > oppBest && myBest >= PRIMIERA_VALUES[6]) { // We have 7 or 6 in this suit and it's the best entry = { state: 'secured', closeness: 1 }; } else if (oppBest > myBest && oppBest >= PRIMIERA_VALUES[6]) { entry = { state: 'lost', closeness: 0 }; } else { // Contested: closer to 1 if we have a better card const closeness = Math.max(0, Math.min(1, (myBest - oppBest + 10) / 20)); entry = { state: 'contested', closeness }; } perSuit[suit] = entry; totalCloseness += entry.closeness; } return { perSuit, overallCloseness: totalCloseness / 4, }; } // --------------------------------------------------------------------------- // Exported functions // --------------------------------------------------------------------------- export function analyzeTableParity(table: Card[]): ParityState { const countByValue = new Map(); for (const card of table) { countByValue.set(card.value, (countByValue.get(card.value) ?? 0) + 1); } const pairedRanks: number[] = []; const unpairedRanks: number[] = []; for (const [value, count] of countByValue) { if (count % 2 === 0) pairedRanks.push(value); else unpairedRanks.push(value); } return { pairedRanks, unpairedRanks, spariglioDegree: unpairedRanks.length, isEvenParity: unpairedRanks.length === 0, }; } export function rankDumpsBySpariglio( hand: Card[], table: Card[], isNonDealerTeam: boolean, ): SpariglioPotential[] { const current = analyzeTableParity(table); const potentials: SpariglioPotential[] = []; for (const card of hand) { if (canCardCapture(card, table)) continue; // only dump moves const tableAfter = [...table, card]; const after = analyzeTableParity(tableAfter); const spariglioDelta = after.spariglioDegree - current.spariglioDegree; potentials.push({ card, spariglioDelta, isSpariglio3Card: spariglioDelta >= 2, }); } // Non-dealer wants highest positive delta first; dealer wants lowest (most negative) first if (isNonDealerTeam) { potentials.sort((a, b) => b.spariglioDelta - a.spariglioDelta); } else { potentials.sort((a, b) => a.spariglioDelta - b.spariglioDelta); } return potentials; } export function detectMulinello( state: GameState, playerIdx: PlayerIndex, inference: CardInferenceEngine, ): MulinelloState { const table = state.table; if (table.length === 0 || table.length > 4) { return { active: false, favorableFor: null, breakingMoves: [] }; } const myHand = state.players[playerIdx].hand; const myCaptures = myHand.filter(c => canCardCapture(c, table)); if (myCaptures.length === 0 && table.length <= 2) { // We can't capture — potential mulinello against us return { active: true, favorableFor: 'them', breakingMoves: myHand.map(c => ({ card: c, capture: [] })), }; } return { active: false, favorableFor: null, breakingMoves: [] }; } export function getPhase(state: GameState): 'opening' | 'midgame' | 'endgame' { const totalCardsInHands = state.players.reduce((sum, p) => sum + p.hand.length, 0); const totalPlayed = 40 - totalCardsInHands - state.table.length; if (totalPlayed <= 12) return 'opening'; if (totalCardsInHands <= 16) return 'endgame'; return 'midgame'; } export function getCategoryStates(state: GameState, team: 0 | 1): CategoryStates { const myPile: Card[] = []; const oppPile: Card[] = []; for (let i = 0; i < 4; i++) { const p = state.players[i]; if (teamOf(i as PlayerIndex) === team) myPile.push(...p.pile); else oppPile.push(...p.pile); } const myCoins = myPile.filter(c => c.suit === 'denara').length; const oppCoins = oppPile.filter(c => c.suit === 'denara').length; const totalCards = myPile.length + oppPile.length + state.table.length; const unseenCoins = 10 - myCoins - oppCoins - state.table.filter(c => c.suit === 'denara').length; const unseenCards = 40 - totalCards; // Denari const denari: CategoryEntry = myCoins >= 6 ? { state: 'secured', closeness: 1 } : oppCoins >= 6 ? { state: 'lost', closeness: 0 } : { state: 'contested', closeness: Math.max(0, Math.min(1, (myCoins - oppCoins + 1) / Math.max(1, unseenCoins + 1))), }; // Carte const myCards = myPile.length; const oppCards = oppPile.length; const carte: CategoryEntry = myCards >= 21 ? { state: 'secured', closeness: 1 } : oppCards >= 21 ? { state: 'lost', closeness: 0 } : { state: 'contested', closeness: Math.max(0, Math.min(1, (myCards - oppCards + 1) / Math.max(1, unseenCards + 1))), }; // Primiera: per suit const primiera = buildPrimieraCategoryEntry(state, team, myPile, oppPile); return { denari, carte, primiera, scope: 'always_contested', settebello: 'always_contested', }; } export function solveEndgame( state: GameState, playerIdx: PlayerIndex, inference: CardInferenceEngine, legalMoves: AIMove[], ): AIMove | null { // Only attempt when very few cards remain const totalRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0); if (totalRemaining > 8) return null; const myHand = state.players[playerIdx].hand; for (let i = 0; i < 4; i++) { if (i === playerIdx) continue; const p = state.players[i]; if (p.hand.length === 0) continue; const constrained = inference.getConstrainedUnseen(i as PlayerIndex, myHand, state.table); if (constrained.length < p.hand.length) { // Can't fully determine this player's hand return null; } // If constrained pool equals exactly handSize, we know exactly what they have if (constrained.length > p.hand.length) return null; // still ambiguous } // We know all hands — pick the best move by greedy evaluation let bestMove: AIMove | null = null; let bestScore = -Infinity; for (const move of legalMoves) { let score = 0; // Scopa const tableAfter = state.table.filter(c => !move.capture.some(cap => cap.id === c.id)); if (move.capture.length > 0 && tableAfter.length === 0) score += 100; // Settebello if (move.capture.some(c => c.id === 'denara_7')) score += 80; // 7s score += move.capture.filter(c => c.value === 7).length * 40; // Coins score += move.capture.filter(c => c.suit === 'denara').length * 15; // Cards score += move.capture.length * 3; if (score > bestScore) { bestScore = score; bestMove = move; } } return bestMove; } export function analyzePrimieraRace(state: GameState, team: 0 | 1): PrimieraRaceState { const myPile: Card[] = []; const oppPile: Card[] = []; for (let i = 0; i < 4; i++) { const p = state.players[i]; if (teamOf(i as PlayerIndex) === team) myPile.push(...p.pile); else oppPile.push(...p.pile); } const allKnown = new Set([...myPile, ...oppPile, ...state.table].map(c => c.id)); const unseenPrimieraCards: Card[] = []; const PRIMIERA_ORDER = [7, 6, 1, 5, 4, 3, 2, 8, 9, 10]; for (const value of PRIMIERA_ORDER) { for (const suit of SUITS) { const id = `${suit}_${value}`; if (!allKnown.has(id) && (value === 7 || value === 6 || value === 1)) { unseenPrimieraCards.push({ suit, value, id }); } } } const teamLeadsBySuit: Record = {} as Record; const contestedSuits: Suit[] = []; for (const suit of SUITS) { const myBest = bestPrimieraValue(myPile, suit); const oppBest = bestPrimieraValue(oppPile, suit); if (myBest > oppBest) teamLeadsBySuit[suit] = true; else if (oppBest > myBest) teamLeadsBySuit[suit] = false; else { teamLeadsBySuit[suit] = null; contestedSuits.push(suit); } } return { teamLeadsBySuit, contestedSuits, unseenPrimieraCards }; }