A game may give its bots words: they speak as a round opens and when spoken to, remember by notes nobody sees, and read the talk when they move

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:18:46 -04:00
co-authored by Claude Opus 5.5
parent 8c99126aee
commit 99a0b23f59
6 changed files with 126 additions and 5 deletions
+40
View File
@@ -124,4 +124,44 @@ describe('rooms', () => {
const host = rooms.create('Ann', 2);
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
});
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => {
const heard: string[][] = [];
const talking: GameSpec<State, Input> = {
...game,
botInput: (state, seat, press) => {
heard.push((press ?? []).map((l) => l.text));
return game.botInput(state, seat);
},
botPress: (state, seat, press, people, trigger) =>
trigger === 'phase' ? [{ to: people, text: `Round ${game.turn(state)}.`, note: 'secret' }] : [{ to: [press[press.length - 1].id], text: 'I hear you.' }],
parting: (_press, viewer) => [{ id: 'B', text: `Farewell, ${viewer}.` }]
};
const rooms = new Rooms(talking, new Store(dir));
const host = rooms.create('Ann', 3);
rooms.addBot(host.room, host.token);
const cy = rooms.join(host.room, 'Cy');
const room = host.room;
rooms.begin(room, host.token);
rooms.say(room, host.token, 'Psst.', ['B']);
rooms.say(room, cy.token, 'Hello, all.');
const annSees = rooms.view(room, 'A').chat;
expect(annSees.map((l) => l.text)).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.']);
expect(annSees.some((l) => 'note' in l)).toBe(false);
expect(rooms.view(room, 'C').chat.map((l) => l.text)).toEqual(['Round 1.', 'Hello, all.', 'I hear you.']);
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
rooms.submit(room, host.token, { pick: 1 }, 0);
rooms.submit(room, cy.token, { pick: 2 }, 0);
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
expect(rooms.view(room, 'A').parting).toEqual([]);
});
it('refuses private talk to a bot when the game gives bots no words', () => {
const rooms = new Rooms(game, new Store(dir));
const host = rooms.create('Ann', 2);
rooms.addBot(host.room, host.token);
rooms.begin(host.room, host.token);
expect(() => rooms.say(host.room, host.token, 'Psst.', ['B'])).toThrow(/read no private talk/);
});
});
+36 -4
View File
@@ -312,6 +312,7 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
this.driveBots(room);
this.botsSpeak(room, 'phase');
}
/** Rounds only bots owe (an opening move, an extra turn) resolve at once; a human's waits for them. */
@@ -319,7 +320,7 @@ export class Rooms<State, Input> {
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
const inputs: Record<SeatId, unknown> = {};
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id);
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id, this.heardBy(room, seat.id));
this.commit(room, { t: 'turn', at: Date.now(), inputs });
}
const outcome = room.state && this.game.over(room.state);
@@ -399,9 +400,33 @@ export class Rooms<State, Input> {
private resolve(room: Room<State>): void {
const inputs: Record<SeatId, unknown> = { ...room.pending };
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id, this.heardBy(room, seat.id));
this.commit(room, { t: 'turn', at: Date.now(), inputs });
this.driveBots(room);
this.botsSpeak(room, 'phase');
}
/**
* Bots' talk, for a game that gives them words: when a round opens, and to
* a person who has just written to them. Their lines go on the ledger like
* anyone's, so a replay repeats them rather than asking again.
*/
private botsSpeak(room: Room<State>, trigger: 'phase' | 'spoken', only?: SeatId[]): void {
if (!this.game.botPress || !room.state) return;
const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
for (const seat of room.seats) {
if (!seat.bot || (only && !only.includes(seat.id))) continue;
for (const line of this.game.botPress(room.state, seat.id, this.heardBy(room, seat.id), people, trigger)) {
const to = line.to.filter((id) => people.includes(id));
if (!to.length || !line.text.trim()) continue;
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(line.text), to, ...(line.note !== undefined ? { note: line.note } : {}) });
}
}
}
/** The talk a seat has heard: the table's, and private lines to it or from it. */
private heardBy(room: Room<State>, seatId: SeatId): ChatLine[] {
return room.chat.filter((line) => !line.to || line.id === seatId || line.to.includes(seatId));
}
/** Whether an input is final; a game without drafts makes every input final. */
@@ -420,7 +445,13 @@ export class Rooms<State, Input> {
? [...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.');
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id)?.bot);
if (botsTo.length && !this.game.botPress) throw new RoomError('The bots at this table read no private talk.');
// A person's first word of the game, said to everyone, is heard by the bots too: the game chooses who answers.
const first = !to.length && !room.chat.some((l) => l.id === seat.id);
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) });
if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo);
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot).map((s) => s.id));
}
private cleanTalk(raw: unknown): string {
@@ -460,8 +491,9 @@ export class Rooms<State, Input> {
ranked: Object.keys(room.rankings).length,
yourInput: seated && seatId in room.pending ? room.pending[seatId] : null,
yourLastInput: seated && seatId in room.played ? room.played[seatId] : null,
// 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),
// Private lines only to their sender and the seats they name; a note is a bot's own memory, never sent.
chat: this.heardBy(room, seatId).map(({ note: _, ...line }) => line),
parting: room.state && seated && this.game.parting && this.game.over(room.state) ? this.game.parting(room.chat, seatId) : [],
audience: this.gallery.get(room.id) ?? 0,
galleryTalk: room.galleryTalk,
challenge: room.challenge,
+1
View File
@@ -51,6 +51,7 @@
<div class="verdict">
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
<p>{g.over.reason}</p>
{#each room.view.parting as last, i (i)}<p><em>{room.nameOf(last.id)}</em> {last.text}</p>{/each}
<a class="commit" href="/">Back to the hall</a>
</div>
{:else if room.spectating}
+40 -1
View File
@@ -13,6 +13,29 @@
/** A seat at the table: a single letter from GameSpec.seatIds. */
export type SeatId = string;
/**
* A line of table talk as a game reads it: who, what, when, the round it was
* said in, to whom (absent: everyone), and a bot's note on its own line.
*/
export interface PressLine {
id: SeatId;
text: string;
at: number;
/** The seats a private line was said to. */
to?: SeatId[];
/** The round the line was said in, from one; absent before the game begins. */
turn?: number;
/** What a bot noted about its own line; kept in the ledger, never shown. */
note?: unknown;
}
/** A bot's line of talk: to the people it names, with a note for its own memory. */
export interface BotLine {
to: SeatId[];
text: string;
note?: unknown;
}
/** The viewer with no seat: the Peanut Gallery. Views built for it show only what every seat could see. */
export const SPECTATOR: SeatId = '';
@@ -91,7 +114,23 @@ export interface GameSpec<State, Input> {
turn(state: State): number;
/** What one viewer may see. The server sends nothing else. */
view(state: State, viewer: SeatId): State;
botInput(state: State, seat: SeatId): Input;
/**
* A bot's move. `press` is the talk its seat has heard (the table's, and
* private lines to it or from it, notes included), for a game whose bots
* keep what was agreed; a function of the state and the press, which the
* turn line records, so a replay never asks again.
*/
botInput(state: State, seat: SeatId, press?: PressLine[]): Input;
/**
* What a bot says: when a round opens ('phase'), and when a person has
* just written to it, or said a first word to the table ('spoken').
* `press` is what its seat has heard; `people` the seats played by people.
* Each line goes on the ledger, to the people it names and no one else.
* Absent, bots say nothing and a private line to one is refused.
*/
botPress?(state: State, seat: SeatId, press: PressLine[], people: SeatId[], trigger: 'phase' | 'spoken'): BotLine[];
/** The bots' last words to `viewer` once the game is over, read from the whole of the talk. */
parting?(press: PressLine[], viewer: SeatId): { id: SeatId; text: string }[];
/** Only the shapes the engine understands get through; the engine validates the rest. */
cleanInput(raw: unknown): Input;
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
+2
View File
@@ -44,6 +44,8 @@ export interface RoomView<State> {
/** This seat's input in the last round it played, for telling it what became of its own choice; null for none, and for the gallery. */
yourLastInput: unknown;
chat: ChatLine[];
/** The bots' last words to this seat once the game is over, for a game that gives them some. */
parting: { id: SeatId; text: string }[];
/** How many watch from the Peanut Gallery. */
audience: number;
/** The host lets the gallery talk. */