From 41349036d16748ff94ee75f1a44dadee55c23f02 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 25 Sep 2026 22:19:45 -0400 Subject: [PATCH] A move names the round it was made for; a stale one is refused The room counts rounds played and sends the count in its view; a page sends it back with each move, and the server refuses a move whose count is behind (a slow connection, a second click, a stale tab), or that has no count at all (an old page, told to reload). Before, such a move was taken as the move for the next round. Found in Dip's acceptance run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- server/src/index.ts | 2 +- server/src/rooms.ts | 17 ++++++++++++++--- src/lib/game/client.ts | 2 +- src/lib/game/duel.svelte.ts | 6 +++++- src/lib/game/view.ts | 2 ++ 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index c3744b8..d840cdc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -247,7 +247,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise } if (action === 'turn') { const token = typeof body.token === 'string' ? body.token : undefined; - rooms.submit(room, token, body.input); + rooms.submit(room, token, body.input, body.resolved); const seat = rooms.seatOf(room, token); return send(res, 200, rooms.view(room, seat.id)); } diff --git a/server/src/rooms.ts b/server/src/rooms.ts index d270ed5..520b82f 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -51,6 +51,8 @@ export interface Room { /** Inputs received for the turn in progress, by seat. */ pending: Record; chat: ChatLine[]; + /** Turns played so far, time stops included; a move names the count it was made against. */ + resolved: number; /** Bumped on every change a client might want to see. */ seq: number; updatedAt: number; @@ -92,7 +94,7 @@ const TRANSFER_FAIL_LIMIT = 20; /** A table with nobody seated yet. */ function blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], seq = 0): Room { - return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, state: null, pending: {}, chat: [], seq, updatedAt: createdAt }; + return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, state: null, pending: {}, chat: [], resolved: 0, seq, updatedAt: createdAt }; } export function normalizeCode(raw: string): string { @@ -315,11 +317,18 @@ export class Rooms { this.commit(room, { t: 'unseat', id: seat.id }); } - /** Record a seat's move on the ledger, so a restart keeps it; resolve the turn once nobody else is awaited. */ - submit(room: Room, token: string | undefined, raw: unknown): void { + /** + * Record a seat's move on the ledger, so a restart keeps it; resolve the turn once nobody else is awaited. + * `resolved` is how many turns the page had seen played when the gestures were made: gestures that + * arrive after their turn was played (a slow connection, a second click, a stale tab) are refused + * rather than taken as the next turn's. + */ + submit(room: Room, token: string | undefined, raw: unknown, resolved?: unknown): void { const seat = this.seatOf(room, token); if (!room.state) throw new RoomError('The duel has not begun: seats are still empty.', 409); if (room.state.over) throw new RoomError('The duel is over.', 409); + if (typeof resolved !== 'number') throw new RoomError('This page is out of date. Reload it to see the duel as it stands.', 409); + if (resolved !== room.resolved) throw new RoomError('Those gestures were made for a turn that has already been played. Your page will catch up.', 409); if (!waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409); this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input: cleanInput(raw) }); if (waitingOn(room).length === 0) this.resolve(room); @@ -356,6 +365,7 @@ export class Rooms { host: room.seats[0]?.id ?? seatId, seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })), waitingOn: waitingOn(room), + resolved: room.resolved, state: room.state ? viewFor(room.state, seatId) : null, chat: room.chat, audience: this.gallery.get(room.id) ?? 0, @@ -450,6 +460,7 @@ function apply(room: Room, line: LedgerLine): void { if (!room.state) return; room.state = resolveTurn(room.state, line.inputs); room.pending = {}; + room.resolved += 1; break; case 'over': break; diff --git a/src/lib/game/client.ts b/src/lib/game/client.ts index a0a00b3..32b2dfb 100644 --- a/src/lib/game/client.ts +++ b/src/lib/game/client.ts @@ -154,7 +154,7 @@ export const api = { view: (roomId: string, token: string) => call('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`), /** The gallery's view: no token, no seat, nothing any wizard could not see. */ watch: (roomId: string) => call('GET', `/api/rooms/${roomId}`), - turn: (roomId: string, token: string, input: TurnInput) => call('POST', `/api/rooms/${roomId}/turn`, { token, input }), + turn: (roomId: string, token: string, input: TurnInput, resolved: number) => call('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }), say: (roomId: string, token: string, text: string) => call('POST', `/api/rooms/${roomId}/say`, { token, text }), /** A word from the gallery, signed with a name, when the host allows it. */ sayFromGallery: (roomId: string, name: string, text: string) => call('POST', `/api/rooms/${roomId}/say`, { name, text }), diff --git a/src/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts index b910fed..c3c9208 100644 --- a/src/lib/game/duel.svelte.ts +++ b/src/lib/game/duel.svelte.ts @@ -229,6 +229,8 @@ export interface Remote extends HeldSeat { expected: string[]; rematch: RoomView['rematch']; rematchOf: string | null; + /** Turns played so far, as the server last said. */ + resolved: number; keeper: string; keeperZone: string; } @@ -780,7 +782,7 @@ export class Duel { this.sending = true; r.error = ''; try { - this.apply(await api.turn(r.roomId, r.token, yours)); + this.apply(await api.turn(r.roomId, r.token, yours, r.resolved)); } catch (e) { r.error = e instanceof Error ? e.message : 'The move did not reach the server.'; } finally { @@ -864,6 +866,7 @@ export class Duel { r.expected = view.expected; r.rematch = view.rematch; r.rematchOf = view.rematchOf; + r.resolved = view.resolved; markTalkSeen(r.roomId, view.chat.length); const wasStarted = r.started; r.started = view.state !== null; @@ -1021,6 +1024,7 @@ function remoteFrom(view: RoomView, token: string): Remote { expected: view.expected, rematch: view.rematch, rematchOf: view.rematchOf, + resolved: view.resolved, keeper: view.keeper, keeperZone: view.keeperZone }; diff --git a/src/lib/game/view.ts b/src/lib/game/view.ts index 86d034f..a1fe2f6 100644 --- a/src/lib/game/view.ts +++ b/src/lib/game/view.ts @@ -36,6 +36,8 @@ export interface RoomView { seats: { id: WizardId; name: string; bot: boolean }[]; /** Seats that still have to move before the turn resolves. */ waitingOn: WizardId[]; + /** Turns played so far, time stops included; gestures are sent with the count they were made against. */ + resolved: number; state: GameState | null; chat: ChatLine[]; /** How many watch from the Peanut Gallery. */