Phase 15: level-up strategy advisor + campaign insights

- Level-up build-route advisor (src/features/characters/sheet/LevelUpAdvisor.tsx +
  useLevelUpAdvisor): ~4 system-aware routes plus a custom one; choosing a route
  expands it into concrete next-level steps. AI when configured, deterministic
  fallback (src/lib/assistant/levelup.ts) otherwise. Embedded in the existing
  HP-only LevelUpModal, which stays primary.
- Level-up prompts/schemas added to prompts.ts (buildRoutes/buildSteps), each
  leading with the system constraint + class + next level.
- Campaign Insights section on the Assistant page renders deterministic themes,
  with an optional 'ask the assistant to expand' affordance when AI is on.

levelup + prompt unit tests (4 routes both systems, system vocabulary, custom
route); e2e for the deterministic route→steps flow and the insights section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:23:16 +02:00
parent dbbf68752e
commit e39def8f02
10 changed files with 443 additions and 2 deletions
+40
View File
@@ -0,0 +1,40 @@
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('level-up advisor offers routes and expands a chosen one (deterministic)', async ({ page }) => {
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 Lia (level 3 Fighter) and start a level-up.
await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click();
await page.getByRole('link', { name: /Lia the Brave/ }).click();
await page.getByRole('button', { name: 'Level up' }).click();
const advisor = page.getByTestId('levelup-advisor');
await page.getByRole('button', { name: 'Suggest build routes' }).click();
await expect(advisor).toBeVisible();
// Four system-aware routes; choose one and see concrete steps for the next level.
await expect(advisor.getByText('Offense')).toBeVisible();
await expect(advisor.getByRole('button', { name: 'Choose' })).toHaveCount(4);
await advisor.locator('li').filter({ hasText: 'Offense' }).getByRole('button', { name: 'Choose' }).click();
await expect(advisor.getByText('Level 4 focus')).toBeVisible();
});
test('assistant page renders the campaign insights section', async ({ page }) => {
await page.getByLabel('Settings').click();
await page.getByRole('button', { name: 'Load sample campaign' }).click();
await page.getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
// Sample has a single encounter → not enough for a trend yet.
await expect(page.getByText(/No trends detected yet/)).toBeVisible();
});
+4
View File
@@ -17,6 +17,7 @@ import { useEncounters } from '@/features/combat/hooks';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { CampaignInsights } from './CampaignInsights';
export function AssistantPage() {
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
@@ -126,6 +127,9 @@ function Assistant({ campaign }: { campaign: Campaign }) {
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2>
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2>
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
</section>
</div>
</Page>
@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react';
import type { Campaign, Character, Encounter, Note, Quest } from '@/lib/schemas';
import { buildCampaignContext } from '@/lib/assistant/context';
import { detectThemes, type CampaignTheme } from '@/lib/assistant/patterns';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
const SEV_CLS = { info: 'border-line', warn: 'border-warning/50', danger: 'border-danger/50' } as const;
/** Deterministic campaign themes as cards, with an optional AI "expand" affordance. */
export function CampaignInsights({ campaign, characters, encounters, quests, notes }: {
campaign: Campaign;
characters: Character[];
encounters: Encounter[];
quests: Quest[];
notes: Note[];
}) {
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const ctx = useMemo(
() => buildCampaignContext({ campaign, characters, encounters, quests, notes }),
[campaign, characters, encounters, quests, notes],
);
const themes = useMemo(() => detectThemes(ctx), [ctx]);
if (themes.length === 0) {
return <p className="text-sm text-muted">No trends detected yet play a few more sessions.</p>;
}
return (
<ul className="space-y-2">
{themes.map((t) => (
<InsightCard key={t.kind} theme={t} ctx={ctx} canUseLlm={canUseLlm} />
))}
</ul>
);
}
function InsightCard({ theme, ctx, canUseLlm }: { theme: CampaignTheme; ctx: ReturnType<typeof buildCampaignContext>; canUseLlm: boolean }) {
const [expanded, setExpanded] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const expand = async () => {
setLoading(true);
const res = await complete(getLlmConfig(), {
system: `${ctx.systemConstraint}\n\nYou are a concise ${ctx.systemLabel} GM coach. Give 23 sentences of practical advice.`,
user: `Trend: ${theme.tendency} ${theme.evidence} How should the GM respond?`,
maxTokens: 400,
});
setExpanded(res.ok && 'text' in res ? res.text : 'Could not reach the assistant.');
setLoading(false);
};
return (
<li className={cn('rounded-lg border bg-panel p-3', SEV_CLS[theme.severity])}>
<div className="text-sm font-medium text-ink">{theme.tendency}</div>
<div className="text-xs text-muted">{theme.evidence}</div>
{theme.recommendation && <div className="mt-1 text-sm text-ink">{theme.recommendation}</div>}
{canUseLlm && (
<div className="mt-2">
<Button size="sm" variant="ghost" onClick={expand} disabled={loading}>
{loading ? 'Thinking…' : 'Ask the assistant to expand'}
</Button>
</div>
)}
{expanded && <p className="mt-2 whitespace-pre-wrap rounded-md border border-line bg-surface p-2 text-sm text-ink">{expanded}</p>}
</li>
);
}
@@ -0,0 +1,70 @@
import { useMemo, useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildCampaignContext } from '@/lib/assistant/context';
import {
buildLevelUpRoutesPrompt, buildLevelUpStepsPrompt, buildRoutesSchema, buildStepsSchema,
type BuildRoute, type BuildStep,
} from '@/lib/assistant/prompts';
import { deterministicRoutes, deterministicSteps } from '@/lib/assistant/levelup';
export type AdvisorState = 'idle' | 'loading' | 'ready' | 'error';
export function useLevelUpAdvisor(campaign: Campaign, character: Character) {
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const nextLevel = Math.min(20, character.level + 1);
const ctx = useMemo(
() => buildCampaignContext({ campaign, characters: [character], encounters: [], quests: [], notes: [] }),
[campaign, character],
);
const [state, setState] = useState<AdvisorState>('idle');
const [routes, setRoutes] = useState<BuildRoute[] | undefined>();
const [steps, setSteps] = useState<BuildStep[] | undefined>();
const [chosen, setChosen] = useState<string | undefined>();
const [source, setSource] = useState<'llm' | 'deterministic'>('deterministic');
const [message, setMessage] = useState<string | null>(null);
const fetchRoutes = async () => {
setState('loading');
setMessage(null);
setSteps(undefined);
setChosen(undefined);
if (canUseLlm) {
const prompt = buildLevelUpRoutesPrompt(ctx, character, nextLevel);
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: buildRoutesSchema, maxTokens: 700 });
if (res.ok && 'data' in res && res.data.routes.length) {
setRoutes(res.data.routes);
setSource('llm');
setState('ready');
return;
}
if (!res.ok) setMessage(`AI unavailable (${res.error}); showing general routes.`);
}
setRoutes(deterministicRoutes(campaign.system, character.className));
setSource('deterministic');
setState('ready');
};
const chooseRoute = async (title: string) => {
setChosen(title);
setSteps(undefined);
if (canUseLlm) {
const prompt = buildLevelUpStepsPrompt(ctx, character, nextLevel, title);
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: buildStepsSchema, maxTokens: 700 });
if (res.ok && 'data' in res && res.data.steps.length) {
setSteps(res.data.steps);
setSource('llm');
return;
}
}
setSteps(deterministicSteps(campaign.system, character.className, title, nextLevel));
setSource('deterministic');
};
return { state, routes, steps, chosen, source, message, canUseLlm, nextLevel, fetchRoutes, chooseRoute };
}
@@ -0,0 +1,89 @@
import { useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useLevelUpAdvisor } from '@/features/assistant/useLevelUpAdvisor';
/**
* Build-route advisor shown alongside the HP-only level-up flow. Presents ~4
* system-aware routes (plus a custom one); choosing one expands it into concrete
* steps. AI-powered when configured, deterministic otherwise.
*/
export function LevelUpAdvisor({ campaign, character }: { campaign: Campaign; character: Character }) {
const { state, routes, steps, chosen, source, message, canUseLlm, fetchRoutes, chooseRoute } =
useLevelUpAdvisor(campaign, character);
const [custom, setCustom] = useState('');
if (state === 'idle') {
return (
<div className="rounded-md border border-line bg-surface p-3">
<div className="mb-2 text-sm text-muted">Not sure where to take this character?</div>
<Button size="sm" variant="secondary" onClick={fetchRoutes}>
{canUseLlm ? 'Suggest build routes (AI)' : 'Suggest build routes'}
</Button>
</div>
);
}
return (
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
{source === 'llm' ? 'AI build routes' : 'Build routes'}
</span>
{state === 'loading' && <span className="text-xs text-muted">Thinking</span>}
</div>
{message && <p className="mb-2 text-xs text-muted">{message}</p>}
{routes && (
<ul className="space-y-2">
{routes.map((r) => (
<li key={r.title} className="rounded-md border border-line p-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-ink">{r.title}</div>
<div className="text-xs text-muted">{r.summary}</div>
<div className="text-xs italic text-muted">{r.playstyle}</div>
</div>
<Button size="sm" variant={chosen === r.title ? 'primary' : 'secondary'} onClick={() => chooseRoute(r.title)}>
Choose
</Button>
</div>
{chosen === r.title && steps && (
<ol className="mt-2 space-y-1 border-t border-line pt-2">
{steps.map((s, i) => (
<li key={i} className="text-sm text-ink">
<span className="font-medium">{s.label}:</span> <span className="text-muted">{s.detail}</span>
</li>
))}
</ol>
)}
</li>
))}
</ul>
)}
<div className="mt-3 flex gap-2">
<Input
value={custom}
onChange={(e) => setCustom(e.target.value)}
placeholder="Or describe your own route…"
aria-label="Custom build route"
className="text-sm"
/>
<Button size="sm" variant="secondary" disabled={!custom.trim()} onClick={() => chooseRoute(custom.trim())}>
Plan it
</Button>
</div>
{chosen && !routes?.some((r) => r.title === chosen) && steps && (
<ol className="mt-2 space-y-1 rounded-md border border-line p-2" data-testid="custom-steps">
{steps.map((s, i) => (
<li key={i} className="text-sm text-ink">
<span className="font-medium">{s.label}:</span> <span className="text-muted">{s.detail}</span>
</li>
))}
</ol>
)}
</div>
);
}
@@ -1,11 +1,12 @@
import { useState } from 'react';
import type { Character } from '@/lib/schemas';
import type { Campaign, Character } from '@/lib/schemas';
import { abilityModifier } from '@/lib/rules';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Input';
import { LevelUpAdvisor } from './LevelUpAdvisor';
const HIT_DICE = [6, 8, 10, 12] as const;
@@ -31,6 +32,11 @@ export function LevelUpModal({ character, onApply, onClose }: {
const preview = (method === 'average' ? average : `1d${die}`) + (conMod !== 0 ? ` ${conMod >= 0 ? '+' : ''}${conMod}` : '');
// The advisor only needs the system + id; synthesize a campaign from the character.
const campaign: Campaign = {
id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '',
};
return (
<Modal
open
@@ -61,6 +67,8 @@ export function LevelUpModal({ character, onApply, onClose }: {
HP gained: <span className="font-medium text-ink">{preview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})
</p>
{character.level >= 20 && <p className="text-sm text-warning">Already at level 20.</p>}
{character.level < 20 && <LevelUpAdvisor campaign={campaign} character={character} />}
</div>
</Modal>
);
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { deterministicRoutes, deterministicSteps } from './levelup';
import { buildRoutesSchema, buildStepsSchema, buildLevelUpRoutesPrompt, buildLevelUpStepsPrompt } from './prompts';
import { buildCampaignContext } from './context';
import { characterDefaults, type Campaign, type Character } from '@/lib/schemas';
function ctxAndChar(system: Campaign['system']) {
const campaign: Campaign = { id: 'c', name: 'T', system, description: '', createdAt: '', updatedAt: '' };
const c: Character = {
id: 'p', campaignId: 'c', system, kind: 'pc', name: 'Hero', ancestry: '', className: 'Cleric', level: 3,
abilities: { str: 10, dex: 12, con: 13, int: 8, wis: 16, cha: 11 },
hp: { current: 20, max: 20, temp: 0 }, speed: 30, armorBonus: 0,
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
};
return { ctx: buildCampaignContext({ campaign, characters: [c], encounters: [], quests: [], notes: [] }), c };
}
describe('deterministic level-up fallback', () => {
it('returns 4 schema-valid routes for both systems', () => {
for (const system of ['5e', 'pf2e'] as const) {
const routes = deterministicRoutes(system, 'Cleric');
expect(routes).toHaveLength(4);
expect(buildRoutesSchema.safeParse({ routes }).success).toBe(true);
}
});
it('produces system-appropriate steps (pf2e vs 5e vocabulary)', () => {
const pf = deterministicSteps('pf2e', 'Cleric', 'Offense', 4);
const dnd = deterministicSteps('5e', 'Cleric', 'Offense', 4);
expect(buildStepsSchema.safeParse({ steps: pf }).success).toBe(true);
expect(JSON.stringify(pf)).toMatch(/ability boosts|skill feat|skill increase/i);
expect(JSON.stringify(dnd)).toMatch(/ability score improvement|feat/i);
});
it('handles a custom route title', () => {
const steps = deterministicSteps('5e', 'Wizard', 'Blaster control mage', 5);
expect(steps.length).toBeGreaterThan(0);
expect(JSON.stringify(steps)).toContain('Blaster control mage');
});
});
describe('level-up prompts', () => {
it('routes prompt names the system, class, and next level', () => {
const { ctx, c } = ctxAndChar('pf2e');
const { system, user } = buildLevelUpRoutesPrompt(ctx, c, 4);
expect(system).toMatch(/pathfinder/i);
expect(user).toContain('Cleric');
expect(user).toContain('4');
});
it('steps prompt includes the chosen route and constrains to the system', () => {
const { ctx, c } = ctxAndChar('5e');
const { system, user } = buildLevelUpStepsPrompt(ctx, c, 4, 'Defense');
expect(system).toMatch(/5e|dungeons/i);
expect(user).toContain('Defense');
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { SystemId } from '@/lib/rules';
import type { BuildRoute, BuildStep } from './prompts';
/**
* Deterministic, system-aware build-route advice used when no LLM is configured.
* Generic archetypes keyed off the system's advancement vocabulary — not a
* substitute for the LLM's class-specific depth, but always available offline.
*/
const ARCHETYPES: { title: string; summary: string; playstyle: string }[] = [
{ title: 'Offense', summary: 'Lean into raw damage and accuracy.', playstyle: 'Strike first, strike hard' },
{ title: 'Defense', summary: 'Shore up survivability and staying power.', playstyle: 'Outlast the fight' },
{ title: 'Utility', summary: 'Broaden what you can do outside combat.', playstyle: 'A tool for every problem' },
{ title: 'Support', summary: "Amplify and protect your allies.", playstyle: 'Make the party better' },
];
function vocab(system: SystemId) {
return system === 'pf2e'
? { boost: 'ability boosts', feat: 'a class or skill feat', train: 'a skill increase' }
: { boost: 'an Ability Score Improvement', feat: 'a feat', train: 'proficiency or expertise' };
}
export function deterministicRoutes(_system: SystemId, className: string): BuildRoute[] {
const cls = className || 'character';
return ARCHETYPES.map((a) => ({
title: a.title,
summary: `${a.summary} (${cls})`,
playstyle: a.playstyle,
}));
}
export function deterministicSteps(system: SystemId, className: string, routeTitle: string, nextLevel: number): BuildStep[] {
const v = vocab(system);
const cls = className || 'character';
const byRoute: Record<string, BuildStep[]> = {
Offense: [
{ label: 'Raise your attack stat', detail: `Put ${v.boost} into your primary attack ability.` },
{ label: 'Pick a damage feat', detail: `Take ${v.feat} that adds damage or extra attacks for a ${cls}.` },
{ label: 'Sharpen accuracy', detail: `Spend ${v.train} on your main weapon or attack proficiency.` },
],
Defense: [
{ label: 'Boost durability', detail: `Invest ${v.boost} in Constitution (and Dexterity for AC).` },
{ label: 'Take a defensive feat', detail: `Choose ${v.feat} that improves AC, saves, or damage mitigation.` },
{ label: 'Train a key save', detail: `Use ${v.train} on your weakest defense.` },
],
Utility: [
{ label: 'Expand your skills', detail: `Apply ${v.train} to skills that fit your concept.` },
{ label: 'Pick a versatile feat', detail: `Take ${v.feat} that grants new options or actions.` },
{ label: 'Round out abilities', detail: `Use ${v.boost} to cover a glaring weakness.` },
],
Support: [
{ label: 'Improve your key ability', detail: `Put ${v.boost} into your spellcasting or support ability.` },
{ label: 'Take a team feat', detail: `Choose ${v.feat} that buffs, heals, or protects allies.` },
{ label: 'Train coordination skills', detail: `Spend ${v.train} on social or healing-adjacent skills.` },
],
};
const steps = byRoute[routeTitle] ?? [
{ label: 'Pick complementary options', detail: `Choose ${v.feat} and ${v.boost} that support "${routeTitle}".` },
{ label: 'Train toward the goal', detail: `Apply ${v.train} where it advances this plan.` },
];
return [{ label: `Level ${nextLevel} focus`, detail: `Build toward "${routeTitle}" as a ${cls}.` }, ...steps];
}
+41
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import type { Character } from '@/lib/schemas';
import type { CampaignContext, CreatureCandidate } from './context';
export const balanceSuggestionSchema = z.object({
@@ -44,3 +45,43 @@ export function buildBalancePrompt(
return { system, user };
}
// ---------------- Level-up advisor ----------------
export const buildRoutesSchema = z.object({
routes: z.array(z.object({
title: z.string(),
summary: z.string(),
playstyle: z.string(),
})).min(3).max(4),
});
export type BuildRoute = z.infer<typeof buildRoutesSchema>['routes'][number];
export const buildStepsSchema = z.object({
steps: z.array(z.object({ label: z.string(), detail: z.string() })).min(1),
});
export type BuildStep = z.infer<typeof buildStepsSchema>['steps'][number];
function charLine(c: Character): string {
const abilities = Object.entries(c.abilities).map(([k, v]) => `${k.toUpperCase()} ${v}`).join(', ');
return `${c.name}, a level ${c.level} ${c.className || 'adventurer'} (${abilities})`;
}
export function buildLevelUpRoutesPrompt(ctx: CampaignContext, c: Character, nextLevel: number): { system: string; user: string } {
const system =
`${ctx.systemConstraint}\n\n` +
`You are a character-building advisor for ${ctx.systemLabel}. Propose 4 distinct build routes for the next level. ` +
`Each route needs a short title, a one-sentence summary, and a one-phrase playstyle. ` +
`Use only ${ctx.systemLabel} mechanics, feats, and class options — never another system's.`;
const user = `Character: ${charLine(c)}. Leveling up to ${nextLevel}. Suggest 4 build routes.`;
return { system, user };
}
export function buildLevelUpStepsPrompt(ctx: CampaignContext, c: Character, nextLevel: number, chosenRoute: string): { system: string; user: string } {
const system =
`${ctx.systemConstraint}\n\n` +
`You are a character-building advisor for ${ctx.systemLabel}. Give concrete, ordered steps to pursue the chosen build route at this level. ` +
`Each step has a short label and a one-sentence detail. Reference only ${ctx.systemLabel} options (feats, ability boosts, spells, skill increases as appropriate). Keep to 35 steps.`;
const user = `Character: ${charLine(c)}. Leveling up to ${nextLevel}. Chosen route: "${chosenRoute}". How should they achieve it?`;
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/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"}
{"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/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.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/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/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/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/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"}