// 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, renameSync, statSync, writeFileSync } 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; /** A rematch: the finished room it follows, and the wizards it waits for. */ rematchOf?: string; expected?: string[]; /** The lobby's expansion default, carried over from the last table. */ expansion?: boolean; } /** A table called the keeper: the seat is held and the keeper told. */ export interface ChallengeLine { kind: "challenge"; by: string; at: string; } /** The finished table called for a rematch: the new room it moved to. */ export interface RematchLine { kind: "rematch"; to: string; by: string; at: 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 interface KickLine { kind: "kick"; /** The seat the host removed from an unstarted room. */ name: string; at: string; } export interface AbandonLine { kind: "abandon"; by: string; at: string; } export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine; const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); /** Room ids reach the filesystem, so only the room-code alphabet may pass — * anything else (dots, slashes, control bytes) is a traversal attempt. */ function safeRoomId(roomId: string): boolean { return /^[A-Z0-9]{1,8}$/.test(roomId); } function fileFor(roomId: string): string { if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`); return join(DATA_DIR, `${roomId}.jsonl`); } /** The data root — the parent of the rooms dir — where everything that * is not a room ledger lives: stats, feedback, stubs, the graveyard, the * clip vault. */ export function dataRoot(): 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 safeRoomId(roomId) && existsSync(fileFor(roomId)); } export function appendLine(roomId: string, line: RoomLine): void { appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8"); } /** Read one persisted room's lines, or null if it has no ledger. */ export function readRoom(roomId: string): RoomLine[] | null { if (!safeRoomId(roomId)) return null; const file = fileFor(roomId); if (!existsSync(file)) return null; try { const lines = readFileSync(file, "utf8") .split("\n") .filter((l) => l.trim().length > 0) .map((l) => JSON.parse(l) as RoomLine); return lines.length > 0 && lines[0]!.kind === "meta" ? lines : null; } catch (e) { console.error(`unreadable room file ${roomId}:`, e); return null; } } /** How many rooms have ledgers on disk — the server-wide cap counts these, * not just the rooms currently awake in memory. */ export function countRoomFiles(): number { ensureDataDir(); return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).length; } /** Every room with a ledger on disk. */ export function listRoomIds(): string[] { ensureDataDir(); return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -".jsonl".length)); } /** A ledger's size and the moment of its last line. */ export function ledgerStat(roomId: string): { bytes: number; mtimeMs: number } | null { if (!safeRoomId(roomId)) return null; const file = fileFor(roomId); if (!existsSync(file)) return null; const st = statSync(file); return { bytes: st.size, mtimeMs: st.mtimeMs }; } // --- Stubs of sleeping rooms. --------------------------------------------- // A room put to sleep leaves a stub beside the ledgers: what the games // ledger needs to answer for it, and the ledger's size at that moment. A // ledger only ever grows, so at boot a stub whose size still matches is // the room's whole truth and the room stays asleep, unreplayed. const stubDir = () => join(dataRoot(), "stubs"); function stubFor(roomId: string): string { if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`); return join(stubDir(), `${roomId}.json`); } export function readStubFile(roomId: string): { bytes: number; stub: T } | null { if (!safeRoomId(roomId)) return null; const file = stubFor(roomId); if (!existsSync(file)) return null; try { const parsed = JSON.parse(readFileSync(file, "utf8")) as { bytes: number; stub: T }; return typeof parsed.bytes === "number" && parsed.stub ? parsed : null; } catch (e) { console.error(`unreadable stub file ${roomId}:`, e); return null; } } export function writeStubFile(roomId: string, bytes: number, stub: T): void { const file = stubFor(roomId); mkdirSync(stubDir(), { recursive: true }); writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8"); renameSync(file + ".tmp", file); } /** Player surprise reports and the wizards' replies share one JSONL file * beside the rooms directory — a report line carries roomId/seq pinning * its moment; a reply line carries reportId/status/text and folds onto * its report when read. Legacy reports without an id answer to their * timestamp. A report line may carry fields (deckRev, player context) * that only the operator's raw read uses; readFeedback keeps the * player-facing subset. */ const feedbackFile = () => join(dataRoot(), "feedback.jsonl"); export function appendFeedback(entry: Record): void { ensureDataDir(); appendFileSync(feedbackFile(), JSON.stringify(entry) + "\n", "utf8"); } export interface FeedbackReport { id: string; at: string; roomId: string; player: string; seq: number; round: number | null; happened: string; expected: string; reply?: { at: string; text: string; status: string }; } export function readFeedback(): FeedbackReport[] { const file = feedbackFile(); if (!existsSync(file)) return []; const reports = new Map(); for (const raw of readFileSync(file, "utf8").split("\n")) { if (!raw.trim()) continue; let line: Record; try { line = JSON.parse(raw); } catch { continue; } if (typeof line.reportId === "string") { const report = reports.get(line.reportId); if (report) { report.reply = { at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"), }; } continue; } const id = String(line.id ?? line.at ?? ""); if (!id) continue; reports.set(id, { id, at: String(line.at ?? ""), roomId: String(line.roomId ?? ""), player: String(line.player ?? ""), seq: Number(line.seq ?? 0), round: line.round == null ? null : Number(line.round), happened: String(line.happened ?? ""), expected: String(line.expected ?? ""), }); } return [...reports.values()]; } /** Retire an abandoned room's ledger to the graveyard — never deleted, * only moved out of the living rooms directory. */ export function archiveRoomFile(roomId: string): void { const src = fileFor(roomId); if (!existsSync(src)) return; const graveyard = join(dataRoot(), "rooms-abandoned"); mkdirSync(graveyard, { recursive: true }); renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`)); } // --- The clip vault. ------------------------------------------------------ // Showcase clips live beside the rooms as plain files — -fpv.mp4, // -board.mp4, .jpg — described by clips.json, all published by // deploy/clips-publish.sh. The server only ever reads them. export interface ClipMeta { name: string; title: string; blurb: string; seconds: number; /** Frame size of the first-person take, stamped by deploy/clips-prep.mjs; * unfurlers won't embed a player without it. Absent on an unprepped set. */ width?: number; height?: number; } const clipsDir = () => join(dataRoot(), "clips"); /** A clip's name, which is also its file stem: no separators, no dots, * so a name can never name a path. */ const SLUG = "[a-z0-9-]{1,60}"; export const CLIP_SLUG = new RegExp(`^${SLUG}$`); const CLIP_FILE = new RegExp(`^${SLUG}(?:(?:-fpv|-board)\\.mp4|(?:-card)?\\.jpg)$`); export function readClips(): ClipMeta[] { const file = join(clipsDir(), "clips.json"); if (!existsSync(file)) return []; try { const parsed = JSON.parse(readFileSync(file, "utf8")) as ClipMeta[]; return parsed.filter((c) => CLIP_SLUG.test(c.name)); } catch { return []; } } /** Resolve a clip asset request to its path — or null for any name that * is not exactly a published clip file: -fpv.mp4, -board.mp4, * .jpg, -card.jpg. The pattern is the only path guard. */ export function clipAssetPath(file: string): string | null { if (!CLIP_FILE.test(file)) return null; const path = join(clipsDir(), file); return existsSync(path) ? path : null; }