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 && ( - + setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" />
{spellResults.map((s) => { diff --git a/src/features/characters/hooks.ts b/src/features/characters/hooks.ts index e4b843c..66ed8d1 100644 --- a/src/features/characters/hooks.ts +++ b/src/features/characters/hooks.ts @@ -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); } diff --git a/src/features/cloud/useCloudAutosave.ts b/src/features/cloud/useCloudAutosave.ts new file mode 100644 index 0000000..4509d0e --- /dev/null +++ b/src/features/cloud/useCloudAutosave.ts @@ -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>) => + (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(undefined); + useEffect(() => { + if (fingerprint === undefined) return; + if (prev.current === undefined) { prev.current = fingerprint; return; } + if (prev.current !== fingerprint) { prev.current = fingerprint; save(); } + }, [fingerprint, save]); +} diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index c965091..fa4853c 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -22,7 +22,7 @@ import { createRng } from '@/lib/rng'; import { rollDice } from '@/lib/dice/notation'; import { getSystem, getConditions, type SystemId } from '@/lib/rules'; 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 { addCombatant, @@ -48,6 +48,9 @@ import { EncounterTipCard } from '@/features/assistant/EncounterTipCard'; export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) { 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 undoStack = useRef([]); @@ -162,7 +165,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter - mutate((e) => addCombatant(e, c))} /> + mutate((e) => addCombatant(e, c))} /> {/* Combatant list */} {encounter.combatants.length === 0 ? ( diff --git a/src/features/play/SessionControl.tsx b/src/features/play/SessionControl.tsx index e4ebae4..ebb5d3f 100644 --- a/src/features/play/SessionControl.tsx +++ b/src/features/play/SessionControl.tsx @@ -74,16 +74,22 @@ export function SessionControl() { } function SeatRequestRow({ req }: { req: SeatRequest }) { + const campaign = useActiveCampaign(); const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]); const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest); const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character'; 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; 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); else removeSeatRequest(req.playerId); }; diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx index 20262af..4b44eff 100644 --- a/src/features/player/PlayerViewPage.tsx +++ b/src/features/player/PlayerViewPage.tsx @@ -8,7 +8,7 @@ import { charactersRepo } from '@/lib/db/repositories'; import { useUiStore } from '@/stores/uiStore'; import { useSessionStore } from '@/stores/sessionStore'; 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 { useCalendar, useMaps } from '@/features/world/hooks'; import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page'; @@ -88,10 +88,12 @@ function NetworkedPlayerView() { const seatStatus = usePlayerSessionStore((s) => s.seatStatus); 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 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(() => { if (error?.toLowerCase().includes('password') && !needPw) { @@ -122,7 +124,7 @@ function NetworkedPlayerView() { {myCharacter && seatStatus === 'granted' ? ( ) : ( - + )} diff --git a/src/features/player/SeatClaimScreen.tsx b/src/features/player/SeatClaimScreen.tsx index bba7f5d..f0d2e56 100644 --- a/src/features/player/SeatClaimScreen.tsx +++ b/src/features/player/SeatClaimScreen.tsx @@ -10,14 +10,19 @@ import { Avatar, Badge } from '@/components/ui/Codex'; * party. If a locally-developed copy exists, its offline edits ride along for * the GM to review. */ -export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: { +export function SeatClaimScreen({ snapshot, localChars, ownCharacters = [], pending, onClaim }: { snapshot: Snapshot; localChars: Map; + /** the player's own local characters — they can push one the GM hasn't added yet */ + ownCharacters?: Character[]; pending: boolean; onClaim: (characterId: string) => void; }) { - if (snapshot.party.length === 0) { - return ; + const partyIds = new Set(snapshot.party.map((p) => p.id)); + const pushable = ownCharacters.filter((c) => c.kind === 'pc' && !partyIds.has(c.id)); + + if (snapshot.party.length === 0 && pushable.length === 0) { + return ; } return (
@@ -25,22 +30,45 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {

Which character is yours?

Pick your character to manage its HP, spells, and rolls live. The GM approves the request.


-
- {snapshot.party.map((c) => { - const hasOffline = localChars.has(c.id); - return ( -
- -
-
{c.name}
-
Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP
- {hasOffline && offline edits ready} + + {snapshot.party.length > 0 && ( +
+ {snapshot.party.map((c) => { + const hasOffline = localChars.has(c.id); + return ( +
+ +
+
{c.name}
+
Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP
+ {hasOffline && offline edits ready} +
+
- -
- ); - })} -
+ ); + })} +
+ )} + + {pushable.length > 0 && ( +
+

Bring your own character

+
+ {pushable.map((c) => ( +
+ +
+
{c.name}
+
Lv {c.level}{c.className ? ` · ${c.className}` : ''}
+ send to the GM +
+ +
+ ))} +
+
+ )} + {pending &&

Waiting for the GM to approve…

}
); diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 3d51f05..c6ddf6f 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -88,6 +88,11 @@ export const charactersRepo = { return db.characters.where('campaignId').equals(campaignId).toArray(); }, + /** All player characters across every campaign (PCs are campaign-agnostic). */ + listAllPcs(): Promise { + return db.characters.where('kind').equals('pc').toArray(); + }, + get(id: string): Promise { return db.characters.get(id); },