From 85831828d7e8f595fe0353fb0965cd97fc2c7102 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 11:31:14 -0400 Subject: [PATCH] The tally: a booklet tab counting the love MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/server/src/index.ts | 5 +++ packages/server/src/rooms.ts | 57 ++++++++++++++++++++++++++++++++++ packages/web/src/App.svelte | 2 +- packages/web/src/Help.svelte | 45 +++++++++++++++++++++++++-- packages/web/src/net.svelte.ts | 9 ++++++ 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a147cce..e7f4f9c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -20,6 +20,7 @@ import { catchUpSteps, claimTransferCode, createRoom, + engagementStats, pickColor, getRoom, joinRoom, @@ -296,6 +297,10 @@ wss.on("connection", (socket) => { broadcastRoomState(room); break; } + case "stats": { + send(socket, { type: "stats", stats: engagementStats() }); + break; + } case "myGames": { // {seats: [{roomId, name, token}]} -> summaries for valid seats. const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : []; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 1138d7d..527927f 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -34,6 +34,7 @@ export interface Room { expansion: boolean; /** Lobby standee choices (colorIndex 0-5), by player. */ colorChoices: Map; + 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(); + 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: [], diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index ea02dff..78da005 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -691,7 +691,7 @@ {/if} {#if showHelp} - (showHelp = false)} /> + net.requestStats()} onclose={() => (showHelp = false)} /> {/if} {#if view?.phase === "finished" && view.winner && !victorySeen} diff --git a/packages/web/src/Help.svelte b/packages/web/src/Help.svelte index 57fd332..8589be2 100644 --- a/packages/web/src/Help.svelte +++ b/packages/web/src/Help.svelte @@ -6,13 +6,25 @@ let { onclose, initialTab = "play", + stats = null, + onstats, }: { onclose: () => void; - initialTab?: "play" | "rules" | "cards" | "about"; + initialTab?: "play" | "rules" | "cards" | "about" | "tally"; + stats?: Record | null; + onstats?: () => void; } = $props(); // svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value - let tab = $state<"play" | "rules" | "cards" | "about">(initialTab); + let tab = $state<"play" | "rules" | "cards" | "about" | "tally">(initialTab); + + function openTally() { + tab = "tally"; + onstats?.(); + } + function hours(mins: number): string { + return mins < 90 ? `${mins} minutes` : `${Math.round(mins / 6) / 10} hours`; + } let search = $state(""); // The playable pool: every card actually in the 6e game (base + Exp1). @@ -53,12 +65,33 @@ +
- {#if tab === "about"} + {#if tab === "tally"} +
+

How much love the maze is getting

+ {#if stats} +
+
{stats.gamesCreated}
games chronicled — {stats.gamesStarted} begun, {stats.gamesFinished} fought to a finish
+
{stats.wizardsSeated}
distinct wizards have taken a seat
+
{stats.commandsPlayed}
spells, steps, and punches recorded in the ledgers
+
{hours(Number(stats.minutesAtTable))}
spent at the table, by the clock between moves
+
{stats.winsByTreasure} / {stats.winsByLastStanding}
victories by treasure-theft / by last wizard standing
+
{stats.longestGameCommands}
moves in the longest game yet played
+
{stats.fullestTable}
wizards at the fullest table
+
+ {#if stats.firstGameAt} +

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.

+ {/if} + {:else} +

Counting the ledgers…

+ {/if} +
+ {:else if tab === "about"}

What this is

@@ -222,6 +255,12 @@ .booklet-body h3:first-child { margin-top: 0; } .booklet-body p { margin: 0.35rem 0; } .colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; } + .tally-list { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.8rem; margin: 0.6rem 0 1rem; } + .tally-list dt { + font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem; + color: #b3372b; text-align: right; white-space: nowrap; + } + .tally-list dd { margin: 0; align-self: center; } .search-row { margin-bottom: 0.8rem; } .search-row input { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 4985712..4cf0c6c 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -196,6 +196,7 @@ class Net { /** Every seat this browser holds, across rooms. */ seats = $state(loadSeats()); /** Lobby ledger: one summary per live seat. */ + stats = $state | null>(null); games = $state([]); notificationsEnabled = $state( typeof Notification !== "undefined" && Notification.permission === "granted", @@ -297,6 +298,10 @@ class Net { this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token }); break; } + case "stats": { + this.stats = msg.stats; + break; + } case "games": { this.games = msg.games; for (const g of msg.games as GameSummary[]) { @@ -367,6 +372,10 @@ class Net { } /** Ask the server how all our games are doing. */ + requestStats(): void { + this.send({ type: "stats" }); + } + refreshGames(): void { if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) }); }