// Chess AI — alpha-beta minimax with piece-square tables and a Nerts-style // 1..5 skill model. // • depth — search depth in plies (capped for browser responsiveness) // • blunder — chance to ignore the best move and play a random legal one // • noise — random value added to root move scores (flattens decisions) // • delay — "thinking" pause (ms range) before moving, for natural pacing // Captures are searched first so alpha-beta prunes hard at the higher depths. import { getLegalMoves, applyMoveRaw, isKingAttacked, SIZE, } from './ChessLogic.js'; const SKILL_PROFILES = { 1: { depth: 1, blunder: 0.45, noise: 90, delay: [900, 1500] }, 2: { depth: 2, blunder: 0.30, noise: 55, delay: [800, 1300] }, 3: { depth: 2, blunder: 0.15, noise: 30, delay: [700, 1100] }, 4: { depth: 3, blunder: 0.05, noise: 12, delay: [550, 950] }, 5: { depth: 4, blunder: 0.00, noise: 0, delay: [450, 850] }, }; const VALUE = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 }; const MATE = 1000000; const PST = { p: [ [0, 0, 0, 0, 0, 0, 0, 0], [50, 50, 50, 50, 50, 50, 50, 50], [10, 10, 20, 30, 30, 20, 10, 10], [5, 5, 10, 25, 25, 10, 5, 5], [0, 0, 0, 20, 20, 0, 0, 0], [5, -5, -10, 0, 0, -10, -5, 5], [5, 10, 10, -20, -20, 10, 10, 5], [0, 0, 0, 0, 0, 0, 0, 0], ], n: [ [-50, -40, -30, -30, -30, -30, -40, -50], [-40, -20, 0, 0, 0, 0, -20, -40], [-30, 0, 10, 15, 15, 10, 0, -30], [-30, 5, 15, 20, 20, 15, 5, -30], [-30, 0, 15, 20, 20, 15, 0, -30], [-30, 5, 10, 15, 15, 10, 5, -30], [-40, -20, 0, 5, 5, 0, -20, -40], [-50, -40, -30, -30, -30, -30, -40, -50], ], b: [ [-20, -10, -10, -10, -10, -10, -10, -20], [-10, 0, 0, 0, 0, 0, 0, -10], [-10, 0, 5, 10, 10, 5, 0, -10], [-10, 5, 5, 10, 10, 5, 5, -10], [-10, 0, 10, 10, 10, 10, 0, -10], [-10, 10, 10, 10, 10, 10, 10, -10], [-10, 5, 0, 0, 0, 0, 5, -10], [-20, -10, -10, -10, -10, -10, -10, -20], ], r: [ [0, 0, 0, 0, 0, 0, 0, 0], [5, 10, 10, 10, 10, 10, 10, 5], [-5, 0, 0, 0, 0, 0, 0, -5], [-5, 0, 0, 0, 0, 0, 0, -5], [-5, 0, 0, 0, 0, 0, 0, -5], [-5, 0, 0, 0, 0, 0, 0, -5], [-5, 0, 0, 0, 0, 0, 0, -5], [0, 0, 0, 5, 5, 0, 0, 0], ], q: [ [-20, -10, -10, -5, -5, -10, -10, -20], [-10, 0, 0, 0, 0, 0, 0, -10], [-10, 0, 5, 5, 5, 5, 0, -10], [-5, 0, 5, 5, 5, 5, 0, -5], [0, 0, 5, 5, 5, 5, 0, -5], [-10, 5, 5, 5, 5, 5, 0, -10], [-10, 0, 5, 0, 0, 0, 0, -10], [-20, -10, -10, -5, -5, -10, -10, -20], ], k: [ [-30, -40, -40, -50, -50, -40, -40, -30], [-30, -40, -40, -50, -50, -40, -40, -30], [-30, -40, -40, -50, -50, -40, -40, -30], [-30, -40, -40, -50, -50, -40, -40, -30], [-20, -30, -30, -40, -40, -30, -30, -20], [-10, -20, -20, -20, -20, -20, -20, -10], [20, 20, 0, 0, 0, 0, 20, 20], [20, 30, 10, 0, 0, 10, 30, 20], ], }; function profileFor(skill) { return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; } export function nextThinkDelay(skill) { const [lo, hi] = profileFor(skill).delay; return lo + Math.random() * (hi - lo); } // Static evaluation from `aiColor`'s perspective (positive = good for AI). function evaluate(board, aiColor) { let score = 0; // white's perspective for (let r = 0; r < SIZE; r++) { for (let c = 0; c < SIZE; c++) { const p = board[r][c]; if (!p) continue; const table = PST[p.type]; const pst = p.color === 'white' ? table[r][c] : table[SIZE - 1 - r][c]; const v = VALUE[p.type] + pst; score += p.color === 'white' ? v : -v; } } return aiColor === 'white' ? score : -score; } // Captures first (most-valuable-victim heuristic) for better pruning. function orderMoves(board, moves) { return moves .map((m) => { let s = 0; if (m.capture) s += 10 * VALUE[board[m.capture[0]][m.capture[1]].type] - VALUE[m.piece]; if (m.promotion) s += VALUE[m.promotion]; return { m, s }; }) .sort((a, b) => b.s - a.s) .map((x) => x.m); } function search(state, depth, alpha, beta, aiColor) { const moves = getLegalMoves(state); if (moves.length === 0) { if (isKingAttacked(state.board, state.turn)) { // Side to move is checkmated. Prefer faster mates via the depth bonus. return state.turn === aiColor ? -(MATE + depth) : (MATE + depth); } return 0; // stalemate } if (depth <= 0) return evaluate(state.board, aiColor); const ordered = orderMoves(state.board, moves); if (state.turn === aiColor) { let value = -Infinity; for (const m of ordered) { value = Math.max(value, search(applyMoveRaw(state, m), depth - 1, alpha, beta, aiColor)); alpha = Math.max(alpha, value); if (alpha >= beta) break; } return value; } let value = Infinity; for (const m of ordered) { value = Math.min(value, search(applyMoveRaw(state, m), depth - 1, alpha, beta, aiColor)); beta = Math.min(beta, value); if (beta <= alpha) break; } return value; } // Return the chosen move object for `aiColor`, or null if none. export function chooseMove(state, aiColor, skill) { const prof = profileFor(skill); const moves = getLegalMoves(state); if (moves.length === 0) return null; if (Math.random() < prof.blunder) { return moves[Math.floor(Math.random() * moves.length)]; } const ordered = orderMoves(state.board, moves); let best = null; let bestScore = -Infinity; for (const m of ordered) { const val = search(applyMoveRaw(state, m), prof.depth - 1, -Infinity, Infinity, aiColor) + (Math.random() * 2 - 1) * prof.noise; if (val > bestScore) { bestScore = val; best = m; } } return best ?? moves[0]; }