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:
1 parent
5b169d7131
commit
e5c240d970
11 files changed
+576
-32
No files matched your search
+56
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user