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:
187
src/game/ai.ts
187
src/game/ai.ts
@@ -1,5 +1,5 @@
|
|||||||
import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS } from './types';
|
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';
|
import { CardTracker } from './card-tracker';
|
||||||
|
|
||||||
export interface AIMove {
|
export interface AIMove {
|
||||||
@@ -337,20 +337,22 @@ function scoreCaptureAdv(
|
|||||||
// --- ANTI-SCOPA (critical) ---
|
// --- ANTI-SCOPA (critical) ---
|
||||||
if (!isScopa) {
|
if (!isScopa) {
|
||||||
const tableSum = afterTable.reduce((s, c) => s + c.value, 0);
|
const tableSum = afterTable.reduce((s, c) => s + c.value, 0);
|
||||||
const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx);
|
|
||||||
|
|
||||||
if (threats.nextOppCanScopa) score -= 550;
|
if (tableSum >= 11) {
|
||||||
if (threats.secondOppCanScopa) score -= 250;
|
score += 100;
|
||||||
score -= threats.totalThreats * 75;
|
} 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;
|
if (tableSum <= 3) score -= 120;
|
||||||
else if (tableSum <= 3) score -= 120;
|
else if (tableSum <= 7) score -= 50;
|
||||||
else if (tableSum <= 7) score -= 50;
|
|
||||||
|
|
||||||
// Single card on table = trivial scopa
|
if (afterTable.length === 1 && nextIsOpp) score -= 200;
|
||||||
if (afterTable.length === 1 && nextIsOpp) score -= 200;
|
if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 120;
|
||||||
// Two low cards = easy to sum & clear
|
}
|
||||||
if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 120;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PARTNER COOPERATION ---
|
// --- PARTNER COOPERATION ---
|
||||||
@@ -469,15 +471,18 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
|
|||||||
const phase = gamePhase(state);
|
const phase = gamePhase(state);
|
||||||
const cardsRemaining = state.players.reduce((s, p) => s + p.hand.length, 0);
|
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 isDeepEndgame = cardsRemaining <= 6;
|
||||||
const isEndgame = cardsRemaining <= 12;
|
const isEndgame = cardsRemaining <= 12;
|
||||||
const NUM_SAMPLES = isDeepEndgame ? 1 : isEndgame ? 28 : 24;
|
const NUM_SAMPLES = isDeepEndgame ? 1 : isEndgame ? 14 : 10;
|
||||||
const MAX_DEPTH = isDeepEndgame ? cardsRemaining : isEndgame ? 10 : 8;
|
const MAX_DEPTH = isDeepEndgame ? cardsRemaining : isEndgame ? 8 : 6;
|
||||||
|
|
||||||
const legalMoves = getLegalMoves(state, playerIdx);
|
const legalMoves = getLegalMoves(state, playerIdx);
|
||||||
if (legalMoves.length === 1) return legalMoves[0];
|
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
|
// Quick-eval move ordering for better pruning
|
||||||
const quickScored = legalMoves.map(m => ({
|
const quickScored = legalMoves.map(m => ({
|
||||||
move: m,
|
move: m,
|
||||||
@@ -494,15 +499,21 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
|
|||||||
? [state]
|
? [state]
|
||||||
: generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
|
: generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
|
||||||
|
|
||||||
|
let timedOut = false;
|
||||||
|
let samplesCompleted = 0;
|
||||||
|
|
||||||
for (const sample of samples) {
|
for (const sample of samples) {
|
||||||
|
if (timedOut) break;
|
||||||
for (const move of sortedMoves) {
|
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 result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
|
||||||
const score = alphaBeta(
|
const score = alphaBeta(
|
||||||
result.nextState, MAX_DEPTH - 1, -Infinity, Infinity,
|
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);
|
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
|
||||||
}
|
}
|
||||||
|
samplesCompleted++;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bestMove = sortedMoves[0];
|
let bestMove = sortedMoves[0];
|
||||||
@@ -582,7 +593,7 @@ function generateSamples(
|
|||||||
: getUnseenWithoutTracker(state, playerIdx);
|
: getUnseenWithoutTracker(state, playerIdx);
|
||||||
|
|
||||||
for (let s = 0; s < count; s++) {
|
for (let s = 0; s < count; s++) {
|
||||||
const sample = JSON.parse(JSON.stringify(state)) as GameState;
|
const sample = cloneState(state);
|
||||||
const shuffled = shuffleArray([...unseen]);
|
const shuffled = shuffleArray([...unseen]);
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
for (let p = 0; p < 4; p++) {
|
for (let p = 0; p < 4; p++) {
|
||||||
@@ -616,35 +627,26 @@ function shuffleArray<T>(arr: T[]): T[] {
|
|||||||
function alphaBeta(
|
function alphaBeta(
|
||||||
state: GameState, depth: number, alpha: number, beta: number,
|
state: GameState, depth: number, alpha: number, beta: number,
|
||||||
myTeam: 0 | 1, rootPlayer: PlayerIndex,
|
myTeam: 0 | 1, rootPlayer: PlayerIndex,
|
||||||
phase: number, tracker?: CardTracker,
|
phase: number, deadline: number,
|
||||||
): number {
|
): number {
|
||||||
if (depth === 0 || state.roundOver) {
|
if (depth === 0 || state.roundOver || Date.now() > deadline) {
|
||||||
return evaluate(state, myTeam, phase);
|
return evaluateFast(state, myTeam, phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cur = state.currentPlayer;
|
const cur = state.currentPlayer;
|
||||||
const isMyTeam = teamOf(cur) === myTeam;
|
const isMyTeam = teamOf(cur) === myTeam;
|
||||||
const moves = getLegalMoves(state, cur);
|
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) {
|
if (moves.length > 2) {
|
||||||
moves.sort((a, b) => {
|
moves.sort((a, b) => {
|
||||||
let sa = 0, sb = 0;
|
// Captures before dumps
|
||||||
if (a.capture.length > 0) sa += 100 + a.capture.length * 15;
|
if (a.capture.length > 0 && b.capture.length === 0) return -1;
|
||||||
if (b.capture.length > 0) sb += 100 + b.capture.length * 15;
|
if (a.capture.length === 0 && b.capture.length > 0) return 1;
|
||||||
const aAfter = state.table.filter(c => !a.capture.some(cc => cc.id === c.id));
|
// Larger captures first
|
||||||
const bAfter = state.table.filter(c => !b.capture.some(cc => cc.id === c.id));
|
return b.capture.length - a.capture.length;
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,7 +654,7 @@ function alphaBeta(
|
|||||||
let value = -Infinity;
|
let value = -Infinity;
|
||||||
for (const move of moves) {
|
for (const move of moves) {
|
||||||
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
|
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);
|
value = Math.max(value, child);
|
||||||
alpha = Math.max(alpha, value);
|
alpha = Math.max(alpha, value);
|
||||||
if (beta <= alpha) break;
|
if (beta <= alpha) break;
|
||||||
@@ -662,7 +664,7 @@ function alphaBeta(
|
|||||||
let value = Infinity;
|
let value = Infinity;
|
||||||
for (const move of moves) {
|
for (const move of moves) {
|
||||||
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined);
|
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);
|
value = Math.min(value, child);
|
||||||
beta = Math.min(beta, value);
|
beta = Math.min(beta, value);
|
||||||
if (beta <= alpha) break;
|
if (beta <= alpha) break;
|
||||||
@@ -671,62 +673,91 @@ function alphaBeta(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluate(state: GameState, myTeam: 0 | 1, phase: number): number {
|
/** Fast evaluation: avoids flatMap/filter at every leaf node */
|
||||||
const oppTeam = myTeam === 0 ? 1 : 0;
|
function evaluateFast(state: GameState, myTeam: 0 | 1, phase: number): number {
|
||||||
const myPlayers = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]];
|
const p0 = state.players[0], p1 = state.players[1], p2 = state.players[2], p3 = state.players[3];
|
||||||
const oppPlayers = oppTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], 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);
|
// Single-pass pile scan — no flatMap/filter allocations
|
||||||
const oppPile = oppPlayers.flatMap(p => p.pile);
|
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;
|
let score = 0;
|
||||||
|
|
||||||
// --- Cards majority (1 point) ---
|
// Cards majority
|
||||||
const cardDiff = myPile.length - oppPile.length;
|
score += (myCards - oppCards) * (22 + phase * 15);
|
||||||
score += cardDiff * (22 + phase * 15);
|
// Denari majority
|
||||||
|
|
||||||
// --- Denari majority (1 point) ---
|
|
||||||
const myDenari = myPile.filter(c => c.suit === 'denara').length;
|
|
||||||
const oppDenari = oppPile.filter(c => c.suit === 'denara').length;
|
|
||||||
score += (myDenari - oppDenari) * 55;
|
score += (myDenari - oppDenari) * 55;
|
||||||
|
// Settebello
|
||||||
|
if (mySettebello) score += 400;
|
||||||
|
if (oppSettebello) score -= 400;
|
||||||
|
|
||||||
// --- Settebello (1 point) ---
|
// Primiera
|
||||||
if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 400;
|
let myPrim = 0, oppPrim = 0;
|
||||||
if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 400;
|
let mySuits = 0, oppSuits = 0;
|
||||||
|
for (const suit of SUITS) {
|
||||||
// --- Primiera (1 point) ---
|
if (myPrimBySuit[suit]) { myPrim += myPrimBySuit[suit]; mySuits++; }
|
||||||
const myPrim = calcPrimiera(myPile);
|
if (oppPrimBySuit[suit]) { oppPrim += oppPrimBySuit[suit]; oppSuits++; }
|
||||||
const oppPrim = calcPrimiera(oppPile);
|
// Per-suit 7 tracking
|
||||||
if (myPrim > 0 && oppPrim > 0) {
|
if (my7[suit] && !opp7[suit]) score += 40;
|
||||||
|
if (opp7[suit] && !my7[suit]) score -= 40;
|
||||||
|
}
|
||||||
|
if (mySuits === 4 && oppSuits === 4) {
|
||||||
score += (myPrim - oppPrim) * 4;
|
score += (myPrim - oppPrim) * 4;
|
||||||
} else if (myPrim > 0) {
|
} else if (mySuits === 4) {
|
||||||
score += 150;
|
score += 150;
|
||||||
} else if (oppPrim > 0) {
|
} else if (oppSuits === 4) {
|
||||||
score -= 150;
|
score -= 150;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-suit 7 tracking (key primiera component)
|
// Scope
|
||||||
for (const suit of SUITS) {
|
score += (myA.scope + myB.scope - oppA.scope - oppB.scope) * 350;
|
||||||
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 (most valuable! each is a full point) ---
|
// Table position
|
||||||
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) ---
|
|
||||||
if (!state.roundOver && state.table.length > 0) {
|
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);
|
const curTeam = teamOf(state.currentPlayer);
|
||||||
if (curTeam === myTeam && tableSum <= 10) score += 25;
|
if (curTeam === myTeam && tableSum <= 10) score += 25;
|
||||||
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 && tableHasSettebello) 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 && state.table.some(c => c.suit === 'denara' && c.value === 7)) score -= 80;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return score;
|
return score;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export function applyMove(
|
|||||||
card: Card,
|
card: Card,
|
||||||
captureChoice?: Card[]
|
captureChoice?: Card[]
|
||||||
): { nextState: GameState; capture: Capture | null; isScopa: boolean } {
|
): { nextState: GameState; capture: Capture | null; isScopa: boolean } {
|
||||||
const state2 = deepClone(state);
|
const state2 = cloneState(state);
|
||||||
const player = state2.players[playerIdx];
|
const player = state2.players[playerIdx];
|
||||||
|
|
||||||
// Remove card from hand
|
// Remove card from hand
|
||||||
@@ -294,6 +294,43 @@ export function getScoreBreakdown(state: GameState): ScoreBreakdown {
|
|||||||
// Helpers
|
// 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 {
|
function deepClone<T>(obj: T): T {
|
||||||
return JSON.parse(JSON.stringify(obj));
|
return JSON.parse(JSON.stringify(obj));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user