Files
ttrpg_manager/src/lib/sync/wsSync.ts
T
NilsBriggen b2cf62f642 P14a: global session sidebar + roster + private-channel backend
- Session sidebar (header "☰ Session" toggle, mobile drawer) visible to GM AND
  players: which session you're in, who's here (GM + players), and the live roll
  feed. The GM's own public rolls now mirror into the local feed too.
- Server roster broadcast to everyone (sendRoster on join/seatGrant/disconnect/
  host-resume); includes the GM. Players now see the roster, not just the GM.
- Targeted private channel (backend, wired to UI next): privateState (GM→server→
  one player only) + privateHandout; image sent INLINE so non-recipients never get
  it. roster/privateHandout schemas + stores + wsSync handlers + senders.
- Adversarial-review fixes: GM included in roster; roster refreshes on GM
  disconnect; privateState returns a forbidden error on auth failure. +3 server tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:01:15 +02:00

191 lines
8.3 KiB
TypeScript

import { useSessionStore } from '@/stores/sessionStore';
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { charactersRepo } from '@/lib/db/repositories';
import type { Character } from '@/lib/schemas';
import { clientMessageSchema, serverMessageSchema, type ClientMessage, type Snapshot, type PartialCharacterDiff } from './messages';
/**
* WebSocket transport for live sessions. The GM hosts and pushes player-safe
* snapshots; players join read-only and render received state. Reconnects with
* backoff and re-hosts/re-joins automatically. No-op-safe when offline.
*/
let ws: WebSocket | null = null;
let gmSecret = '';
let intent: { kind: 'gm'; campaignId: string; password?: string } | { kind: 'player'; joinCode: string; password?: string } | null = null;
let lastSnapshot: Snapshot | null = null;
/** images sent this session (map background + token icons / portraits), by id */
const sentImages = new Map<string, string>();
let reconnectMs = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let manualStop = false;
function wsUrl(): string {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${location.host}/ws`;
}
function send(msg: ClientMessage): void {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(clientMessageSchema.parse(msg)));
}
function connect(): void {
if (!intent) return;
useSessionStore.getState().set({ status: 'connecting', error: null });
let socket: WebSocket;
try {
socket = new WebSocket(wsUrl());
} catch {
useSessionStore.getState().set({ status: 'error', error: 'Could not open connection.' });
return;
}
ws = socket;
socket.onopen = () => {
reconnectMs = 1000;
if (!intent) return;
if (intent.kind === 'gm') send({ t: 'host', campaignId: intent.campaignId, ...(intent.password ? { password: intent.password } : {}), ...(gmSecret ? { resume: gmSecret } : {}) });
else send({ t: 'join', joinCode: intent.joinCode, ...(intent.password ? { password: intent.password } : {}) });
};
socket.onmessage = (ev) => {
let parsed;
try { parsed = serverMessageSchema.safeParse(JSON.parse(String(ev.data))); } catch { return; }
if (!parsed.success) return;
const msg = parsed.data;
const session = useSessionStore.getState();
if (msg.t === 'hosted') {
gmSecret = msg.gmSecret;
session.set({ role: 'gm', status: 'connected', roomId: msg.roomId, joinCode: msg.joinCode, error: null });
// (re)send current state on (re)connect
if (lastSnapshot) send({ t: 'state', gmSecret, snapshot: lastSnapshot });
for (const [id, dataUrl] of sentImages) send({ t: 'image', gmSecret, id, dataUrl });
} else if (msg.t === 'joined') {
session.set({ role: 'player', status: 'connected', roomId: msg.roomId, error: null });
} else if (msg.t === 'snapshot') {
usePlayerSessionStore.getState().setSnapshot(msg.snapshot);
// fetch the map image if we don't have it cached
const id = msg.snapshot.mapImageId;
if (id && !usePlayerSessionStore.getState().images[id]) send({ t: 'requestImage', id });
} else if (msg.t === 'mapImage') {
usePlayerSessionStore.getState().setImage(msg.id, msg.dataUrl);
} else if (msg.t === 'seatRequest') {
if (session.role === 'gm') useSessionStore.getState().addSeatRequest({ playerId: msg.playerId, characterId: msg.characterId, ...(msg.offlineSnapshot ? { offlineSnapshot: msg.offlineSnapshot } : {}) });
} else if (msg.t === 'seatGranted') {
usePlayerSessionStore.getState().setMyCharacter(msg.character);
} else if (msg.t === 'playerPatched') {
if (session.role === 'gm') {
const d = msg.diff;
void charactersRepo.update(msg.characterId, {
...(d.hp ? { hp: d.hp } : {}),
...(d.conditions ? { conditions: d.conditions } : {}),
...(d.spellcasting ? { spellcasting: d.spellcasting } : {}),
...(d.resources ? { resources: d.resources } : {}),
...(d.defenses ? { defenses: d.defenses } : {}),
});
}
} else if (msg.t === 'rollBroadcast') {
usePlayerSessionStore.getState().addRoll({ playerName: msg.playerName, label: msg.label, expression: msg.expression, total: msg.total, breakdown: msg.breakdown });
} else if (msg.t === 'roster') {
useSessionStore.getState().setRoster(msg.players);
} else if (msg.t === 'privateHandout') {
usePlayerSessionStore.getState().setPrivateHandout(msg.handout);
} else if (msg.t === 'error') {
session.set({ status: 'error', error: msg.message });
if (msg.code === 'no-room' || msg.code === 'bad-password') manualStop = true;
}
};
socket.onclose = () => {
ws = null;
if (manualStop || !intent) return;
useSessionStore.getState().set({ status: 'connecting' });
reconnectTimer = setTimeout(connect, reconnectMs);
reconnectMs = Math.min(15000, reconnectMs * 2);
};
socket.onerror = () => { try { socket.close(); } catch { /* ignore */ } };
}
export function hostSession(campaignId: string, password?: string): void {
manualStop = false;
intent = { kind: 'gm', campaignId, ...(password ? { password } : {}) };
connect();
}
export function joinSession(joinCode: string, password?: string): void {
manualStop = false;
intent = { kind: 'player', joinCode: joinCode.trim().toUpperCase(), ...(password ? { password } : {}) };
usePlayerSessionStore.getState().reset();
connect();
}
export function pushSnapshot(snapshot: Snapshot): void {
lastSnapshot = snapshot;
if (useSessionStore.getState().role === 'gm') send({ t: 'state', gmSecret, snapshot });
}
export function pushImage(id: string, dataUrl: string): void {
if (sentImages.get(id) === dataUrl) return; // already sent (dedup by content)
sentImages.set(id, dataUrl);
if (useSessionStore.getState().role === 'gm') send({ t: 'image', gmSecret, id, dataUrl });
}
// ---- two-way play ----
/** Player: request to control a character (optionally bundling offline edits). */
export function claimSeat(characterId: string, offlineSnapshot?: Character): void {
usePlayerSessionStore.getState().setSeatStatus('pending');
send({ t: 'claimSeat', characterId, ...(offlineSnapshot ? { offlineSnapshot } : {}) });
}
/** Player: push a live edit to their own sheet (GM persists it). */
export function sendPlayerPatch(characterId: string, diff: PartialCharacterDiff): void {
send({ t: 'playerPatch', characterId, diff });
}
/** Player: broadcast a dice roll to the whole table. */
export function sendPlayerRoll(characterId: string, label: string, expression: string, total: number, breakdown: string): void {
send({ t: 'playerRoll', characterId, label, expression, total, breakdown });
}
/** GM: broadcast one of their own (public) rolls to the players. No-op unless hosting. */
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
usePlayerSessionStore.getState().addRoll({ playerName: 'GM', label, expression, total, breakdown });
}
}
/** 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 });
}
/** GM: approve a seat request, handing the player their authoritative sheet. */
export function grantSeat(targetPlayerId: string, character: Character): void {
if (useSessionStore.getState().role === 'gm') {
send({ t: 'seatGrant', gmSecret, targetPlayerId, character });
useSessionStore.getState().removeSeatRequest(targetPlayerId);
}
}
/** Explicitly leave a player session: clear the persisted intent + disconnect. */
export function leaveRoom(): void {
useSessionStore.getState().setJoinIntent(null);
stopSession();
}
export function stopSession(): void {
manualStop = true;
intent = null;
gmSecret = '';
lastSnapshot = null;
sentImages.clear();
if (reconnectTimer) clearTimeout(reconnectTimer);
try { ws?.close(); } catch { /* ignore */ }
ws = null;
useSessionStore.getState().reset();
usePlayerSessionStore.getState().reset();
}