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
+19
View File
@@ -0,0 +1,19 @@
import { Link } from '@tanstack/react-router';
import { useSessionStore } from '@/stores/sessionStore';
import { leaveRoom } from '@/lib/sync/wsSync';
/** Header chip shown while connected as a player, with a link back to the table + Leave. */
export function PlayerSessionBadge() {
const intent = useSessionStore((s) => s.joinIntent);
const status = useSessionStore((s) => s.status);
const role = useSessionStore((s) => s.role);
if (!intent || role === 'gm') return null;
const label = status === 'connected' ? 'Live' : status === 'connecting' ? 'Connecting…' : status;
return (
<div data-testid="player-live" className="flex items-center gap-1 rounded-md border border-success/40 bg-success/5 px-2 py-1 text-xs">
<span className={status === 'connected' ? 'text-success' : 'text-muted'}> {label}</span>
<Link to="/play" className="font-mono font-semibold text-accent" title="Open the table">{intent.joinCode}</Link>
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session"></button>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useSessionStore } from '@/stores/sessionStore';
import { joinSession } from '@/lib/sync/wsSync';
/**
* App-level player connection. While a joinIntent is set (persisted), keep the
* room connection alive no matter which view the player is on — so switching to
* their character sheet, the compendium, etc. never drops the live session.
* Mounted once in the app shell. Leaving is explicit (leaveRoom).
*/
export function usePlayerConnection(): void {
const code = useSessionStore((s) => s.joinIntent?.joinCode);
const password = useSessionStore((s) => s.joinIntent?.password);
useEffect(() => {
if (code) joinSession(code, password);
}, [code, password]);
}
+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]);