The credibility pass on Hnefatafl, ported to the template: the ops scripts count traffic through one parser, the reports digest is one program, the plaintext-token fallback and its migration are gone, and the stylesheet holds each shared rule once

From the hnefatafl repo's pass of the same day, everything that touched a
kit-shared file. The visitors digest and the nightly rollup share
deploy/traffic.py, installed to /usr/local/lib/<slug>; the rollup writes
the finished-games count it computed behind "and False". pull-reports.sh
and the reports skill both use deploy/report-digest.ts. The seat line
requires its token hash and the start line its rules revision; the
migration for ledgers written before hashing goes with them. The route
table in server/src/index.ts lists every route; Report, ReportLine and
Tally are declared once in view.ts for both sides; exports nobody
imported are exports no more.

In the client: .small, the × that dismisses, and the frame of the
reading pages are in app.css once; the preferences panel shares the
report slip's modal shape; the room store gains seatEmpty and
seatUnheld, and the lobby and the join page read those instead of
three spellings of their own. The room's moved and awaiting fields
stay: the demo board reads them, and simultaneous rounds are the
contract.

The deploy README no longer describes a browser-only game; the visitors
skill no longer names a /play route; the reports skill's replay call
carries the room's options. The Slack channel id is passed in the
environment rather than filled in as a placeholder.

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:12:37 -04:00
co-authored by Claude Fable 5.1
parent 6c42e55f59
commit 33ac0ec808
41 changed files with 421 additions and 583 deletions
+12 -35
View File
@@ -6,17 +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 { Report, RoomView, Tally } from '$lib/net/view';
import type { State } from '$lib/game';
import { game } from '$lib/game';
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]))
);
@@ -82,7 +81,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;
@@ -163,8 +162,8 @@
<h1>__NAME__</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="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">
@@ -176,7 +175,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)}>
@@ -198,7 +197,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>
@@ -220,7 +219,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}
@@ -382,10 +381,6 @@
text-align: center;
}
.small {
font-size: 0.85rem;
}
.ways {
display: flex;
flex-wrap: wrap;
@@ -489,18 +484,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;
}
@@ -591,6 +574,7 @@
font-size: 0.85rem;
color: var(--bone-faint);
}
.family {
margin: 0.4rem 0 0.8rem 1.2rem;
padding: 0;
@@ -599,6 +583,7 @@
.family li + li {
margin-top: 0.3rem;
}
.options {
display: flex;
flex-wrap: wrap;
@@ -617,6 +602,7 @@
margin: 0.2rem 0 0;
max-width: 22em;
}
.tally {
display: grid;
grid-template-columns: max-content 1fr;
@@ -632,13 +618,4 @@
.tally dd {
margin: 0;
}
.link-like {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
</style>
+8 -37
View File
@@ -2,15 +2,11 @@
import TableTalk from './TableTalk.svelte';
import type { Room } from '$lib/net/room.svelte';
let { room, link }: { room: Room; link: string } = $props();
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
const v = $derived(room.view);
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
const seatFree = $derived(v.seats.length < v.size);
/** A seat is free for a newcomer once the held ones are counted. */
const seatOpen = $derived(v.seats.length + v.expected.length < v.size);
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
let copied = $state(false);
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
let minute = $state(0);
@@ -31,22 +27,12 @@
return '';
}
});
async function copyLink() {
try {
await navigator.clipboard.writeText(link);
copied = true;
setTimeout(() => (copied = false), 1600);
} catch {
// The link is on screen to copy by hand.
}
}
</script>
<section class="lobby">
{#if room.spectating}
<h2>At the table</h2>
{#if seatFree}
{#if room.seatEmpty}
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
@@ -62,7 +48,7 @@
<li>
<span class="seat-name">{s.name}</span>
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
<button type="button" class="unseat" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
<button type="button" class="dismiss" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
</li>
{/each}
{#each v.expected as name (name)}
@@ -78,7 +64,7 @@
{/if}
{#if !room.spectating}
<div class="ways">
{#if seatFree && room.isHost}
{#if room.seatEmpty && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
@@ -94,13 +80,13 @@
Let the Peanut Gallery talk at the table
</label>
{/if}
{#if v.keeper && !v.challenge && !keeperSeated && seatOpen}
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
<div class="keeper">
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
<p class="muted small">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
</div>
{:else if v.challenge}
<p class="muted small">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
{/if}
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
@@ -148,20 +134,6 @@
font-weight: 500;
}
.unseat {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.1rem;
padding: 0 0.3rem;
margin-left: 0.3rem;
line-height: 1;
}
.unseat:hover {
color: var(--blood);
}
.ways {
display: flex;
flex-wrap: wrap;
@@ -203,8 +175,7 @@
max-width: 38em;
}
.small {
font-size: 0.85rem;
.note {
margin-top: 0.4rem;
}
</style>
+33 -37
View File
@@ -1,6 +1,7 @@
<script lang="ts">
// The preferences panel: every declared setting, kit and game, as a
// control. Opened from the masthead; closes with its button or Escape.
// control. Opened from the masthead; closes with its button, a click
// outside it, or Escape.
import { PREFERENCES, prefs, setPref } from '$lib/prefs.svelte';
let { open = $bindable(false) }: { open?: boolean } = $props();
@@ -13,29 +14,30 @@
<svelte:window onkeydown={onKey} />
{#if open}
<div class="scrim" role="presentation" onclick={() => (open = false)}></div>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
<h2 id="prefs-title">Preferences</h2>
<p class="muted small">Kept in this browser, and nowhere else.</p>
{#each PREFERENCES as p (p.key)}
<div class="pref">
{#if p.kind === 'toggle'}
<label class="row">
<input type="checkbox" checked={prefs[p.key] === true} onchange={(e) => setPref(p.key, e.currentTarget.checked)} />
<span>{p.label}</span>
</label>
{:else}
<label class="row">
<span>{p.label}</span>
<select value={prefs[p.key]} onchange={(e) => setPref(p.key, e.currentTarget.value)}>
{#each p.choices ?? [] as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{/if}
{#if p.note}<p class="muted small note">{p.note}</p>{/if}
</div>
{/each}
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
<h2 id="prefs-title">Preferences</h2>
<p class="muted small">Kept in this browser, and nowhere else.</p>
{#each PREFERENCES as p (p.key)}
<div class="pref">
{#if p.kind === 'toggle'}
<label class="row">
<input type="checkbox" checked={prefs[p.key] === true} onchange={(e) => setPref(p.key, e.currentTarget.checked)} />
<span>{p.label}</span>
</label>
{:else}
<label class="row">
<span>{p.label}</span>
<select value={prefs[p.key]} onchange={(e) => setPref(p.key, e.currentTarget.value)}>
{#each p.choices ?? [] as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{/if}
{#if p.note}<p class="muted small note">{p.note}</p>{/if}
</div>
{/each}
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
</div>
</div>
{/if}
@@ -43,22 +45,20 @@
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.panel {
position: fixed;
z-index: 21;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 26rem);
max-height: 90vh;
width: min(100%, 26rem);
max-height: 100%;
overflow: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 8px;
border-radius: 6px;
padding: 1.2rem 1.4rem 1.4rem;
}
@@ -87,10 +87,6 @@
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
}
.quiet {
margin-top: 0.8rem;
}
+3 -17
View File
@@ -38,12 +38,11 @@
async function send(e: SubmitEvent) {
e.preventDefault();
const r = room;
if (!happened.trim() || busy) return;
busy = true;
error = '';
try {
const { id } = await api.report(r.roomId, r.token, happened.trim(), expected.trim());
const { id } = await api.report(room.roomId, room.token, happened.trim(), expected.trim());
if (image) await api.reportImage(id, image).catch(() => (error = 'The report went; the picture did not.'));
sent = true;
setTimeout(close, 2200);
@@ -75,7 +74,7 @@
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label>
<p class="muted small">The room code and the round ride along; the keeper can replay the game to this moment.</p>
<p class="muted small note">The room code and the round ride along; the keeper can replay the game to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
@@ -119,7 +118,6 @@
margin-top: 0.7rem;
}
.picture input {
font-size: 0.9rem;
}
@@ -132,13 +130,11 @@
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
.note {
margin-top: 0.6rem;
}
.warning {
color: var(--blood);
margin-top: 0.4rem;
}
@@ -152,14 +148,4 @@
gap: 1rem;
margin-top: 0.9rem;
}
.link {
background: none;
border: 0;
color: var(--bone-dim);
text-decoration: underline dotted;
padding: 0;
font: inherit;
}
</style>
+2 -3
View File
@@ -57,7 +57,7 @@
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form>
{:else if room.spectating}
<p class="muted small">The gallery listens; the host has not let it talk.</p>
<p class="muted small note">The gallery listens; the host has not let it talk.</p>
{:else}
<form onsubmit={say}>
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
@@ -125,8 +125,7 @@
flex: 0 1 8rem;
}
.small {
font-size: 0.85rem;
.note {
margin-top: 0.4rem;
}
</style>