The keeper can say a word at a table through the keeper's door, signed as the keeper and heard whether or not the gallery may talk, so a call answered too late, after the game began, can still be answered; <slug>-say.sh sends it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Opus 5.5 committed 2026-10-20 13:21:24 -04:00
1 parent 8c37cc5210
commit e91d209cbe
11 files changed
+51 -3

No files matched your search

+6
View File
@@ -33,6 +33,7 @@
//
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
@@ -225,6 +226,11 @@ async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: st
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
}
if (parts[3] === 'say' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
rooms.keeperSays(room, body.text);
return send(res, 204, {});
}
throw new RoomError('Not here.', 404);
}
+10
View File
@@ -6,6 +6,7 @@ import { game, type Input, type State } from '../../src/lib/game';
import type { GameSpec } from '../../src/lib/game/spec';
import { Rooms } from './rooms';
import { Store } from './store';
import { SPECTATOR } from '../../src/lib/game/spec';
let dir: string;
beforeEach(() => {
@@ -35,6 +36,15 @@ describe('rooms', () => {
expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/);
});
it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => {
const { rooms, room } = table();
expect(room.galleryTalk).toBe(false);
rooms.keeperSays(room, 'I came too late for the seat; call me again.');
const said = { id: SPECTATOR, name: 'the keeper', keeper: true, text: 'I came too late for the seat; call me again.' };
expect(room.chat.at(-1)).toMatchObject(said);
expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
});
it('gives a seat a new token at the keeper\'s word: the old one opens nothing, and a restart keeps the change', () => {
const { rooms, room, bo } = table();
const phrase = rooms.transfer(room, bo).phrase;
+6
View File
@@ -242,6 +242,11 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
}
/** The keeper of the site says a word at a table from the keeper's door, whether or not the gallery may talk: the answer to a call they could not take up. */
keeperSays(room: Room<State>, raw: unknown): void {
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: this.keeper.name, text: this.cleanTalk(raw), keeper: true });
}
/** 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);
@@ -739,6 +744,7 @@ export class Rooms<State, Input> {
at: line.at,
...(room.state ? { turn: this.game.turn(room.state) } : {}),
...(line.name ? { name: line.name } : {}),
...(line.keeper ? { keeper: true as const } : {}),
...(line.to ? { to: line.to } : {}),
...(line.note !== undefined ? { note: line.note } : {})
});
+1 -1
View File
@@ -37,7 +37,7 @@ export type LedgerLine =
* A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR).
* `to` names the seats a private line was said to; `note` is a bot's memory of what it said, never shown.
*/
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; to?: SeatId[]; note?: unknown }
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; keeper?: true; to?: SeatId[]; note?: unknown }
/** 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. */