diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 35067be..1691354 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -21,6 +21,7 @@ import { NumberField } from '@/components/ui/NumberField'; import { formatModifier } from '@/lib/format'; import { useCampaigns } from '@/features/campaigns/hooks'; import { rankLabel } from './sheet/labels'; +import { ClassesEditor } from './sheet/ClassesEditor'; import { InventorySection } from './sheet/InventorySection'; import { AttacksSection } from './sheet/AttacksSection'; import { ResourcesSection } from './sheet/ResourcesSection'; @@ -170,13 +171,18 @@ export function CharacterSheet({ character }: { character: Character }) { update({ ancestry: e.target.value })} /> - - update({ className: e.target.value })} /> - - - update({ level })} /> -

Direct edits update proficiency-based stats only — HP and spell slots aren’t recalculated. Use Level Up to advance a level.

-
+ {c.system === '5e' ? ( +
+ ) : ( + <> + + update({ className: e.target.value })} /> + + + update({ level })} /> + + + )} update({ speed })} /> diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index 749c6a3..bd7a566 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -358,17 +358,15 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank; } const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) })); - // Subclass still rides in notes for now (Phase 3 moves it onto classes[]); background is a real field. - const notes = subclass ? `Subclass: ${subclass}` : ''; await charactersRepo.update(created.id, { ...built, ...(Object.keys(saveRanks).length ? { saveRanks } : {}), + classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }], spellcasting: { ...built.spellcasting, spells }, ...(background ? { background } : {}), ...(alignment.trim() ? { alignment: alignment.trim() } : {}), ...(appearance.trim() ? { appearance: appearance.trim() } : {}), ...(personality.trim() ? { personality: personality.trim() } : {}), - ...(notes ? { notes } : {}), ...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}), }); onClose(); diff --git a/src/features/characters/sheet/ClassesEditor.tsx b/src/features/characters/sheet/ClassesEditor.tsx new file mode 100644 index 0000000..1f2d02b --- /dev/null +++ b/src/features/characters/sheet/ClassesEditor.tsx @@ -0,0 +1,90 @@ +import { Plus, X, RefreshCw } from 'lucide-react'; +import type { Character, ClassEntry, Spellcasting } from '@/lib/schemas'; +import { normalizeClassMirror } from '@/lib/schemas'; +import { getClassNames, abilityModifier } from '@/lib/rules'; +import { dnd5eClassesSlots, dnd5eMulticlassHp, DND5E_SUBCLASSES } from '@/lib/rules/dnd5e/progression'; +import { Button } from '@/components/ui/Button'; +import { Input, Select } from '@/components/ui/Input'; +import { NumberField } from '@/components/ui/NumberField'; + +type SlotRow = { level: number; max: number; current: number }; + +/** Update slot maxes from the derived set while preserving how many are currently spent. */ +function reconcileSlots(prev: Spellcasting, derived: { slots: SlotRow[]; pact?: SlotRow }): { slots: SlotRow[]; pact?: SlotRow } { + const slots = derived.slots.map((r) => { + const ex = prev.slots.find((s) => s.level === r.level); + return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max }; + }); + const pact = derived.pact + ? { ...derived.pact, current: Math.min(derived.pact.max, prev.pact?.current ?? derived.pact.max) } + : undefined; + return pact ? { slots, pact } : { slots }; +} + +/** + * 5e multiclass editor: one row per class. Editing classes recomputes the level/class + * mirrors and auto-reconciles spell-slot maxes (preserving spent slots). HP is recomputed + * only on the explicit button so hand-rolled HP isn't clobbered. + */ +export function ClassesEditor({ c, update }: { c: Character; update: (p: Partial) => void }) { + // Fall back to the legacy className/level for any character not yet on classes[]. + const classes: ClassEntry[] = c.classes.length + ? c.classes + : c.className ? [{ className: c.className, level: c.level }] : []; + const names = getClassNames('5e'); + const total = classes.reduce((n, e) => n + (e.level || 0), 0); + + const commit = (next: ClassEntry[]) => { + const mirror = normalizeClassMirror({ classes: next }); + const { slots, pact } = reconcileSlots(c.spellcasting, dnd5eClassesSlots(next)); + const spellcasting: Spellcasting = { ...c.spellcasting, slots }; + if (pact) spellcasting.pact = pact; else delete spellcasting.pact; + update({ classes: next, ...mirror, spellcasting }); + }; + + const patchEntry = (i: number, p: Partial) => commit(classes.map((e, j) => (j === i ? { ...e, ...p } : e))); + const addClass = () => commit([...classes, { className: names.find((n) => !classes.some((e) => e.className === n)) ?? 'Fighter', level: 1 }]); + const removeClass = (i: number) => commit(classes.filter((_, j) => j !== i)); + + const recalcHp = () => { + const max = dnd5eMulticlassHp(classes, abilityModifier(c.abilities.con)); + update({ hp: { ...c.hp, max, current: Math.min(c.hp.current, max) } }); + }; + + return ( +
+
+ Classes · level {Math.min(20, total)} +
+ + +
+
+
+ {classes.map((e, i) => { + const subs = DND5E_SUBCLASSES[e.className] ?? []; + return ( +
+ patchEntry(i, { className: ev.target.value })} aria-label={`Class ${i + 1}`} placeholder="Class" /> + {subs.length > 0 ? ( + + ) : ( + patchEntry(i, { subclass: ev.target.value || undefined })} aria-label={`Subclass ${i + 1}`} placeholder="Subclass" /> + )} + + {classes.length > 1 && ( + + )} +
+ ); + })} + {classes.length === 0 &&

No class set.

} +
+ {names.map((n) => +
+ ); +} diff --git a/src/features/characters/sheet/LevelUpModal.tsx b/src/features/characters/sheet/LevelUpModal.tsx index 26f0123..0ba47d0 100644 --- a/src/features/characters/sheet/LevelUpModal.tsx +++ b/src/features/characters/sheet/LevelUpModal.tsx @@ -1,9 +1,11 @@ import { useMemo, useState } from 'react'; -import type { Campaign, Character } from '@/lib/schemas'; +import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas'; +import { normalizeClassMirror, totalLevel } from '@/lib/schemas'; import { - abilityModifier, getSystem, ABILITY_ABBR, type AbilityKey, type ProficiencyRank, + abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank, planLevelUp, applyIncreases, bumpRank, getClassDef, } from '@/lib/rules'; +import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression'; import { rollDice } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { Modal } from '@/components/ui/Modal'; @@ -19,12 +21,25 @@ export function LevelUpModal({ character, onApply, onClose }: { onClose: () => void; }) { const conMod = abilityModifier(character.abilities.con); + const is5e = character.system === '5e'; + + // 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class. + const classList: ClassEntry[] = character.classes.length + ? character.classes + : character.className ? [{ className: character.className, level: character.level }] : []; + const NEW = '__new__'; + const [levelClass, setLevelClass] = useState(classList[0]?.className ?? character.className); + const [newClassName, setNewClassName] = useState(getClassNames('5e').find((n) => !classList.some((e) => e.className === n)) ?? 'Fighter'); + const levelingName = is5e ? (levelClass === NEW ? newClassName : levelClass) : character.className; + const classCurrentLevel = is5e ? (classList.find((e) => e.className === levelingName)?.level ?? 0) : character.level; + const plan = useMemo( - () => planLevelUp(character.system, character.className, character.level, conMod), - [character.system, character.className, character.level, conMod], + () => planLevelUp(character.system, levelingName, classCurrentLevel, conMod), + [character.system, levelingName, classCurrentLevel, conMod], ); const sys = getSystem(character.system); - const def = getClassDef(character.system, character.className); + const def = getClassDef(character.system, levelingName); + const total = totalLevel(character); const [hpMethod, setHpMethod] = useState<'average' | 'roll'>('average'); const asi = plan.choices.find((c) => c.kind === 'asi'); @@ -40,7 +55,7 @@ export function LevelUpModal({ character, onApply, onClose }: { // Skill increase (pf2e): one skill to bump. const [skillKey, setSkillKey] = useState(sys.skills[0]?.key ?? ''); - const atMax = character.level >= 20; + const atMax = total >= 20; const apply = () => { const gain = character.system === 'pf2e' ? plan.hpGainAverage @@ -51,13 +66,31 @@ export function LevelUpModal({ character, onApply, onClose }: { if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e'); const patch: Partial = { - level: plan.nextLevel, hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain }, abilities, }; - if (plan.slots) { - patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) }; + + if (is5e) { + // Bump the chosen class (or add a new one), re-derive the mirrors + combined slots. + const nextClasses: ClassEntry[] = levelClass === NEW + ? [...classList, { className: newClassName, level: 1 }] + : classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e)); + Object.assign(patch, normalizeClassMirror({ classes: nextClasses })); + patch.classes = nextClasses; + const derived = dnd5eClassesSlots(nextClasses); + const slots = derived.slots.map((r) => { + const ex = character.spellcasting.slots.find((s) => s.level === r.level); + return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max }; + }); + const spellcasting: Spellcasting = { ...character.spellcasting, slots }; + if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) }; + else delete spellcasting.pact; + patch.spellcasting = spellcasting; + } else { + patch.level = plan.nextLevel; + if (plan.slots) patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) }; } + if (skillInc && skillKey) { const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained'; patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) }; @@ -74,12 +107,12 @@ export function LevelUpModal({ character, onApply, onClose }: { - + } > @@ -87,6 +120,24 @@ export function LevelUpModal({ character, onApply, onClose }: {

Already at level 20.

) : (
+ {/* Which class (5e multiclass) */} + {is5e && classList.length > 0 && ( +
+

Class to advance

+
+ + {levelClass === NEW && ( + + )} +
+
+ )} + {/* HP */}

Hit points

diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index 0b54eef..9ac3eb4 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -151,6 +151,36 @@ export class TtrpgDatabase extends Dexie { if (c.feats === undefined) c.feats = []; }); }); + + // v16 — multiclass: characters gain a `classes[]` array (per-class levels) and + // promote `background` out of the notes string. The legacy className/level become + // mirrors of classes[]. Existing single-class characters become a one-entry list. + this.version(16).stores({}).upgrade(async (tx) => { + await tx.table('characters').toCollection().modify((c: Record) => { + if (!Array.isArray(c.classes) || c.classes.length === 0) { + const className = typeof c.className === 'string' && c.className ? c.className : ''; + const level = typeof c.level === 'number' && c.level > 0 ? c.level : 1; + const notes = typeof c.notes === 'string' ? c.notes : ''; + // Pull "Subclass: X" / "Background: Y" out of the notes free-text (whole-line). + const subMatch = /^Subclass:\s*(.+)$/m.exec(notes); + const bgMatch = /^Background:\s*(.+)$/m.exec(notes); + if (c.background === undefined || c.background === '') c.background = bgMatch ? bgMatch[1]!.trim() : (c.background ?? ''); + c.classes = className + ? [{ className, level, ...(subMatch ? { subclass: subMatch[1]!.trim() } : {}) }] + : []; + // Strip exactly the two parsed lines from notes, keep the rest of the user's text. + c.notes = notes + .split('\n') + .filter((ln) => !/^Subclass:\s*/.test(ln) && !/^Background:\s*/.test(ln)) + .join('\n') + .trim(); + } + if (c.background === undefined) c.background = ''; + if (c.alignment === undefined) c.alignment = ''; + if (c.appearance === undefined) c.appearance = ''; + if (c.personality === undefined) c.personality = ''; + }); + }); } } diff --git a/src/lib/rules/dnd5e/progression.ts b/src/lib/rules/dnd5e/progression.ts index 9f43cd4..d18a361 100644 --- a/src/lib/rules/dnd5e/progression.ts +++ b/src/lib/rules/dnd5e/progression.ts @@ -84,6 +84,66 @@ export function dnd5ePact(level: number): { level: number; max: number; current: return p ? { level: p[1], max: p[0], current: p[0] } : undefined; } +// ---------------- Multiclass derivation ---------------- + +type SlotRow = { level: number; max: number; current: number }; +interface ClassPart { className: string; level: number } + +function findDef(name: string): ClassDef | undefined { + const want = name.trim().toLowerCase(); + return DND5E_CLASSES.find((c) => c.name.toLowerCase() === want); +} + +/** + * Total HP for a (possibly multiclass) character: the very first character level uses + * the max of its class's die; every later class level adds that class's average die + + * CON. Equivalent to dnd5eHp for a single class. + */ +export function dnd5eMulticlassHp(classes: readonly ClassPart[], conMod: number): number { + const dice: number[] = []; + for (const e of classes) { + const die = findDef(e.className)?.hitDie ?? 8; + for (let i = 0; i < clamp(e.level); i++) dice.push(die); + } + if (dice.length === 0) return 1; + let hp = dice[0]! + conMod; // first character level: max die + CON + for (let i = 1; i < dice.length; i++) hp += Math.ceil(dice[i]! / 2) + 1 + conMod; + return Math.max(1, hp); +} + +/** Bucket caster levels (raw) for the PHB multiclass slot formula. Warlock pact is excluded. */ +export function dnd5eMulticlassSpellLevel(classes: readonly ClassPart[]): { full: number; half: number; third: number } { + let full = 0, half = 0; + for (const e of classes) { + const def = findDef(e.className); + if (!def) continue; + if (def.caster === 'full') full += clamp(e.level); + else if (def.caster === 'half') half += clamp(e.level); + } + return { full, half, third: 0 }; +} + +/** + * Spell slots for a (possibly multiclass) 5e character, plus warlock pact slots. + * One spellcasting class → that class's own table; two or more → the PHB multiclass + * table; warlock pact magic is always separate. New `current` defaults to full. + */ +export function dnd5eClassesSlots(classes: readonly ClassPart[]): { slots: SlotRow[]; pact?: SlotRow } { + const casters = classes + .map((e) => ({ e, def: findDef(e.className) })) + .filter((x): x is { e: ClassPart; def: ClassDef } => !!x.def); + const spellClasses = casters.filter((x) => x.def.caster === 'full' || x.def.caster === 'half'); + const warlock = casters.find((x) => x.def.caster === 'pact'); + const pact = warlock ? dnd5ePact(warlock.e.level) : undefined; + let slots: SlotRow[] = []; + if (spellClasses.length === 1) { + slots = dnd5eSlots(spellClasses[0]!.def.caster, spellClasses[0]!.e.level); + } else if (spellClasses.length >= 2) { + slots = multiclassSlots(dnd5eMulticlassSpellLevel(classes)); + } + return { slots, ...(pact ? { pact } : {}) }; +} + /** Average HP at a level: max die at L1, then per-level average + CON each level. */ export function dnd5eHp(hitDie: number, level: number, conMod: number): number { const lvl = clamp(level); diff --git a/src/lib/rules/multiclass.test.ts b/src/lib/rules/multiclass.test.ts index cebb7e8..e0e04a8 100644 --- a/src/lib/rules/multiclass.test.ts +++ b/src/lib/rules/multiclass.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { multiclassCasterLevel, multiclassSlots } from './dnd5e/progression'; +import { multiclassCasterLevel, multiclassSlots, dnd5eMulticlassHp, dnd5eClassesSlots, dnd5eSlots, dnd5eHp } from './dnd5e/progression'; describe('multiclass spell slots (5e PHB)', () => { it('combines caster levels: full ×1, half ÷2, third ÷3 (floored)', () => { @@ -27,3 +27,40 @@ describe('multiclass spell slots (5e PHB)', () => { expect(s.length).toBe(9); // 9 spell levels at the cap }); }); + +describe('dnd5eMulticlassHp', () => { + it('matches single-class HP for one class', () => { + // Fighter (d10) level 3, CON +2: 12 (L1) + 8 (L2) + 8 (L3) = 28 + expect(dnd5eMulticlassHp([{ className: 'Fighter', level: 3 }], 2)).toBe(dnd5eHp(10, 3, 2)); + expect(dnd5eMulticlassHp([{ className: 'Fighter', level: 3 }], 2)).toBe(28); + }); + it('first overall level uses the primary class die at max', () => { + // Wizard 1 (d6) / Fighter 1 (d10), CON +1: 6+1 (wizard L1 max) + (avg d10=6 +1) = 7 + 7 = 14 + expect(dnd5eMulticlassHp([{ className: 'Wizard', level: 1 }, { className: 'Fighter', level: 1 }], 1)).toBe(14); + // Order matters — Fighter first gets the max d10 + expect(dnd5eMulticlassHp([{ className: 'Fighter', level: 1 }, { className: 'Wizard', level: 1 }], 1)).toBe(10 + 1 + (4 + 1)); // 16 + }); +}); + +describe('dnd5eClassesSlots', () => { + it('single caster uses its own table (not the multiclass formula)', () => { + // Paladin 5 alone → HALF table [4,2], NOT floor(5/2)=2 on the full table + expect(dnd5eClassesSlots([{ className: 'Paladin', level: 5 }]).slots).toEqual(dnd5eSlots('half', 5)); + expect(dnd5eClassesSlots([{ className: 'Wizard', level: 5 }]).slots).toEqual(dnd5eSlots('full', 5)); + }); + it('two casters use the PHB multiclass table', () => { + // Paladin 6 / Sorcerer 1 → combined level 3+1=4 → [4,3] + expect(dnd5eClassesSlots([{ className: 'Paladin', level: 6 }, { className: 'Sorcerer', level: 1 }]).slots).toEqual([ + { level: 1, max: 4, current: 4 }, + { level: 2, max: 3, current: 3 }, + ]); + }); + it('warlock pact magic is separate from regular slots', () => { + const r = dnd5eClassesSlots([{ className: 'Warlock', level: 5 }, { className: 'Wizard', level: 3 }]); + expect(r.pact).toEqual({ level: 3, max: 2, current: 2 }); // Warlock 5 pact + expect(r.slots).toEqual(dnd5eSlots('full', 3)); // only Wizard is a "spell-slot" caster here + }); + it('a non-caster multiclass grants no slots', () => { + expect(dnd5eClassesSlots([{ className: 'Fighter', level: 3 }, { className: 'Barbarian', level: 2 }]).slots).toEqual([]); + }); +}); diff --git a/src/lib/schemas/character.ts b/src/lib/schemas/character.ts index db5262e..bead4c5 100644 --- a/src/lib/schemas/character.ts +++ b/src/lib/schemas/character.ts @@ -39,6 +39,14 @@ export type CharacterResource = z.infer; export const abilityKeySchema = z.enum(['str', 'dex', 'con', 'int', 'wis', 'cha']); +/** One class a character has levels in. `classes[0]` is the primary class. */ +export const classEntrySchema = z.object({ + className: z.string().min(1).max(80), + level: int.min(1).max(20).default(1), + subclass: z.string().max(80).optional(), +}); +export type ClassEntry = z.infer; + /** Worn body armor, snapshotted from the compendium so AC is computed correctly. */ export const equippedArmorSchema = z.object({ name: z.string().max(120), @@ -144,9 +152,12 @@ export const characterSchema = z.object({ portrait: z.string().optional(), /** ancestry (PF2e) / race (5e), free text for MVP */ ancestry: z.string().max(80).default(''), - /** class, free text for MVP */ + /** Primary class name — a mirror of classes[0].className (kept for display/back-compat). */ className: z.string().max(80).default(''), + /** Total character level — a mirror of sum(classes[].level). */ level: int.min(1).max(20).default(1), + /** Per-class levels for multiclassing. Empty pre-migration → derive from className/level. */ + classes: z.array(classEntrySchema).default([]), /** background / heritage (promoted out of the notes string). */ background: z.string().max(80).default(''), /** alignment, free text (e.g. "Chaotic Good", "Unaligned"). */ @@ -201,6 +212,32 @@ export const characterSchema = z.object({ export type Character = z.infer; +/** Minimal shape the class helpers operate on (works on Character or a draft). */ +type WithClasses = { classes?: ClassEntry[]; className?: string; level?: number }; + +/** The primary class name: classes[0] if present, else the legacy className mirror. */ +export function primaryClass(c: WithClasses): string { + return c.classes?.[0]?.className ?? c.className ?? ''; +} + +/** Total character level: sum of class levels if present, else the legacy level mirror. */ +export function totalLevel(c: WithClasses): number { + if (c.classes && c.classes.length > 0) { + return Math.min(20, Math.max(1, c.classes.reduce((n, e) => n + (e.level || 0), 0))); + } + return c.level ?? 1; +} + +/** + * Recompute the className/level mirrors from classes[] so consumers that read them + * (and the ~30 sites reading c.level as total level) stay correct. No-op when classes + * is empty. Returns only the changed mirror fields. + */ +export function normalizeClassMirror(c: WithClasses): { className: string; level: number } | Record { + if (!c.classes || c.classes.length === 0) return {}; + return { className: primaryClass(c), level: totalLevel(c) }; +} + export const characterDraftSchema = characterSchema.pick({ name: true, kind: true, @@ -213,9 +250,10 @@ export type CharacterDraft = z.infer; /** Default values for the Phase-1 depth fields. Used by create + DB migration. */ export function characterDefaults(): Pick< Character, - 'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality' + 'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality' | 'classes' > { return { + classes: [], background: '', alignment: '', appearance: '', diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 17793f3..591668f 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/NotFound.tsx","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/components/ui/useConfirm.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatsSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/assistant/signals.test.ts","./src/lib/assistant/signals.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/mpmb.test.ts","./src/lib/compendium/mpmb.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/multiclass.test.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/connectivityStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/NotFound.tsx","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/components/ui/useConfirm.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatsSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/assistant/signals.test.ts","./src/lib/assistant/signals.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/mpmb.test.ts","./src/lib/compendium/mpmb.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/multiclass.test.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/connectivityStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file