A credibility pass over the whole repo: the board's mechanics in board.ts, one copy of each helper and stylesheet rule, the kit's plumbing this game never used removed, and the ops scripts counting traffic through one parser

The engine and the bot no longer import each other: geometry, movement
and captures live in src/lib/game/board.ts and both use it. The escape
test, the four directions, the king's neighbours and the enumeration of
a side's moves each exist once; the tally counts by the named end
sentences rather than by regex over them; exports nobody imports are
exports no more. The tests pin the bot's opening move and the three-
beside-the-throne capture they named but never exercised.

In the client: .small, the word-as-button, the × that dismisses, the
visually-hidden rule and the frame of the reading pages are in app.css
once; the preferences panel and the report slip share one modal shape;
the room store drops the simultaneous-round fields this game never read
and gains seatEmpty and seatUnheld, which the lobby and the join page
read instead of three spellings of their own. The wire shapes for
reports and the tally are declared in view.ts for both sides. The
artwork component is re-indented for the top level it lives at.

On the server and in deploy: the plaintext-token fallback and its
migration script guarded ledgers this game never wrote; the seat line
now requires the hash and the start line the rules revision. The route
table lists every route. The visitors digest and the nightly rollup
count Caddy's log through deploy/traffic.py, and the rollup writes the
finished-games count it had been computing behind "and False". The
reports digest is one program, deploy/report-digest.ts, that
pull-reports.sh and the desk skill both use. The deploy README, the
visitors skill and the reports skill no longer describe a browser-only
game, a /play route or a rules text in docs/; conventions.md carries
the kit's Preferences and tally sections.

Kit-shared files touched, to port back: server/src/{index,rooms,store,
tally,reports}.ts, deploy/{deploy.sh,replay-ledgers.ts,pull-reports.sh,
traffic.py,report-digest.ts,*-rollup.sh,*-visitors.sh,*-pulse.sh},
src/lib/net/{client.ts,room.svelte.ts,view.ts}, Preferences.svelte,
ReportSlip.svelte, TableTalk.svelte.

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:02:45 -04:00
co-authored by Claude Fable 5.1
parent 6d041e3a63
commit ee5690ebda
56 changed files with 1051 additions and 1099 deletions
+13 -38
View File
@@ -6,18 +6,16 @@
import { otherGames, type FamilyGame } from '$lib/family';
import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { unreadTalk } from '$lib/net/talk';
import type { RoomView } from '$lib/net/view';
import type { State } from '$lib/game';
import { game, createGame } from '$lib/game';
import type { Report, RoomView, Tally } from '$lib/net/view';
import { createGame, game, type State } from '$lib/game';
import BoardArtwork from './BoardArtwork.svelte';
let name = $state(playerName());
let code = $state('');
/** The host's choices for the next table, from the game's declared options. */
// A new table starts from the preferences' table defaults; the hall's selectors set the same keys.
/** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */
const options = $derived<Record<string, string>>(
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
);
@@ -85,7 +83,7 @@
}
}
// The desk is asked once per visit for every seat this browser holds.
// The desk is asked for every seat this browser holds, again whenever that set changes.
$effect(() => {
const held = seats;
if (!held.length) return;
@@ -166,8 +164,8 @@
<h1>Hnefatafl</h1>
<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 safety. Play a housecarl, 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> · <button type="button" class="link-like" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
<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> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
</div>
<div class="lid-play">
@@ -179,7 +177,7 @@
<div class="options">
{#each game.options as o (o.key)}
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
<div class="option-block">
<div>
<label class="option">
<span>{o.label}</span>
<select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}>
@@ -201,7 +199,7 @@
</form>
</div>
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
<form class="joinform claimform" onsubmit={claim}>
<form class="joinform" onsubmit={claim}>
<span class="or">or bring a seat from another device</span>
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
@@ -222,7 +220,7 @@
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a>
<button type="button" class="ledger-forget" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
</div>
{/each}
{#if reports.length}
@@ -384,10 +382,6 @@
text-align: center;
}
.small {
font-size: 0.85rem;
}
.ways {
display: flex;
flex-wrap: wrap;
@@ -425,7 +419,6 @@
border-radius: 6px;
}
/* the games ledger */
.ledger {
grid-area: ledger;
@@ -486,18 +479,6 @@
color: var(--frost);
}
.ledger-forget {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.1rem;
padding: 0 0.4rem;
}
.ledger-forget:hover {
color: var(--blood);
}
.reports-head {
margin-top: 0.9rem;
}
@@ -588,6 +569,7 @@
font-size: 0.85rem;
color: var(--bone-faint);
}
.family {
margin: 0.4rem 0 0.8rem 1.2rem;
padding: 0;
@@ -596,6 +578,7 @@
.family li + li {
margin-top: 0.3rem;
}
.options {
display: flex;
flex-wrap: wrap;
@@ -614,6 +597,7 @@
margin: 0.2rem 0 0;
max-width: 22em;
}
.still {
width: 100%;
max-width: 18rem;
@@ -637,13 +621,4 @@
.tally dd {
margin: 0;
}
.link-like {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
</style>