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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-29 12:19:38 -04:00
co-authored by Claude Opus 5.5
parent 99a0b23f59
commit 403232087d
8 changed files with 120 additions and 3 deletions
+6 -1
View File
@@ -118,7 +118,12 @@ README.
- Simultaneous by default: every seat that `needsInput` submits, then the - Simultaneous by default: every seat that `needsInput` submits, then the
round resolves at once, bots included. A seat that owes an extra turn 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 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)` - Hidden information is the engine's business: `view(state, viewer)`
returns what one seat may see, and the SPECTATOR view is the returns what one seat may see, and the SPECTATOR view is the
intersection of everyone's. The server sends nothing else. intersection of everyone's. The server sends nothing else.
+5
View File
@@ -9,6 +9,7 @@
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far // 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/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/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`; // 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 // {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 // 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<void>
rooms.rank(room, token, body.ranking); rooms.rank(room, token, body.ranking);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); 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') { if (action === 'turn') {
rooms.submit(room, token, body.input, body.resolved); rooms.submit(room, token, body.input, body.resolved);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
+40
View File
@@ -164,4 +164,44 @@ describe('rooms', () => {
rooms.begin(host.room, host.token); rooms.begin(host.room, host.token);
expect(() => rooms.say(host.room, host.token, 'Psst.', ['B'])).toThrow(/read no private talk/); 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<Count, null> = {
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);
});
});
}); });
+55 -2
View File
@@ -13,6 +13,12 @@ import type { LedgerLine, Store } from './store';
const CHAT_MAX_LENGTH = 300; 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. */ /** 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; 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 { SPECTATOR };
export interface Seat { export interface Seat {
@@ -117,6 +123,10 @@ export class Rooms<State, Input> {
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>(); private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
private failedClaims = 0; private failedClaims = 0;
private failWindowStart = 0; private failWindowStart = 0;
/** Tables whose bots are playing on, paced: the next round's timer. */
private pacing = new Map<string, ReturnType<typeof setTimeout>>();
/** Tables asked to play the rest at once. */
private hurried = new Set<string>();
constructor( constructor(
private game: GameSpec<State, Input>, private game: GameSpec<State, Input>,
@@ -315,18 +325,60 @@ export class Rooms<State, Input> {
this.botsSpeak(room, 'phase'); 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<State>): 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<State>, 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)); 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) { 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<SeatId, unknown> = {}; const inputs: Record<SeatId, unknown> = {};
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id, this.heardBy(room, 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 });
played += 1;
} }
this.hurried.delete(room.id);
const outcome = room.state && this.game.over(room.state); 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 }); 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<State>): 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<State>, 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<State>): boolean {
return this.pacing.has(room.id);
}
/** The host seats a bot in an empty chair. */ /** The host seats a bot in an empty chair. */
addBot(room: Room<State>, token: string | undefined): Seat { addBot(room: Room<State>, token: string | undefined): Seat {
const host = this.seatOf(room, token); const host = this.seatOf(room, token);
@@ -494,6 +546,7 @@ export class Rooms<State, Input> {
// Private lines only to their sender and the seats they name; a note is a bot'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: this.heardBy(room, 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) : [], 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, audience: this.gallery.get(room.id) ?? 0,
galleryTalk: room.galleryTalk, galleryTalk: room.galleryTalk,
challenge: room.challenge, challenge: room.challenge,
+6
View File
@@ -54,6 +54,12 @@
{#each room.view.parting as last, i (i)}<p><em>{room.nameOf(last.id)}</em> {last.text}</p>{/each} {#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.view.playingOn}
<section class="move">
<h2>The bots play on</h2>
<p class="muted">Nobody else owes a move, so the bots play a round every few seconds for you to watch.</p>
{#if !room.spectating}<button type="button" class="quiet" onclick={() => room.hurry()}>Play the rest at once</button>{/if}
</section>
{:else if room.spectating} {:else if room.spectating}
<section class="move"> <section class="move">
<h2>You watch from the Peanut Gallery</h2> <h2>You watch from the Peanut Gallery</h2>
+1
View File
@@ -132,6 +132,7 @@ export function makeApi<State, Input>() {
/** The gallery's view: no token, no seat, nothing any player could not see. */ /** The gallery's view: no token, no seat, nothing any player could not see. */
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`), watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }), turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
hurry: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/hurry`, { token }),
rank: (roomId: string, token: string, ranking: string[]) => call<View>('POST', `/api/rooms/${roomId}/rank`, { token, ranking }), rank: (roomId: string, token: string, ranking: string[]) => call<View>('POST', `/api/rooms/${roomId}/rank`, { token, ranking }),
say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }), say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }),
/** A word from the gallery, signed with a name, when the host allows it. */ /** A word from the gallery, signed with a name, when the host allows it. */
+5
View File
@@ -112,6 +112,11 @@ export class Room {
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.'); 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<boolean> {
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. */ /** This seat's ranking of the table's choices, best first, before the game begins. */
rank(ranking: string[]): Promise<boolean> { rank(ranking: string[]): Promise<boolean> {
return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.'); return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.');
+2
View File
@@ -46,6 +46,8 @@ export interface RoomView<State> {
chat: ChatLine[]; chat: ChatLine[];
/** The bots' last words to this seat once the game is over, for a game that gives them some. */ /** The bots' last words to this seat once the game is over, for a game that gives them some. */
parting: { id: SeatId; text: string }[]; 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. */ /** How many watch from the Peanut Gallery. */
audience: number; audience: number;
/** The host lets the gallery talk. */ /** The host lets the gallery talk. */