The dream feature lands, behind a preference that ships off for now. The chronicle's lines grow structure — each knows its turn, counted by the same boundary events on both ends of the wire (the server counts the deal's own events too, or every number would sit one turn behind). The first notable line of a turn — combat, spectacle, a targeted cast — wears a small eye; clicking it asks the server for that one turn's steps, rebuilt and redacted like any reel, and the replay opens STRAIGHT into first person through the eyes of the wizard whose turn it was: their position, the viewer's knowledge, nothing private leaked. It plays that turn and stops. Save-video and the board toggle come along for free. Two camera bugs die with it: the replay tween effect tracked its own writes, restarting the ease every frame until walks decayed into the slow wall-piercing drift Eric saw — untracked reads run one tween per step now. And the reel camera follows whichever eyes the reel wears, not always your own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
608 lines
23 KiB
TypeScript
608 lines
23 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).
|
|
* A rules change while games are live must bump this and gate the engine;
|
|
* local hotseat games ride the engine's default and follow in lockstep. */
|
|
const RULES_REV = 1;
|
|
|
|
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 };
|
|
}
|
|
|
|
/** 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.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[] } | 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;
|
|
}
|
|
|
|
/** 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" };
|
|
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);
|
|
}
|
|
|
|
/** 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"]);
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): 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" };
|
|
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, or every
|
|
// turn number would sit one behind the chronicle's.
|
|
let counter = -1;
|
|
for (const e of dealt) if (TURN_BOUNDARY.has(e.type)) counter++;
|
|
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;
|
|
for (const e of result.events) if (TURN_BOUNDARY.has(e.type)) counter++;
|
|
if (before > turnIndex) break;
|
|
if (before <= turnIndex && turnIndex <= counter) {
|
|
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 } : {}),
|
|
});
|
|
if (steps.length >= MAX_STEPS) break;
|
|
}
|
|
}
|
|
if (steps.length === 0) return { error: "no such turn yet" };
|
|
return steps;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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`);
|
|
}
|