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:
@@ -52,6 +52,12 @@ test('GM hosts a session; a player on another device sees live combat', async ({
|
|||||||
await expect(player.getByText('Table rolls')).toBeVisible();
|
await expect(player.getByText('Table rolls')).toBeVisible();
|
||||||
await expect(player.getByText('GM').first()).toBeVisible();
|
await expect(player.getByText('GM').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Session sidebar: both the GM and the player see who's in the session.
|
||||||
|
await gm.getByRole('button', { name: 'Open session panel' }).click();
|
||||||
|
await expect(gm.getByText(/Who's here \(2\)/)).toBeVisible(); // GM + 1 player
|
||||||
|
await player.getByRole('button', { name: 'Open session panel' }).click();
|
||||||
|
await expect(player.getByText(/Who's here \(2\)/)).toBeVisible(); // host + player visible to everyone
|
||||||
|
|
||||||
await gmCtx.close();
|
await gmCtx.close();
|
||||||
await playerCtx.close();
|
await playerCtx.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function buildServer() {
|
|||||||
case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break;
|
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 '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 '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); });
|
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 } });
|
hub.playerPatch(player, 'someone-else', { hp: { current: 0, max: 24, temp: 0 } });
|
||||||
expect(gm.msgs.filter((m) => m.t === 'playerPatched').length).toBe(before);
|
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 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';
|
import type { Character } from '@/lib/schemas/character';
|
||||||
|
|
||||||
export interface Sender {
|
export interface Sender {
|
||||||
@@ -78,6 +78,7 @@ export class RoomHub {
|
|||||||
this.conns.set(socket, { roomId: room.roomId, role: 'gm', playerId: 'gm' });
|
this.conns.set(socket, { roomId: room.roomId, role: 'gm', playerId: 'gm' });
|
||||||
socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume });
|
socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume });
|
||||||
for (const req of room.pendingSeatRequests) socket.send({ t: 'seatRequest', ...req });
|
for (const req of room.pendingSeatRequests) socket.send({ t: 'seatRequest', ...req });
|
||||||
|
this.sendRoster(room);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +111,7 @@ export class RoomHub {
|
|||||||
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId: crypto.randomUUID() });
|
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId: crypto.randomUUID() });
|
||||||
socket.send({ t: 'joined', roomId: room.roomId });
|
socket.send({ t: 'joined', roomId: room.roomId });
|
||||||
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
|
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
|
||||||
|
this.sendRoster(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
state(socket: Sender, gmSecret: string, snapshot: Snapshot): void {
|
state(socket: Sender, gmSecret: string, snapshot: Snapshot): void {
|
||||||
@@ -158,6 +160,33 @@ export class RoomHub {
|
|||||||
if (!target) return;
|
if (!target) return;
|
||||||
room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name });
|
room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name });
|
||||||
target.send({ t: 'seatGranted', character });
|
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. */
|
/** 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;
|
if (conn.role === 'gm' && room.gm === socket) room.gm = null;
|
||||||
room.players.delete(socket);
|
room.players.delete(socket);
|
||||||
for (const [pid, seat] of room.seats) if (seat.sender === socket) room.seats.delete(pid);
|
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);
|
this.conns.delete(socket);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { HandoutControl } from '@/features/play/HandoutControl';
|
|||||||
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
||||||
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
||||||
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
||||||
|
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
||||||
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/', label: 'Campaigns', exact: true },
|
{ to: '/', label: 'Campaigns', exact: true },
|
||||||
@@ -61,6 +63,8 @@ function ThemeToggle() {
|
|||||||
export function RootLayout() {
|
export function RootLayout() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [sessionOpen, setSessionOpen] = useState(false);
|
||||||
|
const sessionConnected = useSessionStore((s) => s.status === 'connected');
|
||||||
const activeCampaign = useActiveCampaign();
|
const activeCampaign = useActiveCampaign();
|
||||||
// While the GM is hosting, mirror state to players (inert unless hosting).
|
// While the GM is hosting, mirror state to players (inert unless hosting).
|
||||||
useSessionBroadcaster(activeCampaign ?? null);
|
useSessionBroadcaster(activeCampaign ?? null);
|
||||||
@@ -103,6 +107,9 @@ export function RootLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<PlayerSessionBadge />
|
<PlayerSessionBadge />
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setSessionOpen(true)} title="Session panel — who's here, rolls, chat" aria-label="Open session panel">
|
||||||
|
☰ Session{sessionConnected && <span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-success align-middle" />}
|
||||||
|
</Button>
|
||||||
{activeCampaign && <HandoutControl />}
|
{activeCampaign && <HandoutControl />}
|
||||||
<SessionControl />
|
<SessionControl />
|
||||||
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
|
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
|
||||||
@@ -121,6 +128,7 @@ export function RootLayout() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<RollTray />
|
<RollTray />
|
||||||
|
<SessionSidebar open={sessionOpen} onClose={() => setSessionOpen(false)} />
|
||||||
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
|
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global session panel (drawer). Everyone in a live session sees the same thing:
|
||||||
|
* which session they're in, who else is here (and their character), and the live
|
||||||
|
* roll feed. The GM gets extra per-player actions (added in later sub-phases:
|
||||||
|
* whisper + targeted share). Opened from the header on any page.
|
||||||
|
*/
|
||||||
|
export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const role = useSessionStore((s) => s.role);
|
||||||
|
const status = useSessionStore((s) => s.status);
|
||||||
|
const joinCode = useSessionStore((s) => s.joinCode);
|
||||||
|
const intent = useSessionStore((s) => s.joinIntent);
|
||||||
|
const roster = useSessionStore((s) => s.roster);
|
||||||
|
const rolls = usePlayerSessionStore((s) => s.rolls);
|
||||||
|
const code = role === 'gm' ? joinCode : intent?.joinCode;
|
||||||
|
const inSession = role !== 'off' || !!intent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{open && <div className="fixed inset-0 z-40 bg-black/40 print:hidden" onClick={onClose} aria-hidden />}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed right-0 top-0 z-50 flex h-full w-80 max-w-[88vw] transform flex-col border-l border-line bg-panel shadow-2xl transition-transform print:hidden',
|
||||||
|
open ? 'translate-x-0' : 'translate-x-full',
|
||||||
|
)}
|
||||||
|
aria-label="Session panel"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-line p-3">
|
||||||
|
<h2 className="font-display font-semibold text-ink">Session</h2>
|
||||||
|
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto p-3">
|
||||||
|
{inSession && code ? (
|
||||||
|
<div className="rounded-lg border border-line bg-surface p-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted">{role === 'gm' ? 'Hosting' : 'Joined'}</div>
|
||||||
|
<div className="font-mono text-lg font-semibold text-accent">{code}</div>
|
||||||
|
<div className={cn('text-xs', status === 'connected' ? 'text-success' : 'text-muted')}>● {status === 'connected' ? 'Connected' : status}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">You're not in a live session. Host one (GM) or open a join link to play together.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inSession && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Who's here ({roster.length})</h3>
|
||||||
|
{roster.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">No players have joined yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{roster.map((p) => (
|
||||||
|
<li key={p.playerId} className="flex items-center gap-2 rounded-md border border-line bg-surface px-2 py-1 text-sm text-ink">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
|
||||||
|
<span className="truncate">{p.name}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Rolls</h3>
|
||||||
|
{rolls.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">No rolls yet this session.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{rolls.map((r) => (
|
||||||
|
<li key={r.id} className="flex items-baseline gap-2 rounded-md border border-line bg-surface px-2 py-1 text-xs">
|
||||||
|
<span className="font-semibold text-accent">{r.playerName}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-ink">{r.label || r.expression}</span>
|
||||||
|
<span className="font-display text-sm font-bold text-ink">{r.total}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,6 +98,18 @@ export const rollBroadcastSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RollBroadcast = z.infer<typeof rollBroadcastSchema>;
|
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 ----
|
// ---- messages ----
|
||||||
|
|
||||||
export const clientMessageSchema = z.discriminatedUnion('t', [
|
export const clientMessageSchema = z.discriminatedUnion('t', [
|
||||||
@@ -113,6 +125,8 @@ export const clientMessageSchema = z.discriminatedUnion('t', [
|
|||||||
// two-way play (GM → server)
|
// two-way play (GM → server)
|
||||||
z.object({ t: z.literal('seatGrant'), gmSecret: z.string(), targetPlayerId: z.string(), character: characterSchema }),
|
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) }),
|
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>;
|
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('seatGranted'), character: characterSchema }),
|
||||||
z.object({ t: z.literal('playerPatched'), characterId: z.string(), diff: partialCharacterDiffSchema }),
|
z.object({ t: z.literal('playerPatched'), characterId: z.string(), diff: partialCharacterDiffSchema }),
|
||||||
z.object({ t: z.literal('rollBroadcast'), ...rollBroadcastSchema.shape }),
|
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>;
|
export type ServerMessage = z.infer<typeof serverMessageSchema>;
|
||||||
|
|||||||
+14
-1
@@ -86,6 +86,10 @@ function connect(): void {
|
|||||||
}
|
}
|
||||||
} else if (msg.t === 'rollBroadcast') {
|
} else if (msg.t === 'rollBroadcast') {
|
||||||
usePlayerSessionStore.getState().addRoll({ playerName: msg.playerName, label: msg.label, expression: msg.expression, total: msg.total, breakdown: msg.breakdown });
|
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') {
|
} else if (msg.t === 'error') {
|
||||||
session.set({ status: 'error', error: msg.message });
|
session.set({ status: 'error', error: msg.message });
|
||||||
if (msg.code === 'no-room' || msg.code === 'bad-password') manualStop = true;
|
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. */
|
/** 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 {
|
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. */
|
/** GM: approve a seat request, handing the player their authoritative sheet. */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Snapshot, RollBroadcast } from '@/lib/sync/messages';
|
import type { Snapshot, RollBroadcast, PrivateHandout } from '@/lib/sync/messages';
|
||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
|
|
||||||
export type SeatStatus = 'none' | 'pending' | 'granted';
|
export type SeatStatus = 'none' | 'pending' | 'granted';
|
||||||
@@ -14,12 +14,15 @@ interface PlayerSessionState {
|
|||||||
seatStatus: SeatStatus;
|
seatStatus: SeatStatus;
|
||||||
/** recent dice-roll broadcasts from the whole table */
|
/** recent dice-roll broadcasts from the whole table */
|
||||||
rolls: RollEntry[];
|
rolls: RollEntry[];
|
||||||
|
/** a handout/note the GM sent privately to this player only */
|
||||||
|
privateHandout: PrivateHandout | null;
|
||||||
setSnapshot: (s: Snapshot) => void;
|
setSnapshot: (s: Snapshot) => void;
|
||||||
setImage: (id: string, dataUrl: string) => void;
|
setImage: (id: string, dataUrl: string) => void;
|
||||||
setMyCharacter: (c: Character) => void;
|
setMyCharacter: (c: Character) => void;
|
||||||
patchMyCharacter: (diff: Partial<Character>) => void;
|
patchMyCharacter: (diff: Partial<Character>) => void;
|
||||||
setSeatStatus: (s: SeatStatus) => void;
|
setSeatStatus: (s: SeatStatus) => void;
|
||||||
addRoll: (r: RollBroadcast) => void;
|
addRoll: (r: RollBroadcast) => void;
|
||||||
|
setPrivateHandout: (h: PrivateHandout | null) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,11 +35,13 @@ export const usePlayerSessionStore = create<PlayerSessionState>()((set) => ({
|
|||||||
myCharacter: null,
|
myCharacter: null,
|
||||||
seatStatus: 'none',
|
seatStatus: 'none',
|
||||||
rolls: [],
|
rolls: [],
|
||||||
|
privateHandout: null,
|
||||||
setSnapshot: (snapshot) => set({ snapshot }),
|
setSnapshot: (snapshot) => set({ snapshot }),
|
||||||
setImage: (id, dataUrl) => set((s) => ({ images: { ...s.images, [id]: dataUrl } })),
|
setImage: (id, dataUrl) => set((s) => ({ images: { ...s.images, [id]: dataUrl } })),
|
||||||
setMyCharacter: (c) => set({ myCharacter: c, seatStatus: 'granted' }),
|
setMyCharacter: (c) => set({ myCharacter: c, seatStatus: 'granted' }),
|
||||||
patchMyCharacter: (diff) => set((s) => (s.myCharacter ? { myCharacter: { ...s.myCharacter, ...diff } } : {})),
|
patchMyCharacter: (diff) => set((s) => (s.myCharacter ? { myCharacter: { ...s.myCharacter, ...diff } } : {})),
|
||||||
setSeatStatus: (seatStatus) => set({ seatStatus }),
|
setSeatStatus: (seatStatus) => set({ seatStatus }),
|
||||||
addRoll: (r) => set((s) => ({ rolls: [{ ...r, id: ++rollSeq }, ...s.rolls].slice(0, 30) })),
|
addRoll: (r) => set((s) => ({ rolls: [{ ...r, id: ++rollSeq }, ...s.rolls].slice(0, 30) })),
|
||||||
reset: () => set({ snapshot: null, images: {}, myCharacter: null, seatStatus: 'none', rolls: [] }),
|
setPrivateHandout: (privateHandout) => set({ privateHandout }),
|
||||||
|
reset: () => set({ snapshot: null, images: {}, myCharacter: null, seatStatus: 'none', rolls: [], privateHandout: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
|
import type { RosterEntry } from '@/lib/sync/messages';
|
||||||
|
|
||||||
export type SessionRole = 'off' | 'gm' | 'player';
|
export type SessionRole = 'off' | 'gm' | 'player';
|
||||||
export type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
export type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
||||||
@@ -24,10 +25,13 @@ interface SessionState {
|
|||||||
seatRequests: SeatRequest[];
|
seatRequests: SeatRequest[];
|
||||||
/** when set, the app keeps a player connection alive across views/reloads */
|
/** when set, the app keeps a player connection alive across views/reloads */
|
||||||
joinIntent: JoinIntent | null;
|
joinIntent: JoinIntent | null;
|
||||||
set: (patch: Partial<Omit<SessionState, 'set' | 'reset' | 'addSeatRequest' | 'removeSeatRequest' | 'setJoinIntent'>>) => void;
|
/** GM only: connected players (for targeting private shares) */
|
||||||
|
roster: RosterEntry[];
|
||||||
|
set: (patch: Partial<Omit<SessionState, 'set' | 'reset' | 'addSeatRequest' | 'removeSeatRequest' | 'setJoinIntent' | 'setRoster'>>) => void;
|
||||||
addSeatRequest: (r: SeatRequest) => void;
|
addSeatRequest: (r: SeatRequest) => void;
|
||||||
removeSeatRequest: (playerId: string) => void;
|
removeSeatRequest: (playerId: string) => void;
|
||||||
setJoinIntent: (j: JoinIntent | null) => void;
|
setJoinIntent: (j: JoinIntent | null) => void;
|
||||||
|
setRoster: (r: RosterEntry[]) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +47,13 @@ export const useSessionStore = create<SessionState>()(
|
|||||||
error: null,
|
error: null,
|
||||||
seatRequests: [],
|
seatRequests: [],
|
||||||
joinIntent: null,
|
joinIntent: null,
|
||||||
|
roster: [],
|
||||||
set: (patch) => set(patch),
|
set: (patch) => set(patch),
|
||||||
addSeatRequest: (r) => set((s) => ({ seatRequests: [...s.seatRequests.filter((x) => x.playerId !== r.playerId), r] })),
|
addSeatRequest: (r) => set((s) => ({ seatRequests: [...s.seatRequests.filter((x) => x.playerId !== r.playerId), r] })),
|
||||||
removeSeatRequest: (playerId) => set((s) => ({ seatRequests: s.seatRequests.filter((x) => x.playerId !== playerId) })),
|
removeSeatRequest: (playerId) => set((s) => ({ seatRequests: s.seatRequests.filter((x) => x.playerId !== playerId) })),
|
||||||
setJoinIntent: (joinIntent) => set({ joinIntent }),
|
setJoinIntent: (joinIntent) => set({ joinIntent }),
|
||||||
reset: () => set({ role: 'off', status: 'idle', roomId: null, joinCode: null, error: null, seatRequests: [] }),
|
setRoster: (roster) => set({ roster }),
|
||||||
|
reset: () => set({ role: 'off', status: 'idle', roomId: null, joinCode: null, error: null, seatRequests: [], roster: [] }),
|
||||||
}),
|
}),
|
||||||
{ name: 'ttrpg-session', partialize: (s) => ({ joinIntent: s.joinIntent }) },
|
{ name: 'ttrpg-session', partialize: (s) => ({ joinIntent: s.joinIntent }) },
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user