94 lines
3.4 KiB
JavaScript
94 lines
3.4 KiB
JavaScript
// 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;
|
|
}
|