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:
co-authored by
Claude Opus 5.5
parent
99a0b23f59
commit
403232087d
@@ -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<void>
|
||||
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));
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<State, Input> {
|
||||
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
|
||||
private failedClaims = 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(
|
||||
private game: GameSpec<State, Input>,
|
||||
@@ -315,18 +325,60 @@ export class Rooms<State, Input> {
|
||||
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));
|
||||
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<SeatId, unknown> = {};
|
||||
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<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. */
|
||||
addBot(room: Room<State>, token: string | undefined): Seat {
|
||||
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.
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user