P14b: session chat (table + whispers) + saved campaign recap

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:15:38 +02:00
parent 4cb834ad6c
commit e562a270d1
12 changed files with 259 additions and 44 deletions
+5
View File
@@ -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<Calendar, 'campaignId'>;
maps!: EntityTable<BattleMap, 'id'>;
homebrew!: EntityTable<Homebrew, 'id'>;
sessionLog!: EntityTable<SessionLogEntry, 'id'>;
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' });
}
}
+18
View File
@@ -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<SessionLogEntry[]> {
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<SessionLogEntry, 'id' | 'ts'> & { ts?: string }): Promise<void> {
const record = sessionLogEntrySchema.parse({ ...entry, id: newId(), ts: entry.ts ?? now() });
await db.sessionLog.add(record);
},
async clear(campaignId?: string): Promise<void> {
if (campaignId) await db.sessionLog.where('campaignId').equals(campaignId).delete();
else await db.sessionLog.clear();
},
};
// ---------------- Notes ----------------
export const notesRepo = {
listByCampaign(campaignId: string): Promise<Note[]> {
+1
View File
@@ -4,3 +4,4 @@ export * from './character';
export * from './encounter';
export * from './dice';
export * from './world';
export * from './sessionLog';
+13
View File
@@ -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<typeof sessionLogEntrySchema>;
+3
View File
@@ -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<typeof clientMessageSchema>;
@@ -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<typeof serverMessageSchema>;
+27 -2
View File
@@ -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 });