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:
@@ -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); });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user