From 235cdecb53cd15c9253d19edf6dfcd1f3647a6af Mon Sep 17 00:00:00 2001
From: Nils Briggen
Date: Mon, 8 Jun 2026 22:12:18 +0200
Subject: [PATCH] Fixes batch 3: autosave, campaign-agnostic PCs, live push,
builder
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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)
---
src/app/RootLayout.tsx | 2 +
src/features/characters/CharactersPage.tsx | 10 +--
.../characters/builder/CreationWizard.tsx | 77 ++++++++++++++-----
src/features/characters/hooks.ts | 5 ++
src/features/cloud/useCloudAutosave.ts | 43 +++++++++++
src/features/combat/EncounterTracker.tsx | 7 +-
src/features/play/SessionControl.tsx | 10 ++-
src/features/player/PlayerViewPage.tsx | 10 ++-
src/features/player/SeatClaimScreen.tsx | 64 ++++++++++-----
src/lib/db/repositories.ts | 5 ++
10 files changed, 184 insertions(+), 49 deletions(-)
create mode 100644 src/features/cloud/useCloudAutosave.ts
diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx
index 0d39c32..e9ae06f 100644
--- a/src/app/RootLayout.tsx
+++ b/src/app/RootLayout.tsx
@@ -17,6 +17,7 @@ import { SessionControl } from '@/features/play/SessionControl';
import { HandoutControl } from '@/features/play/HandoutControl';
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
+import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
import { SessionSidebar } from '@/features/play/SessionSidebar';
import { useSessionStore } from '@/stores/sessionStore';
@@ -87,6 +88,7 @@ export function RootLayout() {
// While the GM is hosting, mirror state to players (inert unless hosting).
useSessionBroadcaster(activeCampaign ?? null);
usePlayerConnection();
+ useCloudAutosave();
// Global Ctrl/Cmd+K opens the command palette.
useEffect(() => {
diff --git a/src/features/characters/CharactersPage.tsx b/src/features/characters/CharactersPage.tsx
index c1249d3..2087028 100644
--- a/src/features/characters/CharactersPage.tsx
+++ b/src/features/characters/CharactersPage.tsx
@@ -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(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 }) {
)}
- {characters.length === 0 ? (
+ {pcs.length === 0 && npcs.length === 0 ? (
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(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([]);
const [allSpells, setAllSpells] = useState([]);
- 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([]);
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
+
+
Game system
+
+ {SYSTEM_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
New here? Start from a ready-made hero
- {(TEMPLATES[campaign.system] ?? []).map((t) => (
+ {(TEMPLATES[system] ?? []).map((t) => (
))}
@@ -241,7 +277,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{c.name}
{playstyle(c)}
- {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' : ''}
+ {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' : ''}
{c.description && {c.description}
}
))}
@@ -250,7 +286,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{selectedClass && selectedClass.subclasses.length > 0 && (
-
+