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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 14:37:41 -04:00
co-authored by Claude Fable 5.1
parent 3aeb1ee99e
commit 7aed0c6ce9
+29 -14
View File
@@ -43,6 +43,8 @@ export interface Room<State> {
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<SeatId, unknown>;
@@ -113,7 +115,10 @@ export class Rooms<State, Input> {
) {
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<State, Input> {
private load(id: string): Room<State> | 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<State, Input> {
/** A table with nobody seated yet. */
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
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<State, Input> {
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<State>): 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<SeatId, unknown> = {};
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<State, Input> {
}
private resolve(room: Room<State>): void {
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
const inputs: Record<SeatId, unknown> = { ...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<SeatId, unknown> = {};
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<State, Input> {
private apply(room: Room<State>, 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 });