diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e7f4f9c..f95eeee 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -20,7 +20,6 @@ import { catchUpSteps, claimTransferCode, createRoom, - engagementStats, pickColor, getRoom, joinRoom, @@ -35,6 +34,7 @@ import { viewForPlayer, type Room, } from "./rooms"; +import { engagementStats, recordHotseat } from "./stats"; // --- Abuse limits: this is a public server on a small box. ----------------- const MAX_SOCKETS = 300; // concurrent connections @@ -297,6 +297,17 @@ wss.on("connection", (socket) => { broadcastRoomState(room); 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": { send(socket, { type: "stats", stats: engagementStats() }); break; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 527927f..167647d 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -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(); - 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); diff --git a/packages/server/src/stats.ts b/packages/server/src/stats.ts new file mode 100644 index 0000000..e285b7d --- /dev/null +++ b/packages/server/src/stats.ts @@ -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 { + /** Lowercased wizard names ever seated (the count feeds wizardsSeated). */ + wizards: string[]; + /** Highest stage already counted, per room id. */ + roomStages: Record; +} + +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(); +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 }; +} diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index ac1460a..6403220 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -49,6 +49,11 @@ function fileFor(roomId: string): string { 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 { mkdirSync(DATA_DIR, { recursive: true }); } diff --git a/packages/web/src/Help.svelte b/packages/web/src/Help.svelte index 8589be2..13157b5 100644 --- a/packages/web/src/Help.svelte +++ b/packages/web/src/Help.svelte @@ -83,9 +83,10 @@
{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
+
{stats.hotseatGames}
of the games were hotseat tables, reporting in anonymously
{#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.

+

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.

{/if} {:else}

Counting the ledgers…

diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index ce44ed2..34b3878 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -14,13 +14,17 @@ import { type GameView, type PlayerId, } from "@wizwar/engine"; -import { humanize } from "./net.svelte"; +import { humanize, net } from "./net.svelte"; const SAVE_KEY = "wizwar-hotseat"; interface SavedHotseat { config: GameConfig; 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? */ @@ -48,6 +52,9 @@ class LocalGame { private config: GameConfig | null = null; private commands: { playerId: PlayerId; command: Command }[] = []; + private tallyId: string | null = null; + private activeMs = 0; + private lastMoveAt = 0; hasSave(): boolean { return localStorage.getItem(SAVE_KEY) !== null; @@ -107,6 +114,10 @@ class LocalGame { this.active = true; this.viewerId = null; 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(); return null; } @@ -134,6 +145,9 @@ class LocalGame { } this.config = saved.config; 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.active = true; this.viewerId = null; @@ -165,6 +179,18 @@ class LocalGame { } this.gameState = result.state; 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) { const line = humanize(e); if (line) this.log = [...this.log, line]; @@ -195,7 +221,10 @@ class LocalGame { if (!this.config) return; localStorage.setItem( 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), ); } } diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 4cf0c6c..cd0cffa 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -376,6 +376,14 @@ class Net { 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 { if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) }); }