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>
173 lines
6.0 KiB
TypeScript
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 };
|
|
}
|