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
af5a8518c7
commit
7f340698d5
@@ -68,6 +68,11 @@ README.
|
|||||||
page, gamehall.kestrelsnest.social. A new game is added to the kit's
|
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
|
`family.json` and given a card on that page in the blog repo; both are
|
||||||
hand-kept.
|
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
|
- **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
|
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
|
month, and ko-fi.com/kestrelsnest is where anyone may chip in. It says
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|||||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||||
}
|
}
|
||||||
if (action === 'turn') {
|
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));
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||||
}
|
}
|
||||||
if (action === 'say') {
|
if (action === 'say') {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface Room<State> {
|
|||||||
/** Inputs received for the round in progress, by seat. */
|
/** Inputs received for the round in progress, by seat. */
|
||||||
pending: Record<SeatId, unknown>;
|
pending: Record<SeatId, unknown>;
|
||||||
chat: ChatLine[];
|
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. */
|
/** Bumped on every change a client might want to see. */
|
||||||
seq: number;
|
seq: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
@@ -192,7 +194,7 @@ export class Rooms<State, Input> {
|
|||||||
|
|
||||||
/** A table with nobody seated yet. */
|
/** A table with nobody seated yet. */
|
||||||
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
|
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
|
||||||
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. */
|
/** 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<State, Input> {
|
|||||||
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 round once nobody else is awaited. */
|
/**
|
||||||
submit(room: Room<State>, 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<State>, 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 game has not begun: seats are still empty.', 409);
|
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 (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);
|
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
|
||||||
const input = this.game.cleanInput(raw);
|
const input = this.game.cleanInput(raw);
|
||||||
const why = this.game.validate?.(room.state, seat.id, input);
|
const why = this.game.validate?.(room.state, seat.id, input);
|
||||||
@@ -413,6 +423,7 @@ export class Rooms<State, Input> {
|
|||||||
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: this.waitingOn(room),
|
waitingOn: this.waitingOn(room),
|
||||||
turn: room.state ? this.game.turn(room.state) : null,
|
turn: room.state ? this.game.turn(room.state) : null,
|
||||||
|
resolved: room.resolved,
|
||||||
over: room.state ? this.game.over(room.state) : null,
|
over: room.state ? this.game.over(room.state) : null,
|
||||||
state: room.state ? this.game.view(room.state, seatId) : null,
|
state: room.state ? this.game.view(room.state, seatId) : null,
|
||||||
chat: room.chat,
|
chat: room.chat,
|
||||||
@@ -498,6 +509,7 @@ export class Rooms<State, Input> {
|
|||||||
if (!room.state) return;
|
if (!room.state) return;
|
||||||
room.state = this.game.resolve(room.state, line.inputs as Record<SeatId, Input>);
|
room.state = this.game.resolve(room.state, line.inputs as Record<SeatId, Input>);
|
||||||
room.pending = {};
|
room.pending = {};
|
||||||
|
room.resolved += 1;
|
||||||
break;
|
break;
|
||||||
case 'chat':
|
case 'chat':
|
||||||
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
|
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function makeApi<State, Input>() {
|
|||||||
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||||
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
||||||
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
||||||
turn: (roomId: string, token: string, input: Input) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
|
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
|
||||||
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }),
|
say: (roomId: string, token: string, text: string) => call<View>('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<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export class Room {
|
|||||||
async submit(input: Input): Promise<boolean> {
|
async submit(input: Input): Promise<boolean> {
|
||||||
this.sending = true;
|
this.sending = true;
|
||||||
try {
|
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 {
|
} finally {
|
||||||
this.sending = false;
|
this.sending = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface RoomView<State> {
|
|||||||
seats: { id: SeatId; name: string; bot: boolean }[];
|
seats: { id: SeatId; name: string; bot: boolean }[];
|
||||||
/** Seats that still have to move before the round resolves. */
|
/** Seats that still have to move before the round resolves. */
|
||||||
waitingOn: SeatId[];
|
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. */
|
/** The round being written, from one; null before the game begins. */
|
||||||
turn: number | null;
|
turn: number | null;
|
||||||
over: Outcome | null;
|
over: Outcome | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user