Hnefatafl: Copenhagen and Tablut, a board that ages with the older game, and a bot that looks three moves ahead

The rules are data in rules.ts; the engine plays custodial capture, the
king's own capture rule on and beside the throne, corner and edge
escape, shieldwalls, edge forts, encirclement, repetition and stalemate;
twenty tests pin them and play a bot game to a verdict on both boards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 13:06:23 -04:00
co-authored by Claude Fable 5.1
parent ed1dcad259
commit 0d2f54fe02
12 changed files with 1314 additions and 280 deletions
+153
View File
@@ -0,0 +1,153 @@
// The bot: a short alpha-beta search over the legal moves, scored by what
// the game is about — the king's road to safety, the pieces on the board,
// and the attackers pressing around the king. A function of the state
// alone, so a ledger replays to the same choice.
import { cornersOf, isEdge, cellOf, movesOn, playOn, rulesetOf, sideOf, type Input, type Piece, type Side, type State } from './index';
import type { Ruleset } from './rules';
import type { SeatId } from './spec';
/** The board as the search sees it: pieces, who moves, and a verdict when one has fallen. */
interface Node {
cells: Piece[];
toMove: Side;
/** The side that has won, once the king is taken or away. */
won: Side | null;
}
function movesOf(set: Ruleset, cells: Piece[], side: Side): { from: number; to: number }[] {
const out: { from: number; to: number }[] = [];
for (let i = 0; i < cells.length; i++) {
if (sideOf(cells[i]) !== side) continue;
for (const to of movesOn(set, cells, i)) out.push({ from: i, to });
}
return out;
}
function stepNode(set: Ruleset, node: Node, from: number, to: number): Node {
const piece = node.cells[from];
const { cells } = playOn(set, node.cells, from, to);
const kingAt = cells.indexOf('k');
let won: Side | null = null;
if (kingAt === -1) won = 'attackers';
else if (piece === 'k' && (set.escape === 'corner' ? cornersOf(set.size).includes(kingAt) : isEdge(set.size, kingAt))) won = 'defenders';
return { cells, toMove: node.toMove === 'attackers' ? 'defenders' : 'attackers', won };
}
/** Search depth in plies: three looks ahead on either board, well under a second a move. */
function depthFor(size: number): number {
void size;
return 3;
}
/** Positive is good for the attackers. */
function evaluate(set: Ruleset, node: Node): number {
if (node.won) return node.won === 'attackers' ? 10_000 : -10_000;
const size = set.size;
const cells = node.cells;
let score = 0;
let attackers = 0;
let defenders = 0;
let kingAt = -1;
cells.forEach((p, i) => {
if (p === 'a') attackers += 1;
else if (p === 'd') defenders += 1;
else if (p === 'k') kingAt = i;
});
score += attackers * 10 - defenders * 16;
if (kingAt === -1) return 10_000;
const k = cellOf(size, kingAt);
// The king's distance to safety, and how open his roads are.
const goals = set.escape === 'corner' ? cornersOf(size).map((i) => cellOf(size, i)) : [];
let distance: number;
if (set.escape === 'corner') distance = Math.min(...goals.map((g) => Math.abs(g.x - k.x) + Math.abs(g.y - k.y)));
else distance = Math.min(k.x, k.y, size - 1 - k.x, size - 1 - k.y);
score += distance * 6;
// Open lines from the king straight to an escape square are worth a great deal.
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1]
] as const) {
let x = k.x + dx;
let y = k.y + dy;
let open = true;
while (x >= 0 && y >= 0 && x < size && y < size) {
if (cells[y * size + x] !== '.') {
open = false;
break;
}
x += dx;
y += dy;
}
if (open) {
const last = { x: x - dx, y: y - dy };
const reaches = set.escape === 'edge' ? isEdge(size, last.y * size + last.x) : cornersOf(size).includes(last.y * size + last.x);
if (reaches) score -= 400;
}
}
// Attackers pressing the king.
let press = 0;
for (const [dx, dy] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1]
] as const) {
const x = k.x + dx;
const y = k.y + dy;
if (x < 0 || y < 0 || x >= size || y >= size) continue;
if (cells[y * size + x] === 'a') press += 1;
}
score += press * 12;
return score;
}
function orderMoves(set: Ruleset, cells: Piece[], moves: { from: number; to: number }[]): { from: number; to: number }[] {
// King moves and edge moves first: they change the position most.
const king = cells.indexOf('k');
return moves
.map((m) => ({ m, w: (m.from === king ? 2 : 0) + (isEdge(set.size, m.to) ? 1 : 0) }))
.sort((a, b) => b.w - a.w)
.map((x) => x.m);
}
function search(set: Ruleset, node: Node, depth: number, alpha: number, beta: number, forSide: Side): number {
if (depth === 0 || node.won) return evaluate(set, node) * (forSide === 'attackers' ? 1 : -1);
const maximizing = node.toMove === forSide;
let best = maximizing ? -Infinity : Infinity;
for (const m of orderMoves(set, node.cells, movesOf(set, node.cells, node.toMove))) {
const next = stepNode(set, node, m.from, m.to);
const v = search(set, next, depth - 1, alpha, beta, forSide);
if (maximizing) {
best = Math.max(best, v);
alpha = Math.max(alpha, v);
} else {
best = Math.min(best, v);
beta = Math.min(beta, v);
}
if (beta <= alpha) break;
}
return best;
}
export function chooseMove(state: State, seat: SeatId): Input {
const set = rulesetOf(state);
const side = state.players[seat].side;
const root: Node = { cells: state.cells, toMove: side, won: null };
const moves = orderMoves(set, state.cells, movesOf(set, state.cells, side));
if (moves.length === 0) return { from: -1, to: -1 };
let best = moves[0];
let bestScore = -Infinity;
const depth = depthFor(state.size);
for (const m of moves) {
const next = stepNode(set, root, m.from, m.to);
const v = search(set, next, depth - 1, -Infinity, Infinity, side);
if (v > bestScore) {
bestScore = v;
best = m;
}
}
return { from: best.from, to: best.to };
}