fertig-classic-games/public/src/games/othello/OthelloAI.js

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