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:
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user