A settled move may be taken back as a draft while the round still waits on others

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:26:32 -04:00
co-authored by Claude Opus 5.5
parent af25b672f1
commit 59494aa29f
3 changed files with 10 additions and 4 deletions
+2 -1
View File
@@ -142,7 +142,8 @@ 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
false until the player says it is done; a draft may be replaced (and a
settled move taken back as a draft) until the round resolves, and the
round waits for every seat's settled input. A seat's view carries its
own input in waiting (`yourInput`), so a page reopened shows the draft,
and its input in the last round it played (`yourLastInput`), so the page
+3 -1
View File
@@ -62,7 +62,9 @@ describe('rooms', () => {
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/);
expect(() => rooms.submit(room, bo, { pick: 2, final: true }, 1)).toThrow(/already in/);
rooms.submit(room, bo, { pick: 2 }, 1);
expect(rooms.waitingOn(room)).toEqual(['A', 'B']);
});
it("shows a seat its own move in waiting and its last move played, and nobody else's", () => {
+5 -2
View File
@@ -430,7 +430,8 @@ 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.
* A draft (an input the game does not call settled) may be replaced until it is settled.
* A draft (an input the game does not call settled) may be replaced, and a settled move taken back
* as a draft, until the round resolves.
* `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.
@@ -442,8 +443,10 @@ export class Rooms<State, Input> {
if (typeof resolved !== 'number') throw new RoomError('This page is out of date. Reload it to see the table as it stands.', 409);
if (resolved !== room.resolved) throw new RoomError('That move was made for a turn that has already been played. Your page will catch up.', 409);
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
if (!this.game.needsInput(room.state, seat.id)) throw new RoomError('It is not your move.', 409);
const input = this.game.cleanInput(raw);
// A settled move is final; a draft may replace it while the round still waits on others.
if (seat.id in room.pending && this.settled(room.pending[seat.id]) && this.settled(input)) throw new RoomError('Your move is already in.', 409);
const why = this.game.validate?.(room.state, seat.id, input);
if (why) throw new RoomError(why);
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });