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