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
+8
View File
@@ -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();
});
+1
View File
@@ -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); });
+32
View File
@@ -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<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);
});
});
+26
View File
@@ -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;
+118 -41
View File
@@ -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. */}
<div className="fixed inset-0 z-40 bg-black/40 md:hidden print:hidden" onClick={onClose} aria-hidden />
<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"
aria-label="Session panel"
>
<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>
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-3">
{inSession && code ? (
<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>
<div className={cn('text-xs', status === 'connected' ? 'text-success' : 'text-muted')}> {status === 'connected' ? 'Connected' : status}</div>
</div>
) : (
<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>
{!inSession && (
<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>
)}
{inSession && (
<div className="border-b border-line p-3">
<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">
{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 && (
<section>
<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>
) : (
{tab === 'rolls' && (
rolls.length === 0 ? <p className="text-xs text-muted">No rolls yet.</p> : (
<ul className="space-y-1">
{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">
@@ -76,9 +120,42 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
</li>
))}
</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>
{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>
</>
);
+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 });
+7 -1
View File
@@ -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<Character>) => void;
setSeatStatus: (s: SeatStatus) => void;
addRoll: (r: RollBroadcast) => void;
addChat: (c: Omit<ChatEntry, 'id'>) => void;
setPrivateHandout: (h: PrivateHandout | null) => void;
reset: () => void;
}
@@ -35,6 +39,7 @@ export const usePlayerSessionStore = create<PlayerSessionState>()((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<PlayerSessionState>()((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 }),
}));