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 WagonerandClaude Fable 5.1 committed 2026-09-23 20:12:37 -04:00
1 parent 6c42e55f59
commit 33ac0ec808
41 files changed
+421 -583

No files matched your search

+13 -39
View File
@@ -2,7 +2,7 @@
// browser holds, remembered so a shared link never has to carry a token.
import type { SeatId, TableOptions } from '$lib/game/spec';
import type { RoomView } from './view';
import type { Report, RoomView, Tally } from './view';
export interface HeldSeat {
seat: SeatId;
@@ -41,7 +41,7 @@ export function forgetSeat(roomId: string): void {
/**
* A seat the server refused is doubted, not dropped: a restarting server, a
* stale answer or the wrong backend all answer with ignorance, and ignorance
* is not deletion. Only the SECOND refusal in a row costs the seat.
* is not deletion. Only the second refusal in a row costs the seat.
*/
const DOUBT_KEY = '__SLUG__:doubted';
@@ -103,44 +103,20 @@ export class ServerError extends Error {
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
});
/** The server's answer as JSON, or its refusal as a ServerError with the reason it gave. */
async function fetchJson<T>(path: string, init: RequestInit): Promise<T> {
const res = await fetch(path, init);
const data = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
return data as T;
}
/** A report to the keeper, with the exchange under it. */
export interface Report {
id: string;
at: string;
roomId: string;
player: string;
seat: SeatId | null;
turn: number | null;
happened: string;
expected: string;
thread: { at: string; text: string; from: 'desk' | 'player'; status?: string }[];
image?: string;
}
/** What the ledgers add up to. */
export interface Tally {
tablesOpened: number;
gamesBegun: number;
gamesFinished: number;
namesSeated: number;
turnsPlayed: number;
minutesAtTable: number;
longestGameTurns: number;
gamesWithBot: number;
talkLines: number;
firstGameAt: string | null;
byGame: Record<string, number>;
function call<T>(method: string, path: string, body?: unknown): Promise<T> {
return fetchJson<T>(path, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
});
}
export function makeApi<State, Input>() {
@@ -167,10 +143,8 @@ export function makeApi<State, Input>() {
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
reportImage: async (id: string, file: File) => {
const res = await fetch(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file });
if (!res.ok) throw new ServerError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? `The server answered ${res.status}.`, res.status);
},
reportImage: (id: string, file: File) =>
fetchJson<void>(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file }),
tally: () => call<Tally>('GET', '/api/tally'),
myReports: (seats: { roomId: string; token: string }[]) => call<{ reports: Report[] }>('POST', '/api/reports/mine', { seats }),
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
+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);
+45
View File
@@ -46,3 +46,48 @@ export interface RoomView<State> {
keeper: string;
keeperZone: string;
}
/** One line under a report: the keeper's reply, with its status, or the player's answer. */
export interface ReportLine {
at: string;
text: string;
from: 'desk' | 'player';
status?: string;
}
/** A report to the keeper, with the exchange under it. */
export interface Report {
id: string;
at: string;
roomId: string;
/** The reporter's name, or "(gallery)" for a watcher. */
player: string;
seat: SeatId | null;
/** The round being written when the report was filed, and the ledger's length. */
turn: number | null;
seq: number;
happened: string;
expected: string;
/** The whole exchange in order: the keeper's replies and the player's answers. */
thread: ReportLine[];
/** A screenshot's file name under images/, when one was sent. */
image?: string;
}
/** What the ledgers add up to, for the hall's about panel. */
export interface Tally {
tablesOpened: number;
gamesBegun: number;
gamesFinished: number;
/** Distinct names of people who have taken a seat; a bot's name is not counted. */
namesSeated: number;
turnsPlayed: number;
/** Minutes at the table: gaps under ten minutes between one move and the next, summed. */
minutesAtTable: number;
longestGameTurns: number;
gamesWithBot: number;
talkLines: number;
firstGameAt: string | null;
/** The game's own lines, summed over finished games. */
byGame: Record<string, number>;
}