118 lines
4.0 KiB
JavaScript
118 lines
4.0 KiB
JavaScript
// Othello AI — alpha-beta minimax with classic positional heuristics.
|
|
import { other, getValidMoves, getFlips, getScore, getWinner, cloneState, applyMove, mustPass, SIZE } from './OthelloLogic.js';
|
|
|
|
const SKILL_PROFILES = {
|
|
1: { depth: 1, blunder: 0.50, noise: 100, delay: [900, 1500] },
|
|
2: { depth: 2, blunder: 0.30, noise: 60, delay: [800, 1200] },
|
|
3: { depth: 3, blunder: 0.12, noise: 25, delay: [650, 1050] },
|
|
4: { depth: 4, blunder: 0.04, noise: 10, delay: [500, 900] },
|
|
5: { depth: 5, blunder: 0.00, noise: 0, delay: [400, 800] },
|
|
};
|
|
|
|
// Classic positional weight table (corners are very valuable).
|
|
const W = [
|
|
[120, -20, 20, 5, 5, 20, -20, 120],
|
|
[-20, -40, -5, -5, -5, -5, -40, -20],
|
|
[ 20, -5, 15, 3, 3, 15, -5, 20],
|
|
[ 5, -5, 3, 3, 3, 3, -5, 5],
|
|
[ 5, -5, 3, 3, 3, 3, -5, 5],
|
|
[ 20, -5, 15, 3, 3, 15, -5, 20],
|
|
[-20, -40, -5, -5, -5, -5, -40, -20],
|
|
[120, -20, 20, 5, 5, 20, -20, 120],
|
|
];
|
|
|
|
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);
|
|
}
|
|
|
|
function evaluate(state, aiColor) {
|
|
if (state.phase === 'game_over') {
|
|
const w = getWinner(state);
|
|
if (w === aiColor) return 100000;
|
|
if (w === 'draw') return 0;
|
|
return -100000;
|
|
}
|
|
const human = other(aiColor);
|
|
const board = state.board;
|
|
|
|
// Positional score
|
|
let posScore = 0;
|
|
for (let r = 0; r < SIZE; r++)
|
|
for (let c = 0; c < SIZE; c++) {
|
|
if (board[r][c] === aiColor) posScore += W[r][c];
|
|
else if (board[r][c] === human) posScore -= W[r][c];
|
|
}
|
|
|
|
// Mobility: difference in valid move counts
|
|
const aiMoves = getValidMoves(state, aiColor).length;
|
|
const humanMoves = getValidMoves(state, human).length;
|
|
const mobility = aiMoves - humanMoves;
|
|
|
|
// Disc count matters more in the endgame
|
|
const { black, white } = getScore(state);
|
|
const filled = black + white;
|
|
const discDiff = (aiColor === 'black' ? black - white : white - black);
|
|
const discWeight = filled > 52 ? 3 : 0;
|
|
|
|
return posScore * 10 + mobility * 5 + discDiff * discWeight;
|
|
}
|
|
|
|
function search(state, depth, alpha, beta, aiColor) {
|
|
if (state.phase === 'game_over' || depth <= 0) return evaluate(state, aiColor);
|
|
|
|
const moves = getValidMoves(state, state.turn);
|
|
|
|
if (moves.length === 0) {
|
|
// Current player passes; check if game ends or opponent goes.
|
|
const passState = cloneState(state);
|
|
passState.turn = other(state.turn);
|
|
const oppMoves = getValidMoves(passState, passState.turn);
|
|
if (oppMoves.length === 0) {
|
|
passState.phase = 'game_over';
|
|
passState.winner = (() => { const s = getScore(passState); return s.black > s.white ? 'black' : s.white > s.black ? 'white' : 'draw'; })();
|
|
return evaluate(passState, aiColor);
|
|
}
|
|
return search(passState, depth, alpha, beta, aiColor);
|
|
}
|
|
|
|
if (state.turn === aiColor) {
|
|
let value = -Infinity;
|
|
for (const m of moves) {
|
|
value = Math.max(value, search(applyMove(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 moves) {
|
|
value = Math.min(value, search(applyMove(state, m), depth - 1, alpha, beta, aiColor));
|
|
beta = Math.min(beta, value);
|
|
if (beta <= alpha) break;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function chooseMove(state, aiColor, skill) {
|
|
const prof = profileFor(skill);
|
|
const moves = getValidMoves(state, aiColor);
|
|
if (moves.length === 0) return null;
|
|
if (moves.length === 1) return moves[0];
|
|
|
|
if (Math.random() < prof.blunder) return moves[Math.floor(Math.random() * moves.length)];
|
|
|
|
let best = null, bestScore = -Infinity;
|
|
for (const m of moves) {
|
|
const ns = applyMove(state, m);
|
|
let val = search(ns, prof.depth - 1, -Infinity, Infinity, aiColor);
|
|
val += (Math.random() * 2 - 1) * prof.noise;
|
|
if (val > bestScore) { bestScore = val; best = m; }
|
|
}
|
|
return best ?? moves[0];
|
|
}
|