The Peanut Gallery and table talk

A room's link without a seat is now a place in the gallery: a tokenless
view that withholds any gesture hidden from anyone at the table, counted
for the table and the lobby. Seated wizards can talk; each line is a
ledger line, shown in the rail and in the lobby, and the hall counts
what was said while you were away. The hall's pitch no longer says two
wizards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 09:50:06 -04:00
co-authored by Claude Fable 5.1
parent 75c2dc9241
commit 8e7376d59a
19 changed files with 488 additions and 80 deletions
+28 -11
View File
@@ -5,9 +5,11 @@
// POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the duel begins)
// POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far
// GET /api/rooms/:id?token= the view for that seat
// 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
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
// 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
//
// Caddy serves the static site and proxies /api and /ws here.
@@ -15,7 +17,7 @@ import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import { RateLimit } from './ratelimit';
import { RoomError, Rooms } from './rooms';
import { RoomError, Rooms, SPECTATOR } from './rooms';
import { Store } from './store';
const PORT = Number(process.env.PORT ?? 8788);
@@ -35,10 +37,14 @@ process.on('unhandledRejection', (reason) => {
const rooms = new Rooms(new Store(DATA_DIR));
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
const doors = new RateLimit(40, 60 * 60 * 1000);
/** Table talk: a lively table says a few lines a minute, not hundreds. */
const voices = new RateLimit(240, 60 * 60 * 1000);
const MAX_AUDIENCE = 30;
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS);
doors.prune();
voices.prune();
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
}, 60 * 60 * 1000).unref();
@@ -95,8 +101,9 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const room = rooms.get(parts[2] ?? '');
const action = parts[3];
if (!action && req.method === 'GET') {
const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined);
return send(res, 200, rooms.view(room, seat.id));
const token = url.searchParams.get('token');
if (!token) return send(res, 200, rooms.view(room, SPECTATOR));
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
const body = await readBody(req);
@@ -122,6 +129,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'say') {
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);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
throw new RoomError('Not here.', 404);
}
@@ -139,17 +153,20 @@ server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname !== '/ws') return socket.destroy();
let seatId: string;
let roomId: string;
let room: ReturnType<Rooms['get']>;
try {
roomId = url.searchParams.get('room') ?? '';
seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id;
room = rooms.get(url.searchParams.get('room') ?? '');
const token = url.searchParams.get('token');
seatId = token ? rooms.seatOf(room, token).id : SPECTATOR;
if (seatId === SPECTATOR && rooms.audience(room) >= MAX_AUDIENCE) throw new RoomError('The gallery is full.', 429);
} catch {
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
const unsubscribe = rooms.subscribe(roomId, (room) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq }));
});
const onChange = (changed: ReturnType<Rooms['get']>) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: changed.seq }));
};
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe);
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
});