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
+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);
});
});