A "seats" button beside "transfer seat" opens the host's panel. In a game under way, "give to the clockwork" lets an automaton play a person's seat from the next beat, with their hand, treasures and place untouched; a standIn line on the ledger records it, the chronicle says so, the stand-in never talks under the person's name, and the person's token opens nothing after. "Make a phrase" mints the four-word phrase for any person's seat; claiming it re-seats on a new token, and a seat the clockwork stood in for goes back to its person mid-turn. A rematch holds a stood-in seat for its person. The gallery now has its own claim box, for a player who opens the invite link in a fresh browser. The client's watchdog pinged and then closed a socket quiet for 45 seconds. A background tab's timers run about once a minute, so the last pong was always a minute old when it looked, and idle tabs reconnected every 58 seconds. It now closes only when its own ping went unanswered. The server pings every socket at the protocol level every 30 seconds and drops one that left the last ping unanswered, so a crashed browser's socket no longer lingers until TCP gives up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
353 lines
12 KiB
TypeScript
353 lines
12 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 host opened (or closed) the Peanut Gallery's mouth. */
|
|
export interface GalleryTalkLine {
|
|
kind: "galleryTalk";
|
|
on: boolean;
|
|
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;
|
|
}
|
|
|
|
/** A person's seat got a new token, from the keeper or from a phrase the
|
|
* host made, and the old one opens nothing after. A seat the clockwork was
|
|
* standing in for goes back to a person. */
|
|
export interface ReseatLine {
|
|
kind: "reseat";
|
|
name: string;
|
|
tokenHash: string;
|
|
at: string;
|
|
}
|
|
|
|
/** The host gave a person's seat to the clockwork in a game under way: the
|
|
* wizard keeps their hand and place, the automaton plays their turns, and
|
|
* the person's token opens nothing until a reseat gives the seat back. */
|
|
export interface StandInLine {
|
|
kind: "standIn";
|
|
name: string;
|
|
by: string;
|
|
style: string;
|
|
tier: string;
|
|
at: string;
|
|
}
|
|
|
|
export interface AbandonLine {
|
|
kind: "abandon";
|
|
by: string;
|
|
at: string;
|
|
}
|
|
|
|
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | StandInLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
|
|
|
|
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;
|
|
/** The whole exchange in order: the desk's replies and the player's answers. */
|
|
thread: { at: string; text: string; from: "desk" | "player"; status?: string }[];
|
|
/** A screenshot's file name under feedback-images/, when one was sent. */
|
|
image?: string;
|
|
}
|
|
|
|
/** Where players' screenshots rest, beside the reports. */
|
|
export function feedbackImageDir(): string {
|
|
return join(dataRoot(), "feedback-images");
|
|
}
|
|
|
|
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 && typeof line.image === "string") {
|
|
report.image = line.image;
|
|
} else if (report && line.from === "player") {
|
|
report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), from: "player" });
|
|
} else if (report) {
|
|
report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"), from: "desk" });
|
|
}
|
|
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 ?? ""),
|
|
thread: [],
|
|
});
|
|
}
|
|
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;
|
|
}
|