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:
co-authored by
Claude Fable 5.1
parent
ed1dcad259
commit
0d2f54fe02
+376
-57
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user