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;
+157 -7
View File
@@ -31,6 +31,16 @@ export interface Room<State> {
size: number;
createdAt: number;
seats: Seat[];
/** The host lets the Peanut Gallery talk at this table. */
galleryTalk: boolean;
/** The keeper of the site was called to a seat, and by whom. */
challenge: { by: SeatId; at: number } | null;
/** Names with a seat held for them: the players of the last table, or the keeper. */
expected: string[];
/** A finished table that called for a rematch: where it went, and who called. */
rematch: { to: string; by: SeatId } | null;
/** The table this one is the rematch of. */
rematchOf: string | null;
state: State | null;
/** Inputs received for the round in progress, by seat. */
pending: Record<SeatId, unknown>;
@@ -64,6 +74,16 @@ function newSeed(): number {
return (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0;
}
/** Phrases that carry a seat between devices: four plain words, ten minutes, one use. */
const TRANSFER_WORDS = [
'ember', 'raven', 'flagon', 'wand', 'rune', 'moss', 'torch', 'frost', 'amber', 'wisp', 'cellar', 'gable',
'onyx', 'briar', 'tome', 'cinder', 'gloom', 'spiral', 'mirror', 'lantern', 'thistle', 'harbor', 'quill', 'saddle',
'willow', 'copper', 'meadow', 'anvil', 'beacon', 'orchard', 'pebble', 'cloister', 'walnut', 'tallow', 'heron', 'marble',
'ivy', 'kestrel', 'fennel', 'velvet', 'canyon', 'lattice', 'ledger', 'bramble', 'sable', 'tundra', 'juniper', 'compass'
];
const TRANSFER_TTL_MS = 10 * 60 * 1000;
const TRANSFER_FAIL_LIMIT = 20;
export function normalizeCode(raw: string): string {
return raw.trim().toUpperCase();
}
@@ -85,7 +105,9 @@ export class Rooms<State, Input> {
constructor(
private game: GameSpec<State, Input>,
private store: Store
private store: Store,
/** The keeper of the site: the name a table may call to a seat, and where they live. */
private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' }
) {
for (const id of store.roomIds()) {
const room = this.replay(id, store.read(id));
@@ -141,11 +163,21 @@ export class Rooms<State, Input> {
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
}
return this.open(name, size);
}
/** A table with nobody seated yet. */
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], seq = 0): Room<State> {
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, state: null, pending: {}, chat: [], seq, updatedAt: createdAt };
}
/** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */
private open(name: string, size: number, follows?: { rematchOf: string; expected: string[] }): { room: Room<State>; seat: Seat; token: string } {
let id = newCode();
while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
const room = this.blank(id, size, Date.now(), follows?.rematchOf ?? null, follows?.expected ?? []);
this.rooms.set(id, room);
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt, ...(follows ? { rematchOf: follows.rematchOf, expected: follows.expected } : {}) });
return { room, ...this.sit(room, name, false) };
}
@@ -154,6 +186,99 @@ export class Rooms<State, Input> {
return this.sit(room, name, false);
}
/** Names with a seat held who have not yet sat. */
private awaited(room: Room<State>): string[] {
return room.expected.filter((n) => !room.seats.some((s) => s.name.toLowerCase() === n.toLowerCase()));
}
/** Host only: let the Peanut Gallery talk at this table, or hush it. Written to the ledger. */
setGalleryTalk(room: Room<State>, token: string | undefined, on: boolean): void {
const seat = this.seatOf(room, token);
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table decides whether the gallery may talk.', 403);
if (room.galleryTalk === on) return;
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
}
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
const name = cleanName(rawName);
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('A player at the table has that name.', 409);
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: this.cleanTalk(raw) });
}
/**
* A seated player calls the keeper of the site to the table: a seat is
* held under the keeper's name and the call goes on the ledger. Once per
* table, before the game begins. The caller rings the keeper's bell.
*/
callKeeper(room: Room<State>, token: string | undefined, keeper: string): void {
const seat = this.seatOf(room, token);
if (room.state) throw new RoomError('The game has begun.', 409);
if (room.challenge) throw new RoomError(`${keeper} has already been called to this table.`, 409);
if (room.seats.some((s) => s.name.toLowerCase() === keeper.toLowerCase())) throw new RoomError(`${keeper} is already here.`, 409);
if (room.seats.length + this.awaited(room).length >= room.size) throw new RoomError('Every seat at this table is taken or held.', 409);
this.commit(room, { t: 'challenge', at: Date.now(), by: seat.id, keeper });
}
/**
* A finished table calls for a rematch: a new table of the same size with
* the same bots, seats held for the same people, the caller seated as its
* host. Anyone at the old table may call; a second call finds the table
* already open.
*/
rematch(room: Room<State>, token: string | undefined): { roomId: string; seat: Seat | null; token: string | null; created: boolean } {
const caller = this.seatOf(room, token);
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409);
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
const next = this.open(caller.name, room.size, { rematchOf: room.id, expected: others });
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
}
/** Phrases waiting to be claimed, with the seat and the token they carry. */
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
private failedClaims = 0;
private failWindowStart = 0;
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } {
const seat = this.seatOf(room, token);
const now = Date.now();
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
let phrase: string;
do {
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
} while (this.transfers.has(phrase));
const expiresAt = now + TRANSFER_TTL_MS;
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt });
return { phrase, expiresAt };
}
/** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */
claim(raw: unknown): { roomId: string; seat: SeatId; token: string } {
const now = Date.now();
if (now - this.failWindowStart > TRANSFER_TTL_MS) {
this.failWindowStart = now;
this.failedClaims = 0;
}
if (this.failedClaims >= TRANSFER_FAIL_LIMIT) throw new RoomError('Too many failed claims; transfers are cooling off. Mint a fresh phrase.', 429);
const phrase = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, '-');
const t = this.transfers.get(phrase);
if (!t || t.expiresAt < now) {
this.failedClaims += 1;
if (this.failedClaims >= TRANSFER_FAIL_LIMIT) this.transfers.clear();
throw new RoomError('That phrase is unknown or has expired.', 404);
}
this.transfers.delete(phrase);
const room = this.get(t.roomId);
const seat = this.seatOf(room, t.token);
return { roomId: room.id, seat: seat.id, token: t.token };
}
/** The host, who opened the table, begins once enough are seated, however full the table. */
begin(room: Room<State>, token: string | undefined): void {
const seat = this.seatOf(room, token);
@@ -178,6 +303,10 @@ export class Rooms<State, Input> {
private sit(room: Room<State>, rawName: string, bot: boolean): { seat: Seat; token: string } {
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
const name = cleanName(rawName);
const awaited = this.awaited(room);
if (!awaited.some((n) => n.toLowerCase() === name.toLowerCase()) && room.seats.length + awaited.length >= room.size) {
throw new RoomError('The seats left are held for players expected at this table.', 409);
}
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
const token = bot ? '' : newId(18);
@@ -227,13 +356,17 @@ export class Rooms<State, Input> {
/** Table talk, from a seat to everyone at the table and in the gallery. */
say(room: Room<State>, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token);
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw) });
}
private cleanTalk(raw: unknown): string {
const text = String(raw ?? '')
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, CHAT_MAX_LENGTH);
if (!text) throw new RoomError('Nothing to say.');
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text });
return text;
}
/** Seats whose input the next resolution needs and has not yet received. */
@@ -258,7 +391,14 @@ export class Rooms<State, Input> {
over: room.state ? this.game.over(room.state) : null,
state: room.state ? this.game.view(room.state, seatId) : null,
chat: room.chat,
audience: this.gallery.get(room.id) ?? 0
audience: this.gallery.get(room.id) ?? 0,
galleryTalk: room.galleryTalk,
challenge: room.challenge,
expected: this.awaited(room),
rematch: room.rematch,
rematchOf: room.rematchOf,
keeper: this.keeper.name,
keeperZone: this.keeper.zone
};
}
@@ -307,6 +447,16 @@ export class Rooms<State, Input> {
case 'seat':
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
break;
case 'galleryTalk':
room.galleryTalk = line.on;
break;
case 'challenge':
room.challenge = { by: line.by, at: line.at };
if (!room.expected.some((n) => n.toLowerCase() === line.keeper.toLowerCase())) room.expected = [...room.expected, line.keeper];
break;
case 'rematch':
room.rematch = { to: line.to, by: line.by };
break;
case 'input':
room.pending[line.id] = this.game.cleanInput(line.input);
break;
@@ -322,7 +472,7 @@ export class Rooms<State, Input> {
room.pending = {};
break;
case 'chat':
room.chat.push({ id: line.id, text: line.text, at: line.at });
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
break;
}
@@ -331,7 +481,7 @@ export class Rooms<State, Input> {
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
const first = lines[0];
if (!first || first.t !== 'room') return null;
const room: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], lines.length);
for (const line of lines) {
this.apply(room, line);
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
+10 -3
View File
@@ -7,7 +7,8 @@ import { join } from 'node:path';
import type { SeatId } from '../../src/lib/game/spec';
export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number }
/** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
| { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
@@ -19,8 +20,14 @@ export type LedgerLine =
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
| { t: 'over'; at: number; winner: SeatId | null; reason: string }
/** A line of table talk from a seated player. */
| { t: 'chat'; at: number; id: SeatId; text: string };
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string }
/** The host let the gallery talk, or hushed it. */
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
/** A seat is held for the keeper of the site, called to the table. */
| { t: 'challenge'; at: number; by: SeatId; keeper: string }
/** A finished table called for a rematch: where it went, and who called. */
| { t: 'rematch'; at: number; to: string; by: SeatId };
export class Store {
constructor(private dir: string) {