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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-29 12:14:33 -04:00
co-authored by Claude Opus 5.5
parent 6133a2469a
commit 927e49cf79
4 changed files with 48 additions and 2 deletions
+14
View File
@@ -123,6 +123,20 @@ README.
round resolves; keep the old path behind `state.rules < N` and pin both 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. 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 ## The server
One Node process per game, on its own port, run from source with `tsx` as One Node process per game, on its own port, run from source with `tsx` as
+20
View File
@@ -43,4 +43,24 @@ describe('rooms', () => {
expect(reopened.get(room.id).state).not.toBeNull(); expect(reopened.get(room.id).state).not.toBeNull();
errors.mockRestore(); 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<State, Input> = {
...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/);
});
}); });
+8 -2
View File
@@ -360,6 +360,7 @@ export class Rooms<State, Input> {
/** /**
* Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. * 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 * `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 * 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. * rather than taken as the move for the next round.
@@ -386,6 +387,11 @@ export class Rooms<State, Input> {
this.driveBots(room); 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. */ /** Table talk, from a seat to everyone at the table and in the gallery. */
say(room: Room<State>, token: string | undefined, raw: unknown): void { say(room: Room<State>, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token); const seat = this.seatOf(room, token);
@@ -402,11 +408,11 @@ export class Rooms<State, Input> {
return text; 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<State>): SeatId[] { waitingOn(room: Room<State>): SeatId[] {
if (!room.state || this.game.over(room.state)) return []; if (!room.state || this.game.over(room.state)) return [];
return room.seats 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); .map((s) => s.id);
} }
+6
View File
@@ -70,6 +70,12 @@ export interface GameSpec<State, Input> {
resolve(state: State, inputs: Record<SeatId, Input>): State; resolve(state: State, inputs: Record<SeatId, Input>): State;
/** Whether this seat's input is needed before the next resolution. */ /** Whether this seat's input is needed before the next resolution. */
needsInput(state: State, seat: SeatId): boolean; 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; over(state: State): Outcome | null;
/** The round being written, counted from one, for the ledger and the report pin. */ /** The round being written, counted from one, for the ledger and the report pin. */
turn(state: State): number; turn(state: State): number;