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