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:
co-authored by
Claude Opus 5.5
parent
8e35d3a3a6
commit
eca8c92964
@@ -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') {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
<li class="muted">Nobody has said a word.</li>
|
||||
{/if}
|
||||
{#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}
|
||||
</ul>
|
||||
{#if room.spectating && room.view.galleryTalk}
|
||||
@@ -110,6 +113,10 @@
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
li.private {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -132,7 +132,7 @@ export function makeApi<State, Input>() {
|
||||
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
||||
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 }),
|
||||
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. */
|
||||
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 }),
|
||||
|
||||
@@ -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<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);
|
||||
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. */
|
||||
|
||||
@@ -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<State> {
|
||||
|
||||
Reference in New Issue
Block a user