Files
wizwar6e/packages/server/src/index.ts
T
Eric WagonerandClaude Fable 5.1 1ebd6dbe47 A refused seat is logged, and the wallet drops a seat only on the second refusal in a row
A lost seat mid-game is the costliest quiet failure this table has: one
refusal around a restart cost Kestrel PRPM's seat with nothing in the
journal to say why.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 23:22:00 -04:00

1096 lines
52 KiB
TypeScript

// Websocket front door. Protocol (JSON messages):
// client -> server:
// {type:"create", name} create a room, become host
// {type:"join", roomId, name, token?} join, or reclaim a seat by token
// {type:"start", expansion?} host starts the game
// {type:"command", command} a game Command for the engine
// {type:"pickColor", color} lobby standee choice (0-5)
// {type:"makeTransfer"} mint a seat-transfer phrase
// {type:"claimTransfer", code} claim a seat on a new device
// {type:"catchUp", sinceSeq} replay of moves missed while away
// {type:"chat", text} table talk to the room
// {type:"feedback", happened, expected} a surprise report, pinned to room+seq
// {type:"myFeedback", seats} your reports + the wizards' replies
// {type:"rollDie"} the tabletop D4, published as talk
// {type:"addBot", style?, tier?} host seats an automaton
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
// {type:"leave"} detach this socket from table or gallery
// {type:"myGames", seats} summaries for held seats
// {type:"stats"} the engagement tally
// {type:"hotseatReport", ...} anonymous hotseat game counts
// server -> client:
// {type:"welcome"} on connect
// {type:"seat", playerId, token} your seat secret — keep it
// {type:"room", roomId, players, hostId, started, audience, colors, bots}
// {type:"events", events, replayed?} redacted for this recipient
// {type:"state", view, seq} redacted full view (after every change)
// {type:"chat", player, text, at} one line of table talk
// {type:"watching", roomId} you are seated in the gallery
// {type:"audience", count} how many watch from the gallery
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"|"feedbackList"}
// {type:"error", message}
import * as Sentry from "@sentry/node";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws";
import { cardDef } from "@wizwar/engine";
import type { Command, PlayerId } from "@wizwar/engine";
import { callKeeper, callRematch,
catchUpSteps,
momentSteps,
claimTransferCode,
createRoom,
pickColor,
getRoom,
joinRoom,
loadPersistedRooms,
evictIdleRooms,
runningRooms,
addAutomaton,
addChat,
driveOneAutomaton,
makeTransferCode,
redactFor,
roomCount,
runCommand,
SPECTATOR,
type CatchUpStep,
startGame,
peekSummary,
viewForPlayer,
type Room,
kickSeat,
abandonRoom,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG } from "./store";
import { clipsIndexHtml, clipPageHtml } from "./clips";
import { SlidingLimit, clientAddress } from "./ratelimit";
import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage";
import { BOT_LINES, type BanterTrigger } from "./banter";
// Errors only, no tracing: the box is small and the ledgers are the real
// telemetry.
if (process.env.SENTRY_DSN) {
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 });
}
// --- Abuse limits: this is a public server on a small box. -----------------
const MAX_SOCKETS = 300; // concurrent connections
const MAX_ROOMS = 5000; // total rooms on the server
const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
// Per-address limits, held across reconnects: a table of friends never
// nears them; a script filling the vault or the reports desk does.
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
/** Calls to the keeper: a real person's phone rings for each. */
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
/** The keeper of this table: the wizard a lobby may challenge. */
const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
const MAX_COMMAND_BYTES = 16384; // serialized game command
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
const MAX_AUDIENCE = 30; // gallery seats per room
const NAME_MAX = 24;
/** Player/room names: printable, trimmed, bounded. */
function cleanName(raw: unknown): string {
return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, NAME_MAX);
}
const port = Number(process.env.PORT ?? 8787);
// In production a TLS proxy fronts us: bind loopback so the plaintext port
// is not reachable from the internet. Dev default stays LAN-friendly.
const host = process.env.HOST ?? "0.0.0.0";
loadPersistedRooms();
loadShares();
// A restart can land mid-bot-turn: without a kick, a restored room whose
// current actor is an automaton waits forever for a human to poke it.
setTimeout(() => {
for (const room of runningRooms()) runBots(room);
}, 2000); // a beat for clients to reconnect before the clockwork stirs
// Idle rooms return to their ledgers, so memory carries only live tables;
// getRoom wakes a sleeping room the moment anyone asks for it.
setInterval(() => {
const evicted = evictIdleRooms((roomId) => [...sessions].some((s) => s.roomId === roomId));
if (evicted > 0) console.log(`put ${evicted} idle room(s) back to sleep`);
}, Number(process.env.WIZWAR_EVICT_SWEEP_MS ?? 10 * 60_000));
// One process serves both the built client and the websocket, so production
// needs only a TLS proxy in front (or nothing, on a LAN).
const STATIC_DIR = process.env.WIZWAR_STATIC_DIR ?? join(process.cwd(), "..", "web", "dist");
const MIME: Record<string, string> = {
".html": "text/html", ".js": "text/javascript", ".css": "text/css",
".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon",
".woff2": "font/woff2", ".json": "application/json",
};
// The client build may be absent in development (vite serves it instead);
// serve a 404 for static requests in that case rather than dying on boot.
const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR)) : null;
// --- Share pages: one turn, rebuilt for the nameless viewer. ---------------
// Every lookup is a full-game replay, so results rest briefly in memory.
interface ShareData {
steps: CatchUpStep[];
actor: string;
round: number;
/** A whole finished game rather than one turn. */
whole?: boolean;
}
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
function shareData(id: string): ShareData | null {
const hit = shareCache.get(id);
if (hit && Date.now() - hit.at < 30_000) return hit.data;
let data: ShareData | null = null;
const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) {
if (share.turn < 0) {
// The whole tale: a finished game from the deal to the crown.
const steps = catchUpSteps(room, SPECTATOR, 0, true);
if (!("error" in steps) && steps.length > 0) {
let winner = "";
for (const st of steps) {
for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player;
}
data = { steps, actor: winner, round: 0, whole: true };
}
} else {
const reel = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in reel) && reel.steps.length > 0) {
data = { steps: reel.steps, actor: reel.owner, round: reel.round };
}
}
}
if (shareCache.size > 50) shareCache.clear();
shareCache.set(id, { at: Date.now(), data });
return data;
}
const escapeHtml = (t: string) =>
t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** index.html with a bespoke OpenGraph card folded into its head —
* crawlers never run the app, so the unfurl must arrive pre-baked. The
* stock page carries its own generic card; strip it, or crawlers (which
* take the FIRST tag they meet) never see this page's. */
function ogPage(metas: string[]): string {
return readFileSync(join(staticRoot!, "index.html"), "utf8")
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
.replace(/<title>[^<]*<\/title>\s*/, "")
.replace("</head>", ` ${metas.join("\n ")}\n </head>`);
}
/** Host and proto arrive from request headers — attacker-writable text
* that must never reach an HTML attribute raw. */
const noSuchClip = (res: ServerResponse) =>
res.writeHead(404, { "content-type": "text/plain" }).end("no such clip — see /clips");
/** The absolute origin a request came in on, as the proxy saw it. */
function requestBase(req: IncomingMessage): string {
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
return safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
}
function safeBase(rawHost: string, rawProto: string): string {
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
return `${proto}://${escapeHtml(rawHost)}`;
}
/** The turn's story in one line, for the share card: the deed a
* stranger would click to see, chosen by weight — a crown, a treasure
* carried home, a blow that landed, a spell cast — before the bare fact
* of whose turn it was. */
function headlineOf(data: ShareData): string | null {
// A card retired since the share was written keeps its id as its name.
const name = (id: string | null) => { if (!id) return "a spell"; try { return cardDef(id).name; } catch { return id; } };
const deeds: { rank: number; text: string }[] = [];
const offer = (rank: number, text: string) => { deeds.push({ rank, text }); };
for (const step of data.steps) {
for (const e of step.events) {
switch (e.type) {
case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two treasures home` : `${e.player} is the last wizard standing`); break;
case "playerEliminated": offer(8, e.reason === "killed" ? `${e.player} falls in the maze` : `${e.player} loses both treasures`); break;
case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a stolen treasure home`); break;
case "treasurePickedUp": if (e.owner !== e.player) offer(5, `${e.player} snatches ${e.owner}'s treasure`); break;
case "attackResolved": if (e.damageDealt > 0) offer(6, `${e.attacker} hits ${e.defender} with ${name(e.attackCardId)} for ${e.damageDealt}`); else if (e.fullyStopped) offer(4, `${e.defender} stops ${e.attacker}'s ${name(e.attackCardId)} cold`); break;
case "punched": offer(3, `${e.attacker} punches ${e.target}`); break;
case "spellCast": offer(2, e.target ? `${e.caster} casts ${name(e.cardId)} at ${e.target}` : `${e.caster} casts ${name(e.cardId)}`); break;
case "jumpedPit": offer(3, `${e.player} leaps the pit`); break;
case "fellInPit": offer(3, `${e.player} falls into the pit`); break;
case "wardSprung": offer(4, `${e.owner}'s warded treasure bites ${e.victim}`); break;
case "slimeTrapSprung": offer(4, `the slime springs ${name(e.cardId)} on ${e.victim}`); break;
default: break;
}
}
}
let best: { rank: number; text: string } | null = null;
for (const d of deeds) if (!best || d.rank > best.rank) best = d;
return best?.text ?? null;
}
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto);
const headline = data.whole ? null : headlineOf(data);
const title = data.whole
? "The whole tale — a game of Wiz-War, replayed"
: headline
? `${escapeHtml(headline)} — a Wiz-War instant replay`
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = data.whole
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
const metas = [
`<title>${title}</title>`,
`<meta property="og:type" content="website"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="${title}"/>`,
`<meta property="og:description" content="${desc}"/>`,
`<meta property="og:url" content="${base}/watch/${id}"/>`,
`<meta property="og:image" content="${base}/watch/${id}/og.png"/>`,
`<meta property="og:image:width" content="1200"/>`,
`<meta property="og:image:height" content="630"/>`,
`<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
];
return ogPage(metas);
}
/** The recruiting card for a /join/<code> link: an invitation while the
* room waits to start, a summons to the gallery once it has. */
function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto);
const seats = room.players.length;
const wizards = `${seats} wizard${seats === 1 ? "" : "s"}`;
const title = `You're summoned — Wiz-War room ${room.id}`;
const desc = room.state?.phase === "finished"
? `The tale is told — ${wizards} fought in this labyrinth. Follow the link to see how it ended.`
: room.state
? `The duel is underway, ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
: `${wizards} at the table, waiting to flip the boards. Follow the link, pick a name, and take a seat.`;
const metas = [
`<title>${title}</title>`,
`<meta property="og:type" content="website"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="${title}"/>`,
`<meta property="og:description" content="${desc}"/>`,
`<meta property="og:url" content="${base}/join/${room.id}"/>`,
`<meta property="og:image" content="${base}/og.png"/>`,
`<meta property="og:image:width" content="1200"/>`,
`<meta property="og:image:height" content="630"/>`,
`<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/og.png"/>`,
];
return ogPage(metas);
}
/** Rendered share cards, by share id. Shares are immutable, so unlike the
* TTL'd shareCache above this never invalidates — it only rotates out the
* oldest entry when full. */
const ogPngCache = new Map<string, Buffer>();
const OG_PNG_CACHE_MAX = 200;
const httpServer = createServer((req, res) => {
try {
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405).end();
return;
}
res.setHeader("x-content-type-options", "nosniff");
if (!staticRoot) {
res.writeHead(404).end("client not built");
return;
}
let url: string;
try {
url = decodeURIComponent((req.url ?? "/").split("?")[0]!);
} catch {
res.writeHead(400).end();
return;
}
if (url.includes("\0")) {
res.writeHead(400).end();
return;
}
// Share pages: the turn's data, its card image, and its chrome.
const api = url.match(/^\/api\/share\/([a-z0-9]{4,20})$/);
if (api) {
const data = shareData(api[1]!);
if (!data) { res.writeHead(404, { "content-type": "application/json" }).end('{"error":"no such replay"}'); return; }
res.writeHead(200, {
"content-type": "application/json",
"cache-control": "public, max-age=60",
"access-control-allow-origin": "*",
});
res.end(JSON.stringify({
steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true,
headline: data.whole ? null : headlineOf(data),
}));
return;
}
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
if (watch) {
const data = shareData(watch[1]!);
if (!data) { res.writeHead(404).end("no such replay"); return; }
if (watch[2]) {
// A share never changes, so its card renders once — crawlers
// re-fetching the unfurl image must not cost CPU every time.
let png = ogPngCache.get(watch[1]!);
if (!png) {
png = renderSharePng(data.steps[data.steps.length - 1]!.view);
ogPngCache.set(watch[1]!, png);
if (ogPngCache.size > OG_PNG_CACHE_MAX) {
ogPngCache.delete(ogPngCache.keys().next().value!);
}
}
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400, immutable" });
res.end(png);
return;
}
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const hostname = String(req.headers.host ?? `localhost:${port}`);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(shareHtml(watch[1]!, data, hostname, proto));
return;
}
// The clip gallery: standalone pages and their media. Video ships
// with Range support — Safari refuses an mp4 whose server can't
// serve bytes 0-1 on demand.
if (url === "/clips" || url === "/clips/") {
const base = requestBase(req);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(clipsIndexHtml(readClips(), base));
return;
}
const clipAsset = url.match(/^\/clips\/([^/]+\.(?:mp4|jpg))$/);
if (clipAsset) {
const path = clipAssetPath(clipAsset[1]!);
if (!path) { noSuchClip(res); return; }
const size = statSync(path).size;
const type = path.endsWith(".mp4") ? "video/mp4" : "image/jpeg";
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
const head: Record<string, string> = {
"content-type": type,
"accept-ranges": "bytes",
"cache-control": "public, max-age=3600",
};
if (range && (range[1] || range[2])) {
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
if (start > end || start >= size) {
res.writeHead(416, { "content-range": `bytes */${size}` }).end();
return;
}
res.writeHead(206, { ...head,
"content-range": `bytes ${start}-${end}/${size}`,
"content-length": String(end - start + 1) });
if (req.method === "HEAD") { res.end(); return; }
createReadStream(path, { start, end }).pipe(res);
return;
}
res.writeHead(200, { ...head, "content-length": String(size) });
if (req.method === "HEAD") { res.end(); return; }
createReadStream(path).pipe(res);
return;
}
const clipPage = url.match(/^\/clips\/([^/]+)$/);
if (clipPage && CLIP_SLUG.test(clipPage[1]!)) {
const clip = readClips().find((c) => c.name === clipPage[1]);
if (clip) {
const base = requestBase(req);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(clipPageHtml(clip, base));
return;
}
noSuchClip(res);
return;
}
// Room invitations: a living room gets its recruiting card; a dead
// code falls through to the app, which reports it in the lobby.
const invite = url.match(/^\/join\/([A-Za-z0-9]{4})$/);
if (invite) {
const room = getRoom(invite[1]!);
if (room) {
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const hostname = String(req.headers.host ?? `localhost:${port}`);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(inviteHtml(room, hostname, proto));
return;
}
}
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
res.writeHead(403).end();
return;
}
if (!existsSync(file)) {
// A hashed asset that is gone is gone: a page built before the last
// deploy asking for its old chunk gets a plain 404 it can act on,
// never the app shell as text/html. Every other unknown path is the
// app's to route.
if (url.startsWith("/assets/")) { res.writeHead(404, { "content-type": "text/plain" }).end("gone"); return; }
file = join(staticRoot, "index.html");
}
// Resolve symlinks and re-verify the real location stays inside the root.
const real = realpathSync(file);
if (real !== staticRoot && !real.startsWith(staticRoot + sep)) {
res.writeHead(403).end();
return;
}
const body = readFileSync(real);
// Hashed assets may cache forever; everything else must revalidate, or a
// stale index.html pins users to a dead bundle across deploys.
const cache = real.includes(`${sep}assets${sep}`)
? "public, max-age=31536000, immutable"
: "no-cache";
res.writeHead(200, {
"content-type": MIME[extname(real)] ?? "application/octet-stream",
"cache-control": cache,
});
res.end(body);
} catch {
res.writeHead(500).end();
}
});
const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 });
httpServer.listen(port, host);
interface Session {
socket: WebSocket;
playerId: PlayerId | null;
roomId: string | null;
/** In the Peanut Gallery: spectator implies playerId === null, so every
* handler that requires a seat refuses this session by construction. */
spectator: boolean;
/** The raw seat token this connection authenticated with (memory only). */
token: string | null;
/** Where the connection came from, for the per-address limits. */
address: string;
claimFails: number;
// Token bucket: refills at 15 msg/s up to a burst of 30.
bucket: number;
lastRefill: number;
overLimitStrikes: number;
roomsCreated: number;
lastCatchUpAt: number;
hotseatReports: number;
}
function underRateLimit(s: Session): boolean {
const now = Date.now();
s.bucket = Math.min(30, s.bucket + ((now - s.lastRefill) / 1000) * 15);
s.lastRefill = now;
if (s.bucket < 1) return false;
s.bucket -= 1;
return true;
}
const sessions = new Set<Session>();
function send(socket: WebSocket, message: unknown): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
}
function audienceCount(room: Room): number {
let n = 0;
for (const s of sessions) if (s.roomId === room.id && s.spectator) n++;
return n;
}
function broadcastAudience(room: Room): void {
broadcast(room, () => ({ type: "audience", count: audienceCount(room) }));
}
/** A watcher leaves the gallery (to sit down, watch elsewhere, or vanish). */
function leaveGallery(session: Session): void {
if (!session.spectator) return;
session.spectator = false;
const room = session.roomId ? getRoom(session.roomId) : undefined;
session.roomId = null;
if (room) broadcastAudience(room);
}
function roomInfo(room: Room) {
return {
type: "room",
roomId: room.id,
players: room.players,
hostId: room.hostId,
started: room.state !== null,
audience: audienceCount(room),
rematch: room.rematch ?? null,
expected: room.expected ?? [],
expansion: room.expansion,
challenge: room.challenge ?? null,
keeper: KEEPER,
colors: Object.fromEntries(room.colorChoices),
bots: Object.fromEntries(
// A mystery machine keeps its mood only while the game lives: once it
// ends, the hands go face-up and so does the temperament.
[...room.bots].map(([name, b]) => [
name,
room.state?.phase === "finished"
? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}`
: `${b.tier} ${b.secret ? "mystery" : b.style}`,
]),
),
};
}
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
for (const s of sessions) {
if (s.roomId !== room.id) continue;
// The gallery hears everything too, redacted for the nameless viewer.
if (s.playerId) send(s.socket, makeMessage(s.playerId));
else if (s.spectator) send(s.socket, makeMessage(SPECTATOR));
}
}
/**
* The clockwork plays at a watchable pace: one command every beat, each
* broadcast as it lands, until the maze wants a human again.
*/
const BOT_STEP_MS = 1000;
/** What a step's event means to this bot — its own deeds when it acted,
* its own suffering whoever caused it. Null: nothing worth a word. */
function banterTrigger(
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
): BanterTrigger | null {
if (actor === seat) {
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
if (e.type === "treasureDropped" && e.player === seat && e.onHomeOf != null) return "deliverGold";
if (e.type === "damaged" && e.player !== seat) return "dealPain";
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
}
// A counter-teleport escape resolves during the ATTACKER's step, and a
// last-standing win can land on the victim's turn: self-referential
// triggers hold whoever acted.
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
if (e.type === "gameWon" && e.player === seat) return "win";
if (e.type === "damaged" && e.player === seat) return "takePain";
if (e.type === "died" && e.player === seat) return "die";
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
return null;
}
/** Every seated bot gets a chance to remark on the step — the actor on its
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
for (const [seat, bot] of room.bots) {
const lines = BOT_LINES[bot.style] ?? {};
for (const e of events) {
const trigger = banterTrigger(e, seat, actor);
const pool = trigger ? lines[trigger] : undefined;
if (!pool || Math.random() > 0.5) continue;
const text = pool[Math.floor(Math.random() * pool.length)]!;
const said = addChat(room, seat, text);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
}
return; // one remark per step, whole table
}
}
}
/** Spoken when a clockwork's chosen command was refused by the engine —
* the table sees a stumble instead of an unexplained idle turn. */
const HESITATION_LINES = [
"RECALCULATING.",
"TACTICAL PAUSE. INTENTIONAL. PROBABLY.",
"THE MAZE REFUSES MY GENIUS.",
"ERROR LOGGED. DIGNITY INTACT.",
"I MEANT TO DO THAT.",
];
const pumping = new Set<string>();
function runBots(room: Room): void {
if (pumping.has(room.id)) return;
pumping.add(room.id);
const tick = () => {
const step = driveOneAutomaton(room);
if (!step) {
pumping.delete(room.id);
return;
}
broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
// The game's end unmasks the mystery machines in the roster.
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
// A refused brain-choice becomes a visible stumble, in character —
// an idle bot turn should read as hesitation, never as nothing.
if (step.hesitated) {
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
}
}
botRemark(room, step.seat, step.events as { type: string }[]);
setTimeout(tick, BOT_STEP_MS);
};
setTimeout(tick, 800); // a beat after the human's own action settles
}
function broadcastRoomState(room: Room): void {
broadcast(room, () => roomInfo(room));
if (room.state) {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
}
}
wss.on("connection", (socket, req) => {
if (sessions.size >= MAX_SOCKETS) {
send(socket, { type: "error", message: "the tavern is packed — try again shortly" });
socket.close();
return;
}
const session: Session = {
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
address: clientAddress(req.headers, req.socket.remoteAddress),
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
};
sessions.add(session);
send(socket, { type: "welcome", game: "wizwar" });
socket.on("close", () => {
sessions.delete(session);
leaveGallery(session); // an emptier gallery is news to the table
});
socket.on("message", (data) => {
if (!underRateLimit(session)) {
// terminate, not close: an abuser ignoring the closing handshake
// would otherwise hold the socket (and its session slot) open.
if (++session.overLimitStrikes > 100) return socket.terminate();
return send(socket, { type: "error", message: "slow down" });
}
let msg: Record<string, unknown>;
try {
msg = JSON.parse(data.toString());
} catch {
return send(socket, { type: "error", message: "invalid JSON" });
}
try {
switch (msg.type) {
case "ping":
// Liveness probe: the reply is the point. A restart can leave
// browsers holding half-dead sockets that never fire onclose;
// silence answered by silence is how the client finds out.
send(socket, { type: "pong" });
break;
case "create": {
const name = cleanName(msg.name);
if (!name) return send(socket, { type: "error", message: "name required" });
if (session.roomsCreated >= MAX_ROOMS_PER_CONN || roomCount() >= MAX_ROOMS) {
return send(socket, { type: "error", message: "no new rooms right now — try again later" });
}
if (!roomsPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "that's a lot of new tables from one place — try again in an hour" });
}
session.roomsCreated++;
leaveGallery(session);
const { room, token } = createRoom(name);
session.playerId = name;
session.roomId = room.id;
session.token = token;
send(socket, { type: "seat", playerId: name, token });
broadcastRoomState(room);
break;
}
case "join": {
const name = cleanName(msg.name);
const roomId = String(msg.roomId ?? "").trim().slice(0, 8);
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" });
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
if ("error" in result) {
// A refused seat is worth a line: a lost seat mid-game is the
// costliest quiet failure this table has.
console.warn(`join refused: room ${room.id} name ${JSON.stringify(name)}${result.error}`);
return send(socket, { type: "error", message: result.error });
}
leaveGallery(session);
session.playerId = name;
session.roomId = room.id;
session.token = result.token;
send(socket, { type: "seat", playerId: name, token: result.token });
// Rejoining a running game: replay the chronicle so far. The
// replayed flag keeps one-time fanfare (the opening roll-off)
// from firing again on every return to the room.
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
} else if (room.chat.length > 0) {
// The lobby's talk so far, for whoever just sat down.
send(socket, { type: "events", events: room.chat.map((c) => ({ type: "tableTalk", player: c.player, text: c.text })), replayed: true });
}
broadcastRoomState(room);
runBots(room);
break;
}
case "challengeKeeper": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (!challengesPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "the keeper has been called enough from here for one hour" });
}
const called = callKeeper(room, session.playerId, KEEPER);
if ("error" in called) return send(socket, { type: "error", message: called.error });
// The alarm the keeper listens for: one issue per room, so each
// call rings once, with the door in the message. Error level,
// because Sentry's alerts ring for high-priority issues and a
// warning is filed as medium — a bell nobody hears.
const link = `${PUBLIC_URL}/join/${room.id}`;
if (process.env.SENTRY_DSN) {
Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
level: "error",
fingerprint: ["challenge", room.id],
tags: { room: room.id, challenger: session.playerId },
extra: { link, players: room.players.join(", ") },
});
}
const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`);
if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at }));
broadcastRoomState(room);
break;
}
case "watch": {
// The Peanut Gallery: no name, no seat, no ledger line — a pure
// reader of the public broadcast, counted but never identified.
const roomId = String(msg.roomId ?? "").trim().slice(0, 8);
if (!roomId) return send(socket, { type: "error", message: "roomId required" });
const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" });
if (audienceCount(room) >= MAX_AUDIENCE) {
return send(socket, { type: "error", message: "the gallery is packed — try again later" });
}
leaveGallery(session); // switching galleries updates the old room's count
session.playerId = null;
session.token = null;
session.spectator = true;
session.roomId = room.id;
send(socket, { type: "watching", roomId: room.id });
send(socket, roomInfo(room));
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, SPECTATOR), replayed: true });
send(socket, { type: "state", view: viewForPlayer(room, SPECTATOR), seq: room.log.length });
}
broadcastAudience(room);
break;
}
case "leave": {
// Walk away from the table or the gallery: the seat itself (and
// its token) survives for a later resume; only this socket detaches.
leaveGallery(session);
session.playerId = null;
session.roomId = null;
session.token = null;
break;
}
case "kickSeat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const kickName = String(msg.name ?? "");
const problem = kickSeat(room, session.playerId, kickName);
if (problem) return send(socket, { type: "error", message: problem });
// A kicked live socket is set adrift so it cannot act on a seat it lost.
for (const other of sessions) {
if (other.roomId === room.id && other.playerId === kickName) {
other.playerId = null;
other.roomId = null;
other.token = null;
send(other.socket, { type: "kicked", roomId: room.id });
}
}
broadcastRoomState(room);
break;
}
case "abandonRoom": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const problem = abandonRoom(room, session.playerId);
if (problem) return send(socket, { type: "error", message: problem });
for (const other of sessions) {
if (other.roomId === room.id) {
other.playerId = null;
other.roomId = null;
other.token = null;
send(other.socket, { type: "roomAbandoned", roomId: room.id });
}
}
break;
}
case "addBot": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host seats automatons" });
const result = addAutomaton(
room,
typeof msg.style === "string" ? msg.style : undefined,
typeof msg.tier === "string" ? msg.tier : undefined,
);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcastRoomState(room);
break;
}
case "start": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" });
const result = startGame(room, msg.expansion === true);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcastRoomState(room);
runBots(room);
break;
}
case "command": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (JSON.stringify(msg.command ?? null).length > MAX_COMMAND_BYTES) {
return send(socket, { type: "error", message: "command too large" });
}
const result = runCommand(room, session.playerId, msg.command as Command);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
// The game's end unmasks the mystery machines in the roster.
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
botRemark(room, session.playerId, result.events as { type: string }[]);
runBots(room);
break;
}
case "rollDie": {
// The tabletop D4 for house calls: publishes to the room's talk,
// so it persists, replays, and shows in unread badges like chat.
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const roll = 1 + Math.floor(Math.random() * 4);
const result = addChat(room, session.playerId, `rolls the die \u2014 ${roll}`);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
break;
}
case "rematch": {
// Anyone at a finished table may call; the caller lands in the
// new lobby, and the old table hears where it went.
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const called = callRematch(room, session.playerId);
if ("error" in called) return send(socket, { type: "error", message: called.error });
const next = getRoom(called.roomId);
if (!next) return send(socket, { type: "error", message: "the rematch room is gone" });
let token = called.token;
if (!token) {
const joined = joinRoom(next, session.playerId, null);
if ("error" in joined) return send(socket, { type: "error", message: joined.error });
token = joined.token;
}
if (called.created) broadcast(room, () => ({ type: "rematch", roomId: room.id, to: next.id, by: session.playerId }));
leaveGallery(session);
send(socket, { type: "rematched", roomId: next.id });
session.roomId = next.id;
session.token = token;
send(socket, { type: "seat", playerId: session.playerId, token });
broadcastRoomState(next);
break;
}
case "chat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = addChat(room, session.playerId, String(msg.text ?? ""));
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
break;
}
case "makeTransfer": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = makeTransferCode(room, session.playerId, session.token);
if ("error" in result) return send(socket, { type: "error", message: result.error });
send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt });
break;
}
case "claimTransfer": {
if (session.claimFails >= 5) {
return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" });
}
const result = claimTransferCode(String(msg.code ?? ""));
if ("error" in result) {
session.claimFails++;
return send(socket, { type: "error", message: result.error });
}
session.claimFails = 0;
send(socket, { type: "transferClaimed", seat: result });
break;
}
case "catchUp": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
// A catch-up replays the whole game server-side; one at a time, please.
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "catching up already — one moment" });
}
session.lastCatchUpAt = now;
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0), msg.full === true);
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
send(socket, { type: "catchUp", steps });
break;
}
case "share": {
// Mint (or re-find) the public link for one turn's replay.
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const turn = Number(msg.turn);
if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "catching up already — one moment" });
}
session.lastCatchUpAt = now;
// turn -1 shares the whole finished game; anything else, one turn.
const check = turn === -1
? catchUpSteps(room, session.playerId, 0, true)
: momentSteps(room, session.playerId, turn);
if ("error" in check) return send(socket, { type: "error", message: check.error });
const share = mintShare(room.id, turn);
send(socket, { type: "share", id: share.id, turn });
break;
}
case "moment": {
// A chronicle line's instant-replay eye: one turn's reel.
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "catching up already — one moment" });
}
session.lastCatchUpAt = now;
const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
send(socket, { type: "moment", steps: reel.steps, owner: reel.owner });
break;
}
case "pickColor": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const problem = pickColor(room, session.playerId, Number(msg.color));
if (problem) return send(socket, { type: "error", message: problem });
broadcastRoomState(room);
break;
}
case "feedback": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room) return send(socket, { type: "error", message: "join a room first — a report rides its ledger" });
const clean = (raw: unknown) =>
String(raw ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
const happened = clean(msg.happened);
if (!happened) return send(socket, { type: "error", message: "say what happened" });
if (!reportsPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
}
appendFeedback({
id: randomBytes(4).toString("hex"),
at: new Date().toISOString(),
roomId: room.id,
player: session.playerId ?? "(gallery)",
seq: room.log.length,
round: room.state?.turn.round ?? null,
deckRev: room.state?.config.deckRev ?? null,
happened,
expected: clean(msg.expected),
});
send(socket, { type: "feedbackReceived" });
break;
}
case "myFeedback": {
// Reports for every seat this browser can prove — the same token
// check as the games ledger, and just as wake-free.
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
const proven: { roomId: string; name: string }[] = [];
for (const seat of seats) {
if (typeof seat !== "object" || seat === null) continue;
const roomId = String(seat.roomId ?? "").toUpperCase();
const name = String(seat.name ?? "");
const result = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null);
if (result !== null && result !== "badToken") proven.push({ roomId, name });
}
const reports = readFeedback()
.filter((r) => proven.some((s) => s.roomId === r.roomId && s.name === r.player))
.map(({ player: _player, ...r }) => r);
send(socket, { type: "feedbackList", reports });
break;
}
case "hotseatReport": {
// A device finishes a handful of games at most; a firehose is abuse.
if (++session.hotseatReports > 20) return;
const id = String(msg.id ?? "").slice(0, 64);
const stage = msg.stage === "finished" ? "finished" : msg.stage === "started" ? "started" : null;
if (!id || !stage) return send(socket, { type: "error", message: "bad report" });
recordHotseat({
id, stage,
players: Number(msg.players), commands: Number(msg.commands),
minutes: Number(msg.minutes), winReason: typeof msg.winReason === "string" ? msg.winReason : undefined,
});
break;
}
case "stats": {
send(socket, { type: "stats", stats: engagementStats() });
break;
}
case "myGames": {
// {seats: [{roomId, name, token}]} -> summaries for valid seats,
// plus explicit verdicts on seats PROVEN dead: the room exists
// and refused this exact token. A room this server simply does
// not know earns no verdict — absence is not evidence (a
// restarting or wrong server knows nothing about anything).
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
const games = [];
const voided: string[] = [];
for (const seat of seats) {
if (typeof seat !== "object" || seat === null) continue;
const roomId = String(seat.roomId ?? "").toUpperCase();
const name = String(seat.name ?? "");
// A peek, never a wake: the 45-second poll must not keep every
// held room warm or drag sleeping ones out of their ledgers.
const result = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null);
if (result === null) continue;
if (result === "badToken") {
console.warn(`seat voided: room ${roomId} name ${JSON.stringify(name)} — the token did not match`);
voided.push(`${roomId}:${name}`);
continue;
}
games.push(result);
}
send(socket, { type: "games", games, voided });
break;
}
default:
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
}
} catch (e) {
// Expected failures travel as result.error values; anything thrown is a
// bug, and its message (paths, internals) is not for strangers' eyes.
console.error("unhandled protocol error:", e);
Sentry.captureException(e, { extra: { messageType: String(msg?.type ?? "?"), roomId: session.roomId } });
send(socket, { type: "error", message: "internal error" });
}
});
});
console.log(`wizwar serving client + websocket on port ${port}`);