P12: persistent live session across navigation

- Player room connection lifted out of the /play route to an app-level hook
  (usePlayerConnection in RootLayout), keyed by a persisted joinIntent — so a
  player stays connected while browsing their sheet, the compendium, anywhere,
  and a reload reconnects automatically.
- Header "● Live: CODE · Leave" badge (PlayerSessionBadge); leaving is explicit
  (leaveRoom clears intent + disconnects). /play renders the table from the store.
- Realtime e2e: player navigates away, badge persists, returns to a synced table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:24:31 +02:00
parent 5527b7aa7a
commit d4ae60f199
7 changed files with 99 additions and 25 deletions
+16 -11
View File
@@ -3,7 +3,7 @@ import { useLiveQuery } from 'dexie-react-hooks';
import type { Campaign, Character } from '@/lib/schemas';
import { localSync } from '@/lib/sync';
import { buildSnapshot } from '@/lib/sync/snapshot';
import { joinSession, stopSession, claimSeat, sendPlayerPatch } from '@/lib/sync/wsSync';
import { claimSeat, sendPlayerPatch } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore';
import { useSessionStore } from '@/stores/sessionStore';
@@ -23,8 +23,16 @@ function roomParam(): string | null {
}
export function PlayerViewPage() {
// A player joining via link (?room=CODE) has no local campaign — networked mode.
if (roomParam()) return <NetworkedPlayerView room={roomParam()!} />;
// Joining via link (?room=CODE) records a persistent join intent; the app-level
// connection (RootLayout) keeps it alive across navigation. We render the
// networked table whenever an intent exists, even without ?room in the URL.
const joinIntent = useSessionStore((s) => s.joinIntent);
const room = roomParam();
useEffect(() => {
if (room && room !== joinIntent?.joinCode) useSessionStore.getState().setJoinIntent({ joinCode: room });
}, [room, joinIntent?.joinCode]);
if (joinIntent || room) return <NetworkedPlayerView />;
return <RequireCampaign>{(c) => <LocalPlayerView campaign={c} />}</RequireCampaign>;
}
@@ -64,8 +72,10 @@ function LocalPlayerView({ campaign }: { campaign: Campaign }) {
);
}
/** Networked player view — joins a GM's session over the network. */
function NetworkedPlayerView({ room }: { room: string }) {
/** Networked player view — renders the live table. The connection itself is kept
* alive app-wide by usePlayerConnection (RootLayout), so this view is just UI. */
function NetworkedPlayerView() {
const room = useSessionStore((s) => s.joinIntent?.joinCode) ?? '';
const status = useSessionStore((s) => s.status);
const error = useSessionStore((s) => s.error);
const snapshot = usePlayerSessionStore((s) => s.snapshot);
@@ -79,16 +89,11 @@ function NetworkedPlayerView({ room }: { room: string }) {
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
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); }
if (pw) { useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); setNeedPw(false); }
}
}, [error, needPw, room]);