174 lines
5.6 KiB
JavaScript
174 lines
5.6 KiB
JavaScript
// 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];
|
|
}
|