?fpv&script=<name> plays a screenplay — a seeded deal, cards slipped into hands as props, then a command list run through applyCommand, so every event on screen is a legal move under the real rules. Ten scenes ship in the catalog: wormhole heists, a fireball threaded through a warp, an ambush that watches its corridor through a mouth, the leap over a conjured pit, and the wall between two homes coming down. The server grows a public gallery to hang them in: /clips lists the catalog, /clips/<name> pages each scene in two takes — through the wizard's eyes and from the board — with og:video unfurls and Range-served mp4s (Safari refuses video without it). Files live in the clip vault beside the rooms, shipped by deploy/clips-publish.sh; the name gate is the security. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
960 lines
44 KiB
TypeScript
960 lines
44 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 } 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<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, "&").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(/<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. */
|
|
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}</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 }));
|
|
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<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\/([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<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),
|
|
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) => {
|
|
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<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" });
|
|
}
|
|
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}`);
|