// 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; toMove: Side; moves: Move[]; /** How often each position (cells and side to move) has stood, for the repetition rule. */ seen: Record; 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, _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 = {}; 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 { const squares = new Set([start]); const out = new Set(); 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(); 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): 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 = { 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; 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 = {}; 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; } };