diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index 36f02ed..f695beb 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -15,6 +15,7 @@ import { newId } from '@/lib/ids'; import { formatModifier } from '@/lib/format'; import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder'; import { briefOverview, dedupeByName } from './overview'; +import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin'; import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; @@ -24,9 +25,15 @@ import { NumberField } from '@/components/ui/NumberField'; import { cn } from '@/lib/cn'; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; -type AbilityMethod = 'standard' | 'pointbuy' | 'roll'; +type AbilityMethod = 'standard' | 'pointbuy' | 'roll' | 'manual'; -interface Origin { name: string; desc: string; meta?: string; hp?: number; speed?: number } +interface Origin { + name: string; desc: string; meta?: string; hp?: number; speed?: number; + /** 5e racial ability score increases, applied to the final scores. */ + asiBonuses?: Partial; + /** skill keys this origin grants as trained (background skills, racial proficiencies). */ + skillGrants?: string[]; +} interface SpellOpt { name: string; level: number; meta: string } /** A class's "what it plays like" tag, to help newcomers pick. */ @@ -88,19 +95,24 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl useEffect(() => { let on = true; if (system === '5e') { + const resolve = makeSkillResolver(sys.skills); void loadRaces5e().then((rs) => { if (!on) return; const strip = (s: string) => s.replace(/\*{2,3}[^*]+\*{2,3}\s*/g, '').trim(); setOrigins(rs.map((r) => { const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n'); const speedFt = /(\d+) feet/.exec(r.speed)?.[1]; - const asiMatches = [...r.asi.matchAll(/(\w+) score (?:each )?increases? by (\d+)/g)]; - const asiSummary = asiMatches.map(([, ability, n]) => ability && n ? `+${n} ${ability.slice(0, 3).toUpperCase()}` : '').filter(Boolean).join(', '); + const asiBonuses = parseAsiBonuses(r.asi ?? ''); + const asiSummary = ABILITIES.filter((a) => asiBonuses[a]).map((a) => `+${asiBonuses[a]} ${ABILITY_ABBR[a]}`).join(', '); const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · '); - return { name: r.name, desc, meta }; + const skillGrants = parseTraitSkills(r.traits ?? '', resolve); + return { name: r.name, desc, meta, asiBonuses, ...(skillGrants.length ? { skillGrants } : {}) }; })); }); - void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills })))); + void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => { + const skillGrants = parseBackgroundSkills(b.skills ?? '', resolve); + return { name: b.name, desc: b.desc, meta: b.skills, ...(skillGrants.length ? { skillGrants } : {}) }; + }))); } else { void loadPf2e('ancestries').then((rs) => { if (!on) return; @@ -136,6 +148,8 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl const selectedClass = classes.find((c) => c.slug === classSlug) ?? null; const selectedOrigin = origins.find((o) => o.name === ancestry) ?? null; + const selectedBackground = backgrounds.find((b) => b.name === background) ?? null; + const classDef = selectedClass ? getClassDef(system, selectedClass.name) : undefined; const isCaster = !!selectedClass && selectedClass.caster !== 'none'; // ---- abilities ---- @@ -152,28 +166,44 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl const abilities = useMemo(() => { const out = {} as AbilityScores; ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; }); + // 5e: fold the chosen race's ability score increases into the final scores. + if (system === '5e' && selectedOrigin?.asiBonuses) { + for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0; + } return out; - }, [usesPool, pool, assignment, pb]); + }, [usesPool, pool, assignment, pb, system, selectedOrigin]); const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length; const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0; // ---- skills ---- const [skills, setSkills] = useState([]); const intMod = abilityModifier(abilities.int); + // Prefer the curated class table (canonical free-choice count) over the parsed + // data file, which over-counts pf2e classes and mangles some 5e skill names. + const baseSkillCount = classDef?.skillCount ?? selectedClass?.skillCount ?? 2; const skillCount = selectedClass - ? selectedClass.skillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0) + ? baseSkillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0) : 2; const skillOptions = useMemo(() => { - if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys; - // map class skill labels → our skill keys - const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase())); + if (!selectedClass) return allSkillKeys; + // Curated class skill list (canonical for 5e); empty curated list = choose from all. + if (classDef && classDef.skillKeys.length > 0) return classDef.skillKeys; + if (selectedClass.skillChoices.length === 0) return allSkillKeys; + // Fallback to the parsed data, tolerating Oxford "and X" remnants. + const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase().replace(/^and\s+/, ''))); const labelOf = (k: string) => sys.skills.find((s) => s.key === k)?.label.toLowerCase() ?? k.toLowerCase(); const matched = allSkillKeys.filter((k) => wanted.has(labelOf(k)) || wanted.has(k.toLowerCase())); return matched.length ? matched : allSkillKeys; - }, [selectedClass, allSkillKeys, sys]); + }, [selectedClass, classDef, allSkillKeys, sys]); const toggleSkill = (key: string) => setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev)); + // 5e skill proficiencies granted by background + race (applied as trained, on top of class picks). + const grantedSkills = useMemo( + () => (system === '5e' ? [...(selectedOrigin?.skillGrants ?? []), ...(selectedBackground?.skillGrants ?? [])] : []), + [system, selectedOrigin, selectedBackground], + ); + // ---- spells ---- const [spellPicks, setSpellPicks] = useState([]); const [spellQuery, setSpellQuery] = useState(''); @@ -195,9 +225,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl // ---- derived build ---- const built = useMemo(() => buildCharacter(system, { className: selectedClass?.name ?? '', level, abilities, skillChoices: skills, + ...(grantedSkills.length ? { grantedSkills } : {}), ...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}), ...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}), - }), [system, selectedClass, level, abilities, skills, selectedOrigin]); + }), [system, selectedClass, level, abilities, skills, grantedSkills, 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. @@ -226,7 +257,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl 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([]); } - setMethod('pointbuy'); + // Templates can use scores above the point-buy cap (PF2e starts a key stat at 18), + // which would softlock the point-buy step ("-Infinity / 27"). Use manual entry then. + const fitsPointBuy = ABILITIES.every((a) => t.ability[a] >= POINT_BUY_MIN && t.ability[a] <= POINT_BUY_MAX); + setMethod(fitsPointBuy ? 'pointbuy' : 'manual'); setPb(ABILITIES.map((a) => t.ability[a])); if (!name) setName(t.label.replace(/^[^A-Za-z]+/, '')); }; @@ -395,13 +429,19 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl {stepName === 'Abilities' && (
-
- {([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => ( +
+ {([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => ( ))}
{method === 'roll' && } {method === 'pointbuy' &&

Points remaining: {pointBuyRemaining(pb)} / 27

} + {method === 'manual' &&

Enter each score directly (1–30).

} + {system === '5e' && selectedOrigin && Object.values(selectedOrigin.asiBonuses ?? {}).some(Boolean) && ( +

+ {selectedOrigin.name} racial bonus ({ABILITIES.filter((a) => selectedOrigin.asiBonuses?.[a]).map((a) => `+${selectedOrigin.asiBonuses![a]} ${ABILITY_ABBR[a]}`).join(', ')}) is added to your final scores. +

+ )} {selectedClass && (() => { const tip = getClassTip(system, selectedClass.slug); const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0 @@ -417,19 +457,35 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl {usesPool && !poolValid &&

Assign each value to a different ability.

}
{ABILITIES.map((a, i) => { - const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!; + const racial = (system === '5e' && selectedOrigin?.asiBonuses?.[a]) || 0; + const base = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!; + const value = base + racial; const isKey = selectedClass?.keyAbilities.includes(a); + const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1; + const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30; return (
{ABILITY_ABBR[a]}{isKey && }
{usesPool ? ( - { + const v = Number(e.target.value); + // Swap on collision so a value can be moved between abilities directly. + setAssignment((prev) => { + const next = [...prev]; + const j = next.findIndex((x, k) => x === v && k !== i); + if (j !== -1) next[j] = next[i]!; + next[i] = v; + return next; + }); + }}> + {pool.map((p, idx) => )} ) : ( - setPb((prev) => prev.map((x, j) => (j === i ? Math.max(POINT_BUY_MIN, Math.min(POINT_BUY_MAX, v)) : x)))} /> + setPb((prev) => prev.map((x, j) => (j === i ? Math.max(fMin, Math.min(fMax, v)) : x)))} /> )} -
{formatModifier(abilityModifier(value))}
+
+ {racial ? {base}+{racial} · : null}{formatModifier(abilityModifier(value))} +
); })} diff --git a/src/features/characters/builder/origin.test.ts b/src/features/characters/builder/origin.test.ts new file mode 100644 index 0000000..fbba4a8 --- /dev/null +++ b/src/features/characters/builder/origin.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin'; +import { getSystem } from '@/lib/rules'; + +const resolve = makeSkillResolver(getSystem('5e').skills); + +describe('parseAsiBonuses', () => { + it('parses a single ability increase', () => { + expect(parseAsiBonuses('Your Dexterity score increases by 2.')).toEqual({ dex: 2 }); + }); + it('parses two abilities (Half-Orc)', () => { + expect(parseAsiBonuses('Your Strength score increases by 2, and your Constitution score increases by 1.')) + .toEqual({ str: 2, con: 1 }); + }); + it('parses Human "+1 to all"', () => { + expect(parseAsiBonuses('Your ability scores each increase by 1.')) + .toEqual({ str: 1, dex: 1, con: 1, int: 1, wis: 1, cha: 1 }); + }); + it('is empty for prose with no ASI', () => { + expect(parseAsiBonuses('You have darkvision.')).toEqual({}); + }); +}); + +describe('parseBackgroundSkills', () => { + it('takes both fixed skills (Acolyte)', () => { + expect(parseBackgroundSkills('Insight, Religion', resolve)).toEqual(['insight', 'religion']); + }); + it('stops at a choose-one clause (Charlatan)', () => { + expect(parseBackgroundSkills('Deception, and either Culture, Insight, or Sleight of Hand.', resolve)).toEqual(['deception']); + }); + it('handles "plus your choice" (Crime Syndicate Member)', () => { + expect(parseBackgroundSkills('Deception, plus your choice of one between Sleight of Hand or Stealth.', resolve)).toEqual(['deception']); + }); +}); + +describe('parseTraitSkills', () => { + it('grants Perception from Elf Keen Senses', () => { + expect(parseTraitSkills('Keen Senses. You have proficiency in the Perception skill.', resolve)).toEqual(['perception']); + }); + it('grants Intimidation from Half-Orc Menacing', () => { + expect(parseTraitSkills('Menacing. You gain proficiency in the Intimidation skill.', resolve)).toEqual(['intimidation']); + }); +}); diff --git a/src/features/characters/builder/origin.ts b/src/features/characters/builder/origin.ts new file mode 100644 index 0000000..173fa34 --- /dev/null +++ b/src/features/characters/builder/origin.ts @@ -0,0 +1,58 @@ +import type { AbilityKey, AbilityScores } from '@/lib/rules'; + +const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; + +const ABILITY_BY_WORD: Record = { + strength: 'str', dexterity: 'dex', constitution: 'con', + intelligence: 'int', wisdom: 'wis', charisma: 'cha', +}; + +/** + * Parse a 5e race's ASI prose into structured ability bonuses. Handles the Human + * "Your ability scores each increase by 1" case and the usual "Your X score + * increases by N[, and your Y score increases by M]" form. + */ +export function parseAsiBonuses(asi: string): Partial { + const out: Partial = {}; + const each = /ability scores?\s+each\s+increase(?:s)?\s+by\s+(\d+)/i.exec(asi); + if (each) { + const n = Number(each[1]); + for (const a of ABILITIES) out[a] = (out[a] ?? 0) + n; + return out; + } + for (const m of asi.matchAll(/(strength|dexterity|constitution|intelligence|wisdom|charisma)\s+score\s+(?:each\s+)?increase(?:s)?\s+by\s+(\d+)/gi)) { + const key = ABILITY_BY_WORD[m[1]!.toLowerCase()]; + if (key) out[key] = (out[key] ?? 0) + Number(m[2]); + } + return out; +} + +/** A "skill label/key → key" resolver for the active system. */ +export function makeSkillResolver(skills: readonly { key: string; label: string }[]): (name: string) => string | undefined { + const byLabel = new Map(); + for (const s of skills) { byLabel.set(s.label.toLowerCase(), s.key); byLabel.set(s.key.toLowerCase(), s.key); } + return (name) => byLabel.get(name.trim().toLowerCase()); +} + +/** Fixed skill proficiencies a 5e background grants (ignores "either/or/choice" clauses). */ +export function parseBackgroundSkills(skills: string, resolve: (n: string) => string | undefined): string[] { + const out: string[] = []; + for (let token of skills.split(',')) { + token = token.replace(/\b(and|plus)\b/gi, '').replace(/\.$/, '').trim(); + if (!token) continue; + if (/either|\bor\b|choice|between/i.test(token)) break; // a choose-one clause — stop here + const key = resolve(token); + if (key && !out.includes(key)) out.push(key); + } + return out; +} + +/** Skill proficiencies granted by racial traits ("proficiency in the X skill"). */ +export function parseTraitSkills(traits: string, resolve: (n: string) => string | undefined): string[] { + const out: string[] = []; + for (const m of traits.matchAll(/proficiency in the ([A-Za-z][A-Za-z ]*?) skill/gi)) { + const key = resolve(m[1]!); + if (key && !out.includes(key)) out.push(key); + } + return out; +} diff --git a/src/lib/rules/progression.test.ts b/src/lib/rules/progression.test.ts index 7859b43..69efff5 100644 --- a/src/lib/rules/progression.test.ts +++ b/src/lib/rules/progression.test.ts @@ -32,6 +32,14 @@ describe('buildCharacter — 5e', () => { expect(built.skillRanks).toEqual({ athletics: 'trained' }); expect(built.spellcasting.slots).toEqual([]); }); + it('merges background/racial granted skills as trained, alongside class picks', () => { + const built = buildCharacter('5e', { + className: 'Fighter', level: 1, abilities, + skillChoices: ['athletics'], grantedSkills: ['insight', 'religion', 'athletics'], + }); + // class pick + background/race grants; the duplicate (athletics) is not doubled + expect(built.skillRanks).toEqual({ athletics: 'trained', insight: 'trained', religion: 'trained' }); + }); it('gives a full caster spell slots and a spell ability', () => { const built = buildCharacter('5e', { className: 'Wizard', level: 3, abilities, skillChoices: [] }); expect(built.spellcastingAbility).toBe('int'); diff --git a/src/lib/rules/progression.ts b/src/lib/rules/progression.ts index d1c05e7..5f1f7b5 100644 --- a/src/lib/rules/progression.ts +++ b/src/lib/rules/progression.ts @@ -32,9 +32,15 @@ export interface SpellSlot { level: number; max: number; current: number } export interface BuildChoices { className: string; level: number; + /** Final ability scores (the caller folds in racial/ancestry bonuses before building). */ abilities: AbilityScores; /** chosen skill keys → trained */ skillChoices: string[]; + /** + * Extra skill keys granted by background/race/heritage (trained), merged with the + * class picks. A skill already chosen by the class stays trained (no downgrade). + */ + grantedSkills?: string[]; /** PF2e ancestry HP (from the ancestries data); ignored for 5e. */ ancestryHp?: number; /** Used only when the class is unknown (custom): 5e hit die size. */ @@ -92,6 +98,8 @@ export function buildCharacter(system: SystemId, choices: BuildChoices): BuiltCh const skillRanks: Record = {}; for (const key of choices.skillChoices) skillRanks[key] = 'trained'; + // Background / racial skill proficiencies stack on top of the class picks. + for (const key of choices.grantedSkills ?? []) if (!skillRanks[key]) skillRanks[key] = 'trained'; const saveRanks: Record = {}; if (system === 'pf2e') { diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 1925101..efb3de3 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/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/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/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/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/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/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/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/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/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/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/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