Gallery talk, the keeper's bell, rematches, seats that travel by phrase, and seats doubted before dropped

Brought over from the game kit, where they were ported from Wiz-War. The
host may let the Peanut Gallery talk; a watcher signs a name no seat
holds. A seated wizard may challenge Kestrel, the keeper: a seat is held,
the call rings Sentry once per table, and the lobby says what hour it is
where the keeper lives. A finished table may call for a rematch with the
same company and bots, seats held. Transfer seat mints four words good
for ten minutes and one use; the hall claims them. The hall forgets a
held seat only on the second refusal in a row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-23 12:27:21 -04:00
1 parent 5b169d7131
commit e5c240d970
11 files changed
+576 -32

No files matched your search

+56 -3
View File
@@ -8,7 +8,13 @@
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the duel begins
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
// POST /api/rooms/:id/turn {token, input} this seat's move
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room;
// {name, text} from the gallery, when the host has let it talk
// POST /api/rooms/:id/gallery {token, on} the host lets the gallery talk, or hushes it
// POST /api/rooms/:id/challenge {token} call the keeper of the site to a held seat; their phone rings
// POST /api/rooms/:id/rematch {token} a finished table opens another with the same company
// POST /api/rooms/:id/transfer {token} a four-word phrase that carries this seat to another device
// POST /api/transfer {phrase} claim a phrase: the seat and its token, once
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
// a seat in the Peanut Gallery, counted for the table
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn
@@ -30,6 +36,9 @@ import { dirname } from 'node:path';
const PORT = Number(process.env.PORT ?? 8788);
const HOST = process.env.HOST ?? '127.0.0.1';
const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
/** 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.
@@ -41,7 +50,7 @@ process.on('unhandledRejection', (reason) => {
Sentry.captureException(reason);
});
const rooms = new Rooms(new Store(DATA_DIR));
const rooms = new Rooms(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));
const PUBLIC_URL = (process.env.WH_PUBLIC_URL ?? 'https://hands.kestrelsnest.social').replace(/\/$/, '');
@@ -51,6 +60,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(() => {
@@ -58,6 +73,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();
@@ -178,6 +196,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') {
@@ -237,12 +260,42 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
return send(res, 201, { id: report.id });
}
if (action === 'say') {
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
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);
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.say(room, token, body.text);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'gallery') {
const token = typeof body.token === 'string' ? body.token : undefined;
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 token = typeof body.token === 'string' ? body.token : undefined;
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 duel — ${link}`, {
fingerprint: ['challenge', room.id],
tags: { room: room.id, challenger: seat.name },
extra: { link, wizards: room.seats.map((s) => s.name).join(', ') }
});
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'rematch') {
const next = rooms.rematch(room, typeof body.token === 'string' ? body.token : undefined);
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, typeof body.token === 'string' ? body.token : undefined));
}
throw new RoomError('Not here.', 404);
}