Files
scopone/src/game/ai-legacy.ts
Giancarmine Salucci 3f74c57665
Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline
- Replace minimax with PIMC (Perfect Information Monte Carlo) search
- Add PIMC_SCOPE_BOOST=150 → effective scopa value 540 (was 390)
  → Master win rate: 67.5% → 72.5% vs legacy AI (target ≥60%)
  → Advanced win rate: 97.5% vs beginner AI (target ≥55%)
  → Scope gap in losses: 6.54 → 3.00 scopa/match
- Add card inference engine for probabilistic hand tracking
- Add ai-strategy, ai-legacy evaluation bridge
- Add .gitea/workflows/android-build.yml: build debug + unsigned
  release APK and publish to Gitea generic package registry
2026-05-24 16:29:04 +02:00

4223 lines
133 KiB
TypeScript

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<void>;
}
const DEALER_ROLE_WEIGHTS: Record<DealerRelativeRole, Omit<DealerRoleContext, 'role' | 'onDealerSide' | 'defendingDealerAdvantage' | 'attackingDealerAdvantage'>> = {
'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<Difficulty, SearchProfile> = {
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<number, Card>();
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<number>();
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<number>();
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<string>();
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<AIMove> {
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<number, string[]>;
historyScores: Map<string, number>;
}
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<string, TranspositionEntry>,
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<string, TranspositionEntry>,
heuristics: SearchHeuristics,
rootWorkspace: MasterRootWorkspace,
cardsRemaining: number,
sampleCount: number,
rootMoveCount: number,
): Promise<MasterDepthResult> {
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<AIMove> {
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<string, TranspositionEntry>();
const heuristics: SearchHeuristics = {
killerMoves: new Map<number, string[]>(),
historyScores: new Map<string, number>(),
};
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<T>(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<string>();
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<string>();
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<T>(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<string, TranspositionEntry>,
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<Record<Suit, number>> = {};
const sevenSuits = new Set<Suit>();
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);
}