Files
wizwar6e/packages/server/src/store.ts
T
Eric WagonerandClaude Fable 5.1 1be1cf7d1b A lobby may challenge the keeper, and the table can talk before the boards flip
"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
2026-09-17 00:30:49 -04:00

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;
}