Files
wizwar6e/packages/server/src/index.ts
T
Eric WagonerandClaude Fable 5.1 12a655868b The table says when it is out of reach, a finished table can call a rematch, and spells and treasure read plainly
Connection: while the socket is down a notice says so, the hand, board,
and action buttons go quiet, a card left selected stays selected, and
table talk stays in its box. A command that could not leave is named
as not sent; one that left before the drop is named as unconfirmed
until the board answers. A command on its way shows as sending beside
End turn and confirms when the table replies. Talk shows as sending
until the table echoes it, and comes back to the composer if the
socket drops first.

Rematch: anyone at a finished table may call one. The server makes a
new room hosted by the caller, with the same clockwork at the same
tiers and moods (a mystery stays a mystery) and the same expansion
setting, and keeps seats for the last table's other wizards, who find
the call on the finished table and in their lobby ledger and sit with
one click. The old table's replay stays where it was. A solo table
against the clockwork keeps "play again".

Reading the table: an ongoing spell's chip opens its card with a plain
account — what it does, who cast it on whom, and when it ends, in the
rulebook's own terms. Each score row counts the treasures a wizard has
carried home, and marks a wizard one carry from winning.

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

1054 lines
49 KiB
TypeScript

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