Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.
Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.
Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.
Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.
Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.
288 tests, 24 ledgers verified, all workspaces typecheck.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
772 lines
35 KiB
TypeScript
772 lines
35 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:"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"}
|
|
// {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,
|
|
momentSteps,
|
|
claimTransferCode,
|
|
createRoom,
|
|
pickColor,
|
|
getRoom,
|
|
joinRoom,
|
|
loadPersistedRooms,
|
|
runningRooms,
|
|
addAutomaton,
|
|
addChat,
|
|
driveOneAutomaton,
|
|
makeTransferCode,
|
|
redactFor,
|
|
roomCount,
|
|
runCommand,
|
|
seatTokenValid,
|
|
SPECTATOR,
|
|
type CatchUpStep,
|
|
startGame,
|
|
summarize,
|
|
viewForPlayer,
|
|
type Room,
|
|
kickSeat,
|
|
abandonRoom,
|
|
} from "./rooms";
|
|
import { engagementStats, recordHotseat } from "./stats";
|
|
import { getShare, loadShares, mintShare } from "./shares";
|
|
import { renderSharePng } from "./ogimage";
|
|
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 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
|
|
|
|
// 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 this share's OpenGraph card folded into its head —
|
|
* crawlers never run the app, so the unfurl must arrive pre-baked. */
|
|
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
|
|
// Host and proto arrive from request headers — attacker-writable text
|
|
// that must never reach an HTML attribute raw.
|
|
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
|
|
const host = escapeHtml(rawHost);
|
|
// The stock page carries its own generic card; strip it, or crawlers
|
|
// (which take the FIRST tag they meet) never see this turn's.
|
|
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
|
|
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
|
|
.replace(/<title>[^<]*<\/title>\s*/, "");
|
|
const base = `${proto}://${host}`;
|
|
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"/>`,
|
|
].join("\n ");
|
|
return html.replace("</head>", ` ${metas}\n </head>`);
|
|
}
|
|
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]) {
|
|
const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
|
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
|
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;
|
|
}
|
|
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)) {
|
|
if (++session.overLimitStrikes > 100) socket.close();
|
|
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 "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 "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 room = getRoom(String(seat.roomId ?? ""));
|
|
if (!room) continue;
|
|
const name = String(seat.name ?? "");
|
|
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) {
|
|
voided.push(`${room.id}:${name}`);
|
|
continue;
|
|
}
|
|
games.push(summarize(room, name));
|
|
}
|
|
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);
|
|
send(socket, { type: "error", message: "internal error" });
|
|
}
|
|
});
|
|
});
|
|
|
|
console.log(`wizwar serving client + websocket on port ${port}`);
|