Rematches, seats that travel by phrase, the keeper's bell with the hour where they live, the host's leave for the gallery to talk, and seats doubted before they are dropped

Ported from Wiz-War, where each earned its keep. All on the ledger:
galleryTalk, challenge and rematch lines, gallery chat signed with a
name, held seats on a rematch table's room line. Twenty-two protocol
checks against a scratch server and a replay after restart.

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-23 12:19:03 -04:00
co-authored by Claude Fable 5.1
parent 65d62f2ab3
commit 6dce3b82f0
14 changed files with 521 additions and 28 deletions
+48 -2
View File
@@ -32,6 +32,9 @@ const PORT = Number(process.env.PORT ?? '__PORT__');
const HOST = process.env.HOST ?? '127.0.0.1';
const DATA_DIR = process.env.DATA_DIR ?? '../data/rooms';
const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://__DOMAIN__').replace(/\/$/, '');
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
const KEEPER = process.env.KEEPER ?? 'the keeper';
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
const BODY_LIMIT = 16 * 1024;
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
@@ -43,7 +46,7 @@ process.on('unhandledRejection', (reason) => {
Sentry.captureException(reason);
});
const rooms = new Rooms(game, new Store(DATA_DIR));
const rooms = new Rooms(game, new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ });
/** Reports live beside the room ledgers, not among them. */
const reports = new Reports(dirname(DATA_DIR));
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
@@ -52,6 +55,12 @@ const doors = new RateLimit(40, 60 * 60 * 1000);
const voices = new RateLimit(240, 60 * 60 * 1000);
/** Reports, answers and pictures: a few an hour from one address; each rings the keeper's phone. */
const desk = new RateLimit(6, 60 * 60 * 1000);
/** The gallery talks under a tighter rein than the table: no seat, no token, one address. */
const galleryVoices = new RateLimit(30, 10 * 60 * 1000);
/** Calls to the keeper: a real person's phone rings for each. */
const bells = new RateLimit(3, 60 * 60 * 1000);
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
const claims = new RateLimit(10, 10 * 60 * 1000);
const MAX_AUDIENCE = 30;
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => {
@@ -59,6 +68,9 @@ setInterval(() => {
doors.prune();
voices.prune();
desk.prune();
galleryVoices.prune();
bells.prune();
claims.prune();
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
}, 60 * 60 * 1000).unref();
@@ -108,7 +120,7 @@ async function readBody(req: IncomingMessage): Promise<Record<string, unknown>>
}
}
/** A report or an answer is worth waking the keeper for; the desk's own replies are not. */
/** A report, an answer, or a call to the table is worth waking the keeper for; the desk's own replies are not. */
function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<string, string>; extra: Record<string, unknown> }): void {
Sentry.captureMessage(title, { level: 'error', ...detail });
}
@@ -166,6 +178,11 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
if (!claims.allow(clientOf(req))) throw new RoomError('Too many claims from here just now; try again later.', 429);
const body = await readBody(req);
return send(res, 200, rooms.claim(body.phrase));
}
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
if (parts.length === 2 && req.method === 'POST') {
@@ -207,10 +224,39 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'say') {
if (!token) {
if (!galleryVoices.allow(clientOf(req))) throw new RoomError('The gallery has said plenty from here for now.', 429);
rooms.sayFromGallery(room, body.name, body.text);
return send(res, 200, rooms.view(room, SPECTATOR));
}
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
rooms.say(room, token, body.text);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'gallery') {
rooms.setGalleryTalk(room, token, body.on === true);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'challenge') {
if (!bells.allow(clientOf(req))) throw new RoomError('The keeper has been called enough from here for one hour.', 429);
const seat = rooms.seatOf(room, token);
rooms.callKeeper(room, token, KEEPER);
const link = `${PUBLIC_URL}/join/${room.id}`;
// One issue per table, so each call rings once, with the door in the message.
ringBell(`${seat.name} challenges ${KEEPER} to a game — ${link}`, {
fingerprint: ['challenge', room.id],
tags: { room: room.id, challenger: seat.name },
extra: { link, players: room.seats.map((s) => s.name).join(', ') }
});
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'rematch') {
const next = rooms.rematch(room, token);
return send(res, next.created ? 201 : 200, { roomId: next.roomId, seat: next.seat?.id ?? null, token: next.token, created: next.created });
}
if (action === 'transfer') {
return send(res, 200, rooms.transfer(room, token));
}
if (action === 'report') {
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
const seat = token ? rooms.seatOf(room, token) : null;