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:
co-authored by
Claude Opus 5.5
parent
f0d06db108
commit
41349036d1
+1
-1
@@ -247,7 +247,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|||||||
}
|
}
|
||||||
if (action === 'turn') {
|
if (action === 'turn') {
|
||||||
const token = typeof body.token === 'string' ? body.token : undefined;
|
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);
|
const seat = rooms.seatOf(room, token);
|
||||||
return send(res, 200, rooms.view(room, seat.id));
|
return send(res, 200, rooms.view(room, seat.id));
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -51,6 +51,8 @@ export interface Room {
|
|||||||
/** Inputs received for the turn in progress, by seat. */
|
/** Inputs received for the turn in progress, by seat. */
|
||||||
pending: Record<WizardId, TurnInput>;
|
pending: Record<WizardId, TurnInput>;
|
||||||
chat: ChatLine[];
|
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. */
|
/** Bumped on every change a client might want to see. */
|
||||||
seq: number;
|
seq: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -92,7 +94,7 @@ const TRANSFER_FAIL_LIMIT = 20;
|
|||||||
|
|
||||||
/** A table with nobody seated yet. */
|
/** A table with nobody seated yet. */
|
||||||
function blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], seq = 0): Room {
|
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 {
|
export function normalizeCode(raw: string): string {
|
||||||
@@ -315,11 +317,18 @@ export class Rooms {
|
|||||||
this.commit(room, { t: 'unseat', id: seat.id });
|
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);
|
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) 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 (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);
|
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) });
|
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input: cleanInput(raw) });
|
||||||
if (waitingOn(room).length === 0) this.resolve(room);
|
if (waitingOn(room).length === 0) this.resolve(room);
|
||||||
@@ -356,6 +365,7 @@ export class Rooms {
|
|||||||
host: room.seats[0]?.id ?? seatId,
|
host: room.seats[0]?.id ?? seatId,
|
||||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
||||||
waitingOn: waitingOn(room),
|
waitingOn: waitingOn(room),
|
||||||
|
resolved: room.resolved,
|
||||||
state: room.state ? viewFor(room.state, seatId) : null,
|
state: room.state ? viewFor(room.state, seatId) : null,
|
||||||
chat: room.chat,
|
chat: room.chat,
|
||||||
audience: this.gallery.get(room.id) ?? 0,
|
audience: this.gallery.get(room.id) ?? 0,
|
||||||
@@ -450,6 +460,7 @@ function apply(room: Room, line: LedgerLine): void {
|
|||||||
if (!room.state) return;
|
if (!room.state) return;
|
||||||
room.state = resolveTurn(room.state, line.inputs);
|
room.state = resolveTurn(room.state, line.inputs);
|
||||||
room.pending = {};
|
room.pending = {};
|
||||||
|
room.resolved += 1;
|
||||||
break;
|
break;
|
||||||
case 'over':
|
case 'over':
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export const api = {
|
|||||||
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||||
/** The gallery's view: no token, no seat, nothing any wizard could not see. */
|
/** The gallery's view: no token, no seat, nothing any wizard could not see. */
|
||||||
watch: (roomId: string) => call<RoomView>('GET', `/api/rooms/${roomId}`),
|
watch: (roomId: string) => call<RoomView>('GET', `/api/rooms/${roomId}`),
|
||||||
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
|
turn: (roomId: string, token: string, input: TurnInput, resolved: number) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
|
||||||
say: (roomId: string, token: string, text: string) => call<RoomView>('POST', `/api/rooms/${roomId}/say`, { token, text }),
|
say: (roomId: string, token: string, text: string) => call<RoomView>('POST', `/api/rooms/${roomId}/say`, { token, text }),
|
||||||
/** A word from the gallery, signed with a name, when the host allows it. */
|
/** A word from the gallery, signed with a name, when the host allows it. */
|
||||||
sayFromGallery: (roomId: string, name: string, text: string) => call<RoomView>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
sayFromGallery: (roomId: string, name: string, text: string) => call<RoomView>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ export interface Remote extends HeldSeat {
|
|||||||
expected: string[];
|
expected: string[];
|
||||||
rematch: RoomView['rematch'];
|
rematch: RoomView['rematch'];
|
||||||
rematchOf: string | null;
|
rematchOf: string | null;
|
||||||
|
/** Turns played so far, as the server last said. */
|
||||||
|
resolved: number;
|
||||||
keeper: string;
|
keeper: string;
|
||||||
keeperZone: string;
|
keeperZone: string;
|
||||||
}
|
}
|
||||||
@@ -780,7 +782,7 @@ export class Duel {
|
|||||||
this.sending = true;
|
this.sending = true;
|
||||||
r.error = '';
|
r.error = '';
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
r.error = e instanceof Error ? e.message : 'The move did not reach the server.';
|
r.error = e instanceof Error ? e.message : 'The move did not reach the server.';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -864,6 +866,7 @@ export class Duel {
|
|||||||
r.expected = view.expected;
|
r.expected = view.expected;
|
||||||
r.rematch = view.rematch;
|
r.rematch = view.rematch;
|
||||||
r.rematchOf = view.rematchOf;
|
r.rematchOf = view.rematchOf;
|
||||||
|
r.resolved = view.resolved;
|
||||||
markTalkSeen(r.roomId, view.chat.length);
|
markTalkSeen(r.roomId, view.chat.length);
|
||||||
const wasStarted = r.started;
|
const wasStarted = r.started;
|
||||||
r.started = view.state !== null;
|
r.started = view.state !== null;
|
||||||
@@ -1021,6 +1024,7 @@ function remoteFrom(view: RoomView, token: string): Remote {
|
|||||||
expected: view.expected,
|
expected: view.expected,
|
||||||
rematch: view.rematch,
|
rematch: view.rematch,
|
||||||
rematchOf: view.rematchOf,
|
rematchOf: view.rematchOf,
|
||||||
|
resolved: view.resolved,
|
||||||
keeper: view.keeper,
|
keeper: view.keeper,
|
||||||
keeperZone: view.keeperZone
|
keeperZone: view.keeperZone
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface RoomView {
|
|||||||
seats: { id: WizardId; name: string; bot: boolean }[];
|
seats: { id: WizardId; name: string; bot: boolean }[];
|
||||||
/** Seats that still have to move before the turn resolves. */
|
/** Seats that still have to move before the turn resolves. */
|
||||||
waitingOn: WizardId[];
|
waitingOn: WizardId[];
|
||||||
|
/** Turns played so far, time stops included; gestures are sent with the count they were made against. */
|
||||||
|
resolved: number;
|
||||||
state: GameState | null;
|
state: GameState | null;
|
||||||
chat: ChatLine[];
|
chat: ChatLine[];
|
||||||
/** How many watch from the Peanut Gallery. */
|
/** How many watch from the Peanut Gallery. */
|
||||||
|
|||||||
Reference in New Issue
Block a user