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
@@ -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 <ip>` after `deploy/README.md`.
|
||||
+10
-9
@@ -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;
|
||||
|
||||
+4
-5
@@ -6,21 +6,20 @@
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#121a20" />
|
||||
<title>Hnefatafl</title>
|
||||
<meta name="description" content="Hnefatafl: a game between friends, free in the browser." />
|
||||
<meta name="description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
|
||||
<link rel="canonical" href="https://hnefatafl.kestrelsnest.social/" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Hnefatafl" />
|
||||
<meta property="og:title" content="Hnefatafl" />
|
||||
<meta property="og:description" content="Hnefatafl: a game between friends, free in the browser." />
|
||||
<meta property="og:description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
|
||||
<meta property="og:url" content="https://hnefatafl.kestrelsnest.social/" />
|
||||
<meta property="og:image" content="https://hnefatafl.kestrelsnest.social/og.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Hnefatafl" />
|
||||
<meta name="twitter:description" content="Hnefatafl: a game between friends, free in the browser." />
|
||||
<meta name="twitter:description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
|
||||
<meta name="twitter:image" content="https://hnefatafl.kestrelsnest.social/og.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
+278
-106
@@ -1,88 +1,171 @@
|
||||
<script lang="ts">
|
||||
// The demo game's board. A real game replaces this file entirely; the
|
||||
// masthead, lobby, talk, gallery and reports around it stay.
|
||||
// The board: a grid of squares, the pieces on them, and two taps to
|
||||
// move. Copenhagen is fresh oak and clean stone; Tablut, the older game,
|
||||
// is grey, bleached and a little uneven, by the tokens under data-set
|
||||
// and a heavier grain and warp in the filters.
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import { HIGHEST, TARGET } from '$lib/game';
|
||||
import { cellOf, cornersOf, isEdge, movesFrom, rulesetOf, sideOf, throneOf, type Side } from '$lib/game';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
|
||||
let { room }: { room: Room } = $props();
|
||||
|
||||
const g = $derived(room.state!);
|
||||
let pick = $state(0);
|
||||
const set = $derived(rulesetOf(g));
|
||||
const size = $derived(g.size);
|
||||
const worn = $derived(set.id === 'tablut');
|
||||
const throne = $derived(throneOf(size));
|
||||
const corners = $derived(set.markedCorners ? cornersOf(size) : []);
|
||||
const mySide = $derived<Side | null>(room.spectating ? null : (g.players[room.me]?.side ?? null));
|
||||
const yourMove = $derived(!!mySide && mySide === g.toMove && !g.over);
|
||||
const last = $derived(g.moves[g.moves.length - 1] ?? null);
|
||||
const lastSquares = $derived(last ? [last.from, last.to] : []);
|
||||
const captured = $derived(last?.captured ?? []);
|
||||
const nameOfSide = (side: Side) => {
|
||||
const seat = g.seats.find((id) => g.players[id].side === side);
|
||||
return seat ? g.players[seat].name : side;
|
||||
};
|
||||
/** Linnaeus called the sides the Swedes and the Muscovites; Copenhagen calls them attackers and defenders. */
|
||||
const sideWord = (side: Side) => (worn ? (side === 'attackers' ? 'the Muscovites' : 'the Swedes') : side === 'attackers' ? 'the attackers' : 'the defenders');
|
||||
|
||||
async function play() {
|
||||
if (!pick) return;
|
||||
if (await room.submit({ pick })) pick = 0;
|
||||
let selected = $state<number | null>(null);
|
||||
const targets = $derived(selected === null ? [] : movesFrom(g, selected));
|
||||
|
||||
// A new position clears any half-made move.
|
||||
$effect(() => {
|
||||
void g.moves.length;
|
||||
selected = null;
|
||||
});
|
||||
|
||||
async function tap(i: number) {
|
||||
if (!yourMove || room.sending) return;
|
||||
if (selected !== null && targets.includes(i)) {
|
||||
const from = selected;
|
||||
selected = null;
|
||||
await room.submit({ from, to: i });
|
||||
return;
|
||||
}
|
||||
if (sideOf(g.cells[i]) === mySide) selected = selected === i ? null : i;
|
||||
else selected = null;
|
||||
}
|
||||
|
||||
const CELL = 40;
|
||||
const PAD = 6;
|
||||
const span = $derived(size * CELL + PAD * 2);
|
||||
const label = (i: number) => {
|
||||
const c = cellOf(size, i);
|
||||
return `${String.fromCharCode(97 + c.x)}${size - c.y}`;
|
||||
};
|
||||
const pieceWord = (p: string) => (p === 'k' ? 'the king' : p === 'a' ? 'an attacker' : 'a defender');
|
||||
const verdict = $derived.by(() => {
|
||||
if (!g.over) return '';
|
||||
if (g.over.winner === null) return 'A draw.';
|
||||
return g.over.winner === room.me ? 'You win.' : `${g.players[g.over.winner].name} wins.`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="board">
|
||||
<main class="board" data-set={set.id}>
|
||||
<section class="play">
|
||||
<table class="rounds">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="turn"><span class="visually-hidden">Round</span></th>
|
||||
{#each g.seats as id (id)}
|
||||
<th class:you={id === room.me}>{g.players[id].name}{#if id === room.me} (you){/if}</th>
|
||||
<div class="frame">
|
||||
<svg viewBox="0 0 {span} {span}" role="grid" aria-label="The board">
|
||||
<defs>
|
||||
<filter id="grain" x="0" y="0" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" result="noise" />
|
||||
<feColorMatrix in="noise" type="saturate" values="0" result="grey" />
|
||||
<feComponentTransfer in="grey" result="soft"><feFuncA type="linear" slope={worn ? 0.42 : 0.14} /></feComponentTransfer>
|
||||
<feBlend in="SourceGraphic" in2="soft" mode="multiply" />
|
||||
</filter>
|
||||
<filter id="worn" x="-5%" y="-5%" width="110%" height="110%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.07" numOctaves="2" seed="3" result="warp" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="warp" scale={worn ? 2.4 : 0} xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect class="wood" x="0" y="0" width={span} height={span} rx="6" filter="url(#grain)" />
|
||||
<g filter="url(#worn)">
|
||||
{#each g.cells as piece, i (i)}
|
||||
{@const c = cellOf(size, i)}
|
||||
{@const x = PAD + c.x * CELL}
|
||||
{@const y = PAD + c.y * CELL}
|
||||
<rect
|
||||
class="square"
|
||||
class:throne={i === throne}
|
||||
class:corner={corners.includes(i)}
|
||||
class:last={lastSquares.includes(i)}
|
||||
class:target={targets.includes(i)}
|
||||
class:fallen={captured.includes(i)}
|
||||
class:edge={set.escape === 'edge' && isEdge(size, i)}
|
||||
{x}
|
||||
{y}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
role="gridcell"
|
||||
aria-label={`${label(i)}${piece === '.' ? '' : `, ${pieceWord(piece)}`}`}
|
||||
tabindex={yourMove ? 0 : -1}
|
||||
onclick={() => tap(i)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
tap(i);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if targets.includes(i)}
|
||||
<circle class="hint" cx={x + CELL / 2} cy={y + CELL / 2} r={CELL * 0.14} pointer-events="none" />
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each g.rounds as round, i (i)}
|
||||
<tr>
|
||||
<td class="turn">{i + 1}</td>
|
||||
{#each g.seats as id (id)}
|
||||
<td class:scored={round.scorer === id}>{round.picks[id]}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{#if !g.over}
|
||||
<tr class="draft">
|
||||
<td class="turn">{g.rounds.length + 1}</td>
|
||||
{#each g.seats as id (id)}
|
||||
<td class="muted">{id === room.me && pick ? pick : '?'}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{#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}
|
||||
<g class="piece {piece === 'a' ? 'attacker' : piece === 'k' ? 'king' : 'defender'}" class:selected={selected === i} pointer-events="none">
|
||||
<ellipse class="shadow" cx={cx + 1.5} cy={cy + 2.5} rx={CELL * 0.34} ry={CELL * 0.3} />
|
||||
<circle class="body" {cx} {cy} r={CELL * 0.33} />
|
||||
{#if piece === 'k'}
|
||||
<path class="crown" d="M{cx - 9} {cy + 4} l0 -9 l4.5 5 l4.5 -8 l4.5 8 l4.5 -5 l0 9 z" />
|
||||
{/if}
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{#if g.over}
|
||||
<div class="verdict">
|
||||
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
|
||||
<h2>{verdict}</h2>
|
||||
<p>{g.over.reason}</p>
|
||||
<a class="commit" href="/">Back to the hall</a>
|
||||
</div>
|
||||
{:else if room.spectating}
|
||||
<section class="move">
|
||||
<h2>You watch from the Peanut Gallery</h2>
|
||||
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'is' : 'are'} still choosing.` : 'The round is being written.'}</p>
|
||||
<p class="muted">{nameOfSide(g.toMove)}, {sideWord(g.toMove)}, to move. Turn {g.moves.length + 1}.</p>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="move">
|
||||
<h2>Round {g.rounds.length + 1}: name a number</h2>
|
||||
<p class="muted">The highest number named by exactly one player scores. First to {TARGET}.</p>
|
||||
<div class="picks">
|
||||
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
|
||||
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="commit" disabled={!pick || room.moved || room.sending} onclick={play}>
|
||||
{room.moved ? `Waiting on ${room.awaitingText}` : room.sending ? 'Sending' : 'Commit'}
|
||||
</button>
|
||||
<h2>{yourMove ? 'Your move' : `Waiting on ${nameOfSide(g.toMove)}`}</h2>
|
||||
<p class="muted">
|
||||
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}
|
||||
</p>
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="rail">
|
||||
<section class="standings">
|
||||
<h2>Standings</h2>
|
||||
<section class="sides">
|
||||
<h2>{set.name}</h2>
|
||||
<ul>
|
||||
{#each g.seats as id (id)}
|
||||
<li><span>{g.players[id].name}</span><span>{g.players[id].points} / {TARGET}</span></li>
|
||||
<li class:to-move={g.players[id].side === g.toMove && !g.over}>
|
||||
<span class="dot {g.players[id].side}"></span>
|
||||
<span>{g.players[id].name}{id === room.me ? ' (you)' : ''}</span>
|
||||
<span class="muted">{sideWord(g.players[id].side)}, {g.cells.filter((p) => sideOf(p) === g.players[id].side).length} pieces</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="muted small">Turn {g.moves.length + 1}.{#if last} Last: {label(last.from)} to {label(last.to)}{#if last.captured.length}, taking {last.captured.length}{/if}.{/if}</p>
|
||||
</section>
|
||||
<TableTalk {room} />
|
||||
</aside>
|
||||
@@ -91,11 +174,42 @@
|
||||
<style>
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
|
||||
padding: 0 var(--gutter);
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
/* Copenhagen: fresh oak, clean stone. */
|
||||
--wood: #8a5a2b;
|
||||
--wood-line: rgba(30, 16, 6, 0.55);
|
||||
--square: rgba(255, 240, 210, 0.08);
|
||||
--square-edge: rgba(255, 250, 235, 0.11);
|
||||
--throne: rgba(240, 165, 58, 0.35);
|
||||
--corner: rgba(224, 101, 92, 0.35);
|
||||
--attacker: #2a2320;
|
||||
--attacker-rim: #0d0a08;
|
||||
--defender: #ece4d0;
|
||||
--defender-rim: #b9ab8b;
|
||||
--king: #f0d58a;
|
||||
--king-rim: #a67c1a;
|
||||
--crown: #6b4a0f;
|
||||
}
|
||||
|
||||
/* Tablut: the older game, on a board that has been in a barn. */
|
||||
.board[data-set='tablut'] {
|
||||
--wood: #8d8577;
|
||||
--wood-line: rgba(40, 34, 28, 0.4);
|
||||
--square: rgba(255, 250, 235, 0.05);
|
||||
--square-edge: rgba(255, 250, 235, 0.09);
|
||||
--throne: rgba(120, 100, 70, 0.4);
|
||||
--corner: transparent;
|
||||
--attacker: #3d3530;
|
||||
--attacker-rim: #1c1714;
|
||||
--defender: #cfc4ad;
|
||||
--defender-rim: #8c8069;
|
||||
--king: #c9b27a;
|
||||
--king-rim: #7d6636;
|
||||
--crown: #5a4a2a;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
@@ -104,99 +218,157 @@
|
||||
}
|
||||
}
|
||||
|
||||
.rounds {
|
||||
.frame {
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 1.3rem;
|
||||
text-align: center;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rounds th {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
.wood {
|
||||
fill: var(--wood);
|
||||
transition: fill 1.2s ease;
|
||||
}
|
||||
|
||||
.rounds th.you {
|
||||
color: var(--frost);
|
||||
.square {
|
||||
fill: var(--square);
|
||||
stroke: var(--wood-line);
|
||||
stroke-width: 1;
|
||||
cursor: default;
|
||||
transition: fill 0.3s ease;
|
||||
}
|
||||
|
||||
.rounds td {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
.square.edge {
|
||||
fill: var(--square-edge);
|
||||
}
|
||||
|
||||
.rounds .turn {
|
||||
width: 2.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bone-faint);
|
||||
.square.throne {
|
||||
fill: var(--throne);
|
||||
}
|
||||
|
||||
.rounds td.scored {
|
||||
color: var(--ember);
|
||||
font-weight: 600;
|
||||
.square.corner {
|
||||
fill: var(--corner);
|
||||
}
|
||||
|
||||
.rounds tr.draft td {
|
||||
background: var(--slate);
|
||||
.square.last {
|
||||
fill: rgba(140, 200, 224, 0.28);
|
||||
}
|
||||
|
||||
.square.target {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.square.fallen {
|
||||
fill: rgba(224, 101, 92, 0.45);
|
||||
}
|
||||
|
||||
.square:focus-visible {
|
||||
outline: none;
|
||||
stroke: var(--frost);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.hint {
|
||||
fill: var(--frost);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.piece .shadow {
|
||||
fill: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.piece .body {
|
||||
stroke-width: 2;
|
||||
transition: fill 1.2s ease, stroke 1.2s ease;
|
||||
}
|
||||
|
||||
.piece.attacker .body {
|
||||
fill: var(--attacker);
|
||||
stroke: var(--attacker-rim);
|
||||
}
|
||||
|
||||
.piece.defender .body {
|
||||
fill: var(--defender);
|
||||
stroke: var(--defender-rim);
|
||||
}
|
||||
|
||||
.piece.king .body {
|
||||
fill: var(--king);
|
||||
stroke: var(--king-rim);
|
||||
}
|
||||
|
||||
.piece .crown {
|
||||
fill: var(--crown);
|
||||
}
|
||||
|
||||
.piece.selected .body {
|
||||
stroke: var(--frost);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.move,
|
||||
.verdict {
|
||||
margin-top: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.move h2,
|
||||
.verdict h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.picks {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.pick {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.3rem;
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.pick.chosen {
|
||||
border-color: var(--ember);
|
||||
color: var(--ember);
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.verdict .commit {
|
||||
display: inline-block;
|
||||
margin-top: 0.6rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.standings h2 {
|
||||
.sides h2 {
|
||||
font-size: 1.1rem;
|
||||
font-style: italic;
|
||||
margin: 0.75rem 0 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.standings ul {
|
||||
.sides ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.standings li {
|
||||
.sides li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sides li.to-move {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.attackers {
|
||||
background: #2a2320;
|
||||
border: 1px solid #6b6058;
|
||||
}
|
||||
|
||||
.dot.defenders {
|
||||
background: #ece4d0;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -149,8 +149,8 @@
|
||||
<section class="lid">
|
||||
<div class="lid-head">
|
||||
<h1>Hnefatafl</h1>
|
||||
<p class="pitch">One line that says what the game is.</p>
|
||||
<p class="tag">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.</p>
|
||||
<p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p>
|
||||
<p class="tag">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.</p>
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a></p>
|
||||
</div>
|
||||
|
||||
@@ -190,8 +190,21 @@
|
||||
</div>
|
||||
|
||||
<div class="lid-look" aria-hidden="true">
|
||||
<!-- A still of the game at its most characteristic: a few rounds of the ledger, a board mid-play. -->
|
||||
<p class="caption">A picture of the game goes here.</p>
|
||||
<svg class="still" viewBox="0 0 110 110" aria-hidden="true">
|
||||
<rect x="0" y="0" width="110" height="110" rx="4" fill="#8a5a2b" />
|
||||
{#each Array.from({ length: 11 }, (_, r) => r) as r (r)}
|
||||
{#each Array.from({ length: 11 }, (_, c) => c) as c (c)}
|
||||
<rect x={c * 10} y={r * 10} width="10" height="10" fill={(r === 0 || r === 10) && (c === 0 || c === 10) ? 'rgba(224,101,92,0.4)' : r === 5 && c === 5 ? 'rgba(240,165,58,0.4)' : 'rgba(255,240,210,0.08)'} stroke="rgba(30,16,6,0.5)" stroke-width="0.4" />
|
||||
{/each}
|
||||
{/each}
|
||||
{#each [[3,0],[4,0],[5,0],[6,0],[7,0],[5,1],[0,3],[10,3],[0,4],[10,4],[0,5],[1,5],[9,5],[10,5],[0,6],[10,6],[0,7],[10,7],[5,9],[3,10],[4,10],[5,10],[6,10],[7,10]] as [x, y] (`${x},${y}`)}
|
||||
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill="#2a2320" stroke="#0d0a08" stroke-width="0.6" />
|
||||
{/each}
|
||||
{#each [[5,3],[4,4],[5,4],[6,4],[3,5],[4,5],[6,5],[7,5],[4,6],[5,6],[6,6],[5,7]] as [x, y] (`${x},${y}`)}
|
||||
<circle cx={x * 10 + 5} cy={y * 10 + 5} r="3.4" fill="#ece4d0" stroke="#b9ab8b" stroke-width="0.6" />
|
||||
{/each}
|
||||
<circle cx="55" cy="55" r="3.6" fill="#f0d58a" stroke="#a67c1a" stroke-width="0.6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{#if seats.length}
|
||||
@@ -243,8 +256,8 @@
|
||||
<details class="about" id="about" bind:open={aboutOpen}>
|
||||
<summary>about this game</summary>
|
||||
<div class="about-body">
|
||||
<p>Where the game comes from, who made the original and when, and how the keeper of this page first met it.</p>
|
||||
<p>What this version keeps and what it changes. Where the rules text it follows lives (the <a href="/rules">rules page</a>), and any borrowed art or text with its notice.</p>
|
||||
<p>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.</p>
|
||||
<p>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 <a href="/rules">rules page</a>. The game is in the public domain; the board and pieces here are drawn, not borrowed.</p>
|
||||
<p>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.</p>
|
||||
<h3>Send word</h3>
|
||||
<p>A rule read wrong, a bug, a game you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
@@ -256,7 +269,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="made">Hnefatafl is its designer's. This page was made by Eric and a very enthusiastic AI, 2026.</p>
|
||||
<p class="made">Hnefatafl belongs to everyone; it is a thousand years old. This page was made by Eric and a very enthusiastic AI, 2026.</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+210
-28
@@ -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<number>();
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
+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
|
||||
};
|
||||
|
||||
@@ -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<Ruleset['id'], Ruleset> = { copenhagen: COPENHAGEN, tablut: TABLUT };
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
// How to play: a walk through the page in the game's own voice, one
|
||||
// screenshot per section, from the hall to a first game. Capture the
|
||||
// figures from the running game into static/guide/ and describe what a
|
||||
// new player is looking at, not what the code does.
|
||||
// How to play: a walk through the page, from the hall to a first game.
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -11,44 +8,60 @@
|
||||
|
||||
<main class="guide">
|
||||
<header>
|
||||
<a class="back" href="/">Back to the hall</a>
|
||||
<a class="back" href="/">Back to the hall</a> · <a class="back" href="/rules">The rules</a>
|
||||
<h1>How to play</h1>
|
||||
<p class="lede">Two sentences that set the scene and say what this page walks through.</p>
|
||||
<nav aria-label="Sections">
|
||||
<a href="#hall">The hall</a>
|
||||
<a href="#table">The table</a>
|
||||
<a href="#board">The board</a>
|
||||
<a href="#gallery">The Peanut Gallery and table talk</a>
|
||||
<p class="lede">A walk through the page, from the hall to a first game. The game's own rules are on <a href="/rules">the rules page</a>.</p>
|
||||
<nav>
|
||||
<a href="#hall">The hall</a> · <a href="#table">The table</a> · <a href="#board">The board</a> · <a href="#gallery">The Peanut Gallery and table talk</a> ·
|
||||
<a href="#first">A first game</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section id="hall">
|
||||
<h2>The hall</h2>
|
||||
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from the house's construct. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you. Beneath, <em>your games</em> lists every table this browser holds a seat at, and whose move it is.</p>
|
||||
<p>
|
||||
Give your name and choose the rules: <em>Copenhagen</em>, the balanced modern game on the big board, or <em>Tablut</em>, the smaller and older one. Choose which side you play.
|
||||
<em>Play now against the bot</em> seats a bot opposite you and begins. <em>Open a table</em> 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="table">
|
||||
<h2>The table</h2>
|
||||
<p>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.</p>
|
||||
<p>
|
||||
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 <em>Transfer seat</em> 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="board">
|
||||
<h2>The board</h2>
|
||||
<p>What the player sees during play, section by section, with a figure for each.</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="gallery">
|
||||
<h2>The Peanut Gallery and table talk</h2>
|
||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk; then a watcher signs a name and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
|
||||
<p>A seat can follow you to another device: <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. When a game ends, anyone at the table may call for a rematch: a new table with the same company, seats held until everyone is back. And while a table is laid, you may challenge the keeper of this site to a seat; the note beside the button says what hour it is where they live.</p>
|
||||
<p>
|
||||
A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery.
|
||||
<em>Table talk</em> is for the players seated, unless the host lets the gallery talk; then a watcher signs a name and their lines are marked as the gallery's. Nothing in
|
||||
hnefatafl is hidden, so the gallery sees the whole board.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="first">
|
||||
<h2>A first game</h2>
|
||||
<p>The advice you would give a friend across the table for their first few turns.</p>
|
||||
<p>The full rules are on the <a href="/rules">rules page</a>. If a rule here reads wrong to you, or the page misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>; the hall's <em>about this game</em> has the other ways to reach the keeper.</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="word">Something on this page behave unexpectedly? The <em>Report</em> button in a room's masthead tells the keeper, pinned to the moment.</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -58,45 +71,36 @@
|
||||
padding: 1.5rem var(--gutter) 4rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 300;
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
margin: 0.6rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--bone-dim);
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 1rem;
|
||||
margin: 1rem 0 0.5rem;
|
||||
margin-top: 0.8rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 1.25rem 0 0.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
margin-top: 1rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.7rem;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-top: 0.7rem;
|
||||
.word {
|
||||
margin-top: 2rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
</style>
|
||||
|
||||
+119
-25
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
// The rules page: the game's own text, structured for reading mid-game.
|
||||
// Keep the original rules text verbatim in docs/ and render from it
|
||||
// where you can, so the page and the engine follow one source.
|
||||
// The rules page: Copenhagen as this table plays it, then Tablut and the
|
||||
// gaps in Linnaeus that every reconstruction fills for itself.
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -12,25 +11,100 @@
|
||||
<header>
|
||||
<a class="back" href="/">Back to the hall</a> · <a class="back" href="/guide">How to play</a>
|
||||
<h1>The rules</h1>
|
||||
<p class="lede">One paragraph on where these rules come from and which edition or text this page follows.</p>
|
||||
<p class="lede">
|
||||
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 <em>Copenhagen</em>, 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 <em>Tablut</em>, 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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="turn">
|
||||
<h2>A turn</h2>
|
||||
<p>What each player does on a turn, in order, in short paragraphs. Put the thing a player checks mid-game first.</p>
|
||||
<section id="sides">
|
||||
<h2>The two sides</h2>
|
||||
<p>
|
||||
The <strong>attackers</strong> begin in four groups on the edges and move first. The <strong>defenders</strong> begin around their <strong>king</strong> 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).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="moving">
|
||||
<h2>Moving</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="capturing">
|
||||
<h2>Capturing</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The king</strong> 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.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Shieldwall</strong> (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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="winning">
|
||||
<h2>Winning</h2>
|
||||
<p>How the game ends and who wins, including draws.</p>
|
||||
<ul>
|
||||
<li>The king reaches a corner (Copenhagen) or the edge (Tablut): the defenders win.</li>
|
||||
<li>The king is captured: the attackers win.</li>
|
||||
<li>
|
||||
<strong>Edge fort</strong> (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.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Encirclement</strong> (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.
|
||||
</li>
|
||||
<li>A side with no legal move loses.</li>
|
||||
<li>A side that brings about the same position for the third time forfeits.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="tablut">
|
||||
<h2>Tablut, after Linnaeus</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="source">
|
||||
<h2>The original text</h2>
|
||||
<p>The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.</p>
|
||||
<details>
|
||||
<summary>Linnaeus, <em>Iter Lapponicum</em>, 1732 (English)</summary>
|
||||
<p class="muted">
|
||||
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.
|
||||
</p>
|
||||
<p class="muted">
|
||||
The text is in the public domain. A transcription of the Latin and the standard English translation belongs in <code>docs/</code> beside this page, so that a rules question
|
||||
can be settled by the words the engine follows.
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Copenhagen rules, 2012</summary>
|
||||
<p class="muted">
|
||||
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.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<p class="word">If this page reads a rule differently from the original text, that is a bug: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
|
||||
<p class="word">If this page reads a rule differently from the text it follows, that is a bug: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -40,39 +114,59 @@
|
||||
padding: 1.5rem var(--gutter) 4rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 300;
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
margin: 0.6rem 0 0.4rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--bone-dim);
|
||||
max-width: 40em;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 1.25rem 0 0.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
margin-top: 1rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.7rem;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
details {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details p {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.word {
|
||||
margin-top: 2rem;
|
||||
color: var(--bone-faint);
|
||||
font-size: 0.9rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#121a20"/><circle cx="16" cy="16" r="8" fill="none" stroke="#e9e2d2" stroke-width="2.5"/><circle cx="16" cy="16" r="2.5" fill="#f0a53a"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#8a5a2b"/><circle cx="16" cy="17" r="9" fill="#f0d58a" stroke="#a67c1a" stroke-width="1.5"/><path d="M9 20v-8l3.5 4 3.5-6 3.5 6 3.5-4v8z" fill="#6b4a0f"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 263 B |
Reference in New Issue
Block a user