The dream feature lands, behind a preference that ships off for now. The chronicle's lines grow structure — each knows its turn, counted by the same boundary events on both ends of the wire (the server counts the deal's own events too, or every number would sit one turn behind). The first notable line of a turn — combat, spectacle, a targeted cast — wears a small eye; clicking it asks the server for that one turn's steps, rebuilt and redacted like any reel, and the replay opens STRAIGHT into first person through the eyes of the wizard whose turn it was: their position, the viewer's knowledge, nothing private leaked. It plays that turn and stops. Save-video and the board toggle come along for free. Two camera bugs die with it: the replay tween effect tracked its own writes, restarting the ease every frame until walks decayed into the slow wall-piercing drift Eric saw — untracked reads run one tween per step now. And the reel camera follows whichever eyes the reel wears, not always your own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
578 lines
26 KiB
TypeScript
578 lines
26 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,
|
|
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 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();
|
|
// 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;
|
|
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;
|
|
/** 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
|
|
}
|
|
}
|
|
}
|
|
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));
|
|
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 "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 "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 steps = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
|
|
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
|
send(socket, { type: "moment", 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}`);
|