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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-25 22:19:45 -04:00
co-authored by Claude Opus 5.5
parent f0d06db108
commit 41349036d1
5 changed files with 23 additions and 6 deletions
+1 -1
View File
@@ -247,7 +247,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
}
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));
}
+14 -3
View File
@@ -51,6 +51,8 @@ export interface Room {
/** Inputs received for the turn in progress, by seat. */
pending: Record<WizardId, TurnInput>;
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;