From 866f1e1bf113284fbf24471cd487032e9cad0a44 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 00:40:43 +0200 Subject: [PATCH] Fixes + UX: combat HP, char delete, dice animation, compendium filters, import/export - Combat: relabel HP controls Dmg/Heal, disabled at 0 (were silent no-ops) - Characters: per-card Delete (confirm) + Export; header Import from JSON - Dice: tumbling roll animation that settles on the result (a11y-safe) - Compendium: per-category filters (monster type/CR, spell level/school, item rarity/type) + Clear - Character import/export to portable JSON (reassigns id, validates on import) - New: 4 io unit tests, e2e fixes spec (HP damage, delete, CR filter) Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/fixes.spec.ts | 64 +++++++++ src/features/characters/CharactersPage.tsx | 128 +++++++++++++---- src/features/combat/EncounterTracker.tsx | 10 +- src/features/compendium/CompendiumPage.tsx | 152 ++++++++++++++++++++- src/features/dice/DicePage.tsx | 51 ++++++- src/lib/db/repositories.ts | 5 + src/lib/io/character.test.ts | 43 ++++++ src/lib/io/character.ts | 59 ++++++++ src/lib/io/file.ts | 41 ++++++ src/styles/globals.css | 40 ++++++ tsconfig.app.tsbuildinfo | 2 +- 11 files changed, 554 insertions(+), 41 deletions(-) create mode 100644 e2e/fixes.spec.ts create mode 100644 src/lib/io/character.test.ts create mode 100644 src/lib/io/character.ts create mode 100644 src/lib/io/file.ts diff --git a/e2e/fixes.spec.ts b/e2e/fixes.spec.ts new file mode 100644 index 0000000..5a37f1d --- /dev/null +++ b/e2e/fixes.spec.ts @@ -0,0 +1,64 @@ +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(); +}); + +async function makeCampaign(page: import('@playwright/test').Page, name: string) { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill(name); + await page.getByRole('button', { name: 'Create' }).click(); +} + +test('combat HP damage button reduces hit points', async ({ page }) => { + await makeCampaign(page, 'Combat Test'); + await page.getByRole('link', { name: 'Combat' }).click(); + await page.getByRole('button', { name: '+ New encounter' }).first().click(); + await page.locator('input[data-autofocus]').fill('Fight'); + await page.getByRole('button', { name: 'Create' }).click(); + + await page.getByLabel('Name').fill('Goblin'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + await expect(page.getByText('10/10')).toBeVisible(); + + // Enter 4 damage and apply + await page.getByLabel('Goblin HP amount').fill('4'); + await page.getByRole('button', { name: 'Dmg' }).click(); + await expect(page.getByText('6/10')).toBeVisible(); + + // Heal 2 back + await page.getByLabel('Goblin HP amount').fill('2'); + await page.getByRole('button', { name: 'Heal' }).click(); + await expect(page.getByText('8/10')).toBeVisible(); +}); + +test('character can be deleted', async ({ page }) => { + await makeCampaign(page, 'Delete Test'); + await page.getByRole('link', { name: 'Characters' }).click(); + await page.getByRole('button', { name: '+ New character' }).first().click(); + await page.locator('input[data-autofocus]').fill('Doomed'); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByRole('heading', { name: 'Doomed' })).toBeVisible(); + + await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete', exact: true }).last().click(); + await expect(page.getByRole('heading', { name: 'Doomed' })).toHaveCount(0); +}); + +test('compendium CR filter narrows the bestiary', async ({ page }) => { + await makeCampaign(page, 'Filter Test'); + await page.getByRole('link', { name: 'Compendium' }).click(); + await expect(page.getByText(/results/)).toBeVisible(); + + const countText = async () => (await page.getByText(/results/).textContent()) ?? ''; + const before = parseInt((await countText()).replace(/\D/g, ''), 10); + + await page.getByLabel('Minimum CR').selectOption({ label: 'CR ≥ 10' }); + // wait for the count to update + await expect.poll(async () => parseInt((await countText()).replace(/\D/g, ''), 10)).toBeLessThan(before); +}); diff --git a/src/features/characters/CharactersPage.tsx b/src/features/characters/CharactersPage.tsx index 4d72583..5627403 100644 --- a/src/features/characters/CharactersPage.tsx +++ b/src/features/characters/CharactersPage.tsx @@ -3,6 +3,8 @@ import { Link } from '@tanstack/react-router'; import { charactersRepo } from '@/lib/db/repositories'; import type { Campaign, Character } from '@/lib/schemas'; import { getSystem } from '@/lib/rules'; +import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character'; +import { pickTextFile } from '@/lib/io/file'; import { useCharacters } from './hooks'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; @@ -16,25 +18,49 @@ export function CharactersPage() { function CharactersList({ campaign }: { campaign: Campaign }) { const characters = useCharacters(campaign.id); const [creating, setCreating] = useState(false); + const [importError, setImportError] = useState(null); const pcs = characters.filter((c) => c.kind === 'pc'); const npcs = characters.filter((c) => c.kind === 'npc'); + const importCharacter = async () => { + setImportError(null); + const text = await pickTextFile(); + if (text === null) return; + try { + const character = parseCharacterImport(text, campaign.id); + await charactersRepo.insert(character); + } catch (e) { + setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.'); + } + }; + return ( setCreating(true)}> - + New character - + <> + + + } /> + {importError && ( +

+ {importError} +

+ )} + {characters.length === 0 ? ( setCreating(true)}> + New character @@ -60,34 +86,84 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })

