fertig-classic-games/src/games/chess/ChessAI.js

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];
}