A new table plays Tablut unless the host chooses Copenhagen

The declared default on the rules option is now Tablut, listed first, and
the engine plays it when no rules are given, so the hall's still, the
preferences' table default and a bare createGame agree. Tests that read
Copenhagen positions ask for Copenhagen; the Tablut layout test pins the
default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
This commit is contained in:
Eric Wagoner
2026-09-23 20:28:59 -04:00
co-authored by Claude Fable 5.1
parent 32152c05f0
commit e95a090ac0
3 changed files with 15 additions and 13 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
# 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.
king must reach the edge. Tablut, the nine-by-nine game Linnaeus wrote down in
1732, by default, on a board that shows its age; Copenhagen rules on the
eleven-by-eleven board, where the king needs a corner, as an option.
Built on the game kit: `src/lib/game/` is the game (rules as data in
`rules.ts`, the board's mechanics in `board.ts`, the engine in `index.ts`,
+6 -6
View File
@@ -21,7 +21,7 @@ const E9 = '.........';
describe('the opening', () => {
it('lays Copenhagen out with 24 attackers, 12 defenders and the king on the throne', () => {
const s = createGame(names);
const s = createGame(names, 1, 1, { set: 'copenhagen', side: 'defenders' });
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);
@@ -29,8 +29,8 @@ describe('the opening', () => {
expect(s.toMove).toBe('attackers');
});
it('lays Tablut out on nine by nine with 16 attackers and 8 defenders', () => {
const s = createGame(names, 1, 1, { set: 'tablut', side: 'defenders' });
it('lays Tablut out on nine by nine with 16 attackers and 8 defenders, and plays it when no rules are chosen', () => {
const s = createGame(names);
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);
@@ -70,7 +70,7 @@ describe('moving', () => {
});
it('refuses the wrong side, a stranger, and an impossible square, with reasons', () => {
const s = createGame(names);
const s = createGame(names, 1, 1, { set: 'copenhagen', side: 'defenders' });
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/);
@@ -139,7 +139,7 @@ describe('the king', () => {
});
it('needs four attackers on the throne, and three beside it', () => {
const s = createGame(names);
const s = createGame(names, 1, 1, { set: 'copenhagen', side: 'defenders' });
const t = throneOf(11);
// Clear the defenders and ring the throne with three attackers; the fourth arrives.
const onThrone = s.cells.map((p) => (p === 'd' ? '.' : p)) as Piece[];
@@ -216,7 +216,7 @@ describe('the ends of the game', () => {
}, 120_000);
it('opens with the same move every time, so a ledger replays to the same choice', () => {
const s = createGame(names, 3);
const s = createGame(names, 3, 1, { set: 'copenhagen', side: 'defenders' });
expect(movesOf(rulesetOf(s), s.cells, 'attackers').length).toBe(116);
expect(chooseMove(s, 'B')).toEqual({ from: 3, to: 2 });
});
+6 -4
View File
@@ -13,6 +13,8 @@ import { chooseMove } from './bot';
export { at, beyond, cellOf, cornersOf, isEdge, pieceWord, sideOf, squareName, throneOf, type Input, type Piece, type Side } from './board';
const CURRENT_RULES = 1;
/** The rules a table plays when the host chooses none. */
const DEFAULT_SET: Ruleset['id'] = 'tablut';
export interface Player {
id: SeatId;
@@ -64,7 +66,7 @@ export function rulesetOf(state: State): Ruleset {
// --- 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 set = RULESETS[options?.set as Ruleset['id']] ?? RULESETS[DEFAULT_SET];
const size = set.size;
const cells: Piece[] = Array(size * size).fill('.');
for (const c of set.attackers) cells[at(size, c)] = 'a';
@@ -205,10 +207,10 @@ export const game: GameSpec<State, Input> = {
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.' }
{ 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.' },
{ 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.' }
],
default: 'copenhagen'
default: DEFAULT_SET
},
{ key: 'side', label: 'You play', choices: SIDES, default: 'defenders' }
],