P14a: global session sidebar + roster + private-channel backend

- Session sidebar (header "☰ Session" toggle, mobile drawer) visible to GM AND
  players: which session you're in, who's here (GM + players), and the live roll
  feed. The GM's own public rolls now mirror into the local feed too.
- Server roster broadcast to everyone (sendRoster on join/seatGrant/disconnect/
  host-resume); includes the GM. Players now see the roster, not just the GM.
- Targeted private channel (backend, wired to UI next): privateState (GM→server→
  one player only) + privateHandout; image sent INLINE so non-recipients never get
  it. roster/privateHandout schemas + stores + wsSync handlers + senders.
- Adversarial-review fixes: GM included in roster; roster refreshes on GM
  disconnect; privateState returns a forbidden error on auth failure. +3 server tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:01:15 +02:00
parent d94df41a95
commit b2cf62f642
10 changed files with 207 additions and 6 deletions
+32 -1
View File
@@ -1,5 +1,5 @@
import crypto from 'node:crypto';
import type { ServerMessage, Snapshot, PartialCharacterDiff } from '@/lib/sync/messages';
import type { ServerMessage, Snapshot, PartialCharacterDiff, PrivateHandout } from '@/lib/sync/messages';
import type { Character } from '@/lib/schemas/character';
export interface Sender {
@@ -78,6 +78,7 @@ export class RoomHub {
this.conns.set(socket, { roomId: room.roomId, role: 'gm', playerId: 'gm' });
socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume });
for (const req of room.pendingSeatRequests) socket.send({ t: 'seatRequest', ...req });
this.sendRoster(room);
return;
}
}
@@ -110,6 +111,7 @@ export class RoomHub {
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId: crypto.randomUUID() });
socket.send({ t: 'joined', roomId: room.roomId });
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
this.sendRoster(room);
}
state(socket: Sender, gmSecret: string, snapshot: Snapshot): void {
@@ -158,6 +160,33 @@ export class RoomHub {
if (!target) return;
room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name });
target.send({ t: 'seatGranted', character });
this.sendRoster(room);
}
/** GM sends a private handout/note/image to one player; non-recipients never receive it. */
privateState(socket: Sender, gmSecret: string, targetPlayerId: string, handout: PrivateHandout | null): void {
const room = this.gmRoom(socket, gmSecret);
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
room.lastActivity = this.now();
for (const [s, c] of this.conns) {
if (c.roomId === room.roomId && c.playerId === targetPlayerId) s.send({ t: 'privateHandout', handout });
}
}
/** Tell everyone (GM + players) the current roster: the GM (if present) + players. */
private sendRoster(room: Room): void {
const players: { playerId: string; name: string }[] = [];
if (room.gm) players.push({ playerId: 'gm', name: 'GM' });
const seen = new Set<string>();
for (const s of room.players) {
const conn = this.conns.get(s);
if (!conn || seen.has(conn.playerId)) continue;
seen.add(conn.playerId);
players.push({ playerId: conn.playerId, name: room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}` });
}
const msg = { t: 'roster', players } as const;
room.gm?.send(msg);
for (const p of room.players) p.send(msg);
}
/** A seated player edits their own sheet; the diff is forwarded to the GM only. */
@@ -201,6 +230,8 @@ export class RoomHub {
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);
// Refresh the roster for whoever remains (GM left → players see it; player left → GM sees it).
this.sendRoster(room);
}
this.conns.delete(socket);
}