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
+7 -4
View File
@@ -1,6 +1,6 @@
// The reactive room a page shows: the seat this browser holds (or the
// gallery), the table, the talk, and the game's view. It knows the game only
// through GameSpec; a game's own store wraps or extends this one.
// through GameSpec.
import { game } from '$lib/game';
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
@@ -32,7 +32,7 @@ export function rememberName(raw: string): void {
}
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
export function listText(names: string[]): string {
function listText(names: string[]): string {
if (names.length <= 1) return names[0] ?? '';
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
}
@@ -57,13 +57,16 @@ export class Room {
started = $derived(this.view.state !== null);
state = $derived(this.view.state);
isHost = $derived(!this.spectating && this.view.host === this.view.me);
others = $derived(this.view.seats.filter((s) => s.id !== this.me));
/** Your move is in and the round waits on someone else. */
moved = $derived(this.started && !this.view.over && !this.spectating && !this.view.waitingOn.includes(this.me));
yourMove = $derived(this.started && !this.view.over && this.view.waitingOn.includes(this.me));
/** Names of the seats the round is waiting on, yours aside. */
awaiting = $derived(this.view.waitingOn.filter((id) => id !== this.me).map((id) => this.nameOf(id)));
awaitingText = $derived(listText(this.awaiting));
others = $derived(this.view.seats.filter((s) => s.id !== this.me));
/** A chair at the table is empty, held for someone or not. */
seatEmpty = $derived(!this.started && this.view.seats.length < this.view.size);
/** An empty chair not held for anyone: a stranger may sit. */
seatUnheld = $derived(this.seatEmpty && this.view.seats.length + this.view.expected.length < this.view.size);
nameOf(id: SeatId): string {
return this.view.seats.find((s) => s.id === id)?.name ?? (this.view.state ? game.nameOf(this.view.state, id) : id);