Files
wizwar6e/packages/server/src/index.ts
T
Eric WagonerandClaude Fable 5 85831828d7 The tally: a booklet tab counting the love
A new tab in the help booklet answers "how much is this getting
played?" from the ledgers themselves: games chronicled (begun and
fought to a finish), distinct wizards seated, every spell, step, and
punch recorded, time at the table estimated by the clock between
moves (gaps over ten minutes don't count — async games tell the
truth), victories split by treasure-theft versus last standing, the
longest game, the fullest table, and the date the first game was
dealt. Computed server-side over the in-memory rooms with a 60-second
cache, fetched over the socket when the tab opens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:31:14 -04:00

332 lines
13 KiB
TypeScript

// Websocket front door. Protocol (JSON messages):
// client -> server:
// {type:"create", name} create a room, become host
// {type:"join", roomId, name} join (or rejoin) a room
// {type:"start"} host starts the game
// {type:"command", command} a game Command for the engine
// server -> client:
// {type:"welcome"} on connect
// {type:"room", roomId, players, hostId, started}
// {type:"events", events} redacted for this recipient
// {type:"state", view} redacted full view (after every change)
// {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,
engagementStats,
pickColor,
getRoom,
joinRoom,
loadPersistedRooms,
makeTransferCode,
redactFor,
roomCount,
runCommand,
seatTokenValid,
startGame,
summarize,
viewForPlayer,
type Room,
} from "./rooms";
// --- 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 {
// eslint-disable-next-line no-control-regex
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();
// 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);
res.writeHead(200, { "content-type": MIME[extname(real)] ?? "application/octet-stream" });
res.end(body);
} catch {
res.writeHead(500).end();
}
});
const wss = new WebSocketServer({ server: httpServer, path: undefined, 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;
}
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),
};
}
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));
}
}
}
function broadcastRoomState(room: Room): void {
broadcast(room, (playerId) => 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,
};
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.
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name) });
}
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);
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 }));
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));
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 "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}`);