"It's not letting me cast Absorb Spell on your Full Shield" — and Jolly's FAQ says exactly why: "ABSORB SPELL will work on FULL REFLECTION or REFLECTION, but not FULL SHIELD, as that is not used against you, but cast upon the player using it." The refusal stands, now with that reason in the error — and the neighboring case the engine wrongly refused is fixed: an attacker may answer a reflection with ABSORB SPELL, nullifying it and taking the reflection card into their hand, with the original spell then landing unturned. And the card face corrects my own hours-old work: ANTI-ANTI "does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE" — so under rules rev 6 it can no longer pin a teleport escape. Earlier games keep their stored chains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
457 lines
17 KiB
TypeScript
457 lines
17 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,
|
|
createGame,
|
|
redactEvent,
|
|
viewFor,
|
|
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 }[];
|
|
}
|
|
|
|
const rooms = new Map<string, Room>();
|
|
|
|
/** Rules revision new games are dealt under (stored games keep their own). */
|
|
const RULES_REV = 6;
|
|
|
|
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: [],
|
|
};
|
|
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);
|
|
appendLine(room.id, { kind: "chat", player: playerId, text, at });
|
|
return { text, at };
|
|
}
|
|
|
|
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: [],
|
|
};
|
|
for (const line of lines.slice(1)) {
|
|
if (line.kind === "join") {
|
|
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") {
|
|
room.chat.push({ player: line.player, text: line.text, at: line.at });
|
|
} 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`);
|
|
}
|