From 7aed0c6ce972a96657f070b1d7e383ce8cc4b65a Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 14:37:41 -0400 Subject: [PATCH] A bot playing the attackers opens the game at once, and a restored table plays any owed bot move Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- server/src/rooms.ts | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/server/src/rooms.ts b/server/src/rooms.ts index b89f9a9..e4f494f 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -43,6 +43,8 @@ export interface Room { rematchOf: string | null; /** The host's choices for this table, every declared option filled. */ options: TableOptions; + /** The over line has been written. */ + over: boolean; state: State | null; /** Inputs received for the round in progress, by seat. */ pending: Record; @@ -113,7 +115,10 @@ export class Rooms { ) { for (const id of store.roomIds()) { const room = this.replay(id, store.read(id)); - if (room) this.rooms.set(id, room); + if (!room) continue; + this.rooms.set(id, room); + // A table that stopped with a bot's move owed picks up where it left off. + if (room.state) this.driveBots(room); } } @@ -131,8 +136,10 @@ export class Rooms { private load(id: string): Room | undefined { if (!/^[A-Z0-9]{4}$/.test(id)) return undefined; const room = this.replay(id, this.store.read(id)); - if (room) this.rooms.set(id, room); - return room ?? undefined; + if (!room) return undefined; + this.rooms.set(id, room); + if (room.state) this.driveBots(room); + return room; } /** Forget rooms nobody has touched or watched for a while; their ledgers stay on disk. */ @@ -181,7 +188,7 @@ export class Rooms { /** A table with nobody seated yet. */ private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room { - return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, state: null, pending: {}, chat: [], seq, updatedAt: createdAt }; + return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, chat: [], seq, updatedAt: createdAt }; } /** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */ @@ -299,6 +306,20 @@ export class Rooms { if (room.state) throw new RoomError('The game has already begun.', 409); 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 }); + // A game whose first move belongs to a bot must not wait for a human who is not owed a move. + this.driveBots(room); + } + + /** Rounds only bots owe (an opening move, an extra turn) resolve at once; a human's waits for them. */ + private driveBots(room: Room): void { + 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); + this.commit(room, { t: 'turn', at: Date.now(), inputs }); + } + 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 host seats a bot in an empty chair. */ @@ -352,18 +373,10 @@ export class Rooms { } private resolve(room: Room): void { - const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id)); const inputs: Record = { ...room.pending }; - for (const seat of bots(room.state!)) 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.commit(room, { t: 'turn', at: Date.now(), inputs }); - // A round only bots owe (an extra turn, say) resolves at once; a human's waits for them. - 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); - this.commit(room, { t: 'turn', at: Date.now(), inputs }); - } - const outcome = room.state && this.game.over(room.state); - if (outcome) this.commit(room, { t: 'over', at: Date.now(), winner: outcome.winner, reason: outcome.reason }); + this.driveBots(room); } /** Table talk, from a seat to everyone at the table and in the gallery. */ @@ -456,7 +469,9 @@ export class Rooms { private apply(room: Room, line: LedgerLine): void { switch (line.t) { case 'room': + break; case 'over': + room.over = true; break; case 'seat': room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });