// 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; seed: number; expansion: boolean; /** Lobby standee choices (colorIndex 0-5), by player. */ colorChoices: Map; 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; } const rooms = new Map(); 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(); 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(); 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; 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`); }