Operational: auto-reboot for kernel patches (09:30 UTC), Caddy access logs at /var/lib/caddy (self-rotating; the sandbox denies /var/log), logrotate for the backup log, a restore drill proving a Spaces snapshot boots 56/56 rooms clean, the about page's plain sentence on permanent recording, and /wizwar-pulse to read it all weekly. Rules: safes now smash — attacks aimed at the box accumulate on it and the fifteenth point bursts it (widening, ungated). And SLOW DEATH's bites pause for a victim holding an ABSORB — the card named by the FAQ, not Absorb Spell — each soaking exactly one point, rev-gated at 13 with the instant-bite legacy pinned. Bots weigh the soak against their life. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
791 lines
30 KiB
TypeScript
791 lines
30 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, countRoomFiles, ensureDataDir, readAllRooms, readRoom, 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 }>;
|
|
/** Last time anything looked at this room (memory eviction clock). */
|
|
touchedAt?: number;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function tokenMatches(room: { tokens: Map<PlayerId, string> }, 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 countRoomFiles();
|
|
}
|
|
|
|
/** 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(),
|
|
touchedAt: Date.now(),
|
|
};
|
|
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 {
|
|
const code = id.toUpperCase();
|
|
const live = rooms.get(code);
|
|
if (live) {
|
|
live.touchedAt = Date.now();
|
|
return live;
|
|
}
|
|
// A room out of memory sleeps in its ledger; wake it on demand.
|
|
const lines = readRoom(code);
|
|
if (!lines) return undefined;
|
|
try {
|
|
const room = rebuildRoom(code, lines);
|
|
if (!room) return undefined;
|
|
room.touchedAt = Date.now();
|
|
rooms.set(code, room);
|
|
sleepingStubs.delete(code);
|
|
return room;
|
|
} catch (e) {
|
|
console.error(`could not wake room ${code}:`, e);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Put idle rooms back to sleep: their ledger holds everything, so memory
|
|
* only carries rooms someone is actually at. A room leaves when no socket
|
|
* is attached and it has sat untouched past its allowance — a finished
|
|
* game soon, anything else after a day. */
|
|
export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number {
|
|
const now = Date.now();
|
|
const FINISHED_MS = Number(process.env.WIZWAR_EVICT_FINISHED_MS ?? 30 * 60_000);
|
|
const IDLE_MS = Number(process.env.WIZWAR_EVICT_IDLE_MS ?? 24 * 60 * 60_000);
|
|
let evicted = 0;
|
|
for (const [id, room] of rooms) {
|
|
if (hasSockets(id)) continue;
|
|
const idle = now - (room.touchedAt ?? now);
|
|
const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS;
|
|
if (idle < allowance) continue;
|
|
sleepingStubs.set(id, stubOf(room));
|
|
rooms.delete(id);
|
|
evicted++;
|
|
}
|
|
return evicted;
|
|
}
|
|
|
|
/** What the games ledger needs from a sleeping room, snapped at eviction —
|
|
* a sleeping room cannot change, so its stub stays true until it wakes. */
|
|
interface RoomStub {
|
|
tokens: Map<PlayerId, string>;
|
|
turnHolder: PlayerId | null;
|
|
waitKind: "counteract" | "discard" | "interrupt" | null;
|
|
playing: boolean;
|
|
base: Omit<GameSummary, "name" | "yourTurn" | "attention">;
|
|
}
|
|
const sleepingStubs = new Map<string, RoomStub>();
|
|
|
|
function stubOf(room: Room): RoomStub {
|
|
const { active, turnHolder, waitKind } = turnFacts(room.state);
|
|
return {
|
|
tokens: new Map(room.tokens),
|
|
turnHolder,
|
|
waitKind,
|
|
playing: room.state?.phase === "playing",
|
|
base: {
|
|
roomId: room.id,
|
|
players: [...room.players],
|
|
started: room.state !== null,
|
|
finished: room.state?.phase === "finished",
|
|
winner: room.state?.winner ?? null,
|
|
activePlayerId: active,
|
|
round: room.state?.turn.round ?? null,
|
|
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
|
|
chatCount: room.chat.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** The games ledger looks without waking: a live room answers live, a
|
|
* sleeping one answers from its stub, and neither look counts as a
|
|
* touch — polling must never keep a room warm or drag one out of bed. */
|
|
export function peekSummary(
|
|
roomId: string, playerId: PlayerId, token: string | null,
|
|
): GameSummary | "badToken" | null {
|
|
const code = roomId.toUpperCase();
|
|
const live = rooms.get(code);
|
|
if (live) {
|
|
if (!tokenMatches(live, playerId, token)) return "badToken";
|
|
return summarize(live, playerId);
|
|
}
|
|
const stub = sleepingStubs.get(code);
|
|
if (!stub) return null;
|
|
if (!tokenMatches(stub, playerId, token)) return "badToken";
|
|
const yourTurn = stub.playing && stub.turnHolder === playerId;
|
|
return { ...stub.base, name: playerId, yourTurn, attention: yourTurn ? (stub.waitKind ?? "turn") : null };
|
|
}
|
|
|
|
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.slowDeathPending?.playerId ??
|
|
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. */
|
|
/** Who holds the table's attention, and why — the summary's turn facts. */
|
|
function turnFacts(s: GameState | null): {
|
|
active: PlayerId | null;
|
|
turnHolder: PlayerId | null;
|
|
waitKind: "counteract" | "discard" | "interrupt" | null;
|
|
} {
|
|
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 waitKind = s == null ? null
|
|
: s.stack?.waitingOn != null ? "counteract" as const
|
|
: s.pendingDiscard != null ? "discard" as const
|
|
: s.chaosPending?.queue[0] != null ? "counteract" as const
|
|
: s.outOfTurnWindow?.playerId != null ? "interrupt" as const
|
|
: null;
|
|
return { active, turnHolder: waitingOn ?? active, waitKind };
|
|
}
|
|
|
|
export function summarize(room: Room, playerId: PlayerId): GameSummary {
|
|
const s = room.state;
|
|
const { active, turnHolder, waitKind } = turnFacts(s);
|
|
const yourTurn = s?.phase === "playing" && turnHolder === playerId;
|
|
return {
|
|
roomId: room.id,
|
|
name: playerId,
|
|
players: [...room.players],
|
|
started: s !== null,
|
|
finished: s?.phase === "finished",
|
|
winner: s?.winner ?? null,
|
|
activePlayerId: active,
|
|
yourTurn,
|
|
attention: yourTurn ? (waitKind ?? "turn") : null,
|
|
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 one room from its ledger lines — the seed plus the log IS the
|
|
* game. Returns null for an abandoned room; throws on a corrupt ledger. */
|
|
function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
|
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(),
|
|
touchedAt: Date.now(),
|
|
};
|
|
if (lines.some((l) => l.kind === "abandon")) return null;
|
|
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);
|
|
}
|
|
}
|
|
return room;
|
|
}
|
|
|
|
/** Rebuild every persisted room by replaying its file. */
|
|
export function loadPersistedRooms(): void {
|
|
ensureDataDir();
|
|
let restored = 0;
|
|
for (const [id, lines] of readAllRooms()) {
|
|
try {
|
|
const room = rebuildRoom(id, lines);
|
|
if (!room) continue;
|
|
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`);
|
|
}
|