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,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);
|
||||
|
||||
Reference in New Issue
Block a user