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:
@@ -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 you’ve 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 ? ' — that’s 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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user