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 @@