Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
- 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
4223 lines
133 KiB
TypeScript
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);
|
|
}
|