Phase 14: LLM-grounded encounter-balancing tip

- src/lib/assistant/prompts.ts: balanceSuggestionSchema + buildBalancePrompt
  (system message leads with the grounding constraint; restricts choices to the
  provided compendium candidates).
- useEncounterAdvisor hook: builds context → picks system-correct candidates →
  asks the LLM (when configured) for a structured pick, validates + filters names
  to the candidate set, else falls back to the deterministic buildSuggestedEncounter.
  Apply adds the creatures transactionally via encountersRepo.mutate.
- EncounterTipCard in the combat tracker: leads with the detected difficulty
  tendency (or the current difficulty), offers a one-click grounded suggestion
  (AI or deterministic) with propose→confirm Apply.

prompts unit tests + e2e for both the deterministic apply flow and the AI path
(stubbed provider); the AI test also confirms candidate-grounding (only
shortlisted creatures survive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:17:23 +02:00
parent f84e429ca4
commit dbbf68752e
7 changed files with 407 additions and 1 deletions
+73
View File
@@ -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();
});
@@ -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 (
<div
className={cn('mb-4 rounded-lg border p-3', theme ? 'border-warning/40 bg-warning/5' : 'border-line bg-panel')}
data-testid="encounter-tip"
>
<div className="flex items-start gap-3">
<span className="text-lg" aria-hidden>🧠</span>
<div className="min-w-0 flex-1">
{theme ? (
<>
<div className="text-sm font-medium text-ink">{theme.tendency}</div>
<div className="text-xs text-muted">{theme.evidence}</div>
</>
) : (
<>
<div className="text-sm font-medium text-ink">Encounter balance</div>
<div className="text-xs text-muted">
{currentDifficulty ? (
<>Currently <span className="capitalize">{currentDifficulty}</span> for the party.</>
) : (
'Add a creature suggestion for your party.'
)}
</div>
</>
)}
{state === 'ready' && suggestion && (
<div className="mt-2 rounded-md border border-line bg-surface p-2">
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
</span>
<span className="text-xs text-muted"> {suggestion.targetDifficulty}</span>
</div>
<p className="mb-2 text-sm text-ink">{suggestion.reasoning}</p>
<ul className="mb-2 text-sm text-ink">
{suggestion.add.map((a) => (
<li key={a.name}> {a.count}× {a.name}</li>
))}
</ul>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={apply}>Add to encounter</Button>
<Button size="sm" variant="ghost" onClick={run}>Regenerate</Button>
</div>
</div>
)}
{message && <p className="mt-1 text-xs text-muted">{message}</p>}
{state !== 'ready' && (
<div className="mt-2">
<Button size="sm" variant="secondary" onClick={run} disabled={state === 'loading'}>
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Suggest a fix (AI)' : 'Suggest a fix'}
</Button>
</div>
)}
</div>
</div>
</div>
);
}
@@ -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<string, number> = {
trivial: 0, easy: 1, low: 1, medium: 2, moderate: 2, hard: 3, severe: 3, deadly: 4, extreme: 4,
};
const TIER_LABEL: Record<Campaign['system'], Record<number, string>> = {
'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<string, unknown>) {
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<string, unknown>[]): BalanceSuggestion['add'] {
const counts = new Map<string, number>();
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<CampaignTheme | undefined>(
() => 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<AdvisorState>('idle');
const [suggestion, setSuggestion] = useState<BalanceSuggestion | undefined>();
const [source, setSource] = useState<'llm' | 'deterministic'>('deterministic');
const [message, setMessage] = useState<string | null>(null);
const poolRef = useRef<Record<string, unknown>[]>([]);
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<string, unknown>[];
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<string, unknown> & { 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 };
}
+3
View File
@@ -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
</div>
</div>
<EncounterTipCard campaign={campaign} encounter={encounter} />
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
{/* Combatant list */}
+47
View File
@@ -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);
});
});
+46
View File
@@ -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<typeof balanceSuggestionSchema>;
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 13 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 };
}
+1 -1
View File
@@ -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"}
{"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"}