The masthead, the scrim, the quiet button and the colophon are styled once in app.css instead of pasted into each page and slip with drifting paddings; the style blocks of the play page and the board are indented like the rest of the files; and comments in script and CSS say what the code does or guards, in plain words, where they had been written in the game's own voice

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-29 11:10:19 -04:00
1 parent 22979afd35
commit 42144c1cc9
19 files changed
+364 -481

No files matched your search

+4 -4
View File
@@ -19,7 +19,7 @@ function heldSeats(): Record<string, HeldSeat> {
}
}
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
/** A seat carried in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of server logs. */
export function seatInLink(hash: string): HeldSeat | null {
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
return m ? { seat: m[1], token: m[2] } : null;
@@ -122,8 +122,7 @@ async function call<T>(method: string, path: string, body?: unknown): Promise<T>
type Seated = { seat: WizardId; token: string; view: RoomView };
/** A report to the keeper, with the exchange under it. */
/** What the ledgers add up to. */
/** Site-wide statistics. */
export interface Tally {
tablesOpened: number;
gamesBegun: number;
@@ -138,6 +137,7 @@ export interface Tally {
byGame: Record<string, number>;
}
/** A bug report, with the exchange of replies under it. */
export interface Report {
id: string;
at: string;
@@ -184,7 +184,7 @@ export const api = {
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
};
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
/** A socket that only ever signals "fetch the view again"; reconnects on its own. An empty token spectates. */
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
let socket: WebSocket | null = null;
let closed = false;
+7 -7
View File
@@ -219,7 +219,7 @@ export interface Remote extends HeldSeat {
started: boolean;
connected: boolean;
error: string;
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
/** Set when the server stopped accepting this seat's token while the page was open, with the reason; the page then spectates. */
lost: string;
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
@@ -340,7 +340,7 @@ export class Duel {
/** Watching from the gallery, with no seat at the table. */
spectating = $derived(this.remote?.spectating ?? false);
/** The gallery has no wizard of its own; it borrows the first seat for the fields that need one, and shows none of them. */
/** A spectator has no wizard of their own; borrow the first seat for the fields that need one (none of them are shown). */
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
/** The opponent spells go at by default: the first still standing. */
foeId = $derived(enemyOf(this.state, this.me));
@@ -850,12 +850,12 @@ export class Duel {
return this.remoteAct((r) => api.setGalleryTalk(r.roomId, r.token, on), 'The gallery could not be told.');
}
/** Call the keeper of the site to a held seat; their phone rings. */
/** Invite the site maintainer to the seat held for them; the server notifies them. */
callKeeper(): Promise<boolean> {
return this.remoteAct((r) => api.challenge(r.roomId, r.token), 'The keeper could not be called.');
}
/** A finished table opens another with the same company; the caller is seated as its host. */
/** Open a rematch room for the same players; the caller becomes its host. */
async rematch(): Promise<string> {
const r = this.remote!;
const next = await api.rematch(r.roomId, r.token);
@@ -943,7 +943,7 @@ export class Duel {
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
}
/** You opened this table, so the duel begins when you say. */
/** The viewer holds the host seat, so may begin the game. */
isHost = $derived(!!this.remote && this.remote.host === this.remote.seat);
/** Begin the duel with whoever is seated. */
@@ -1026,7 +1026,7 @@ export class Duel {
this.resetDrafts();
}
/** Open a table on the server, seated as its host; it takes up to a dozen and begins when the host says. */
/** Create a room on the server with the caller as host; it takes up to `size` seats and starts when the host says. */
static async createRoom(name: string, size = 12): Promise<Remote> {
const seated = await api.create(name, size);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
@@ -1052,7 +1052,7 @@ export class Duel {
return remoteFrom(await api.view(roomId, held.token), held.token);
}
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
/** Open a room as a spectator: no seat and no token. */
static async watchRoom(roomId: string): Promise<Remote> {
return remoteFrom(await api.watch(roomId), '');
}
+1 -1
View File
@@ -306,7 +306,7 @@ export function visibleHistory(state: GameState, viewer: WizardId, subject: Wiza
return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
}
/** The gallery holds no seat: it is shown only what every wizard at the table could see. */
/** A spectator holds no seat and is shown only what every seated player could see. */
export const SPECTATOR: WizardId = '';
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
+1 -1
View File
@@ -1,4 +1,4 @@
// How much of each table's talk this browser has read, so the hall can say what is new.
// How many chat lines of each room this browser has read, so the landing page can count what is new.
const SEEN_KEY = 'wh:talk-seen';
+3 -3
View File
@@ -1,6 +1,6 @@
// The game's own lines for the hall's tally, read off a finished duel. Only
// what the final state records for certain: who is dead, who yielded, and
// how the duel was decided.
// This game's lines for the site-wide tally, read off a finished game: only
// what the final state records for certain (who is dead, who yielded, and
// how the game was decided).
import type { GameState } from './state';
+4 -4
View File
@@ -15,7 +15,7 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
return view;
}
/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`), or from the keeper of the site. */
/** One chat line: from a seat, from a spectator when the host allows it (id SPECTATOR, signed with `name`), or from the site maintainer (`keeper`). */
export interface ChatLine {
id: WizardId;
text: string;
@@ -46,15 +46,15 @@ export interface RoomView {
audience: number;
/** The host lets the gallery talk. */
galleryTalk: boolean;
/** The keeper of the site was called to a seat, and by whom. */
/** The site maintainer was invited to a held seat, and by whom. */
challenge: { by: WizardId; at: number } | null;
/** Names with a seat held who have not yet sat: the last table's wizards, or the keeper. */
/** Names with a seat held who have not yet sat: the previous room's players, or the site maintainer. */
expected: string[];
/** A finished table's rematch: where it went, and who called. */
rematch: { to: string; by: WizardId } | null;
/** The table this one is the rematch of. */
rematchOf: string | null;
/** The keeper of the site, and where they live, for a table that calls them. */
/** The site maintainer's name and time zone, for rooms that invite them. */
keeper: string;
keeperZone: string;
}