Files
wizwar6e/packages/server/src/stats.ts
T
Eric WagonerandClaude Fable 5 f62fcf2510 Harden the hotseat tally endpoint
Security review of the accumulator commit found three holes, all in
the unauthenticated hotseat ping. Worst: Number(undefined) is NaN,
and NaN survives Math.min/max — one malformed report would have
poisoned commandsPlayed and friends permanently (NaN serializes to
null). All numeric fields now pass through a NaN-proof clamp with a
fallback. The dedupe ledger caps at 50k hotseat entries so spammed
random ids cannot grow stats.json without bound, and each connection
may deliver at most 20 reports — a real device finishes a handful of
games; a firehose is abuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:41:20 -04:00

173 lines
6.0 KiB
TypeScript

// 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<EngagementStats, "wizardsSeated"> {
/** Lowercased wizard names ever seated (the count feeds wizardsSeated). */
wizards: string[];
/** Highest stage already counted, per room id. */
roomStages: Record<string, "created" | "started" | "finished">;
}
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<string>();
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 };
}