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