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
+4
View File
@@ -30,6 +30,10 @@ README.
*lets the Peanut Gallery talk*; then a watcher signs a name no seat holds *lets the Peanut Gallery talk*; then a watcher signs a name no seat holds
and their lines read *Name (gallery)*. Each line is a ledger line, so it and their lines read *Name (gallery)*. Each line is a ledger line, so it
replays with the game, and the hall counts lines said while you were away. replays with the game, and the hall counts lines said while you were away.
A line said while a game is running carries its round (`turn`). A seat
may also speak privately, naming seats (`room.say(text, to)`): the line
is shown to its sender and those seats alone, never to the gallery, and
a game whose talk is its heart puts the choice of whom in its talk box.
- **A rematch**: a finished table may call for one. A new table of the same - **A rematch**: a finished table may call for one. A new table of the same
size opens with the same bots, seats held for the same people, and the size opens with the same bots, seats held for the same people, and the
caller as host; a second call finds it already open. Held seats refuse caller as host; a second call finds it already open. Held seats refuse
+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/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/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/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 // {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/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 // 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)); 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); 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)); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
} }
if (action === 'gallery') { if (action === 'gallery') {
+18 -4
View File
@@ -15,13 +15,14 @@ afterEach(() => {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
}); });
/** A table of the demo game: the host and a second person, the game begun. */ /** 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) { function table(spec: GameSpec<State, Input> = game, withCy = false) {
const rooms = new Rooms(spec, new Store(dir)); 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 guest = rooms.join(host.room, 'Bo');
const third = withCy ? rooms.join(host.room, 'Cy') : null;
rooms.begin(host.room, host.token); 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', () => { describe('rooms', () => {
@@ -76,4 +77,17 @@ describe('rooms', () => {
expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 }); expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 });
expect(rooms.view(room, '').yourLastInput).toBeNull(); 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 { ChatLine, RoomView } from '../../src/lib/net/view';
import type { LedgerLine, Store } from './store'; 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; 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; const CHAT_KEEP = 200;
export { SPECTATOR }; export { SPECTATOR };
@@ -394,10 +395,18 @@ export class Rooms<State, Input> {
return this.game.settled ? this.game.settled(input as Input) : true; 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); 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 { private cleanTalk(raw: unknown): string {
@@ -435,7 +444,8 @@ export class Rooms<State, Input> {
state: room.state ? this.game.view(room.state, seatId) : null, state: room.state ? this.game.view(room.state, seatId) : null,
yourInput: seated && seatId in room.pending ? room.pending[seatId] : null, yourInput: seated && seatId in room.pending ? room.pending[seatId] : null,
yourLastInput: seated && seatId in room.played ? room.played[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, audience: this.gallery.get(room.id) ?? 0,
galleryTalk: room.galleryTalk, galleryTalk: room.galleryTalk,
challenge: room.challenge, challenge: room.challenge,
@@ -522,7 +532,15 @@ export class Rooms<State, Input> {
room.resolved += 1; room.resolved += 1;
break; break;
case 'chat': 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); if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
break; break;
} }
+5 -2
View File
@@ -20,8 +20,11 @@ export type LedgerLine =
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> } | { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */ /** 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 } | { 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. */ /** The host let the gallery talk, or hushed it. */
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId } | { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
/** A seat is held for the keeper of the site, called to the table. */ /** A seat is held for the keeper of the site, called to the table. */
+8 -1
View File
@@ -47,7 +47,10 @@
<li class="muted">Nobody has said a word.</li> <li class="muted">Nobody has said a word.</li>
{/if} {/if}
{#each lines as line, i (line.at + ':' + i)} {#each lines as line, i (line.at + ':' + i)}
<li class:you={!line.name && line.id === room.me}><em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li> <li class:you={!line.name && line.id === room.me} class:private={!!line.to}>
<em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}<span class="to">, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone</span>{/if}</em>
{line.text}
</li>
{/each} {/each}
</ul> </ul>
{#if room.spectating && room.view.galleryTalk} {#if room.spectating && room.view.galleryTalk}
@@ -110,6 +113,10 @@
color: var(--frost); color: var(--frost);
} }
li.private {
font-style: italic;
}
form { form {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
+1 -1
View File
@@ -132,7 +132,7 @@ export function makeApi<State, Input>() {
/** The gallery's view: no token, no seat, nothing any player could not see. */ /** The gallery's view: no token, no seat, nothing any player could not see. */
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`), watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }), turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }), say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }),
/** A word from the gallery, signed with a name, when the host allows it. */ /** A word from the gallery, signed with a name, when the host allows it. */
sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }), sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<View>('POST', `/api/rooms/${roomId}/gallery`, { token, on }), setGalleryTalk: (roomId: string, token: string, on: boolean) => call<View>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
+3 -2
View File
@@ -112,9 +112,10 @@ export class Room {
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.'); return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
} }
say(text: string): Promise<boolean> { /** Talk to the whole table, or, naming seats, to them alone. */
say(text: string, to?: SeatId[]): Promise<boolean> {
if (this.spectating) return Promise.resolve(false); if (this.spectating) return Promise.resolve(false);
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.'); return this.act(() => api.say(this.roomId, this.token, text, to), 'The table did not hear you.');
} }
/** A word from the gallery, signed; only when the host has let the gallery talk. */ /** A word from the gallery, signed; only when the host has let the gallery talk. */
+6
View File
@@ -9,6 +9,12 @@ export interface ChatLine {
text: string; text: string;
at: number; at: number;
name?: string; name?: string;
/** A private line: the seats it was said to. Absent, the whole table and the gallery heard it. */
to?: SeatId[];
/** The round the line was said in, counted from one; absent before the game begins. */
turn?: number;
/** A bot's memory of the line (an offer made, an answer heard). Kept in the ledger; never sent. */
note?: unknown;
} }
export interface RoomView<State> { export interface RoomView<State> {