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,
|
||||
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) : [];
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user