diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9acb53 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# Hnefatafl + +The Viking board game, in the browser: the attackers must take the king, the +king must reach a corner. Copenhagen rules on the eleven-by-eleven board by +default; Tablut, the nine-by-nine game Linnaeus wrote down in 1732, as an +option, on a board that shows its age. + +Built on the game kit: `src/lib/game/` is the game (rules as data in +`rules.ts`, the engine in `index.ts`, a short alpha-beta bot in `bot.ts`), +`src/lib/components/Board.svelte` is the board, and everything else is the +kit's hall, tables, gallery, ledgers, reports desk and ops tools. +`docs/conventions.md` is the house style. + + npm install && (cd server && npm install) + npm run server # the game server on port 8789 + npm run dev -- --host + +Tests: `npm test`. Deploy: `deploy/deploy.sh ` after `deploy/README.md`. diff --git a/src/app.css b/src/app.css index c63ae91..848f4d2 100644 --- a/src/app.css +++ b/src/app.css @@ -1,15 +1,16 @@ /* Design tokens. Every game picks its own palette and typeface here; the components only ever name these tokens, so a new look is this block. */ :root { - --slate: #1c2830; - --slate-deep: #121a20; - --slate-raised: #24333d; - --bone: #e9e2d2; - --bone-dim: #98a4ab; - --bone-faint: #5d6b73; - --ember: #f0a53a; - --frost: #8cc8e0; - --blood: #e0655c; + /* Sea-dark iron, driftwood bone, a coal ember, and northern ice. */ + --slate: #1f2426; + --slate-deep: #14181a; + --slate-raised: #2a3134; + --bone: #e6dfcf; + --bone-dim: #9ea59e; + --bone-faint: #626b66; + --ember: #e09a3a; + --frost: #9cd0dc; + --blood: #d8655a; --rule: rgba(233, 226, 210, 0.14); --rule-strong: rgba(233, 226, 210, 0.32); --font: 'Iowan Old Style', Georgia, serif; diff --git a/src/app.html b/src/app.html index 7015427..7ee3cc1 100644 --- a/src/app.html +++ b/src/app.html @@ -6,21 +6,20 @@ Hnefatafl - + - - + - + - + %sveltekit.head% diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index 5d4e225..f25d479 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -1,88 +1,171 @@ -
+
- - - - - {#each g.seats as id (id)} - +
+ + + + + + + + + + + + + + + + {#each g.cells as piece, i (i)} + {@const c = cellOf(size, i)} + {@const x = PAD + c.x * CELL} + {@const y = PAD + c.y * CELL} + tap(i)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + tap(i); + } + }} + /> + {#if targets.includes(i)} + + {/if} {/each} -
- - - {#each g.rounds as round, i (i)} - - - {#each g.seats as id (id)} - - {/each} - - {/each} - {#if !g.over} - - - {#each g.seats as id (id)} - - {/each} - - {/if} - -
Round{g.players[id].name}{#if id === room.me} (you){/if}
{i + 1}{round.picks[id]}
{g.rounds.length + 1}{id === room.me && pick ? pick : '?'}
+ {#each g.cells as piece, i (i)} + {#if piece !== '.'} + {@const c = cellOf(size, i)} + {@const cx = PAD + c.x * CELL + CELL / 2} + {@const cy = PAD + c.y * CELL + CELL / 2} + + + + {#if piece === 'k'} + + {/if} + + {/if} + {/each} + + + {#if g.over}
-

{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}

+

{verdict}

{g.over.reason}

Back to the hall
{:else if room.spectating}

You watch from the Peanut Gallery

-

{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'is' : 'are'} still choosing.` : 'The round is being written.'}

+

{nameOfSide(g.toMove)}, {sideWord(g.toMove)}, to move. Turn {g.moves.length + 1}.

{:else}
-

Round {g.rounds.length + 1}: name a number

-

The highest number named by exactly one player scores. First to {TARGET}.

-
- {#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)} - - {/each} -
- +

{yourMove ? 'Your move' : `Waiting on ${nameOfSide(g.toMove)}`}

+

+ You play {sideWord(mySide!)}: {mySide === 'attackers' ? 'take the king.' : set.escape === 'corner' ? 'bring the king to a corner.' : 'bring the king to the edge.'} + {#if yourMove}Tap a piece, then a square.{/if} +

{#if room.error}

{room.error}

{/if}
{/if}
@@ -91,11 +174,42 @@ diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index 7884170..ca0d81d 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -149,8 +149,8 @@

Hnefatafl

-

One line that says what the game is.

-

Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.

+

The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.

+

Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach a corner. Play the bot, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.

@@ -190,8 +190,21 @@ {#if seats.length} @@ -243,8 +256,8 @@
about this game
-

Where the game comes from, who made the original and when, and how the keeper of this page first met it.

-

What this version keeps and what it changes. Where the rules text it follows lives (the rules page), and any borrowed art or text with its notice.

+

Hnefatafl is the tafl game of the Vikings, carried by them from Scandinavia to Britain, Ireland, Iceland and beyond, and played until chess arrived in the eleventh and twelfth centuries. No rules from that age survive; the game was reconstructed in modern times from a Sámi cousin, Tablut, whose rules the botanist Carl Linnaeus jotted down on his journey through Lapland in 1732.

+

This table plays Copenhagen, the modern tournament ruleset, on the eleven-by-eleven board, and Tablut on the nine-by-nine one, with the older board showing its age. The rules it follows are on the rules page. The game is in the public domain; the board and pieces here are drawn, not borrowed.

It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.

Send word

A rule read wrong, a bug, a game you would like to tell of: write to eric@ericwagoner.com, @kestrelsnest.social on Bluesky, or @eric@toots.kestrelsnest.social on Mastodon. The keeper of this hall roosts at kestrelsnest.social.

@@ -256,7 +269,7 @@ {/each} {/if} -

Hnefatafl is its designer's. This page was made by Eric and a very enthusiastic AI, 2026.

+

Hnefatafl belongs to everyone; it is a thousand years old. This page was made by Eric and a very enthusiastic AI, 2026.

@@ -389,11 +402,6 @@ border-radius: 6px; } - .caption { - font-size: 0.85rem; - color: var(--bone-dim); - text-align: center; - } /* the games ledger */ .ledger { @@ -578,4 +586,11 @@ gap: 0.5rem; font-size: 0.9rem; } + .still { + width: 100%; + max-width: 18rem; + display: block; + margin: 0 auto; + border-radius: 4px; + } diff --git a/src/lib/game/bot.ts b/src/lib/game/bot.ts new file mode 100644 index 0000000..e286f16 --- /dev/null +++ b/src/lib/game/bot.ts @@ -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 }; +} diff --git a/src/lib/game/index.test.ts b/src/lib/game/index.test.ts index 6adefe2..8f70114 100644 --- a/src/lib/game/index.test.ts +++ b/src/lib/game/index.test.ts @@ -1,36 +1,218 @@ import { describe, expect, it } from 'vitest'; -import { CURRENT_RULES, createGame, game, resolveRound } from './index'; +import { applyMove, at, cornersOf, createGame, game, legalMoves, movesFrom, throneOf, validateMove, type Piece, type State } from './index'; +import { chooseMove } from './bot'; -describe('High Card', () => { - it('scores the highest number named by exactly one seat', () => { - let s = createGame({ A: 'Ann', B: 'Bo', C: 'Cy' }, 1); - s = resolveRound(s, { A: { pick: 5 }, B: { pick: 5 }, C: { pick: 2 } }); - expect(s.rounds[0].scorer).toBe('C'); - expect(s.players.C.points).toBe(1); +const names = { A: 'Ann', B: 'Bo' }; + +/** A board from rows of text: `.` empty, `a` attacker, `d` defender, `k` king. Attackers to move unless said. */ +function board(rows: string[], toMove: 'attackers' | 'defenders' = 'attackers', set: 'copenhagen' | 'tablut' = 'copenhagen'): State { + const state = createGame(names, 1, 1, { set, side: 'defenders' }); + state.cells = rows.join('').split('') as Piece[]; + // A board without a king would end at once; put him on the throne unless the rows place him. + if (!state.cells.includes('k')) state.cells[throneOf(state.size)] = 'k'; + state.toMove = toMove; + state.seen = {}; + return state; +} + +const E11 = '...........'; +const E9 = '.........'; + +describe('the opening', () => { + it('lays Copenhagen out with 24 attackers, 12 defenders and the king on the throne', () => { + const s = createGame(names); + expect(s.size).toBe(11); + expect(s.cells.filter((p) => p === 'a').length).toBe(24); + expect(s.cells.filter((p) => p === 'd').length).toBe(12); + expect(s.cells[throneOf(11)]).toBe('k'); + expect(s.toMove).toBe('attackers'); }); - it('ends when a seat reaches the target', () => { - let s = createGame({ A: 'Ann', B: 'Bo' }, 1); - for (let i = 0; i < 3; i++) s = resolveRound(s, { A: { pick: 3 }, B: { pick: 1 } }); - expect(s.over?.winner).toBe('A'); - expect(game.needsInput(s, 'A')).toBe(false); + it('lays Tablut out on nine by nine with 16 attackers and 8 defenders', () => { + const s = createGame(names, 1, 1, { set: 'tablut', side: 'defenders' }); + expect(s.size).toBe(9); + expect(s.cells.filter((p) => p === 'a').length).toBe(16); + expect(s.cells.filter((p) => p === 'd').length).toBe(8); }); - it('draws the same bot picks from the same seed, and different ones as rounds pass', () => { - const a = createGame({ A: 'Ann', B: 'Bo' }, 42); - const b = createGame({ A: 'Ann', B: 'Bo' }, 42); - expect(game.botInput(a, 'B')).toEqual(game.botInput(b, 'B')); - const picks = new Set(); - let s = a; - for (let i = 0; i < 12; i++) { - picks.add(game.botInput(s, 'B').pick); - s = resolveRound(s, { A: { pick: 1 }, B: game.botInput(s, 'B') }); - } - expect(picks.size).toBeGreaterThan(1); - }); - - it('stamps the rules revision', () => { - expect(createGame({ A: 'Ann', B: 'Bo' }).rules).toBe(CURRENT_RULES); - expect(createGame({ A: 'Ann', B: 'Bo' }, 1, 1).rules).toBe(1); + it('seats the host on the side they chose', () => { + expect(createGame(names, 1, 1, { set: 'copenhagen', side: 'attackers' }).players.A.side).toBe('attackers'); + expect(createGame(names, 1, 1, { set: 'copenhagen', side: 'attackers' }).players.B.side).toBe('defenders'); + expect(createGame(names).players.A.side).toBe('defenders'); + }); +}); + +describe('moving', () => { + it('slides like a rook and stops at the first piece', () => { + const s = board([E11, '.a.........', ...Array(9).fill(E11)]); + const from = at(11, { x: 1, y: 1 }); + const targets = movesFrom(s, from); + expect(targets).toContain(at(11, { x: 1, y: 10 })); + expect(targets).toContain(at(11, { x: 10, y: 1 })); + expect(targets).not.toContain(at(11, { x: 0, y: 0 })); // a corner + expect(targets.length).toBe(9 + 9 + 1 + 1); + }); + + it('keeps every piece but the king off the corners and the throne, and lets pieces cross the empty throne in Copenhagen', () => { + const s = board([...Array(5).fill(E11), 'a..........', ...Array(3).fill(E11), '.........k.', E11]); + const from = at(11, { x: 0, y: 5 }); + const targets = movesFrom(s, from); + expect(targets).not.toContain(throneOf(11)); + expect(targets).toContain(at(11, { x: 10, y: 5 })); + }); + + it('does not let pieces cross the throne in Tablut', () => { + const s = board([...Array(4).fill(E9), 'a........', ...Array(4).fill(E9)], 'attackers', 'tablut'); + const targets = movesFrom(s, at(9, { x: 0, y: 4 })); + expect(targets).not.toContain(at(9, { x: 8, y: 4 })); + expect(targets).toContain(at(9, { x: 3, y: 4 })); + }); + + it('refuses the wrong side, a stranger, and an impossible square, with reasons', () => { + const s = createGame(names); + expect(validateMove(s, 'A', { from: at(11, { x: 5, y: 3 }), to: at(11, { x: 5, y: 2 }) })).toMatch(/not your side/); + expect(validateMove(s, 'B', { from: at(11, { x: 5, y: 3 }), to: at(11, { x: 5, y: 2 }) })).toMatch(/not one of your pieces/); + expect(validateMove(s, 'B', { from: at(11, { x: 3, y: 0 }), to: at(11, { x: 3, y: 5 }) })).toMatch(/cannot move there/); + expect(validateMove(s, 'B', { from: at(11, { x: 3, y: 0 }), to: at(11, { x: 3, y: 2 }) })).toBeNull(); + }); +}); + +describe('capturing', () => { + it('takes a piece sandwiched between two enemies, only by the moving piece', () => { + const s = board([E11, '.a.d.a.....', ...Array(9).fill(E11)]); + // The attacker at x=1 slides to x=2: the defender at x=3 is between it and the attacker at x=5? No: x=4 is empty. + let next = applyMove(s, at(11, { x: 1, y: 1 }), at(11, { x: 2, y: 1 })); + expect(next.cells[at(11, { x: 3, y: 1 })]).toBe('d'); + // The attacker at x=5 slides to x=4: now the defender is sandwiched. + next = applyMove(next, at(11, { x: 5, y: 1 }), at(11, { x: 4, y: 1 })); + expect(next.cells[at(11, { x: 3, y: 1 })]).toBe('.'); + expect(next.moves[1].captured).toEqual([at(11, { x: 3, y: 1 })]); + }); + + it('does not take a piece that moves between two enemies itself', () => { + const s = board([E11, '.a.a.......', E11, '..d........', ...Array(7).fill(E11)], 'defenders'); + const next = applyMove(s, at(11, { x: 2, y: 3 }), at(11, { x: 2, y: 1 })); + expect(next.cells[at(11, { x: 2, y: 1 })]).toBe('d'); + }); + + it('takes against a corner and against the empty throne', () => { + const corner = board(['.d.........', E11, '..a........', ...Array(8).fill(E11)]); + const next = applyMove(corner, at(11, { x: 2, y: 2 }), at(11, { x: 2, y: 0 })); + expect(next.cells[at(11, { x: 1, y: 0 })]).toBe('.'); + const throne = board([...Array(5).fill(E11), '..a.d......', ...Array(5).fill(E11)]); + throne.cells[throneOf(11)] = '.'; + throne.cells[at(11, { x: 9, y: 9 })] = 'k'; + const after = applyMove(throne, at(11, { x: 2, y: 5 }), at(11, { x: 3, y: 5 })); + expect(after.cells[at(11, { x: 4, y: 5 })]).toBe('.'); + }); + + it('lets the armed king close a capture', () => { + const s = board([E11, '.ka..d.....', ...Array(9).fill(E11)], 'defenders'); + const next = applyMove(s, at(11, { x: 5, y: 1 }), at(11, { x: 3, y: 1 })); + expect(next.cells[at(11, { x: 2, y: 1 })]).toBe('.'); + }); +}); + +describe('the king', () => { + it('wins Copenhagen on a corner and not on a plain edge square', () => { + const s = board(['..k........', ...Array(9).fill(E11), '..........a'], 'defenders'); + expect(applyMove(s, at(11, { x: 2, y: 0 }), at(11, { x: 1, y: 0 })).over).toBeNull(); + const won = applyMove(s, at(11, { x: 2, y: 0 }), at(11, { x: 0, y: 0 })); + expect(won.over).toEqual({ winner: 'A', reason: 'The king reaches a corner.' }); + }); + + it('wins Tablut on any edge square', () => { + const s = board([E9, '..k......', ...Array(6).fill(E9), '........a'], 'defenders', 'tablut'); + const won = applyMove(s, at(9, { x: 2, y: 1 }), at(9, { x: 2, y: 0 })); + expect(won.over?.reason).toBe('The king reaches the edge.'); + }); + + it('falls to four attackers in the open in Copenhagen, and to two in Tablut', () => { + const open = board([E11, E11, '...a.......', '..ak.a.....', '...a.......', ...Array(6).fill(E11)]); + expect(applyMove(open, at(11, { x: 5, y: 3 }), at(11, { x: 4, y: 3 })).over?.reason).toBe('The king is taken.'); + // Three attackers are not enough in the open. + const three = board([E11, E11, '...a.......', '..ak.a.....', ...Array(7).fill(E11)]); + expect(applyMove(three, at(11, { x: 5, y: 3 }), at(11, { x: 4, y: 3 })).over).toBeNull(); + const two = board([E9, E9, '..ak..a..', ...Array(6).fill(E9)], 'attackers', 'tablut'); + expect(applyMove(two, at(9, { x: 6, y: 2 }), at(9, { x: 4, y: 2 })).over?.reason).toBe('The king is taken.'); + }); + + it('needs four attackers on the throne, and three beside it', () => { + const s = createGame(names); + // Clear the defenders and ring the throne with three attackers; the fourth arrives. + const cells = s.cells.map((p) => (p === 'd' ? '.' : p)) as Piece[]; + const t = throneOf(11); + cells[t - 1] = 'a'; + cells[t + 1] = 'a'; + cells[t - 11] = 'a'; + cells[t + 22] = 'a'; + const state = { ...s, cells, seen: {} }; + const done = applyMove(state, t + 22, t + 11); + expect(done.over?.reason).toBe('The king is taken.'); + }); +}); + +describe('the ends of the game', () => { + it('gives the attackers the game when the defenders are sealed from every edge', () => { + const rows = [...Array(11).fill(E11)]; + rows[3] = '...aaaaa...'; + rows[4] = '...a...a...'; + rows[5] = '...a.k...a.'; + rows[6] = '...a...a...'; + rows[7] = '...aaaaa...'; + const s = board(rows); + // The ring closes with the last attacker's move to the gap at x=7. + const done = applyMove(s, at(11, { x: 9, y: 5 }), at(11, { x: 7, y: 5 })); + expect(done.over?.reason).toMatch(/sealed/); + }); + + it('gives the defenders the game for an edge fort with room to move', () => { + const rows = [...Array(11).fill(E11)]; + rows[0] = '...d.k.d...'; + rows[1] = '....d.d....'; + const s = board(rows, 'defenders'); + // The last defender closes the fort; the king can still step to x=4 or x=6. + s.cells[at(11, { x: 4, y: 1 })] = '.'; + s.cells[at(11, { x: 4, y: 3 })] = 'd'; + const done = applyMove(s, at(11, { x: 4, y: 3 }), at(11, { x: 4, y: 1 })); + expect(done.over?.reason).toMatch(/edge fort/); + }); + + it('forfeits the side that repeats a position a third time', () => { + let s = board([E11, '.a.........', E11, '..d........', ...Array(7).fill(E11)]); + const a1 = at(11, { x: 1, y: 1 }); + const a2 = at(11, { x: 2, y: 1 }); + const d1 = at(11, { x: 2, y: 3 }); + const d2 = at(11, { x: 3, y: 3 }); + // The position after the first move stands again at ply 5 and a third time at ply 9, + // when the attackers (Bo) are the side that brought it back. + const plies = [[a1, a2], [d1, d2], [a2, a1], [d2, d1]] as const; + for (let ply = 0; ply < 8; ply++) { + s = applyMove(s, plies[ply % 4][0], plies[ply % 4][1]); + expect(s.over).toBeNull(); + } + s = applyMove(s, a1, a2); + expect(s.over?.reason).toBe('Bo repeats the position a third time and forfeits.'); + expect(s.over?.winner).toBe('A'); + }); + + it('runs a whole game between two bots to a verdict on both boards', () => { + for (const set of ['tablut', 'copenhagen'] as const) { + let s = createGame(names, 7, 1, { set, side: 'defenders' }); + for (let ply = 0; ply < 400 && !s.over; ply++) { + const seat = s.players.A.side === s.toMove ? 'A' : 'B'; + const move = chooseMove(s, seat); + expect(validateMove(s, seat, move)).toBeNull(); + s = game.resolve(s, { [seat]: move }); + } + expect(s.over).not.toBeNull(); + } + }, 120_000); + + it('chooses the same move from the same position', () => { + const s = createGame(names, 3); + expect(chooseMove(s, 'B')).toEqual(chooseMove(s, 'B')); + expect(legalMoves(s).length).toBeGreaterThan(50); + expect(cornersOf(11)).toEqual([0, 10, 110, 120]); }); }); diff --git a/src/lib/game/index.ts b/src/lib/game/index.ts index 6485635..9876112 100644 --- a/src/lib/game/index.ts +++ b/src/lib/game/index.ts @@ -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; - 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; - rounds: Round[]; + toMove: Side; + moves: Move[]; + /** How often each position (cells and side to move) has stood, for the repetition rule. */ + seen: Record; 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, 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, _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 = {}; + 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): State { - const state = structuredClone(previous); - if (state.over) return state; - const picks: Record = {}; - for (const id of state.seats) picks[id] = inputs[id]?.pick ?? 1; - const counts = new Map(); - 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 { + 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: '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; + 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 }; diff --git a/src/lib/game/rules.ts b/src/lib/game/rules.ts new file mode 100644 index 0000000..06eadd3 --- /dev/null +++ b/src/lib/game/rules.ts @@ -0,0 +1,77 @@ +// The two rulesets this table plays, as data: the board, the opening +// layout, and the handful of points on which reconstructions of the game +// differ. Copenhagen is the modern tournament standard, balanced by play. +// Tablut is the only historically attested set, from Linnaeus's 1732 notes +// on the Sámi game, with the gaps in his notes filled the way most +// reconstructions fill them (see the rules page). + +export type Cell = { x: number; y: number }; + +export interface Ruleset { + id: 'copenhagen' | 'tablut'; + name: string; + size: number; + attackers: Cell[]; + defenders: Cell[]; + /** Where the king must reach: a corner square, or any edge square. */ + escape: 'corner' | 'edge'; + /** The corners are marked: only the king may enter them, and they capture like an enemy. */ + markedCorners: boolean; + /** Other pieces may cross the empty throne (never stop on it). */ + passThrone: boolean; + /** The king may take part in captures. */ + kingArmed: boolean; + /** Away from the throne the king falls to four attackers, or to two like any piece. */ + kingCapturedByFour: boolean; + /** A row of pieces along the edge, bracketed and faced, falls together. */ + shieldwall: boolean; + /** The king in an unbreakable fort on the edge, with room to move, wins. */ + edgeFort: boolean; + /** Defenders sealed away from every edge lose. */ + encirclement: boolean; +} + +function cells(spec: string): Cell[] { + // "3-7@0" is x 3..7 on row 0; "0@3" is x 0 on row 3. + return spec.split(' ').flatMap((part) => { + const [xs, y] = part.split('@'); + const [a, b] = xs.split('-').map(Number); + const out: Cell[] = []; + for (let x = a; x <= (b ?? a); x++) out.push({ x, y: Number(y) }); + return out; + }); +} + +export const COPENHAGEN: Ruleset = { + id: 'copenhagen', + name: 'Copenhagen', + size: 11, + attackers: cells('3-7@0 5@1 0@3 10@3 0@4 10@4 0-1@5 9-10@5 0@6 10@6 0@7 10@7 5@9 3-7@10'), + defenders: cells('5@3 4-6@4 3-4@5 6-7@5 4-6@6 5@7'), + escape: 'corner', + markedCorners: true, + passThrone: true, + kingArmed: true, + kingCapturedByFour: true, + shieldwall: true, + edgeFort: true, + encirclement: true +}; + +export const TABLUT: Ruleset = { + id: 'tablut', + name: 'Tablut', + size: 9, + attackers: cells('3-5@0 4@1 0@3 8@3 0-1@4 7-8@4 0@5 8@5 4@7 3-5@8'), + defenders: cells('4@2 4@3 2-3@4 5-6@4 4@5 4@6'), + escape: 'edge', + markedCorners: false, + passThrone: false, + kingArmed: true, + kingCapturedByFour: false, + shieldwall: false, + edgeFort: false, + encirclement: false +}; + +export const RULESETS: Record = { copenhagen: COPENHAGEN, tablut: TABLUT }; diff --git a/src/routes/guide/+page.svelte b/src/routes/guide/+page.svelte index 669b7f8..f948c73 100644 --- a/src/routes/guide/+page.svelte +++ b/src/routes/guide/+page.svelte @@ -1,8 +1,5 @@ @@ -11,44 +8,60 @@
- Back to the hall + Back to the hall · The rules

How to play

-

Two sentences that set the scene and say what this page walks through.

-

The hall

-

Write your name and choose your company. Play now against the bot seats you across from the house's construct. Open a table lays a table and hands you a link and a four-letter code to send. Join one takes a code someone sent you. Beneath, your games lists every table this browser holds a seat at, and whose move it is.

+

+ Give your name and choose the rules: Copenhagen, the balanced modern game on the big board, or Tablut, the smaller and older one. Choose which side you play. + Play now against the bot seats a bot opposite you and begins. Open a table gives you a four-letter code and a link for a friend. The tables this browser + holds a seat at are listed below, with whose move it is. +

The table

-

A table fills as players open the link. Whoever laid it is the host: they may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.

+

+ Whoever opened the table is its host, and begins the game once a second player has sat or a bot has been seated. A friend who opens the link takes the other seat. The + masthead's Transfer seat gives four words that bring your seat to another device, and when a game is over anyone at the table may call for a rematch. +

The board

-

What the player sees during play, section by section, with a figure for each.

+

+ Tap one of your pieces and its reachable squares light up; tap a square to move there. The last move is shaded, and a square a piece was just taken from shows red for a + moment. The attackers are the dark pieces and move first; the defenders are the pale pieces around the crowned king on the throne. In Copenhagen the four corners are marked: + only the king may enter them, and reaching one wins. In Tablut the board is older and plainer, and the king needs only the edge. +

A first game

-

The advice you would give a friend across the table for their first few turns.

-

The full rules are on the rules page. If a rule here reads wrong to you, or the page misbehaves, send word; the hall's about this game has the other ways to reach the keeper.

+

+ Play the defenders first. The king wants an open row or column to a corner; the attackers want to close every road while they close in. Pieces are taken by being sandwiched, + never by moving between two enemies yourself, so a piece can step into a gap safely. Keep the king off the edge in Copenhagen until a corner is in reach, and remember that the + attackers cannot take him against the edge: a king on the edge with his pieces around him is a fort. +

+ +

Something on this page behave unexpectedly? The Report button in a room's masthead tells the keeper, pinned to the moment.

diff --git a/src/routes/rules/+page.svelte b/src/routes/rules/+page.svelte index 5488474..e9e36fd 100644 --- a/src/routes/rules/+page.svelte +++ b/src/routes/rules/+page.svelte @@ -1,7 +1,6 @@ @@ -12,25 +11,100 @@
Back to the hall · How to play

The rules

-

One paragraph on where these rules come from and which edition or text this page follows.

+

+ Hnefatafl is the tafl family's best-known member: a Viking board game played across Scandinavia and the North Atlantic from the fourth century until chess displaced it. No + medieval rules survive. This table plays Copenhagen, the ruleset settled by the Copenhagen Hnefatafl Club in 2012 and used for tournament play since, on the + eleven-by-eleven board. It also offers Tablut, the nine-by-nine Sámi game whose rules Carl Linnaeus wrote down in his Lapland journal in 1732: the only version of + the game with a historical rules text, gaps and all. +

-
-

A turn

-

What each player does on a turn, in order, in short paragraphs. Put the thing a player checks mid-game first.

+
+

The two sides

+

+ The attackers begin in four groups on the edges and move first. The defenders begin around their king on the throne at the + centre. The attackers win by capturing the king. The defenders win when the king reaches a corner (Copenhagen) or any edge square (Tablut). +

+
+ +
+

Moving

+

+ Every piece moves like a rook in chess: any number of empty squares along a row or column, never diagonally, never over another piece. Only the king may stop on the throne + or on a corner square. In Copenhagen an ordinary piece may pass across the empty throne; in Tablut it may not. +

+
+ +
+

Capturing

+

+ A piece is captured when the moving piece closes it between itself and another enemy piece on the opposite side, along a row or column. A piece may safely move between two + enemies: only the mover captures. The four corners and the empty throne stand in for an enemy piece, so a piece pinned against one of them by a single enemy is taken. The + king takes part in captures like any piece. +

+

+ The king is harder to take. In Copenhagen he falls only when surrounded on all four sides by attackers, or on three sides with the empty throne as the + fourth; on the throne itself he needs all four. He cannot be captured against the edge of the board. In Tablut, away from the throne he is taken between two attackers like + any piece; on the throne he needs four, and beside it three with the throne as the fourth. +

+

+ Shieldwall (Copenhagen only): two or more pieces standing along the edge are captured together when the moving piece closes the row at one end, an enemy or a + corner closes the other, and each piece in the row has an enemy directly in front of it. The king standing in such a row is not taken, but the pieces beside him are. +

Winning

-

How the game ends and who wins, including draws.

+
    +
  • The king reaches a corner (Copenhagen) or the edge (Tablut): the defenders win.
  • +
  • The king is captured: the attackers win.
  • +
  • + Edge fort (Copenhagen): the king stands on the edge, walled in by his own pieces so that no attacker can reach him, and still has a move. The defenders + win. +
  • +
  • + Encirclement (Copenhagen): the attackers have closed a ring around every remaining defender and the king, so that none of them can ever reach an edge. The + attackers win. +
  • +
  • A side with no legal move loses.
  • +
  • A side that brings about the same position for the third time forfeits.
  • +
+
+ +
+

Tablut, after Linnaeus

+

+ Linnaeus's notes are in Latin, incomplete, and were mistranslated for two centuries; the confusion produced the edge-escape and corner-escape families of modern hnefatafl. + This table follows the reading now common among reconstructions: nine by nine; sixteen attackers, whom Linnaeus called the Muscovites, against eight defenders, the Swedes, + and their king; the king escapes by reaching any edge square; no ordinary piece may enter or cross the throne; the empty throne is hostile to both sides; the king is armed; + and away from the throne the king is captured by two attackers, like any piece. There are no shieldwalls, edge forts or encirclement in Tablut. +

The original text

-

The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.

+
+ Linnaeus, Iter Lapponicum, 1732 (English) +

+ Linnaeus described the board, the pieces and the moves in a few Latin sentences with a diagram, in the journal of his journey through Lapland. His notes name the two sides, + say that every piece moves in straight lines like the rook, that a piece is taken between two enemies, that the king is taken by four (or three and the throne) and wins by + reaching the edge, and that the king alone may stand on the central square. The passage is short enough that every modern Tablut is a reading of it rather than a copy. +

+

+ The text is in the public domain. A transcription of the Latin and the standard English translation belongs in docs/ beside this page, so that a rules question + can be settled by the words the engine follows. +

+
+
+ Copenhagen rules, 2012 +

+ Copenhagen is a modern ruleset, written and revised by its club, and this page follows it in the club's own terms: corner escape, an armed king captured by four, the + shieldwall, the edge fort, and encirclement. Where this page and the club's text differ, the club's text is right and this page has a bug. +

+
-

If this page reads a rule differently from the original text, that is a bug: send word.

+

If this page reads a rule differently from the text it follows, that is a bug: send word.

diff --git a/static/favicon.svg b/static/favicon.svg index fa91d51..a4e1461 100644 --- a/static/favicon.svg +++ b/static/favicon.svg @@ -1 +1 @@ - +