b2cf62f642
- 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>
191 lines
8.3 KiB
TypeScript
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();
|
|
}
|