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:
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user