{title}

{items.map((c) => ( - -
-

{c.name}

- Lv {c.level} -
-

- {[c.ancestry, c.className].filter(Boolean).join(' ') || '—'} -

-
- - HP {c.hp.current}/{c.hp.max} - - - AC {getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })} - -
- + ))}
); } +function CharacterCard({ c }: { c: Character }) { + const [confirming, setConfirming] = useState(false); + const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus }); + + return ( +
+ +
+

{c.name}

+ Lv {c.level} +
+

+ {[c.ancestry, c.className].filter(Boolean).join(' ') || '—'} +

+
+ + HP {c.hp.current}/{c.hp.max} + + + AC {ac} + +
+ +
+ + +
+ + setConfirming(false)} + title="Delete character?" + footer={ + <> + + + + } + > +

+ Permanently delete {c.name}? This cannot be undone. + {' '}Consider exporting first. +

+
+
+ ); +} + function CharacterFormModal({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) { const [name, setName] = useState(''); const [kind, setKind] = useState('pc'); diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index ae611c7..e1f02bc 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -268,12 +268,12 @@ function CombatantRow({ {c.hp.temp > 0 && +{c.hp.temp}}
- - -
diff --git a/src/features/compendium/CompendiumPage.tsx b/src/features/compendium/CompendiumPage.tsx index b3f50b2..bc398bd 100644 --- a/src/features/compendium/CompendiumPage.tsx +++ b/src/features/compendium/CompendiumPage.tsx @@ -11,7 +11,7 @@ import { encountersRepo } from '@/lib/db/repositories'; import { useUiStore } from '@/stores/uiStore'; import { Page, PageHeader } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { Input, Select } from '@/components/ui/Input'; import { cn } from '@/lib/cn'; import { MonsterDetail } from './MonsterDetail'; @@ -23,11 +23,55 @@ const TABS: { kind: CompendiumKind; label: string }[] = [ { kind: 'items', label: 'Magic Items' }, ]; +interface Filters { + type?: string; + school?: string; + level?: number; + rarity?: string; + crMin?: number; + crMax?: number; +} + +function distinct(items: T[], get: (x: T) => string | undefined): string[] { + return [...new Set(items.map(get).filter((v): v is string => !!v && v.trim() !== ''))].sort((a, b) => + a.localeCompare(b), + ); +} + +function crNumLabel(n: number): string { + if (n === 0.125) return '1/8'; + if (n === 0.25) return '1/4'; + if (n === 0.5) return '1/2'; + return String(n); +} + +function applyFilters(kind: CompendiumKind, data: AnyEntry[], f: Filters): AnyEntry[] { + if (kind === 'monsters') { + return (data as Monster[]).filter((m) => { + const cr = m.cr ?? 0; + return ( + (!f.type || m.type === f.type) && + (f.crMin === undefined || cr >= f.crMin) && + (f.crMax === undefined || cr <= f.crMax) + ); + }); + } + if (kind === 'spells') { + return (data as Spell[]).filter( + (s) => (f.level === undefined || s.level_int === f.level) && (!f.school || s.school === f.school), + ); + } + return (data as MagicItem[]).filter( + (i) => (!f.rarity || i.rarity === f.rarity) && (!f.type || i.type === f.type), + ); +} + export function CompendiumPage() { const [kind, setKind] = useState('monsters'); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(''); + const [filters, setFilters] = useState({}); const [selected, setSelected] = useState(null); useEffect(() => { @@ -45,15 +89,23 @@ export function CompendiumPage() { }; }, [kind]); + const filtered = useMemo(() => applyFilters(kind, data, filters), [kind, data, filters]); + const fuse = useMemo( - () => new Fuse(data, { keys: ['name', 'type', 'school', 'rarity'], threshold: 0.35, ignoreLocation: true }), - [data], + () => new Fuse(filtered, { keys: ['name', 'type', 'school', 'rarity'], threshold: 0.35, ignoreLocation: true }), + [filtered], ); const results = useMemo(() => { - const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : data; + const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : filtered; return list.slice(0, 300); - }, [query, fuse, data]); + }, [query, fuse, filtered]); + + const switchTab = (k: CompendiumKind) => { + setKind(k); + setQuery(''); + setFilters({}); + }; return ( @@ -63,7 +115,7 @@ export function CompendiumPage() { {TABS.map((t) => ( + )} + + ); +} + function EntryMeta({ kind, entry }: { kind: CompendiumKind; entry: AnyEntry }) { if (kind === 'monsters') return CR {crLabel(entry as Monster)}; if (kind === 'spells') { diff --git a/src/features/dice/DicePage.tsx b/src/features/dice/DicePage.tsx index 8006952..4cacc95 100644 --- a/src/features/dice/DicePage.tsx +++ b/src/features/dice/DicePage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; import { rollDice, DiceParseError, type RollResult } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; @@ -7,6 +7,7 @@ import { useUiStore } from '@/stores/uiStore'; import { Page, PageHeader } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; +import { cn } from '@/lib/cn'; const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100']; @@ -14,6 +15,7 @@ export function DicePage() { const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined; const [expr, setExpr] = useState('1d20'); const [last, setLast] = useState(null); + const [rollCount, setRollCount] = useState(0); const [error, setError] = useState(null); const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []); @@ -22,6 +24,7 @@ export function DicePage() { try { const result = rollDice(expression, createRng()); setLast(result); + setRollCount((n) => n + 1); setError(null); await diceRepo.add({ ...(campaignId ? { campaignId } : {}), @@ -83,9 +86,12 @@ export function DicePage() { {error &&

{error}

} {last && ( -
-
{last.total}
+
+
{last.breakdown}
+ + Rolled {last.expression}: {last.total} +
)}
@@ -115,3 +121,42 @@ export function DicePage() {
); } + +/** + * Shows the rolling die "tumbling" through random numbers before settling on the + * real total. Mounted with a per-roll key so each roll replays the animation. + */ +function AnimatedTotal({ result }: { result: RollResult }) { + const [display, setDisplay] = useState(result.total); + const [rolling, setRolling] = useState(true); + + useEffect(() => { + setRolling(true); + let frames = 0; + const maxFrames = 9; + const span = Math.max(6, Math.abs(result.total) + 4); + const id = setInterval(() => { + frames++; + if (frames >= maxFrames) { + clearInterval(id); + setDisplay(result.total); + setRolling(false); + } else { + setDisplay(Math.floor(Math.random() * span) + 1); + } + }, 45); + return () => clearInterval(id); + }, [result]); + + return ( +
+ {display} +
+ ); +} diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 8edc9c5..598d884 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -102,6 +102,11 @@ export const charactersRepo = { await db.characters.update(id, { ...patch, updatedAt: now() }); }, + /** Insert a fully-formed character (e.g. from an import). Validates on write. */ + async insert(character: Character): Promise { + await db.characters.add(characterSchema.parse(character)); + }, + async remove(id: string): Promise { await db.characters.delete(id); }, diff --git a/src/lib/io/character.test.ts b/src/lib/io/character.test.ts new file mode 100644 index 0000000..99b99c3 --- /dev/null +++ b/src/lib/io/character.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { parseCharacterImport, CharacterImportError } from './character'; +import { characterSchema, characterDefaults, type Character } from '@/lib/schemas'; + +function sampleCharacter(): Character { + return characterSchema.parse({ + id: 'orig', campaignId: 'orig-camp', system: '5e', kind: 'pc', + name: 'Imported Hero', ancestry: 'Elf', className: 'Ranger', level: 4, + abilities: { str: 12, dex: 16, con: 14, int: 10, wis: 13, cha: 8 }, + hp: { current: 30, max: 30, temp: 0 }, speed: 30, armorBonus: 2, + skillRanks: { stealth: 'expert' }, saveRanks: {}, perceptionRank: 'trained', + ...characterDefaults(), + notes: 'hi', createdAt: 'x', updatedAt: 'x', + }); +} + +describe('parseCharacterImport', () => { + it('round-trips a wrapped export, reassigning id + campaign', () => { + const c = sampleCharacter(); + const file = JSON.stringify({ format: 'ttrpg-manager:character', version: 1, character: c }); + const imported = parseCharacterImport(file, 'new-camp'); + expect(imported.name).toBe('Imported Hero'); + expect(imported.campaignId).toBe('new-camp'); + expect(imported.id).not.toBe('orig'); + expect(imported.abilities.dex).toBe(16); + expect(imported.skillRanks['stealth']).toBe('expert'); + }); + + it('accepts a bare character object too', () => { + const c = sampleCharacter(); + const imported = parseCharacterImport(JSON.stringify(c), 'camp2'); + expect(imported.name).toBe('Imported Hero'); + expect(imported.campaignId).toBe('camp2'); + }); + + it('rejects invalid JSON', () => { + expect(() => parseCharacterImport('{not json', 'c')).toThrow(CharacterImportError); + }); + + it('rejects a file that is not a character', () => { + expect(() => parseCharacterImport(JSON.stringify({ foo: 1 }), 'c')).toThrow(CharacterImportError); + }); +}); diff --git a/src/lib/io/character.ts b/src/lib/io/character.ts new file mode 100644 index 0000000..6dce007 --- /dev/null +++ b/src/lib/io/character.ts @@ -0,0 +1,59 @@ +import { characterSchema, type Character } from '@/lib/schemas'; +import { newId } from '@/lib/ids'; +import { downloadJson, safeFilename } from './file'; + +const FORMAT = 'ttrpg-manager:character'; +const VERSION = 1; + +interface CharacterFile { + format: typeof FORMAT; + version: number; + character: Character; +} + +/** Download a character as a portable JSON file. */ +export function exportCharacter(c: Character): void { + const payload: CharacterFile = { format: FORMAT, version: VERSION, character: c }; + downloadJson(`${safeFilename(c.name)}.character.json`, payload); +} + +export class CharacterImportError extends Error {} + +/** + * Parse imported JSON into a valid Character bound to the given campaign. Accepts + * both our wrapped export format and a bare character object. Always assigns a + * fresh id + timestamps so importing never collides with or overwrites existing + * data. + */ +export function parseCharacterImport(text: string, campaignId: string): Character { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new CharacterImportError('That file is not valid JSON.'); + } + + const candidate = + raw && typeof raw === 'object' && 'character' in raw + ? (raw as { character: unknown }).character + : raw; + + if (!candidate || typeof candidate !== 'object') { + throw new CharacterImportError('No character data found in that file.'); + } + + const now = new Date().toISOString(); + const result = characterSchema.safeParse({ + ...(candidate as Record), + id: newId(), + campaignId, + createdAt: now, + updatedAt: now, + }); + if (!result.success) { + throw new CharacterImportError( + `That file isn't a valid character: ${result.error.issues[0]?.message ?? 'unknown error'}`, + ); + } + return result.data; +} diff --git a/src/lib/io/file.ts b/src/lib/io/file.ts new file mode 100644 index 0000000..e2139d7 --- /dev/null +++ b/src/lib/io/file.ts @@ -0,0 +1,41 @@ +/** Trigger a browser download of a JSON-serializable value. */ +export function downloadJson(filename: string, data: unknown): void { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + // revoke on the next tick so the download has started + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** Open the OS file picker and resolve with the chosen file's text (or null if cancelled). */ +export function pickTextFile(accept = 'application/json,.json'): Promise { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.onchange = () => { + const file = input.files?.[0]; + if (!file) { + resolve(null); + return; + } + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null); + reader.onerror = () => resolve(null); + reader.readAsText(file); + }; + // If the user cancels, onchange never fires; that's fine — the promise just + // never resolves and is garbage-collected with the input. + input.click(); + }); +} + +/** Make a string safe to use as a filename. */ +export function safeFilename(name: string): string { + return name.replace(/[^a-z0-9-_]+/gi, '_').replace(/^_+|_+$/g, '') || 'export'; +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 0387ec3..b1e081d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -84,3 +84,43 @@ body { scrollbar-width: thin; scrollbar-color: var(--app-line) transparent; } + +/* Dice roll animations */ +@keyframes dice-tumble { + 0% { + transform: scale(0.7) rotate(-12deg); + opacity: 0.5; + } + 50% { + transform: scale(1.12) rotate(8deg); + } + 75% { + transform: scale(0.96) rotate(-3deg); + } + 100% { + transform: scale(1) rotate(0deg); + opacity: 1; + } +} +.animate-dice-settle { + animation: dice-tumble 0.45s cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes dice-shake { + 0%, + 100% { + transform: translate(0, 0) rotate(0deg); + } + 25% { + transform: translate(-2px, 1px) rotate(-3deg); + } + 50% { + transform: translate(2px, -1px) rotate(3deg); + } + 75% { + transform: translate(-1px, 2px) rotate(-2deg); + } +} +.animate-dice-rolling { + animation: dice-shake 0.12s linear infinite; +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 5717579..0ebef53 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/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/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/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.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/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.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/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/rules/abilities.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/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/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/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/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.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/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.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/notation.test.ts","./src/lib/dice/notation.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/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/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file