// --------------------------------------------------------------------------- // AI entry point — strategy-driven, three difficulty levels // --------------------------------------------------------------------------- import { Card, GameState, PlayerIndex, Difficulty, AIMove, PRIMIERA_VALUES } from './types'; import { findCaptures, teamOf, RandomSource } from './engine'; import { CardTracker } from './card-tracker'; import { CardInferenceEngine } from './card-inference'; import { getCategoryStates, getPhase, solveEndgame, analyzeTableParity, rankDumpsBySpariglio, } from './ai-strategy'; import { pimcSearch } from './ai-pimc'; import type { PIMCOptions } from './ai-pimc'; export type { AIMove }; export interface AIDecisionProgress { difficulty: Difficulty; progress: number; 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 { timeBudgetMs: number; sampleCount: number; maxDepth: number; 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; inference?: CardInferenceEngine; } interface SearchTimingContext { now(): number; checkpoint(costMs?: number): number; yieldToHost(): Promise; } const SEARCH_PROFILES: Record = { beginner: { timeBudgetMs: 120, sampleCount: 0, maxDepth: 0, batchSize: 0 }, advanced: { timeBudgetMs: 650, sampleCount: 0, maxDepth: 0, batchSize: 0 }, master: { timeBudgetMs: 4300, sampleCount: 8, maxDepth: 5, batchSize: 2 }, }; const REAL_TIME_SOURCE: AITimingSource = { now: () => Date.now(), }; 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)); }, }; } // --------------------------------------------------------------------------- // Search profile helpers (preserved) // --------------------------------------------------------------------------- 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 applySearchProfileOverride( { timeBudgetMs: 3200, sampleCount: 4, maxDepth: cardsRemaining, batchSize: 1 }, profileOverride, ); } if (cardsRemaining <= 6) { return applySearchProfileOverride( { timeBudgetMs: 3600, sampleCount: 6, maxDepth: cardsRemaining, batchSize: 1 }, profileOverride, ); } if (cardsRemaining <= 8) { return applySearchProfileOverride( { timeBudgetMs: 3900, sampleCount: 8, maxDepth: cardsRemaining, batchSize: 1 }, profileOverride, ); } if (cardsRemaining <= 12) { return applySearchProfileOverride( { timeBudgetMs: 4200, sampleCount: 8, maxDepth: 8, batchSize: 1 }, profileOverride, ); } if (cardsRemaining <= 20) { return applySearchProfileOverride( { timeBudgetMs: 4350, sampleCount: 12, maxDepth: 5, batchSize: 2 }, profileOverride, ); } 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: timing.now() - startedAt, budgetMs, batchesCompleted, ...(masterDetails ?? {}), }); } // --------------------------------------------------------------------------- // Core move helpers // --------------------------------------------------------------------------- function nextPlayer(p: PlayerIndex): PlayerIndex { return ((p + 1) % 4) as PlayerIndex; } function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean { return teamOf(me) !== teamOf(other); } function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] { const moves: AIMove[] = []; const player = state.players[playerIdx]; const table = state.table; for (const card of player.hand) { const captures = findCaptures(card, table); if (captures.length > 0) { for (const captureSet of captures) moves.push({ card, capture: captureSet }); } else { moves.push({ card, capture: [] }); } } return moves; } /** * Returns a forced move (scopa or settebello capture) if one exists. * Priority 1: any capture that empties the table (scopa). * Priority 2: any capture that includes the settebello (denara_7). */ function checkForcedMove(legalMoves: AIMove[], table: Card[]): AIMove | null { // 1. Scopa: capture that clears the table for (const move of legalMoves) { if (move.capture.length > 0) { const tableAfter = table.filter(c => !move.capture.some(cap => cap.id === c.id)); if (tableAfter.length === 0) return move; } } // 2. Settebello capture for (const move of legalMoves) { if (move.capture.some(c => c.id === 'denara_7')) return move; } return null; } /** * Danger score for dumping a card on an empty table. * Lower = safer: fewer capture-combo possibilities + less strategic value. * * Primary combo-capture proxy: face value (higher → more subsets of future * table cards can sum to it, letting opponents capture it indirectly). * Category penalties: settebello, non-coin 7 (primiera), other denara, high * primiera-value cards (1/5/6 whose primiera contribution ≥ 15 pts). */ function emptyTableDumpDanger(card: Card): number { let danger = card.value; // 1-10: direct proxy for combo-capture breadth if (card.id === 'denara_7') danger += 50; // settebello — never dump else if (card.value === 7) danger += 20; // non-coin 7: top primiera else if (card.suit === 'denara') danger += 15; // coin card // High-primiera cards (1=16, 5=15, 6=18) get a small extra penalty. if (card.value !== 7 && (PRIMIERA_VALUES[card.value] ?? 0) >= 15) danger += 5; return danger; } /** * When the table is empty, choose the least-dangerous dump move. * * Rank 1 (primary): highest count of same face-value in our hand * — opponent holds fewer of that value → harder to * capture it with a direct same-value play. * Rank 2 (secondary): lowest emptyTableDumpDanger() score * — lower face value (fewer capture combos) and * not a strategically valuable card (7, denara, etc.). */ function pickSafestEmptyTableDump(hand: Card[], legalMoves: AIMove[]): AIMove | null { const dumps = legalMoves.filter(m => m.capture.length === 0); if (dumps.length === 0) return null; const countByValue = new Map(); for (const c of hand) countByValue.set(c.value, (countByValue.get(c.value) ?? 0) + 1); const sorted = [...dumps].sort((a, b) => { const cntA = countByValue.get(a.card.value) ?? 1; const cntB = countByValue.get(b.card.value) ?? 1; if (cntB !== cntA) return cntB - cntA; // more in hand → safer (desc) return emptyTableDumpDanger(a.card) - emptyTableDumpDanger(b.card); // less danger (asc) }); return sorted[0]; } function buildFallbackInference(tracker: CardTracker | undefined): CardInferenceEngine { return new CardInferenceEngine(tracker ?? new CardTracker()); } // =========================================================================== // BEGINNER — beatable, static priority + noise // =========================================================================== function beginnerMove( state: GameState, playerIdx: PlayerIndex, _tracker: CardTracker | undefined, rng: RandomSource, ): AIMove { const legalMoves = getLegalMoves(state, playerIdx); // 5% pure random if (rng() < 0.05) { return legalMoves[Math.floor(rng() * legalMoves.length)]; } // Forced moves (scopa / settebello) — unconditional const forced = checkForcedMove(legalMoves, state.table); if (forced) return forced; let bestMove = legalMoves[0]; let bestScore = -Infinity; for (const move of legalMoves) { let score = 0; const captured = move.capture; if (captured.length > 0) { // 7s (primiera) score += captured.filter(c => c.value === 7).length * 30; // Coins score += captured.filter(c => c.suit === 'denara').length * 20; // Cards score += captured.length * 5; } else { // Dump: penalise if it makes the table easy to scopa const tableAfter = [...state.table, move.card]; const tableSum = tableAfter.reduce((s, c) => s + c.value, 0); if (tableSum <= 10 && tableAfter.length <= 3) { score -= 80; } } // 20% noise band score *= 0.8 + rng() * 0.4; if (score > bestScore) { bestScore = score; bestMove = move; } } return bestMove; } // =========================================================================== // ADVANCED — forced check → category states → phase-aware heuristics + inference // =========================================================================== function advancedMove( state: GameState, playerIdx: PlayerIndex, _tracker: CardTracker | undefined, inference: CardInferenceEngine | null, ): AIMove { const legalMoves = getLegalMoves(state, playerIdx); // Forced moves first const forced = checkForcedMove(legalMoves, state.table); if (forced) return forced; // Early-game: empty table → no captures possible, dump the safest card. if (state.table.length === 0) { const safe = pickSafestEmptyTableDump(state.players[playerIdx].hand, legalMoves); if (safe) return safe; } const myTeam = teamOf(playerIdx); const categoryStates = getCategoryStates(state, myTeam); const phase = getPhase(state); // Endgame: attempt deterministic solve if (phase === 'endgame' && inference) { const endgameMove = solveEndgame(state, playerIdx, inference, legalMoves); if (endgameMove) return endgameMove; } const next = nextPlayer(playerIdx); const nextIsOpp = isOpponent(playerIdx, next); const nextHandSize = state.players[next].hand.length; const myHand = state.players[playerIdx].hand; // Spariglio analysis for dump moves const dealerTeam = teamOf(state.dealer); const isNonDealerTeam = myTeam !== dealerTeam; const spariglioPotentials = rankDumpsBySpariglio(myHand, state.table, isNonDealerTeam); const spariglioPotentialMap = new Map(spariglioPotentials.map(sp => [sp.card.id, sp])); const denariCloseness = categoryStates.denari.state === 'contested' ? categoryStates.denari.closeness : 0; const carteCloseness = categoryStates.carte.state === 'contested' ? categoryStates.carte.closeness : 0; const primieraCloseness = categoryStates.primiera.overallCloseness; let bestMove = legalMoves[0]; let bestScore = -Infinity; for (const move of legalMoves) { let score = 0; const captured = move.capture; if (captured.length > 0) { // Settebello if (captured.some(c => c.id === 'denara_7')) score += 50; // Coins weighted by closeness score += captured.filter(c => c.suit === 'denara').length * Math.round(15 + denariCloseness * 25); // 7s weighted by primiera closeness score += captured.filter(c => c.value === 7).length * Math.round(20 + primieraCloseness * 40); // Cards: use floor of 6 so captures stay attractive even when carte is secured score += captured.length * Math.round(6 + carteCloseness * 8); } else { // Dump: spariglio-aware scoring const sp = spariglioPotentialMap.get(move.card.id); if (sp) { score += sp.spariglioDelta * (isNonDealerTeam ? 15 : -15); if (sp.isSpariglio3Card) score += isNonDealerTeam ? 20 : -20; } // Damage minimization: penalise dumping contested-category cards. // We cannot capture with them, so we're leaving them on the table for // the opponent to take — the higher the contest, the bigger the risk. const dumpCard = move.card; if (dumpCard.suit === 'denara') { // Giving away a coin card when the denari race is live score -= Math.round(8 + denariCloseness * 18); if (dumpCard.value === 7) score -= 18; // settebello is uniquely dangerous } if (dumpCard.value === 7) { // Giving away a 7 hurts primiera regardless of suit score -= Math.round(10 + primieraCloseness * 24); } // High primiera-value cards in contested suits (7=21, 6=18, 1=16, 5=15) const dumpSuitEntry = categoryStates.primiera.perSuit[dumpCard.suit]; if (dumpSuitEntry && dumpSuitEntry.state === 'contested') { const primVal = PRIMIERA_VALUES[dumpCard.value] ?? 0; if (primVal >= 15) { score -= Math.round(primVal * 0.25 + dumpSuitEntry.closeness * 14); } } // If the opponent likely holds the same value they can directly capture // our dump card on the very next turn — compound the damage if (inference && nextIsOpp && nextHandSize > 0) { const projectedTable = [...state.table, dumpCard]; const probCapture = inference.probabilityPlayerHasValue( next, dumpCard.value, nextHandSize, myHand, projectedTable, ); if (probCapture > 0.05) { score -= Math.round(probCapture * 20); if (dumpCard.suit === 'denara') score -= Math.round(probCapture * (14 + denariCloseness * 20)); if (dumpCard.value === 7) score -= Math.round(probCapture * (10 + primieraCloseness * 20)); } } } // Anti-scopa: penalise leaving a table the opponent can immediately sweep if (inference && nextIsOpp && nextHandSize > 0) { const tableAfter = captured.length > 0 ? state.table.filter(c => !captured.some(cap => cap.id === c.id)) : [...state.table, move.card]; if (tableAfter.length > 0) { const tableSum = tableAfter.reduce((s, c) => s + c.value, 0); const prob = inference.probabilityPlayerHasValue(next, tableSum, nextHandSize, myHand, tableAfter); score -= prob * 60; } } if (score > bestScore) { bestScore = score; bestMove = move; } } return bestMove; } // =========================================================================== // MASTER — forced check → endgame solve → PIMC search // =========================================================================== async function masterMove( state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, onProgress: ((progress: AIDecisionProgress) => void) | undefined, profile: SearchProfile, startedAt: number, timing: SearchTimingContext, rng: RandomSource, inference: CardInferenceEngine | null, ): Promise { const cardsRemaining = state.players.reduce((s, p) => s + p.hand.length, 0); const legalMoves = getLegalMoves(state, playerIdx); if (legalMoves.length === 1) { reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { cardsRemaining, sampleCount: 1, maxDepth: 1, completedDepth: 1, rootMoveCount: 1, timedOut: false, aspirationExpansions: 0, }); return legalMoves[0]; } // Forced moves const forced = checkForcedMove(legalMoves, state.table); if (forced) { reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { cardsRemaining, sampleCount: 0, maxDepth: 0, completedDepth: 0, rootMoveCount: legalMoves.length, timedOut: false, aspirationExpansions: 0, }); return forced; } // Deterministic endgame solve if (inference) { const endgameMove = solveEndgame(state, playerIdx, inference, legalMoves); if (endgameMove) { reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { cardsRemaining, sampleCount: 0, maxDepth: 0, completedDepth: 0, rootMoveCount: legalMoves.length, timedOut: false, aspirationExpansions: 0, }); return endgameMove; } } // Early-game: empty table → no captures possible, dump the safest card. if (state.table.length === 0) { const safe = pickSafestEmptyTableDump(state.players[playerIdx].hand, legalMoves); if (safe) { reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { cardsRemaining, sampleCount: 0, maxDepth: 0, completedDepth: 0, rootMoveCount: legalMoves.length, timedOut: false, aspirationExpansions: 0, }); return safe; } } const myTeam = teamOf(playerIdx); const categoryStates = getCategoryStates(state, myTeam); const parityState = analyzeTableParity(state.table); const effectiveInference = inference ?? buildFallbackInference(tracker); await timing.yieldToHost(); const deadline = startedAt + profile.timeBudgetMs; const remainingBudget = Math.max(100, deadline - timing.now()); reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 0, 0, { cardsRemaining, sampleCount: profile.sampleCount, maxDepth: profile.maxDepth, completedDepth: 0, rootMoveCount: legalMoves.length, timedOut: false, aspirationExpansions: 0, }); const pimcOptions: Partial = { determinizations: profile.sampleCount ?? 12, timeBudgetMs: remainingBudget, maxDepthMidgame: Math.min(profile.maxDepth ?? 5, 5), maxDepthEndgame: 8, stabilityWeight: 0.3, timingSource: timing, }; const results = pimcSearch( state, playerIdx, legalMoves, effectiveInference, categoryStates, parityState, pimcOptions, rng, tracker, ); reportDecisionProgress(onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, 1, { cardsRemaining, sampleCount: profile.sampleCount, maxDepth: profile.maxDepth, completedDepth: profile.maxDepth, rootMoveCount: legalMoves.length, timedOut: timing.now() >= deadline, aspirationExpansions: 0, }); return results[0]?.move ?? legalMoves[0]; } // =========================================================================== // Main entry point // =========================================================================== export async function chooseMove( state: GameState, playerIdx: PlayerIndex, difficulty: Difficulty = 'advanced', tracker?: CardTracker, onProgress?: (progress: AIDecisionProgress) => void, options?: AIChooseMoveOptions, ): Promise { const timing = createSearchTimingContext(options?.timingSource); const startedAt = timing.now(); const profile = getSearchProfile(state, difficulty, options?.profileOverride); const rng = options?.rng ?? Math.random; const inference = options?.inference ?? null; reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 0, 0); switch (difficulty) { case 'beginner': { const move = beginnerMove(state, playerIdx, tracker, rng); reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); return move; } case 'advanced': { const move = advancedMove(state, playerIdx, tracker, inference); reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); return move; } case 'master': return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt, timing, rng, inference); } }