Fix CRITICAL player seat loss on WebSocket reconnect (stable playerId + seat grace)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:46:58 +02:00
parent 80c5d2a828
commit db829ff1a6
5 changed files with 110 additions and 10 deletions
+1 -1
View File
@@ -128,7 +128,7 @@ export type RosterEntry = z.infer<typeof rosterEntrySchema>;
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() }),
+13 -1
View File
@@ -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) => {