Files
wizwar6e/packages/server/src/rooms.ts
T
Eric WagonerandClaude Fable 5 8d308bc606 Rev 20: the junction wall rolls for its allegiance
The rulebook (Cards And Actions That Change The Map): a CREATE WALL
or DESTROY WALL alteration on a junction between sectors rolls a
50-50 when "some other moron" rotates one of them — 1-2 stays with
the standing sector, 3-4 travels with the moving one, per the book's
own low/high convention. Both rotation and relocation roll (the same
seam tears either way), demolished holes roll alongside conjured
walls, the pips land loud in the chronicle, and a traveling edge
rides anchored to its in-sector cell. Rev-gated: the roll consumes
dice that stored games never spent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:34:33 -04:00

547 lines
20 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,
} from "@wizwar/engine";
import { appendLine, 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>();
/** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 20;
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;
}
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 };
}
/** 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, RULES_REV);
if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion, colors, deckRev: 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.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[] } | 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 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.
if (chosen) 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 };
}
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;
}
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" };
if (!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);
}
// ---------------------------------------------------------------------------
// 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(),
};
for (const line of lines.slice(1)) {
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`);
}