// The tally, as a ledger of its own: aggregate engagement counts accumulated // as rooms advance, persisted to stats.json beside the room files. Each room // is remembered at the highest stage already counted ("created" → "started" // → "finished"), so boots, replays, and restarts can reconcile without ever // double-counting — and the tally survives any future pruning of old rooms. import { readFileSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { statsDir } from "./store"; import type { Room } from "./rooms"; export interface EngagementStats { gamesCreated: number; gamesStarted: number; gamesFinished: number; wizardsSeated: number; commandsPlayed: number; /** Sum of command-to-command gaps under 10 minutes, in minutes. */ minutesAtTable: number; winsByTreasure: number; winsByLastStanding: number; longestGameCommands: number; fullestTable: number; firstGameAt: string | null; /** How many of the games were hotseat tables reporting in anonymously. */ hotseatGames: number; } interface StatsFile extends Omit { /** Lowercased wizard names ever seated (the count feeds wizardsSeated). */ wizards: string[]; /** Highest stage already counted, per room id. */ roomStages: Record; } const FILE = () => join(statsDir(), "stats.json"); let data: StatsFile = { gamesCreated: 0, gamesStarted: 0, gamesFinished: 0, commandsPlayed: 0, minutesAtTable: 0, winsByTreasure: 0, winsByLastStanding: 0, longestGameCommands: 0, fullestTable: 0, firstGameAt: null, hotseatGames: 0, wizards: [], roomStages: {}, }; let wizardSet = new Set(); let loaded = false; export function loadStats(): void { try { data = { ...data, ...JSON.parse(readFileSync(FILE(), "utf8")) as StatsFile }; } catch { // First boot (or unreadable file): start from zero; reconcile fills it in. } wizardSet = new Set(data.wizards); loaded = true; } function save(): void { data.wizards = [...wizardSet].sort(); const tmp = FILE() + ".tmp"; writeFileSync(tmp, JSON.stringify(data), "utf8"); renameSync(tmp, FILE()); } const SESSION_GAP_MS = 10 * 60 * 1000; function tableMinutes(room: Room): number { let activeMs = 0; for (let i = 1; i < room.log.length; i++) { const gap = Date.parse(room.log[i]!.at) - Date.parse(room.log[i - 1]!.at); if (gap > 0 && gap < SESSION_GAP_MS) activeMs += gap; } return Math.round(activeMs / 60_000); } /** * Bring the tally up to date with this room's current stage. Called on * create, join, start, finish, and for every room restored at boot; only * stages not yet counted add to the totals. */ export function recordRoom(room: Room): void { if (!loaded) loadStats(); let dirty = false; const stage = data.roomStages[room.id]; for (const p of room.players) { const key = p.toLowerCase(); if (!wizardSet.has(key)) { wizardSet.add(key); dirty = true; } } if (!stage) { data.gamesCreated++; if (!data.firstGameAt || room.createdAt < data.firstGameAt) data.firstGameAt = room.createdAt; data.roomStages[room.id] = "created"; dirty = true; } if (room.state && data.roomStages[room.id] === "created") { data.gamesStarted++; data.fullestTable = Math.max(data.fullestTable, room.players.length); data.roomStages[room.id] = "started"; dirty = true; } if (room.state?.phase === "finished" && data.roomStages[room.id] === "started") { data.gamesFinished++; data.commandsPlayed += room.log.length; data.minutesAtTable += tableMinutes(room); data.longestGameCommands = Math.max(data.longestGameCommands, room.log.length); if (room.state.winReason === "treasures") data.winsByTreasure++; else if (room.state.winReason === "lastStanding") data.winsByLastStanding++; data.roomStages[room.id] = "finished"; dirty = true; } if (dirty) save(); } export interface HotseatReport { id: string; stage: "started" | "finished"; players?: number; commands?: number; minutes?: number; winReason?: string; } /** NaN-proof clamp: anything non-finite becomes the fallback. */ function clamp(v: unknown, lo: number, hi: number, fallback: number): number { const x = Math.floor(Number(v)); return Number.isFinite(x) ? Math.min(hi, Math.max(lo, x)) : fallback; } /** Unauthenticated pings must not grow the dedupe ledger without bound. */ const MAX_HOTSEAT_ENTRIES = 50_000; /** Anonymous count-only pings from hotseat tables. Deduped by client id. */ export function recordHotseat(r: HotseatReport): void { if (!loaded) loadStats(); const key = `hs-${r.id}`; if (!data.roomStages[key] && Object.keys(data.roomStages).filter((k) => k.startsWith("hs-")).length >= MAX_HOTSEAT_ENTRIES) { return; // the ledger is full of strangers; stop counting new hotseat tables } let dirty = false; const begin = () => { data.hotseatGames++; data.gamesCreated++; data.gamesStarted++; data.fullestTable = Math.max(data.fullestTable, clamp(r.players, 2, 6, 2)); if (!data.firstGameAt) data.firstGameAt = new Date().toISOString(); data.roomStages[key] = "started"; dirty = true; }; if (!data.roomStages[key] && (r.stage === "started" || r.stage === "finished")) begin(); if (r.stage === "finished" && data.roomStages[key] !== "finished") { const commands = clamp(r.commands, 0, 10_000, 0); const minutes = clamp(r.minutes, 0, 24 * 60, 0); data.gamesFinished++; data.commandsPlayed += commands; data.minutesAtTable += minutes; data.longestGameCommands = Math.max(data.longestGameCommands, commands); if (r.winReason === "treasures") data.winsByTreasure++; else if (r.winReason === "lastStanding") data.winsByLastStanding++; data.roomStages[key] = "finished"; dirty = true; } if (dirty) save(); } export function engagementStats(): EngagementStats { if (!loaded) loadStats(); const { wizards, roomStages, ...totals } = data; void wizards; void roomStages; return { ...totals, wizardsSeated: wizardSet.size }; }