From 403232087d1995cb84659671acc0f5c74be48372 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 29 Sep 2026 12:19:38 -0400 Subject: [PATCH] Bots that play on alone do it at a pace to watch, a round every two seconds after the first few, and a seat may hurry them to the end Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- CONVENTIONS.md | 7 ++- template/server/src/index.ts | 5 +++ template/server/src/rooms.test.ts | 40 +++++++++++++++++ template/server/src/rooms.ts | 57 +++++++++++++++++++++++- template/src/lib/components/Board.svelte | 6 +++ template/src/lib/net/client.ts | 1 + template/src/lib/net/room.svelte.ts | 5 +++ template/src/lib/net/view.ts | 2 + 8 files changed, 120 insertions(+), 3 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 9ebe042..0beb5e3 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -118,7 +118,12 @@ README. - Simultaneous by default: every seat that `needsInput` submits, then the round resolves at once, bots included. A seat that owes an extra turn alone (a time stop, a bonus move) is just a state where only it needs - input; the server resolves rounds owed only to bots straight away. + input; the server resolves rounds owed only to bots straight away. A few + such rounds are played at once; past that (a table every person is out + of), the bots play on a round every two seconds so the table can be + watched and the server answers everyone else between rounds. The view + says so (`playingOn`), and a seat may ask for the rest at once + (`room.hurry()`). - Hidden information is the engine's business: `view(state, viewer)` returns what one seat may see, and the SPECTATOR view is the intersection of everyone's. The server sends nothing else. diff --git a/template/server/src/index.ts b/template/server/src/index.ts index 7c21143..e2c4a4b 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -9,6 +9,7 @@ // POST /api/rooms/:id/begin {token} the host begins with the players seated so far // POST /api/rooms/:id/rank {token, ranking} a seat ranks the table's choices, best first, where the table asks for it // POST /api/rooms/:id/turn {token, input} this seat's move +// POST /api/rooms/:id/hurry {token} a table the bots are playing on plays the rest at once // 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 @@ -235,6 +236,10 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise rooms.rank(room, token, body.ranking); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); } + if (action === 'hurry') { + rooms.hurry(room, token); + return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); + } if (action === 'turn') { rooms.submit(room, token, body.input, body.resolved); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index fb03b13..68a51da 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -164,4 +164,44 @@ describe('rooms', () => { rooms.begin(host.room, host.token); expect(() => rooms.say(host.room, host.token, 'Psst.', ['B'])).toThrow(/read no private talk/); }); + + describe('a table the bots play on alone', () => { + /** A count to ten that only the bots play: the host is never owed a move. */ + type Count = { n: number; over: { winner: null; reason: string } | null }; + const count: GameSpec = { + seatIds: ['A', 'B'], + minSeats: 2, + botNames: ['Aldric'], + currentRules: 1, + create: () => ({ n: 0, over: null }), + resolve: (s) => ({ n: s.n + 1, over: s.n + 1 >= 10 ? { winner: null, reason: 'Ten.' } : null }), + needsInput: (s, seat) => seat === 'B' && !s.over, + over: (s) => s.over, + turn: (s) => s.n + 1, + view: (s) => s, + botInput: () => null, + cleanInput: () => null, + nameOf: (_s, seat) => seat + }; + + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('plays a few rounds at once, then one at a pace to watch, and the rest at once when hurried', () => { + const rooms = new Rooms(count, new Store(dir)); + const host = rooms.create('Ann', 2); + rooms.addBot(host.room, host.token); + rooms.begin(host.room, host.token); + const room = host.room; + expect(room.state!.n).toBe(4); + expect(rooms.view(room, 'A').playingOn).toBe(true); + vi.advanceTimersByTime(2000); + expect(room.state!.n).toBe(5); + rooms.hurry(room, host.token); + vi.runAllTimers(); + expect(room.state!.n).toBe(10); + expect(room.over).toBe(true); + expect(rooms.view(room, 'A').playingOn).toBe(false); + }); + }); }); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 806f6f5..27b3b24 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -13,6 +13,12 @@ import type { LedgerLine, Store } from './store'; const CHAT_MAX_LENGTH = 300; /** 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; +/** Bots' rounds played in one go, when nobody else owes a move, before the rest are paced. */ +const PLAYED_AT_ONCE = 4; +/** Between paced bots' rounds: long enough to watch the table move. */ +const PACE_MS = 2000; +/** Bots' rounds played between answers to everyone else, once hurried. */ +const HURRIED_STEP = 3; export { SPECTATOR }; export interface Seat { @@ -117,6 +123,10 @@ export class Rooms { private transfers = new Map(); private failedClaims = 0; private failWindowStart = 0; + /** Tables whose bots are playing on, paced: the next round's timer. */ + private pacing = new Map>(); + /** Tables asked to play the rest at once. */ + private hurried = new Set(); constructor( private game: GameSpec, @@ -315,18 +325,60 @@ export class Rooms { this.botsSpeak(room, 'phase'); } - /** Rounds only bots owe (an opening move, an extra turn) resolve at once; a human's waits for them. */ - private driveBots(room: Room): void { + /** + * Rounds only bots owe (an opening move, an extra turn, a table the + * people are out of) resolve without waiting; a human's waits for them. + * A few are played at once; past that, the bots play on one round at a + * time, so the table can be watched and the server answers everyone else + * between rounds. Hurried, they play in larger steps. + */ + private driveBots(room: Room, burst = PLAYED_AT_ONCE): void { + if (this.pacing.has(room.id)) return; const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id)); + let played = 0; while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) { + if (played === burst) return this.playOnLater(room); const inputs: Record = {}; 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 }); + played += 1; } + this.hurried.delete(room.id); const outcome = room.state && this.game.over(room.state); if (outcome && !room.over) this.commit(room, { t: 'over', at: Date.now(), winner: outcome.winner, reason: outcome.reason }); } + /** The bots' next round, after a pause to watch it by, or at once when hurried. */ + private playOnLater(room: Room): void { + const hurried = this.hurried.has(room.id); + const next = setTimeout(() => { + this.pacing.delete(room.id); + // A room forgotten and loaded again drives itself; this one is no longer the table. + if (this.rooms.get(room.id) !== room) return; + this.driveBots(room, hurried ? HURRIED_STEP : 1); + // Played out: the bots have their say once, as after any round, which ends a game with parting words. + if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase'); + }, hurried ? 0 : PACE_MS); + next.unref(); + this.pacing.set(room.id, next); + } + + /** A seat at a table the bots are playing on asks for the rest at once. */ + hurry(room: Room, token: string | undefined): void { + this.seatOf(room, token); + this.hurried.add(room.id); + const waiting = this.pacing.get(room.id); + if (!waiting) return; + clearTimeout(waiting); + this.pacing.delete(room.id); + this.playOnLater(room); + } + + /** Whether the bots are playing on by themselves at this table, one paced round at a time. */ + playingOn(room: Room): boolean { + return this.pacing.has(room.id); + } + /** The host seats a bot in an empty chair. */ addBot(room: Room, token: string | undefined): Seat { const host = this.seatOf(room, token); @@ -494,6 +546,7 @@ export class Rooms { // 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) : [], + playingOn: this.playingOn(room), 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 561031a..b3b4034 100644 --- a/template/src/lib/components/Board.svelte +++ b/template/src/lib/components/Board.svelte @@ -54,6 +54,12 @@ {#each room.view.parting as last, i (i)}

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

