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