The tally: a booklet tab counting the love

A new tab in the help booklet answers "how much is this getting
played?" from the ledgers themselves: games chronicled (begun and
fought to a finish), distinct wizards seated, every spell, step, and
punch recorded, time at the table estimated by the clock between
moves (gaps over ten minutes don't count — async games tell the
truth), victories split by treasure-theft versus last standing, the
longest game, the fullest table, and the date the first game was
dealt. Computed server-side over the in-memory rooms with a 60-second
cache, fetched over the socket when the tab opens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 11:31:14 -04:00
co-authored by Claude Fable 5
parent ced4dc5749
commit 85831828d7
5 changed files with 114 additions and 4 deletions
+57
View File
@@ -34,6 +34,7 @@ export interface Room {
expansion: boolean;
/** Lobby standee choices (colorIndex 0-5), by player. */
colorChoices: Map<PlayerId, number>;
createdAt: string;
state: GameState | null; // null until started
log: LoggedCommand[];
events: GameEvent[]; // full history (unredacted — redact per recipient)
@@ -84,6 +85,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
seed: randomInt(0, 0xffffffff),
expansion: false,
colorChoices: new Map(),
createdAt: new Date().toISOString(),
state: null,
log: [],
events: [],
@@ -360,6 +362,60 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
return { roomId: t.roomId, name: t.name, token: t.token };
}
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;
}
let statsCache: { at: number; stats: EngagementStats } | null = null;
/** The tally of love the maze has received, computed over every room. */
export function engagementStats(): EngagementStats {
const now = Date.now();
if (statsCache && now - statsCache.at < 60_000) return statsCache.stats;
const wizards = new Set<string>();
const stats: EngagementStats = {
gamesCreated: 0, gamesStarted: 0, gamesFinished: 0, wizardsSeated: 0,
commandsPlayed: 0, minutesAtTable: 0, winsByTreasure: 0, winsByLastStanding: 0,
longestGameCommands: 0, fullestTable: 0, firstGameAt: null,
};
const SESSION_GAP_MS = 10 * 60 * 1000;
for (const room of rooms.values()) {
stats.gamesCreated++;
for (const p of room.players) wizards.add(p.toLowerCase());
if (!stats.firstGameAt || room.createdAt < stats.firstGameAt) stats.firstGameAt = room.createdAt;
if (!room.state) continue;
stats.gamesStarted++;
stats.commandsPlayed += room.log.length;
stats.longestGameCommands = Math.max(stats.longestGameCommands, room.log.length);
stats.fullestTable = Math.max(stats.fullestTable, room.players.length);
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;
}
stats.minutesAtTable += Math.round(activeMs / 60_000);
if (room.state.phase === "finished") {
stats.gamesFinished++;
if (room.state.winReason === "treasures") stats.winsByTreasure++;
else if (room.state.winReason === "lastStanding") stats.winsByLastStanding++;
}
}
stats.wizardsSeated = wizards.size;
statsCache = { at: now, stats };
return stats;
}
/** Rebuild every persisted room by replaying its file. */
export function loadPersistedRooms(): void {
ensureDataDir();
@@ -377,6 +433,7 @@ export function loadPersistedRooms(): void {
seed: meta.seed,
expansion: false,
colorChoices: new Map(),
createdAt: meta.createdAt,
state: null,
log: [],
events: [],