From e562a270d17da6f3aa97e91893adbdf19a7c1409 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 17:15:38 +0200 Subject: [PATCH] P14b: session chat (table + whispers) + saved campaign recap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar gains tabs: Chat / Rolls / (GM) Recap. Chat supports table messages and private whispers (GM→player via a recipient picker; player→GM via a toggle). Server routes table to all and whispers to the target only (+server tests). - Rolls + chat are persisted to a per-campaign session recap (Dexie v13 sessionLog table + sessionLogRepo); the GM's "Recap" tab shows it (survives reloads/sessions). - wsSync: chat send/receive, optimistic local echo, persistLog for the GM. - Realtime e2e: GM table message reaches the player; the player's reply reaches the GM. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e-realtime/realtime.spec.ts | 8 ++ server/src/index.ts | 1 + server/src/rooms.test.ts | 32 ++++++ server/src/rooms.ts | 26 +++++ src/features/play/SessionSidebar.tsx | 159 ++++++++++++++++++++------- src/lib/db/db.ts | 5 + src/lib/db/repositories.ts | 18 +++ src/lib/schemas/index.ts | 1 + src/lib/schemas/sessionLog.ts | 13 +++ src/lib/sync/messages.ts | 3 + src/lib/sync/wsSync.ts | 29 ++++- src/stores/playerSessionStore.ts | 8 +- 12 files changed, 259 insertions(+), 44 deletions(-) create mode 100644 src/lib/schemas/sessionLog.ts diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index 9a3cc4d..94656c5 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -58,6 +58,14 @@ test('GM hosts a session; a player on another device sees live combat', async ({ await player.getByRole('button', { name: 'Open session panel' }).click(); await expect(player.getByText(/Who's here \(2\)/)).toBeVisible(); // host + player visible to everyone + // Chat: a GM table message reaches the player; the player's reply reaches the GM. + await gm.getByLabel('Chat message').fill('Welcome adventurers'); + await gm.getByRole('button', { name: 'Send' }).click(); + await expect(player.getByText('Welcome adventurers')).toBeVisible(); + await player.getByLabel('Chat message').fill('Hello GM'); + await player.getByRole('button', { name: 'Send' }).click(); + await expect(gm.getByText('Hello GM')).toBeVisible(); + await gmCtx.close(); await playerCtx.close(); }); diff --git a/server/src/index.ts b/server/src/index.ts index bdd5120..08b0dfd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -102,6 +102,7 @@ export function buildServer() { 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; + case 'chat': hub.chat(sender, m.body, m.to); break; } }); socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index aa1fb42..2898466 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -157,4 +157,36 @@ describe('RoomHub', () => { hub.privateState(player, 'not-the-secret', 'gm', { title: 'x', body: 'y' }); expect(lastOf(player, 'error')?.code).toBe('forbidden'); }); + + it('routes chat: table to all, whisper to the target 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 p1id = lastOf(gm, 'roster')!.players.filter((p) => p.playerId !== 'gm')[0]!.playerId; + const chatCount = (s: ReturnType) => s.msgs.filter((m) => m.t === 'chat').length; + + // GM table → both players + hub.chat(gm, 'hello table'); + expect(lastOf(p1, 'chat')).toMatchObject({ from: 'GM', body: 'hello table', scope: 'table' }); + expect(lastOf(p2, 'chat')).toMatchObject({ scope: 'table' }); + + // GM whisper to p1 → only p1 + const p2c = chatCount(p2); + hub.chat(gm, 'psst', p1id); + expect(lastOf(p1, 'chat')).toMatchObject({ from: 'GM', body: 'psst', scope: 'whisper' }); + expect(chatCount(p2)).toBe(p2c); + + // Player table → GM + the other player (not the sender) + hub.chat(p1, 'hi all'); + expect(lastOf(gm, 'chat')).toMatchObject({ body: 'hi all', scope: 'table' }); + expect(lastOf(p2, 'chat')).toMatchObject({ body: 'hi all', scope: 'table' }); + + // Player whisper → GM only + const p2c2 = chatCount(p2); + hub.chat(p1, 'private q', 'gm'); + expect(lastOf(gm, 'chat')).toMatchObject({ body: 'private q', scope: 'whisper' }); + expect(chatCount(p2)).toBe(p2c2); + }); }); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index f07444d..507625c 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -222,6 +222,32 @@ export class RoomHub { for (const p of room.players) p.send(msg); // players only; the GM already sees it locally } + /** Table chat (to all) or a whisper. GM `to` = target player; player `to` set = whisper to GM. */ + chat(socket: Sender, body: string, to?: string): void { + const conn = this.conns.get(socket); + const room = conn ? this.rooms.get(conn.roomId) : undefined; + if (!conn || !room) return; + room.lastActivity = this.now(); + if (conn.role === 'gm') { + if (to) { + const msg = { t: 'chat', from: 'GM', body, scope: 'whisper' } as const; + for (const [s, c] of this.conns) if (c.roomId === room.roomId && c.playerId === to) s.send(msg); + } else { + const msg = { t: 'chat', from: 'GM', body, scope: 'table' } as const; + for (const p of room.players) p.send(msg); + } + } else { + const from = room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}`; + if (to) { + room.gm?.send({ t: 'chat', from, body, scope: 'whisper' }); + } else { + const msg = { t: 'chat', from, body, scope: 'table' } as const; + room.gm?.send(msg); + for (const p of room.players) if (p !== socket) p.send(msg); + } + } + } + disconnect(socket: Sender): void { const conn = this.conns.get(socket); if (!conn) return; diff --git a/src/features/play/SessionSidebar.tsx b/src/features/play/SessionSidebar.tsx index 3fe1eda..6519812 100644 --- a/src/features/play/SessionSidebar.tsx +++ b/src/features/play/SessionSidebar.tsx @@ -1,12 +1,20 @@ +import { useState } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; import { useSessionStore } from '@/stores/sessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore'; +import { useUiStore } from '@/stores/uiStore'; +import { sendChat } from '@/lib/sync/wsSync'; +import { sessionLogRepo } from '@/lib/db/repositories'; +import type { SessionLogEntry } from '@/lib/schemas'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; 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. + * Global session panel (docked on desktop, drawer on phones). Everyone sees the + * session, who's here, the live roll feed, and chat. The GM can whisper specific + * players and (for them) review the persisted recap (rolls + chat saved to the + * campaign). */ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () => void }) { const role = useSessionStore((s) => s.role); @@ -15,58 +23,94 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () = 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 chat = usePlayerSessionStore((s) => s.chat); + const activeCampaignId = useUiStore((s) => s.activeCampaignId); + + const isGm = role === 'gm'; + const code = isGm ? joinCode : intent?.joinCode; const inSession = role !== 'off' || !!intent; + const [tab, setTab] = useState<'chat' | 'rolls' | 'recap'>('chat'); + const [draft, setDraft] = useState(''); + const [whisperTo, setWhisperTo] = useState(''); // GM: playerId; player: 'gm' to whisper the GM + const targets = roster.filter((p) => p.playerId !== 'gm'); + const nameOf = (pid: string) => (pid === 'gm' ? 'GM' : roster.find((p) => p.playerId === pid)?.name ?? 'player'); + + const recap = useLiveQuery( + () => (isGm && activeCampaignId ? sessionLogRepo.recent(activeCampaignId, 200) : Promise.resolve([] as SessionLogEntry[])), + [isGm, activeCampaignId], + [] as SessionLogEntry[], + ); + + const submit = () => { + if (!draft.trim()) return; + sendChat(draft, whisperTo || undefined); + setDraft(''); + }; + if (!open) return null; return ( <> - {/* On phones the panel floats over content with a backdrop; on desktop it docks as a column. */}
); diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index 38529dd..63e6be4 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -4,6 +4,7 @@ import type { Character } from '@/lib/schemas/character'; import type { Encounter } from '@/lib/schemas/encounter'; import type { DiceRoll } from '@/lib/schemas/dice'; import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas/world'; +import type { SessionLogEntry } from '@/lib/schemas/sessionLog'; import { characterDefaults } from '@/lib/schemas/character'; /** @@ -25,6 +26,7 @@ export class TtrpgDatabase extends Dexie { calendars!: EntityTable; maps!: EntityTable; homebrew!: EntityTable; + sessionLog!: EntityTable; constructor() { super('ttrpg-manager'); @@ -122,6 +124,9 @@ export class TtrpgDatabase extends Dexie { if (m.sightRadiusFeet === undefined) m.sightRadiusFeet = 60; }); }); + + // v13 — P14b: persisted live-session recap (rolls + chat) per campaign. + this.version(13).stores({ sessionLog: 'id, campaignId, ts' }); } } diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 4f4f55e..3d51f05 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -11,6 +11,8 @@ import { calendarSchema, battleMapSchema, homebrewSchema, + sessionLogEntrySchema, + type SessionLogEntry, type HomebrewKind, type Homebrew, type Campaign, @@ -216,6 +218,22 @@ export const diceRepo = { }, }; +// ---------------- Session recap (rolls + chat, GM-persisted) ---------------- +export const sessionLogRepo = { + async recent(campaignId: string, limit = 200): Promise { + const all = await db.sessionLog.where('campaignId').equals(campaignId).toArray(); + return all.sort((a, b) => a.ts.localeCompare(b.ts)).slice(-limit); + }, + async add(entry: Omit & { ts?: string }): Promise { + const record = sessionLogEntrySchema.parse({ ...entry, id: newId(), ts: entry.ts ?? now() }); + await db.sessionLog.add(record); + }, + async clear(campaignId?: string): Promise { + if (campaignId) await db.sessionLog.where('campaignId').equals(campaignId).delete(); + else await db.sessionLog.clear(); + }, +}; + // ---------------- Notes ---------------- export const notesRepo = { listByCampaign(campaignId: string): Promise { diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts index 46b7ed2..8618778 100644 --- a/src/lib/schemas/index.ts +++ b/src/lib/schemas/index.ts @@ -4,3 +4,4 @@ export * from './character'; export * from './encounter'; export * from './dice'; export * from './world'; +export * from './sessionLog'; diff --git a/src/lib/schemas/sessionLog.ts b/src/lib/schemas/sessionLog.ts new file mode 100644 index 0000000..e60ba54 --- /dev/null +++ b/src/lib/schemas/sessionLog.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +/** A persisted entry in a campaign's live-session recap (rolls + chat). */ +export const sessionLogEntrySchema = z.object({ + id: z.string(), + campaignId: z.string(), + kind: z.enum(['roll', 'chat']), + from: z.string(), + text: z.string(), + scope: z.enum(['table', 'whisper']).optional(), + ts: z.string(), // ISO timestamp +}); +export type SessionLogEntry = z.infer; diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index a0afec2..cbe25c8 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -127,6 +127,8 @@ export const clientMessageSchema = z.discriminatedUnion('t', [ 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() }), + // chat: table message (no `to`) or whisper. GM `to` = target playerId; player `to` set = whisper to GM. + z.object({ t: z.literal('chat'), body: z.string().min(1).max(2000), to: z.string().optional() }), ]); export type ClientMessage = z.infer; @@ -143,5 +145,6 @@ export const serverMessageSchema = z.discriminatedUnion('t', [ 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) }), + z.object({ t: z.literal('chat'), from: z.string(), body: z.string(), scope: z.enum(['table', 'whisper']), to: z.string().optional() }), ]); export type ServerMessage = z.infer; diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts index 2963c68..6c76eb9 100644 --- a/src/lib/sync/wsSync.ts +++ b/src/lib/sync/wsSync.ts @@ -1,9 +1,18 @@ import { useSessionStore } from '@/stores/sessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore'; -import { charactersRepo } from '@/lib/db/repositories'; +import { useUiStore } from '@/stores/uiStore'; +import { charactersRepo, sessionLogRepo } from '@/lib/db/repositories'; import type { Character } from '@/lib/schemas'; import { clientMessageSchema, serverMessageSchema, type ClientMessage, type Snapshot, type PartialCharacterDiff } from './messages'; +/** GM-only: persist a roll/chat line to the campaign's session recap. */ +function persistLog(kind: 'roll' | 'chat', from: string, text: string, scope?: 'table' | 'whisper'): void { + if (useSessionStore.getState().role !== 'gm') return; + const campaignId = useUiStore.getState().activeCampaignId; + if (!campaignId) return; + void sessionLogRepo.add({ campaignId, kind, from, text, ...(scope ? { scope } : {}) }); +} + /** * WebSocket transport for live sessions. The GM hosts and pushes player-safe * snapshots; players join read-only and render received state. Reconnects with @@ -86,10 +95,14 @@ 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 }); + persistLog('roll', msg.playerName, `${msg.label || msg.expression} → ${msg.total}`); } else if (msg.t === 'roster') { useSessionStore.getState().setRoster(msg.players); } else if (msg.t === 'privateHandout') { usePlayerSessionStore.getState().setPrivateHandout(msg.handout); + } else if (msg.t === 'chat') { + usePlayerSessionStore.getState().addChat({ from: msg.from, body: msg.body, scope: msg.scope, ...(msg.to ? { to: msg.to } : {}) }); + persistLog('chat', msg.from, msg.body, msg.scope); } else if (msg.t === 'error') { session.set({ status: 'error', error: msg.message }); if (msg.code === 'no-room' || msg.code === 'bad-password') manualStop = true; @@ -152,11 +165,23 @@ export function sendPlayerRoll(characterId: string, label: string, expression: s 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 }); - // mirror into the local table feed so the GM's recap includes their own public rolls + // mirror into the local table feed + recap so the GM's history includes their own public rolls usePlayerSessionStore.getState().addRoll({ playerName: 'GM', label, expression, total, breakdown }); + persistLog('roll', 'GM', `${label || expression} → ${total}`); } } +/** Send a chat message. `to` set = whisper (GM → that playerId; player → the GM). */ +export function sendChat(body: string, to?: string): void { + const text = body.trim(); + if (!text) return; + send({ t: 'chat', body: text, ...(to ? { to } : {}) }); + const scope = to ? 'whisper' : 'table'; + // optimistic local echo (the server doesn't echo to the sender) + usePlayerSessionStore.getState().addChat({ from: 'You', body: text, scope, mine: true, ...(to ? { to } : {}) }); + persistLog('chat', 'GM', text, scope); +} + /** 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 }); diff --git a/src/stores/playerSessionStore.ts b/src/stores/playerSessionStore.ts index 1855126..2e7c9a2 100644 --- a/src/stores/playerSessionStore.ts +++ b/src/stores/playerSessionStore.ts @@ -4,6 +4,7 @@ import type { Character } from '@/lib/schemas'; export type SeatStatus = 'none' | 'pending' | 'granted'; export interface RollEntry extends RollBroadcast { id: number } +export interface ChatEntry { id: number; from: string; body: string; scope: 'table' | 'whisper'; to?: string; mine?: boolean } interface PlayerSessionState { snapshot: Snapshot | null; @@ -14,6 +15,8 @@ interface PlayerSessionState { seatStatus: SeatStatus; /** recent dice-roll broadcasts from the whole table */ rolls: RollEntry[]; + /** chat messages this client can see (table + whispers involving them) */ + chat: ChatEntry[]; /** a handout/note the GM sent privately to this player only */ privateHandout: PrivateHandout | null; setSnapshot: (s: Snapshot) => void; @@ -22,6 +25,7 @@ interface PlayerSessionState { patchMyCharacter: (diff: Partial) => void; setSeatStatus: (s: SeatStatus) => void; addRoll: (r: RollBroadcast) => void; + addChat: (c: Omit) => void; setPrivateHandout: (h: PrivateHandout | null) => void; reset: () => void; } @@ -35,6 +39,7 @@ export const usePlayerSessionStore = create()((set) => ({ myCharacter: null, seatStatus: 'none', rolls: [], + chat: [], privateHandout: null, setSnapshot: (snapshot) => set({ snapshot }), setImage: (id, dataUrl) => set((s) => ({ images: { ...s.images, [id]: dataUrl } })), @@ -42,6 +47,7 @@ export const usePlayerSessionStore = create()((set) => ({ 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) })), + addChat: (c) => set((s) => ({ chat: [...s.chat, { ...c, id: ++rollSeq }].slice(-200) })), setPrivateHandout: (privateHandout) => set({ privateHandout }), - reset: () => set({ snapshot: null, images: {}, myCharacter: null, seatStatus: 'none', rolls: [], privateHandout: null }), + reset: () => set({ snapshot: null, images: {}, myCharacter: null, seatStatus: 'none', rolls: [], chat: [], privateHandout: null }), }));