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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user