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
+6
View File
@@ -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();
}); });
+1
View File
@@ -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); });
+30
View File
@@ -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
View File
@@ -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);
} }
+8
View File
@@ -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>
); );
+85
View File
@@ -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>
</>
);
}
+16
View File
@@ -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
View File
@@ -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. */
+7 -2
View File
@@ -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 }),
})); }));
+8 -2
View File
@@ -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 }) },
), ),