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:
+1
-1
@@ -195,7 +195,7 @@ export function buildServer() {
|
||||
const m = parsed.data;
|
||||
switch (m.t) {
|
||||
case 'host': hub.host(sender, m.password, m.resume); break;
|
||||
case 'join': hub.join(sender, m.joinCode, m.password); break;
|
||||
case 'join': hub.join(sender, m.joinCode, m.password, m.playerId); break;
|
||||
case 'state': hub.state(sender, m.gmSecret, m.snapshot); break;
|
||||
case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break;
|
||||
case 'requestImage': hub.requestImage(sender, m.id); break;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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