Credibility pass: one teleport step, one bell, one step type, and the comments that argued are gone

Since 6e49fc9. Engine: the teleport BFS in the view and the engine's
wallIgnoringDistance share teleportStep; the counter trail is weighed
between steps instead of on six arms; the bush attack takes its
adjacency from SIDES like the wall does; a dead redirected clause, a
one-member set, an identity ternary, and a stray try/catch are gone;
two tests now assert what their names promise. Server: KEEPER and
MAX_SEATS live in rooms.ts, the three Sentry bells ring through one
helper, the unread reply field is dropped, actingSeat comes from the
engine, and the protocol header names every message. Web: ReplayStep
is one type, cardName one helper, the say box and the reference section
one snippet each, the keeper's name arrives from the server rather than
the source, and the lifesaver ruling that ruled nothing is out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-22 09:56:49 -04:00
co-authored by Claude Fable 5.1
parent c4a555f8df
commit 66b5c06f17
21 changed files with 245 additions and 239 deletions
+41 -39
View File
@@ -13,6 +13,9 @@
// {type:"myFeedback", seats} your reports + the wizards' replies
// {type:"rollDie"} the tabletop D4, published as talk
// {type:"addBot", style?, tier?} host seats an automaton
// {type:"challengeKeeper"} call the keeper of the site to a seat
// {type:"rematch"} a finished table asks for another
// {type:"feedbackAnswer", seat, reportId, text} a player's word under the desk's reply
// {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
@@ -27,18 +30,22 @@
// {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:"rematch", roomId, to, by} the table hears where the rematch went
// {type:"rematched", roomId} the caller's own seat at the new table
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"|"feedbackList"}
// {type:"error", message}
import * as Sentry from "@sentry/node";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream, mkdirSync, writeFileSync } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws";
import { cardDef } from "@wizwar/engine";
import type { Command, PlayerId } from "@wizwar/engine";
import { callKeeper, callRematch,
import {
callKeeper,
callRematch,
catchUpSteps,
momentSteps,
claimTransferCode,
@@ -64,10 +71,11 @@ import { callKeeper, callRematch,
type Room,
kickSeat,
abandonRoom,
KEEPER,
MAX_SEATS,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
import { mkdirSync, writeFileSync } from "node:fs";
import { clipsIndexHtml, clipPageHtml } from "./clips";
import { SlidingLimit, clientAddress } from "./ratelimit";
import { getShare, loadShares, mintShare } from "./shares";
@@ -80,6 +88,14 @@ if (process.env.SENTRY_DSN) {
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: "production", tracesSampleRate: 0 });
}
/** The bell the keeper's phone answers: a Sentry issue at error level,
* because the alert rule rings for high-priority issues and a warning is
* filed as medium — a bell nobody hears. The SDK drops it when no DSN is
* set. */
function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<string, string>; extra: Record<string, unknown> }): void {
Sentry.captureMessage(title, { level: "error", ...detail });
}
// --- 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
@@ -131,8 +147,6 @@ function receiveFeedbackImage(req: IncomingMessage, res: ServerResponse, reportI
/** Calls to the keeper: a real person's phone rings for each. */
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
/** The keeper of this table: the wizard a lobby may challenge. */
const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
const MAX_COMMAND_BYTES = 16384; // serialized game command
@@ -769,8 +783,7 @@ wss.on("connection", (socket, req) => {
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) {
// A refused seat is worth a line: a lost seat mid-game is the
// costliest quiet failure this table has.
// Logged: a refused seat is otherwise invisible after the fact.
console.warn(`join refused: room ${room.id} name ${JSON.stringify(name)} — ${result.error}`);
return send(socket, { type: "error", message: result.error });
}
@@ -800,19 +813,14 @@ wss.on("connection", (socket, req) => {
}
const called = callKeeper(room, session.playerId, KEEPER);
if ("error" in called) return send(socket, { type: "error", message: called.error });
// The alarm the keeper listens for: one issue per room, so each
// call rings once, with the door in the message. Error level,
// because Sentry's alerts ring for high-priority issues and a
// warning is filed as medium — a bell nobody hears.
// One issue per room, so each call rings once, with the door in
// the message.
const link = `${PUBLIC_URL}/join/${room.id}`;
if (process.env.SENTRY_DSN) {
Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
level: "error",
fingerprint: ["challenge", room.id],
tags: { room: room.id, challenger: session.playerId },
extra: { link, players: room.players.join(", ") },
});
}
ringBell(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
fingerprint: ["challenge", room.id],
tags: { room: room.id, challenger: session.playerId },
extra: { link, players: room.players.join(", ") },
});
const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`);
if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at }));
broadcastRoomState(room);
@@ -1066,15 +1074,12 @@ wss.on("connection", (socket, req) => {
happened,
expected: clean(msg.expected),
});
// The desk's bell: one issue per report, the gist in the title.
if (process.env.SENTRY_DSN) {
Sentry.captureMessage(`Report from ${session.playerId ?? "the gallery"} in ${room.id}: ${happened.slice(0, 100)}`, {
level: "error",
fingerprint: ["report", reportId],
tags: { room: room.id, report: reportId, player: session.playerId ?? "(gallery)" },
extra: { happened, expected: clean(msg.expected), link: `${PUBLIC_URL}/join/${room.id}`, seq: room.log.length },
});
}
// One issue per report, the gist in the title.
ringBell(`Report from ${session.playerId ?? "the gallery"} in ${room.id}: ${happened.slice(0, 100)}`, {
fingerprint: ["report", reportId],
tags: { room: room.id, report: reportId, player: session.playerId ?? "(gallery)" },
extra: { happened, expected: clean(msg.expected), link: `${PUBLIC_URL}/join/${room.id}`, seq: room.log.length },
});
send(socket, { type: "feedbackReceived", id: reportId });
break;
}
@@ -1082,6 +1087,9 @@ wss.on("connection", (socket, req) => {
// A player's word under the desk's reply, from their lobby: the
// seat is proven as the ledger proves it, and the report must be
// theirs. Each answer rings the desk's bell.
if (!reportsPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
}
const seat = (msg.seat ?? {}) as Record<string, unknown>;
const roomId = String(seat.roomId ?? "").toUpperCase();
const name = String(seat.name ?? "");
@@ -1092,19 +1100,13 @@ wss.on("connection", (socket, req) => {
if (!report || report.player !== name || report.roomId !== roomId) return send(socket, { type: "error", message: "no such report of yours" });
const text = String(msg.text ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
if (!text) return send(socket, { type: "error", message: "say something" });
if (!reportsPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
}
const at = new Date().toISOString();
appendFeedback({ reportId, from: "player", player: name, text, at });
if (process.env.SENTRY_DSN) {
Sentry.captureMessage(`${name} answers on report ${reportId} (${roomId}): ${text.slice(0, 100)}`, {
level: "error",
fingerprint: ["report-answer", reportId, at],
tags: { room: roomId, report: reportId, player: name },
extra: { text, link: `${PUBLIC_URL}/join/${roomId}` },
});
}
ringBell(`${name} answers on report ${reportId} (${roomId}): ${text.slice(0, 100)}`, {
fingerprint: ["report-answer", reportId, at],
tags: { room: roomId, report: reportId, player: name },
extra: { text, link: `${PUBLIC_URL}/join/${roomId}` },
});
send(socket, { type: "feedbackReceived" });
break;
}
+17 -17
View File
@@ -5,6 +5,7 @@
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
import {
actingSeat as engineActingSeat,
applyCommand,
automatonCommand,
automatonFallback,
@@ -62,6 +63,16 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Six wizards fit at a table: the physical set's standees. */
export const MAX_SEATS = 6;
/** The keeper of this table: the wizard a lobby may challenge. */
export const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
/** Hold a seat for the keeper among the expected, once. */
function expectKeeper(room: Room, keeper: PlayerId): void {
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
}
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
@@ -141,10 +152,10 @@ export function callKeeper(room: Room, byId: PlayerId, keeper: PlayerId): { at:
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "take a seat first" };
if (room.challenge) return { error: `${keeper} has already been called to this table` };
if (room.players.includes(keeper)) return { error: `${keeper} is already here` };
if (room.players.length + (room.expected?.length ?? 0) >= 6) return { error: "the table is full" };
if (room.players.length + (room.expected?.length ?? 0) >= MAX_SEATS) return { error: "room is full" };
const at = new Date().toISOString();
room.challenge = { by: byId, at };
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
expectKeeper(room, keeper);
appendLine(room.id, { kind: "challenge", by: byId, at });
return { at };
}
@@ -290,7 +301,7 @@ export function joinRoom(
return { error: "that wizard name is taken in this room" };
}
if (room.state) return { error: "game already started" };
if (room.players.length >= 6) return { error: "room is full" };
if (room.players.length >= MAX_SEATS) return { error: "room is full" };
const fresh = randomBytes(16).toString("hex");
room.players.push(playerId);
room.tokens.set(playerId, hashToken(fresh));
@@ -431,7 +442,7 @@ export function addAutomaton(
tierWanted?: string,
): { name: PlayerId } | { error: string } {
if (room.state) return { error: "the game has started" };
if (room.players.length >= 6) return { error: "room is full" };
if (room.players.length >= MAX_SEATS) return { error: "room is full" };
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
if (!name) return { error: "the workshop is empty" };
const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle);
@@ -451,17 +462,7 @@ export function addAutomaton(
/** Whose input does the maze want right now? */
function actingSeat(room: Room): PlayerId | null {
const s = room.state;
if (!s || s.phase !== "playing") return null;
return (
s.wardPending?.ownerId ??
s.pushPending?.pusheeId ??
s.slowDeathPending?.playerId ??
s.stack?.waitingOn ??
s.pendingDiscard ??
s.chaosPending?.queue[0] ??
s.outOfTurnWindow?.playerId ??
s.players[s.turn.activeIndex]!.id
);
return s && s.phase === "playing" ? engineActingSeat(s) : null;
}
/**
@@ -827,8 +828,7 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
room.rematch = { roomId: line.to, by: line.by };
} else if (line.kind === "challenge") {
room.challenge = { by: line.by, at: line.at };
const keeper = process.env.WIZWAR_KEEPER ?? "Kestrel";
if (!room.players.includes(keeper)) room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
if (!room.players.includes(KEEPER)) expectKeeper(room, KEEPER);
} else if (line.kind === "chat") {
// File order preserves the interleaving with commands.
room.chat.push({ player: line.player, text: line.text, at: line.at });
+1 -5
View File
@@ -220,8 +220,6 @@ export interface FeedbackReport {
round: number | null;
happened: string;
expected: string;
/** The latest word from the desk, for the lobby's one-line summary. */
reply?: { at: string; text: string; status: string };
/** The whole exchange in order: the desk's replies and the player's answers. */
thread: { at: string; text: string; from: "desk" | "player"; status?: string }[];
/** A screenshot's file name under feedback-images/, when one was sent. */
@@ -248,9 +246,7 @@ export function readFeedback(): FeedbackReport[] {
} else if (report && line.from === "player") {
report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), from: "player" });
} else if (report) {
const reply = { at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved") };
report.reply = reply;
report.thread.push({ ...reply, from: "desk" });
report.thread.push({ at: String(line.at ?? ""), text: String(line.text ?? ""), status: String(line.status ?? "resolved"), from: "desk" });
}
continue;
}