The tally becomes a ledger of its own — and hotseat tables count
Instead of re-scanning every room's log on demand, stats.json now accumulates beside the room files: each room is remembered at its highest counted stage (created → started → finished), so boots and replays reconcile without double-counting, and the tally will survive any future pruning of old rooms. Finished games contribute their moves, table-time, and manner of victory exactly once, at the moment of victory. And with an accumulator to receive them, hotseat games finally count: each local game mints an anonymous id, pings "started" with its player count, tracks its own between-moves clock, and on the final move reports counts only — commands, minutes, players, win reason. No names, no moves leave the device. The server dedupes by id, clamps everything to sane ranges, and the booklet's tally now shows how many of the games were hotseat tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
85831828d7
commit
4114386ed8
@@ -0,0 +1,159 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** 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}`;
|
||||
let dirty = false;
|
||||
const begin = () => {
|
||||
data.hotseatGames++;
|
||||
data.gamesCreated++;
|
||||
data.gamesStarted++;
|
||||
data.fullestTable = Math.max(data.fullestTable, Math.min(6, Math.max(2, r.players ?? 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 = Math.min(10_000, Math.max(0, Math.floor(r.commands ?? 0)));
|
||||
const minutes = Math.min(24 * 60, Math.max(0, Math.floor(r.minutes ?? 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user