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:
+32
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user