diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index fd8c40f..9a3cc4d 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -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('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 playerCtx.close(); }); diff --git a/server/src/index.ts b/server/src/index.ts index 78d983d..bdd5120 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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); }); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index e5dd69d..aa1fb42 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -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; + 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'); + }); }); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 1ffc143..f07444d 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -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(); + 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); } diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index abede25..1ca19b8 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -13,6 +13,8 @@ import { HandoutControl } from '@/features/play/HandoutControl'; import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster'; import { usePlayerConnection } from '@/features/play/usePlayerConnection'; import { PlayerSessionBadge } from '@/features/play/PlayerConnection'; +import { SessionSidebar } from '@/features/play/SessionSidebar'; +import { useSessionStore } from '@/stores/sessionStore'; const NAV = [ { to: '/', label: 'Campaigns', exact: true }, @@ -61,6 +63,8 @@ function ThemeToggle() { export function RootLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); const [paletteOpen, setPaletteOpen] = useState(false); + const [sessionOpen, setSessionOpen] = useState(false); + const sessionConnected = useSessionStore((s) => s.status === 'connected'); const activeCampaign = useActiveCampaign(); // While the GM is hosting, mirror state to players (inert unless hosting). useSessionBroadcaster(activeCampaign ?? null); @@ -103,6 +107,9 @@ export function RootLayout() {
+ {activeCampaign && }
); diff --git a/src/features/play/SessionSidebar.tsx b/src/features/play/SessionSidebar.tsx new file mode 100644 index 0000000..2eca5a4 --- /dev/null +++ b/src/features/play/SessionSidebar.tsx @@ -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 &&
} + + + ); +} diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index c907c2c..a0afec2 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -98,6 +98,18 @@ export const rollBroadcastSchema = z.object({ }); export type RollBroadcast = z.infer; +/** 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; + +export const rosterEntrySchema = z.object({ playerId: z.string(), name: z.string() }); +export type RosterEntry = z.infer; + // ---- 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; @@ -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; diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts index 298899e..2963c68 100644 --- a/src/lib/sync/wsSync.ts +++ b/src/lib/sync/wsSync.ts @@ -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. */ diff --git a/src/stores/playerSessionStore.ts b/src/stores/playerSessionStore.ts index 1536dad..1855126 100644 --- a/src/stores/playerSessionStore.ts +++ b/src/stores/playerSessionStore.ts @@ -1,5 +1,5 @@ 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'; export type SeatStatus = 'none' | 'pending' | 'granted'; @@ -14,12 +14,15 @@ interface PlayerSessionState { seatStatus: SeatStatus; /** recent dice-roll broadcasts from the whole table */ rolls: RollEntry[]; + /** a handout/note the GM sent privately to this player only */ + privateHandout: PrivateHandout | null; setSnapshot: (s: Snapshot) => void; setImage: (id: string, dataUrl: string) => void; setMyCharacter: (c: Character) => void; patchMyCharacter: (diff: Partial) => void; setSeatStatus: (s: SeatStatus) => void; addRoll: (r: RollBroadcast) => void; + setPrivateHandout: (h: PrivateHandout | null) => void; reset: () => void; } @@ -32,11 +35,13 @@ export const usePlayerSessionStore = create()((set) => ({ myCharacter: null, seatStatus: 'none', rolls: [], + privateHandout: null, setSnapshot: (snapshot) => set({ snapshot }), setImage: (id, dataUrl) => set((s) => ({ images: { ...s.images, [id]: dataUrl } })), setMyCharacter: (c) => set({ myCharacter: c, seatStatus: 'granted' }), patchMyCharacter: (diff) => set((s) => (s.myCharacter ? { myCharacter: { ...s.myCharacter, ...diff } } : {})), setSeatStatus: (seatStatus) => set({ seatStatus }), 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 }), })); diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 5ccb032..0be578a 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { Character } from '@/lib/schemas'; +import type { RosterEntry } from '@/lib/sync/messages'; export type SessionRole = 'off' | 'gm' | 'player'; export type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'; @@ -24,10 +25,13 @@ interface SessionState { seatRequests: SeatRequest[]; /** when set, the app keeps a player connection alive across views/reloads */ joinIntent: JoinIntent | null; - set: (patch: Partial>) => void; + /** GM only: connected players (for targeting private shares) */ + roster: RosterEntry[]; + set: (patch: Partial>) => void; addSeatRequest: (r: SeatRequest) => void; removeSeatRequest: (playerId: string) => void; setJoinIntent: (j: JoinIntent | null) => void; + setRoster: (r: RosterEntry[]) => void; reset: () => void; } @@ -43,11 +47,13 @@ export const useSessionStore = create()( error: null, seatRequests: [], joinIntent: null, + roster: [], set: (patch) => set(patch), 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) })), 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 }) }, ),