diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index 24a92a3..1804de9 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -5,6 +5,7 @@ import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@ import { charactersRepo } from '@/lib/db/repositories'; import { getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS, + pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId, } from '@/lib/rules'; import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen'; @@ -33,6 +34,12 @@ interface Origin { asiBonuses?: Partial; /** skill keys this origin grants as trained (background skills, racial proficiencies). */ skillGrants?: string[]; + /** pf2e ancestry boosts: specific abilities + count of free boosts. */ + ancestryBoosts?: { fixed: AbilityKey[]; free: number }; + /** pf2e legacy ancestry flaw (-2), if any. */ + ancestryFlaw?: AbilityKey; + /** pf2e background boosts: choose-one options + free-boost count. */ + backgroundBoosts?: { options: AbilityKey[]; free: number }; } interface SpellOpt { name: string; level: number; meta: string } @@ -119,19 +126,27 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl setOrigins(dedupeByName(rs.map((r) => { const hp = typeof r.hp === 'number' ? r.hp : undefined; const speed = (r.speed as { land?: number } | undefined)?.land; - const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : []; - const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/'); - const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · '); + const ancestryBoosts = parseAncestryBoosts(r.attribute); + const ancestryFlaw = parseFlaw((r as Record).attribute_flaw ?? (r as Record).flaw); + const fixedSummary = ancestryBoosts.fixed.map((a) => `+${ABILITY_ABBR[a]}`).join('/'); + const freeSummary = ancestryBoosts.free ? `+${ancestryBoosts.free} free` : ''; + const meta = [fixedSummary, freeSummary, ancestryFlaw ? `−${ABILITY_ABBR[ancestryFlaw]}` : '', hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · '); return { name: String(r.name), desc: briefOverview(String((r.summary ?? r.text) ?? '')), ...(hp !== undefined ? { hp } : {}), ...(speed ? { speed } : {}), + ancestryBoosts, + ...(ancestryFlaw ? { ancestryFlaw } : {}), meta, }; }))); }); - void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ name: String(b.name), desc: briefOverview(String((b.description ?? b.text) ?? '')) }))))); + void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ + name: String(b.name), + desc: briefOverview(String((b.description ?? b.text) ?? '')), + backgroundBoosts: parseBackgroundBoosts(b.attribute), + }))))); } return () => { on = false; }; // sys.skills is a stable singleton keyed by `system`, so `system` covers it. @@ -164,15 +179,59 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); } if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); } }; + + // ---- pf2e ability boosts (start at 10; ancestry/background/class/free boosts) ---- + const boostSlots = useMemo(() => { + if (system !== 'pf2e') return null; + const anc = selectedOrigin?.ancestryBoosts ?? { fixed: [], free: 0 }; + const bg = selectedBackground?.backgroundBoosts ?? { options: [], free: 0 }; + const keyOpts = (classDef?.keyAbilities ?? selectedClass?.keyAbilities ?? []) as AbilityKey[]; + const slots: { id: string; label: string; options: AbilityKey[] }[] = []; + for (let i = 0; i < anc.free; i++) slots.push({ id: `anc-free-${i}`, label: 'Ancestry free boost', options: ABILITIES }); + if (bg.options.length) slots.push({ id: 'bg-choice', label: 'Background boost', options: bg.options }); + for (let i = 0; i < bg.free; i++) slots.push({ id: `bg-free-${i}`, label: 'Background free boost', options: ABILITIES }); + if (keyOpts.length) slots.push({ id: 'class', label: 'Class key ability', options: keyOpts }); + for (let i = 0; i < 4; i++) slots.push({ id: `free-${i}`, label: 'Free boost', options: ABILITIES }); + return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots }; + }, [system, selectedOrigin, selectedBackground, classDef, selectedClass]); + + const [boostPicks, setBoostPicks] = useState>({}); + // A boost slot's "source" — boosts within one source must target different abilities. + const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : 'free'); + // Seed a legal default assignment (distinct within each source) when slots change. + useEffect(() => { + if (!boostSlots) return; + const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[]; + const favored = keyOpts[0] ?? 'str'; + const order: AbilityKey[] = [favored, ...ABILITIES.filter((a) => a !== favored)]; + const used: Record> = { ancestry: new Set(boostSlots.fixed), background: new Set(), class: new Set(), free: new Set() }; + const next: Record = {}; + for (const s of boostSlots.slots) { + const u = used[boostSource(s.id)]!; + const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o)) + ?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored; + next[s.id] = pick; + u.add(pick); + } + setBoostPicks(next); + }, [boostSlots, classDef]); + + const pf2eAbilities = useMemo(() => { + if (!boostSlots) return null; + const chosen = boostSlots.slots.map((s) => boostPicks[s.id]).filter((b): b is AbilityKey => !!b); + return pf2eApplyBoosts([...boostSlots.fixed, ...chosen], boostSlots.flaw); + }, [boostSlots, boostPicks]); + const abilities = useMemo(() => { + if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }; 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) { + if (selectedOrigin?.asiBonuses) { for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0; } return out; - }, [usesPool, pool, assignment, pb, system, selectedOrigin]); + }, [system, pf2eAbilities, usesPool, pool, assignment, pb, selectedOrigin]); const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length; const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0; @@ -269,7 +328,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl const stepValid: Record = { Class: !!selectedClass, Origin: true, - Abilities: poolValid && pbValid, + Abilities: system === 'pf2e' ? true : poolValid && pbValid, Skills: skills.length === Math.min(skillCount, skillOptions.length), Spells: true, Review: true, @@ -430,6 +489,51 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl {stepName === 'Abilities' && (
+ {system === 'pf2e' && boostSlots && ( +
+

PF2e scores are built from boosts: each starts at 10, and a boost adds +2 (or +1 once a score reaches 18). Assign your free boosts below.

+ {(boostSlots.fixed.length > 0 || boostSlots.flaw) && ( +
+ {selectedOrigin?.name ?? 'Ancestry'}: + {boostSlots.fixed.map((a, i) => +{ABILITY_ABBR[a]})} + {boostSlots.flaw && −{ABILITY_ABBR[boostSlots.flaw]}} + (fixed) +
+ )} + {boostSlots.slots.length > 0 && ( +
+ {boostSlots.slots.map((s) => { + // Abilities already taken by OTHER slots in the same source (can't double-boost). + const taken = new Set(boostSource(s.id) === 'ancestry' ? boostSlots.fixed : []); + for (const o of boostSlots.slots) { + if (o.id !== s.id && boostSource(o.id) === boostSource(s.id) && boostPicks[o.id]) taken.add(boostPicks[o.id]!); + } + return ( + + ); + })} +
+ )} +
+ {ABILITIES.map((a) => { + const isKey = selectedClass?.keyAbilities.includes(a); + return ( +
+
{ABILITY_ABBR[a]}{isKey && }
+
{abilities[a]}
+
{formatModifier(abilityModifier(abilities[a]))}
+
+ ); + })} +
+
+ )} + {system !== 'pf2e' && (<>
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => ( @@ -491,6 +595,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl ); })}
+ )}
)} diff --git a/src/lib/rules/index.ts b/src/lib/rules/index.ts index ab82de6..bf4c7c2 100644 --- a/src/lib/rules/index.ts +++ b/src/lib/rules/index.ts @@ -19,5 +19,6 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [ export * from './types'; export * from './progression'; export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities'; +export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities'; export { applyRest } from './rest'; export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions'; diff --git a/src/lib/rules/pf2e/abilities.test.ts b/src/lib/rules/pf2e/abilities.test.ts new file mode 100644 index 0000000..7231ceb --- /dev/null +++ b/src/lib/rules/pf2e/abilities.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw } from './abilities'; + +describe('pf2eApplyBoosts', () => { + it('each boost adds +2 below 18', () => { + // Human Fighter: ancestry 2 free (str, con), background (str, free dex), class str, 4 free (str, con, dex, wis) + const scores = pf2eApplyBoosts(['str', 'con', 'str', 'dex', 'str', 'str', 'con', 'dex', 'wis']); + expect(scores.str).toBe(18); // 10 +2+2+2+2 (four boosts) = 18 + expect(scores.con).toBe(14); // two boosts + expect(scores.dex).toBe(14); // two boosts + expect(scores.wis).toBe(12); // one boost + expect(scores.int).toBe(10); + expect(scores.cha).toBe(10); + }); + + it('applies a flaw before boosts', () => { + const scores = pf2eApplyBoosts(['con'], 'cha'); + expect(scores.cha).toBe(8); // flaw -2 + expect(scores.con).toBe(12); + }); + + it('a 5th boost on one ability only adds +1 (already at 18)', () => { + const scores = pf2eApplyBoosts(['str', 'str', 'str', 'str', 'str']); + expect(scores.str).toBe(19); // 18 then +1 + }); +}); + +describe('parseAncestryBoosts', () => { + it('reads fixed boosts plus a Free token (Dwarf)', () => { + expect(parseAncestryBoosts(['Constitution', 'Wisdom', 'Free'])).toEqual({ fixed: ['con', 'wis'], free: 1 }); + }); + it('reads "Two free ability boosts" (Human)', () => { + expect(parseAncestryBoosts(['Two free ability boosts'])).toEqual({ fixed: [], free: 2 }); + }); + it('tolerates junk', () => { + expect(parseAncestryBoosts(undefined)).toEqual({ fixed: [], free: 0 }); + }); +}); + +describe('parseBackgroundBoosts', () => { + it('lists the choose-one options and grants one free boost (Acolyte)', () => { + expect(parseBackgroundBoosts(['Intelligence', 'Wisdom'])).toEqual({ options: ['int', 'wis'], free: 1 }); + }); +}); + +describe('parseFlaw', () => { + it('reads a flaw string or array', () => { + expect(parseFlaw('Charisma')).toBe('cha'); + expect(parseFlaw(['Strength'])).toBe('str'); + expect(parseFlaw(undefined)).toBeUndefined(); + }); +}); diff --git a/src/lib/rules/pf2e/abilities.ts b/src/lib/rules/pf2e/abilities.ts new file mode 100644 index 0000000..707c359 --- /dev/null +++ b/src/lib/rules/pf2e/abilities.ts @@ -0,0 +1,71 @@ +import type { AbilityKey, AbilityScores } from '../types'; + +/** + * PF2e ability scores are built from boosts, not a 5e-style array. Every score + * starts at 10; an ancestry flaw subtracts 2; each boost adds +2 while the score + * is below 18, otherwise +1. Boosts are applied in canonical order (ancestry → + * background → class → free) so the rare >18 case resolves correctly. + */ + +const ABIL_WORD: Record = { + strength: 'str', dexterity: 'dex', constitution: 'con', + intelligence: 'int', wisdom: 'wis', charisma: 'cha', +}; + +const NUM_WORD: Record = { one: 1, two: 2, three: 3, four: 4 }; + +export function abilityWord(word: string): AbilityKey | undefined { + return ABIL_WORD[word.trim().toLowerCase()]; +} + +function base10(): AbilityScores { + return { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }; +} + +/** Apply an ordered list of boosts (and an optional flaw) to a base-10 spread. */ +export function pf2eApplyBoosts(boosts: readonly AbilityKey[], flaw?: AbilityKey | null): AbilityScores { + const s = base10(); + if (flaw) s[flaw] -= 2; + for (const b of boosts) s[b] += s[b] < 18 ? 2 : 1; + return s; +} + +/** + * Parse an ancestry `attribute` array into the fixed boosts and the number of free + * boosts it grants. Handles specific abilities ("Constitution"), the "Free" token, + * and prose like "Two free ability boosts". + */ +export function parseAncestryBoosts(attribute: unknown): { fixed: AbilityKey[]; free: number } { + const arr = Array.isArray(attribute) ? attribute.map(String) : []; + const fixed: AbilityKey[] = []; + let free = 0; + for (const raw of arr) { + const k = abilityWord(raw); + if (k) { fixed.push(k); continue; } + const m = /(\bone\b|\btwo\b|\bthree\b|\bfour\b|\d+)\s+free/i.exec(raw); + if (m) { + const word = m[1]!.toLowerCase(); + const n = NUM_WORD[word] ?? Number(word); + free += Number.isFinite(n) ? n : 1; + } else if (/free/i.test(raw)) free += 1; + } + return { fixed, free }; +} + +/** Parse an ancestry flaw field (legacy ancestries) into a single ability, if any. */ +export function parseFlaw(flaw: unknown): AbilityKey | undefined { + if (Array.isArray(flaw)) { for (const f of flaw) { const k = abilityWord(String(f)); if (k) return k; } return undefined; } + if (typeof flaw === 'string') return abilityWord(flaw); + return undefined; +} + +/** + * Parse a background `attribute` array. A PF2e background grants one boost chosen + * from its listed abilities plus one free boost; the data lists the choose-from + * options. Returns the options and the free-boost count (1). + */ +export function parseBackgroundBoosts(attribute: unknown): { options: AbilityKey[]; free: number } { + const arr = Array.isArray(attribute) ? attribute.map(String) : []; + const options = arr.map(abilityWord).filter((k): k is AbilityKey => !!k); + return { options, free: options.length ? 1 : 0 }; +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index a73b7b9..008a174 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/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/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/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/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