Fixes batch 3: autosave, campaign-agnostic PCs, live push, builder

- Autosave: while signed in, a debounced full backup pushes whenever durable data
  changes (useCloudAutosave watches a fingerprint of the main tables; high-churn
  tables excluded). No-op signed out.
- Campaign-agnostic characters: PCs are now global — the Characters roster shows
  every player character regardless of active campaign, and any PC can be added to
  a fight. NPCs stay per-campaign. (charactersRepo.listAllPcs / useAllPcs; difficulty
  fallback stays campaign-scoped.)
- Live "bring your own character": a joining player can push one of their own local
  characters; the GM's grant inserts it into the campaign and seats them.
- Character builder: a system picker (5e / PF2e, defaults to the campaign) so creation
  no longer assumes 5e; PF2e skill wording ("Trained" vs "proficient"); spell step now
  explains how many to pick + that they're known spells prepared later.

230 unit + 34 e2e + 2 realtime green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 22:12:18 +02:00
parent 3f524ce82c
commit 235cdecb53
10 changed files with 184 additions and 49 deletions
+2
View File
@@ -17,6 +17,7 @@ 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 { usePlayerConnection } from '@/features/play/usePlayerConnection';
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
import { PlayerSessionBadge } from '@/features/play/PlayerConnection'; import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
import { SessionSidebar } from '@/features/play/SessionSidebar'; import { SessionSidebar } from '@/features/play/SessionSidebar';
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
@@ -87,6 +88,7 @@ export function RootLayout() {
// 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(); usePlayerConnection();
useCloudAutosave();
// Global Ctrl/Cmd+K opens the command palette. // Global Ctrl/Cmd+K opens the command palette.
useEffect(() => { useEffect(() => {
+5 -5
View File
@@ -6,7 +6,7 @@ import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character'; import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
import { pickTextFile } from '@/lib/io/file'; import { pickTextFile } from '@/lib/io/file';
import { useCharacters } from './hooks'; import { useCharacters, useAllPcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Avatar, Badge } from '@/components/ui/Codex'; import { Avatar, Badge } from '@/components/ui/Codex';
@@ -18,11 +18,11 @@ export function CharactersPage() {
} }
function CharactersList({ campaign }: { campaign: Campaign }) { function CharactersList({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id); // PCs are campaign-agnostic — show every player character; NPCs stay per-campaign.
const pcs = useAllPcs();
const npcs = useCharacters(campaign.id).filter((c) => c.kind === 'npc');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [importError, setImportError] = useState<string | null>(null); const [importError, setImportError] = useState<string | null>(null);
const pcs = characters.filter((c) => c.kind === 'pc');
const npcs = characters.filter((c) => c.kind === 'npc');
const importCharacter = async () => { const importCharacter = async () => {
setImportError(null); setImportError(null);
@@ -60,7 +60,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
</p> </p>
)} )}
{characters.length === 0 ? ( {pcs.length === 0 && npcs.length === 0 ? (
<EmptyState <EmptyState
title="No characters yet" title="No characters yet"
hint="Add player characters and NPCs, or import a character file." hint="Add player characters and NPCs, or import a character file."
@@ -3,8 +3,8 @@ import { useNavigate } from '@tanstack/react-router';
import type { Campaign, Character, SpellEntry } from '@/lib/schemas'; import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
import { import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, SYSTEM_OPTIONS,
type AbilityKey, type AbilityScores, type ProficiencyRank, type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} from '@/lib/rules'; } from '@/lib/rules';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen'; import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium'; import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
@@ -48,7 +48,10 @@ const TEMPLATES: Record<string, { label: string; hint: string; className: string
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) { export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const sys = getSystem(campaign.system); // Characters are campaign-agnostic: the wizard asks which system to build for,
// defaulting to the campaign's. Changing it resets all dependent selections.
const [system, setSystem] = useState<SystemId>(campaign.system);
const sys = getSystem(system);
const allSkillKeys = sys.skills.map((s) => s.key); const allSkillKeys = sys.skills.map((s) => s.key);
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key; const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
@@ -58,10 +61,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const [backgrounds, setBackgrounds] = useState<Origin[]>([]); const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]); const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
useEffect(() => { let on = true; void loadClasses(campaign.system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [campaign.system]); useEffect(() => { let on = true; void loadClasses(system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [system]);
useEffect(() => { useEffect(() => {
let on = true; let on = true;
if (campaign.system === '5e') { if (system === '5e') {
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed })))); void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed }))));
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills })))); void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
} else { } else {
@@ -69,7 +72,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') })))); void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') }))));
} }
return () => { on = false; }; return () => { on = false; };
}, [campaign.system]); }, [system]);
// ---- selections ---- // ---- selections ----
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
@@ -108,7 +111,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const [skills, setSkills] = useState<string[]>([]); const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int); const intMod = abilityModifier(abilities.int);
const skillCount = selectedClass const skillCount = selectedClass
? selectedClass.skillCount + (campaign.system === 'pf2e' ? Math.max(0, intMod) : 0) ? selectedClass.skillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
: 2; : 2;
const skillOptions = useMemo(() => { const skillOptions = useMemo(() => {
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys; if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
@@ -127,10 +130,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
useEffect(() => { useEffect(() => {
if (!isCaster || allSpells.length) return; if (!isCaster || allSpells.length) return;
let on = true; let on = true;
if (campaign.system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' })))); if (system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => ({ name: String(s.name), level: Number(s.level) || 0, meta: Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '' })))); else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => ({ name: String(s.name), level: Number(s.level) || 0, meta: Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '' }))));
return () => { on = false; }; return () => { on = false; };
}, [isCaster, campaign.system, allSpells.length]); }, [isCaster, system, allSpells.length]);
const spellResults = useMemo(() => { const spellResults = useMemo(() => {
const q = spellQuery.trim().toLowerCase(); const q = spellQuery.trim().toLowerCase();
const maxLevel = Math.min(9, Math.ceil(level / 2)); const maxLevel = Math.min(9, Math.ceil(level / 2));
@@ -140,15 +143,36 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s])); setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
// ---- derived build ---- // ---- derived build ----
const built = useMemo(() => buildCharacter(campaign.system, { const built = useMemo(() => buildCharacter(system, {
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills, className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}), ...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}), ...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
}), [campaign.system, selectedClass, level, abilities, skills, selectedOrigin]); }), [system, selectedClass, level, abilities, skills, selectedOrigin]);
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
// just guidance: a few cantrips plus a handful of starting leveled spells.
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
const suggestedSpells = Math.max(4, leveledSlots + 2);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]); const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!; const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
const changeSystem = (next: SystemId) => {
if (next === system) return;
setSystem(next);
// Reset everything keyed on the system so stale picks don't leak across systems.
// The class/origin/background/spell lists reload from the loaders above.
setClassSlug('');
setSubclass('');
setSkills([]);
setSpellPicks([]);
setSpellQuery('');
setAllSpells([]); // force the spell loader (guarded on length) to refetch for the new system
setAncestry('');
setBackground('');
setStep(0);
};
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => { const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
const c = classes.find((x) => x.name === t.className); const c = classes.find((x) => x.name === t.className);
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); } if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
@@ -169,7 +193,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const finish = async () => { const finish = async () => {
if (!selectedClass) return; if (!selectedClass) return;
const created = await charactersRepo.create(campaign.id, { const created = await charactersRepo.create(campaign.id, {
system: campaign.system, name: name.trim() || selectedClass.name, kind, system, name: name.trim() || selectedClass.name, kind,
ancestry: ancestry.trim(), className: selectedClass.name, level, ancestry: ancestry.trim(), className: selectedClass.name, level,
}); });
// For classes outside the curated tables (esp. PF2e), fill saves from data. // For classes outside the curated tables (esp. PF2e), fill saves from data.
@@ -222,10 +246,22 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field> <Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
</div> </div>
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Game system</div>
<div className="flex gap-1" role="group" aria-label="Game system">
{SYSTEM_OPTIONS.map((opt) => (
<button key={opt.id} type="button" onClick={() => changeSystem(opt.id)} aria-pressed={system === opt.id}
className={cn('rounded-md px-3 py-1.5 text-sm', system === opt.id ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>
{opt.label}
</button>
))}
</div>
</div>
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2"> <div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">New here? Start from a ready-made hero</div> <div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">New here? Start from a ready-made hero</div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{(TEMPLATES[campaign.system] ?? []).map((t) => ( {(TEMPLATES[system] ?? []).map((t) => (
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button> <button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
))} ))}
</div> </div>
@@ -241,7 +277,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<span className="font-display font-semibold text-ink">{c.name}</span> <span className="font-display font-semibold text-ink">{c.name}</span>
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span> <span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
</div> </div>
<div className="mt-0.5 text-[11px] text-muted">{campaign.system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div> <div className="mt-0.5 text-[11px] text-muted">{system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{c.description}</p>} {c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{c.description}</p>}
</button> </button>
))} ))}
@@ -250,7 +286,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div> </div>
{selectedClass && selectedClass.subclasses.length > 0 && ( {selectedClass && selectedClass.subclasses.length > 0 && (
<Field label={campaign.system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}> <Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}> <Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
<option value=""> decide later </option> <option value=""> decide later </option>
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)} {selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
@@ -262,7 +298,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Origin' && ( {stepName === 'Origin' && (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<OriginPicker title={campaign.system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} /> <OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} /> <OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</div> </div>
)} )}
@@ -302,7 +338,8 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Skills' && ( {stepName === 'Skills' && (
<div> <div>
<p className="mb-3 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> trained {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p> <p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-3 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => { {skillOptions.map((key) => {
const checked = skills.includes(key); const checked = skills.includes(key);
@@ -320,7 +357,11 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Spells' && ( {stepName === 'Spells' && (
<div> <div>
<p className="mb-2 text-sm text-muted">Pick starting spells (search by name). You can add more on the sheet later. {spellPicks.length} selected.</p> <p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
<p className="mb-1 text-xs text-muted">These are the spells you <span className="text-ink">{system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells youve learned)'}</span>. You prepare or cast them from slots on the character sheet.</p>
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — thats quite a few; you can trim some later if you like.' : ''}
</p>
<Input value={spellQuery} onChange={(e) => setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" /> <Input value={spellQuery} onChange={(e) => setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" />
<div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2"> <div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
{spellResults.map((s) => { {spellResults.map((s) => {
+5
View File
@@ -6,6 +6,11 @@ export function useCharacters(campaignId: string): Character[] {
return useLiveQuery(() => charactersRepo.listByCampaign(campaignId), [campaignId], []); return useLiveQuery(() => charactersRepo.listByCampaign(campaignId), [campaignId], []);
} }
/** All player characters across every campaign — PCs are campaign-agnostic. */
export function useAllPcs(): Character[] {
return useLiveQuery(() => charactersRepo.listAllPcs(), [], []);
}
export function useCharacter(id: string): Character | undefined { export function useCharacter(id: string): Character | undefined {
return useLiveQuery(() => charactersRepo.get(id), [id], undefined); return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
} }
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db';
import { cloudUsername, pushBackup } from '@/lib/cloud/client';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
/**
* Autosave to the cloud while signed in: whenever durable data changes, debounce
* a full backup push. A fingerprint of (id:updatedAt) over the durable tables is
* watched via liveQuery, so it reacts to edits, adds, and deletes alike. High-churn
* tables (dice rolls, the session recap) are excluded so a roll doesn't trigger a
* save. No-op when signed out. Mounted once in the app shell.
*/
export function useCloudAutosave(): void {
const fingerprint = useLiveQuery(async () => {
const stamp = async (rows: Promise<Array<{ id?: string; campaignId?: string; updatedAt?: string }>>) =>
(await rows).map((r) => `${r.id ?? r.campaignId ?? ''}:${r.updatedAt ?? ''}`).join(',');
const [c, ch, e, n, np, q, m, h, cal] = await Promise.all([
stamp(db.campaigns.toArray()),
stamp(db.characters.toArray()),
stamp(db.encounters.toArray()),
stamp(db.notes.toArray()),
stamp(db.npcs.toArray()),
stamp(db.quests.toArray()),
stamp(db.maps.toArray()),
stamp(db.homebrew.toArray()),
stamp(db.calendars.toArray()),
]);
return [c, ch, e, n, np, q, m, h, cal].join('|');
}, [], undefined);
const save = useDebouncedCallback(() => {
if (cloudUsername()) void pushBackup().catch(() => { /* offline / transient — next change retries */ });
}, 8000);
// Skip the initial load; only push once data actually changes after mount.
const prev = useRef<string | undefined>(undefined);
useEffect(() => {
if (fingerprint === undefined) return;
if (prev.current === undefined) { prev.current = fingerprint; return; }
if (prev.current !== fingerprint) { prev.current = fingerprint; save(); }
}, [fingerprint, save]);
}
+5 -2
View File
@@ -22,7 +22,7 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation'; import { rollDice } from '@/lib/dice/notation';
import { getSystem, getConditions, type SystemId } from '@/lib/rules'; import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget'; import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
import { useCharacters } from '@/features/characters/hooks'; import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useConditionGlossary } from './useConditionGlossary'; import { useConditionGlossary } from './useConditionGlossary';
import { import {
addCombatant, addCombatant,
@@ -48,6 +48,9 @@ import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) { export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
const characters = useCharacters(campaign.id); const characters = useCharacters(campaign.id);
// PCs are campaign-agnostic, so any PC can join a fight; NPCs stay campaign-scoped.
const allPcs = useAllPcs();
const addableChars = [...allPcs, ...characters.filter((c) => c.kind === 'npc')];
const glossary = useConditionGlossary(campaign.system); const glossary = useConditionGlossary(campaign.system);
const undoStack = useRef<Encounter[]>([]); const undoStack = useRef<Encounter[]>([]);
@@ -162,7 +165,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
<EncounterTipCard campaign={campaign} encounter={encounter} /> <EncounterTipCard campaign={campaign} encounter={encounter} />
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} /> <AddCombatantBar encounter={encounter} characters={addableChars} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
{/* Combatant list */} {/* Combatant list */}
{encounter.combatants.length === 0 ? ( {encounter.combatants.length === 0 ? (
+8 -2
View File
@@ -74,16 +74,22 @@ export function SessionControl() {
} }
function SeatRequestRow({ req }: { req: SeatRequest }) { function SeatRequestRow({ req }: { req: SeatRequest }) {
const campaign = useActiveCampaign();
const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]); const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]);
const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest); const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest);
const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character'; const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character';
const grant = async (applyOffline: boolean) => { const grant = async (applyOffline: boolean) => {
if (applyOffline && req.offlineSnapshot) { let current = await charactersRepo.get(req.characterId);
if (!current && req.offlineSnapshot && campaign) {
// A character the player made and pushed — add it to this campaign, then seat them.
await charactersRepo.insert({ ...req.offlineSnapshot, campaignId: campaign.id });
current = await charactersRepo.get(req.characterId);
} else if (applyOffline && req.offlineSnapshot && current) {
const { id: _id, campaignId: _c, createdAt: _cr, updatedAt: _u, ...rest } = req.offlineSnapshot; const { id: _id, campaignId: _c, createdAt: _cr, updatedAt: _u, ...rest } = req.offlineSnapshot;
await charactersRepo.update(req.characterId, rest); await charactersRepo.update(req.characterId, rest);
current = await charactersRepo.get(req.characterId);
} }
const current = await charactersRepo.get(req.characterId);
if (current) grantSeat(req.playerId, current); if (current) grantSeat(req.playerId, current);
else removeSeatRequest(req.playerId); else removeSeatRequest(req.playerId);
}; };
+6 -4
View File
@@ -8,7 +8,7 @@ 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';
import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { useCharacters } from '@/features/characters/hooks'; import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks'; import { useEncounters } from '@/features/combat/hooks';
import { useCalendar, useMaps } from '@/features/world/hooks'; import { useCalendar, useMaps } from '@/features/world/hooks';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
@@ -88,10 +88,12 @@ function NetworkedPlayerView() {
const seatStatus = usePlayerSessionStore((s) => s.seatStatus); const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
const [needPw, setNeedPw] = useState(false); const [needPw, setNeedPw] = useState(false);
// Locally-developed copies of party characters (for offline-edit handoff). // Locally-developed copies of party characters (for offline-edit handoff) PLUS
// the player's own PCs, so they can push one the GM hasn't added yet.
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : ''; const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
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 myPcs = useAllPcs();
const localChars = new Map([...(localList ?? []), ...myPcs].map((c) => [c.id, c]));
useEffect(() => { useEffect(() => {
if (error?.toLowerCase().includes('password') && !needPw) { if (error?.toLowerCase().includes('password') && !needPw) {
@@ -122,7 +124,7 @@ function NetworkedPlayerView() {
{myCharacter && seatStatus === 'granted' ? ( {myCharacter && seatStatus === 'granted' ? (
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} /> <MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
) : ( ) : (
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} /> <SeatClaimScreen snapshot={snapshot} localChars={localChars} ownCharacters={myPcs} pending={seatStatus === 'pending'} onClaim={handleClaim} />
)} )}
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} /> <PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
<RollFeed /> <RollFeed />
+46 -18
View File
@@ -10,14 +10,19 @@ import { Avatar, Badge } from '@/components/ui/Codex';
* party. If a locally-developed copy exists, its offline edits ride along for * party. If a locally-developed copy exists, its offline edits ride along for
* the GM to review. * the GM to review.
*/ */
export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: { export function SeatClaimScreen({ snapshot, localChars, ownCharacters = [], pending, onClaim }: {
snapshot: Snapshot; snapshot: Snapshot;
localChars: Map<string, Character>; localChars: Map<string, Character>;
/** the player's own local characters — they can push one the GM hasn't added yet */
ownCharacters?: Character[];
pending: boolean; pending: boolean;
onClaim: (characterId: string) => void; onClaim: (characterId: string) => void;
}) { }) {
if (snapshot.party.length === 0) { const partyIds = new Set(snapshot.party.map((p) => p.id));
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />; const pushable = ownCharacters.filter((c) => c.kind === 'pc' && !partyIds.has(c.id));
if (snapshot.party.length === 0 && pushable.length === 0) {
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters — make one of your own and it'll appear here to bring to the table." />;
} }
return ( return (
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4"> <section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
@@ -25,22 +30,45 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
<h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2> <h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2>
<p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p> <p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
<hr className="gilt-rule my-3" /> <hr className="gilt-rule my-3" />
<div className="grid gap-2 sm:grid-cols-2">
{snapshot.party.map((c) => { {snapshot.party.length > 0 && (
const hasOffline = localChars.has(c.id); <div className="grid gap-2 sm:grid-cols-2">
return ( {snapshot.party.map((c) => {
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3"> const hasOffline = localChars.has(c.id);
<Avatar name={c.name} size={40} /> return (
<div className="min-w-0 flex-1"> <div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div> <Avatar name={c.name} size={40} />
<div className="font-mono text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP</div> <div className="min-w-0 flex-1">
{hasOffline && <Badge tone="arcane" className="mt-1"><UserPlus size={11} aria-hidden /> offline edits ready</Badge>} <div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
<div className="font-mono text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP</div>
{hasOffline && <Badge tone="arcane" className="mt-1"><UserPlus size={11} aria-hidden /> offline edits ready</Badge>}
</div>
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
</div> </div>
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button> );
</div> })}
); </div>
})} )}
</div>
{pushable.length > 0 && (
<div className="mt-4">
<h3 className="smallcaps mb-1.5 text-muted">Bring your own character</h3>
<div className="grid gap-2 sm:grid-cols-2">
{pushable.map((c) => (
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
<Avatar name={c.name} size={40} />
<div className="min-w-0 flex-1">
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
<div className="font-mono text-xs text-muted">Lv {c.level}{c.className ? ` · ${c.className}` : ''}</div>
<Badge tone="gold" className="mt-1"><UserPlus size={11} aria-hidden /> send to the GM</Badge>
</div>
<Button size="sm" variant="secondary" disabled={pending} onClick={() => onClaim(c.id)}>Use this one</Button>
</div>
))}
</div>
</div>
)}
{pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve</p>} {pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve</p>}
</section> </section>
); );
+5
View File
@@ -88,6 +88,11 @@ export const charactersRepo = {
return db.characters.where('campaignId').equals(campaignId).toArray(); return db.characters.where('campaignId').equals(campaignId).toArray();
}, },
/** All player characters across every campaign (PCs are campaign-agnostic). */
listAllPcs(): Promise<Character[]> {
return db.characters.where('kind').equals('pc').toArray();
},
get(id: string): Promise<Character | undefined> { get(id: string): Promise<Character | undefined> {
return db.characters.get(id); return db.characters.get(id);
}, },