import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS, DealerRelativeRole, AIMove } from './types'; import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState, getDealerRelativeRole, RandomSource } from './engine'; import { CardTracker } from './card-tracker'; 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; } interface DealerRoleContext { role: DealerRelativeRole; onDealerSide: boolean; defendingDealerAdvantage: boolean; attackingDealerAdvantage: boolean; aggressionBias: number; controlBias: number; pairPreservingBias: number; pairBreakingBias: number; tablePressureBias: number; } interface RankResidueSnapshot { unseenSameRankCounts: number[]; hasSingletonResidue: boolean[]; hasPairedResidue: boolean[]; } interface SearchTimingContext { now(): number; checkpoint(costMs?: number): number; yieldToHost(): Promise; } const DEALER_ROLE_WEIGHTS: Record> = { 'first-hand': { aggressionBias: 1.28, controlBias: 0.9, pairPreservingBias: 0.88, pairBreakingBias: 1.26, tablePressureBias: 1.3, }, 'second-hand': { aggressionBias: 1, controlBias: 1.08, pairPreservingBias: 1.12, pairBreakingBias: 0.96, tablePressureBias: 1, }, 'third-hand': { aggressionBias: 1.16, controlBias: 0.94, pairPreservingBias: 0.94, pairBreakingBias: 1.16, tablePressureBias: 1.12, }, dealer: { aggressionBias: 0.84, controlBias: 1.32, pairPreservingBias: 1.34, pairBreakingBias: 0.82, tablePressureBias: 0.78, }, }; 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 UPCOMING_TABLE_EXPOSURE_WEIGHTS = [1, 0.72, 0.44] as const; const REPRESENTATIVE_CARD_BY_VALUE = (() => { const cardsByValue = new Map(); for (const card of buildDeck()) { if (!cardsByValue.has(card.value)) { cardsByValue.set(card.value, card); } } return cardsByValue; })(); 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 // --------------------------------------------------------------------------- function nextPlayer(p: PlayerIndex): PlayerIndex { return ((p + 1) % 4) as PlayerIndex; } function partnerOf(p: PlayerIndex): PlayerIndex { return ((p + 2) % 4) as PlayerIndex; } function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean { return teamOf(me) !== teamOf(other); } function primieraVal(card: Card): number { return PRIMIERA_VALUES[card.value] ?? 0; } function gamePhase(state: GameState): number { const totalCards = state.players.reduce((s, p) => s + p.hand.length, 0); return 1 - totalCards / 40; } function getTeamPile(state: GameState, playerIdx: PlayerIndex): Card[] { return [...state.players[playerIdx].pile, ...state.players[partnerOf(playerIdx)].pile]; } /** Is this the very last play of the round? (all hands have 0 or 1 cards, and it's this player's turn) */ function isLastPlay(state: GameState, playerIdx: PlayerIndex): boolean { for (let i = 0; i < 4; i++) { if (i === playerIdx) { if (state.players[i].hand.length !== 1) return false; } else { if (state.players[i].hand.length !== 0) return false; } } return true; } /** Count how many cards in hand match a given value (anchor candidates) */ function countValueInHand(hand: Card[], value: number): number { let n = 0; for (const c of hand) if (c.value === value) n++; return n; } function getDealerRoleContext(state: GameState, playerIdx: PlayerIndex): DealerRoleContext { const role = getDealerRelativeRole(state.dealer, playerIdx); const onDealerSide = role === 'dealer' || role === 'second-hand'; return { role, onDealerSide, defendingDealerAdvantage: onDealerSide, attackingDealerAdvantage: !onDealerSide, ...DEALER_ROLE_WEIGHTS[role], }; } function getRankResidueSnapshot( tracker: CardTracker | undefined, myHand: Card[], table: Card[], ): RankResidueSnapshot | null { if (!tracker) return null; 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) { unseenSameRankCounts[residue.value] = residue.unseenCount; hasSingletonResidue[residue.value] = residue.hasSingletonUnseenRankResidue; hasPairedResidue[residue.value] = residue.hasPairedUnseenRankResidue; } return { unseenSameRankCounts, hasSingletonResidue, hasPairedResidue }; } function countRankResidueValuesOnTable( afterTable: Card[], rankResidue: RankResidueSnapshot | null, ): { singletonValues: number; pairedValues: number } { if (!rankResidue || afterTable.length === 0) { return { singletonValues: 0, pairedValues: 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 (rankResidue.hasSingletonResidue[card.value]) singletonValues++; else if (rankResidue.hasPairedResidue[card.value]) pairedValues++; } 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( afterTable: Card[], roleContext: DealerRoleContext, nextIsOpp: boolean, ): number { if (afterTable.length === 0) return 0; const tableSum = afterTable.reduce((sum, card) => sum + card.value, 0); let score = 0; if (roleContext.role === 'first-hand') { if (afterTable.length >= 2) score += 22 * roleContext.tablePressureBias; if (tableSum >= 8 && tableSum <= 15) score += 18 * roleContext.aggressionBias; } if (roleContext.role === 'third-hand') { if (afterTable.length >= 2) score += 14 * roleContext.tablePressureBias; if (tableSum >= 10) score += 10 * roleContext.aggressionBias; } if (roleContext.role === 'second-hand') { if (nextIsOpp && tableSum >= 11) score += 16 * roleContext.controlBias; if (!nextIsOpp && tableSum <= 10) score += 10 * roleContext.tablePressureBias; } if (roleContext.role === 'dealer') { if (tableSum >= 11) score += 28 * roleContext.controlBias; if (tableSum <= 10 && nextIsOpp) score -= 24 * roleContext.controlBias; if (afterTable.length === 1 && nextIsOpp) score -= 16 * roleContext.controlBias; } return Math.round(score); } function scoreRankResidueTableState( afterTable: Card[], rankResidue: RankResidueSnapshot | null, roleContext: DealerRoleContext, nextIsOpp: boolean, ): number { const { singletonValues, pairedValues } = countRankResidueValuesOnTable(afterTable, rankResidue); if (singletonValues === 0 && pairedValues === 0) return 0; let score = 0; if (roleContext.defendingDealerAdvantage) { score += pairedValues * 18 * roleContext.controlBias; score -= singletonValues * 22 * roleContext.controlBias; if (nextIsOpp) score += pairedValues * 8 - singletonValues * 10; } else { score += singletonValues * 20 * roleContext.tablePressureBias; score -= pairedValues * 10; if (nextIsOpp) score += singletonValues * 12; } return Math.round(score); } function scoreCaptureRankResiduePlan( played: Card, captured: Card[], afterTable: Card[], rankResidue: RankResidueSnapshot | null, roleContext: DealerRoleContext, nextIsOpp: boolean, ): number { if (!rankResidue || captured.length === 0) return 0; let score = 0; const directCapture = captured.length === 1 && captured[0].value === played.value; if (directCapture) { 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 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 ((rankResidue.unseenSameRankCounts[card.value] ?? 0) > 0) pairBreaks++; if (rankResidue.hasSingletonResidue[card.value]) singletonTargets++; } 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 += scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); return Math.round(score); } function scoreDumpRankResiduePlan( card: Card, afterTable: Card[], rankResidue: RankResidueSnapshot | null, roleContext: DealerRoleContext, nextIsOpp: boolean, ): number { if (!rankResidue) return 0; let score = scoreRankResidueTableState(afterTable, rankResidue, roleContext, nextIsOpp); if (rankResidue.hasSingletonResidue[card.value]) { score += roleContext.attackingDealerAdvantage ? 18 * roleContext.tablePressureBias : -20 * roleContext.controlBias; } if (rankResidue.hasPairedResidue[card.value]) { score += roleContext.defendingDealerAdvantage ? 14 * roleContext.pairPreservingBias : 6; } return Math.round(score); } 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 ?? {}), }); } function handLikelyHasValue( value: number, handSize: number, state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, myHand: Card[], table: Card[], ): number { if (handSize <= 0) return 0; if (tracker) { return tracker.probabilityHandHasValue(value, handSize, myHand, table); } const unseen = getUnseenCardsForEstimate(state, playerIdx, myHand, table, tracker); let unseenWithValue = 0; for (const card of unseen) { if (card.value === value) unseenWithValue++; } if (unseenWithValue === 0 || unseen.length === 0) return 0; const probNone = hypergeometricNone(unseen.length, unseenWithValue, handSize); return 1 - probNone; } /** Check if partner likely holds a card of given value (via tracker inference) */ function partnerLikelyHolds( value: number, playerIdx: PlayerIndex, state: GameState, tracker: CardTracker | undefined, myHand: Card[], table: Card[], ): number { const partner = partnerOf(playerIdx); return handLikelyHasValue(value, state.players[partner].hand.length, state, playerIdx, tracker, myHand, table); } /** Race state: who's winning each scoring category */ interface RaceState { myCards: number; oppCards: number; myDenari: number; oppDenari: number; mySettebello: boolean; oppSettebello: boolean; my7s: number; opp7s: number; myScope: number; oppScope: number; behindInCards: boolean; behindInDenari: boolean; denariRaceLive: boolean; needSettebello: boolean; need7s: boolean; sevenRaceLive: boolean; aheadOverall: boolean; } function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { const myTeam = teamOf(playerIdx); const mine = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; const opps = myTeam === 0 ? [state.players[1], state.players[3]] : [state.players[0], state.players[2]]; const myPile = mine.flatMap(p => p.pile); const oppPile = opps.flatMap(p => p.pile); const myCards = myPile.length, oppCards = oppPile.length; const myDenari = myPile.filter(c => c.suit === 'denara').length; const oppDenari = oppPile.filter(c => c.suit === 'denara').length; const mySettebello = myPile.some(c => c.suit === 'denara' && c.value === 7); const oppSettebello = oppPile.some(c => c.suit === 'denara' && c.value === 7); const my7s = myPile.filter(c => c.value === 7).length; const opp7s = oppPile.filter(c => c.value === 7).length; const myScope = mine.reduce((s, p) => s + p.scope, 0); const oppScope = opps.reduce((s, p) => s + p.scope, 0); const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); // Simple overall advantage estimate let myAdv = 0; if (myCards > oppCards) myAdv++; else if (oppCards > myCards) myAdv--; if (myDenari > oppDenari) myAdv++; else if (oppDenari > myDenari) myAdv--; if (mySettebello) myAdv++; else if (oppSettebello) myAdv--; myAdv += myScope - oppScope; return { myCards, oppCards, myDenari, oppDenari, mySettebello, oppSettebello, my7s, opp7s, myScope, oppScope, behindInCards: myCards < oppCards, behindInDenari: myDenari < oppDenari, denariRaceLive: cardsRemaining > 0 && myDenari < 6 && oppDenari < 6 && Math.abs(myDenari - oppDenari) <= 1, needSettebello: !mySettebello && !oppSettebello, need7s: my7s <= opp7s, sevenRaceLive: cardsRemaining > 0 && my7s < 3 && opp7s < 3 && Math.abs(my7s - opp7s) <= 1, aheadOverall: myAdv > 0, }; } /** * Count scopa threats: how many unseen cards can clear a given table. * Uses probabilistic assessment per-player based on hand sizes. */ function countScopaThreats( afterTable: Card[], myHand: Card[], tracker: CardTracker | undefined, state: GameState, playerIdx: PlayerIndex, ): { totalThreats: number; nextOppCanScopa: boolean; secondOppCanScopa: boolean; partnerCanScopa: boolean } { if (afterTable.length === 0) return { totalThreats: 0, nextOppCanScopa: false, secondOppCanScopa: false, partnerCanScopa: false }; const unseen = tracker ? tracker.getUnseenCards(myHand, afterTable) : getUnseenWithoutTracker(state, playerIdx); // Count every unseen card that has at least one capture clearing the full table let totalThreats = 0; const threatCardIds = new Set(); for (const uc of unseen) { const caps = findCaptures(uc, afterTable); for (const cap of caps) { if (cap.length === afterTable.length) { totalThreats++; threatCardIds.add(uc.id); break; } } } // Probabilistic check for each player const next = nextPlayer(playerIdx); const second = nextPlayer(next); const third = nextPlayer(second); // = partner const unseenCount = unseen.length; let nextOppCanScopa = false; let secondOppCanScopa = false; let partnerCanScopa = false; if (totalThreats > 0 && unseenCount > 0) { for (const other of [next, second, third]) { const hs = state.players[other].hand.length; if (hs === 0) continue; const probNone = hypergeometricNone(unseenCount, totalThreats, hs); const prob = 1 - probNone; if (isOpponent(playerIdx, other)) { if (other === next) nextOppCanScopa = prob > 0.20; else secondOppCanScopa = prob > 0.20; } else if (other !== playerIdx) { partnerCanScopa = prob > 0.30; } } } 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: 5000000, 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 isImmediateTacticalConcession( afterTable: Card[], nextIsOpp: boolean, threats: ScopaThreatSummary | null, ): boolean { if (!nextIsOpp || afterTable.length === 0) return false; const tableSum = sumCardValues(afterTable); if (afterTable.length === 1 && tableSum <= 10) return true; if (afterTable.length === 2 && tableSum <= 10) return true; return Boolean(threats?.nextOppCanScopa); } function evaluateSafeScopaPriority( clearsTable: boolean, afterTable: Card[], lastPlay: boolean, nextIsOpp: boolean, threats: ScopaThreatSummary | null, ): number { if (!clearsTable || lastPlay) return 0; return isImmediateTacticalConcession(afterTable, nextIsOpp, threats) ? 1 : 2; } function evaluateFirstHandOpeningReleasePriority( card: Card, myHand: Card[], projectedHand: Card[], afterTable: Card[], state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, nextIsOpp: boolean, roleContext: DealerRoleContext, ): number { if (!nextIsOpp || roleContext.role !== 'first-hand' || afterTable.length !== 1) { return 0; } const nextHandSize = state.players[nextPlayer(playerIdx)].hand.length; if (nextHandSize <= 0) return 0; const sameValueCount = countValueInHand(myHand, card.value); const immediateScopaRisk = handLikelyHasValue( card.value, nextHandSize, state, playerIdx, tracker, projectedHand, afterTable, ); let score = 0; score += Math.max(0, sameValueCount - 1) * 2; if (sameValueCount >= 3) score += 2; score += Math.round((0.32 - immediateScopaRisk) * 12); if (sameValueCount >= 2 && card.value >= 8) score += 2; if (sameValueCount >= 2 && card.value >= 8 && card.suit !== 'denara') score += 3; if (sameValueCount >= 2 && card.suit === 'denara') score -= 2; if (card.suit === 'denara') score -= 1; if (card.value === 7) score -= 1; if (sameValueCount === 1 && card.value <= 3) score -= 2; return clampPriorityBand(score, -8, 8); } function evaluateAntiScopaPriority( afterTable: Card[], nextIsOpp: boolean, threats: ScopaThreatSummary | null, ): number { if (afterTable.length === 0) return 8; const tableSum = sumCardValues(afterTable); const exposedDenari = afterTable.filter(card => card.suit === 'denara').length; const exposedSevens = afterTable.filter(card => card.value === 7).length; let score = tableSum >= 14 ? 7 : tableSum >= 11 ? 6 : 0; if (nextIsOpp) { if (tableSum <= 12) score -= 3; if (tableSum <= 10) score -= 6; if (tableSum <= 6) score -= 4; if (afterTable.length === 1) score -= 9; else if (afterTable.length === 2 && tableSum <= 12) score -= 6; else if (afterTable.length === 3 && tableSum <= 12) score -= 3; if (afterTable.length === 3 && tableSum <= 18) score -= 2; score -= exposedDenari * 2; score -= exposedSevens * 3; if (afterTable.length <= 2 && (exposedDenari > 0 || exposedSevens > 0)) { score -= 4 + exposedDenari + exposedSevens; } if (afterTable.length === 3 && tableSum <= 18 && (exposedDenari > 0 || exposedSevens > 0)) { score -= 4 + exposedDenari * 2 + exposedSevens * 2; } if (afterTable.length >= 4 && tableSum >= 20) score += 4; if (afterTable.length >= 5 && tableSum >= 24) score += 2; } if (threats) { if (threats.nextOppCanScopa) score -= 10; if (threats.secondOppCanScopa) score -= 5; score -= Math.min(8, threats.totalThreats); if (threats.partnerCanScopa) { score += nextIsOpp && !threats.nextOppCanScopa ? 4 : 2; } } if (!nextIsOpp && tableSum >= 11) score += 2; if (!nextIsOpp && afterTable.length >= 4 && tableSum >= 15) 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; } if (threats?.partnerCanScopa && !threats.nextOppCanScopa) { score += tableSum <= 12 ? 7 : 4; if (afterTable.length >= 4) score += 3; if (denariOnTable === 0) score += 1; if (sevensOnTable === 0) score += 1; } if (threats?.secondOppCanScopa) score -= 2; } 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; const strippedAllSevens = capturedSevens > 0 && exposedSevens === 0; score += capturedSevens * (need7s ? 8 : 5); if (capturedSevens > 0) score += need7s ? 4 : 2; if (strippedAllSevens) score += need7s ? 5 : 3; if (nextIsOpp) { score -= exposedSevens * (need7s ? 10 : 6); if (exposedSevens > 0 && afterTable.length <= 2) { score -= need7s ? 6 : 4; } } else { score += exposedSevens; } if (releasedCard?.value === 7) { score -= need7s ? 12 : 7; if (nextIsOpp && afterTable.length <= 2) score -= need7s ? 6 : 4; } 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; const strippedAllDenari = capturedDenari > 0 && exposedDenari === 0; score += capturedDenari * (behindInDenari ? 7 : 4); if (capturedDenari > 0) score += behindInDenari ? 4 : 2; if (strippedAllDenari) score += behindInDenari ? 5 : 3; if (nextIsOpp) { score -= exposedDenari * (behindInDenari ? 10 : 6); if (exposedDenari > 0 && afterTable.length <= 2) { score -= behindInDenari ? 6 : 4; } } else { score += Math.min(2, exposedDenari); } if (releasedCard?.suit === 'denara') { score -= behindInDenari ? 11 : 6; if (nextIsOpp && afterTable.length <= 2) score -= behindInDenari ? 6 : 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; let p = 1; for (let i = 0; i < drawn; i++) { p *= Math.max(0, (total - threats - i)) / (total - i); } return p; } // --------------------------------------------------------------------------- // 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); reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 0, 0); switch (difficulty) { case 'beginner': { const move = beginnerMove(state, playerIdx, tracker); reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); return move; } case 'advanced': { const move = advancedMove(state, playerIdx, tracker); reportDecisionProgress(onProgress, difficulty, startedAt, timing, profile.timeBudgetMs, 1, 1); return move; } case 'master': return masterMove(state, playerIdx, tracker, onProgress, profile, startedAt, timing, options?.rng ?? Math.random); } } // =========================================================================== // BEGINNER — beatable but not stupid, basic strategy awareness // =========================================================================== function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { const player = state.players[playerIdx]; const table = state.table; const phase = gamePhase(state); const next = nextPlayer(playerIdx); const nextIsOpp = isOpponent(playerIdx, next); const lastPlay = isLastPlay(state, playerIdx); // 5% pure random (reduced from 8%) if (Math.random() < 0.05) { return randomMove(state, playerIdx); } let bestMove: AIMove | null = null; let bestScore = -Infinity; for (const card of player.hand) { const captures = findCaptures(card, table); if (captures.length > 0) { for (const captureSet of captures) { const base = scoreCaptureBeginner(card, captureSet, table, state, playerIdx, phase, nextIsOpp, lastPlay); const score = base + (Math.random() - 0.5) * Math.max(60, Math.abs(base) * 0.2); if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { const base = scoreDumpBeginner(card, table, state, playerIdx, phase, nextIsOpp, player.hand); const score = base + (Math.random() - 0.5) * Math.max(50, Math.abs(base) * 0.2); if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } } } return bestMove!; } function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove { const hand = state.players[playerIdx].hand; const card = hand[Math.floor(Math.random() * hand.length)]; const captures = findCaptures(card, state.table); if (captures.length > 0) { return { card, capture: captures[Math.floor(Math.random() * captures.length)] }; } return { card, capture: [] }; } function scoreCaptureBeginner( played: Card, captured: Card[], table: Card[], state: GameState, playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, lastPlay: boolean, ): number { 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; 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 (!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; } } 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( card: Card, table: Card[], state: GameState, playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, hand: Card[], ): number { const afterTable = [...table, card]; // 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; 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; const dupes = countValueInHand(hand, card.value); if (dupes >= 2) material += 18; if (dupes >= 3) material += 8; 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, }); } // =========================================================================== // ADVANCED — strong heuristic with card counting, race tracking, cooperation // anchor strategy, whirlwind detection, team signaling // =========================================================================== function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { const player = state.players[playerIdx]; const table = state.table; const phase = gamePhase(state); const race = getRaceState(state, playerIdx); const roleContext = getDealerRoleContext(state, playerIdx); const rankResidue = getRankResidueSnapshot(tracker, player.hand, table); const next = nextPlayer(playerIdx); const nextIsOpp = isOpponent(playerIdx, next); const partner = partnerOf(playerIdx); const partnerHandSize = state.players[partner].hand.length; const lastPlay = isLastPlay(state, playerIdx); let bestMove: AIMove | null = null; let bestScore = -Infinity; for (const card of player.hand) { const captures = findCaptures(card, table); if (captures.length > 0) { for (const captureSet of captures) { const score = scoreCaptureAdv( card, captureSet, table, state, playerIdx, race, 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, rankResidue, ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } } } return bestMove!; } 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, rankResidue: RankResidueSnapshot | null, ): number { const allCaptured = [played, ...captured]; const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); const projectedHand = myHand.filter(card => card.id !== played.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, projectedHand, tracker, state, playerIdx); const scopaPriority = evaluateSafeScopaPriority(isScopa, afterTable, lastPlay, nextIsOpp, threats); const afterTableSum = sumCardValues(afterTable); const exposedDenariCount = afterTable.filter(card => card.suit === 'denara').length; const exposedSevenCount = afterTable.filter(card => card.value === 7).length; const capturedDenariCount = allCaptured.filter(card => card.suit === 'denara').length; const capturedSevenCount = allCaptured.filter(card => card.value === 7).length; const liveDenariPressure = race.behindInDenari || race.denariRaceLive; const liveSevenPressure = race.need7s || race.sevenRaceLive; const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext); const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); const directSevenPrimieraSwing = scoreDirectSevenPrimieraSwing( played, captured, afterTable, myHand, table, liveSevenPressure, ); const liveCardsMajorityRace = race.myCards < 21 && race.oppCards < 21 && Math.abs(race.myCards - race.oppCards) <= 5; const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards; const cardsMajorityDelta = scoreCardsMajorityPosition(race.myCards + allCaptured.length, race.oppCards, phase) - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase); let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6; material += capturedDenariCount * (race.behindInDenari ? 20 : race.denariRaceLive ? (protectingCardsLead ? 12 : 16) : protectingCardsLead ? 7 : 10); material += capturedSevenCount * (race.need7s ? 14 : race.sevenRaceLive ? 11 : 7); for (const card of allCaptured) material += primieraVal(card) * 2; material += Math.round((afterPairInventory - beforePairInventory) * 1.8); material += directSevenPrimieraSwing; material += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 1.2 : 0.6)); if (protectingCardsLead && captured.length > 1) material += 96 + captured.length * 24; if (capturesSettebello) material += 72; if (tableHasSettebello && nextIsOpp && !capturesSettebello) material -= 84; if ( protectingCardsLead && !race.behindInDenari && captured.length === 1 && captured[0].suit === 'denara' && !capturesSettebello ) { material -= 84; } if (capturedDenariCount > 0 && nextIsOpp && exposedDenariCount === 0) material += liveDenariPressure ? 30 : 14; if (capturedSevenCount > 0 && nextIsOpp && exposedSevenCount === 0) material += liveSevenPressure ? 34 : 16; if ( nextIsOpp && !isScopa && afterTable.length <= 2 && afterTableSum <= 12 ) { material -= 34; material -= exposedDenariCount * (liveDenariPressure ? 18 : 10); material -= exposedSevenCount * (liveSevenPressure ? 20 : 12); } const teamPile = getTeamPile(state, playerIdx); for (const card of allCaptured) { if (card.value === 7 && !teamPile.some(teamCard => teamCard.suit === card.suit && teamCard.value === 7)) { material += 10; } } material += Math.round(scoreCaptureRankResiduePlan(played, captured, afterTable, rankResidue, roleContext, nextIsOpp) / 6); material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); if (!isScopa) { for (const tableCard of afterTable) { const dupes = countValueInHand(myHand, tableCard.value); if (dupes >= 1) material += 7; if (dupes >= 2) material += 5; const partnerProb = partnerLikelyHolds(tableCard.value, playerIdx, state, tracker, myHand, afterTable); if (partnerProb > 0.4) material += 6; } } if (nextIsOpp && tracker?.isSettebelloUnseen() && !capturesSettebello && afterTable.some(c => c.suit === 'denara' && c.value === 7)) { material -= 18; } if (tracker && !isScopa && phase > 0.5 && sumCardValues(afterTable) <= 10) { const confidence = Math.min(1, tracker.playedCount / 25); material -= Math.round(confidence * 20); } 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; if (countValueInHand(myHand, played.value) >= 2) { material -= Math.round((played.value >= 8 ? 28 : 14) * roleContext.pairPreservingBias); if (roleContext.defendingDealerAdvantage && !isScopa) { material -= Math.round((played.value >= 8 ? 18 : 8) * roleContext.controlBias); } } return scoreTacticalPriorityLadder({ scopa: scopaPriority, 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, rankResidue: RankResidueSnapshot | null, ): number { const afterTable = [...table, card]; const projectedHand = myHand.filter(held => held.id !== card.id); // --- HARD RULES --- if (card.suit === 'denara' && card.value === 7) return -10000; const threats = getPriorityThreatSummary(afterTable, projectedHand, tracker, state, playerIdx); const tableSum = sumCardValues(afterTable); const exposedDenariCount = afterTable.filter(tableCard => tableCard.suit === 'denara').length; const exposedSevenCount = afterTable.filter(tableCard => tableCard.value === 7).length; const liveDenariPressure = race.behindInDenari || race.denariRaceLive; const liveSevenPressure = race.need7s || race.sevenRaceLive; const complement = 10 - card.value; const preservesHighComplementWindow = nextIsOpp && card.value >= 1 && card.value <= 3 && afterTable.length >= 4 && afterTable.some(tableCard => tableCard.value === complement); const openingReleasePriority = evaluateFirstHandOpeningReleasePriority( card, myHand, projectedHand, afterTable, state, playerIdx, tracker, nextIsOpp, roleContext, ); const beforePairInventory = scoreProtectedPairInventory(myHand, roleContext); const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); const openingDuplicateReleaseBias = scoreOpeningDuplicateReleaseBias( card, myHand, state, playerIdx, nextIsOpp, roleContext, ); let material = -20 + phase * 6; if (card.suit === 'denara') material -= race.behindInDenari ? 28 : race.denariRaceLive ? 24 : 16; if (card.value === 7) material -= race.need7s ? 26 : race.sevenRaceLive ? 22 : 14; if (card.value === 6) material -= 12; if (card.value === 1) material -= 10; if (card.value >= 8) material += 14 + card.value * 2; const dupes = countValueInHand(myHand, card.value); if (dupes >= 2) material += 24; if (dupes >= 3) material += 10; material += Math.round((afterPairInventory - beforePairInventory) * 1.9); material += Math.round(openingDuplicateReleaseBias * 0.35); const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table); if (partnerProb > 0.4) material += 14; material += Math.round(scoreDumpRankResiduePlan(card, afterTable, rankResidue, roleContext, nextIsOpp) / 6); material += Math.round(scoreRoleTablePlan(afterTable, roleContext, nextIsOpp) / 8); if (afterTable.length >= 4 && tableSum >= 15) material += 10; if (nextIsOpp && afterTable.length >= 4 && tableSum >= 24) material += 22; if (!nextIsOpp && card.value >= 8) material += 8; if (nextIsOpp && afterTable.length <= 2 && tableSum <= 12) { material -= 28; material -= exposedDenariCount * (liveDenariPressure ? 14 : 8); material -= exposedSevenCount * (liveSevenPressure ? 16 : 10); } if (threats?.partnerCanScopa && !threats.nextOppCanScopa) { material += afterTable.length >= 4 ? 34 : 22; if (tableSum >= 10 && tableSum <= 12) material += 26; } if (preservesHighComplementWindow) { material += tableSum >= 24 ? 56 : 32; if (card.suit !== 'denara') material += 12; } if ( roleContext.defendingDealerAdvantage && beforePairInventory > 0 && afterPairInventory === beforePairInventory && card.suit !== 'denara' && card.value <= 4 ) { material += 42; } if (tracker) { const unseen = tracker.getUnseenCards(myHand, afterTable); let directThreats = 0; for (const unseenCard of unseen) { const caps = findCaptures(unseenCard, afterTable); for (const cap of caps) { if (cap.length === afterTable.length) { directThreats++; break; } } } material -= directThreats * 8; for (const suit of SUITS) { 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); material += Math.round(material * confidence * 0.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; return scoreTacticalPriorityLadder({ scopa: 0, settebello: 0, antiScopa: evaluateAntiScopaPriority(afterTable, nextIsOpp, threats) + openingReleasePriority, partnerSetup: evaluatePartnerSetupPriority(afterTable, nextIsOpp, partnerHandSize, threats), sevenDenial: evaluateSevenDenialPriority(afterTable, [], card, nextIsOpp, race.need7s), denariDenial: evaluateDenariDenialPriority(afterTable, [], card, nextIsOpp, race.behindInDenari), material, }) + (lastPlay ? 0 : 0); } // =========================================================================== // MASTER — deep minimax, alpha-beta, determinization, endgame solver // improved evaluation, team-aware search, last-play awareness // =========================================================================== function tableControlPressure( afterTable: Card[], state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, myHand: Card[], race: RaceState, roleContext: DealerRoleContext, rankResidue: RankResidueSnapshot | null, ): number { if (afterTable.length === 0) return 0; let score = 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); if (tableSum >= 11) score += 70; if (tableSum <= 10 && nextIsOpp) score -= 110; 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); if (myAnchors > 0) score += myAnchors * 18; const partnerProb = handLikelyHasValue( tableCard.value, partnerHandSize, state, playerIdx, tracker, myHand, afterTable, ); score += partnerProb * (nextIsOpp ? 20 : 55); if (nextHandSize > 0 && nextIsOpp) { const nextProb = handLikelyHasValue( tableCard.value, nextHandSize, state, playerIdx, tracker, myHand, afterTable, ); score -= nextProb * 80; } } if (race.aheadOverall && nextIsOpp && tableSum <= 10) score -= 60; score += scoreExposedTableCards(afterTable, state, playerIdx, tracker, myHand, race); score += scoreRoleTablePlan(afterTable, 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 = getMoveCollectedCards(move); 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 scoreQuietControlWindow( move: AIMove, summary: MoveTacticalSummary, nextIsOpp: boolean, ): number { if (move.capture.length > 0 || !nextIsOpp || summary.projectedTable.length < 4) { return 0; } let score = 0; const complement = 10 - move.card.value; const preservesTenLine = move.card.value >= 1 && move.card.value <= 3 && summary.projectedTable.some(card => card.value === complement); if (preservesTenLine) { score += 44; if (summary.tableSum >= 24) score += 32; } if (summary.projectedTable.length >= 5) score += 18; if (summary.tableSum >= 24) score += 18; if (move.card.suit !== 'denara' && move.card.value <= 2 && summary.tableSum >= 20) score += 20; if (summary.exposedDenariCount <= 1) score += 8; if (summary.exposedSevenCount <= 1) score += 8; return score; } function scoreOpeningDuplicateReleaseBias( card: Card, hand: Card[], state: GameState, playerIdx: PlayerIndex, nextIsOpp: boolean, roleContext: DealerRoleContext, ): number { if ( state.table.length > 0 || !nextIsOpp || roleContext.role !== 'first-hand' ) { return 0; } const sameValueCount = countValueInHand(hand, card.value); if (sameValueCount >= 2 && card.value >= 8) { let score = card.suit === 'denara' ? -180 : 280; if ( card.suit !== 'denara' && hand.some(held => held.id !== card.id && held.value === card.value && held.suit === 'denara') ) { score += 220; } if (sameValueCount >= 3) score += 56; return score; } if (sameValueCount === 1 && card.value <= 3) return -180; if (card.suit === 'denara' && card.value <= 8) return -60; return 0; } function scoreDirectSevenPrimieraSwing( played: Card, captured: Card[], afterTable: Card[], hand: Card[], table: Card[], liveSevenPressure: boolean, ): number { if (!table.some(card => card.value === 7) || !captured.some(card => card.value === 7)) { return 0; } const directSevenCapture = played.value === 7 && captured.length === 1 && captured[0].value === 7; const alternateDirectSevenAvailable = played.value !== 7 && hand.some(card => card.id !== played.id && card.value === 7); let score = 0; if (directSevenCapture) { score += liveSevenPressure ? 320 : 220; if (afterTable.length <= 3) score += 72; if (!afterTable.some(card => card.value === 7)) score += liveSevenPressure ? 120 : 60; } if (alternateDirectSevenAvailable) { score -= liveSevenPressure ? 260 : 160; } return score; } 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 scoreProtectedPairInventory( hand: Card[], roleContext: DealerRoleContext, ): number { if (hand.length < 2) 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) continue; score += value >= 8 ? 18 : 10; if (value === 7) score += 6; if (counts[value] >= 3) score += 6; } return Math.round(score * roleContext.pairPreservingBias); } 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, ); } function scoreCurrentPlayerVisibleTempo( state: GameState, perspectiveTeam: 0 | 1, ): number { const currentPlayer = state.currentPlayer; if (state.players[currentPlayer].hand.length === 0) return 0; const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); const urgency = cardsRemaining <= 8 ? 0.92 : cardsRemaining <= 16 ? 0.82 : 0.72; const sign = teamOf(currentPlayer) === perspectiveTeam ? 1 : -1; return Math.round(scorePlayerVisibleTempo(state, currentPlayer) * urgency * sign); } function scoreMoveObjectiveBias( move: AIMove, state: GameState, playerIdx: PlayerIndex, rootPlayer: PlayerIndex, tracker: CardTracker | undefined, ): number { const hand = state.players[playerIdx].hand; const phase = gamePhase(state); const race = getRaceState(state, playerIdx); const roleContext = getDealerRoleContext(state, playerIdx); const rankResidue = getRankResidueSnapshot(tracker, hand, state.table); const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; const lastPlay = isLastPlay(state, playerIdx); const summary = summarizeMoveTactics(move, hand, state.table); const projectedHand = hand.filter(card => card.id !== move.card.id); const capturedCards = getMoveCollectedCards(move); const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats); const antiScopaPriority = evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats); const partnerSetupPriority = evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats); const immediateTacticalConcession = isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats); const openingReleasePriority = move.capture.length === 0 ? evaluateFirstHandOpeningReleasePriority( move.card, hand, projectedHand, summary.projectedTable, state, playerIdx, tracker, nextIsOpp, roleContext, ) : 0; const beforeHandStructure = scoreHandStructure(hand, state.table, roleContext); const afterHandStructure = scoreHandStructure(projectedHand, summary.projectedTable, roleContext); const beforePairInventory = scoreProtectedPairInventory(hand, roleContext); const afterPairInventory = scoreProtectedPairInventory(projectedHand, roleContext); const handStructureDelta = afterHandStructure - beforeHandStructure; const pairInventoryDelta = afterPairInventory - beforePairInventory; const rankResiduePlanScore = move.capture.length > 0 ? scoreCaptureRankResiduePlan(move.card, move.capture, summary.projectedTable, rankResidue, roleContext, nextIsOpp) : scoreDumpRankResiduePlan(move.card, summary.projectedTable, rankResidue, roleContext, nextIsOpp); const quietControlWindow = scoreQuietControlWindow(move, summary, nextIsOpp); const liveDenariPressure = race.behindInDenari || race.denariRaceLive; const liveSevenPressure = race.need7s || race.sevenRaceLive; const openingDuplicateReleaseBias = move.capture.length === 0 ? scoreOpeningDuplicateReleaseBias(move.card, hand, state, playerIdx, nextIsOpp, roleContext) : 0; const directSevenPrimieraSwing = move.capture.length > 0 ? scoreDirectSevenPrimieraSwing(move.card, move.capture, summary.projectedTable, hand, state.table, liveSevenPressure) : 0; const liveCardsMajorityRace = race.myCards < 21 && race.oppCards < 21 && Math.abs(race.myCards - race.oppCards) <= 5; const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards; const cardsMajorityDelta = move.capture.length > 0 ? scoreCardsMajorityPosition(race.myCards + capturedCards.length, race.oppCards, phase) - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase) : 0; const directRankCapture = move.capture.length === 1 && move.capture[0].value === move.card.value; const directSettebelloCapture = directRankCapture && move.capture[0].suit === 'denara' && move.capture[0].value === 7; const exactPartnerWindow = move.capture.length === 0 && partnerHandSize > 0 && summary.projectedTable.length >= 4 && summary.tableSum >= 10 && summary.tableSum <= 12 && summary.exposedDenariCount <= 1 && summary.exposedSevenCount <= 1; const safePartnerWindow = move.capture.length === 0 && nextIsOpp && threats?.partnerCanScopa && !threats.nextOppCanScopa; let bias = 0; bias += scopaPriority * 380; if (summary.clearsTable && !lastPlay) bias += 220; if (summary.capturesSettebello) bias += 460; if (directRankCapture) bias += move.card.value === 7 ? 90 : 34; if (directRankCapture && move.capture[0].value === 7) bias += liveSevenPressure ? 140 : 70; if (directRankCapture && move.capture[0].suit === 'denara') { bias += liveDenariPressure ? (protectingCardsLead ? 88 : 150) : protectingCardsLead ? 36 : 72; } if ( protectingCardsLead && !race.behindInDenari && move.capture.length === 1 && move.capture[0].suit === 'denara' && !summary.capturesSettebello ) { bias -= 180; } if (directSettebelloCapture) bias += 180; if (directSettebelloCapture && nextIsOpp) bias += 220; if ( !summary.capturesSettebello && state.table.some(card => card.suit === 'denara' && card.value === 7) && nextIsOpp ) { bias -= 460; } bias += antiScopaPriority * 48; bias += partnerSetupPriority * 8; bias += evaluateSevenDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.need7s) * (race.need7s ? 42 : race.sevenRaceLive ? 40 : 36); bias += evaluateDenariDenialPriority(summary.projectedTable, capturedCards, move.capture.length === 0 ? move.card : null, nextIsOpp, race.behindInDenari) * (race.behindInDenari ? 38 : race.denariRaceLive ? 36 : 32); bias += openingReleasePriority * 52; bias += openingDuplicateReleaseBias; bias += quietControlWindow; bias += directSevenPrimieraSwing; bias += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 3.6 : 1.8)); if (protectingCardsLead && move.capture.length > 1) { bias += 220 + move.capture.length * 36; } bias += Math.round(handStructureDelta * 1.35); bias += Math.round(pairInventoryDelta * (roleContext.defendingDealerAdvantage ? 6.5 : 4.5)); bias += Math.round(scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp) * 0.85); bias += Math.round(rankResiduePlanScore * 0.9); bias += Math.round(scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker) * 0.55); bias += Math.round(capturedCards.reduce((sum, card) => sum + primieraVal(card), 0) * 3.2); if (nextIsOpp) { bias -= Math.round(summary.projectedTable.reduce((sum, card) => sum + primieraVal(card), 0) * 1.25); } if (nextIsOpp && !summary.clearsTable && immediateTacticalConcession) { bias -= 720; } if (move.capture.length === 0) { if (summary.highQuietRelease) bias += 72; bias += summary.sameValueAnchorsRemaining * 44; if ( nextIsOpp && summary.projectedTable.length >= 5 && summary.tableSum >= 24 && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0) ) { bias -= 96 + summary.exposedDenariCount * 54 + summary.exposedSevenCount * 68; } if (exactPartnerWindow) bias += 96; if (safePartnerWindow) bias += exactPartnerWindow ? 120 : 76; if ( roleContext.defendingDealerAdvantage && move.card.suit !== 'denara' && move.card.value <= 4 && summary.sameValueAnchorsRemaining > 0 ) { bias += 90; } if ( roleContext.defendingDealerAdvantage && beforePairInventory > 0 && afterPairInventory === beforePairInventory && move.card.suit !== 'denara' && move.card.value <= 4 ) { bias += 152; } } else if ( nextIsOpp && !summary.clearsTable && !summary.capturesSettebello && summary.capturedSevenCount === 0 && summary.projectedTable.length <= 2 && summary.tableSum <= 12 ) { bias -= 120; } if ( move.capture.length > 0 && roleContext.defendingDealerAdvantage && countValueInHand(hand, move.card.value) >= 2 && !summary.clearsTable ) { bias -= Math.round((move.card.value >= 8 ? 180 : 80) * roleContext.pairPreservingBias); } return teamOf(playerIdx) === teamOf(rootPlayer) ? bias : -bias; } 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, race: RaceState, roleContext: DealerRoleContext, timing: SearchTimingContext, ): RankedRootMove[] { const hand = state.players[playerIdx].hand; const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); const rankedMoves = legalMoves.map(move => { timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); const quick = quickEval( move, state, playerIdx, playerIdx, tracker, false, ); const summary = summarizeMoveTactics(move, hand, state.table); return { move, key: moveKey(move), quick, forcing: isForcingSearchMove(summary, race), priorityControlQuiet: isPriorityControlQuietMove(move, summary, nextIsOpp, roleContext), }; }); return rankedMoves .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, timing: SearchTimingContext, ): RankedRootMove[] { if (rankedMoves.length <= 1) return rankedMoves; resetMasterRootWorkspace(workspace); const hashMoveKey = ttEntry?.bestMoveKey ?? undefined; for (const rankedMove of rankedMoves) { timing.checkpoint(SIMULATED_ROOT_MOVE_COST_MS); 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, tracker: CardTracker | undefined, ): number { const hand = state.players[playerIdx].hand; const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); const lastPlay = isLastPlay(state, playerIdx); const summary = summarizeMoveTactics(move, hand, state.table); const projectedHand = hand.filter(card => card.id !== move.card.id); const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); const partnerHandSize = state.players[partnerOf(playerIdx)].hand.length; const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, nextIsOpp, threats); const antiScopaPriority = evaluateAntiScopaPriority(summary.projectedTable, nextIsOpp, threats); const partnerSetupPriority = evaluatePartnerSetupPriority(summary.projectedTable, nextIsOpp, partnerHandSize, threats); const immediateTacticalConcession = isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats); const openingReleasePriority = move.capture.length === 0 ? evaluateFirstHandOpeningReleasePriority( move.card, hand, projectedHand, summary.projectedTable, state, playerIdx, tracker, nextIsOpp, roleContext, ) : 0; 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; score += scopaPriority * 600; score += antiScopaPriority * 54; score += partnerSetupPriority * 8; if (nextIsOpp && !summary.clearsTable && immediateTacticalConcession) { score -= 720; } if (move.capture.length === 0) { if (summary.highQuietRelease) score += 220; score += openingReleasePriority * 180; 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 ) { if (summary.exposedDenariCount === 0 && summary.exposedSevenCount === 0) { score += 260; } else { score -= 80 + summary.exposedDenariCount * 90 + summary.exposedSevenCount * 120; } } } else { if (!isForcingSearchMove(summary, race)) { score -= summary.projectedTable.length <= 2 || summary.tableSum <= 12 ? 200 : 80; } if (!summary.clearsTable && isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats)) { score -= 180; } if (nextIsOpp && summary.projectedTable.length <= 2) score -= 150; else if (nextIsOpp && summary.projectedTable.length === 3 && summary.tableSum <= 12) score -= 90; 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, tracker: CardTracker | undefined, ): AIMove | undefined { if (legalMoves.length <= 1) return undefined; const lastPlay = isLastPlay(state, playerIdx); if (lastPlay) return undefined; if (!isOpponent(playerIdx, nextPlayer(playerIdx))) return undefined; let bestQuiet: | { move: AIMove; score: number } | undefined; let bestCapture: | { move: AIMove; score: number } | undefined; let bestSafeScopa: | { move: AIMove; score: number } | undefined; for (const move of legalMoves) { const score = scoreControlOverrideCandidate(move, state, playerIdx, race, roleContext, tracker); const summary = summarizeMoveTactics(move, state.players[playerIdx].hand, state.table); const projectedHand = state.players[playerIdx].hand.filter(card => card.id !== move.card.id); const threats = getPriorityThreatSummary(summary.projectedTable, projectedHand, tracker, state, playerIdx); const scopaPriority = evaluateSafeScopaPriority(summary.clearsTable, summary.projectedTable, lastPlay, true, threats); if (scopaPriority > 0) { if (!bestSafeScopa || score > bestSafeScopa.score) bestSafeScopa = { move, score }; } if (move.capture.length === 0) { if (!bestQuiet || score > bestQuiet.score) bestQuiet = { move, score }; continue; } if (!bestCapture || score > bestCapture.score) bestCapture = { move, score }; } if (bestSafeScopa) return bestSafeScopa.move; 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, tracker: CardTracker | undefined, 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, 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; const progressState: MasterSearchProgressState = { evaluationsCompleted: 0, totalEvaluations: Math.max(1, rootMoveCount * profile.maxDepth), batchesCompleted: 0, completedDepth: 0, aspirationExpansions: 0, timedOut: false, }; const race = getRaceState(state, playerIdx); const roleContext = getDealerRoleContext(state, playerIdx); const rankedMoves = rankRootMoves( legalMoves, state, playerIdx, tracker, race, roleContext, timing, ); let bestPreSearchMove = rankedMoves[0].move; if (timing.now() > deadline) { progressState.timedOut = true; reportDecisionProgress( onProgress, 'master', startedAt, timing, profile.timeBudgetMs, getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), progressState.batchesCompleted, buildMasterProgressDetails(progressState, cardsRemaining, 0, profile.maxDepth, rankedMoves.length), ); return bestPreSearchMove; } const samples = generateSamples(state, playerIdx, tracker, profile.sampleCount, rng, timing); const sampleCount = samples.length; progressState.totalEvaluations = Math.max(1, samples.length * rankedMoves.length * profile.maxDepth); if (timing.now() > deadline) { progressState.timedOut = true; reportDecisionProgress( onProgress, 'master', startedAt, timing, profile.timeBudgetMs, getMasterProgress(progressState, startedAt, profile.timeBudgetMs, timing), progressState.batchesCompleted, buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), ); return bestPreSearchMove; } const transpositionTable = new Map(); const heuristics: SearchHeuristics = { killerMoves: new Map(), historyScores: new Map(), }; const rootWorkspace = createMasterRootWorkspace(rankedMoves.length); const rootStateKey = buildSearchStateKey(state); reportDecisionProgress( onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 0, 0, buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), ); let previousBestKey: string | undefined; let lastCompletedDepth: MasterDepthResult | undefined; let lastCompletedDepthElapsedMs: number | undefined; for (let depth = 1; depth <= profile.maxDepth; depth++) { const depthStartedAt = timing.now(); if (depthStartedAt > deadline) { progressState.timedOut = true; break; } if (cardsRemaining > 20) { const remainingBudgetMs = deadline - depthStartedAt; if (remainingBudgetMs < EARLY_TURN_MIN_REMAINING_BUDGET_MS) { break; } if ( lastCompletedDepthElapsedMs !== undefined && lastCompletedDepthElapsedMs >= profile.timeBudgetMs * EARLY_TURN_DEPTH_ADMISSION_BUDGET_FRACTION ) { break; } } 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; 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, timing); bestPreSearchMove = orderedMoves[0]?.move ?? bestPreSearchMove; if (timing.now() > deadline) { progressState.timedOut = true; break; } 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(); } } const bestMove = lastCompletedDepth?.bestMove ?? bestPreSearchMove; reportDecisionProgress( onProgress, 'master', startedAt, timing, profile.timeBudgetMs, 1, progressState.batchesCompleted, buildMasterProgressDetails(progressState, cardsRemaining, sampleCount, profile.maxDepth, rankedMoves.length), ); return bestMove; } function quickEval( move: AIMove, state: GameState, playerIdx: PlayerIndex, rootPlayer: PlayerIndex, tracker: CardTracker | undefined, allowHiddenHands: boolean, ): number { const result = applyMove(state, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined); const nextState = result.nextState; return evaluateTeamPosition( nextState, teamOf(rootPlayer), gamePhase(nextState), tracker, rootPlayer, allowHiddenHands, ) + scoreMoveObjectiveBias(move, state, playerIdx, rootPlayer, tracker); } function moveKey(move: AIMove): string { const capIds = move.capture.map(c => c.id).sort().join(','); return `${move.card.id}|${capIds}`; } function getMoveCollectedCards(move: AIMove): Card[] { if (move.capture.length === 0) return []; return [move.card, ...move.capture]; } 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 roleContext = getDealerRoleContext(state, playerIdx); const hand = state.players[playerIdx].hand; const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); const maximizingNode = teamOf(playerIdx) === teamOf(rootPlayer); const rankedMoves = moves .map(move => ({ move, key: moveKey(move), quick: quickEval(move, state, playerIdx, rootPlayer, tracker, true), })) .sort((a, b) => maximizingNode ? b.quick - a.quick : a.quick - b.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]; 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; } 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[], timing: SearchTimingContext, ): 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++) { timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); 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, timing: SearchTimingContext, ): 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++) { timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); 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) { timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); bucket.cards.push(remainingCards[remainingIndex]); remainingIndex++; } } return buckets; } function generateSamples( state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, count: number, rng: RandomSource, timing: SearchTimingContext, ): GameState[] { const myHand = state.players[playerIdx].hand; const unseen = tracker ? tracker.getUnseenCards(myHand, state.table) : getUnseenWithoutTracker(state, playerIdx); const assignments = createSampleHandAssignments(state, playerIdx); 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, timing); } 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++) { timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); const sample = cloneState(state); const sampleBuckets = buildStratifiedSampleBuckets( state, playerIdx, prioritizedUnseen, assignments, rankResidue, attempt, rng, timing, ); const sampleKey = buildSampleAssignmentKey(sampleBuckets); if (seenAssignments.has(sampleKey)) continue; seenAssignments.add(sampleKey); assignBucketsToSample(sample, sampleBuckets); samples.push(sample); } if (samples.length === 0) { timing.checkpoint(SIMULATED_SEARCH_NODE_COST_MS); const fallbackSample = cloneState(state); assignBucketsToSample( fallbackSample, buildStratifiedSampleBuckets(state, playerIdx, prioritizedUnseen, assignments, rankResidue, 0, rng, timing), ); return [fallbackSample]; } return samples; } function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] { return getUnseenCardsForEstimate( state, playerIdx, state.players[playerIdx].hand, state.table, undefined, ); } function getUnseenCardsForEstimate( state: GameState, playerIdx: PlayerIndex, myHand: Card[], table: Card[], tracker: CardTracker | undefined, ): Card[] { if (tracker) { return tracker.getUnseenCards(myHand, table); } const known = new Set(); for (const card of myHand) known.add(card.id); for (const card of table) known.add(card.id); for (const player of state.players) { for (const card of player.pile) { known.add(card.id); } } const deck = buildDeck(); return deck.filter(card => !known.has(card.id)); } function shuffleArray(arr: T[], rng: RandomSource = Math.random): T[] { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } 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 { 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) { 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; 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 = 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) { 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; 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 = 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) { 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; } } interface TeamEvaluationSnapshot { cards: number; denari: number; settebello: boolean; primiera: number; primieraSuits: number; sevenSuits: number; sevens: number; sixes: number; aces: number; scope: number; totalPoints: number; } function buildTeamEvaluationSnapshot(state: GameState, team: 0 | 1): TeamEvaluationSnapshot { const players = team === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; const bestPrimieraBySuit: Partial> = {}; const sevenSuits = new Set(); let cards = 0; let denari = 0; let settebello = false; let sevens = 0; let sixes = 0; let aces = 0; let scope = 0; for (const player of players) { scope += player.scope; for (const card of player.pile) { cards++; if (card.suit === 'denara') { denari++; if (card.value === 7) settebello = true; } if (card.value === 7) { sevens++; sevenSuits.add(card.suit); } if (card.value === 6) sixes++; if (card.value === 1) aces++; const primieraScore = PRIMIERA_VALUES[card.value] ?? 0; if ((bestPrimieraBySuit[card.suit] ?? 0) < primieraScore) { bestPrimieraBySuit[card.suit] = primieraScore; } } } let primiera = 0; let primieraSuits = 0; for (const suit of SUITS) { const suitScore = bestPrimieraBySuit[suit] ?? 0; if (suitScore > 0) { primiera += suitScore; primieraSuits++; } } return { cards, denari, settebello, primiera, primieraSuits, sevenSuits: sevenSuits.size, sevens, sixes, aces, scope, totalPoints: state.teamScores[team].totalPoints, }; } function scoreMajorityRace( myValue: number, oppValue: number, target: number, unitWeight: number, thresholdBonus: number, ): number { let score = (myValue - oppValue) * unitWeight; if (myValue >= target && oppValue < target) { score += thresholdBonus; } else if (oppValue >= target && myValue < target) { score -= thresholdBonus; } else { const myDistance = Math.max(0, target - myValue); const oppDistance = Math.max(0, target - oppValue); score += (oppDistance - myDistance) * Math.round(unitWeight * 0.75); } return score; } function scoreCardsMajorityPosition( myCards: number, oppCards: number, phase: number, ): number { return scoreMajorityRace(myCards, oppCards, 21, Math.round(24 + phase * 22), 240); } function getRoundScoringCardWeight(card: Card): number { let weight = 18 + primieraVal(card) * 3; if (card.suit === 'denara') weight += 42; if (card.value === 7) weight += 44; if (card.suit === 'denara' && card.value === 7) weight += 120; return weight; } function scorePendingTableOwnership(state: GameState, perspectiveTeam: 0 | 1): number { if (state.table.length === 0 || state.lastCapturTeam === null) return 0; const cardsRemaining = state.players.reduce((sum, player) => sum + player.hand.length, 0); const urgency = cardsRemaining <= 4 ? 1.35 : cardsRemaining <= 8 ? 1.15 : 0.75; const tableValue = state.table.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); return Math.round((state.lastCapturTeam === perspectiveTeam ? 1 : -1) * tableValue * urgency); } function scoreObjectiveTableExposure(state: GameState, perspectiveTeam: 0 | 1): number { if (state.table.length === 0) return 0; const nextTeamSign = teamOf(state.currentPlayer) === perspectiveTeam ? 1 : -1; const tableSum = sumCardValues(state.table); const exposedDenari = state.table.filter(card => card.suit === 'denara').length; const exposedSevens = state.table.filter(card => card.value === 7).length; const exposedSettebello = state.table.some(card => card.suit === 'denara' && card.value === 7); const shortTable = state.table.length <= 2 || tableSum <= 12; let pressure = exposedDenari * 34 + exposedSevens * 42; if (exposedSettebello) pressure += 120; if (shortTable) pressure += 36; return Math.round(nextTeamSign * pressure * (shortTable ? 1.2 : 0.8)); } function scoreTableControlReserve(state: GameState, perspectiveTeam: 0 | 1): number { if (state.table.length < 4) return 0; const tableSum = sumCardValues(state.table); if (tableSum < 20) return 0; const nextTeam = teamOf(state.currentPlayer); const exposedDenari = state.table.filter(card => card.suit === 'denara').length; const exposedSevens = state.table.filter(card => card.value === 7).length; let reserve = state.table.length * 22 + tableSum * 3; reserve -= exposedDenari * 10; reserve -= exposedSevens * 12; if (state.table.length >= 5) reserve += 24; if (tableSum >= 24) reserve += 28; return Math.round((nextTeam === perspectiveTeam ? -0.35 : 0.55) * reserve); } function scoreKnownImmediateCapturePressure(state: GameState, playerIdx: PlayerIndex): number { if (state.table.length === 0 || state.players[playerIdx].hand.length === 0) return 0; let bestScore = 0; for (const move of getLegalMoves(state, playerIdx)) { if (move.capture.length === 0) continue; const captured = [move.card, ...move.capture]; let moveScore = captured.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); if (move.capture.length === state.table.length) { const isTerminalClear = state.players.every((player, index) => ( index === playerIdx ? player.hand.length === 1 : player.hand.length === 0 )); moveScore += isTerminalClear ? 90 : 240; } bestScore = Math.max(bestScore, moveScore); } return bestScore; } function getUpcomingTableExposureActors(state: GameState): Array<{ playerIdx: PlayerIndex; weight: number }> { const actors: Array<{ playerIdx: PlayerIndex; weight: number }> = []; let playerIdx = state.currentPlayer; for (const weight of UPCOMING_TABLE_EXPOSURE_WEIGHTS) { actors.push({ playerIdx, weight }); playerIdx = nextPlayer(playerIdx); } return actors; } function scoreCaptureOpportunity( capturedCards: Card[], playedValue: number, tableSize: number, ): number { let score = capturedCards.reduce((sum, card) => sum + getRoundScoringCardWeight(card), 0); score += 18 + (PRIMIERA_VALUES[playedValue] ?? 0) * 2; if (playedValue === 7) score += 48; if (capturedCards.some(card => card.suit === 'denara')) score += 24; if (capturedCards.some(card => card.value === 7)) score += 32; if (capturedCards.some(card => card.suit === 'denara' && card.value === 7)) score += 160; if (capturedCards.length === tableSize) { score += tableSize <= 3 ? 220 : 280; } return score; } function scoreProbableImmediateCapturePressure( state: GameState, playerIdx: PlayerIndex, rootPlayer: PlayerIndex, tracker: CardTracker | undefined, ): number { if (state.table.length === 0) return 0; const handSize = state.players[playerIdx].hand.length; if (handSize <= 0) return 0; const observerHand = state.players[rootPlayer].hand; let bestScore = 0; for (let value = 1; value <= 10; value++) { const representativeCard = REPRESENTATIVE_CARD_BY_VALUE.get(value); if (!representativeCard) continue; const captures = findCaptures(representativeCard, state.table); if (captures.length === 0) continue; const probability = handLikelyHasValue( value, handSize, state, rootPlayer, tracker, observerHand, state.table, ); if (probability <= 0) continue; let bestCaptureScore = 0; for (const capture of captures) { bestCaptureScore = Math.max( bestCaptureScore, scoreCaptureOpportunity(capture, value, state.table.length), ); } bestScore = Math.max(bestScore, Math.round(probability * bestCaptureScore)); } return bestScore; } function scoreKnownTableExposure(state: GameState, perspectiveTeam: 0 | 1): number { if (state.table.length === 0) return 0; let score = 0; for (const actor of getUpcomingTableExposureActors(state)) { const capturePressure = scoreKnownImmediateCapturePressure(state, actor.playerIdx); if (capturePressure === 0) continue; score += Math.round( capturePressure * actor.weight * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1), ); } return score; } function scoreProbableTableExposure( state: GameState, perspectiveTeam: 0 | 1, rootPlayer: PlayerIndex, tracker: CardTracker | undefined, ): number { if (state.table.length === 0) return 0; let score = 0; for (const actor of getUpcomingTableExposureActors(state)) { const actorScore = scoreProbableImmediateCapturePressure(state, actor.playerIdx, rootPlayer, tracker); if (actorScore === 0) continue; score += Math.round( actorScore * actor.weight * (teamOf(actor.playerIdx) === perspectiveTeam ? 1 : -1), ); } return score; } function scoreRootOpeningAnchorState( state: GameState, perspectiveTeam: 0 | 1, rootPlayer: PlayerIndex, ): number { if ( teamOf(rootPlayer) !== perspectiveTeam || state.table.length !== 1 || teamOf(state.currentPlayer) === perspectiveTeam ) { return 0; } const exposedCard = state.table[0]; const rootHand = state.players[rootPlayer].hand; const sameValueAnchors = countValueInHand(rootHand, exposedCard.value); let score = 0; if (sameValueAnchors > 0) { score += exposedCard.value >= 8 ? 240 : 88; if (exposedCard.suit !== 'denara') score += 36; } if (sameValueAnchors === 0 && exposedCard.value <= 3) score -= 220; if (exposedCard.suit === 'denara') score -= 120; if (exposedCard.value === 7) score -= 140; return score; } function evaluateTeamPosition( state: GameState, perspectiveTeam: 0 | 1, _phase: number, tracker: CardTracker | undefined, rootPlayer: PlayerIndex, allowHiddenHands: boolean, ): number { const opponentTeam = perspectiveTeam === 0 ? 1 : 0; const mine = buildTeamEvaluationSnapshot(state, perspectiveTeam); const opp = buildTeamEvaluationSnapshot(state, opponentTeam); const phase = gamePhase(state); const matchWeight = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 360 : 260; const matchPointCardsPressure = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 3.2 : 1; let score = 0; score += (mine.totalPoints - opp.totalPoints) * Math.round(matchWeight + phase * 40); if (mine.totalPoints >= 10 && opp.totalPoints < 10) score += 260; if (opp.totalPoints >= 10 && mine.totalPoints < 10) score -= 260; score += Math.round(scoreCardsMajorityPosition(mine.cards, opp.cards, phase) * matchPointCardsPressure); score += scoreMajorityRace(mine.denari, opp.denari, 6, Math.round(70 + phase * 22), 220); if (mine.settebello) score += 420; if (opp.settebello) score -= 420; score += (mine.scope - opp.scope) * 390; score += (mine.primiera - opp.primiera) * Math.round(4.5 + phase * 3); score += (mine.primieraSuits - opp.primieraSuits) * 124; if (mine.primieraSuits === 4 && opp.primieraSuits < 4) score += 180; if (opp.primieraSuits === 4 && mine.primieraSuits < 4) score -= 180; score += (mine.sevenSuits - opp.sevenSuits) * 92; score += (mine.sevens - opp.sevens) * 68; score += (mine.sixes - opp.sixes) * 16; score += (mine.aces - opp.aces) * 12; score += scorePendingTableOwnership(state, perspectiveTeam); score += scoreRootOpeningAnchorState(state, perspectiveTeam, rootPlayer); score += scoreObjectiveTableExposure(state, perspectiveTeam); score += scoreTableControlReserve(state, perspectiveTeam); if ((mine.totalPoints >= 9 || opp.totalPoints >= 9) && state.table.length <= 2) { score += Math.max(0, mine.cards - opp.cards) * 32; score -= Math.max(0, opp.cards - mine.cards) * 32; } if (allowHiddenHands) { score += scoreCurrentPlayerVisibleTempo(state, perspectiveTeam); } score += allowHiddenHands ? scoreKnownTableExposure(state, perspectiveTeam) : scoreProbableTableExposure(state, perspectiveTeam, rootPlayer, tracker); return score; } /** Fast evaluation: avoids flatMap/filter at every leaf node */ function evaluateFast( state: GameState, myTeam: 0 | 1, _phase: number, tracker: CardTracker | undefined, rootPlayer: PlayerIndex, ): number { return evaluateTeamPosition(state, myTeam, 0, tracker, rootPlayer, true); } /** * PIMC evaluation: full-information evaluation for use in determinized PIMC search. * Enables scoreCurrentPlayerVisibleTempo and scoreKnownTableExposure which require * all hands to be visible — satisfied in a determinized PIMC state. * Exported so the new PIMC engine can use the same evaluation quality. */ export function evaluateTeamPositionPIMC(state: GameState, perspectiveTeam: 0 | 1, rootPlayer?: PlayerIndex): number { return evaluateTeamPosition(state, perspectiveTeam, 0, undefined, rootPlayer ?? state.currentPlayer, true); } /** * Full-quality move score for use in PIMC move ordering (root and interior nodes). * Equivalent to the legacy's internal quickEval: position eval + scoreMoveObjectiveBias. * Use this for 1-ply ordering in determinized states where all hands are visible. * * @param playerIdx The player whose turn it is at this node. * @param rootPlayer The player at the root of the search (perspective stays fixed). */ export function quickEvalRootMovePIMC( move: AIMove, state: GameState, playerIdx: PlayerIndex, tracker: import('./card-tracker').CardTracker | undefined, rootPlayer?: PlayerIndex, ): number { return quickEval(move, state, playerIdx, rootPlayer ?? playerIdx, tracker, true); } /** * Generate hand samples using the legacy's stratified bucketing algorithm. * Much more accurate than random inference sampling: assigns high-value cards * (7s, denari) to the most likely holders using rank-residue analysis. * * @param count Number of samples to generate (legacy target). */ export function generateSamplesForPIMC( state: GameState, playerIdx: PlayerIndex, tracker: import('./card-tracker').CardTracker | undefined, count: number, rng: RandomSource, timingSource?: AITimingSource, ): GameState[] { const timing = createSearchTimingContext(timingSource); return generateSamples(state, playerIdx, tracker, count, rng, timing); }