From 7c60d19ad3b75f5a4d30ac57eebff91b980988e6 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 18:36:32 -0400 Subject: [PATCH] The tally: what the ledgers add up to, in the about panel Ported from the game kit (7aa0553). GET /api/tally scans the ledgers, replays each duel, and returns the counts, cached a minute; the duel's own lines come from tallyOf on the final state (wins by slaying or by surrender, draws, wizards slain and yielded, stopped moments). The hall fetches it when the about panel opens. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up --- server/src/index.ts | 4 ++ server/src/tally.ts | 102 +++++++++++++++++++++++++++++++++ src/lib/components/Hall.svelte | 42 +++++++++++++- src/lib/game/client.ts | 16 ++++++ src/lib/game/tally.test.ts | 16 ++++++ src/lib/game/tally.ts | 26 +++++++++ 6 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 server/src/tally.ts create mode 100644 src/lib/game/tally.test.ts create mode 100644 src/lib/game/tally.ts diff --git a/server/src/index.ts b/server/src/index.ts index 610257f..c3744b8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,6 +20,7 @@ // POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn // 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 // // Caddy serves the static site and proxies /api and /ws here. @@ -31,6 +32,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'; import { dirname } from 'node:path'; const PORT = Number(process.env.PORT ?? 8788); @@ -53,6 +55,7 @@ process.on('unhandledRejection', (reason) => { const rooms = new Rooms(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(new Store(DATA_DIR)); const PUBLIC_URL = (process.env.WH_PUBLIC_URL ?? 'https://hands.kestrelsnest.social').replace(/\/$/, ''); /** 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); @@ -196,6 +199,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/server/src/tally.ts b/server/src/tally.ts new file mode 100644 index 0000000..55d5393 --- /dev/null +++ b/server/src/tally.ts @@ -0,0 +1,102 @@ +// 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; the duel's own lines come from tallyOf. + +import { resolveTurn } from '../../src/lib/game/resolve'; +import { createGame, type GameState, type WizardId } from '../../src/lib/game/state'; +import { tallyOf } from '../../src/lib/game/tally'; +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 duel's own lines, summed over finished duels. */ + 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 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); + const first = lines[0]; + if (first?.t !== 'room') continue; + t.tablesOpened += 1; + const seats: { id: WizardId; name: string; bot: boolean }[] = []; + let state: GameState | 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 (first.createdAt < firstStart) firstStart = first.createdAt; + try { + state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); + } 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 = resolveTurn(state, line.inputs); + } 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) for (const [k, v] of Object.entries(tallyOf(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/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index 3a5809f..5f9f4bc 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -1,7 +1,7 @@