The bot pump only ran when a command or join arrived, so a server restart landing mid-bot-turn left the restored room waiting forever for a human to poke it — room 928D sat exactly there, its automaton holding a perfectly answerable stack. Boot now kicks the pump for every restored still-running room, two seconds after restore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
482 lines
21 KiB
TypeScript
482 lines
21 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:"myGames", seats} summaries for held seats
|
|
// {type:"stats"} the engagement tally
|
|
// {type:"hotseatReport", ...} anonymous hotseat game counts
|
|
// server -> client:
|
|
// {type:"welcome"} on connect
|
|
// {type:"seat", playerId, token} your seat secret — keep it
|
|
// {type:"room", roomId, players, hostId, started, colors}
|
|
// {type:"events", events} redacted for this recipient
|
|
// {type:"state", view, seq} redacted full view (after every change)
|
|
// {type:"chat", player, text, at} one line of table talk
|
|
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
|
|
// {type:"error", message}
|
|
|
|
import { createServer } from "node:http";
|
|
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
import { extname, join, normalize, sep } from "node:path";
|
|
import { WebSocketServer, WebSocket } from "ws";
|
|
import type { Command, PlayerId } from "@wizwar/engine";
|
|
import {
|
|
catchUpSteps,
|
|
claimTransferCode,
|
|
createRoom,
|
|
pickColor,
|
|
getRoom,
|
|
joinRoom,
|
|
loadPersistedRooms,
|
|
runningRooms,
|
|
addAutomaton,
|
|
addChat,
|
|
driveOneAutomaton,
|
|
makeTransferCode,
|
|
redactFor,
|
|
roomCount,
|
|
runCommand,
|
|
seatTokenValid,
|
|
startGame,
|
|
summarize,
|
|
viewForPlayer,
|
|
type Room,
|
|
} from "./rooms";
|
|
import { engagementStats, recordHotseat } from "./stats";
|
|
import { BOT_LINES, type BanterTrigger } from "./banter";
|
|
|
|
// --- Abuse limits: this is a public server on a small box. -----------------
|
|
const MAX_SOCKETS = 300; // concurrent connections
|
|
const MAX_ROOMS = 5000; // total rooms on the server
|
|
const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
|
|
const MAX_COMMAND_BYTES = 16384; // serialized game command
|
|
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
|
|
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
|
|
const NAME_MAX = 24;
|
|
|
|
/** Player/room names: printable, trimmed, bounded. */
|
|
function cleanName(raw: unknown): string {
|
|
return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, NAME_MAX);
|
|
}
|
|
|
|
const port = Number(process.env.PORT ?? 8787);
|
|
// In production a TLS proxy fronts us: bind loopback so the plaintext port
|
|
// is not reachable from the internet. Dev default stays LAN-friendly.
|
|
const host = process.env.HOST ?? "0.0.0.0";
|
|
loadPersistedRooms();
|
|
// A restart can land mid-bot-turn: without a kick, a restored room whose
|
|
// current actor is an automaton waits forever for a human to poke it.
|
|
setTimeout(() => {
|
|
for (const room of runningRooms()) runBots(room);
|
|
}, 2000);
|
|
|
|
// One process serves both the built client and the websocket, so production
|
|
// needs only a TLS proxy in front (or nothing, on a LAN).
|
|
const STATIC_DIR = process.env.WIZWAR_STATIC_DIR ?? join(process.cwd(), "..", "web", "dist");
|
|
const MIME: Record<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;
|
|
const httpServer = createServer((req, res) => {
|
|
try {
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
res.writeHead(405).end();
|
|
return;
|
|
}
|
|
res.setHeader("x-content-type-options", "nosniff");
|
|
if (!staticRoot) {
|
|
res.writeHead(404).end("client not built");
|
|
return;
|
|
}
|
|
let url: string;
|
|
try {
|
|
url = decodeURIComponent((req.url ?? "/").split("?")[0]!);
|
|
} catch {
|
|
res.writeHead(400).end();
|
|
return;
|
|
}
|
|
if (url.includes("\0")) {
|
|
res.writeHead(400).end();
|
|
return;
|
|
}
|
|
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
|
|
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
|
|
res.writeHead(403).end();
|
|
return;
|
|
}
|
|
if (!existsSync(file)) file = join(staticRoot, "index.html"); // SPA fallback
|
|
// Resolve symlinks and re-verify the real location stays inside the root.
|
|
const real = realpathSync(file);
|
|
if (real !== staticRoot && !real.startsWith(staticRoot + sep)) {
|
|
res.writeHead(403).end();
|
|
return;
|
|
}
|
|
const body = readFileSync(real);
|
|
// Hashed assets may cache forever; everything else must revalidate, or a
|
|
// stale index.html pins users to a dead bundle across deploys.
|
|
const cache = real.includes(`${sep}assets${sep}`)
|
|
? "public, max-age=31536000, immutable"
|
|
: "no-cache";
|
|
res.writeHead(200, {
|
|
"content-type": MIME[extname(real)] ?? "application/octet-stream",
|
|
"cache-control": cache,
|
|
});
|
|
res.end(body);
|
|
} catch {
|
|
res.writeHead(500).end();
|
|
}
|
|
});
|
|
const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 });
|
|
httpServer.listen(port, host);
|
|
|
|
interface Session {
|
|
socket: WebSocket;
|
|
playerId: PlayerId | null;
|
|
roomId: string | null;
|
|
/** The raw seat token this connection authenticated with (memory only). */
|
|
token: string | null;
|
|
claimFails: number;
|
|
// Token bucket: refills at 15 msg/s up to a burst of 30.
|
|
bucket: number;
|
|
lastRefill: number;
|
|
overLimitStrikes: number;
|
|
roomsCreated: number;
|
|
lastCatchUpAt: number;
|
|
hotseatReports: number;
|
|
}
|
|
|
|
function underRateLimit(s: Session): boolean {
|
|
const now = Date.now();
|
|
s.bucket = Math.min(30, s.bucket + ((now - s.lastRefill) / 1000) * 15);
|
|
s.lastRefill = now;
|
|
if (s.bucket < 1) return false;
|
|
s.bucket -= 1;
|
|
return true;
|
|
}
|
|
|
|
const sessions = new Set<Session>();
|
|
|
|
function send(socket: WebSocket, message: unknown): void {
|
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
|
}
|
|
|
|
function roomInfo(room: Room) {
|
|
return {
|
|
type: "room",
|
|
roomId: room.id,
|
|
players: room.players,
|
|
hostId: room.hostId,
|
|
started: room.state !== null,
|
|
colors: Object.fromEntries(room.colorChoices),
|
|
bots: Object.fromEntries(
|
|
[...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]),
|
|
),
|
|
};
|
|
}
|
|
|
|
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
|
|
for (const s of sessions) {
|
|
if (s.roomId === room.id && s.playerId) {
|
|
send(s.socket, makeMessage(s.playerId));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The clockwork plays at a watchable pace: one command every beat, each
|
|
* broadcast as it lands, until the maze wants a human again.
|
|
*/
|
|
const BOT_STEP_MS = 1000;
|
|
|
|
/** What a step's event means to this bot — its own deeds when it acted,
|
|
* its own suffering whoever caused it. Null: nothing worth a word. */
|
|
function banterTrigger(
|
|
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
|
|
): BanterTrigger | null {
|
|
if (actor === seat) {
|
|
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
|
|
if (e.type === "treasureDropped" && e.player === seat && e.onHomeOf != null) return "deliverGold";
|
|
if (e.type === "damaged" && e.player !== seat) return "dealPain";
|
|
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
|
|
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
|
|
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
|
|
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
|
|
}
|
|
// A counter-teleport escape resolves during the ATTACKER's step, and a
|
|
// last-standing win can land on the victim's turn: self-referential
|
|
// triggers hold whoever acted.
|
|
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
|
|
if (e.type === "gameWon" && e.player === seat) return "win";
|
|
if (e.type === "damaged" && e.player === seat) return "takePain";
|
|
if (e.type === "died" && e.player === seat) return "die";
|
|
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
|
|
return null;
|
|
}
|
|
|
|
/** Every seated bot gets a chance to remark on the step — the actor on its
|
|
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
|
|
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
|
|
for (const [seat, bot] of room.bots) {
|
|
const lines = BOT_LINES[bot.style] ?? {};
|
|
for (const e of events) {
|
|
const trigger = banterTrigger(e, seat, actor);
|
|
const pool = trigger ? lines[trigger] : undefined;
|
|
if (!pool || Math.random() > 0.5) continue;
|
|
const text = pool[Math.floor(Math.random() * pool.length)]!;
|
|
const said = addChat(room, seat, text);
|
|
if (!("error" in said)) {
|
|
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
|
|
}
|
|
return; // one remark per step, whole table
|
|
}
|
|
}
|
|
}
|
|
const pumping = new Set<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 }));
|
|
botRemark(room, step.seat, step.events as { type: string }[]);
|
|
setTimeout(tick, BOT_STEP_MS);
|
|
};
|
|
setTimeout(tick, 800); // a beat after the human's own action settles
|
|
}
|
|
|
|
function broadcastRoomState(room: Room): void {
|
|
broadcast(room, () => roomInfo(room));
|
|
if (room.state) {
|
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
|
}
|
|
}
|
|
|
|
wss.on("connection", (socket) => {
|
|
if (sessions.size >= MAX_SOCKETS) {
|
|
send(socket, { type: "error", message: "the tavern is packed — try again shortly" });
|
|
socket.close();
|
|
return;
|
|
}
|
|
const session: Session = {
|
|
socket, playerId: null, roomId: null, token: null, claimFails: 0,
|
|
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
|
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
|
|
};
|
|
sessions.add(session);
|
|
send(socket, { type: "welcome", game: "wizwar" });
|
|
|
|
socket.on("close", () => sessions.delete(session));
|
|
|
|
socket.on("message", (data) => {
|
|
if (!underRateLimit(session)) {
|
|
if (++session.overLimitStrikes > 100) socket.close();
|
|
return send(socket, { type: "error", message: "slow down" });
|
|
}
|
|
let msg: Record<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++;
|
|
const { room, token } = createRoom(name);
|
|
session.playerId = name;
|
|
session.roomId = room.id;
|
|
session.token = token;
|
|
send(socket, { type: "seat", playerId: name, token });
|
|
broadcastRoomState(room);
|
|
break;
|
|
}
|
|
case "join": {
|
|
const name = cleanName(msg.name);
|
|
const roomId = String(msg.roomId ?? "").trim().slice(0, 8);
|
|
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
|
|
const room = getRoom(roomId);
|
|
if (!room) return send(socket, { type: "error", message: "no such room" });
|
|
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
session.playerId = name;
|
|
session.roomId = room.id;
|
|
session.token = result.token;
|
|
send(socket, { type: "seat", playerId: name, token: result.token });
|
|
// Rejoining a running game: replay the chronicle so far. The
|
|
// replayed flag keeps one-time fanfare (the opening roll-off)
|
|
// from firing again on every return to the room.
|
|
if (room.state) {
|
|
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
|
|
}
|
|
broadcastRoomState(room);
|
|
runBots(room);
|
|
break;
|
|
}
|
|
case "addBot": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host seats automatons" });
|
|
const result = addAutomaton(
|
|
room,
|
|
typeof msg.style === "string" ? msg.style : undefined,
|
|
typeof msg.tier === "string" ? msg.tier : undefined,
|
|
);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
broadcastRoomState(room);
|
|
break;
|
|
}
|
|
case "start": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" });
|
|
const result = startGame(room, msg.expansion === true);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
|
broadcastRoomState(room);
|
|
runBots(room);
|
|
break;
|
|
}
|
|
case "command": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
if (JSON.stringify(msg.command ?? null).length > MAX_COMMAND_BYTES) {
|
|
return send(socket, { type: "error", message: "command too large" });
|
|
}
|
|
const result = runCommand(room, session.playerId, msg.command as Command);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
|
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
|
botRemark(room, session.playerId, result.events as { type: string }[]);
|
|
runBots(room);
|
|
break;
|
|
}
|
|
case "rollDie": {
|
|
// The tabletop D4 for house calls: publishes to the room's talk,
|
|
// so it persists, replays, and shows in unread badges like chat.
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
const roll = 1 + Math.floor(Math.random() * 4);
|
|
const result = addChat(room, session.playerId, `rolls the die \u2014 ${roll}`);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
|
|
break;
|
|
}
|
|
case "chat": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
const result = addChat(room, session.playerId, String(msg.text ?? ""));
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
|
|
break;
|
|
}
|
|
case "makeTransfer": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
const result = makeTransferCode(room, session.playerId, session.token);
|
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
|
send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt });
|
|
break;
|
|
}
|
|
case "claimTransfer": {
|
|
if (session.claimFails >= 5) {
|
|
return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" });
|
|
}
|
|
const result = claimTransferCode(String(msg.code ?? ""));
|
|
if ("error" in result) {
|
|
session.claimFails++;
|
|
return send(socket, { type: "error", message: result.error });
|
|
}
|
|
session.claimFails = 0;
|
|
send(socket, { type: "transferClaimed", seat: result });
|
|
break;
|
|
}
|
|
case "catchUp": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
// A catch-up replays the whole game server-side; one at a time, please.
|
|
const now = Date.now();
|
|
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
|
return send(socket, { type: "error", message: "catching up already — one moment" });
|
|
}
|
|
session.lastCatchUpAt = now;
|
|
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0), msg.full === true);
|
|
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
|
send(socket, { type: "catchUp", steps });
|
|
break;
|
|
}
|
|
case "pickColor": {
|
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
|
const problem = pickColor(room, session.playerId, Number(msg.color));
|
|
if (problem) return send(socket, { type: "error", message: problem });
|
|
broadcastRoomState(room);
|
|
break;
|
|
}
|
|
case "hotseatReport": {
|
|
// A device finishes a handful of games at most; a firehose is abuse.
|
|
if (++session.hotseatReports > 20) return;
|
|
const id = String(msg.id ?? "").slice(0, 64);
|
|
const stage = msg.stage === "finished" ? "finished" : msg.stage === "started" ? "started" : null;
|
|
if (!id || !stage) return send(socket, { type: "error", message: "bad report" });
|
|
recordHotseat({
|
|
id, stage,
|
|
players: Number(msg.players), commands: Number(msg.commands),
|
|
minutes: Number(msg.minutes), winReason: typeof msg.winReason === "string" ? msg.winReason : undefined,
|
|
});
|
|
break;
|
|
}
|
|
case "stats": {
|
|
send(socket, { type: "stats", stats: engagementStats() });
|
|
break;
|
|
}
|
|
case "myGames": {
|
|
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
|
|
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
|
|
const games = [];
|
|
for (const seat of seats) {
|
|
if (typeof seat !== "object" || seat === null) continue;
|
|
const room = getRoom(String(seat.roomId ?? ""));
|
|
if (!room) continue;
|
|
const name = String(seat.name ?? "");
|
|
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue;
|
|
games.push(summarize(room, name));
|
|
}
|
|
send(socket, { type: "games", games });
|
|
break;
|
|
}
|
|
default:
|
|
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
|
|
}
|
|
} catch (e) {
|
|
// Expected failures travel as result.error values; anything thrown is a
|
|
// bug, and its message (paths, internals) is not for strangers' eyes.
|
|
console.error("unhandled protocol error:", e);
|
|
send(socket, { type: "error", message: "internal error" });
|
|
}
|
|
});
|
|
});
|
|
|
|
console.log(`wizwar serving client + websocket on port ${port}`);
|