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
+8
View File
@@ -38,6 +38,14 @@ test('GM hosts a session; a player on another device sees live combat', async ({
// enemy exact HP must NOT be shown to players
await expect(player.getByText('7/7')).toHaveCount(0);
// Player navigates away — the live connection persists (badge stays) and
// returning still shows the synced table.
await player.getByLabel('Primary').getByRole('link', { name: 'Dice' }).click();
await expect(player.getByTestId('player-live')).toBeVisible();
await expect(player.getByTestId('player-live')).toContainText(code);
await player.getByTestId('player-live').getByRole('link').click();
await expect(player.getByText('Lia the Brave').first()).toBeVisible();
await gmCtx.close();
await playerCtx.close();
});
+4
View File
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/Button';
import { SessionControl } from '@/features/play/SessionControl';
import { HandoutControl } from '@/features/play/HandoutControl';
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
const NAV = [
{ to: '/', label: 'Campaigns', exact: true },
@@ -62,6 +64,7 @@ export function RootLayout() {
const activeCampaign = useActiveCampaign();
// While the GM is hosting, mirror state to players (inert unless hosting).
useSessionBroadcaster(activeCampaign ?? null);
usePlayerConnection();
// Global Ctrl/Cmd+K opens the command palette.
useEffect(() => {
@@ -99,6 +102,7 @@ export function RootLayout() {
})}
</nav>
<div className="ml-auto flex items-center gap-2">
<PlayerSessionBadge />
{activeCampaign && <HandoutControl />}
<SessionControl />
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
+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]);
+6
View File
@@ -152,6 +152,12 @@ export function grantSeat(targetPlayerId: string, character: Character): void {
}
}
/** 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;
+29 -14
View File
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Character } from '@/lib/schemas';
export type SessionRole = 'off' | 'gm' | 'player';
@@ -10,6 +11,9 @@ export interface SeatRequest {
offlineSnapshot?: Character;
}
/** Persisted intent to be connected to a room as a player — survives navigation + reload. */
export interface JoinIntent { joinCode: string; password?: string }
interface SessionState {
role: SessionRole;
status: SessionStatus;
@@ -18,22 +22,33 @@ interface SessionState {
error: string | null;
/** seat-control requests awaiting the GM's approval */
seatRequests: SeatRequest[];
set: (patch: Partial<Omit<SessionState, 'set' | 'reset' | 'addSeatRequest' | 'removeSeatRequest'>>) => void;
/** when set, the app keeps a player connection alive across views/reloads */
joinIntent: JoinIntent | null;
set: (patch: Partial<Omit<SessionState, 'set' | 'reset' | 'addSeatRequest' | 'removeSeatRequest' | 'setJoinIntent'>>) => void;
addSeatRequest: (r: SeatRequest) => void;
removeSeatRequest: (playerId: string) => void;
setJoinIntent: (j: JoinIntent | null) => void;
reset: () => void;
}
/** Ephemeral (non-persisted) state for a live collaboration session. */
export const useSessionStore = create<SessionState>()((set) => ({
role: 'off',
status: 'idle',
roomId: null,
joinCode: null,
error: null,
seatRequests: [],
set: (patch) => set(patch),
addSeatRequest: (r) => set((s) => ({ seatRequests: [...s.seatRequests.filter((x) => x.playerId !== r.playerId), r] })),
removeSeatRequest: (playerId) => set((s) => ({ seatRequests: s.seatRequests.filter((x) => x.playerId !== playerId) })),
reset: () => set({ role: 'off', status: 'idle', roomId: null, joinCode: null, error: null, seatRequests: [] }),
}));
/** Ephemeral session state, EXCEPT joinIntent which is persisted so a player stays
* connected across page navigations and reloads. */
export const useSessionStore = create<SessionState>()(
persist(
(set) => ({
role: 'off',
status: 'idle',
roomId: null,
joinCode: null,
error: null,
seatRequests: [],
joinIntent: null,
set: (patch) => set(patch),
addSeatRequest: (r) => set((s) => ({ seatRequests: [...s.seatRequests.filter((x) => x.playerId !== r.playerId), r] })),
removeSeatRequest: (playerId) => set((s) => ({ seatRequests: s.seatRequests.filter((x) => x.playerId !== playerId) })),
setJoinIntent: (joinIntent) => set({ joinIntent }),
reset: () => set({ role: 'off', status: 'idle', roomId: null, joinCode: null, error: null, seatRequests: [] }),
}),
{ name: 'ttrpg-session', partialize: (s) => ({ joinIntent: s.joinIntent }) },
),
);