// 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:"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, colors} // {type:"events", events} 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:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"} // {type:"error", message} import { createServer } from "node:http"; import { readFileSync, existsSync, realpathSync } 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, claimTransferCode, createRoom, pickColor, getRoom, joinRoom, loadPersistedRooms, runningRooms, addAutomaton, addChat, driveOneAutomaton, makeTransferCode, redactFor, roomCount, runCommand, seatTokenValid, startGame, summarize, viewForPlayer, type Room, } from "./rooms"; import { engagementStats, recordHotseat } from "./stats"; import { BOT_LINES, type BanterTrigger } from "./banter"; // --- 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 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(); // 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); // 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; 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; } 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; /** 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 roomInfo(room: Room) { return { type: "room", roomId: room.id, players: room.players, hostId: room.hostId, started: room.state !== null, colors: Object.fromEntries(room.colorChoices), bots: Object.fromEntries( [...room.bots].map(([name, b]) => [name, `${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 && s.playerId) { send(s.socket, makeMessage(s.playerId)); } } } /** * 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 } } } 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 })); 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, 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)); socket.on("message", (data) => { if (!underRateLimit(session)) { if (++session.overLimitStrikes > 100) socket.close(); 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 "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++; 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 }); 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 "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 })); 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 "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 "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. const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : []; const games = []; for (const seat of seats) { if (typeof seat !== "object" || seat === null) continue; const room = getRoom(String(seat.roomId ?? "")); if (!room) continue; const name = String(seat.name ?? ""); if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue; games.push(summarize(room, name)); } send(socket, { type: "games", games }); 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); send(socket, { type: "error", message: "internal error" }); } }); }); console.log(`wizwar serving client + websocket on port ${port}`);