diff --git a/e2e/assistant-llm.spec.ts b/e2e/assistant-llm.spec.ts new file mode 100644 index 0000000..cc500f0 --- /dev/null +++ b/e2e/assistant-llm.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(async () => { + indexedDB.deleteDatabase('ttrpg-manager'); + localStorage.clear(); + }); + await page.reload(); +}); + +test('encounter tip suggests and applies a deterministic balance fix', async ({ page }) => { + // Sample campaign: 2 level-3 PCs + a "Goblin Ambush" with 3 goblins. + await page.getByLabel('Settings').click(); + await page.getByRole('button', { name: 'Load sample campaign' }).click(); + await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); + + // Open the seeded encounter in the combat tracker. + await page.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click(); + await page.getByText('Goblin Ambush').click(); + + // The contextual balance tip is present (LLM off → deterministic path). + const tip = page.getByTestId('encounter-tip'); + await expect(tip).toBeVisible(); + + // Each combatant row has a "Dmg" button — use it to count combatants. + const hpControls = page.getByRole('button', { name: 'Dmg' }); + const before = await hpControls.count(); + + await tip.getByRole('button', { name: 'Suggest a fix' }).click(); + await expect(tip.getByRole('button', { name: 'Add to encounter' })).toBeVisible(); + await tip.getByRole('button', { name: 'Add to encounter' }).click(); + + // Suggestion closes and at least one combatant was added. + await expect(tip.getByRole('button', { name: 'Add to encounter' })).toBeHidden(); + await expect.poll(async () => hpControls.count()).toBeGreaterThan(before); +}); + +test('encounter tip uses the AI path when a provider is configured', async ({ page }) => { + // Stub the Anthropic endpoint with a canned structured response. + await page.route('**/v1/messages', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + content: [{ type: 'text', text: JSON.stringify({ + reasoning: 'An ettin raises the threat to match the party.', + add: [{ name: 'Ettin', count: 1 }], + targetDifficulty: 'Hard', + }) }], + }), + }); + }); + + await page.getByLabel('Settings').click(); + await page.getByRole('button', { name: 'Load sample campaign' }).click(); + await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); + + // Enable the assistant with a dummy key (network is stubbed). + await page.getByLabel('Settings').click(); + await page.getByLabel('Enable AI assistant').check(); + await page.getByLabel('API key', { exact: true }).fill('sk-stub'); + + await page.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click(); + await page.getByText('Goblin Ambush').click(); + + const tip = page.getByTestId('encounter-tip'); + await tip.getByRole('button', { name: 'Suggest a fix (AI)' }).click(); + // The canned AI reasoning + the "AI suggestion" label render. + await expect(tip.getByText('AI suggestion')).toBeVisible(); + await expect(tip.getByText('An ettin raises the threat to match the party.')).toBeVisible(); + await expect(tip.getByText('1× Ettin')).toBeVisible(); +}); diff --git a/src/features/assistant/EncounterTipCard.tsx b/src/features/assistant/EncounterTipCard.tsx new file mode 100644 index 0000000..83f5c30 --- /dev/null +++ b/src/features/assistant/EncounterTipCard.tsx @@ -0,0 +1,79 @@ +import type { Campaign, Encounter } from '@/lib/schemas'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/cn'; +import { useEncounterAdvisor } from './useEncounterAdvisor'; + +/** + * Contextual encounter-balancing tip. Shows the detected difficulty tendency and, + * on demand, a grounded recommendation (AI when configured, deterministic otherwise) + * with one-click apply. Renders nothing when there's no tendency to report. + */ +export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign; encounter: Encounter }) { + const { theme, hasMonsters, currentDifficulty, state, suggestion, source, message, canUseLlm, run, apply } = + useEncounterAdvisor(campaign, encounter); + + // Surface when there's a detected tendency, an encounter with monsters to balance, + // or an in-progress/ready suggestion. Stay out of the way otherwise. + if (!theme && !hasMonsters && state === 'idle') return null; + + return ( +
+
+ 🧠 +
+ {theme ? ( + <> +
{theme.tendency}
+
{theme.evidence}
+ + ) : ( + <> +
Encounter balance
+
+ {currentDifficulty ? ( + <>Currently {currentDifficulty} for the party. + ) : ( + 'Add a creature suggestion for your party.' + )} +
+ + )} + + {state === 'ready' && suggestion && ( +
+
+ + {source === 'llm' ? 'AI suggestion' : 'Suggested'} + + → {suggestion.targetDifficulty} +
+

{suggestion.reasoning}

+
    + {suggestion.add.map((a) => ( +
  • • {a.count}× {a.name}
  • + ))} +
+
+ + +
+
+ )} + + {message &&

{message}

} + + {state !== 'ready' && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/features/assistant/useEncounterAdvisor.ts b/src/features/assistant/useEncounterAdvisor.ts new file mode 100644 index 0000000..09da60d --- /dev/null +++ b/src/features/assistant/useEncounterAdvisor.ts @@ -0,0 +1,158 @@ +import { useMemo, useRef, useState } from 'react'; +import type { Campaign, Encounter } from '@/lib/schemas'; +import { encountersRepo } from '@/lib/db/repositories'; +import { newId } from '@/lib/ids'; +import { createRng } from '@/lib/rng'; +import { rollDice } from '@/lib/dice/notation'; +import { addCombatant } from '@/lib/combat/engine'; +import { computeBudget } from '@/lib/combat/budget'; +import { loadMonsters, loadPf2e } from '@/lib/compendium'; +import { complete } from '@/lib/llm/client'; +import { getLlmConfig } from '@/stores/assistantStore'; +import { useAssistantStore } from '@/stores/assistantStore'; +import { buildCampaignContext } from '@/lib/assistant/context'; +import { pickCreatureCandidates } from '@/lib/assistant/context'; +import { detectThemes, type CampaignTheme } from '@/lib/assistant/patterns'; +import { buildBalancePrompt, balanceSuggestionSchema, type BalanceSuggestion } from '@/lib/assistant/prompts'; +import { buildSuggestedEncounter } from '@/lib/assistant/encounter'; +import { useCharacters } from '@/features/characters/hooks'; +import { useNotes, useQuests } from '@/features/world/hooks'; +import { useEncounters } from '@/features/combat/hooks'; + +export type AdvisorState = 'idle' | 'loading' | 'ready' | 'error'; + +const ORDINAL: Record = { + trivial: 0, easy: 1, low: 1, medium: 2, moderate: 2, hard: 3, severe: 3, deadly: 4, extreme: 4, +}; +const TIER_LABEL: Record> = { + '5e': { 2: 'Medium', 3: 'Hard', 4: 'Deadly' }, + pf2e: { 2: 'Moderate', 3: 'Severe', 4: 'Extreme' }, +}; + +/** One tier above the current difficulty, floored at Medium/Moderate. */ +function nextTarget(system: Campaign['system'], current: string): string { + const ord = ORDINAL[current] ?? 0; + const desired = Math.min(4, Math.max(2, ord + 1)); + return TIER_LABEL[system][desired]!; +} + +function toCombatant(system: Campaign['system'], m: Record) { + const is5e = system === '5e'; + const name = String(m.name); + const ac = Number(is5e ? m.armor_class : m.ac) || 10; + const hp = Number(is5e ? m.hit_points : m.hp) || 1; + const initBonus = is5e ? Math.floor(((Number(m.dexterity) || 10) - 10) / 2) : Number(m.perception) || 0; + const cr = Number(m.cr); + const level = Number(m.level); + return { + id: newId(), name, kind: 'monster' as const, + initiative: rollDice('1d20', createRng()).total + initBonus, initBonus, + ac, hp: { current: hp, max: hp, temp: 0 }, conditions: [], notes: '', + ...(is5e && Number.isFinite(cr) ? { cr } : {}), + ...(!is5e && Number.isFinite(level) ? { level } : {}), + }; +} + +function countByName(chosen: Record[]): BalanceSuggestion['add'] { + const counts = new Map(); + for (const m of chosen) { + const name = String(m.name); + counts.set(name, (counts.get(name) ?? 0) + 1); + } + return [...counts].map(([name, count]) => ({ name, count })); +} + +export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) { + const characters = useCharacters(campaign.id); + const notes = useNotes(campaign.id); + const quests = useQuests(campaign.id); + const encounters = useEncounters(campaign.id); + const llmEnabled = useAssistantStore((s) => s.enabled); + const hasKey = useAssistantStore((s) => !!s.apiKey); + + const ctx = useMemo( + () => buildCampaignContext({ campaign, characters, encounters, quests, notes }), + [campaign, characters, encounters, quests, notes], + ); + const theme = useMemo( + () => detectThemes(ctx).find((t) => t.kind === 'encounter-difficulty'), + [ctx], + ); + + const { hasMonsters, currentDifficulty } = useMemo(() => { + const monsters = encounter.combatants.filter((c) => c.kind === 'monster').map((c) => ({ cr: c.cr, level: c.level })); + const diff = monsters.length && ctx.partyLevels.length + ? computeBudget(campaign.system, ctx.partyLevels, monsters).difficulty + : undefined; + return { hasMonsters: monsters.length > 0, currentDifficulty: diff }; + }, [encounter.combatants, ctx.partyLevels, campaign.system]); + + const [state, setState] = useState('idle'); + const [suggestion, setSuggestion] = useState(); + const [source, setSource] = useState<'llm' | 'deterministic'>('deterministic'); + const [message, setMessage] = useState(null); + const poolRef = useRef[]>([]); + + const canUseLlm = llmEnabled && hasKey; + + const run = async () => { + setState('loading'); + setMessage(null); + setSuggestion(undefined); + try { + const raw = (campaign.system === '5e' ? await loadMonsters() : await loadPf2e('creatures')) as unknown as Record[]; + poolRef.current = raw; + const partyLevels = ctx.partyLevels; + if (partyLevels.length === 0) { setMessage('Add player characters first.'); setState('error'); return; } + + const monsters = encounter.combatants.filter((c) => c.kind === 'monster').map((c) => ({ cr: c.cr, level: c.level })); + const current = computeBudget(campaign.system, partyLevels, monsters).difficulty; + const target = nextTarget(campaign.system, current); + const candidates = pickCreatureCandidates(campaign.system, partyLevels, raw, target, 14); + if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; } + + if (canUseLlm) { + const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates }); + const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema }); + if (res.ok && 'data' in res) { + const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name)); + if (valid.length) { + setSuggestion({ ...res.data, add: valid }); + setSource('llm'); + setState('ready'); + return; + } + } else if (!res.ok) { + setMessage(`AI unavailable (${res.error}); showing a deterministic pick.`); + } + } + + // Deterministic fallback + const chosen = buildSuggestedEncounter(campaign.system, partyLevels, raw as (Record & { cr?: number; level?: number })[], target); + if (!chosen.length) { setMessage('Could not assemble a balanced suggestion.'); setState('error'); return; } + setSuggestion({ reasoning: `A level-appropriate pick to reach ${target} difficulty.`, add: countByName(chosen), targetDifficulty: target }); + setSource('deterministic'); + setState('ready'); + } catch { + setMessage('Something went wrong building a suggestion.'); + setState('error'); + } + }; + + const apply = async () => { + if (!suggestion) return; + await encountersRepo.mutate(encounter.id, (e) => { + let next = e; + for (const a of suggestion.add) { + const m = poolRef.current.find((p) => String(p.name) === a.name); + if (!m) continue; + for (let i = 0; i < a.count; i++) next = addCombatant(next, toCombatant(campaign.system, m)); + } + return next; + }); + setState('idle'); + setSuggestion(undefined); + }; + + return { theme, hasMonsters, currentDifficulty, state, suggestion, source, message, canUseLlm, run, apply }; +} diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 8a826c9..94896fb 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -27,6 +27,7 @@ import { cn } from '@/lib/cn'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; import { NumberField } from '@/components/ui/NumberField'; +import { EncounterTipCard } from '@/features/assistant/EncounterTipCard'; export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) { const characters = useCharacters(campaign.id); @@ -129,6 +130,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter + + mutate((e) => addCombatant(e, c))} /> {/* Combatant list */} diff --git a/src/lib/assistant/prompts.test.ts b/src/lib/assistant/prompts.test.ts new file mode 100644 index 0000000..e97e080 --- /dev/null +++ b/src/lib/assistant/prompts.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { buildBalancePrompt, balanceSuggestionSchema } from './prompts'; +import { buildCampaignContext } from './context'; +import { characterDefaults, type Campaign, type Character } from '@/lib/schemas'; + +function ctxFor(system: Campaign['system']) { + const campaign: Campaign = { id: 'c', name: 'T', system, description: '', createdAt: '', updatedAt: '' }; + const pc: Character = { + id: 'p', campaignId: 'c', system, kind: 'pc', name: 'Hero', ancestry: '', className: 'Wizard', level: 4, + abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 }, + hp: { current: 20, max: 20, temp: 0 }, speed: 30, armorBonus: 0, + skillRanks: {}, saveRanks: {}, perceptionRank: 'trained', + ...characterDefaults(), notes: '', createdAt: '', updatedAt: '', + }; + return buildCampaignContext({ campaign, characters: [pc], encounters: [], quests: [], notes: [] }); +} + +describe('buildBalancePrompt', () => { + it('leads with the system constraint and lists only the given candidates', () => { + const ctx = ctxFor('pf2e'); + const { system, user } = buildBalancePrompt(ctx, { + difficulty: 'low', targetDifficulty: 'Moderate', + candidates: [{ name: 'Goblin Warrior', rating: 1 }, { name: 'Goblin Dog', rating: 1 }], + }); + expect(system).toMatch(/pathfinder/i); + expect(system.toLowerCase()).toContain('only from the provided candidate'); + expect(user).toContain('Goblin Warrior'); + expect(user).toContain('Moderate'); + }); + + it('does not leak the other system into a 5e prompt', () => { + const ctx = ctxFor('5e'); + const { system } = buildBalancePrompt(ctx, { difficulty: 'easy', targetDifficulty: 'Medium', candidates: [{ name: 'Ogre', rating: 2 }] }); + expect(system).toMatch(/5e|dungeons/i); + }); +}); + +describe('balanceSuggestionSchema', () => { + it('accepts a well-formed suggestion', () => { + const r = balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 2 }], targetDifficulty: 'Medium' }); + expect(r.success).toBe(true); + }); + it('rejects bad counts and missing fields', () => { + expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], targetDifficulty: 'Medium' }).success).toBe(false); + expect(balanceSuggestionSchema.safeParse({ add: [], targetDifficulty: 'Medium' }).success).toBe(false); + }); +}); diff --git a/src/lib/assistant/prompts.ts b/src/lib/assistant/prompts.ts new file mode 100644 index 0000000..d089146 --- /dev/null +++ b/src/lib/assistant/prompts.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import type { CampaignContext, CreatureCandidate } from './context'; + +export const balanceSuggestionSchema = z.object({ + reasoning: z.string(), + add: z.array(z.object({ + name: z.string(), + count: z.number().int().min(1).max(8), + ref: z.string().optional(), + })), + targetDifficulty: z.string(), +}); +export type BalanceSuggestion = z.infer; + +function partyLine(ctx: CampaignContext): string { + return ctx.party.map((p) => `${p.name} (level ${p.level} ${p.className})`).join(', ') || 'no PCs recorded'; +} + +/** + * Prompt for rebalancing the current encounter. The system message leads with the + * grounding constraint and restricts choices to the provided candidate list. + */ +export function buildBalancePrompt( + ctx: CampaignContext, + current: { difficulty: string; targetDifficulty: string; candidates: CreatureCandidate[] }, +): { system: string; user: string } { + const system = + `${ctx.systemConstraint}\n\n` + + `You are an encounter-balancing assistant for a ${ctx.systemLabel} game master. ` + + `Recommend which creatures to ADD to the current encounter to bring it to roughly "${current.targetDifficulty}" difficulty. ` + + `You MUST choose only from the provided candidate creatures (by exact name). Do not invent creatures or use any from another system. ` + + `Prefer 1–3 distinct creatures with sensible counts. Keep the reasoning to one or two sentences.`; + + const candidateList = current.candidates + .map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`) + .join('\n'); + + const user = + `Party: ${partyLine(ctx)}.\n` + + `Current encounter difficulty: ${current.difficulty}.\n` + + `Target difficulty: ${current.targetDifficulty}.\n\n` + + `Candidate creatures (choose only from these):\n${candidateList}\n\n` + + `Respond with the creatures to add (name + count), a short reasoning, and the targetDifficulty.`; + + return { system, user }; +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index b7dec09..91ae0b1 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/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 +{"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/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.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/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/assistant/prompts.test.ts","./src/lib/assistant/prompts.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