From 0930136c46d6d03bcb6ed8f193af98a3e4d070a1 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 08:06:20 +0200 Subject: [PATCH] Phase 11: data-driven Assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deterministic advisors (src/lib/assistant): party resources (downed/bloodied/ spent slots/rest nudges), session prep (dangling [[wiki links]], active quests, empty journal), live combat hints (turn, downed combatants) โ€” pure + tested - Data-driven encounter builder: greedily assembles level/CR-appropriate monsters to a target difficulty via the Phase-3 budget; one click builds + opens combat - Assistant page: suggestion cards with one-click actions (goto, long rest, create note); wired into routes, dashboard, command palette - 8 advisor/builder unit tests + assistant e2e Conversational LLM layer intentionally deferred (no provider/key in this env); the deterministic advisor is the offline default per the plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/assistant.spec.ts | 33 +++++ src/components/CommandPalette.tsx | 1 + src/features/assistant/AssistantPage.tsx | 152 +++++++++++++++++++++++ src/features/world/DashboardPage.tsx | 1 + src/lib/assistant/advisors.test.ts | 74 +++++++++++ src/lib/assistant/advisors.ts | 112 +++++++++++++++++ src/lib/assistant/encounter.ts | 52 ++++++++ src/router.tsx | 3 + tsconfig.app.tsbuildinfo | 2 +- 9 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 e2e/assistant.spec.ts create mode 100644 src/features/assistant/AssistantPage.tsx create mode 100644 src/lib/assistant/advisors.test.ts create mode 100644 src/lib/assistant/advisors.ts create mode 100644 src/lib/assistant/encounter.ts diff --git a/e2e/assistant.spec.ts b/e2e/assistant.spec.ts new file mode 100644 index 0000000..7fce28b --- /dev/null +++ b/e2e/assistant.spec.ts @@ -0,0 +1,33 @@ +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('assistant flags a bloodied PC and builds an encounter', async ({ page }) => { + // Sample campaign gives us PCs + monsters + await page.getByLabel('Settings').click(); + await page.getByRole('button', { name: 'Load sample campaign' }).click(); + await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); + + // Wound a PC so the assistant has something to flag + await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click(); + await page.getByRole('link', { name: /Lia the Brave/ }).click(); + await page.getByLabel('Current HP').fill('3'); + await page.waitForTimeout(450); // let autosave flush + + // Assistant shows a resource suggestion + await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Assistant' }).click(); + await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible(); + await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible(); + + // Build a Medium encounter โ†’ lands in combat with monsters + await page.getByRole('button', { name: 'Medium' }).click(); + await expect(page.getByRole('button', { name: 'Start combat' })).toBeVisible(); +}); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index abaaa96..f4422ee 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -17,6 +17,7 @@ interface Command { const NAV: { label: string; to: string }[] = [ { label: 'Campaigns', to: '/' }, { label: 'Dashboard', to: '/dashboard' }, + { label: 'Assistant', to: '/assistant' }, { label: 'Characters', to: '/characters' }, { label: 'Combat', to: '/combat' }, { label: 'Dice', to: '/dice' }, diff --git a/src/features/assistant/AssistantPage.tsx b/src/features/assistant/AssistantPage.tsx new file mode 100644 index 0000000..9ea243b --- /dev/null +++ b/src/features/assistant/AssistantPage.tsx @@ -0,0 +1,152 @@ +import { useMemo, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import type { Campaign } from '@/lib/schemas'; +import { charactersRepo, encountersRepo, notesRepo } from '@/lib/db/repositories'; +import { getSystem, applyRest } from '@/lib/rules'; +import { newId } from '@/lib/ids'; +import { createRng } from '@/lib/rng'; +import { rollDice } from '@/lib/dice/notation'; +import { addCombatant } from '@/lib/combat/engine'; +import { loadMonsters, loadPf2e } from '@/lib/compendium'; +import { resourceSuggestions, planningSuggestions, combatSuggestions, type Suggestion, type SuggestAction } from '@/lib/assistant/advisors'; +import { buildSuggestedEncounter } from '@/lib/assistant/encounter'; +import { useUiStore } from '@/stores/uiStore'; +import { useCharacters } from '@/features/characters/hooks'; +import { useNotes, useQuests } from '@/features/world/hooks'; +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'; + +export function AssistantPage() { + return {(c) => }; +} + +const SEV_CLS = { info: 'border-line', warn: 'border-warning/50', danger: 'border-danger/50' } as const; +const DIFFICULTY: Record = { + '5e': ['Easy', 'Medium', 'Hard', 'Deadly'], + pf2e: ['Low', 'Moderate', 'Severe', 'Extreme'], +}; + +function Assistant({ campaign }: { campaign: Campaign }) { + const navigate = useNavigate(); + const setActiveEncounter = useUiStore((s) => s.setActiveEncounter); + const characters = useCharacters(campaign.id); + const notes = useNotes(campaign.id); + const quests = useQuests(campaign.id); + const encounters = useEncounters(campaign.id); + const activeEnc = encounters.find((e) => e.status === 'active'); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + + const suggestions = useMemo( + () => [...combatSuggestions(activeEnc), ...resourceSuggestions(characters), ...planningSuggestions(notes, quests)], + [activeEnc, characters, notes, quests], + ); + + const runAction = async (a: SuggestAction) => { + if (a.type === 'goto') { void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to }); return; } + if (a.type === 'longRest') { + const c = await charactersRepo.get(a.characterId); + if (!c) return; + const opt = getSystem(c.system).restOptions.find((o) => o.restoresHp); + if (opt) await charactersRepo.update(c.id, applyRest(c, opt)); + setMsg('Applied a long rest.'); + return; + } + if (a.type === 'createNote') { + await notesRepo.create(campaign.id, a.title); + void navigate({ to: '/notes' }); + } + }; + + const buildEncounter = async (difficulty: string) => { + setBusy(true); + setMsg(null); + try { + const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level); + if (partyLevels.length === 0) { setMsg('Add player characters first.'); return; } + const raw = campaign.system === '5e' ? await loadMonsters() : await loadPf2e('creatures'); + const pool = raw as unknown as (Record & { cr?: number; level?: number })[]; + const chosen = buildSuggestedEncounter(campaign.system, partyLevels, pool, difficulty, createRng()); + if (chosen.length === 0) { setMsg('No suitable monsters found for that difficulty.'); return; } + + let enc = await encountersRepo.create(campaign.id, `${difficulty} encounter`); + for (const m of chosen) { + const mm = m as Record; + const is5e = campaign.system === '5e'; + const name = String(mm.name); + const ac = Number(is5e ? mm.armor_class : mm.ac) || 10; + const hp = Number(is5e ? mm.hit_points : mm.hp) || 1; + const initBonus = is5e ? Math.floor(((Number(mm.dexterity) || 10) - 10) / 2) : Number(mm.perception) || 0; + const cr = Number(mm.cr); + const level = Number(mm.level); + enc = addCombatant(enc, { + id: newId(), name, kind: 'monster', + 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 } : {}), + }); + } + await encountersRepo.save(enc); + setActiveEncounter(enc.id); + void navigate({ to: '/combat' }); + } finally { + setBusy(false); + } + }; + + const byCat = (cat: Suggestion['category']) => suggestions.filter((s) => s.category === cat); + + return ( + + + {msg &&

{msg}

} + +
+
+

Encounter builder

+
+

Build a balanced encounter for your party and jump into combat.

+
+ {(DIFFICULTY[campaign.system] ?? []).map((d) => ( + + ))} +
+
+ +

Combat

+ +
+ +
+

Party resources

+ + +

Session prep

+ +
+
+
+ ); +} + +function SuggestionList({ items, onAction, empty }: { items: Suggestion[]; onAction: (a: SuggestAction) => void; empty: string }) { + if (items.length === 0) return

{empty}

; + return ( +
    + {items.map((s) => ( +
  • +
    +
    {s.title}
    + {s.detail &&
    {s.detail}
    } +
    + {s.action && ( + + )} +
  • + ))} +
+ ); +} diff --git a/src/features/world/DashboardPage.tsx b/src/features/world/DashboardPage.tsx index 66d4493..6c5ac96 100644 --- a/src/features/world/DashboardPage.tsx +++ b/src/features/world/DashboardPage.tsx @@ -13,6 +13,7 @@ export function DashboardPage() { } const LINKS = [ + { to: '/assistant', label: 'Assistant', icon: '๐Ÿง ' }, { to: '/characters', label: 'Characters', icon: '๐Ÿง' }, { to: '/combat', label: 'Combat', icon: 'โš”' }, { to: '/notes', label: 'Notes & Wiki', icon: '๐Ÿ“œ' }, diff --git a/src/lib/assistant/advisors.test.ts b/src/lib/assistant/advisors.test.ts new file mode 100644 index 0000000..0f392b2 --- /dev/null +++ b/src/lib/assistant/advisors.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { resourceSuggestions, planningSuggestions, combatSuggestions } from './advisors'; +import { buildSuggestedEncounter } from './encounter'; +import { characterDefaults, type Character, type Note, type Encounter } from '@/lib/schemas'; + +function pc(name: string, cur: number, max: number, over: Partial = {}): Character { + return { + id: name, campaignId: 'c', system: '5e', kind: 'pc', name, ancestry: '', className: '', level: 3, + abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, + hp: { current: cur, max, temp: 0 }, speed: 30, armorBonus: 0, + skillRanks: {}, saveRanks: {}, perceptionRank: 'trained', + ...characterDefaults(), notes: '', createdAt: '', updatedAt: '', ...over, + }; +} + +describe('resourceSuggestions', () => { + it('flags downed and bloodied PCs', () => { + const s = resourceSuggestions([pc('Down', 0, 20), pc('Hurt', 5, 20), pc('Fine', 20, 20)]); + expect(s.find((x) => x.id === 'down-Down')?.severity).toBe('danger'); + expect(s.find((x) => x.id === 'bloodied-Hurt')?.action?.type).toBe('longRest'); + expect(s.some((x) => x.id === 'bloodied-Fine')).toBe(false); + }); + it('suggests rest when the party is low overall', () => { + const s = resourceSuggestions([pc('A', 4, 20), pc('B', 5, 20)]); + expect(s.some((x) => x.id === 'party-low')).toBe(true); + }); +}); + +describe('planningSuggestions', () => { + const note = (title: string, body = ''): Note => ({ id: title, campaignId: 'c', title, body, tags: [], createdAt: '', updatedAt: '' }); + it('surfaces dangling wiki links as create-note actions', () => { + const s = planningSuggestions([note('Home', 'Go to [[Castle]]')], []); + const d = s.find((x) => x.title.includes('Castle')); + expect(d?.action).toMatchObject({ type: 'createNote', title: 'Castle' }); + }); + it('does not flag links that resolve', () => { + const s = planningSuggestions([note('Home', 'See [[Castle]]'), note('Castle')], []); + expect(s.some((x) => x.title.includes('Missing note'))).toBe(false); + }); +}); + +describe('combatSuggestions', () => { + const enc = (over: Partial = {}): Encounter => ({ + id: 'e', campaignId: 'c', name: 'E', status: 'active', round: 2, turnIndex: 0, + combatants: [], log: [], createdAt: '', updatedAt: '', ...over, + }); + it('returns nothing when no active combat', () => { + expect(combatSuggestions(undefined)).toEqual([]); + expect(combatSuggestions(enc({ status: 'planning' }))).toEqual([]); + }); + it('reports the current turn and downed combatants', () => { + const e = enc({ + combatants: [ + { id: 'a', name: 'Hero', kind: 'pc', initiative: 10, initBonus: 0, ac: 12, hp: { current: 5, max: 10, temp: 0 }, conditions: [], notes: '' }, + { id: 'b', name: 'Goblin', kind: 'monster', initiative: 8, initBonus: 0, ac: 13, hp: { current: 0, max: 7, temp: 0 }, conditions: [], notes: '' }, + ], + }); + const s = combatSuggestions(e); + expect(s.some((x) => x.title.includes("Hero's turn"))).toBe(true); + expect(s.find((x) => x.id === 'down-combatants')?.detail).toContain('Goblin'); + }); +}); + +describe('buildSuggestedEncounter', () => { + it('reaches at least the target difficulty for 5e', () => { + const pool = [{ cr: 0.25 }, { cr: 0.5 }, { cr: 1 }, { cr: 2 }]; + const chosen = buildSuggestedEncounter('5e', [3, 3, 3, 3], pool, 'Medium'); + expect(chosen.length).toBeGreaterThan(0); + expect(chosen.length).toBeLessThanOrEqual(8); + }); + it('returns nothing when the pool has no eligible creatures', () => { + expect(buildSuggestedEncounter('5e', [1, 1, 1, 1], [{ cr: 25 }], 'Hard')).toEqual([]); + }); +}); diff --git a/src/lib/assistant/advisors.ts b/src/lib/assistant/advisors.ts new file mode 100644 index 0000000..a8bda95 --- /dev/null +++ b/src/lib/assistant/advisors.ts @@ -0,0 +1,112 @@ +import type { Character, Encounter, Note, Quest } from '@/lib/schemas'; +import { extractWikiLinks } from '@/lib/wikilinks'; + +export type Severity = 'info' | 'warn' | 'danger'; + +export type SuggestAction = + | { type: 'goto'; to: string; params?: Record; label: string } + | { type: 'longRest'; characterId: string; label: string } + | { type: 'createNote'; title: string; label: string }; + +export interface Suggestion { + id: string; + category: 'resources' | 'planning' | 'combat'; + severity: Severity; + title: string; + detail?: string; + action?: SuggestAction; +} + +/** Party readiness: downed, bloodied, and expended-resource nudges. */ +export function resourceSuggestions(characters: Character[]): Suggestion[] { + const pcs = characters.filter((c) => c.kind === 'pc'); + const out: Suggestion[] = []; + + for (const c of pcs) { + if (c.hp.current <= 0) { + out.push({ + id: `down-${c.id}`, category: 'resources', severity: 'danger', + title: `${c.name} is down`, detail: 'At 0 HP.', + action: { type: 'goto', to: '/characters/$characterId', params: { characterId: c.id }, label: 'Open sheet' }, + }); + } else if (c.hp.max > 0 && c.hp.current < c.hp.max * 0.5) { + out.push({ + id: `bloodied-${c.id}`, category: 'resources', severity: 'warn', + title: `${c.name} is bloodied`, detail: `${c.hp.current}/${c.hp.max} HP.`, + action: { type: 'longRest', characterId: c.id, label: 'Long rest' }, + }); + } + const spentSlots = c.spellcasting.slots.some((s) => s.current < s.max); + if (spentSlots && c.hp.current > 0) { + out.push({ + id: `slots-${c.id}`, category: 'resources', severity: 'info', + title: `${c.name} has expended spell slots`, + action: { type: 'longRest', characterId: c.id, label: 'Long rest' }, + }); + } + } + + if (pcs.length > 0) { + const totalFrac = pcs.reduce((s, c) => s + (c.hp.max > 0 ? c.hp.current / c.hp.max : 1), 0) / pcs.length; + if (totalFrac < 0.5) { + out.push({ id: 'party-low', category: 'resources', severity: 'warn', title: 'The party is running low', detail: 'Average HP is under half โ€” a rest may be wise.' }); + } + } + return out; +} + +/** Session-prep gaps: dangling wiki links, quest status, empty journal. */ +export function planningSuggestions(notes: Note[], quests: Quest[]): Suggestion[] { + const out: Suggestion[] = []; + const titles = new Set(notes.map((n) => n.title.toLowerCase())); + const dangling = new Set(); + for (const n of notes) { + for (const link of extractWikiLinks(n.body)) { + if (!titles.has(link.toLowerCase())) dangling.add(link); + } + } + for (const title of dangling) { + out.push({ + id: `dangling-${title}`, category: 'planning', severity: 'info', + title: `Missing note: โ€œ${title}โ€`, detail: 'A wiki link points to a note that does not exist yet.', + action: { type: 'createNote', title, label: 'Create note' }, + }); + } + + const active = quests.filter((q) => q.status === 'active'); + if (active.length > 0) { + out.push({ + id: 'active-quests', category: 'planning', severity: 'info', + title: `${active.length} quest${active.length === 1 ? '' : 's'} in progress`, + action: { type: 'goto', to: '/quests', label: 'Review quests' }, + }); + } + if (notes.length === 0) { + out.push({ + id: 'no-notes', category: 'planning', severity: 'info', title: 'No session notes yet', + detail: 'Start a journal or lore wiki to keep track of the story.', + action: { type: 'goto', to: '/notes', label: 'Open notes' }, + }); + } + return out; +} + +/** Live combat hints from the active encounter. */ +export function combatSuggestions(encounter: Encounter | undefined): Suggestion[] { + if (!encounter || encounter.status !== 'active') return []; + const out: Suggestion[] = []; + const current = encounter.combatants[encounter.turnIndex]; + if (current) { + out.push({ id: 'turn', category: 'combat', severity: 'info', title: `Round ${encounter.round}: ${current.name}'s turn` }); + } + const down = encounter.combatants.filter((c) => c.hp.current <= 0); + if (down.length > 0) { + out.push({ + id: 'down-combatants', category: 'combat', severity: 'warn', + title: `${down.length} combatant${down.length === 1 ? '' : 's'} down`, + detail: down.map((c) => c.name).join(', '), + action: { type: 'goto', to: '/combat', label: 'Open combat' }, + }); + } + return out; +} diff --git a/src/lib/assistant/encounter.ts b/src/lib/assistant/encounter.ts new file mode 100644 index 0000000..e22baa5 --- /dev/null +++ b/src/lib/assistant/encounter.ts @@ -0,0 +1,52 @@ +import type { SystemId } from '@/lib/rules'; +import type { Rng } from '@/lib/rng'; +import { createRng } from '@/lib/rng'; +import { computeBudget } from '@/lib/combat/budget'; + +export interface PoolMonster { + cr?: number | undefined; + level?: number | undefined; +} + +function shuffle(arr: T[], rng: Rng): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = rng.int(0, i); + [a[i], a[j]] = [a[j]!, a[i]!]; + } + return a; +} + +/** + * Greedily assemble monsters from a pool to reach (at least) the target + * difficulty for the party, choosing level/CR-appropriate creatures. Pure given + * an Rng. Returns the chosen pool entries (may repeat a creature). + */ +export function buildSuggestedEncounter( + system: SystemId, + partyLevels: number[], + pool: T[], + difficulty: string, + rng: Rng = createRng(), + maxMonsters = 8, +): T[] { + const avg = partyLevels.length ? Math.round(partyLevels.reduce((a, b) => a + b, 0) / partyLevels.length) : 1; + const thresholds = computeBudget(system, partyLevels, []).thresholds; + const target = thresholds.find((t) => t.label.toLowerCase() === difficulty.toLowerCase())?.value ?? 0; + if (target <= 0) return []; + + const eligible = pool.filter((m) => + system === '5e' + ? m.cr !== undefined && m.cr <= avg + 1 && m.cr >= Math.max(0, avg - 6) + : m.level !== undefined && m.level <= avg + 2 && m.level >= avg - 4, + ); + if (eligible.length === 0) return []; + + const order = shuffle(eligible, rng); + const chosen: T[] = []; + for (let i = 0; chosen.length < maxMonsters; i++) { + chosen.push(order[i % order.length]!); + if (computeBudget(system, partyLevels, chosen).ratingXp >= target) break; + } + return chosen; +} diff --git a/src/router.tsx b/src/router.tsx index 2298cb5..aedf1fa 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -15,6 +15,7 @@ import { MapsPage } from '@/features/world/MapsPage'; import { PlayerViewPage } from '@/features/player/PlayerViewPage'; import { HomebrewPage } from '@/features/world/HomebrewPage'; import { SettingsPage } from '@/features/settings/SettingsPage'; +import { AssistantPage } from '@/features/assistant/AssistantPage'; const rootRoute = createRootRoute({ component: RootLayout }); @@ -37,6 +38,7 @@ const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage }); const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage }); const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage }); +const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage }); const routeTree = rootRoute.addChildren([ indexRoute, @@ -54,6 +56,7 @@ const routeTree = rootRoute.addChildren([ playRoute, homebrewRoute, settingsRoute, + assistantRoute, ]); export const router = createRouter({ routeTree, defaultPreload: 'intent' }); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 3166f2e..e043527 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/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/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/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/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/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/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/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/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file