perf(ai): optimize AI decision speed — fast clone, reduced search, time budget

SCOPONE-0005 iteration 8

- Replace JSON.parse/stringify deepClone with hand-written cloneState() (~10-50x faster)
- Export cloneState from engine.ts, use in applyMove and generateSamples
- Master: reduce samples 24→10 (14 endgame), depth 8→6 (8 endgame)
- Add 1.5s time budget with early termination in masterMove and alphaBeta
- evaluateFast(): single-pass pile scan, zero array allocations at leaf nodes
- Simplified move ordering in alphaBeta (captures-first, no per-move eval)
- Skip countScopaThreats when tableSum >= 11 (impossible to clear)
- Remove unused calcPrimiera import from ai.ts
This commit is contained in:
Giancarmine Salucci
2026-03-31 23:34:22 +02:00
parent a045efd798
commit 185f7c36c7
2 changed files with 147 additions and 79 deletions

View File

@@ -1,5 +1,5 @@
import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS } from './types';
import { findCaptures, canCapture, calcPrimiera, teamOf, applyMove, buildDeck } from './engine';
import { findCaptures, canCapture, teamOf, applyMove, buildDeck, cloneState } from './engine';
import { CardTracker } from './card-tracker';
export interface AIMove {
@@ -337,21 +337,23 @@ function scoreCaptureAdv(
// --- ANTI-SCOPA (critical) ---
if (!isScopa) {
const tableSum = afterTable.reduce((s, c) => s + c.value, 0);
const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx);
if (tableSum >= 11) {
score += 100;
} else {
// Only run expensive threat counting when table is actually clearable
const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx);
if (threats.nextOppCanScopa) score -= 550;
if (threats.secondOppCanScopa) score -= 250;
score -= threats.totalThreats * 75;
if (tableSum >= 11) score += 100;
else if (tableSum <= 3) score -= 120;
if (tableSum <= 3) score -= 120;
else if (tableSum <= 7) score -= 50;
// Single card on table = trivial scopa
if (afterTable.length === 1 && nextIsOpp) score -= 200;
// Two low cards = easy to sum & clear
if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 120;
}
}
// --- PARTNER COOPERATION ---
const next = nextPlayer(playerIdx);
@@ -469,15 +471,18 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
const phase = gamePhase(state);
const cardsRemaining = state.players.reduce((s, p) => s + p.hand.length, 0);
// Adaptive: much deeper in endgame, exact solve when very few cards
// Reduced parameters: much faster while still strong
const isDeepEndgame = cardsRemaining <= 6;
const isEndgame = cardsRemaining <= 12;
const NUM_SAMPLES = isDeepEndgame ? 1 : isEndgame ? 28 : 24;
const MAX_DEPTH = isDeepEndgame ? cardsRemaining : isEndgame ? 10 : 8;
const NUM_SAMPLES = isDeepEndgame ? 1 : isEndgame ? 14 : 10;
const MAX_DEPTH = isDeepEndgame ? cardsRemaining : isEndgame ? 8 : 6;
const legalMoves = getLegalMoves(state, playerIdx);
if (legalMoves.length === 1) return legalMoves[0];
// Time budget: 1.5 seconds max
const deadline = Date.now() + 1500;
// Quick-eval move ordering for better pruning
const quickScored = legalMoves.map(m => ({
move: m,
@@ -494,15 +499,21 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
? [state]
: generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
let timedOut = false;
let samplesCompleted = 0;
for (const sample of samples) {
if (timedOut) break;
for (const move of sortedMoves) {
if (Date.now() > deadline) { timedOut = true; break; }
const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
const score = alphaBeta(
result.nextState, MAX_DEPTH - 1, -Infinity, Infinity,
myTeam, playerIdx, phase, tracker,
myTeam, playerIdx, phase, deadline,
);
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
}
samplesCompleted++;
}
let bestMove = sortedMoves[0];
@@ -582,7 +593,7 @@ function generateSamples(
: getUnseenWithoutTracker(state, playerIdx);
for (let s = 0; s < count; s++) {
const sample = JSON.parse(JSON.stringify(state)) as GameState;
const sample = cloneState(state);
const shuffled = shuffleArray([...unseen]);
let idx = 0;
for (let p = 0; p < 4; p++) {
@@ -616,35 +627,26 @@ function shuffleArray<T>(arr: T[]): T[] {
function alphaBeta(
state: GameState, depth: number, alpha: number, beta: number,
myTeam: 0 | 1, rootPlayer: PlayerIndex,
phase: number, tracker?: CardTracker,
phase: number, deadline: number,
): number {
if (depth === 0 || state.roundOver) {
return evaluate(state, myTeam, phase);
if (depth === 0 || state.roundOver || Date.now() > deadline) {
return evaluateFast(state, myTeam, phase);
}
const cur = state.currentPlayer;
const isMyTeam = teamOf(cur) === myTeam;
const moves = getLegalMoves(state, cur);
if (moves.length === 0) return evaluate(state, myTeam, phase);
if (moves.length === 0) return evaluateFast(state, myTeam, phase);
// Move ordering for pruning
// Simple move ordering: captures first, then by capture size (avoids expensive sort)
if (moves.length > 2) {
moves.sort((a, b) => {
let sa = 0, sb = 0;
if (a.capture.length > 0) sa += 100 + a.capture.length * 15;
if (b.capture.length > 0) sb += 100 + b.capture.length * 15;
const aAfter = state.table.filter(c => !a.capture.some(cc => cc.id === c.id));
const bAfter = state.table.filter(c => !b.capture.some(cc => cc.id === c.id));
if (a.capture.length > 0 && aAfter.length === 0) sa += 600;
if (b.capture.length > 0 && bAfter.length === 0) sb += 600;
// Settebello
if ([a.card, ...a.capture].some(c => c.suit === 'denara' && c.value === 7)) sa += 400;
if ([b.card, ...b.capture].some(c => c.suit === 'denara' && c.value === 7)) sb += 400;
// Anti-scopa in ordering
if (aAfter.length > 0 && aAfter.reduce((s, c) => s + c.value, 0) <= 10) sa -= 80;
if (bAfter.length > 0 && bAfter.reduce((s, c) => s + c.value, 0) <= 10) sb -= 80;
return isMyTeam ? sb - sa : sa - sb;
// Captures before dumps
if (a.capture.length > 0 && b.capture.length === 0) return -1;
if (a.capture.length === 0 && b.capture.length > 0) return 1;
// Larger captures first
return b.capture.length - a.capture.length;
});
}
@@ -652,7 +654,7 @@ function alphaBeta(
let value = -Infinity;
for (const move of moves) {
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, tracker);
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline);
value = Math.max(value, child);
alpha = Math.max(alpha, value);
if (beta <= alpha) break;
@@ -662,7 +664,7 @@ function alphaBeta(
let value = Infinity;
for (const move of moves) {
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, tracker);
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, deadline);
value = Math.min(value, child);
beta = Math.min(beta, value);
if (beta <= alpha) break;
@@ -671,62 +673,91 @@ function alphaBeta(
}
}
function evaluate(state: GameState, myTeam: 0 | 1, phase: number): number {
const oppTeam = myTeam === 0 ? 1 : 0;
const myPlayers = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]];
const oppPlayers = oppTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]];
/** Fast evaluation: avoids flatMap/filter at every leaf node */
function evaluateFast(state: GameState, myTeam: 0 | 1, phase: number): number {
const p0 = state.players[0], p1 = state.players[1], p2 = state.players[2], p3 = state.players[3];
const myA = myTeam === 0 ? p0 : p1;
const myB = myTeam === 0 ? p2 : p3;
const oppA = myTeam === 0 ? p1 : p0;
const oppB = myTeam === 0 ? p3 : p2;
const myPile = myPlayers.flatMap(p => p.pile);
const oppPile = oppPlayers.flatMap(p => p.pile);
// Single-pass pile scan — no flatMap/filter allocations
let myCards = 0, oppCards = 0;
let myDenari = 0, oppDenari = 0;
let mySettebello = false, oppSettebello = false;
let my7: Record<string, boolean> = {}, opp7: Record<string, boolean> = {};
const myPrimBySuit: Record<string, number> = {};
const oppPrimBySuit: Record<string, number> = {};
for (const pile of [myA.pile, myB.pile]) {
for (const c of pile) {
myCards++;
if (c.suit === 'denara') {
myDenari++;
if (c.value === 7) mySettebello = true;
}
if (c.value === 7) my7[c.suit] = true;
const pv = PRIMIERA_VALUES[c.value] ?? 0;
if (!myPrimBySuit[c.suit] || pv > myPrimBySuit[c.suit]) myPrimBySuit[c.suit] = pv;
}
}
for (const pile of [oppA.pile, oppB.pile]) {
for (const c of pile) {
oppCards++;
if (c.suit === 'denara') {
oppDenari++;
if (c.value === 7) oppSettebello = true;
}
if (c.value === 7) opp7[c.suit] = true;
const pv = PRIMIERA_VALUES[c.value] ?? 0;
if (!oppPrimBySuit[c.suit] || pv > oppPrimBySuit[c.suit]) oppPrimBySuit[c.suit] = pv;
}
}
let score = 0;
// --- Cards majority (1 point) ---
const cardDiff = myPile.length - oppPile.length;
score += cardDiff * (22 + phase * 15);
// --- Denari majority (1 point) ---
const myDenari = myPile.filter(c => c.suit === 'denara').length;
const oppDenari = oppPile.filter(c => c.suit === 'denara').length;
// Cards majority
score += (myCards - oppCards) * (22 + phase * 15);
// Denari majority
score += (myDenari - oppDenari) * 55;
// Settebello
if (mySettebello) score += 400;
if (oppSettebello) score -= 400;
// --- Settebello (1 point) ---
if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 400;
if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 400;
// --- Primiera (1 point) ---
const myPrim = calcPrimiera(myPile);
const oppPrim = calcPrimiera(oppPile);
if (myPrim > 0 && oppPrim > 0) {
// Primiera
let myPrim = 0, oppPrim = 0;
let mySuits = 0, oppSuits = 0;
for (const suit of SUITS) {
if (myPrimBySuit[suit]) { myPrim += myPrimBySuit[suit]; mySuits++; }
if (oppPrimBySuit[suit]) { oppPrim += oppPrimBySuit[suit]; oppSuits++; }
// Per-suit 7 tracking
if (my7[suit] && !opp7[suit]) score += 40;
if (opp7[suit] && !my7[suit]) score -= 40;
}
if (mySuits === 4 && oppSuits === 4) {
score += (myPrim - oppPrim) * 4;
} else if (myPrim > 0) {
} else if (mySuits === 4) {
score += 150;
} else if (oppPrim > 0) {
} else if (oppSuits === 4) {
score -= 150;
}
// Per-suit 7 tracking (key primiera component)
for (const suit of SUITS) {
const my7 = myPile.some(c => c.suit === suit && c.value === 7);
const opp7 = oppPile.some(c => c.suit === suit && c.value === 7);
if (my7 && !opp7) score += 40;
if (opp7 && !my7) score -= 40;
}
// Scope
score += (myA.scope + myB.scope - oppA.scope - oppB.scope) * 350;
// --- Scope (most valuable! each is a full point) ---
const myScope = myPlayers.reduce((s, p) => s + p.scope, 0);
const oppScope = oppPlayers.reduce((s, p) => s + p.scope, 0);
score += (myScope - oppScope) * 350;
// --- Table position (non-terminal) ---
// Table position
if (!state.roundOver && state.table.length > 0) {
const tableSum = state.table.reduce((s, c) => s + c.value, 0);
let tableSum = 0;
let tableHasSettebello = false;
for (const c of state.table) {
tableSum += c.value;
if (c.suit === 'denara' && c.value === 7) tableHasSettebello = true;
}
const curTeam = teamOf(state.currentPlayer);
if (curTeam === myTeam && tableSum <= 10) score += 25;
if (curTeam !== myTeam && tableSum <= 10) score -= 25;
// Bonus: table has settebello and it's our turn
if (curTeam === myTeam && state.table.some(c => c.suit === 'denara' && c.value === 7)) score += 80;
if (curTeam !== myTeam && state.table.some(c => c.suit === 'denara' && c.value === 7)) score -= 80;
if (curTeam === myTeam && tableHasSettebello) score += 80;
if (curTeam !== myTeam && tableHasSettebello) score -= 80;
}
return score;

View File

@@ -126,7 +126,7 @@ export function applyMove(
card: Card,
captureChoice?: Card[]
): { nextState: GameState; capture: Capture | null; isScopa: boolean } {
const state2 = deepClone(state);
const state2 = cloneState(state);
const player = state2.players[playerIdx];
// Remove card from hand
@@ -294,6 +294,43 @@ export function getScoreBreakdown(state: GameState): ScoreBreakdown {
// Helpers
// ---------------------------------------------------------------------------
function cloneCard(c: Card): Card {
return { suit: c.suit, value: c.value, id: c.id };
}
function clonePlayer(p: Player): Player {
return {
index: p.index,
hand: p.hand.map(cloneCard),
pile: p.pile.map(cloneCard),
scope: p.scope,
isHuman: p.isHuman,
name: p.name,
};
}
function cloneTeamScore(ts: TeamScore): TeamScore {
return { ...ts };
}
export function cloneState(state: GameState): GameState {
return {
players: [
clonePlayer(state.players[0]),
clonePlayer(state.players[1]),
clonePlayer(state.players[2]),
clonePlayer(state.players[3]),
],
table: state.table.map(cloneCard),
currentPlayer: state.currentPlayer,
roundOver: state.roundOver,
gameOver: state.gameOver,
teamScores: [cloneTeamScore(state.teamScores[0]), cloneTeamScore(state.teamScores[1])],
lastCapturTeam: state.lastCapturTeam,
roundNumber: state.roundNumber,
};
}
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}