// Blokus AI — heuristic single-ply move scorer. No Phaser imports. // // Blokus has an enormous branching factor, so instead of a minimax search we // enumerate every legal placement and score it greedily, then pick among the // top candidates with skill-scaled blunder/noise (the suite's standard idiom). import { SIZE, inBounds } from './BlokusBoard.js'; import { generateMoves } from './BlokusLogic.js'; const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]]; const DIAG = [[-1, -1], [-1, 1], [1, -1], [1, 1]]; const CENTER = (SIZE - 1) / 2; const SKILL_PROFILES = { 1: { topN: 6, blunder: 0.45, noise: 14, blockWeight: 0.0, delay: [800, 1300] }, 2: { topN: 8, blunder: 0.28, noise: 9, blockWeight: 0.3, delay: [700, 1150] }, 3: { topN: 10, blunder: 0.14, noise: 6, blockWeight: 0.6, delay: [600, 1000] }, 4: { topN: 14, blunder: 0.05, noise: 3, blockWeight: 1.0, delay: [500, 900] }, 5: { topN: 20, blunder: 0.00, noise: 0, blockWeight: 1.4, delay: [420, 820] }, }; 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 scoreMove(state, seat, move, blockWeight) { const board = state.board; const cells = move.cells; const placed = new Set(cells.map(([r, c]) => r * SIZE + c)); const ownAt = (r, c) => inBounds(r, c) && (board[r][c] === seat || placed.has(r * SIZE + c)); // Dump big pieces early. let score = cells.length * 6; // Mobility — new diagonal "outlets" this placement opens for our colour. const counted = new Set(); let outlets = 0; for (const [r, c] of cells) { for (const [dr, dc] of DIAG) { const nr = r + dr, nc = c + dc; if (!inBounds(nr, nc)) continue; const k = nr * SIZE + nc; if (board[nr][nc] !== null || placed.has(k) || counted.has(k)) continue; if (ORTH.some(([or, oc]) => ownAt(nr + or, nc + oc))) continue; // can't host a future corner counted.add(k); outlets++; } } score += outlets * 4; // Reach toward the centre (contest territory). let dsum = 0; for (const [r, c] of cells) dsum += Math.abs(r - CENTER) + Math.abs(c - CENTER); score -= (dsum / cells.length) * 0.5; // Crowd opponents (skill-scaled). if (blockWeight > 0) { let block = 0; for (const [r, c] of cells) { for (const [dr, dc] of [...ORTH, ...DIAG]) { const nr = r + dr, nc = c + dc; if (inBounds(nr, nc) && board[nr][nc] !== null && board[nr][nc] !== seat) block++; } } score += block * blockWeight * 3; } return score; } /** Pick a placement for `seat`, or null if it must pass. */ export function chooseMove(state, seat, skill = 3) { const prof = profileFor(skill); const moves = generateMoves(state, seat); 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)]; const scored = moves.map((m) => ({ m, s: scoreMove(state, seat, m, prof.blockWeight) })); scored.sort((a, b) => b.s - a.s); const pool = scored.slice(0, Math.min(prof.topN, scored.length)); let best = pool[0]; let bestVal = -Infinity; for (const cand of pool) { const v = cand.s + (prof.noise ? (Math.random() * 2 - 1) * prof.noise : 0); if (v > bestVal) { bestVal = v; best = cand; } } return best.m; }