"Challenge Kestrel, the keeper" holds a seat under the keeper's name, writes the call to the ledger, says so at the table, and raises a Sentry issue fingerprinted to the room — one ring per call, with the room's link in the message — which is the alarm the keeper's phone listens for. The keeper answers by the link: taking the seat, and leaving a word if the game must wait. For that word, and for any table to settle when to start, the lobby now shows its talk and carries a composer; a joiner sees what was said before they sat. Three calls per address per hour, one per room. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
312 lines
10 KiB
TypeScript
312 lines
10 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, 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<T>(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<T>(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<string, unknown>): 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<string, FeedbackReport>();
|
|
for (const raw of readFileSync(file, "utf8").split("\n")) {
|
|
if (!raw.trim()) continue;
|
|
let line: Record<string, unknown>;
|
|
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 — <slug>-fpv.mp4,
|
|
// <slug>-board.mp4, <slug>.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: <slug>-fpv.mp4, <slug>-board.mp4,
|
|
* <slug>.jpg, <slug>-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;
|
|
}
|