Table talk may be private, to the seats named and never the gallery, and a line says the round it was said in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-29 12:16:08 -04:00
co-authored by Claude Opus 5.5
parent 8e35d3a3a6
commit eca8c92964
9 changed files with 71 additions and 18 deletions
+2 -2
View File
@@ -8,7 +8,7 @@
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
// 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, to?} table talk, from a seat to the whole room, or privately to the seats in `to`;
// {name, text} or from the gallery, signed, when the host allows it
// POST /api/rooms/:id/gallery {token, on} the host lets the Peanut Gallery talk, or hushes it
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
@@ -241,7 +241,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
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);
rooms.say(room, token, body.text, body.to);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'gallery') {
+18 -4
View File
@@ -15,13 +15,14 @@ afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** A table of the demo game: the host and a second person, the game begun. */
function table(spec: GameSpec<State, Input> = game) {
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */
function table(spec: GameSpec<State, Input> = game, withCy = false) {
const rooms = new Rooms(spec, new Store(dir));
const host = rooms.create('Ann', 2);
const host = rooms.create('Ann', 3);
const guest = rooms.join(host.room, 'Bo');
const third = withCy ? rooms.join(host.room, 'Cy') : null;
rooms.begin(host.room, host.token);
return { rooms, room: host.room, ann: host.token, bo: guest.token };
return { rooms, room: host.room, ann: host.token, bo: guest.token, cy: third?.token };
}
describe('rooms', () => {
@@ -76,4 +77,17 @@ describe('rooms', () => {
expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 });
expect(rooms.view(room, '').yourLastInput).toBeNull();
});
it('shows a private line to its sender and the seats it names, never to anyone else or the gallery', () => {
const { rooms, room, ann, bo } = table(game, true);
rooms.say(room, ann, 'Everyone, hello.');
rooms.say(room, ann, 'Bo, lay a five.', ['B']);
const heard = (seat: string) => rooms.view(room, seat).chat.map((l) => l.text);
expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
expect(heard('C')).toEqual(['Everyone, hello.']);
expect(heard('')).toEqual(['Everyone, hello.']);
expect(rooms.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 });
expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/);
});
});
+24 -6
View File
@@ -9,8 +9,9 @@ import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type Ta
import type { ChatLine, RoomView } from '../../src/lib/net/view';
import type { LedgerLine, Store } from './store';
/** The longest line of talk. A game of long negotiation raises this, and the talk box's maxlength with it. */
const CHAT_MAX_LENGTH = 300;
/** Lines of talk kept and sent; the ledger keeps them all. */
/** Lines of talk kept and sent; the ledger keeps them all. A game that runs for weeks of talk raises this. */
const CHAT_KEEP = 200;
export { SPECTATOR };
@@ -394,10 +395,18 @@ export class Rooms<State, Input> {
return this.game.settled ? this.game.settled(input as Input) : true;
}
/** Table talk, from a seat to everyone at the table and in the gallery. */
say(room: Room<State>, token: string | undefined, raw: unknown): void {
/**
* Table talk, from a seat: to everyone at the table and in the gallery,
* or, naming seats, privately to them alone. A private line is never
* shown to anyone else, the gallery included.
*/
say(room: Room<State>, token: string | undefined, raw: unknown, rawTo?: unknown): void {
const seat = this.seatOf(room, token);
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw) });
const to = Array.isArray(rawTo)
? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))]
: [];
if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.');
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) });
}
private cleanTalk(raw: unknown): string {
@@ -435,7 +444,8 @@ export class Rooms<State, Input> {
state: room.state ? this.game.view(room.state, seatId) : null,
yourInput: seated && seatId in room.pending ? room.pending[seatId] : null,
yourLastInput: seated && seatId in room.played ? room.played[seatId] : null,
chat: room.chat,
// Private lines only to their sender and the seats they name; a note is the sender's own memory, never sent.
chat: room.chat.filter((line) => !line.to || line.id === seatId || (seated && line.to.includes(seatId))).map(({ note: _, ...line }) => line),
audience: this.gallery.get(room.id) ?? 0,
galleryTalk: room.galleryTalk,
challenge: room.challenge,
@@ -522,7 +532,15 @@ export class Rooms<State, Input> {
room.resolved += 1;
break;
case 'chat':
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
room.chat.push({
id: line.id,
text: line.text,
at: line.at,
...(room.state ? { turn: this.game.turn(room.state) } : {}),
...(line.name ? { name: line.name } : {}),
...(line.to ? { to: line.to } : {}),
...(line.note !== undefined ? { note: line.note } : {})
});
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
break;
}
+5 -2
View File
@@ -20,8 +20,11 @@ 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 seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string }
/**
* 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 }
/** 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. */