From 927e49cf799cd8a6316642ae48ba34ca821f2d77 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 29 Sep 2026 12:14:33 -0400 Subject: [PATCH] A game may take drafts: an input it does not call settled can be replaced, and the round waits for settled ones Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- CONVENTIONS.md | 14 ++++++++++++++ template/server/src/rooms.test.ts | 20 ++++++++++++++++++++ template/server/src/rooms.ts | 10 ++++++++-- template/src/lib/game/spec.ts | 6 ++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 2505052..682faa3 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -123,6 +123,20 @@ README. round resolves; keep the old path behind `state.rules < N` and pin both with tests. A fix that diverges from no ledger ships without a bump. +## Optional hooks + +A game implements only the hooks it needs; without one, the kit behaves as +if it were never there. + +- `validate(state, seat, input)`: a reason to refuse a move, shown to the player. +- `seatsFor(options)`: fewer seats for some table options. +- `settled(input)`: whether an input is final. A game with drafts answers + false until the player says it is done; a draft may be replaced, and the + round waits for every seat's settled input. +- `tally(state)`: the game's own lines for the tally, from a finished game. +- `preferences`, `options`: the preferences panel's settings and the + host's table choices (above). + ## The server One Node process per game, on its own port, run from source with `tsx` as diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index e64b858..ec6f63c 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -43,4 +43,24 @@ describe('rooms', () => { expect(reopened.get(room.id).state).not.toBeNull(); errors.mockRestore(); }); + + it('holds a draft until it is settled, and lets it be replaced until then', () => { + type Draft = Input & { final?: boolean }; + const drafts: GameSpec = { + ...game, + cleanInput: (raw) => ({ ...game.cleanInput(raw), final: (raw as Draft)?.final === true }) as Draft, + settled: (input) => (input as Draft).final === true + }; + const { rooms, room, ann, bo } = table(drafts); + rooms.submit(room, ann, { pick: 5 }, 0); + rooms.submit(room, bo, { pick: 2, final: true }, 0); + expect(rooms.waitingOn(room)).toEqual(['A']); + rooms.submit(room, ann, { pick: 4 }, 0); + expect(room.resolved).toBe(0); + rooms.submit(room, ann, { pick: 3, final: true }, 0); + expect(room.resolved).toBe(1); + expect(room.state!.rounds[0].picks).toEqual({ A: 3, B: 2 }); + rooms.submit(room, bo, { pick: 1, final: true }, 1); + expect(() => rooms.submit(room, bo, { pick: 2 }, 1)).toThrow(/not your move/); + }); }); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index aa9d719..7aad504 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -360,6 +360,7 @@ export class Rooms { /** * Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. + * A draft (an input the game does not call settled) may be replaced until it is settled. * `resolved` is how many rounds the page had seen played when the move was made: a move that * arrives after its round was played (a slow connection, a second click, a stale tab) is refused * rather than taken as the move for the next round. @@ -386,6 +387,11 @@ export class Rooms { this.driveBots(room); } + /** Whether an input is final; a game without drafts makes every input final. */ + private settled(input: unknown): boolean { + return this.game.settled ? this.game.settled(input as Input) : true; + } + /** Table talk, from a seat to everyone at the table and in the gallery. */ say(room: Room, token: string | undefined, raw: unknown): void { const seat = this.seatOf(room, token); @@ -402,11 +408,11 @@ export class Rooms { return text; } - /** Seats whose input the next resolution needs and has not yet received. */ + /** Seats whose input the next resolution needs and has not yet received settled; a draft still waits. */ waitingOn(room: Room): SeatId[] { if (!room.state || this.game.over(room.state)) return []; return room.seats - .filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending)) + .filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending && this.settled(room.pending[s.id]))) .map((s) => s.id); } diff --git a/template/src/lib/game/spec.ts b/template/src/lib/game/spec.ts index 1e2e0c3..7da0acb 100644 --- a/template/src/lib/game/spec.ts +++ b/template/src/lib/game/spec.ts @@ -70,6 +70,12 @@ export interface GameSpec { resolve(state: State, inputs: Record): State; /** Whether this seat's input is needed before the next resolution. */ needsInput(state: State, seat: SeatId): boolean; + /** + * Whether a submitted input is final. A game that lets a player save a + * draft and change it answers false until the player says it is done; + * the round waits for every seat's settled input. Absent, every input is final. + */ + settled?(input: Input): boolean; over(state: State): Outcome | null; /** The round being written, counted from one, for the ledger and the report pin. */ turn(state: State): number;