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
+376 -57
View File
@@ -1,94 +1,413 @@
// The demo game, "High Card": every round each seat names a number from one
// to five; the highest number named by exactly one seat scores it a point,
// and the first to three points wins. It exists to show the shape of a game
// this kit can run: simultaneous hidden moves, a seeded bot, a view that
// withholds the round in progress, and a rules revision. Replace this file
// with your own game and keep the GameSpec shape.
// Hnefatafl: the Viking board game of a king's escape. Two sides: the
// attackers, who move first and must capture the king; the defenders, whose
// king must reach a corner (Copenhagen) or the edge (Tablut). Every piece
// moves like a rook, and a piece is taken by being sandwiched between two
// enemies. Nothing is hidden, so a view is the whole state.
import { SPECTATOR, type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec';
import type { GameSpec, Outcome, SeatId, TableOptions } from './spec';
import { RULESETS, type Cell, type Ruleset } from './rules';
import { chooseMove } from './bot';
export const CURRENT_RULES = 1;
export const TARGET = 3;
export const HIGHEST = 5;
export type Side = 'attackers' | 'defenders';
/** One square: empty, an attacker, a defender, or the king. */
export type Piece = '.' | 'a' | 'd' | 'k';
export interface Player {
id: SeatId;
name: string;
points: number;
side: Side;
}
export interface Round {
picks: Record<SeatId, number>;
scorer: SeatId | null;
export interface Move {
from: number;
to: number;
/** Squares emptied by this move's captures. */
captured: number[];
}
export interface State {
rules: number;
set: Ruleset['id'];
size: number;
/** Row-major, y * size + x. */
cells: Piece[];
seats: SeatId[];
players: Record<SeatId, Player>;
rounds: Round[];
toMove: Side;
moves: Move[];
/** How often each position (cells and side to move) has stood, for the repetition rule. */
seen: Record<string, number>;
over: Outcome | null;
rng: number;
}
export type Input = { pick: number };
export type Input = { from: number; to: number };
/** Mulberry32 on a seed: a small generator, so a game replays to the same bot picks. */
function random(seed: number): number {
let t = (seed + 0x6d2b79f5) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
export const SIDES: { value: Side; label: string }[] = [
{ value: 'defenders', label: 'the defenders, with the king' },
{ value: 'attackers', label: 'the attackers' }
];
// --- The board ---------------------------------------------------------------
export function at(size: number, c: Cell): number {
return c.y * size + c.x;
}
export function createGame(names: Record<SeatId, string>, seed = Date.now(), rules = CURRENT_RULES, _options?: TableOptions): State {
export function cellOf(size: number, i: number): Cell {
return { x: i % size, y: Math.floor(i / size) };
}
export function throneOf(size: number): number {
const mid = (size - 1) / 2;
return mid * size + mid;
}
export function cornersOf(size: number): number[] {
return [0, size - 1, size * (size - 1), size * size - 1];
}
export function isEdge(size: number, i: number): boolean {
const { x, y } = cellOf(size, i);
return x === 0 || y === 0 || x === size - 1 || y === size - 1;
}
const DIRECTIONS = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1]
] as const;
function neighbors(size: number, i: number): number[] {
const { x, y } = cellOf(size, i);
const out: number[] = [];
if (y > 0) out.push(i - size);
if (y < size - 1) out.push(i + size);
if (x > 0) out.push(i - 1);
if (x < size - 1) out.push(i + 1);
return out;
}
/** The square on the far side of `mid` from `from`, or -1 off the board. */
function beyond(size: number, from: number, mid: number): number {
const a = cellOf(size, from);
const b = cellOf(size, mid);
const c = { x: b.x + (b.x - a.x), y: b.y + (b.y - a.y) };
if (c.x < 0 || c.y < 0 || c.x >= size || c.y >= size) return -1;
return at(size, c);
}
export function sideOf(p: Piece | undefined): Side | null {
return p === 'a' ? 'attackers' : p === 'd' || p === 'k' ? 'defenders' : null;
}
export function rulesetOf(state: State): Ruleset {
return RULESETS[state.set];
}
// --- Setting up ---------------------------------------------------------------
export function createGame(names: Record<SeatId, string>, _seed = 0, rules = CURRENT_RULES, options?: TableOptions): State {
const set = options?.set === 'tablut' ? RULESETS.tablut : RULESETS.copenhagen;
const size = set.size;
const cells: Piece[] = Array(size * size).fill('.');
for (const c of set.attackers) cells[at(size, c)] = 'a';
for (const c of set.defenders) cells[at(size, c)] = 'd';
cells[throneOf(size)] = 'k';
const seats = Object.keys(names);
return {
rules,
seats,
players: Object.fromEntries(seats.map((id) => [id, { id, name: names[id], points: 0 }])),
rounds: [],
over: null,
rng: seed >>> 0 || 1
};
const hostSide: Side = options?.side === 'attackers' ? 'attackers' : 'defenders';
const players: Record<SeatId, Player> = {};
seats.forEach((id, i) => {
players[id] = { id, name: names[id], side: i === 0 ? hostSide : hostSide === 'attackers' ? 'defenders' : 'attackers' };
});
const state: State = { rules, set: set.id, size, cells, seats, players, toMove: 'attackers', moves: [], seen: {}, over: null };
state.seen[positionKey(state)] = 1;
return state;
}
export function resolveRound(previous: State, inputs: Record<SeatId, Input>): State {
const state = structuredClone(previous);
if (state.over) return state;
const picks: Record<SeatId, number> = {};
for (const id of state.seats) picks[id] = inputs[id]?.pick ?? 1;
const counts = new Map<number, SeatId[]>();
for (const id of state.seats) counts.set(picks[id], [...(counts.get(picks[id]) ?? []), id]);
let scorer: SeatId | null = null;
for (let n = HIGHEST; n >= 1 && !scorer; n--) {
const who = counts.get(n);
if (who?.length === 1) scorer = who[0];
function positionKey(state: State): string {
return state.cells.join('') + (state.toMove === 'attackers' ? 'a' : 'd');
}
export function seatOfSide(state: State, side: Side): SeatId | null {
return state.seats.find((id) => state.players[id].side === side) ?? null;
}
// --- Moving -----------------------------------------------------------------------
/** Where the piece on `from` may go: along its row and column, through empty squares. */
export function movesFrom(state: State, from: number): number[] {
return movesOn(rulesetOf(state), state.cells, from);
}
export function movesOn(set: Ruleset, cells: Piece[], from: number): number[] {
const size = set.size;
const piece = cells[from];
if (piece === '.' || piece === undefined) return [];
const king = piece === 'k';
const throne = throneOf(size);
const corners = set.markedCorners ? cornersOf(size) : [];
const { x, y } = cellOf(size, from);
const out: number[] = [];
for (const [dx, dy] of DIRECTIONS) {
for (let step = 1; ; step++) {
const nx = x + dx * step;
const ny = y + dy * step;
if (nx < 0 || ny < 0 || nx >= size || ny >= size) break;
const i = at(size, { x: nx, y: ny });
if (cells[i] !== '.') break;
if (corners.includes(i)) {
if (king) out.push(i);
break;
}
if (i === throne) {
if (king) out.push(i);
else if (!set.passThrone) break;
continue;
}
out.push(i);
}
}
if (scorer) state.players[scorer].points += 1;
state.rounds.push({ picks, scorer });
if (scorer && state.players[scorer].points >= TARGET) {
state.over = { winner: scorer, reason: `${state.players[scorer].name} reached ${TARGET} points.` };
return out;
}
export function legalMoves(state: State, side: Side = state.toMove): Move[] {
const out: Move[] = [];
if (state.over) return out;
state.cells.forEach((p, i) => {
if (sideOf(p) !== side) return;
for (const to of movesFrom(state, i)) out.push({ from: i, to, captured: [] });
});
return out;
}
/** Whether `i` closes a capture on a piece of `side`: an enemy (an armed king counts), a marked corner, or the empty throne. */
function hostile(set: Ruleset, cells: Piece[], i: number, side: Side): boolean {
const p = cells[i];
if (p !== '.') {
const s = sideOf(p);
if (s === null || s === side) return false;
return p !== 'k' || set.kingArmed;
}
if (set.markedCorners && cornersOf(set.size).includes(i)) return true;
return i === throneOf(set.size);
}
/** Whether the attackers have the king where he stands. */
function kingTaken(set: Ruleset, cells: Piece[], kingAt: number): boolean {
const size = set.size;
const throne = throneOf(size);
const around = neighbors(size, kingAt);
const attackersAround = around.filter((n) => cells[n] === 'a').length;
if (kingAt === throne) return attackersAround === 4;
if (around.includes(throne) && cells[throne] === '.') return attackersAround === 3;
if (set.kingCapturedByFour) return around.length === 4 && attackersAround === 4;
// Like any piece: two attackers on opposite sides.
const { x, y } = cellOf(size, kingAt);
if (x > 0 && x < size - 1 && cells[kingAt - 1] === 'a' && cells[kingAt + 1] === 'a') return true;
return y > 0 && y < size - 1 && cells[kingAt - size] === 'a' && cells[kingAt + size] === 'a';
}
/** The squares a piece just landed on `to` takes: custodial captures, the king by his own rule, a shieldwall along the edge. */
export function capturesOn(set: Ruleset, cells: Piece[], to: number): number[] {
const size = set.size;
const piece = cells[to];
const side = sideOf(piece)!;
const captured: number[] = [];
for (const n of neighbors(size, to)) {
const target = cells[n];
if (target === '.' || target === 'k' || sideOf(target) === side) continue;
const far = beyond(size, to, n);
if (far !== -1 && hostile(set, cells, far, sideOf(target)!)) captured.push(n);
}
if (side === 'attackers') {
const kingAt = cells.indexOf('k');
if (kingAt !== -1 && neighbors(size, kingAt).includes(to) && kingTaken(set, cells, kingAt)) captured.push(kingAt);
}
if (set.shieldwall && isEdge(size, to)) {
for (const run of shieldwallRuns(set, cells, to, side)) captured.push(...run);
}
return captured;
}
/** Slide a piece and take what it takes, on a copy of the board. */
export function playOn(set: Ruleset, cells: Piece[], from: number, to: number): { cells: Piece[]; captured: number[] } {
const next = cells.slice();
next[to] = next[from];
next[from] = '.';
const captured = capturesOn(set, next, to);
for (const i of captured) next[i] = '.';
return { cells: next, captured };
}
/** Apply a move to a copy of the state: the piece slides, captures fall, and the game may end. */
export function applyMove(previous: State, from: number, to: number): State {
const set = rulesetOf(previous);
const piece = previous.cells[from];
const side = sideOf(piece)!;
const played = playOn(set, previous.cells, from, to);
const state: State = { ...previous, cells: played.cells, moves: [...previous.moves, { from, to, captured: played.captured }], seen: { ...previous.seen } };
const { size, cells } = state;
const kingAt = cells.indexOf('k');
const win = (winner: Side, reason: string) => {
state.over = { winner: seatOfSide(state, winner), reason };
};
if (kingAt === -1) win('attackers', 'The king is taken.');
else if (side === 'defenders' && piece === 'k' && escaped(state, kingAt)) win('defenders', set.escape === 'corner' ? 'The king reaches a corner.' : 'The king reaches the edge.');
else if (set.edgeFort && side === 'defenders' && edgeFort(state, kingAt)) win('defenders', 'The king holds an edge fort the attackers cannot break.');
else if (set.encirclement && side === 'attackers' && encircled(state)) win('attackers', 'The defenders are sealed away from every edge.');
state.toMove = side === 'attackers' ? 'defenders' : 'attackers';
if (!state.over) {
const key = positionKey(state);
state.seen[key] = (state.seen[key] ?? 0) + 1;
const mover = state.players[seatOfSide(state, side)!]?.name ?? side;
const next = state.players[seatOfSide(state, state.toMove)!]?.name ?? state.toMove;
if (state.seen[key] >= 3) win(state.toMove, `${mover} repeats the position a third time and forfeits.`);
else if (legalMoves(state, state.toMove).length === 0) win(side, `${next} has no move.`);
}
return state;
}
function escaped(state: State, kingAt: number): boolean {
return rulesetOf(state).escape === 'corner' ? cornersOf(state.size).includes(kingAt) : isEdge(state.size, kingAt);
}
/** Runs of enemy pieces along the edge beside `to`, bracketed at both ends and each faced from inland. */
function shieldwallRuns(set: Ruleset, cells: Piece[], to: number, side: Side): number[][] {
const size = set.size;
const { x, y } = cellOf(size, to);
const along: [number, number][] = x === 0 || x === size - 1 ? [[0, 1], [0, -1]] : [[1, 0], [-1, 0]];
const inland = x === 0 ? { x: 1, y: 0 } : x === size - 1 ? { x: -1, y: 0 } : y === 0 ? { x: 0, y: 1 } : { x: 0, y: -1 };
const runs: number[][] = [];
for (const [dx, dy] of along) {
const run: number[] = [];
let cx = x + dx;
let cy = y + dy;
let closed = false;
while (cx >= 0 && cy >= 0 && cx < size && cy < size) {
const i = at(size, { x: cx, y: cy });
const p = cells[i];
if (p === '.') {
closed = cornersOf(size).includes(i);
break;
}
if (sideOf(p) === side) {
closed = true;
break;
}
const facing = cells[at(size, { x: cx + inland.x, y: cy + inland.y })];
if (sideOf(facing) !== side) break;
run.push(i);
cx += dx;
cy += dy;
}
if (closed && run.length >= 2) runs.push(run.filter((i) => cells[i] !== 'k'));
}
return runs;
}
/** The pieces that bound the empty squares reachable from `start`. */
function bounds(state: State, start: number): Set<number> {
const squares = new Set<number>([start]);
const out = new Set<number>();
const queue = [start];
while (queue.length) {
const i = queue.pop()!;
for (const n of neighbors(state.size, i)) {
if (state.cells[n] === '.') {
if (!squares.has(n)) {
squares.add(n);
queue.push(n);
}
} else out.add(n);
}
}
return out;
}
/** The king on the edge, walled in by defenders alone, with a move to make. */
function edgeFort(state: State, kingAt: number): boolean {
if (!isEdge(state.size, kingAt) || movesFrom(state, kingAt).length === 0) return false;
for (const b of bounds(state, kingAt)) if (state.cells[b] === 'a') return false;
return true;
}
/** No defender, king included, can ever reach an edge square. */
function encircled(state: State): boolean {
const { size, cells } = state;
const seen = new Set<number>();
const queue: number[] = [];
cells.forEach((p, i) => {
if (sideOf(p) === 'defenders') {
seen.add(i);
queue.push(i);
}
});
if (queue.length === 0) return false;
while (queue.length) {
const i = queue.pop()!;
if (isEdge(size, i)) return false;
for (const n of neighbors(size, i)) {
if (cells[n] === 'a' || seen.has(n)) continue;
seen.add(n);
queue.push(n);
}
}
return true;
}
// --- The contract ---------------------------------------------------------------
export function resolveRound(previous: State, inputs: Record<SeatId, Input>): State {
if (previous.over) return previous;
const seat = seatOfSide(previous, previous.toMove);
const input = seat ? inputs[seat] : undefined;
if (!seat || !input || validateMove(previous, seat, input)) return previous;
return applyMove(previous, input.from, input.to);
}
export function validateMove(state: State, seat: SeatId, input: Input): string | null {
if (state.over) return 'The game is over.';
const player = state.players[seat];
if (!player) return 'You hold no seat.';
if (player.side !== state.toMove) return 'It is not your side to move.';
if (sideOf(state.cells[input.from]) !== player.side) return 'That is not one of your pieces.';
if (!movesFrom(state, input.from).includes(input.to)) return 'That piece cannot move there.';
return null;
}
export const game: GameSpec<State, Input> = {
seatIds: 'ABCDEFGH'.split(''),
seatIds: ['A', 'B'],
minSeats: 2,
botNames: ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'],
botNames: ['Hrothgar', 'Ingrid', 'Sigrid', 'Torvald', 'Astrid', 'Leif'],
currentRules: CURRENT_RULES,
options: [
{
key: 'set',
label: 'Rules',
choices: [
{ value: 'copenhagen', label: 'Copenhagen, 11 by 11' },
{ value: 'tablut', label: 'Tablut, 9 by 9, after Linnaeus' }
],
default: 'copenhagen'
},
{ key: 'side', label: 'You play', choices: SIDES, default: 'defenders' }
],
create: createGame,
resolve: resolveRound,
needsInput: (state) => !state.over,
needsInput: (state, seat) => !state.over && state.players[seat]?.side === state.toMove,
over: (state) => state.over,
turn: (state) => state.rounds.length + 1,
// Every finished round is public; nothing is hidden, so the gallery sees what a seat sees.
view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)),
// A function of the state alone: the seed, the round and the seat, so a replay draws the same pick.
botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }),
cleanInput: (raw) => ({ pick: Number((raw as { pick?: unknown })?.pick) }),
validate: (_state, _seat, input) =>
Number.isInteger(input.pick) && input.pick >= 1 && input.pick <= HIGHEST ? null : `Name a number from 1 to ${HIGHEST}.`,
turn: (state) => state.moves.length + 1,
view: (state) => state,
botInput: (state, seat) => chooseMove(state, seat),
cleanInput: (raw) => {
const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
return { from: Number.isInteger(r.from) ? (r.from as number) : -1, to: Number.isInteger(r.to) ? (r.to as number) : -1 };
},
validate: validateMove,
nameOf: (state, seat) => state.players[seat]?.name ?? seat
};