// 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 } 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 type { Command, PlayerId } from "@wizwar/engine"; import { 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 } from "./store"; import { clipsIndexHtml, clipPageHtml } from "./clips"; 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 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 = { ".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(); 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, "&").replace(//g, ">").replace(/"/g, """); /** 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(/]*>\s*/g, "") .replace(/[^<]*<\/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. */ function safeBase(rawHost: string, rawProto: string): string { const proto = /^https?$/.test(rawProto) ? rawProto : "https"; return `${proto}://${escapeHtml(rawHost)}`; } function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string { const base = safeBase(rawHost, rawProto); const title = data.whole ? "The whole tale — a game of Wiz-War, replayed" : `${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}`, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ]; return ogPage(metas); } /** The recruiting card for a /join/ 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}`, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ]; 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(); 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 })); 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 proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto); res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }); res.end(clipsIndexHtml(readClips(), base)); return; } const clipAsset = url.match(/^\/clips\/([a-z0-9-]{1,70}\.(?:mp4|jpg))$/); if (clipAsset) { const path = clipAssetPath(clipAsset[1]!); if (!path) { res.writeHead(404).end("no such clip"); 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 = { "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\/([a-z0-9-]{1,60})$/); if (clipPage) { const clip = readClips().find((c) => c.name === clipPage[1]); if (clip) { const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto); res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }); res.end(clipPageHtml(clip, base)); return; } res.writeHead(404, { "content-type": "text/html" }).end("no such clip — see /clips"); 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)) file = join(staticRoot, "index.html"); // SPA fallback // 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; 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(); 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), 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(); 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) => { 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, 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; 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" }); } 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) 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 }); } broadcastRoomState(room); runBots(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 "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" }); 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") { 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}`);