// 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 (from a // finished game) and GameSpec.tallyBegun (from any game begun). import { cleanOptions, type GameSpec, type SeatId } from '../../src/lib/game/spec'; import type { Tally } from '../../src/lib/net/view'; import type { LedgerLine, Store } from './store'; 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()) { let lines: LedgerLine[]; try { lines = this.store.read(id); } catch { continue; // an unreadable ledger is left out of the count, as its room is left unloaded } 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') { const i = seats.findIndex((s) => s.id === line.id); if (i >= 0) seats.splice(i, 1); } else if (line.t === 'start') { t.gamesBegun += 1; if (hasBot) t.gamesWithBot += 1; if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; for (const [k, v] of Object.entries(this.game.tallyBegun?.(cleanOptions(this.game, lines[0].options)) ?? {})) t.byGame[k] = (t.byGame[k] ?? 0) + v; try { state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options), line.rankings); } 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; } }