From eca8c929647f9001bdf62852c184fe450ab5bb55 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 29 Sep 2026 12:16:08 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- CONVENTIONS.md | 4 +++ template/server/src/index.ts | 4 +-- template/server/src/rooms.test.ts | 22 +++++++++++--- template/server/src/rooms.ts | 30 ++++++++++++++++---- template/server/src/store.ts | 7 +++-- template/src/lib/components/TableTalk.svelte | 9 +++++- template/src/lib/net/client.ts | 2 +- template/src/lib/net/room.svelte.ts | 5 ++-- template/src/lib/net/view.ts | 6 ++++ 9 files changed, 71 insertions(+), 18 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 77c7ae1..6118e2a 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -30,6 +30,10 @@ README. *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 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 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 diff --git a/template/server/src/index.ts b/template/server/src/index.ts index abf7aa3..e334bfa 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -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 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') { diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index a9ded26..fdd3322 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -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 = game) { +/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */ +function table(spec: GameSpec = 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/); + }); }); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 96e8a12..bb5fe5e 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -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 { 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, 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, 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: 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 { 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; } diff --git a/template/server/src/store.ts b/template/server/src/store.ts index cc57110..16e1b4b 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -20,8 +20,11 @@ export type LedgerLine = | { t: 'turn'; at: number; inputs: Record } /** 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. */ diff --git a/template/src/lib/components/TableTalk.svelte b/template/src/lib/components/TableTalk.svelte index 61fa85e..42cb23e 100644 --- a/template/src/lib/components/TableTalk.svelte +++ b/template/src/lib/components/TableTalk.svelte @@ -47,7 +47,10 @@
  • Nobody has said a word.
  • {/if} {#each lines as line, i (line.at + ':' + i)} -
  • {line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)} {line.text}
  • +
  • + {line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone{/if} + {line.text} +
  • {/each} {#if room.spectating && room.view.galleryTalk} @@ -110,6 +113,10 @@ color: var(--frost); } + li.private { + font-style: italic; + } + form { display: flex; gap: 0.4rem; diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index 0d06bfb..c85cbcf 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -132,7 +132,7 @@ export function makeApi() { /** The gallery's view: no token, no seat, nothing any player could not see. */ watch: (roomId: string) => call('GET', `/api/rooms/${roomId}`), turn: (roomId: string, token: string, input: Input, resolved: number) => call('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }), - say: (roomId: string, token: string, text: string) => call('POST', `/api/rooms/${roomId}/say`, { token, text }), + say: (roomId: string, token: string, text: string, to?: SeatId[]) => call('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }), /** A word from the gallery, signed with a name, when the host allows it. */ sayFromGallery: (roomId: string, name: string, text: string) => call('POST', `/api/rooms/${roomId}/say`, { name, text }), setGalleryTalk: (roomId: string, token: string, on: boolean) => call('POST', `/api/rooms/${roomId}/gallery`, { token, on }), diff --git a/template/src/lib/net/room.svelte.ts b/template/src/lib/net/room.svelte.ts index 446d604..e696cbd 100644 --- a/template/src/lib/net/room.svelte.ts +++ b/template/src/lib/net/room.svelte.ts @@ -112,9 +112,10 @@ export class Room { return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.'); } - say(text: string): Promise { + /** Talk to the whole table, or, naming seats, to them alone. */ + say(text: string, to?: SeatId[]): Promise { 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. */ diff --git a/template/src/lib/net/view.ts b/template/src/lib/net/view.ts index 16c5933..bb23689 100644 --- a/template/src/lib/net/view.ts +++ b/template/src/lib/net/view.ts @@ -9,6 +9,12 @@ export interface ChatLine { text: string; at: number; 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 {