From 7aa05536c262e5143691793af446f291fa743c87 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 18:16:41 -0400 Subject: [PATCH] The tally: what the ledgers add up to, in every game's about panel, with the game's own lines through GameSpec.tally Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- CONVENTIONS.md | 4 + template/server/src/index.ts | 4 + template/server/src/tally.ts | 104 ++++++++++++++++++++++++ template/src/lib/components/Hall.svelte | 40 ++++++++- template/src/lib/game/spec.ts | 2 + template/src/lib/net/client.ts | 16 ++++ 6 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 template/server/src/tally.ts diff --git a/CONVENTIONS.md b/CONVENTIONS.md index a88f95d..050f709 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -64,6 +64,10 @@ README. where the keeper roosts. It lives in the about panel; the colophons, the rules page and the guide each carry a one-line mail link. +- **The tally** sits in the about panel: what the ledgers add up to + (tables, games begun and finished, names seated, turns, time at the + table, games with a bot, talk), counted on demand from the files and + cached a minute. A game adds its own lines through `GameSpec.tally`. - **Table options** are the host's choices when opening a table (a variant, a side, a length): the game declares them in `GameSpec.options`, the hall renders them as selects, the room line records them, and diff --git a/template/server/src/index.ts b/template/server/src/index.ts index 8d93e06..54b96a5 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -12,6 +12,7 @@ // POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round // POST /api/reports/:id/image a screenshot for a report just filed // POST /api/reports/mine {seats: [{roomId, token}]} your reports and the keeper's replies +// GET /api/tally what the ledgers add up to, for the hall // POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply // WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token, // a seat in the Peanut Gallery, counted for the table @@ -27,6 +28,7 @@ import { RateLimit } from './ratelimit'; import { IMAGE_MAX_BYTES, Reports } from './reports'; import { RoomError, Rooms, SPECTATOR } from './rooms'; import { Store } from './store'; +import { Tallies } from './tally'; const PORT = Number(process.env.PORT ?? '__PORT__'); const HOST = process.env.HOST ?? '127.0.0.1'; @@ -49,6 +51,7 @@ process.on('unhandledRejection', (reason) => { const rooms = new Rooms(game, new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ }); /** Reports live beside the room ledgers, not among them. */ const reports = new Reports(dirname(DATA_DIR)); +const tallies = new Tallies(game, new Store(DATA_DIR)); /** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */ const doors = new RateLimit(40, 60 * 60 * 1000); /** Table talk: a lively table says a few lines a minute, not hundreds. */ @@ -178,6 +181,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise const url = new URL(req.url ?? '/', 'http://localhost'); const parts = url.pathname.split('/').filter(Boolean); if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts); + if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get()); if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') { if (!claims.allow(clientOf(req))) throw new RoomError('Too many claims from here just now; try again later.', 429); const body = await readBody(req); diff --git a/template/server/src/tally.ts b/template/server/src/tally.ts new file mode 100644 index 0000000..b038885 --- /dev/null +++ b/template/server/src/tally.ts @@ -0,0 +1,104 @@ +// The tally: what the ledgers add up to, for the hall's about panel. Read +// from the files on demand and cached for a minute, so a curious visitor +// costs nothing and a busy hall costs one scan a minute. The counts are +// the kit's; a game may add its own lines through GameSpec.tally. + +import type { GameSpec, SeatId } from '../../src/lib/game/spec'; +import type { LedgerLine, Store } from './store'; + +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; +} + +const GAP_LIMIT_MS = 10 * 60 * 1000; +const CACHE_MS = 60 * 1000; + +export class Tallies { + private cached: { at: number; tally: Tally } | null = null; + + constructor( + private game: GameSpec, + private store: Store + ) {} + + get(): Tally { + if (this.cached && Date.now() - this.cached.at < CACHE_MS) return this.cached.tally; + const tally = this.count(); + this.cached = { at: Date.now(), tally }; + return tally; + } + + private count(): Tally { + const t: Tally = { tablesOpened: 0, gamesBegun: 0, gamesFinished: 0, namesSeated: 0, turnsPlayed: 0, minutesAtTable: 0, longestGameTurns: 0, gamesWithBot: 0, talkLines: 0, firstGameAt: null, byGame: {} }; + const names = new Set(); + let firstStart = Infinity; + for (const id of this.store.roomIds()) { + const lines = this.store.read(id); + if (lines[0]?.t !== 'room') continue; + t.tablesOpened += 1; + const seats: { id: SeatId; name: string; bot: boolean }[] = []; + let state: State | null = null; + let turns = 0; + let lastMoveAt = 0; + let hasBot = false; + let over: LedgerLine | null = null; + for (const line of lines) { + if (line.t === 'seat') { + seats.push(line); + if (line.bot) hasBot = true; + else names.add(line.name.toLowerCase()); + } else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1); + else if (line.t === 'start') { + t.gamesBegun += 1; + if (hasBot) t.gamesWithBot += 1; + if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; + try { + state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, lines[0].options); + } catch { + state = null; + } + } else if (line.t === 'input') { + if (lastMoveAt && line.at - lastMoveAt < GAP_LIMIT_MS) t.minutesAtTable += (line.at - lastMoveAt) / 60000; + lastMoveAt = line.at; + } else if (line.t === 'turn') { + turns += 1; + if (lastMoveAt && line.at - lastMoveAt < GAP_LIMIT_MS) t.minutesAtTable += (line.at - lastMoveAt) / 60000; + lastMoveAt = line.at; + if (state) { + try { + state = this.game.resolve(state, line.inputs as Record); + } catch { + state = null; + } + } + } else if (line.t === 'chat') t.talkLines += 1; + else if (line.t === 'over') over = line; + } + t.turnsPlayed += turns; + if (turns > t.longestGameTurns) t.longestGameTurns = turns; + if (over) { + t.gamesFinished += 1; + if (state && this.game.tally) { + for (const [k, v] of Object.entries(this.game.tally(state))) t.byGame[k] = (t.byGame[k] ?? 0) + v; + } + } + } + t.namesSeated = names.size; + t.minutesAtTable = Math.round(t.minutesAtTable); + t.firstGameAt = Number.isFinite(firstStart) ? new Date(firstStart).toISOString() : null; + return t; + } +} diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index 44b3cfc..ed061b8 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -4,7 +4,7 @@ // about panel with the rules, the guide and a way to reach the keeper. import { goto } from '$app/navigation'; import { otherGames, type FamilyGame } from '$lib/family'; - import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report } from '$lib/net/client'; + import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } 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'; @@ -20,6 +20,12 @@ let seats = $state(allSeats()); let games = $state | null>>({}); let aboutOpen = $state(false); + /** The ledgers' tally, fetched when the about panel opens. */ + let tally = $state(null); + $effect(() => { + if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null)); + }); + const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`); /** The other games of the Kestrel's Nest, for the about panel. */ let family = $state([]); $effect(() => { @@ -248,6 +254,23 @@

