diff --git a/src/game/ai.ts b/src/game/ai.ts index 9952370..6feb35e 100644 --- a/src/game/ai.ts +++ b/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,20 +337,22 @@ 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 (threats.nextOppCanScopa) score -= 550; - if (threats.secondOppCanScopa) score -= 250; - score -= threats.totalThreats * 75; + 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; - else if (tableSum <= 7) score -= 50; + 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; + if (afterTable.length === 1 && nextIsOpp) score -= 200; + if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 120; + } } // --- PARTNER COOPERATION --- @@ -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(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 = {}, opp7: Record = {}; + const myPrimBySuit: Record = {}; + const oppPrimBySuit: Record = {}; + + 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; diff --git a/src/game/engine.ts b/src/game/engine.ts index 82c3ba3..ed0ddde 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -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(obj: T): T { return JSON.parse(JSON.stringify(obj)); }