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:
@@ -101,6 +101,7 @@ export function buildServer() {
|
||||
case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break;
|
||||
case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break;
|
||||
case 'gmRoll': hub.gmRoll(sender, m.gmSecret, m.label, m.expression, m.total, m.breakdown); break;
|
||||
case 'privateState': hub.privateState(sender, m.gmSecret, m.targetPlayerId, m.handout); break;
|
||||
}
|
||||
});
|
||||
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
|
||||
|
||||
@@ -127,4 +127,34 @@ describe('RoomHub', () => {
|
||||
hub.playerPatch(player, 'someone-else', { hp: { current: 0, max: 24, temp: 0 } });
|
||||
expect(gm.msgs.filter((m) => m.t === 'playerPatched').length).toBe(before);
|
||||
});
|
||||
|
||||
it('broadcasts a roster (GM + players) to everyone', () => {
|
||||
const hub = new RoomHub();
|
||||
const { gm, player } = hostAndJoin(hub);
|
||||
const roster = lastOf(gm, 'roster');
|
||||
expect(roster?.players.map((p) => p.name)).toContain('GM');
|
||||
expect(roster?.players).toHaveLength(2); // GM + 1 player
|
||||
expect(lastOf(player, 'roster')?.players).toHaveLength(2); // players see the roster too
|
||||
});
|
||||
|
||||
it('routes a private handout to the target player ONLY', () => {
|
||||
const hub = new RoomHub();
|
||||
const gm = fake(); hub.host(gm);
|
||||
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
|
||||
const p1 = fake(); hub.join(p1, hosted.joinCode);
|
||||
const p2 = fake(); hub.join(p2, hosted.joinCode);
|
||||
const roster = lastOf(gm, 'roster')!;
|
||||
const targetId = roster.players.find((p) => p.playerId !== 'gm')!.playerId; // first player (insertion order)
|
||||
|
||||
hub.privateState(gm, hosted.gmSecret, targetId, { title: 'Secret', body: 'just for you' });
|
||||
expect(lastOf(p1, 'privateHandout')).toMatchObject({ handout: { title: 'Secret' } });
|
||||
expect(lastOf(p2, 'privateHandout')).toBeUndefined(); // a non-recipient never receives it
|
||||
});
|
||||
|
||||
it('rejects privateState from a non-GM', () => {
|
||||
const hub = new RoomHub();
|
||||
const { player } = hostAndJoin(hub);
|
||||
hub.privateState(player, 'not-the-secret', 'gm', { title: 'x', body: 'y' });
|
||||
expect(lastOf(player, 'error')?.code).toBe('forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
+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