From 7f340698d5d7948260ecbf27439339c6343f41f9 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 25 Sep 2026 22:19:43 -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 --- CONVENTIONS.md | 5 +++++ template/server/src/index.ts | 2 +- template/server/src/rooms.ts | 18 +++++++++++++++--- template/src/lib/net/client.ts | 2 +- template/src/lib/net/room.svelte.ts | 2 +- template/src/lib/net/view.ts | 2 ++ 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 2c9e8ac..abc28a5 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -68,6 +68,11 @@ README. page, gamehall.kestrelsnest.social. A new game is added to the kit's `family.json` and given a card on that page in the blog repo; both are hand-kept. +- **A move names the round it was made for.** The room counts rounds + played (`resolved` in its view), and a page sends that count with each + move; the server refuses a move whose count is behind, so a slow + connection, a second click or a stale tab can never be taken as the + move for the next round. A page without the count is told to reload. - **The hosting line** follows the family list in the about panel: the games are free and will stay free, the servers cost a few dollars a month, and ko-fi.com/kestrelsnest is where anyone may chip in. It says diff --git a/template/server/src/index.ts b/template/server/src/index.ts index 876fead..b53d4ae 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -231,7 +231,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); } if (action === 'turn') { - rooms.submit(room, token, body.input); + rooms.submit(room, token, body.input, body.resolved); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); } if (action === 'say') { diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 6c0034c..b159a57 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -49,6 +49,8 @@ export interface Room { /** Inputs received for the round in progress, by seat. */ pending: Record; chat: ChatLine[]; + /** Rounds played so far; 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; @@ -192,7 +194,7 @@ export class Rooms { /** A table with nobody seated yet. */ private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room { - return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, chat: [], seq, updatedAt: createdAt }; + return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, chat: [], resolved: 0, seq, updatedAt: createdAt }; } /** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */ @@ -358,11 +360,19 @@ 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 round 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 round once nobody else is awaited. + * `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. + */ + submit(room: Room, token: string | undefined, raw: unknown, resolved?: unknown): void { const seat = this.seatOf(room, token); if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409); if (this.game.over(room.state)) throw new RoomError('The game is over.', 409); + 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); const input = this.game.cleanInput(raw); const why = this.game.validate?.(room.state, seat.id, input); @@ -413,6 +423,7 @@ export class Rooms { seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })), waitingOn: this.waitingOn(room), turn: room.state ? this.game.turn(room.state) : null, + resolved: room.resolved, over: room.state ? this.game.over(room.state) : null, state: room.state ? this.game.view(room.state, seatId) : null, chat: room.chat, @@ -498,6 +509,7 @@ export class Rooms { if (!room.state) return; room.state = this.game.resolve(room.state, line.inputs as Record); room.pending = {}; + room.resolved += 1; break; case 'chat': room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) }); diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index 954c71d..0d06bfb 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -131,7 +131,7 @@ export function makeApi() { view: (roomId: string, token: string) => call('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`), /** The gallery's view: no token, no seat, nothing any player could not see. */ watch: (roomId: string) => call('GET', `/api/rooms/${roomId}`), - turn: (roomId: string, token: string, input: Input) => call('POST', `/api/rooms/${roomId}/turn`, { token, input }), + turn: (roomId: string, token: string, input: Input, 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/template/src/lib/net/room.svelte.ts b/template/src/lib/net/room.svelte.ts index 9e331dc..446d604 100644 --- a/template/src/lib/net/room.svelte.ts +++ b/template/src/lib/net/room.svelte.ts @@ -148,7 +148,7 @@ export class Room { async submit(input: Input): Promise { this.sending = true; try { - return await this.act(() => api.turn(this.roomId, this.token, input), 'The move did not reach the server.'); + return await this.act(() => api.turn(this.roomId, this.token, input, this.view.resolved), 'The move did not reach the server.'); } finally { this.sending = false; } diff --git a/template/src/lib/net/view.ts b/template/src/lib/net/view.ts index f9b80d6..812dc99 100644 --- a/template/src/lib/net/view.ts +++ b/template/src/lib/net/view.ts @@ -23,6 +23,8 @@ export interface RoomView { seats: { id: SeatId; name: string; bot: boolean }[]; /** Seats that still have to move before the round resolves. */ waitingOn: SeatId[]; + /** Rounds played so far; a move is sent with the count it was made against. */ + resolved: number; /** The round being written, from one; null before the game begins. */ turn: number | null; over: Outcome | null;