diff --git a/CONVENTIONS.md b/CONVENTIONS.md index d897ae7..2183ff7 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -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 diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index 68a51da..7b965e6 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -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", () => { diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 27b3b24..81274f3 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -430,7 +430,8 @@ 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. + * 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 { 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 });