Every play-strength item, in one education. DOORS: the pathfinder walks through doors it can open — already-opened ones freely, locked ones when holding Master Key or Pick Lock, casting the key at the door on the path before stepping through (and a loop where the clockwork re-keyed an already-open door forever is fixed: opened doors read as open). TREASURE DEFENSE: a wizard carrying the automaton's gold becomes the priority — chased over all objectives, shaken down with DROP OBJECT when held, and attacked first. WIDER SPELLCRAFT: blaster wands charged by number and fired; teleport as escape when wounded and hunted, and as a counteraction clear of big incoming spells; SPEED cast on sight; WARD armed to guard the gold; shieldstone displayed so spare numbers soak small hits; ANTI-ANTI pressed against counters (never against escapes); the berserker grows BIG with an enemy near, the worrier fades invisible or slams a CREATE WALL in its pursuer's face; ambushes armed from held interrupts — the worrier trapping its doorstep, the others trapping the treasure. Tournament suite green across all temperaments. And the clockwork speaks: sparing, temperament-voiced table talk on its own deeds — "ACQUISITION COMPLETE.", "SCHEDULED DEMISE: DELIVERED.", "good wall. safe wall." — through the same chat ledger as everyone else. The tally gains "games fought against the clockwork", and automatons no longer pollute the count of wizards seated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
177 lines
6.2 KiB
TypeScript
177 lines
6.2 KiB
TypeScript
// 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;
|
|
/** Games with at least one clockwork wizard at the table. */
|
|
automatonGames: 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, automatonGames: 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) {
|
|
if (room.bots.has(p)) continue; // the clockwork are not wizards seated
|
|
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++;
|
|
if (room.bots.size > 0) data.automatonGames++;
|
|
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;
|
|
}
|
|
|
|
/** NaN-proof clamp: anything non-finite becomes the fallback. */
|
|
function clamp(v: unknown, lo: number, hi: number, fallback: number): number {
|
|
const x = Math.floor(Number(v));
|
|
return Number.isFinite(x) ? Math.min(hi, Math.max(lo, x)) : fallback;
|
|
}
|
|
|
|
/** Unauthenticated pings must not grow the dedupe ledger without bound. */
|
|
const MAX_HOTSEAT_ENTRIES = 50_000;
|
|
|
|
/** 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}`;
|
|
if (!data.roomStages[key] &&
|
|
Object.keys(data.roomStages).filter((k) => k.startsWith("hs-")).length >= MAX_HOTSEAT_ENTRIES) {
|
|
return; // the ledger is full of strangers; stop counting new hotseat tables
|
|
}
|
|
let dirty = false;
|
|
const begin = () => {
|
|
data.hotseatGames++;
|
|
data.gamesCreated++;
|
|
data.gamesStarted++;
|
|
data.fullestTable = Math.max(data.fullestTable, clamp(r.players, 2, 6, 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 = clamp(r.commands, 0, 10_000, 0);
|
|
const minutes = clamp(r.minutes, 0, 24 * 60, 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 };
|
|
}
|