diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 23b9588..9ebe042 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -145,6 +145,13 @@ if it were never there. and how many have ranked, nothing more; the start line records every ranking and `create` receives them. A solo table asking for one waits in the lobby for the player's. +- `botPress(state, seat, press, people, trigger)`: bots that talk. A bot + speaks when a round opens and when a person writes to it (or says a first + word to the table); its lines go privately to the people it names, as + ledger lines, with a `note` for its own memory that no view ever carries. + `botInput` receives the same press, so a bot can keep what was agreed. + `parting(press, viewer)` gives the bots' last words once the game is + over. Without `botPress`, private talk to a bot is refused. - `tally(state)`: the game's own lines for the tally, from a finished game. - `preferences`, `options`: the preferences panel's settings and the host's table choices (above). diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index 6d7afbe..fb03b13 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -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 = { + ...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/); + }); }); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 6ae2a16..806f6f5 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -312,6 +312,7 @@ export class Rooms { 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 { 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 = {}; - 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 { private resolve(room: Room): void { const inputs: Record = { ...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, 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, 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 { ? [...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 { 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, diff --git a/template/src/lib/components/Board.svelte b/template/src/lib/components/Board.svelte index a4a6681..561031a 100644 --- a/template/src/lib/components/Board.svelte +++ b/template/src/lib/components/Board.svelte @@ -51,6 +51,7 @@

{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}

{g.over.reason}

+ {#each room.view.parting as last, i (i)}

{room.nameOf(last.id)} {last.text}

{/each} Back to the hall
{:else if room.spectating} diff --git a/template/src/lib/game/spec.ts b/template/src/lib/game/spec.ts index a6f64b1..0938f7c 100644 --- a/template/src/lib/game/spec.ts +++ b/template/src/lib/game/spec.ts @@ -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 { 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. */ diff --git a/template/src/lib/net/view.ts b/template/src/lib/net/view.ts index 6ca0290..1f86b68 100644 --- a/template/src/lib/net/view.ts +++ b/template/src/lib/net/view.ts @@ -44,6 +44,8 @@ export interface RoomView { /** 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. */