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:
175
src/game/ai.ts
175
src/game/ai.ts
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user