Phase 18: internet realtime collaboration (GM-authoritative) + server
- Shared Zod wire protocol (src/lib/sync/messages.ts): hosted/joined/snapshot/ mapImage/error; player-safe projections only (enemy HP masked on the GM before broadcast via src/lib/combat/playerProjection.ts). - wsSync adapter (src/lib/sync/wsSync.ts) behind the existing SyncAdapter seam: GM hostSession + debounced snapshot broadcast (useSessionBroadcaster), player joinSession into an ephemeral playerSessionStore, exponential-backoff reconnect with seamless room resume (GM secret). localSync remains the offline default. - buildSnapshot (src/lib/sync/snapshot.ts) reuses the same projection as the local /play view, guaranteeing parity. Player view refactored into shared PlayerBoards; /play?room=CODE = networked read-only mode. - SessionControl in the header: Host (optional password) → shareable room code/link. - Server (server/): Fastify + @fastify/websocket + @fastify/static, in-memory GM-authoritative rooms (crypto room id/secret hashed, optional join password, TTL sweep, players strictly read-only), origin allowlist, per-frame size cap + rate limit, Zod validation. esbuild bundle (server/build.mjs), tsconfig.server.json. - Dockerfile (multi-stage) + deploy/ttrpg.compose.yml (own project on external 'proxy' net, Traefik labels for ttrpg.briggen.dev, non-root, no-new-privileges). CSP connect-src now allows same-origin wss:. Tests: protocol round-trip, room hub (auth/password/resume/TTL/read-only), enemy masking, Fastify+ws integration (host→join→snapshot, player push rejected), and a two-context Playwright realtime spec (separate config) — GM hosts, player device sees live combat with masked enemy HP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,129 +1,97 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import { localSync } from '@/lib/sync';
|
||||
import { enemyStatus } from '@/lib/combat/playerProjection';
|
||||
import { toPlayerProjection } from '@/lib/map';
|
||||
import { buildSnapshot } from '@/lib/sync/snapshot';
|
||||
import { joinSession, stopSession } from '@/lib/sync/wsSync';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { useCalendar, useMaps } from '@/features/world/hooks';
|
||||
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { PlayerMapView } from './PlayerMapView';
|
||||
import { PlayerBoards } from './PlayerBoards';
|
||||
|
||||
export function PlayerViewPage() {
|
||||
return <RequireCampaign>{(c) => <PlayerView campaign={c} />}</RequireCampaign>;
|
||||
function roomParam(): string | null {
|
||||
return new URLSearchParams(window.location.search).get('room');
|
||||
}
|
||||
|
||||
function PlayerView({ campaign }: { campaign: Campaign }) {
|
||||
export function PlayerViewPage() {
|
||||
// A player joining via link (?room=CODE) has no local campaign — networked mode.
|
||||
if (roomParam()) return <NetworkedPlayerView room={roomParam()!} />;
|
||||
return <RequireCampaign>{(c) => <LocalPlayerView campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
function Shell({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
|
||||
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
return (
|
||||
<Page>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-accent">{title}</h1>
|
||||
<p className="text-sm text-muted">{subtitle}</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden">⛶ Fullscreen</Button>
|
||||
</div>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
/** Local single-device player view (GM's own screen). */
|
||||
function LocalPlayerView({ campaign }: { campaign: Campaign }) {
|
||||
const characters = useCharacters(campaign.id);
|
||||
const encounters = useEncounters(campaign.id);
|
||||
const calendar = useCalendar(campaign.id);
|
||||
const maps = useMaps(campaign.id);
|
||||
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||
const activeMapId = useUiStore((s) => s.activeMapId);
|
||||
const activeMap = maps.find((mp) => mp.id === activeMapId) ?? null;
|
||||
const sys = getSystem(campaign.system);
|
||||
|
||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||
const encounter =
|
||||
encounters.find((e) => e.id === activeEncounterId && e.status === 'active') ??
|
||||
encounters.find((e) => e.status === 'active');
|
||||
|
||||
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
const snapshot = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId });
|
||||
const image = maps.find((m) => m.id === activeMapId)?.image;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-accent">{campaign.name}</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Player View{calendar ? ` · in-world day ${calendar.currentDay}` : ''} ·{' '}
|
||||
<span className="uppercase">{localSync.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden">⛶ Fullscreen</Button>
|
||||
</div>
|
||||
|
||||
{activeMap && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
|
||||
<PlayerMapView map={toPlayerProjection(activeMap)} image={activeMap.image} maxWidth={1100} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Party */}
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Party</h2>
|
||||
{pcs.length === 0 ? (
|
||||
<p className="text-sm text-muted">No player characters.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pcs.map((c) => {
|
||||
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
|
||||
return (
|
||||
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="font-display text-lg font-semibold text-ink">{c.name}</span>
|
||||
<span className="text-sm text-muted">
|
||||
Lv {c.level} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-3 overflow-hidden rounded-full bg-surface">
|
||||
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 text-right text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
|
||||
{c.conditions.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{c.conditions.map((cond, i) => (
|
||||
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Initiative */}
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">
|
||||
Initiative {encounter ? `· Round ${encounter.round}` : ''}
|
||||
</h2>
|
||||
{!encounter ? (
|
||||
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
|
||||
) : (
|
||||
<ol className="space-y-1">
|
||||
{encounter.combatants.map((c, idx) => {
|
||||
const isCurrent = idx === encounter.turnIndex;
|
||||
const isPC = c.kind === 'pc';
|
||||
const status = enemyStatus(c);
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md border px-3 py-2',
|
||||
isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel',
|
||||
)}
|
||||
>
|
||||
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
|
||||
<span className="flex-1 text-ink">{c.name}{isCurrent && <span className="ml-2 text-xs text-accent">◀ turn</span>}</span>
|
||||
{isPC ? (
|
||||
<span className="text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
|
||||
) : (
|
||||
<span className={cn('text-sm font-medium', status.cls)}>{status.label}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
<Shell title={campaign.name} subtitle={`Player View${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''} · ${localSync.status.toUpperCase()}`}>
|
||||
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Networked player view — joins a GM's session over the network. */
|
||||
function NetworkedPlayerView({ room }: { room: string }) {
|
||||
const status = useSessionStore((s) => s.status);
|
||||
const error = useSessionStore((s) => s.error);
|
||||
const snapshot = usePlayerSessionStore((s) => s.snapshot);
|
||||
const images = usePlayerSessionStore((s) => s.images);
|
||||
const [needPw, setNeedPw] = useState(false);
|
||||
const joined = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
joinSession(room);
|
||||
return () => stopSession();
|
||||
}, [room]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error?.toLowerCase().includes('password') && !needPw) {
|
||||
setNeedPw(true);
|
||||
const pw = window.prompt('This session needs a password:')?.trim();
|
||||
if (pw) { joinSession(room, pw); setNeedPw(false); }
|
||||
}
|
||||
}, [error, needPw, room]);
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
|
||||
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting…</p>}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
|
||||
void joined;
|
||||
return (
|
||||
<Shell title={snapshot.campaignName} subtitle={`Player View · live${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''}`}>
|
||||
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user