A lobby may challenge the keeper, and the table can talk before the boards flip

"Challenge Kestrel, the keeper" holds a seat under the keeper's name,
writes the call to the ledger, says so at the table, and raises a
Sentry issue fingerprinted to the room — one ring per call, with the
room's link in the message — which is the alarm the keeper's phone
listens for. The keeper answers by the link: taking the seat, and
leaving a word if the game must wait. For that word, and for any table
to settle when to start, the lobby now shows its talk and carries a
composer; a joiner sees what was said before they sat. Three calls per
address per hour, one per room.

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-17 00:30:49 -04:00
co-authored by Claude Fable 5.1
parent de279364ce
commit 1be1cf7d1b
5 changed files with 110 additions and 3 deletions
+35 -1
View File
@@ -38,7 +38,7 @@ 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 { callRematch,
import { callKeeper, callRematch,
catchUpSteps,
momentSteps,
claimTransferCode,
@@ -86,6 +86,11 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
// Per-address limits, held across reconnects: a table of friends never
// nears them; a script filling the vault or the reports desk does.
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
/** 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
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
@@ -524,6 +529,8 @@ function roomInfo(room: Room) {
rematch: room.rematch ?? null,
expected: room.expected ?? [],
expansion: room.expansion,
challenge: room.challenge ?? null,
keeper: KEEPER,
colors: Object.fromEntries(room.colorChoices),
bots: Object.fromEntries(
// A mystery machine keeps its mood only while the game lives: once it
@@ -721,11 +728,38 @@ wss.on("connection", (socket, req) => {
// from firing again on every return to the room.
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
} else if (room.chat.length > 0) {
// The lobby's talk so far, for whoever just sat down.
send(socket, { type: "events", events: room.chat.map((c) => ({ type: "tableTalk", player: c.player, text: c.text })), replayed: true });
}
broadcastRoomState(room);
runBots(room);
break;
}
case "challengeKeeper": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (!challengesPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "the keeper has been called enough from here for one hour" });
}
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.
const link = `${PUBLIC_URL}/join/${room.id}`;
if (process.env.SENTRY_DSN) {
Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
level: "warning",
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);
break;
}
case "watch": {
// The Peanut Gallery: no name, no seat, no ledger line — a pure
// reader of the public broadcast, counted but never identified.
+22
View File
@@ -56,6 +56,8 @@ export interface Room {
rematch?: { roomId: string; by: PlayerId };
/** A rematch lobby: the wizards of the last table who have not yet sat. */
expected?: PlayerId[];
/** The table called the keeper of this site to a seat, and by whom. */
challenge?: { by: PlayerId; at: string };
}
const rooms = new Map<string, Room>();
@@ -131,6 +133,22 @@ export function createRoom(
return { room, token };
}
/** A lobby calls the keeper to the table: a seat is held under their
* name, the call is written to the ledger, and the caller (index.ts)
* raises the alarm the keeper listens for. Once per room. */
export function callKeeper(room: Room, byId: PlayerId, keeper: PlayerId): { at: string } | { error: string } {
if (room.state) return { error: "the game has started" };
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" };
const at = new Date().toISOString();
room.challenge = { by: byId, at };
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
appendLine(room.id, { kind: "challenge", by: byId, at });
return { at };
}
/** A finished table calls for a rematch: a new room with the same
* clockwork at the same tiers and the same expansion setting, waiting for
* the same humans. The first caller hosts it; anyone at the old table may
@@ -804,6 +822,10 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
} else if (line.kind === "rematch") {
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];
} else if (line.kind === "chat") {
// File order preserves the interleaving with commands.
room.chat.push({ player: line.player, text: line.text, at: line.at });
+8 -1
View File
@@ -23,6 +23,13 @@ export interface RoomMetaLine {
expansion?: boolean;
}
/** A table called the keeper: the seat is held and the keeper told. */
export interface ChallengeLine {
kind: "challenge";
by: string;
at: string;
}
/** The finished table called for a rematch: the new room it moved to. */
export interface RematchLine {
kind: "rematch";
@@ -84,7 +91,7 @@ export interface AbandonLine {
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine;
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");