Files
wizwar6e/packages/server/src/rooms.ts
T
Eric WagonerandClaude Fable 5 3bd7e3c81d Credibility pass: the duels leave the code, staying in the ledgers
Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.

Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.

Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.

Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.

Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.

288 tests, 24 ledgers verified, all workspaces typecheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:43:24 -04:00

685 lines
26 KiB
TypeScript

// Game rooms: the server is the authority. Each room holds one GameState and
// the append-only command log (the seed + log IS the game — the basis for
// replays, async play, and crash recovery). Every join, start, and command is
// persisted; on boot, rooms are rebuilt by replaying their files.
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
import {
applyCommand,
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
createGame,
redactEvent,
viewFor,
type AutomatonStyle,
type AutomatonTier,
type Command,
type GameEvent,
type GameState,
type GameView,
type PlayerId,
CURRENT_RULES_REV,
} from "@wizwar/engine";
import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats";
export interface LoggedCommand {
seq: number;
playerId: PlayerId;
command: Command;
at: string; // ISO timestamp (server-side wall clock; not used by the engine)
}
export interface Room {
id: string;
hostId: PlayerId;
players: PlayerId[]; // join order
/** Per-player secret HASHES: reclaiming a seat requires the raw token. */
tokens: Map<PlayerId, string>;
seed: number;
expansion: boolean;
/** Lobby standee choices (colorIndex 0-5), by player. */
colorChoices: Map<PlayerId, number>;
createdAt: string;
state: GameState | null; // null until started
log: LoggedCommand[];
events: GameEvent[]; // full history (unredacted — redact per recipient)
/** Table talk, persisted with the room (public to all seats). */
chat: { player: PlayerId; text: string; at: string }[];
/** Seats the server itself plays: temperament, tier, and secrecy. */
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
}
const rooms = new Map<string, Room>();
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
/** Public seat check for protocol handlers (timing-safe under the hood). */
export function seatTokenValid(room: Room, playerId: PlayerId, raw: string | null): boolean {
return tokenMatches(room, playerId, raw);
}
function tokenMatches(room: Room, playerId: PlayerId, raw: string | null): boolean {
if (!raw) return false;
const stored = room.tokens.get(playerId);
if (!stored) return false;
const a = Buffer.from(stored, "hex");
const b = Buffer.from(hashToken(raw), "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
function makeRoomCode(): string {
let code = "";
for (let i = 0; i < 4; i++) {
code += ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)];
}
// The disk check covers rooms that exist on file but failed to restore —
// reusing such a code would append a new game into the orphaned file.
return rooms.has(code) || roomFileExists(code) ? makeRoomCode() : code;
}
export function roomCount(): number {
return rooms.size;
}
/** Every restored, still-running room — so boot can wake their bot pumps. */
export function runningRooms(): Room[] {
return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing");
}
export function createRoom(hostId: PlayerId): { room: Room; token: string } {
const token = randomBytes(16).toString("hex");
const room: Room = {
id: makeRoomCode(),
hostId,
players: [hostId],
tokens: new Map([[hostId, hashToken(token)]]),
seed: randomInt(0, 0xffffffff),
expansion: false,
colorChoices: new Map(),
createdAt: new Date().toISOString(),
state: null,
log: [],
events: [],
chat: [],
bots: new Map(),
};
rooms.set(room.id, room);
recordRoom(room);
appendLine(room.id, {
kind: "meta",
id: room.id,
hostId,
hostTokenHash: hashToken(token),
seed: room.seed,
createdAt: new Date().toISOString(),
});
return { room, token };
}
export function getRoom(id: string): Room | undefined {
return rooms.get(id.toUpperCase());
}
export function joinRoom(
room: Room,
playerId: PlayerId,
token: string | null,
): { token: string } | { error: string } {
if (room.players.includes(playerId)) {
// Reclaiming an existing seat requires that seat's secret.
if (tokenMatches(room, playerId, token)) return { token: token! };
return { error: "that wizard name is taken in this room" };
}
if (room.state) return { error: "game already started" };
if (room.players.length >= 6) return { error: "room is full" };
const fresh = randomBytes(16).toString("hex");
room.players.push(playerId);
room.tokens.set(playerId, hashToken(fresh));
appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) });
recordRoom(room);
return { token: fresh };
}
/** The host shows an unclaimed (or unwanted) seat the door — lobby only:
* once the deal happens, every seat is a player in the game's record. */
export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null {
if (byId !== room.hostId) return "only the host may clear a seat";
if (room.state) return "the game has started — seats are settled";
if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead";
if (!room.players.includes(name)) return "no such seat";
room.players = room.players.filter((p) => p !== name);
room.tokens.delete(name);
room.bots.delete(name);
room.colorChoices.delete(name);
appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
recordRoom(room);
return null;
}
/** The host dissolves the room: a lobby that never dealt, or a finished
* game done being remembered. The ledger is archived, never deleted. */
export function abandonRoom(room: Room, byId: PlayerId): string | null {
if (byId !== room.hostId) return "only the host may abandon the room";
if (room.state && room.state.phase === "playing") return "the game is still being played";
appendLine(room.id, { kind: "abandon", by: byId, at: new Date().toISOString() });
archiveRoomFile(room.id);
rooms.delete(room.id);
return null;
}
/** Everyone gets their chosen standee; the undecided get the first free one. */
export function resolveColors(room: Room): number[] {
const taken = new Set<number>();
const resolved: number[] = [];
for (const p of room.players) {
const choice = room.colorChoices.get(p);
if (choice !== undefined && !taken.has(choice)) {
resolved.push(choice);
taken.add(choice);
} else {
const free = [0, 1, 2, 3, 4, 5].find((c) => !taken.has(c))!;
resolved.push(free);
taken.add(free);
}
}
return resolved;
}
export function pickColor(room: Room, playerId: PlayerId, color: number): string | null {
if (room.state) return "the game has started — your robes are dyed";
if (!room.players.includes(playerId)) return "you hold no seat in this room";
if (!Number.isInteger(color) || color < 0 || color > 5) return "no such wizard";
for (const [other, c] of room.colorChoices) {
if (other !== playerId && c === color) return "that wizard has been claimed";
}
room.colorChoices.set(playerId, color);
return null;
}
function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRev?: number): { events: GameEvent[] } | { error: string } {
const n = room.players.length;
if (n < 2 || n > 6) return { error: "supported player counts: 2 to 6" };
const { state, events } = createGame({
playerIds: room.players,
seed: room.seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
...(deckRev ? { deckRev } : {}),
});
room.expansion = expansion;
room.state = state;
room.events.push(...events);
return { events };
}
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
const colors = resolveColors(room);
const result = startInMemory(room, expansion, colors, CURRENT_RULES_REV);
if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion, colors, deckRev: CURRENT_RULES_REV });
recordRoom(room);
return result;
}
export function runCommand(
room: Room,
playerId: PlayerId,
command: Command,
): { events: GameEvent[] } | { error: string } {
if (!room.state) return { error: "game not started" };
const result = applyCommand(room.state, playerId, command);
if (!result.ok) return { error: result.error };
room.state = result.state;
const logged: LoggedCommand = {
seq: room.log.length,
playerId,
command,
at: new Date().toISOString(),
};
room.log.push(logged);
room.events.push(...result.events);
appendLine(room.id, { kind: "command", ...logged });
if (room.state.phase === "finished") recordRoom(room);
return { events: result.events };
}
const CHAT_MAX_LENGTH = 300;
const CHAT_KEEP = 500;
export function addChat(room: Room, playerId: PlayerId, rawText: string): { text: string; at: string } | { error: string } {
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
const text = rawText.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, CHAT_MAX_LENGTH);
if (!text) return { error: "say something" };
const at = new Date().toISOString();
room.chat.push({ player: playerId, text, at });
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
// Talk takes its place in the chronicle where it was said, so rejoining
// players see it interwoven with the action, not sunk to the bottom.
room.events.push({ type: "tableTalk", player: playerId, text });
appendLine(room.id, { kind: "chat", player: playerId, text, at });
return { text, at };
}
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
/** Seat a clockwork wizard (host's choice, before the game starts). */
export function addAutomaton(
room: Room,
styleWanted?: string,
tierWanted?: string,
): { name: PlayerId } | { error: string } {
if (room.state) return { error: "the game has started" };
if (room.players.length >= 6) return { error: "room is full" };
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
if (!name) return { error: "the workshop is empty" };
const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle);
const style: AutomatonStyle = known
? (styleWanted as AutomatonStyle)
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
const secret = !known; // the mystery machine keeps its mood to itself
const tier: AutomatonTier = AUTOMATON_TIERS.includes(tierWanted as AutomatonTier)
? (tierWanted as AutomatonTier)
: "adept";
room.players.push(name);
room.bots.set(name, { style, secret, tier });
appendLine(room.id, { kind: "join", name, bot: true, style, tier, ...(secret ? { secret: true } : {}) });
return { name };
}
/** Whose input does the maze want right now? */
function actingSeat(room: Room): PlayerId | null {
const s = room.state;
if (!s || s.phase !== "playing") return null;
return (
s.wardPending?.ownerId ??
s.stack?.waitingOn ??
s.pendingDiscard ??
s.chaosPending?.queue[0] ??
s.outOfTurnWindow?.playerId ??
s.players[s.turn.activeIndex]!.id
);
}
/**
* One automaton command, if the maze is waiting on clockwork. Null when a
* human holds the floor (or the game is over, or the clockwork is wedged).
*/
export function driveOneAutomaton(
room: Room,
): { seat: PlayerId; events: GameEvent[]; hesitated?: true } | null {
const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat);
const chosen = automatonCommand(view, bot?.style, bot?.tier);
let hesitated: true | undefined;
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) {
// A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass.
// The refusal is remembered: a brain whose choice the engine rejects
// is a bug in the brain, and the table deserves to see the stumble
// rather than an unexplained idle turn.
if (chosen) {
hesitated = true;
console.warn(
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
}
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) {
console.error(`automaton ${seat} wedged in ${room.id}: ${r.error}`);
return null;
}
}
return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
}
export interface GameSummary {
roomId: string;
name: PlayerId;
players: PlayerId[];
started: boolean;
finished: boolean;
winner: PlayerId | null;
activePlayerId: PlayerId | null;
yourTurn: boolean;
/** WHY it is your turn: a normal turn, or an out-of-turn demand. */
attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null;
lastMoveAt: string | null;
/** Total table-talk messages; the client tracks which it has seen. */
chatCount: number;
}
/** A seat-holder's one-line view of a room, for the lobby ledger. */
export function summarize(room: Room, playerId: PlayerId): GameSummary {
const s = room.state;
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null;
const turnHolder = waitingOn ?? active;
let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null;
if (s?.phase === "playing" && turnHolder === playerId) {
attention =
s.stack?.waitingOn === playerId ? "counteract"
: s.chaosPending?.queue[0] === playerId ? "counteract"
: s.pendingDiscard === playerId ? "discard"
: s.outOfTurnWindow?.playerId === playerId ? "interrupt"
: "turn";
}
return {
roomId: room.id,
name: playerId,
players: [...room.players],
started: s !== null,
finished: s?.phase === "finished",
winner: s?.winner ?? null,
activePlayerId: active,
yourTurn: s?.phase === "playing" && turnHolder === playerId,
attention,
round: s?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
chatCount: room.chat.length,
};
}
export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
return room.state ? viewFor(room.state, playerId) : null;
}
/** The Peanut Gallery's viewer id: the empty name can never hold a seat
* (joins reject blank names), so a view built for it shows public knowledge
* only — no hand, no ward, no ambushes, no boobytrap truths — and event
* redaction drops everything marked visibleTo a player. */
export const SPECTATOR: PlayerId = "";
export interface CatchUpStep {
seq: number;
actor: PlayerId;
events: GameEvent[];
view: GameView;
/** Table talk uttered between the previous command and this one. */
chat?: { player: PlayerId; text: string }[];
}
/**
* Rebuild the game and capture a redacted view after each command from
* `sinceSeq` on — the "what happened while you were away" reel.
*/
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, full = false): CatchUpStep[] | { error: string } {
if (!room.state) return { error: "game not started" };
// The SPECTATOR builds whole-game share pages: public knowledge, no seat.
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
return { error: "you hold no seat in this room" };
}
if (full && room.state.phase !== "finished") return { error: "full replays wait for the game to finish" };
const MAX_STEPS = 200;
const from = full ? 0 : Math.max(sinceSeq, room.log.length - MAX_STEPS);
const { state: fresh } = createGame(room.state.config);
let current = fresh;
const steps: CatchUpStep[] = [];
for (const entry of room.log) {
const result = applyCommand(current, entry.playerId, entry.command);
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
current = result.state;
if (entry.seq >= from) {
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
const said = room.chat
.filter((c) => c.at > prevAt && c.at <= entry.at)
.map((c) => ({ player: c.player, text: c.text }));
steps.push({
seq: entry.seq,
actor: entry.playerId,
events: redactFor(result.events, playerId),
view: viewFor(current, playerId),
...(said.length > 0 ? { chat: said } : {}),
});
}
}
return steps;
}
export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
}
/** The events that begin a turn — the client's chronicle counts these
* identically, so a turn number names the same stretch on both ends. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
export interface MomentReel {
steps: CatchUpStep[];
/** The wizard whose turn this is — the reel's eyes. */
owner: PlayerId;
round: number;
}
/**
* One turn's reel: every command whose events touch turn `turnIndex`
* (0-counted across boundary events). A command that ends one turn and
* starts the next belongs to both, so a reel opens with the blow that
* began it and closes on the handover. The owner comes from the very
* boundary that opened the turn — which for turn 0 lives in the DEAL,
* before any command, so no scan of the steps could find it.
*/
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): MomentReel | { error: string } {
if (!room.state) return { error: "game not started" };
// The SPECTATOR builds share pages: public knowledge only, no seat.
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
return { error: "you hold no seat in this room" };
}
const MAX_STEPS = 80;
const { state: fresh, events: dealt } = createGame(room.state.config);
let current = fresh;
// The deal's own events open the first turn — count them so turn
// numbers match the client chronicle's.
let counter = -1;
let owner: PlayerId | null = null;
let round = 0;
const bump = (e: GameEvent) => {
if (!TURN_BOUNDARY.has(e.type)) return;
counter++;
if (counter === turnIndex && owner === null && "player" in e) {
owner = (e as { player: PlayerId }).player;
if ("round" in e) round = (e as { round: number }).round;
}
};
for (const e of dealt) bump(e);
const steps: CatchUpStep[] = [];
for (const entry of room.log) {
const result = applyCommand(current, entry.playerId, entry.command);
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
current = result.state;
const before = counter;
// The straddle commands hold more than this turn: the opener carries
// the previous wizard's last breaths, the closer the NEXT wizard's
// first. Trim both so the reel is exactly one turn — it opens on
// "X's turn begins" and stops at X's turnEnded.
let sliceStart = 0;
let sliceEnd = result.events.length;
result.events.forEach((e, i) => {
if (!TURN_BOUNDARY.has(e.type)) return;
bump(e);
if (counter === turnIndex && before < turnIndex) sliceStart = i;
if (counter === turnIndex + 1 && sliceEnd === result.events.length) sliceEnd = i;
});
if (before > turnIndex) break;
if (before <= turnIndex && turnIndex <= counter) {
const kept = result.events.slice(sliceStart, sliceEnd);
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
const said = room.chat
.filter((c) => c.at > prevAt && c.at <= entry.at)
.map((c) => ({ player: c.player, text: c.text }));
steps.push({
seq: entry.seq,
actor: entry.playerId,
events: redactFor(kept, playerId),
view: viewFor(current, playerId),
...(said.length > 0 ? { chat: said } : {}),
});
if (steps.length >= MAX_STEPS) break;
}
}
if (steps.length === 0) return { error: "no such turn yet" };
return { steps, owner: owner ?? steps[steps.length - 1]!.actor, round };
}
// ---------------------------------------------------------------------------
// Seat transfers: a spoken-word one-time code hands a seat to another device.
// Ephemeral by design — a server restart voids pending codes, never seats.
const TRANSFER_WORDS = [
"ember", "troll", "dagger", "raven", "flagon", "goat", "wand", "rune",
"moss", "torch", "skull", "frost", "amber", "wisp", "cellar", "gable",
"onyx", "briar", "tome", "cinder", "gloom", "spiral", "hex", "mirror",
"portal", "quill", "shade", "tusk", "vault", "wyrm", "zephyr", "idol",
"anvil", "bramble", "crypt", "dusk", "fable", "grotto", "hollow", "ivory",
"jinx", "keep", "lantern", "marsh", "nettle", "oath", "plume", "quartz",
"relic", "sconce", "talon", "umber", "vellum", "warden", "yarrow", "zeal",
"bastion", "chalice", "drake", "eaves", "fen", "gargoyle", "harrow", "imp",
] as const; // 64 words; 4-word phrases = 64^4 = ~16.7M combinations (~24 bits)
interface PendingTransfer {
roomId: string;
name: PlayerId;
token: string;
expiresAt: number;
}
const transfers = new Map<string, PendingTransfer>();
const TRANSFER_TTL_MS = 10 * 60 * 1000;
export function makeTransferCode(
room: Room,
playerId: PlayerId,
rawToken: string | null,
): { code: string; expiresAt: number } | { error: string } {
if (!tokenMatches(room, playerId, rawToken)) return { error: "you hold no seat in this room" };
const token = rawToken!;
// Sweep expired codes while we are here.
const now = Date.now();
for (const [code, t] of transfers) {
if (t.expiresAt < now) transfers.delete(code);
}
if (transfers.size >= 200) return { error: "too many transfers pending — try again in a few minutes" };
let code: string;
do {
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
} while (transfers.has(code));
const expiresAt = now + TRANSFER_TTL_MS;
transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt });
return { code, expiresAt };
}
// Brute-force backstop: too many failed claims globally voids every pending
// code (they cost nothing to re-mint) and cools the endpoint off.
let failedClaims = 0;
let failWindowStart = 0;
const FAIL_WINDOW_MS = 10 * 60 * 1000;
const FAIL_LIMIT = 30;
export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } {
const now = Date.now();
if (now - failWindowStart > FAIL_WINDOW_MS) {
failWindowStart = now;
failedClaims = 0;
}
if (failedClaims >= FAIL_LIMIT) {
return { error: "too many failed claims — transfers are cooling off, mint a fresh phrase" };
}
const normalized = code.trim().toLowerCase().replace(/\s+/g, "-");
const t = transfers.get(normalized);
if (!t || t.expiresAt < now) {
failedClaims++;
if (failedClaims >= FAIL_LIMIT) transfers.clear();
return { error: "that transfer phrase is unknown or has expired" };
}
transfers.delete(normalized); // one-time
const room = rooms.get(t.roomId);
if (!room || !tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" };
return { roomId: t.roomId, name: t.name, token: t.token };
}
/** Rebuild every persisted room by replaying its file. */
export function loadPersistedRooms(): void {
ensureDataDir();
let restored = 0;
for (const [id, lines] of readAllRooms()) {
try {
const meta = lines[0] as Extract<RoomLine, { kind: "meta" }>;
const hostHash = meta.hostTokenHash ?? (meta.hostToken ? hashToken(meta.hostToken) : null);
if (!hostHash) throw new Error("meta line has no host token");
const room: Room = {
id,
hostId: meta.hostId,
players: [meta.hostId],
tokens: new Map([[meta.hostId, hostHash]]),
seed: meta.seed,
expansion: false,
colorChoices: new Map(),
createdAt: meta.createdAt,
state: null,
log: [],
events: [],
chat: [],
bots: new Map(),
};
if (lines.some((l) => l.kind === "abandon")) continue;
for (const line of lines.slice(1)) {
if (line.kind === "kick") {
room.players = room.players.filter((p) => p !== line.name);
room.tokens.delete(line.name);
room.bots.delete(line.name);
room.colorChoices.delete(line.name);
} else if (line.kind === "join") {
if (line.bot) {
room.players.push(line.name);
room.bots.set(line.name, {
style: (line.style as AutomatonStyle) ?? "hunter",
secret: line.secret === true,
// Tier-less join lines predate tiers, when every automaton
// played the full repertoire — not the "adept" lobby default.
tier: (line.tier as AutomatonTier) ?? "archmage",
});
} else {
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
if (!joinHash) throw new Error("join line has no token");
room.players.push(line.name);
room.tokens.set(line.name, joinHash);
}
} else if (line.kind === "start") {
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
} else if (line.kind === "chat") {
// File order preserves the interleaving with commands.
room.chat.push({ player: line.player, text: line.text, at: line.at });
room.events.push({ type: "tableTalk", player: line.player, text: line.text });
} else if (line.kind === "command") {
if (!room.state) throw new Error("command before start in log");
const result = applyCommand(room.state, line.playerId, line.command as Command);
if (!result.ok) throw new Error(`replay failed at seq ${line.seq}: ${result.error}`);
room.state = result.state;
room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at });
room.events.push(...result.events);
}
}
rooms.set(id, room);
recordRoom(room);
restored++;
} catch (e) {
console.error(`could not restore room ${id}:`, e);
}
}
if (restored > 0) console.log(`restored ${restored} room(s) from disk`);
}