From 62cb00be33c93c418a00b2eafa948e7350f27364 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 26 Oct 2026 17:32:23 -0400 Subject: [PATCH] The template's bots move once the player's request is answered: a slow bot no longer holds up the reply to the move it is answering Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx --- template/server/src/rooms.test.ts | 21 ++++++++++++++++----- template/server/src/rooms.ts | 27 +++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index f460bf6..9b0a318 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -16,6 +16,9 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); +/** The bots move once the request that made their move due has been answered: after this tick. */ +const settled = () => new Promise((r) => setImmediate(r)); + /** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */ function table(spec: GameSpec = game, withCy = false) { const rooms = new Rooms(spec, new Store(dir)); @@ -161,7 +164,7 @@ describe('rooms', () => { 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', () => { + it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', async () => { const heard: string[][] = []; const talking: GameSpec = { ...game, @@ -179,6 +182,7 @@ describe('rooms', () => { const cy = rooms.join(host.room, 'Cy'); const room = host.room; rooms.begin(room, host.token); + await settled(); rooms.say(room, host.token, 'Psst.', ['B']); rooms.say(room, cy.token, 'Hello, all.'); const annSees = rooms.view(room, 'A').chat; @@ -188,6 +192,7 @@ describe('rooms', () => { 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); + await settled(); 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([]); @@ -229,6 +234,7 @@ describe('rooms', () => { rooms.addBot(host.room, host.token); rooms.begin(host.room, host.token); const room = host.room; + vi.runOnlyPendingTimers(); expect(room.state!.n).toBe(4); expect(rooms.view(room, 'A').playingOn).toBe(true); vi.advanceTimersByTime(2000); @@ -243,16 +249,18 @@ describe('rooms', () => { }); describe('the host and the other seats', () => { - it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", () => { + it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", async () => { const { rooms, room, ann, bo } = table(); rooms.submit(room, ann, { pick: 5 }, 0); rooms.standIn(room, ann, 'B'); - // The round waited on Bo alone; the bot played it. + // The round waited on Bo alone; the bot plays it as soon as the host has been answered. + await settled(); expect(room.resolved).toBe(1); expect(rooms.waitingOn(room)).toEqual(['A']); expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/); expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true }); rooms.submit(room, ann, { pick: 3 }, 1); + await settled(); expect(room.resolved).toBe(2); }); @@ -305,12 +313,15 @@ describe('the host and the other seats', () => { expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/); }); - it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', () => { + it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', async () => { const { rooms, room, ann } = table(); const { phrase } = rooms.hostPhrase(room, ann, 'B'); rooms.standIn(room, ann, 'B'); expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/); - while (!game.over(room.state!)) rooms.submit(room, ann, { pick: 5 }, room.resolved); + while (!game.over(room.state!)) { + rooms.submit(room, ann, { pick: 5 }, room.resolved); + await settled(); + } const next = rooms.get(rooms.rematch(room, ann).roomId); expect(next.expected).toEqual(['Bo']); expect(next.seats.map((s) => s.name)).toEqual(['Ann']); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 56e0d9f..ae7affc 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -351,8 +351,28 @@ export class Rooms { if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409); 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'); + this.botsAfterAnswering(room); + } + + /** + * The bots' moves, once the request that made them due has been answered. + * A bot that thinks for a second or two would otherwise hold up the reply + * to the person whose move it is answering, so their own move would seem + * to hang and the bot's to come at once. The table is told as each move + * lands, the same as any other. + */ + private botsAfterAnswering(room: Room): void { + setImmediate(() => { + // A room forgotten and loaded again drives itself; this one is no longer the table. + if (this.rooms.get(room.id) !== room) return; + try { + this.driveBots(room); + this.botsSpeak(room, 'phase'); + } catch (e) { + // Nothing is waiting on this callback: an error here would take the process down, so it is logged and the table left as it stands. + console.error(`${room.id}: the bots could not move: ${e instanceof Error ? e.stack ?? e.message : String(e)}`); + } + }); } /** @@ -538,8 +558,7 @@ export class Rooms { } } this.commit(room, { t: 'turn', at: Date.now(), inputs }); - this.driveBots(room); - this.botsSpeak(room, 'phase'); + this.botsAfterAnswering(room); } /**