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:
Eric Wagoner
2026-08-16 11:38:05 -04:00
co-authored by Claude Fable 5
parent 85831828d7
commit 4114386ed8
7 changed files with 223 additions and 58 deletions
+6 -54
View File
@@ -16,6 +16,7 @@ import {
type PlayerId,
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, type RoomLine } from "./store";
import { recordRoom } from "./stats";
export interface LoggedCommand {
seq: number;
@@ -91,6 +92,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
events: [],
};
rooms.set(room.id, room);
recordRoom(room);
appendLine(room.id, {
kind: "meta",
id: room.id,
@@ -122,6 +124,7 @@ export function joinRoom(
room.players.push(playerId);
room.tokens.set(playerId, hashToken(fresh));
appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) });
recordRoom(room);
return { token: fresh };
}
@@ -175,6 +178,7 @@ export function startGame(room: Room, expansion: boolean): { events: GameEvent[]
const result = startInMemory(room, expansion, colors);
if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion, colors });
recordRoom(room);
return result;
}
@@ -196,6 +200,7 @@ export function runCommand(
room.log.push(logged);
room.events.push(...result.events);
appendLine(room.id, { kind: "command", ...logged });
if (room.state.phase === "finished") recordRoom(room);
return { events: result.events };
}
@@ -362,60 +367,6 @@ 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();
@@ -457,6 +408,7 @@ export function loadPersistedRooms(): void {
}
}
rooms.set(id, room);
recordRoom(room);
restored++;
} catch (e) {
console.error(`could not restore room ${id}:`, e);