fertig-classic-games/public/src/games/blokus/BlokusAI.js

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