P21: player seats + two-way live play + offline handoff
Players claim a seat (GM approves) and drive their own character live — HP/slots/resources/conditions/dice broadcast to the table — via new seat protocol messages and per-seat ownership enforced in RoomHub. GM persists player edits (charactersRepo.update → liveQuery → rebroadcast). Offline dev: "Share with player" encodes a claim link (src/lib/sync/playerLink.ts) → /player imports the character locally for full-sheet editing, bundled back as an offlineSnapshot on seat claim for GM review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { useEffect, useState } from 'react';
|
||||
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 } from '@/lib/sync/wsSync';
|
||||
import { joinSession, stopSession, claimSeat, sendPlayerPatch } from '@/lib/sync/wsSync';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
@@ -12,6 +14,9 @@ import { useCalendar, useMaps } from '@/features/world/hooks';
|
||||
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { PlayerBoards } from './PlayerBoards';
|
||||
import { SeatClaimScreen } from './SeatClaimScreen';
|
||||
import { MyCharacterPanel } from './MyCharacterPanel';
|
||||
import { RollFeed } from './RollFeed';
|
||||
|
||||
function roomParam(): string | null {
|
||||
return new URLSearchParams(window.location.search).get('room');
|
||||
@@ -64,8 +69,14 @@ function NetworkedPlayerView({ room }: { room: string }) {
|
||||
const error = useSessionStore((s) => s.error);
|
||||
const snapshot = usePlayerSessionStore((s) => s.snapshot);
|
||||
const images = usePlayerSessionStore((s) => s.images);
|
||||
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
|
||||
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
|
||||
const [needPw, setNeedPw] = useState(false);
|
||||
const joined = useRef(false);
|
||||
|
||||
// Locally-developed copies of party characters (for offline-edit handoff).
|
||||
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
|
||||
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
|
||||
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
|
||||
|
||||
useEffect(() => {
|
||||
joinSession(room);
|
||||
@@ -80,6 +91,14 @@ function NetworkedPlayerView({ room }: { room: string }) {
|
||||
}
|
||||
}, [error, needPw, room]);
|
||||
|
||||
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
|
||||
const handlePatch = (diff: Partial<Character>) => {
|
||||
const cur = usePlayerSessionStore.getState().myCharacter;
|
||||
if (!cur) return;
|
||||
usePlayerSessionStore.getState().patchMyCharacter(diff);
|
||||
sendPlayerPatch(cur.id, diff);
|
||||
};
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
|
||||
@@ -88,10 +107,15 @@ function NetworkedPlayerView({ room }: { room: string }) {
|
||||
);
|
||||
}
|
||||
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
|
||||
void joined;
|
||||
return (
|
||||
<Shell title={snapshot.campaignName} subtitle={`Player View · live${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''}`}>
|
||||
{myCharacter && seatStatus === 'granted' ? (
|
||||
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
|
||||
) : (
|
||||
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
|
||||
)}
|
||||
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
|
||||
<RollFeed />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user