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:
co-authored by
Claude Opus 5.5
parent
8c99126aee
commit
99a0b23f59
@@ -145,6 +145,13 @@ if it were never there.
|
|||||||
and how many have ranked, nothing more; the start line records every
|
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
|
ranking and `create` receives them. A solo table asking for one waits in
|
||||||
the lobby for the player's.
|
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.
|
- `tally(state)`: the game's own lines for the tally, from a finished game.
|
||||||
- `preferences`, `options`: the preferences panel's settings and the
|
- `preferences`, `options`: the preferences panel's settings and the
|
||||||
host's table choices (above).
|
host's table choices (above).
|
||||||
|
|||||||
@@ -124,4 +124,44 @@ describe('rooms', () => {
|
|||||||
const host = rooms.create('Ann', 2);
|
const host = rooms.create('Ann', 2);
|
||||||
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } } : {}) });
|
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.
|
// 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.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. */
|
/** 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));
|
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) {
|
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
|
||||||
const inputs: Record<SeatId, unknown> = {};
|
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 });
|
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||||
}
|
}
|
||||||
const outcome = room.state && this.game.over(room.state);
|
const outcome = room.state && this.game.over(room.state);
|
||||||
@@ -399,9 +400,33 @@ export class Rooms<State, Input> {
|
|||||||
|
|
||||||
private resolve(room: Room<State>): void {
|
private resolve(room: Room<State>): void {
|
||||||
const inputs: Record<SeatId, unknown> = { ...room.pending };
|
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.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||||
this.driveBots(room);
|
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. */
|
/** 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)))]
|
? [...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.');
|
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 } : {}) });
|
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 {
|
private cleanTalk(raw: unknown): string {
|
||||||
@@ -460,8 +491,9 @@ export class Rooms<State, Input> {
|
|||||||
ranked: Object.keys(room.rankings).length,
|
ranked: Object.keys(room.rankings).length,
|
||||||
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,
|
||||||
// Private lines only to their sender and the seats they name; a note is the sender's own memory, never sent.
|
// Private lines only to their sender and the seats they name; a note is a bot's own memory, never sent.
|
||||||
chat: room.chat.filter((line) => !line.to || line.id === seatId || (seated && line.to.includes(seatId))).map(({ note: _, ...line }) => line),
|
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,
|
audience: this.gallery.get(room.id) ?? 0,
|
||||||
galleryTalk: room.galleryTalk,
|
galleryTalk: room.galleryTalk,
|
||||||
challenge: room.challenge,
|
challenge: room.challenge,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<div class="verdict">
|
<div class="verdict">
|
||||||
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
|
<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>
|
<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>
|
<a class="commit" href="/">Back to the hall</a>
|
||||||
</div>
|
</div>
|
||||||
{:else if room.spectating}
|
{:else if room.spectating}
|
||||||
|
|||||||
@@ -13,6 +13,29 @@
|
|||||||
/** A seat at the table: a single letter from GameSpec.seatIds. */
|
/** A seat at the table: a single letter from GameSpec.seatIds. */
|
||||||
export type SeatId = string;
|
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. */
|
/** The viewer with no seat: the Peanut Gallery. Views built for it show only what every seat could see. */
|
||||||
export const SPECTATOR: SeatId = '';
|
export const SPECTATOR: SeatId = '';
|
||||||
|
|
||||||
@@ -91,7 +114,23 @@ export interface GameSpec<State, Input> {
|
|||||||
turn(state: State): number;
|
turn(state: State): number;
|
||||||
/** What one viewer may see. The server sends nothing else. */
|
/** What one viewer may see. The server sends nothing else. */
|
||||||
view(state: State, viewer: SeatId): State;
|
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. */
|
/** Only the shapes the engine understands get through; the engine validates the rest. */
|
||||||
cleanInput(raw: unknown): Input;
|
cleanInput(raw: unknown): Input;
|
||||||
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
|
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
|
||||||
|
|||||||
@@ -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. */
|
/** 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;
|
yourLastInput: unknown;
|
||||||
chat: ChatLine[];
|
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. */
|
/** How many watch from the Peanut Gallery. */
|
||||||
audience: number;
|
audience: number;
|
||||||
/** The host lets the gallery talk. */
|
/** The host lets the gallery talk. */
|
||||||
|
|||||||
Reference in New Issue
Block a user