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
+16
View File
@@ -98,6 +98,18 @@ export const rollBroadcastSchema = z.object({
});
export type RollBroadcast = z.infer<typeof rollBroadcastSchema>;
/** A handout/note/image targeted at specific players. Image is inline (NOT the
* shared image channel) so non-recipients never receive it. */
export const privateHandoutSchema = z.object({
title: z.string().max(200),
body: z.string().max(5000),
image: z.string().optional(),
});
export type PrivateHandout = z.infer<typeof privateHandoutSchema>;
export const rosterEntrySchema = z.object({ playerId: z.string(), name: z.string() });
export type RosterEntry = z.infer<typeof rosterEntrySchema>;
// ---- messages ----
export const clientMessageSchema = z.discriminatedUnion('t', [
@@ -113,6 +125,8 @@ export const clientMessageSchema = z.discriminatedUnion('t', [
// two-way play (GM → server)
z.object({ t: z.literal('seatGrant'), gmSecret: z.string(), targetPlayerId: z.string(), character: characterSchema }),
z.object({ t: z.literal('gmRoll'), gmSecret: z.string(), label: z.string().max(120), expression: z.string().max(200), total: int, breakdown: z.string().max(600) }),
// targeted private handout (GM → one player)
z.object({ t: z.literal('privateState'), gmSecret: z.string(), targetPlayerId: z.string(), handout: privateHandoutSchema.nullable() }),
]);
export type ClientMessage = z.infer<typeof clientMessageSchema>;
@@ -127,5 +141,7 @@ export const serverMessageSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('seatGranted'), character: characterSchema }),
z.object({ t: z.literal('playerPatched'), characterId: z.string(), diff: partialCharacterDiffSchema }),
z.object({ t: z.literal('rollBroadcast'), ...rollBroadcastSchema.shape }),
z.object({ t: z.literal('privateHandout'), handout: privateHandoutSchema.nullable() }),
z.object({ t: z.literal('roster'), players: z.array(rosterEntrySchema) }),
]);
export type ServerMessage = z.infer<typeof serverMessageSchema>;
+14 -1
View File
@@ -86,6 +86,10 @@ function connect(): void {
}
} else if (msg.t === 'rollBroadcast') {
usePlayerSessionStore.getState().addRoll({ playerName: msg.playerName, label: msg.label, expression: msg.expression, total: msg.total, breakdown: msg.breakdown });
} else if (msg.t === 'roster') {
useSessionStore.getState().setRoster(msg.players);
} else if (msg.t === 'privateHandout') {
usePlayerSessionStore.getState().setPrivateHandout(msg.handout);
} else if (msg.t === 'error') {
session.set({ status: 'error', error: msg.message });
if (msg.code === 'no-room' || msg.code === 'bad-password') manualStop = true;
@@ -146,7 +150,16 @@ export function sendPlayerRoll(characterId: string, label: string, expression: s
/** GM: broadcast one of their own (public) rolls to the players. No-op unless hosting. */
export function broadcastGmRoll(label: string, expression: string, total: number, breakdown: string): void {
if (useSessionStore.getState().role === 'gm' && gmSecret) send({ t: 'gmRoll', gmSecret, label, expression, total, breakdown });
if (useSessionStore.getState().role === 'gm' && gmSecret) {
send({ t: 'gmRoll', gmSecret, label, expression, total, breakdown });
// mirror into the local table feed so the GM's recap includes their own public rolls
usePlayerSessionStore.getState().addRoll({ playerName: 'GM', label, expression, total, breakdown });
}
}
/** GM: send a private handout/note/image to one player (or null to clear it). */
export function sendPrivateHandout(targetPlayerId: string, handout: { title: string; body: string; image?: string } | null): void {
if (useSessionStore.getState().role === 'gm' && gmSecret) send({ t: 'privateState', gmSecret, targetPlayerId, handout });
}
/** GM: approve a seat request, handing the player their authoritative sheet. */