{/each} Back to the hall + {:else if room.view.playingOn} +
+

The bots play on

+

Nobody else owes a move, so the bots play a round every few seconds for you to watch.

+ {#if !room.spectating}{/if} +
{:else if room.spectating}

You watch from the Peanut Gallery

diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index 7354692..603a54d 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -132,6 +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 }), + hurry: (roomId: string, token: string) => call('POST', `/api/rooms/${roomId}/hurry`, { token }), rank: (roomId: string, token: string, ranking: string[]) => call('POST', `/api/rooms/${roomId}/rank`, { token, ranking }), 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. */ diff --git a/template/src/lib/net/room.svelte.ts b/template/src/lib/net/room.svelte.ts index f1dc7a4..51e5677 100644 --- a/template/src/lib/net/room.svelte.ts +++ b/template/src/lib/net/room.svelte.ts @@ -112,6 +112,11 @@ export class Room { return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.'); } + /** Out of the game, with the bots playing on: play the rest at once. */ + hurry(): Promise { + return this.act(() => api.hurry(this.roomId, this.token), 'The table did not hear you.'); + } + /** This seat's ranking of the table's choices, best first, before the game begins. */ rank(ranking: string[]): Promise { return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.'); diff --git a/template/src/lib/net/view.ts b/template/src/lib/net/view.ts index 1f86b68..52a74e6 100644 --- a/template/src/lib/net/view.ts +++ b/template/src/lib/net/view.ts @@ -46,6 +46,8 @@ export interface RoomView { 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 }[]; + /** The bots are playing on by themselves, a round at a time, with no person owing a move. */ + playingOn: boolean; /** How many watch from the Peanut Gallery. */ audience: number; /** The host lets the gallery talk. */