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
+12 -1
View File
@@ -20,7 +20,6 @@ import {
catchUpSteps, catchUpSteps,
claimTransferCode, claimTransferCode,
createRoom, createRoom,
engagementStats,
pickColor, pickColor,
getRoom, getRoom,
joinRoom, joinRoom,
@@ -35,6 +34,7 @@ import {
viewForPlayer, viewForPlayer,
type Room, type Room,
} from "./rooms"; } from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
// --- Abuse limits: this is a public server on a small box. ----------------- // --- Abuse limits: this is a public server on a small box. -----------------
const MAX_SOCKETS = 300; // concurrent connections const MAX_SOCKETS = 300; // concurrent connections
@@ -297,6 +297,17 @@ wss.on("connection", (socket) => {
broadcastRoomState(room); broadcastRoomState(room);
break; break;
} }
case "hotseatReport": {
const id = String(msg.id ?? "").slice(0, 64);
const stage = msg.stage === "finished" ? "finished" : msg.stage === "started" ? "started" : null;
if (!id || !stage) return send(socket, { type: "error", message: "bad report" });
recordHotseat({
id, stage,
players: Number(msg.players), commands: Number(msg.commands),
minutes: Number(msg.minutes), winReason: typeof msg.winReason === "string" ? msg.winReason : undefined,
});
break;
}
case "stats": { case "stats": {
send(socket, { type: "stats", stats: engagementStats() }); send(socket, { type: "stats", stats: engagementStats() });
break; break;
+6 -54
View File
@@ -16,6 +16,7 @@ import {
type PlayerId, type PlayerId,
} from "@wizwar/engine"; } from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, type RoomLine } from "./store"; import { appendLine, ensureDataDir, readAllRooms, type RoomLine } from "./store";
import { recordRoom } from "./stats";
export interface LoggedCommand { export interface LoggedCommand {
seq: number; seq: number;
@@ -91,6 +92,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
events: [], events: [],
}; };
rooms.set(room.id, room); rooms.set(room.id, room);
recordRoom(room);
appendLine(room.id, { appendLine(room.id, {
kind: "meta", kind: "meta",
id: room.id, id: room.id,
@@ -122,6 +124,7 @@ export function joinRoom(
room.players.push(playerId); room.players.push(playerId);
room.tokens.set(playerId, hashToken(fresh)); room.tokens.set(playerId, hashToken(fresh));
appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) }); appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) });
recordRoom(room);
return { token: fresh }; return { token: fresh };
} }
@@ -175,6 +178,7 @@ export function startGame(room: Room, expansion: boolean): { events: GameEvent[]
const result = startInMemory(room, expansion, colors); const result = startInMemory(room, expansion, colors);
if ("error" in result) return result; if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion, colors }); appendLine(room.id, { kind: "start", expansion, colors });
recordRoom(room);
return result; return result;
} }
@@ -196,6 +200,7 @@ export function runCommand(
room.log.push(logged); room.log.push(logged);
room.events.push(...result.events); room.events.push(...result.events);
appendLine(room.id, { kind: "command", ...logged }); appendLine(room.id, { kind: "command", ...logged });
if (room.state.phase === "finished") recordRoom(room);
return { events: result.events }; 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 }; 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. */ /** Rebuild every persisted room by replaying its file. */
export function loadPersistedRooms(): void { export function loadPersistedRooms(): void {
ensureDataDir(); ensureDataDir();
@@ -457,6 +408,7 @@ export function loadPersistedRooms(): void {
} }
} }
rooms.set(id, room); rooms.set(id, room);
recordRoom(room);
restored++; restored++;
} catch (e) { } catch (e) {
console.error(`could not restore room ${id}:`, e); console.error(`could not restore room ${id}:`, e);
+159
View File
@@ -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 };
}
+5
View File
@@ -49,6 +49,11 @@ function fileFor(roomId: string): string {
return join(DATA_DIR, `${roomId}.jsonl`); return join(DATA_DIR, `${roomId}.jsonl`);
} }
/** Directory holding stats.json — the parent of the rooms dir. */
export function statsDir(): string {
return join(DATA_DIR, "..");
}
export function ensureDataDir(): void { export function ensureDataDir(): void {
mkdirSync(DATA_DIR, { recursive: true }); mkdirSync(DATA_DIR, { recursive: true });
} }
+2 -1
View File
@@ -83,9 +83,10 @@
<dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd> <dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd>
<dt>{stats.longestGameCommands}</dt><dd>moves in the longest game yet played</dd> <dt>{stats.longestGameCommands}</dt><dd>moves in the longest game yet played</dd>
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd> <dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
</dl> </dl>
{#if stats.firstGameAt} {#if stats.firstGameAt}
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat games are played off the ledger and go uncounted.</p> <p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p>
{/if} {/if}
{:else} {:else}
<p>Counting the ledgers…</p> <p>Counting the ledgers…</p>
+31 -2
View File
@@ -14,13 +14,17 @@ import {
type GameView, type GameView,
type PlayerId, type PlayerId,
} from "@wizwar/engine"; } from "@wizwar/engine";
import { humanize } from "./net.svelte"; import { humanize, net } from "./net.svelte";
const SAVE_KEY = "wizwar-hotseat"; const SAVE_KEY = "wizwar-hotseat";
interface SavedHotseat { interface SavedHotseat {
config: GameConfig; config: GameConfig;
commands: { playerId: PlayerId; command: Command }[]; commands: { playerId: PlayerId; command: Command }[];
/** Anonymous tally identity + table-time bookkeeping (added later; optional). */
tallyId?: string;
activeMs?: number;
lastMoveAt?: number;
} }
/** Whose input does the game need right now? */ /** Whose input does the game need right now? */
@@ -48,6 +52,9 @@ class LocalGame {
private config: GameConfig | null = null; private config: GameConfig | null = null;
private commands: { playerId: PlayerId; command: Command }[] = []; private commands: { playerId: PlayerId; command: Command }[] = [];
private tallyId: string | null = null;
private activeMs = 0;
private lastMoveAt = 0;
hasSave(): boolean { hasSave(): boolean {
return localStorage.getItem(SAVE_KEY) !== null; return localStorage.getItem(SAVE_KEY) !== null;
@@ -107,6 +114,10 @@ class LocalGame {
this.active = true; this.active = true;
this.viewerId = null; this.viewerId = null;
this.handoffTo = actorId(state); this.handoffTo = actorId(state);
this.tallyId = crypto.randomUUID();
this.activeMs = 0;
this.lastMoveAt = Date.now();
net.reportHotseat({ id: this.tallyId, stage: "started", players: cleaned.length });
this.persist(); this.persist();
return null; return null;
} }
@@ -134,6 +145,9 @@ class LocalGame {
} }
this.config = saved.config; this.config = saved.config;
this.commands = saved.commands; this.commands = saved.commands;
this.tallyId = saved.tallyId ?? crypto.randomUUID();
this.activeMs = saved.activeMs ?? 0;
this.lastMoveAt = saved.lastMoveAt ?? Date.now();
this.gameState = current; this.gameState = current;
this.active = true; this.active = true;
this.viewerId = null; this.viewerId = null;
@@ -165,6 +179,18 @@ class LocalGame {
} }
this.gameState = result.state; this.gameState = result.state;
this.commands = [...this.commands, { playerId: this.viewerId, command }]; this.commands = [...this.commands, { playerId: this.viewerId, command }];
const now = Date.now();
if (this.lastMoveAt && now - this.lastMoveAt < 10 * 60 * 1000) this.activeMs += now - this.lastMoveAt;
this.lastMoveAt = now;
if (this.gameState.phase === "finished" && this.tallyId) {
net.reportHotseat({
id: this.tallyId, stage: "finished",
players: this.gameState.players.length,
commands: this.commands.length,
minutes: Math.round(this.activeMs / 60_000),
winReason: this.gameState.winReason ?? undefined,
});
}
for (const e of result.events) { for (const e of result.events) {
const line = humanize(e); const line = humanize(e);
if (line) this.log = [...this.log, line]; if (line) this.log = [...this.log, line];
@@ -195,7 +221,10 @@ class LocalGame {
if (!this.config) return; if (!this.config) return;
localStorage.setItem( localStorage.setItem(
SAVE_KEY, SAVE_KEY,
JSON.stringify({ config: this.config, commands: this.commands } satisfies SavedHotseat), JSON.stringify({
config: this.config, commands: this.commands,
tallyId: this.tallyId ?? undefined, activeMs: this.activeMs, lastMoveAt: this.lastMoveAt,
} satisfies SavedHotseat),
); );
} }
} }
+8
View File
@@ -376,6 +376,14 @@ class Net {
this.send({ type: "stats" }); this.send({ type: "stats" });
} }
/** Anonymous count-only ping so hotseat tables show in the tally. */
reportHotseat(report: {
id: string; stage: "started" | "finished";
players?: number; commands?: number; minutes?: number; winReason?: string;
}): void {
this.send({ type: "hotseatReport", ...report });
}
refreshGames(): void { refreshGames(): void {
if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) }); if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) });
} }