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
+5 -5
View File
@@ -6,7 +6,7 @@ import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
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 { Button } from '@/components/ui/Button';
import { Avatar, Badge } from '@/components/ui/Codex';
@@ -18,11 +18,11 @@ export function CharactersPage() {
}
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 [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 () => {
setImportError(null);
@@ -60,7 +60,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
</p>
)}
{characters.length === 0 ? (
{pcs.length === 0 && npcs.length === 0 ? (
<EmptyState
title="No characters yet"
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 { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter,
type AbilityKey, type AbilityScores, type ProficiencyRank,
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, SYSTEM_OPTIONS,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} from '@/lib/rules';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
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 }) {
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 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 [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(() => {
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 loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
} 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) ?? '') }))));
}
return () => { on = false; };
}, [campaign.system]);
}, [system]);
// ---- selections ----
const [step, setStep] = useState(0);
@@ -108,7 +111,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int);
const skillCount = selectedClass
? selectedClass.skillCount + (campaign.system === 'pf2e' ? Math.max(0, intMod) : 0)
? selectedClass.skillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
: 2;
const skillOptions = useMemo(() => {
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
@@ -127,10 +130,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
useEffect(() => {
if (!isCaster || allSpells.length) return;
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(', ') : '' }))));
return () => { on = false; };
}, [isCaster, campaign.system, allSpells.length]);
}, [isCaster, system, allSpells.length]);
const spellResults = useMemo(() => {
const q = spellQuery.trim().toLowerCase();
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]));
// ---- derived build ----
const built = useMemo(() => buildCharacter(campaign.system, {
const built = useMemo(() => buildCharacter(system, {
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
...(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 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 c = classes.find((x) => x.name === t.className);
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
@@ -169,7 +193,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const finish = async () => {
if (!selectedClass) return;
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,
});
// 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>
</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="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">
{(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>
))}
</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="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
</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>}
</button>
))}
@@ -250,7 +286,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div>
{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)}>
<option value=""> decide later </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' && (
<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} />
</div>
)}
@@ -302,7 +338,8 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Skills' && (
<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">
{skillOptions.map((key) => {
const checked = skills.includes(key);
@@ -320,7 +357,11 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Spells' && (
<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" />
<div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
{spellResults.map((s) => {
+5
View File
@@ -6,6 +6,11 @@ export function useCharacters(campaignId: string): Character[] {
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 {
return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
}