Files
scopone/src/game/ai.ts
Giancarmine Salucci b2a84eb167
Some checks failed
Android Build & Publish / android (push) Failing after 2m0s
feat: CI creates Gitea releases with changelog, app polls for updates on startup
- 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)
2026-05-25 09:39:08 +02:00

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);
}
}