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:
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './character';
|
||||
export * from './encounter';
|
||||
export * from './dice';
|
||||
export * from './world';
|
||||
export * from './sessionLog';
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user