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:
@@ -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 player.getByRole('button', { name: 'Open session panel' }).click();
|
||||||
await expect(player.getByText(/Who's here \(2\)/)).toBeVisible(); // host + player visible to everyone
|
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 gmCtx.close();
|
||||||
await playerCtx.close();
|
await playerCtx.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export function buildServer() {
|
|||||||
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;
|
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); });
|
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
|
||||||
|
|||||||
@@ -157,4 +157,36 @@ describe('RoomHub', () => {
|
|||||||
hub.privateState(player, 'not-the-secret', 'gm', { title: 'x', body: 'y' });
|
hub.privateState(player, 'not-the-secret', 'gm', { title: 'x', body: 'y' });
|
||||||
expect(lastOf(player, 'error')?.code).toBe('forbidden');
|
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<ServerMessage, { t: 'hosted' }>;
|
||||||
|
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<typeof fake>) => 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -222,6 +222,32 @@ export class RoomHub {
|
|||||||
for (const p of room.players) p.send(msg); // players only; the GM already sees it locally
|
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 {
|
disconnect(socket: Sender): void {
|
||||||
const conn = this.conns.get(socket);
|
const conn = this.conns.get(socket);
|
||||||
if (!conn) return;
|
if (!conn) return;
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
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';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global session panel (drawer). Everyone in a live session sees the same thing:
|
* Global session panel (docked on desktop, drawer on phones). Everyone sees the
|
||||||
* which session they're in, who else is here (and their character), and the live
|
* session, who's here, the live roll feed, and chat. The GM can whisper specific
|
||||||
* roll feed. The GM gets extra per-player actions (added in later sub-phases:
|
* players and (for them) review the persisted recap (rolls + chat saved to the
|
||||||
* whisper + targeted share). Opened from the header on any page.
|
* campaign).
|
||||||
*/
|
*/
|
||||||
export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const role = useSessionStore((s) => s.role);
|
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 intent = useSessionStore((s) => s.joinIntent);
|
||||||
const roster = useSessionStore((s) => s.roster);
|
const roster = useSessionStore((s) => s.roster);
|
||||||
const rolls = usePlayerSessionStore((s) => s.rolls);
|
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 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;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* On phones the panel floats over content with a backdrop; on desktop it docks as a column. */}
|
|
||||||
<div className="fixed inset-0 z-40 bg-black/40 md:hidden print:hidden" onClick={onClose} aria-hidden />
|
<div className="fixed inset-0 z-40 bg-black/40 md:hidden print:hidden" onClick={onClose} aria-hidden />
|
||||||
<aside
|
<aside
|
||||||
className="fixed right-0 top-0 z-50 flex h-full w-80 max-w-[88vw] flex-col border-l border-line bg-panel shadow-2xl print:hidden md:static md:z-auto md:h-auto md:w-80 md:max-w-none md:shrink-0 md:self-stretch md:shadow-none"
|
className="fixed right-0 top-0 z-50 flex h-full w-80 max-w-[88vw] flex-col border-l border-line bg-panel shadow-2xl print:hidden md:static md:z-auto md:h-auto md:w-80 md:max-w-none md:shrink-0 md:self-stretch md:shadow-none"
|
||||||
aria-label="Session panel"
|
aria-label="Session panel"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-line p-3">
|
<div className="flex items-center justify-between border-b border-line p-3">
|
||||||
<h2 className="font-display font-semibold text-ink">Session</h2>
|
<div className="min-w-0">
|
||||||
|
<h2 className="font-display font-semibold text-ink">Session</h2>
|
||||||
|
{inSession && code && (
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{isGm ? 'Hosting' : 'Joined'} <span className="font-mono text-accent">{code}</span> · <span className={status === 'connected' ? 'text-success' : ''}>{status === 'connected' ? 'live' : status}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink">✕</button>
|
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-3">
|
{!inSession && (
|
||||||
{inSession && code ? (
|
<p className="p-3 text-sm text-muted">You're not in a live session. Host one (GM) or open a join link to play together.</p>
|
||||||
<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>
|
{inSession && (
|
||||||
<div className={cn('text-xs', status === 'connected' ? 'text-success' : 'text-muted')}>● {status === 'connected' ? 'Connected' : status}</div>
|
<div className="border-b border-line p-3">
|
||||||
</div>
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Who's here ({roster.length})</h3>
|
||||||
) : (
|
<ul className="flex flex-wrap gap-1">
|
||||||
<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>
|
{roster.map((p) => (
|
||||||
|
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
|
||||||
|
{p.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex border-b border-line text-sm">
|
||||||
|
{(['chat', 'rolls', ...(isGm ? ['recap'] as const : [])] as const).map((t) => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} className={cn('flex-1 px-2 py-2 capitalize', tab === t ? 'border-b-2 border-accent text-ink' : 'text-muted hover:text-ink')}>
|
||||||
|
{t === 'recap' ? 'Recap' : t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
{tab === 'chat' && (
|
||||||
|
chat.length === 0 ? <p className="text-xs text-muted">No messages yet.</p> : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{chat.map((c) => (
|
||||||
|
<li key={c.id} className={cn('text-sm', c.scope === 'whisper' && 'rounded bg-elevated/60 px-1.5 py-0.5 italic')}>
|
||||||
|
<span className="font-semibold text-accent">{c.mine ? (c.to ? `You → ${nameOf(c.to)}` : 'You') : c.from}{c.scope === 'whisper' && !c.mine ? ' (whisper)' : ''}:</span>{' '}
|
||||||
|
<span className="text-ink">{c.body}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inSession && (
|
{tab === 'rolls' && (
|
||||||
<section>
|
rolls.length === 0 ? <p className="text-xs text-muted">No rolls yet.</p> : (
|
||||||
<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">
|
<ul className="space-y-1">
|
||||||
{rolls.map((r) => (
|
{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">
|
<li key={r.id} className="flex items-baseline gap-2 rounded-md border border-line bg-surface px-2 py-1 text-xs">
|
||||||
@@ -76,9 +120,42 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
|
{tab === 'recap' && isGm && (
|
||||||
|
(recap ?? []).length === 0 ? <p className="text-xs text-muted">No saved session activity yet.</p> : (
|
||||||
|
<ul className="space-y-0.5 text-xs">
|
||||||
|
{(recap ?? []).map((e) => (
|
||||||
|
<li key={e.id} className="flex gap-2">
|
||||||
|
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'}>{e.kind === 'roll' ? '🎲' : '💬'}</span>
|
||||||
|
<span className="text-muted">{e.from}:</span>
|
||||||
|
<span className="min-w-0 flex-1 text-ink">{e.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{inSession && tab !== 'recap' && (
|
||||||
|
<div className="border-t border-line p-2">
|
||||||
|
{isGm ? (
|
||||||
|
<select value={whisperTo} onChange={(e) => setWhisperTo(e.target.value)} className="mb-1 w-full rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink" aria-label="Chat recipient">
|
||||||
|
<option value="">Everyone (table)</option>
|
||||||
|
{targets.map((p) => <option key={p.playerId} value={p.playerId}>Whisper {p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<label className="mb-1 flex items-center gap-1 text-xs text-muted">
|
||||||
|
<input type="checkbox" checked={whisperTo === 'gm'} onChange={(e) => setWhisperTo(e.target.checked ? 'gm' : '')} /> Whisper the GM
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} placeholder={whisperTo ? 'Whisper…' : 'Message the table…'} aria-label="Chat message" className="flex-1" />
|
||||||
|
<Button size="sm" variant="primary" onClick={submit} disabled={!draft.trim()}>Send</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Character } from '@/lib/schemas/character';
|
|||||||
import type { Encounter } from '@/lib/schemas/encounter';
|
import type { Encounter } from '@/lib/schemas/encounter';
|
||||||
import type { DiceRoll } from '@/lib/schemas/dice';
|
import type { DiceRoll } from '@/lib/schemas/dice';
|
||||||
import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas/world';
|
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';
|
import { characterDefaults } from '@/lib/schemas/character';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +26,7 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
calendars!: EntityTable<Calendar, 'campaignId'>;
|
calendars!: EntityTable<Calendar, 'campaignId'>;
|
||||||
maps!: EntityTable<BattleMap, 'id'>;
|
maps!: EntityTable<BattleMap, 'id'>;
|
||||||
homebrew!: EntityTable<Homebrew, 'id'>;
|
homebrew!: EntityTable<Homebrew, 'id'>;
|
||||||
|
sessionLog!: EntityTable<SessionLogEntry, 'id'>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('ttrpg-manager');
|
super('ttrpg-manager');
|
||||||
@@ -122,6 +124,9 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
if (m.sightRadiusFeet === undefined) m.sightRadiusFeet = 60;
|
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,
|
calendarSchema,
|
||||||
battleMapSchema,
|
battleMapSchema,
|
||||||
homebrewSchema,
|
homebrewSchema,
|
||||||
|
sessionLogEntrySchema,
|
||||||
|
type SessionLogEntry,
|
||||||
type HomebrewKind,
|
type HomebrewKind,
|
||||||
type Homebrew,
|
type Homebrew,
|
||||||
type Campaign,
|
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 ----------------
|
// ---------------- Notes ----------------
|
||||||
export const notesRepo = {
|
export const notesRepo = {
|
||||||
listByCampaign(campaignId: string): Promise<Note[]> {
|
listByCampaign(campaignId: string): Promise<Note[]> {
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './character';
|
|||||||
export * from './encounter';
|
export * from './encounter';
|
||||||
export * from './dice';
|
export * from './dice';
|
||||||
export * from './world';
|
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) }),
|
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)
|
// targeted private handout (GM → one player)
|
||||||
z.object({ t: z.literal('privateState'), gmSecret: z.string(), targetPlayerId: z.string(), handout: privateHandoutSchema.nullable() }),
|
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>;
|
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('rollBroadcast'), ...rollBroadcastSchema.shape }),
|
||||||
z.object({ t: z.literal('privateHandout'), handout: privateHandoutSchema.nullable() }),
|
z.object({ t: z.literal('privateHandout'), handout: privateHandoutSchema.nullable() }),
|
||||||
z.object({ t: z.literal('roster'), players: z.array(rosterEntrySchema) }),
|
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>;
|
export type ServerMessage = z.infer<typeof serverMessageSchema>;
|
||||||
|
|||||||
+27
-2
@@ -1,9 +1,18 @@
|
|||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
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 type { Character } from '@/lib/schemas';
|
||||||
import { clientMessageSchema, serverMessageSchema, type ClientMessage, type Snapshot, type PartialCharacterDiff } from './messages';
|
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
|
* WebSocket transport for live sessions. The GM hosts and pushes player-safe
|
||||||
* snapshots; players join read-only and render received state. Reconnects with
|
* snapshots; players join read-only and render received state. Reconnects with
|
||||||
@@ -86,10 +95,14 @@ 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 });
|
||||||
|
persistLog('roll', msg.playerName, `${msg.label || msg.expression} → ${msg.total}`);
|
||||||
} else if (msg.t === 'roster') {
|
} else if (msg.t === 'roster') {
|
||||||
useSessionStore.getState().setRoster(msg.players);
|
useSessionStore.getState().setRoster(msg.players);
|
||||||
} else if (msg.t === 'privateHandout') {
|
} else if (msg.t === 'privateHandout') {
|
||||||
usePlayerSessionStore.getState().setPrivateHandout(msg.handout);
|
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') {
|
} 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;
|
||||||
@@ -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 {
|
export function broadcastGmRoll(label: string, expression: string, total: number, breakdown: string): void {
|
||||||
if (useSessionStore.getState().role === 'gm' && gmSecret) {
|
if (useSessionStore.getState().role === 'gm' && gmSecret) {
|
||||||
send({ t: 'gmRoll', gmSecret, label, expression, total, breakdown });
|
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 });
|
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). */
|
/** 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 {
|
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 });
|
if (useSessionStore.getState().role === 'gm' && gmSecret) send({ t: 'privateState', gmSecret, targetPlayerId, handout });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Character } from '@/lib/schemas';
|
|||||||
|
|
||||||
export type SeatStatus = 'none' | 'pending' | 'granted';
|
export type SeatStatus = 'none' | 'pending' | 'granted';
|
||||||
export interface RollEntry extends RollBroadcast { id: number }
|
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 {
|
interface PlayerSessionState {
|
||||||
snapshot: Snapshot | null;
|
snapshot: Snapshot | null;
|
||||||
@@ -14,6 +15,8 @@ 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[];
|
||||||
|
/** chat messages this client can see (table + whispers involving them) */
|
||||||
|
chat: ChatEntry[];
|
||||||
/** a handout/note the GM sent privately to this player only */
|
/** a handout/note the GM sent privately to this player only */
|
||||||
privateHandout: PrivateHandout | null;
|
privateHandout: PrivateHandout | null;
|
||||||
setSnapshot: (s: Snapshot) => void;
|
setSnapshot: (s: Snapshot) => void;
|
||||||
@@ -22,6 +25,7 @@ interface PlayerSessionState {
|
|||||||
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;
|
||||||
|
addChat: (c: Omit<ChatEntry, 'id'>) => void;
|
||||||
setPrivateHandout: (h: PrivateHandout | null) => void;
|
setPrivateHandout: (h: PrivateHandout | null) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -35,6 +39,7 @@ export const usePlayerSessionStore = create<PlayerSessionState>()((set) => ({
|
|||||||
myCharacter: null,
|
myCharacter: null,
|
||||||
seatStatus: 'none',
|
seatStatus: 'none',
|
||||||
rolls: [],
|
rolls: [],
|
||||||
|
chat: [],
|
||||||
privateHandout: null,
|
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 } })),
|
||||||
@@ -42,6 +47,7 @@ export const usePlayerSessionStore = create<PlayerSessionState>()((set) => ({
|
|||||||
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) })),
|
||||||
|
addChat: (c) => set((s) => ({ chat: [...s.chat, { ...c, id: ++rollSeq }].slice(-200) })),
|
||||||
setPrivateHandout: (privateHandout) => set({ privateHandout }),
|
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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user