It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.

Send word

A rule read wrong, a bug, a game you would like to tell of: write to eric@ericwagoner.com, @kestrelsnest.social on Bluesky, or @eric@toots.kestrelsnest.social on Mastodon. The keeper of this hall roosts at kestrelsnest.social.

+ {#if tally} +

The tally

+
+
{tally.gamesFinished}
{tally.gamesFinished === 1 ? 'game' : 'games'} played to a finish, of {tally.gamesBegun} begun at {tally.tablesOpened} {tally.tablesOpened === 1 ? 'table' : 'tables'}
+
{tally.namesSeated}
{tally.namesSeated === 1 ? 'name has' : 'names have'} taken a seat
+
{tally.turnsPlayed}
turns recorded; {tally.longestGameTurns} in the longest game
+
{hours(tally.minutesAtTable)}
at the table, by the clock between moves
+
{tally.gamesWithBot}
{tally.gamesWithBot === 1 ? 'game' : 'games'} with a bot seated; {tally.talkLines} {tally.talkLines === 1 ? 'line' : 'lines'} of table talk
+ {#each Object.entries(tally.byGame) as [label, n] (label)} +
{n}
{label}
+ {/each} +
+

+ {#if tally.firstGameAt}The first game began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if} + Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night. +

+ {/if} {#if family.length}

More games at the Kestrel's Nest

    @@ -578,4 +601,19 @@ gap: 0.5rem; font-size: 0.9rem; } + .tally { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 0.9rem; + margin: 0.4rem 0 0.6rem; + } + + .tally dt { + text-align: right; + font-weight: 500; + } + + .tally dd { + margin: 0; + } diff --git a/template/src/lib/game/spec.ts b/template/src/lib/game/spec.ts index c79a2d6..5be71f4 100644 --- a/template/src/lib/game/spec.ts +++ b/template/src/lib/game/spec.ts @@ -66,4 +66,6 @@ export interface GameSpec { validate?(state: State, seat: SeatId, input: Input): string | null; /** A seat's name from the state, for the hall and the chronicle. */ nameOf(state: State, seat: SeatId): string; + /** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */ + tally?(state: State): Record; } diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index d7192ae..6de1061 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -128,6 +128,21 @@ export interface Report { 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; +} + export function makeApi() { type Seated = { seat: SeatId; token: string; view: RoomView }; type View = RoomView; @@ -156,6 +171,7 @@ export function makeApi() { 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); }, + tally: () => call('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 }) };