Files
hnefatafl/src/lib/game/index.ts
T

425 lines
16 KiB
TypeScript

// 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 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 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;
side: Side;
}
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>;
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;
}
export type Input = { from: number; to: number };
export const SIDES: { value: Side; label: string; note: string }[] = [
{ value: 'defenders', label: 'the defenders, with the king', note: 'Fewer pieces, and the king must reach safety before the ring closes.' },
{ value: 'attackers', label: 'the attackers', note: 'Twice the pieces, and the first move; close every road and take the king.' }
];
// --- The board ---------------------------------------------------------------
export function at(size: number, c: Cell): number {
return c.y * size + c.x;
}
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. */
export 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);
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;
}
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);
}
}
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 next = state.players[seatOfSide(state, state.toMove)!]?.name ?? state.toMove;
// Perpetual repetition is forbidden, and the Copenhagen rule lays the loss on the defenders.
if (state.seen[key] >= 3) win('attackers', 'The position stands a third time: perpetual repetition loses for the defenders.');
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: ['A', 'B'],
minSeats: 2,
botNames: ['Hrothgar', 'Ingrid', 'Sigrid', 'Torvald', 'Astrid', 'Leif'],
currentRules: CURRENT_RULES,
options: [
{
key: 'set',
label: 'Rules',
choices: [
{ value: 'copenhagen', label: 'Copenhagen, 11 by 11', note: 'The modern tournament game. The king escapes to a corner and falls only to four attackers, three beside the throne.' },
{ value: 'tablut', label: 'Tablut, 9 by 9, after Linnaeus', note: 'The older game. The king escapes to any edge square, and away from the throne two attackers take him like any piece.' }
],
default: 'copenhagen'
},
{ key: 'side', label: 'You play', choices: SIDES, default: 'defenders' }
],
create: createGame,
resolve: resolveRound,
needsInput: (state, seat) => !state.over && state.players[seat]?.side === state.toMove,
over: (state) => state.over,
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,
tally: (state) => {
const lines: Record<string, number> = {};
lines[state.set === 'tablut' ? 'played as Tablut' : 'played by Copenhagen rules'] = 1;
const reason = state.over?.reason ?? '';
if (/reaches/.test(reason)) lines["won by the king's escape"] = 1;
else if (/taken/.test(reason)) lines["won by taking the king"] = 1;
else if (/edge fort/.test(reason)) lines['won by an edge fort'] = 1;
else if (/sealed/.test(reason)) lines['won by encirclement'] = 1;
else if (/repetition|no move/.test(reason)) lines['decided by repetition or a stalemate'] = 1;
return lines;
}
};