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:
co-authored by
Claude Fable 5
parent
ced4dc5749
commit
85831828d7
@@ -20,6 +20,7 @@ import {
|
|||||||
catchUpSteps,
|
catchUpSteps,
|
||||||
claimTransferCode,
|
claimTransferCode,
|
||||||
createRoom,
|
createRoom,
|
||||||
|
engagementStats,
|
||||||
pickColor,
|
pickColor,
|
||||||
getRoom,
|
getRoom,
|
||||||
joinRoom,
|
joinRoom,
|
||||||
@@ -296,6 +297,10 @@ wss.on("connection", (socket) => {
|
|||||||
broadcastRoomState(room);
|
broadcastRoomState(room);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "stats": {
|
||||||
|
send(socket, { type: "stats", stats: engagementStats() });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "myGames": {
|
case "myGames": {
|
||||||
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
|
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
|
||||||
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
|
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface Room {
|
|||||||
expansion: boolean;
|
expansion: boolean;
|
||||||
/** Lobby standee choices (colorIndex 0-5), by player. */
|
/** Lobby standee choices (colorIndex 0-5), by player. */
|
||||||
colorChoices: Map<PlayerId, number>;
|
colorChoices: Map<PlayerId, number>;
|
||||||
|
createdAt: string;
|
||||||
state: GameState | null; // null until started
|
state: GameState | null; // null until started
|
||||||
log: LoggedCommand[];
|
log: LoggedCommand[];
|
||||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
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),
|
seed: randomInt(0, 0xffffffff),
|
||||||
expansion: false,
|
expansion: false,
|
||||||
colorChoices: new Map(),
|
colorChoices: new Map(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
state: null,
|
state: null,
|
||||||
log: [],
|
log: [],
|
||||||
events: [],
|
events: [],
|
||||||
@@ -360,6 +362,60 @@ 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();
|
||||||
@@ -377,6 +433,7 @@ export function loadPersistedRooms(): void {
|
|||||||
seed: meta.seed,
|
seed: meta.seed,
|
||||||
expansion: false,
|
expansion: false,
|
||||||
colorChoices: new Map(),
|
colorChoices: new Map(),
|
||||||
|
createdAt: meta.createdAt,
|
||||||
state: null,
|
state: null,
|
||||||
log: [],
|
log: [],
|
||||||
events: [],
|
events: [],
|
||||||
|
|||||||
@@ -691,7 +691,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showHelp}
|
{#if showHelp}
|
||||||
<Help initialTab={helpTab} onclose={() => (showHelp = false)} />
|
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if view?.phase === "finished" && view.winner && !victorySeen}
|
{#if view?.phase === "finished" && view.winner && !victorySeen}
|
||||||
|
|||||||
@@ -6,13 +6,25 @@
|
|||||||
let {
|
let {
|
||||||
onclose,
|
onclose,
|
||||||
initialTab = "play",
|
initialTab = "play",
|
||||||
|
stats = null,
|
||||||
|
onstats,
|
||||||
}: {
|
}: {
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
initialTab?: "play" | "rules" | "cards" | "about";
|
initialTab?: "play" | "rules" | "cards" | "about" | "tally";
|
||||||
|
stats?: Record<string, number | string | null> | null;
|
||||||
|
onstats?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
|
// 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("");
|
let search = $state("");
|
||||||
|
|
||||||
// The playable pool: every card actually in the 6e game (base + Exp1).
|
// The playable pool: every card actually in the 6e game (base + Exp1).
|
||||||
@@ -53,12 +65,33 @@
|
|||||||
<button class:current={tab === "rules"} onclick={() => (tab = "rules")}>The rules</button>
|
<button class:current={tab === "rules"} onclick={() => (tab = "rules")}>The rules</button>
|
||||||
<button class:current={tab === "cards"} onclick={() => (tab = "cards")}>Card library</button>
|
<button class:current={tab === "cards"} onclick={() => (tab = "cards")}>Card library</button>
|
||||||
<button class:current={tab === "about"} onclick={() => (tab = "about")}>About</button>
|
<button class:current={tab === "about"} onclick={() => (tab = "about")}>About</button>
|
||||||
|
<button class:current={tab === "tally"} onclick={openTally}>The tally</button>
|
||||||
</nav>
|
</nav>
|
||||||
<button class="close" onclick={onclose} aria-label="close help">×</button>
|
<button class="close" onclick={onclose} aria-label="close help">×</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="booklet-body">
|
<div class="booklet-body">
|
||||||
{#if tab === "about"}
|
{#if tab === "tally"}
|
||||||
|
<div class="tally">
|
||||||
|
<h3>How much love the maze is getting</h3>
|
||||||
|
{#if stats}
|
||||||
|
<dl class="tally-list">
|
||||||
|
<dt>{stats.gamesCreated}</dt><dd>games chronicled — {stats.gamesStarted} begun, {stats.gamesFinished} fought to a finish</dd>
|
||||||
|
<dt>{stats.wizardsSeated}</dt><dd>distinct wizards have taken a seat</dd>
|
||||||
|
<dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd>
|
||||||
|
<dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</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.fullestTable}</dt><dd>wizards at the fullest table</dd>
|
||||||
|
</dl>
|
||||||
|
{#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>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p>Counting the ledgers…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if tab === "about"}
|
||||||
<div class="about">
|
<div class="about">
|
||||||
<h3>What this is</h3>
|
<h3>What this is</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -222,6 +255,12 @@
|
|||||||
.booklet-body h3:first-child { margin-top: 0; }
|
.booklet-body h3:first-child { margin-top: 0; }
|
||||||
.booklet-body p { margin: 0.35rem 0; }
|
.booklet-body p { margin: 0.35rem 0; }
|
||||||
.colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; }
|
.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 { margin-bottom: 0.8rem; }
|
||||||
.search-row input {
|
.search-row input {
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ class Net {
|
|||||||
/** Every seat this browser holds, across rooms. */
|
/** Every seat this browser holds, across rooms. */
|
||||||
seats = $state<Seat[]>(loadSeats());
|
seats = $state<Seat[]>(loadSeats());
|
||||||
/** Lobby ledger: one summary per live seat. */
|
/** Lobby ledger: one summary per live seat. */
|
||||||
|
stats = $state<Record<string, number | string | null> | null>(null);
|
||||||
games = $state<GameSummary[]>([]);
|
games = $state<GameSummary[]>([]);
|
||||||
notificationsEnabled = $state(
|
notificationsEnabled = $state(
|
||||||
typeof Notification !== "undefined" && Notification.permission === "granted",
|
typeof Notification !== "undefined" && Notification.permission === "granted",
|
||||||
@@ -297,6 +298,10 @@ class Net {
|
|||||||
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "stats": {
|
||||||
|
this.stats = msg.stats;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "games": {
|
case "games": {
|
||||||
this.games = msg.games;
|
this.games = msg.games;
|
||||||
for (const g of msg.games as GameSummary[]) {
|
for (const g of msg.games as GameSummary[]) {
|
||||||
@@ -367,6 +372,10 @@ class Net {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ask the server how all our games are doing. */
|
/** Ask the server how all our games are doing. */
|
||||||
|
requestStats(): void {
|
||||||
|
this.send({ type: "stats" });
|
||||||
|
}
|
||||||
|
|
||||||
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) });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user