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
+54
View File
@@ -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<ServerMessage, { t: 'hosted' }>;
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<ServerMessage, { t: 'hosted' }>;
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<ServerMessage, { t: 'hosted' }>;
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);