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:
@@ -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
|
// enemy exact HP must NOT be shown to players
|
||||||
await expect(player.getByText('7/7')).toHaveCount(0);
|
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 gmCtx.close();
|
||||||
await playerCtx.close();
|
await playerCtx.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { SessionControl } from '@/features/play/SessionControl';
|
import { SessionControl } from '@/features/play/SessionControl';
|
||||||
import { HandoutControl } from '@/features/play/HandoutControl';
|
import { HandoutControl } from '@/features/play/HandoutControl';
|
||||||
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
|
||||||
|
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
|
||||||
|
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/', label: 'Campaigns', exact: true },
|
{ to: '/', label: 'Campaigns', exact: true },
|
||||||
@@ -62,6 +64,7 @@ export function RootLayout() {
|
|||||||
const activeCampaign = useActiveCampaign();
|
const activeCampaign = useActiveCampaign();
|
||||||
// While the GM is hosting, mirror state to players (inert unless hosting).
|
// While the GM is hosting, mirror state to players (inert unless hosting).
|
||||||
useSessionBroadcaster(activeCampaign ?? null);
|
useSessionBroadcaster(activeCampaign ?? null);
|
||||||
|
usePlayerConnection();
|
||||||
|
|
||||||
// Global Ctrl/Cmd+K opens the command palette.
|
// Global Ctrl/Cmd+K opens the command palette.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,6 +102,7 @@ export function RootLayout() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<PlayerSessionBadge />
|
||||||
{activeCampaign && <HandoutControl />}
|
{activeCampaign && <HandoutControl />}
|
||||||
<SessionControl />
|
<SessionControl />
|
||||||
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
|
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useLiveQuery } from 'dexie-react-hooks';
|
|||||||
import type { Campaign, Character } from '@/lib/schemas';
|
import type { Campaign, Character } from '@/lib/schemas';
|
||||||
import { localSync } from '@/lib/sync';
|
import { localSync } from '@/lib/sync';
|
||||||
import { buildSnapshot } from '@/lib/sync/snapshot';
|
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 { charactersRepo } from '@/lib/db/repositories';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
@@ -23,8 +23,16 @@ function roomParam(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerViewPage() {
|
export function PlayerViewPage() {
|
||||||
// A player joining via link (?room=CODE) has no local campaign — networked mode.
|
// Joining via link (?room=CODE) records a persistent join intent; the app-level
|
||||||
if (roomParam()) return <NetworkedPlayerView room={roomParam()!} />;
|
// 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>;
|
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. */
|
/** Networked player view — renders the live table. The connection itself is kept
|
||||||
function NetworkedPlayerView({ room }: { room: string }) {
|
* 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 status = useSessionStore((s) => s.status);
|
||||||
const error = useSessionStore((s) => s.error);
|
const error = useSessionStore((s) => s.error);
|
||||||
const snapshot = usePlayerSessionStore((s) => s.snapshot);
|
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 localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
|
||||||
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
|
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
joinSession(room);
|
|
||||||
return () => stopSession();
|
|
||||||
}, [room]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error?.toLowerCase().includes('password') && !needPw) {
|
if (error?.toLowerCase().includes('password') && !needPw) {
|
||||||
setNeedPw(true);
|
setNeedPw(true);
|
||||||
const pw = window.prompt('This session needs a password:')?.trim();
|
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]);
|
}, [error, needPw, room]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
export function stopSession(): void {
|
||||||
manualStop = true;
|
manualStop = true;
|
||||||
intent = null;
|
intent = null;
|
||||||
|
|||||||
+29
-14
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
|
|
||||||
export type SessionRole = 'off' | 'gm' | 'player';
|
export type SessionRole = 'off' | 'gm' | 'player';
|
||||||
@@ -10,6 +11,9 @@ export interface SeatRequest {
|
|||||||
offlineSnapshot?: Character;
|
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 {
|
interface SessionState {
|
||||||
role: SessionRole;
|
role: SessionRole;
|
||||||
status: SessionStatus;
|
status: SessionStatus;
|
||||||
@@ -18,22 +22,33 @@ interface SessionState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
/** seat-control requests awaiting the GM's approval */
|
/** seat-control requests awaiting the GM's approval */
|
||||||
seatRequests: SeatRequest[];
|
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;
|
addSeatRequest: (r: SeatRequest) => void;
|
||||||
removeSeatRequest: (playerId: string) => void;
|
removeSeatRequest: (playerId: string) => void;
|
||||||
|
setJoinIntent: (j: JoinIntent | null) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ephemeral (non-persisted) state for a live collaboration session. */
|
/** Ephemeral session state, EXCEPT joinIntent which is persisted so a player stays
|
||||||
export const useSessionStore = create<SessionState>()((set) => ({
|
* connected across page navigations and reloads. */
|
||||||
role: 'off',
|
export const useSessionStore = create<SessionState>()(
|
||||||
status: 'idle',
|
persist(
|
||||||
roomId: null,
|
(set) => ({
|
||||||
joinCode: null,
|
role: 'off',
|
||||||
error: null,
|
status: 'idle',
|
||||||
seatRequests: [],
|
roomId: null,
|
||||||
set: (patch) => set(patch),
|
joinCode: null,
|
||||||
addSeatRequest: (r) => set((s) => ({ seatRequests: [...s.seatRequests.filter((x) => x.playerId !== r.playerId), r] })),
|
error: null,
|
||||||
removeSeatRequest: (playerId) => set((s) => ({ seatRequests: s.seatRequests.filter((x) => x.playerId !== playerId) })),
|
seatRequests: [],
|
||||||
reset: () => 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 }) },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user