// 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, existsSync, 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; /** An automaton seat: no token; the server plays it. */ bot?: true; /** The automaton's temperament. */ style?: string; /** The automaton's difficulty tier. */ tier?: string; /** A mystery machine: the temperament is not revealed to the table. */ secret?: true; } export interface StartLine { kind: "start"; expansion: boolean; /** Final wizard colors, in player join order. */ colors?: number[]; /** Deck revision the game was dealt from; absent = original build. */ deckRev?: number; } export interface CommandLine { kind: "command"; seq: number; playerId: string; command: unknown; at: string; } export interface ChatLine { kind: "chat"; player: string; text: string; at: string; } export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine; 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 }); } /** A room file may exist even when the room failed to restore into memory. */ export function roomFileExists(roomId: string): boolean { return existsSync(fileFor(roomId)); } 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 { ensureDataDir(); const rooms = new Map(); 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; }