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
+6
-1
@@ -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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user