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:
2026-06-08 12:34:00 +02:00
parent c28c29a6c6
commit 360d9ff842
21 changed files with 863 additions and 25 deletions
+12 -1
View File
@@ -3,7 +3,8 @@ import { Link } from '@tanstack/react-router';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { charactersRepo } from '@/lib/db/repositories';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll';
@@ -37,6 +38,15 @@ export function CharacterSheet({ character }: { character: Character }) {
const [c, setC] = useState<Character>(character);
const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false);
const [shared, setShared] = useState(false);
const shareWithPlayer = async () => {
const campaign = await campaignsRepo.get(c.campaignId);
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
try { await navigator.clipboard?.writeText(link); } catch { /* clipboard blocked */ }
setShared(true);
setTimeout(() => setShared(false), 1500);
};
const save = useDebouncedCallback((next: Character) => {
// Persist everything except identity/timestamps (update() stamps updatedAt).
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
@@ -90,6 +100,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-sm text-muted">{profLabel}</span>
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div>