Phase 11: data-driven Assistant

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 08:06:20 +02:00
parent bd6bb240a1
commit 0930136c46
9 changed files with 429 additions and 1 deletions
+33
View File
@@ -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();
});
+1
View File
@@ -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' },
+152
View File
@@ -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 <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
}
const SEV_CLS = { info: 'border-line', warn: 'border-warning/50', danger: 'border-danger/50' } as const;
const DIFFICULTY: Record<string, string[]> = {
'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<string | null>(null);
const suggestions = useMemo<Suggestion[]>(
() => [...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<string, unknown> & { 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<string, unknown>;
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 (
<Page>
<PageHeader title="Assistant" subtitle={`${campaign.name} · suggestions from your campaign data`} />
{msg && <p className="mb-3 text-sm text-success" aria-live="polite">{msg}</p>}
<div className="grid gap-6 lg:grid-cols-2">
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Encounter builder</h2>
<div className="rounded-lg border border-line bg-panel p-4">
<p className="mb-3 text-sm text-muted">Build a balanced encounter for your party and jump into combat.</p>
<div className="flex flex-wrap gap-2">
{(DIFFICULTY[campaign.system] ?? []).map((d) => (
<Button key={d} variant="secondary" disabled={busy} onClick={() => buildEncounter(d)}>{d}</Button>
))}
</div>
</div>
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Combat</h2>
<SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
</section>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Party resources</h2>
<SuggestionList items={byCat('resources')} onAction={runAction} empty="The party looks ready." />
<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." />
</section>
</div>
</Page>
);
}
function SuggestionList({ items, onAction, empty }: { items: Suggestion[]; onAction: (a: SuggestAction) => void; empty: string }) {
if (items.length === 0) return <p className="text-sm text-muted">{empty}</p>;
return (
<ul className="space-y-2">
{items.map((s) => (
<li key={s.id} className={cn('flex items-center gap-3 rounded-lg border bg-panel p-3', SEV_CLS[s.severity])}>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ink">{s.title}</div>
{s.detail && <div className="text-xs text-muted">{s.detail}</div>}
</div>
{s.action && (
<Button size="sm" variant="secondary" onClick={() => onAction(s.action!)}>{s.action.label}</Button>
)}
</li>
))}
</ul>
);
}
+1
View File
@@ -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: '📜' },
+74
View File
@@ -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> = {}): 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> = {}): 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([]);
});
});
+112
View File
@@ -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<string, string>; 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<string>();
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;
}
+52
View File
@@ -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<T>(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<T extends PoolMonster>(
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;
}
+3
View File
@@ -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' });
+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/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"}
{"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"}