Instead of re-scanning every room's log on demand, stats.json now accumulates beside the room files: each room is remembered at its highest counted stage (created → started → finished), so boots and replays reconcile without double-counting, and the tally will survive any future pruning of old rooms. Finished games contribute their moves, table-time, and manner of victory exactly once, at the moment of victory. And with an accumulator to receive them, hotseat games finally count: each local game mints an anonymous id, pings "started" with its player count, tracks its own between-moves clock, and on the final move reports counts only — commands, minutes, players, win reason. No names, no moves leave the device. The server dedupes by id, clamps everything to sane ranges, and the booklet's tally now shows how many of the games were hotseat tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
84 lines
2.4 KiB
TypeScript
84 lines
2.4 KiB
TypeScript
// Durable rooms: one append-only JSONL file per room. The first line is the
|
|
// room's birth certificate; every later line is a join, a start, or a game
|
|
// command. Because the engine is deterministic, replaying a file rebuilds
|
|
// the exact game state — server restarts lose nothing.
|
|
|
|
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
export interface RoomMetaLine {
|
|
kind: "meta";
|
|
id: string;
|
|
hostId: string;
|
|
/** sha-256 of the host's seat token. Raw tokens never touch disk. */
|
|
hostTokenHash?: string;
|
|
/** Legacy plaintext token (pre-hashing files only). */
|
|
hostToken?: string;
|
|
seed: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface JoinLine {
|
|
kind: "join";
|
|
name: string;
|
|
tokenHash?: string;
|
|
/** Legacy plaintext token (pre-hashing files only). */
|
|
token?: string;
|
|
}
|
|
|
|
export interface StartLine {
|
|
kind: "start";
|
|
expansion: boolean;
|
|
/** Final wizard colors, in player join order. */
|
|
colors?: number[];
|
|
}
|
|
|
|
export interface CommandLine {
|
|
kind: "command";
|
|
seq: number;
|
|
playerId: string;
|
|
command: unknown;
|
|
at: string;
|
|
}
|
|
|
|
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine;
|
|
|
|
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
|
|
|
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 });
|
|
}
|
|
|
|
export function appendLine(roomId: string, line: RoomLine): void {
|
|
appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8");
|
|
}
|
|
|
|
/** Read every persisted room's lines, keyed by room id. */
|
|
export function readAllRooms(): Map<string, RoomLine[]> {
|
|
ensureDataDir();
|
|
const rooms = new Map<string, RoomLine[]>();
|
|
for (const file of readdirSync(DATA_DIR)) {
|
|
if (!file.endsWith(".jsonl")) continue;
|
|
const id = file.slice(0, -".jsonl".length);
|
|
try {
|
|
const lines = readFileSync(join(DATA_DIR, file), "utf8")
|
|
.split("\n")
|
|
.filter((l) => l.trim().length > 0)
|
|
.map((l) => JSON.parse(l) as RoomLine);
|
|
if (lines.length > 0 && lines[0]!.kind === "meta") rooms.set(id, lines);
|
|
} catch (e) {
|
|
console.error(`skipping unreadable room file ${file}:`, e);
|
|
}
|
|
}
|
|
return rooms;
|
|
}
|