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:
+41
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user