diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index a90f905..8a826c9 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -50,8 +50,11 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter mutate((e) => logEvent(applyInitiatives(e, rolls), 'Rolled initiative for all combatants')); }; - // Difficulty budget from monster combatants vs the campaign's PCs. - const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level); + // Difficulty budget from monster combatants vs the campaign's PCs. Once combat + // has started, rate against the levels captured at that time (snapshot) so the + // reading — and the assistant's history — reflects the party as it was. + const currentLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level); + const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels; const monsters = encounter.combatants .filter((c) => c.kind === 'monster') .map((c) => ({ cr: c.cr, level: c.level })); @@ -97,7 +100,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter diff --git a/src/lib/assistant/context.test.ts b/src/lib/assistant/context.test.ts new file mode 100644 index 0000000..51e03a0 --- /dev/null +++ b/src/lib/assistant/context.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { buildCampaignContext, pickCreatureCandidates } from './context'; +import { characterDefaults, type Campaign, type Character, type Encounter } from '@/lib/schemas'; + +function campaign(system: Campaign['system']): Campaign { + return { id: 'c', name: 'Test', system, description: '', createdAt: '', updatedAt: '' }; +} +function pc(name: string, level: number, system: Campaign['system']): Character { + return { + id: name, campaignId: 'c', system, kind: 'pc', name, ancestry: '', className: 'Fighter', level, + abilities: { str: 14, dex: 12, con: 13, int: 10, wis: 11, cha: 9 }, + hp: { current: 20, max: 20, temp: 0 }, speed: 30, armorBonus: 0, + skillRanks: {}, saveRanks: {}, perceptionRank: 'trained', + ...characterDefaults(), notes: '', createdAt: '', updatedAt: '', + }; +} +function enc(combatants: { cr?: number; level?: number }[], over: Partial = {}): Encounter { + return { + id: 'e', campaignId: 'c', name: 'Fight', status: 'ended', round: 1, turnIndex: 0, + combatants: combatants.map((c, i) => ({ + id: `m${i}`, name: 'Mob', kind: 'monster', initiative: 10, initBonus: 0, ac: 12, + hp: { current: 5, max: 5, temp: 0 }, conditions: [], notes: '', ...c, + })), + log: [], createdAt: '2026-01-01', updatedAt: '2026-01-01', ...over, + }; +} + +describe('buildCampaignContext', () => { + it('states the right system constraint and forbids the other (pf2e)', () => { + const ctx = buildCampaignContext({ campaign: campaign('pf2e'), characters: [pc('A', 3, 'pf2e')], encounters: [], quests: [], notes: [] }); + expect(ctx.systemLabel).toMatch(/pathfinder/i); + expect(ctx.systemConstraint).toMatch(/pathfinder/i); + expect(ctx.systemConstraint.toLowerCase()).toContain('never suggest content from any other game system'); + expect(ctx.partyLevels).toEqual([3]); + }); + + it('rates recent encounters using the party-level snapshot when present', () => { + const snap = enc([{ cr: 1 }, { cr: 1 }], { partyLevelsSnapshot: [1, 1, 1, 1] }); + const ctx = buildCampaignContext({ campaign: campaign('5e'), characters: [pc('A', 10, '5e')], encounters: [snap], quests: [], notes: [] }); + expect(ctx.recentEncounters).toHaveLength(1); + expect(ctx.recentEncounters[0]!.basedOnSnapshot).toBe(true); + // Two CR-1 monsters vs four level-1 PCs is a real fight, not trivial. + expect(ctx.recentEncounters[0]!.difficulty).not.toBe('trivial'); + }); +}); + +describe('pickCreatureCandidates', () => { + it('returns only level-appropriate 5e creatures, using cr', () => { + const pool = [{ name: 'Rat', cr: 0, armor_class: 10, hit_points: 1 }, { name: 'Ogre', cr: 2 }, { name: 'Tarrasque', cr: 30 }]; + const got = pickCreatureCandidates('5e', [3, 3, 3, 3], pool, 'Medium'); + const names = got.map((c) => c.name); + expect(names).toContain('Ogre'); + expect(names).not.toContain('Tarrasque'); + expect(got.find((c) => c.name === 'Rat')?.ac).toBe(10); + }); + it('uses level (not cr) for pf2e', () => { + const pool = [{ name: 'Goblin', level: 1, ac: 16, hp: 9 }, { name: 'Dragon', level: 18 }]; + const got = pickCreatureCandidates('pf2e', [2, 2, 2, 2], pool, 'Moderate'); + expect(got.map((c) => c.name)).toEqual(['Goblin']); + expect(got[0]!.hp).toBe(9); + }); +}); diff --git a/src/lib/assistant/context.ts b/src/lib/assistant/context.ts new file mode 100644 index 0000000..2d69552 --- /dev/null +++ b/src/lib/assistant/context.ts @@ -0,0 +1,137 @@ +import type { Campaign, Character, Encounter, Note, Quest } from '@/lib/schemas'; +import { getSystem, type SystemId } from '@/lib/rules'; +import { computeBudget } from '@/lib/combat/budget'; + +export interface PartyMember { + name: string; + className: string; + level: number; + abilities: Character['abilities']; + keyResources: string[]; +} + +export interface RecentEncounter { + name: string; + difficulty: string; + ratingXp: number; + basedOnSnapshot: boolean; + outcome?: string; +} + +export interface CampaignContext { + system: SystemId; + systemLabel: string; + /** Leads every prompt — the anti-cross-system-hallucination anchor. */ + systemConstraint: string; + partyLevels: number[]; + party: PartyMember[]; + recentEncounters: RecentEncounter[]; + questsSummary: string; + notesSummary: string; +} + +export interface CreatureCandidate { + name: string; + rating: number; + ac?: number; + hp?: number; + ref?: string; +} + +function num(v: unknown): number | undefined { + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +} + +/** Assemble a grounded, system-aware snapshot of the campaign for LLM prompts. */ +export function buildCampaignContext(input: { + campaign: Campaign; + characters: Character[]; + encounters: Encounter[]; + quests: Quest[]; + notes: Note[]; +}): CampaignContext { + const { campaign, characters, encounters, quests, notes } = input; + const system = campaign.system; + const systemLabel = getSystem(system).label; + const systemConstraint = + `This is a ${systemLabel} (system id: ${system}) campaign. ` + + `Only recommend ${systemLabel} rules, creatures, classes, feats, and options. ` + + `Never suggest content from any other game system (e.g. do not mention D&D 5e content in a Pathfinder 2e game, or vice versa).`; + + const pcs = characters.filter((c) => c.kind === 'pc'); + const partyLevels = pcs.map((c) => c.level); + const party: PartyMember[] = pcs.map((c) => ({ + name: c.name, + className: c.className || 'Adventurer', + level: c.level, + abilities: c.abilities, + keyResources: c.resources.map((r) => r.name), + })); + + const recentEncounters: RecentEncounter[] = [...encounters] + .filter((e) => e.status !== 'planning') + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 8) + .map((e) => { + const levels = e.partyLevelsSnapshot?.length ? e.partyLevelsSnapshot : partyLevels; + const budget = computeBudget(system, levels, e.combatants); + return { + name: e.name, + difficulty: budget.difficulty, + ratingXp: budget.ratingXp, + basedOnSnapshot: !!e.partyLevelsSnapshot?.length, + ...(e.outcome ? { outcome: e.outcome } : {}), + }; + }); + + const activeQuests = quests.filter((q) => q.status === 'active'); + const questsSummary = activeQuests.length + ? `${activeQuests.length} active: ${activeQuests.map((q) => q.title).slice(0, 8).join('; ')}` + : 'No active quests.'; + const notesSummary = notes.length + ? `${notes.length} notes: ${notes.map((n) => n.title).slice(0, 8).join('; ')}` + : 'No notes yet.'; + + return { system, systemLabel, systemConstraint, partyLevels, party, recentEncounters, questsSummary, notesSummary }; +} + +/** + * Pull a shortlist of level/CR-appropriate creatures from the correct system's + * bestiary, so LLM recommendations are grounded in real data (no hallucination). + * Reuses the eligibility window from `buildSuggestedEncounter`. + */ +export function pickCreatureCandidates( + system: SystemId, + partyLevels: number[], + pool: Record[], + targetDifficulty: string, + limit = 12, +): CreatureCandidate[] { + const avg = partyLevels.length ? Math.round(partyLevels.reduce((a, b) => a + b, 0) / partyLevels.length) : 1; + const is5e = system === '5e'; + + const normalized = pool + .map((m): CreatureCandidate | null => { + const rating = is5e ? num(m.cr) : num(m.level); + const name = typeof m.name === 'string' ? m.name : undefined; + if (rating === undefined || !name) return null; + const c: CreatureCandidate = { name, rating }; + const ac = num(is5e ? m.armor_class : m.ac); + const hp = num(is5e ? m.hit_points : m.hp); + const ref = typeof m.slug === 'string' ? m.slug : name; + if (ac !== undefined) c.ac = ac; + if (hp !== undefined) c.hp = hp; + c.ref = ref; + return c; + }) + .filter((c): c is CreatureCandidate => c !== null) + .filter((c) => + is5e ? c.rating <= avg + 1 && c.rating >= Math.max(0, avg - 6) : c.rating <= avg + 2 && c.rating >= avg - 4, + ); + + // Harder targets favour the top of the window; easier ones the bottom. + const harder = /hard|deadly|severe|extreme/i.test(targetDifficulty); + normalized.sort((a, b) => (harder ? b.rating - a.rating : a.rating - b.rating)); + return normalized.slice(0, limit); +} diff --git a/src/lib/assistant/patterns.test.ts b/src/lib/assistant/patterns.test.ts new file mode 100644 index 0000000..849654a --- /dev/null +++ b/src/lib/assistant/patterns.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { difficultyTendency, detectThemes } from './patterns'; +import { buildCampaignContext } from './context'; +import type { Campaign, Character, Encounter } from '@/lib/schemas'; +import { characterDefaults } from '@/lib/schemas'; + +function enc(id: string, crs: number[], over: Partial = {}): Encounter { + return { + id, campaignId: 'c', name: id, status: 'ended', round: 1, turnIndex: 0, + combatants: crs.map((cr, i) => ({ + id: `${id}-${i}`, name: 'Mob', kind: 'monster', initiative: 10, initBonus: 0, ac: 12, + hp: { current: 5, max: 5, temp: 0 }, conditions: [], notes: '', cr, + })), + log: [], createdAt: `2026-01-0${id}`, updatedAt: '', ...over, + }; +} +function campaign(): Campaign { + return { id: 'c', name: 'T', system: '5e', description: '', createdAt: '', updatedAt: '' }; +} +function pc(level: number): Character { + return { + id: 'p', campaignId: 'c', system: '5e', kind: 'pc', name: 'P', ancestry: '', className: 'Fighter', level, + abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, + hp: { current: 10, max: 10, temp: 0 }, speed: 30, armorBonus: 0, + skillRanks: {}, saveRanks: {}, perceptionRank: 'trained', + ...characterDefaults(), notes: '', createdAt: '', updatedAt: '', + }; +} + +describe('difficultyTendency', () => { + it('flags a too-easy trend for a high-level party fighting weak mobs', () => { + const fights = [enc('1', [0]), enc('2', [0.125]), enc('3', [0.25]), enc('4', [0])]; + const t = difficultyTendency(fights, '5e', [12, 12, 12, 12]); + expect(t.skew).toBe('too-easy'); + expect(t.sampleSize).toBe(4); + }); + it('flags a too-hard trend for a low-level party fighting strong mobs', () => { + const fights = [enc('1', [8, 8]), enc('2', [9]), enc('3', [8, 8])]; + const t = difficultyTendency(fights, '5e', [1, 1, 1, 1]); + expect(t.skew).toBe('too-hard'); + }); + it('returns balanced with low sample size', () => { + const t = difficultyTendency([enc('1', [1])], '5e', [3, 3, 3, 3]); + expect(t.skew).toBe('balanced'); + expect(t.sampleSize).toBe(1); + }); + it('prefers the per-encounter snapshot over fallback levels', () => { + // Snapshot says the party was level 1 when they fought CR-3 mobs → hard/deadly, + // even though the fallback (current) levels are 20. + const fights = [ + enc('1', [3, 3], { partyLevelsSnapshot: [1, 1, 1, 1] }), + enc('2', [3, 3], { partyLevelsSnapshot: [1, 1, 1, 1] }), + enc('3', [3], { partyLevelsSnapshot: [1, 1, 1, 1] }), + ]; + const t = difficultyTendency(fights, '5e', [20, 20, 20, 20]); + expect(t.skew).toBe('too-hard'); + }); +}); + +describe('detectThemes', () => { + it('produces an encounter-difficulty theme from a too-easy context', () => { + const fights = [enc('1', [0]), enc('2', [0.125]), enc('3', [0.25]), enc('4', [0])]; + const ctx = buildCampaignContext({ campaign: campaign(), characters: [pc(12)], encounters: fights, quests: [], notes: [] }); + const themes = detectThemes(ctx); + expect(themes.find((t) => t.kind === 'encounter-difficulty')?.tendency).toMatch(/easy/i); + expect(themes[0]!.recommendation).toBeTruthy(); + }); +}); diff --git a/src/lib/assistant/patterns.ts b/src/lib/assistant/patterns.ts new file mode 100644 index 0000000..4009b13 --- /dev/null +++ b/src/lib/assistant/patterns.ts @@ -0,0 +1,81 @@ +import type { Encounter } from '@/lib/schemas'; +import type { SystemId } from '@/lib/rules'; +import { computeBudget } from '@/lib/combat/budget'; +import type { Severity } from './advisors'; +import type { CampaignContext } from './context'; + +export type ThemeKind = 'encounter-difficulty' | 'resource-attrition' | 'quest-stagnation'; + +export interface CampaignTheme { + kind: ThemeKind; + severity: Severity; + tendency: string; + evidence: string; + recommendation?: string; +} + +export type Skew = 'too-easy' | 'balanced' | 'too-hard'; + +/** difficulty label → 0 (trivial) … 4 (deadly/extreme), shared across systems. */ +const ORDINAL: Record = { + trivial: 0, easy: 1, low: 1, medium: 2, moderate: 2, hard: 3, severe: 3, deadly: 4, extreme: 4, +}; + +function classify(ords: number[]): { skew: Skew; sampleSize: number; detail: string } { + const n = ords.length; + if (n < 3) return { skew: 'balanced', sampleSize: n, detail: `Only ${n} rated encounter${n === 1 ? '' : 's'} so far — not enough to spot a trend.` }; + const avg = ords.reduce((a, b) => a + b, 0) / n; + const easyCount = ords.filter((o) => o <= 1).length; + const hardCount = ords.filter((o) => o >= 3).length; + if (avg < 1.5) { + return { skew: 'too-easy', sampleSize: n, detail: `${easyCount} of the last ${n} encounters were easy or trivial for the party.` }; + } + if (avg > 2.75) { + return { skew: 'too-hard', sampleSize: n, detail: `${hardCount} of the last ${n} encounters were hard or deadly for the party.` }; + } + return { skew: 'balanced', sampleSize: n, detail: `Encounter difficulty is well balanced across the last ${n} encounters.` }; +} + +/** + * Classify whether the DM's recent encounters skew easy/hard for the party, + * rating each fight against its party-level snapshot (or the fallback levels). + */ +export function difficultyTendency( + encounters: Encounter[], + system: SystemId, + fallbackLevels: number[], +): { skew: Skew; sampleSize: number; detail: string } { + const ords: number[] = []; + for (const e of encounters) { + if (e.status === 'planning') continue; + const levels = e.partyLevelsSnapshot?.length ? e.partyLevelsSnapshot : fallbackLevels; + const budget = computeBudget(system, levels, e.combatants); + if (budget.monstersCounted > 0) ords.push(ORDINAL[budget.difficulty] ?? 2); + } + return classify(ords); +} + +/** Deterministic campaign "themes" surfaced as insight cards and used to ground LLM tips. */ +export function detectThemes(ctx: CampaignContext, window = 8): CampaignTheme[] { + const themes: CampaignTheme[] = []; + + const rated = ctx.recentEncounters.slice(0, window).filter((e) => e.ratingXp > 0); + const t = classify(rated.map((e) => ORDINAL[e.difficulty] ?? 2)); + if (t.skew === 'too-easy') { + themes.push({ + kind: 'encounter-difficulty', severity: 'warn', + tendency: 'Your encounters skew easy for the party.', + evidence: t.detail, + recommendation: 'Add a stronger creature or two to your next fight to raise the challenge.', + }); + } else if (t.skew === 'too-hard') { + themes.push({ + kind: 'encounter-difficulty', severity: 'warn', + tendency: 'Your encounters skew hard for the party.', + evidence: t.detail, + recommendation: 'Consider easing the next encounter, or give the party a chance to rest first.', + }); + } + + return themes; +} diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index 7e7c3db..8785dae 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -70,6 +70,10 @@ export class TtrpgDatabase extends Dexie { // v6 — Phase 9 homebrew content. this.version(6).stores({ homebrew: 'id, campaignId, [campaignId+kind]' }); + + // v7 — Phase 13 assistant: encounters gain optional partyLevelsSnapshot + + // outcome. Both optional, so existing rows stay valid; no backfill needed. + this.version(7).stores({}); } } diff --git a/src/lib/schemas/encounter.ts b/src/lib/schemas/encounter.ts index fdf383e..f13e912 100644 --- a/src/lib/schemas/encounter.ts +++ b/src/lib/schemas/encounter.ts @@ -46,6 +46,11 @@ export const encounterSchema = z.object({ combatants: z.array(combatantSchema).default([]), /** newest-last event feed for the fight */ log: z.array(logEntrySchema).default([]), + /** PC levels captured when combat started — lets the assistant rate this + * encounter's difficulty against the party as it was, not as it is now. */ + partyLevelsSnapshot: z.array(int.positive()).optional(), + /** how the fight resolved, if recorded */ + outcome: z.enum(['tpk', 'party-victory', 'fled', 'unknown']).optional(), createdAt: z.string(), updatedAt: z.string(), }); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 915d0cc..b7dec09 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/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/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/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.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/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/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.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/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/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/encounter.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/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/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/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.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/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.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/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.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/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/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/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.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/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/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.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/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/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.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/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/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/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.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/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.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/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file