From db829ff1a68a035c52c08a3f7e38dfff9b1c6b24 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 19:46:58 +0200 Subject: [PATCH] Fix CRITICAL player seat loss on WebSocket reconnect (stable playerId + seat grace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A player who dropped briefly got a fresh server playerId on auto-reconnect and silently lost their seat: the UI still showed 'granted' but every HP/condition/spell patch and dice roll was rejected server-side. Now: - The client persists a stable playerId (localStorage) and sends it on join. - The server reuses that id when it isn't already live on another connection (so a second tab/peer can't hijack an active seat), and on reconnect re-binds the seat to the new socket and re-sends seatGranted — control is restored seamlessly. - Seats survive a disconnect for a 5-minute grace window (marked, not deleted); sweep() evicts seats whose holder never returns. Adds 3 server tests (reconnect reclaim, grace eviction, anti-hijack). 4 files: join message gains optional playerId; server dispatch, RoomHub, and wsSync client. Co-Authored-By: Claude Opus 4.8 --- server/src/index.ts | 2 +- server/src/rooms.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++ server/src/rooms.ts | 48 +++++++++++++++++++++++++++++------ src/lib/sync/messages.ts | 2 +- src/lib/sync/wsSync.ts | 14 ++++++++++- 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 0e270e7..012b988 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -195,7 +195,7 @@ export function buildServer() { const m = parsed.data; switch (m.t) { case 'host': hub.host(sender, m.password, m.resume); break; - case 'join': hub.join(sender, m.joinCode, m.password); break; + case 'join': hub.join(sender, m.joinCode, m.password, m.playerId); break; case 'state': hub.state(sender, m.gmSecret, m.snapshot); break; case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break; case 'requestImage': hub.requestImage(sender, m.id); break; diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 448181a..940d2bf 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -128,6 +128,60 @@ describe('RoomHub', () => { expect(gm.msgs.filter((m) => m.t === 'playerPatched').length).toBe(before); }); + it('survives a player reconnect: the seat is reclaimed with the same stable playerId', () => { + let t = 0; + const hub = new RoomHub(() => t); + const gm = fake(); hub.host(gm); + const hosted = lastOf(gm, 'hosted') as Extract; + const PID = 'stable-player-1'; + const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, PID); + hub.claimSeat(p1, 'ch1', char); + hub.seatGrant(gm, hosted.gmSecret, lastOf(gm, 'seatRequest')!.playerId, char); + expect(lastOf(p1, 'seatGranted')).toBeTruthy(); + + // Transient drop, then auto-reconnect with the SAME stable id within the grace window. + hub.disconnect(p1); + const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, PID); + + // The seat is re-granted to the new socket and its patches are accepted again + // (before the fix the player got a fresh playerId and was silently seatless). + expect(lastOf(p2, 'seatGranted')).toMatchObject({ character: { id: 'ch1' } }); + hub.playerPatch(p2, 'ch1', { hp: { current: 5, max: 24, temp: 0 } }); + expect(lastOf(gm, 'playerPatched')).toMatchObject({ characterId: 'ch1', diff: { hp: { current: 5 } } }); + }); + + it('evicts a seat whose holder stays gone past the grace window', () => { + let t = 0; + const hub = new RoomHub(() => t); + const gm = fake(); hub.host(gm); + const hosted = lastOf(gm, 'hosted') as Extract; + const PID = 'gone-player'; + const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, PID); + hub.claimSeat(p1, 'ch1', char); + hub.seatGrant(gm, hosted.gmSecret, lastOf(gm, 'seatRequest')!.playerId, char); + hub.disconnect(p1); + + t += 6 * 60 * 1000; // past the 5-minute seat grace + hub.sweep(); + + const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, PID); + expect(lastOf(p2, 'seatGranted')).toBeUndefined(); // seat was reclaimed by no one + }); + + it('does not let a second live connection hijack an active seat with the same id', () => { + const hub = new RoomHub(); + const gm = fake(); hub.host(gm); + const hosted = lastOf(gm, 'hosted') as Extract; + const PID = 'shared-id'; + const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, PID); + hub.claimSeat(p1, 'ch1', char); + hub.seatGrant(gm, hosted.gmSecret, lastOf(gm, 'seatRequest')!.playerId, char); + + // p1 is still live; a second socket presenting the same id must NOT inherit the seat. + const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, PID); + expect(lastOf(p2, 'seatGranted')).toBeUndefined(); + }); + it('broadcasts a roster (GM + players) to everyone', () => { const hub = new RoomHub(); const { gm, player } = hostAndJoin(hub); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index a49a4bc..872f86c 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -10,8 +10,15 @@ interface Seat { sender: Sender; characterId: string; name: string; + /** the granted sheet, re-sent on reconnect so the player regains control seamlessly */ + character: Character; + /** set when the holder's socket drops; the seat survives a short grace window */ + disconnectedAt?: number; } +/** How long a seat survives after its player disconnects, so a reconnect can reclaim it. */ +const SEAT_GRACE_MS = 5 * 60 * 1000; + interface SeatRequest { playerId: string; characterId: string; @@ -98,7 +105,7 @@ export class RoomHub { socket.send({ t: 'hosted', roomId, joinCode, gmSecret }); } - join(socket: Sender, joinCode: string, password?: string): void { + join(socket: Sender, joinCode: string, password?: string, clientPlayerId?: string): void { const roomId = this.byCode.get(joinCode.trim().toUpperCase()); const room = roomId ? this.rooms.get(roomId) : undefined; if (!room) { socket.send({ t: 'error', code: 'no-room', message: 'No session with that code.' }); return; } @@ -108,9 +115,25 @@ export class RoomHub { } room.players.add(socket); room.lastActivity = this.now(); - this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId: crypto.randomUUID() }); + // Reuse the client's stable id (so a transient drop keeps the seat) ONLY when it + // isn't already live on another connection — preventing a second tab/peer from + // seizing an active seat. The id is a client-generated UUID kept in localStorage. + let playerId: string = crypto.randomUUID(); + if (clientPlayerId && clientPlayerId !== 'gm' && clientPlayerId.length <= 64) { + const liveElsewhere = [...this.conns.values()].some((c) => c.roomId === room.roomId && c.playerId === clientPlayerId); + if (!liveElsewhere) playerId = clientPlayerId; + } + this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId }); socket.send({ t: 'joined', roomId: room.roomId }); if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot }); + // Reconnect: if this player still owns a seat, re-bind it to the new socket and + // re-grant the sheet so their HP/condition/roll messages are accepted again. + const seat = room.seats.get(playerId); + if (seat) { + seat.sender = socket; + delete seat.disconnectedAt; + socket.send({ t: 'seatGranted', character: seat.character }); + } this.sendRoster(room); } @@ -158,7 +181,7 @@ export class RoomHub { let target: Sender | undefined; for (const [s, c] of this.conns) { if (c.roomId === room.roomId && c.playerId === targetPlayerId) { target = s; break; } } if (!target) return; - room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name }); + room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name, character }); target.send({ t: 'seatGranted', character }); this.sendRoster(room); } @@ -268,18 +291,29 @@ export class RoomHub { if (room) { if (conn.role === 'gm' && room.gm === socket) room.gm = null; room.players.delete(socket); - for (const [pid, seat] of room.seats) if (seat.sender === socket) room.seats.delete(pid); + // Don't drop the seat on disconnect — start its grace timer so a reconnect (same + // stable playerId) can reclaim it. sweep() evicts seats that stay gone too long. + for (const [, seat] of room.seats) if (seat.sender === socket) seat.disconnectedAt = this.now(); // Refresh the roster for whoever remains (GM left → players see it; player left → GM sees it). this.sendRoster(room); } this.conns.delete(socket); } - /** Evict rooms idle past the TTL. Call periodically. */ + /** Evict rooms idle past the TTL, and seats whose holder has been gone past the grace window. */ sweep(): void { - const cutoff = this.now() - ROOM_TTL_MS; + const now = this.now(); + const cutoff = now - ROOM_TTL_MS; for (const [id, room] of this.rooms) { - if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); } + if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); continue; } + let dropped = false; + for (const [pid, seat] of room.seats) { + if (seat.disconnectedAt !== undefined && now - seat.disconnectedAt > SEAT_GRACE_MS) { + room.seats.delete(pid); + dropped = true; + } + } + if (dropped) this.sendRoster(room); } } diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index 5bc91e0..47ec628 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -128,7 +128,7 @@ export type RosterEntry = z.infer; export const clientMessageSchema = z.discriminatedUnion('t', [ z.object({ t: z.literal('host'), campaignId: z.string(), password: z.string().max(128).optional(), resume: z.string().optional() }), - z.object({ t: z.literal('join'), joinCode: z.string().max(16), password: z.string().max(128).optional() }), + z.object({ t: z.literal('join'), joinCode: z.string().max(16), password: z.string().max(128).optional(), playerId: z.string().max(64).optional() }), z.object({ t: z.literal('state'), gmSecret: z.string(), snapshot: snapshotSchema }), z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string(), dataUrl: z.string() }), z.object({ t: z.literal('requestImage'), id: z.string() }), diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts index b6fd69f..42952b1 100644 --- a/src/lib/sync/wsSync.ts +++ b/src/lib/sync/wsSync.ts @@ -34,6 +34,18 @@ function wsUrl(): string { return `${proto}://${location.host}/ws`; } +const PLAYER_ID_KEY = 'ttrpg:playerId'; +/** A stable per-browser id so a transient WebSocket drop keeps the player's seat. */ +function stablePlayerId(): string { + try { + let id = localStorage.getItem(PLAYER_ID_KEY); + if (!id) { id = crypto.randomUUID(); localStorage.setItem(PLAYER_ID_KEY, id); } + return id; + } catch { + return crypto.randomUUID(); + } +} + function send(msg: ClientMessage): void { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(clientMessageSchema.parse(msg))); } @@ -54,7 +66,7 @@ function connect(): void { reconnectMs = 1000; if (!intent) return; if (intent.kind === 'gm') send({ t: 'host', campaignId: intent.campaignId, ...(intent.password ? { password: intent.password } : {}), ...(gmSecret ? { resume: gmSecret } : {}) }); - else send({ t: 'join', joinCode: intent.joinCode, ...(intent.password ? { password: intent.password } : {}) }); + else send({ t: 'join', joinCode: intent.joinCode, ...(intent.password ? { password: intent.password } : {}), playerId: stablePlayerId() }); }; socket.onmessage = (ev) => {