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
+41 -7
View File
@@ -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);
}
}