diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index af3b045..31924e3 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -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(); }); diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 7435725..abede25 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -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() { })}
+ {activeCampaign && } +
+ ); +} diff --git a/src/features/play/usePlayerConnection.ts b/src/features/play/usePlayerConnection.ts new file mode 100644 index 0000000..7427b5e --- /dev/null +++ b/src/features/play/usePlayerConnection.ts @@ -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]); +} diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx index 09ee55e..2d45c0f 100644 --- a/src/features/player/PlayerViewPage.tsx +++ b/src/features/player/PlayerViewPage.tsx @@ -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 ; + // 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 ; return {(c) => }; } @@ -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]); diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts index 19fe7b4..ed89dd3 100644 --- a/src/lib/sync/wsSync.ts +++ b/src/lib/sync/wsSync.ts @@ -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; diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 53b1d7e..5ccb032 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -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>) => void; + /** when set, the app keeps a player connection alive across views/reloads */ + joinIntent: JoinIntent | null; + set: (patch: Partial>) => 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()((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()( + 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 }) }, + ), +);