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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import { Link } from '@tanstack/react-router';
|
|||||||
import { charactersRepo } from '@/lib/db/repositories';
|
import { charactersRepo } from '@/lib/db/repositories';
|
||||||
import type { Campaign, Character } from '@/lib/schemas';
|
import type { Campaign, Character } from '@/lib/schemas';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
|
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
|
||||||
|
import { pickTextFile } from '@/lib/io/file';
|
||||||
import { useCharacters } from './hooks';
|
import { useCharacters } from './hooks';
|
||||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -16,25 +18,49 @@ export function CharactersPage() {
|
|||||||
function CharactersList({ campaign }: { campaign: Campaign }) {
|
function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||||
const characters = useCharacters(campaign.id);
|
const characters = useCharacters(campaign.id);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||||
const npcs = characters.filter((c) => c.kind === 'npc');
|
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Characters"
|
title="Characters"
|
||||||
subtitle={campaign.name}
|
subtitle={campaign.name}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="primary" onClick={() => setCreating(true)}>
|
<>
|
||||||
+ New character
|
<Button variant="secondary" onClick={importCharacter}>
|
||||||
</Button>
|
Import
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => setCreating(true)}>
|
||||||
|
+ New character
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{importError && (
|
||||||
|
<p className="mb-4 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||||
|
{importError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{characters.length === 0 ? (
|
{characters.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No characters yet"
|
title="No characters yet"
|
||||||
hint="Add player characters and NPCs to this campaign."
|
hint="Add player characters and NPCs, or import a character file."
|
||||||
action={
|
action={
|
||||||
<Button variant="primary" onClick={() => setCreating(true)}>
|
<Button variant="primary" onClick={() => setCreating(true)}>
|
||||||
+ New character
|
+ New character
|
||||||
@@ -60,34 +86,84 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
|
|||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
|
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
<Link
|
<CharacterCard key={c.id} c={c} />
|
||||||
key={c.id}
|
|
||||||
to="/characters/$characterId"
|
|
||||||
params={{ characterId: c.id }}
|
|
||||||
className="rounded-lg border border-line bg-panel p-4 transition-colors hover:border-accent/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
|
|
||||||
<span className="text-sm text-muted">Lv {c.level}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-sm text-muted">
|
|
||||||
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex gap-4 text-sm">
|
|
||||||
<span className="text-muted">
|
|
||||||
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-muted">
|
|
||||||
AC <span className="font-medium text-ink">{getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col rounded-lg border border-line bg-panel transition-colors hover:border-accent/50">
|
||||||
|
<Link
|
||||||
|
to="/characters/$characterId"
|
||||||
|
params={{ characterId: c.id }}
|
||||||
|
className="block p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
|
||||||
|
<span className="text-sm text-muted">Lv {c.level}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-sm text-muted">
|
||||||
|
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex gap-4 text-sm">
|
||||||
|
<span className="text-muted">
|
||||||
|
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted">
|
||||||
|
AC <span className="font-medium text-ink">{ac}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-1 border-t border-line px-2 py-1.5">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => exportCharacter(c)} title="Export to JSON file">
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-auto text-danger hover:bg-danger/10"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={confirming}
|
||||||
|
onClose={() => setConfirming(false)}
|
||||||
|
title="Delete character?"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirming(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={async () => {
|
||||||
|
await charactersRepo.remove(c.id);
|
||||||
|
setConfirming(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Permanently delete <strong className="text-ink">{c.name}</strong>? This cannot be undone.
|
||||||
|
{' '}Consider exporting first.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CharacterFormModal({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
|
function CharacterFormModal({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [kind, setKind] = useState<Character['kind']>('pc');
|
const [kind, setKind] = useState<Character['kind']>('pc');
|
||||||
|
|||||||
@@ -268,12 +268,12 @@ function CombatantRow({
|
|||||||
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
|
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-1">
|
<div className="mt-1 flex items-center gap-1">
|
||||||
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP change`} />
|
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
|
||||||
<Button size="sm" variant="danger" onClick={() => { onDamage(delta); setDelta(0); }}>
|
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
|
||||||
−
|
Dmg
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" onClick={() => { onHeal(delta); setDelta(0); }}>
|
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
|
||||||
+
|
Heal
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { encountersRepo } from '@/lib/db/repositories';
|
|||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { Page, PageHeader } from '@/components/ui/Page';
|
import { Page, PageHeader } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
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 { cn } from '@/lib/cn';
|
||||||
import { MonsterDetail } from './MonsterDetail';
|
import { MonsterDetail } from './MonsterDetail';
|
||||||
|
|
||||||
@@ -23,11 +23,55 @@ const TABS: { kind: CompendiumKind; label: string }[] = [
|
|||||||
{ kind: 'items', label: 'Magic Items' },
|
{ kind: 'items', label: 'Magic Items' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
type?: string;
|
||||||
|
school?: string;
|
||||||
|
level?: number;
|
||||||
|
rarity?: string;
|
||||||
|
crMin?: number;
|
||||||
|
crMax?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distinct<T>(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() {
|
export function CompendiumPage() {
|
||||||
const [kind, setKind] = useState<CompendiumKind>('monsters');
|
const [kind, setKind] = useState<CompendiumKind>('monsters');
|
||||||
const [data, setData] = useState<AnyEntry[]>([]);
|
const [data, setData] = useState<AnyEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [filters, setFilters] = useState<Filters>({});
|
||||||
const [selected, setSelected] = useState<AnyEntry | null>(null);
|
const [selected, setSelected] = useState<AnyEntry | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,15 +89,23 @@ export function CompendiumPage() {
|
|||||||
};
|
};
|
||||||
}, [kind]);
|
}, [kind]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => applyFilters(kind, data, filters), [kind, data, filters]);
|
||||||
|
|
||||||
const fuse = useMemo(
|
const fuse = useMemo(
|
||||||
() => new Fuse(data, { keys: ['name', 'type', 'school', 'rarity'], threshold: 0.35, ignoreLocation: true }),
|
() => new Fuse(filtered, { keys: ['name', 'type', 'school', 'rarity'], threshold: 0.35, ignoreLocation: true }),
|
||||||
[data],
|
[filtered],
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = useMemo(() => {
|
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);
|
return list.slice(0, 300);
|
||||||
}, [query, fuse, data]);
|
}, [query, fuse, filtered]);
|
||||||
|
|
||||||
|
const switchTab = (k: CompendiumKind) => {
|
||||||
|
setKind(k);
|
||||||
|
setQuery('');
|
||||||
|
setFilters({});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -63,7 +115,7 @@ export function CompendiumPage() {
|
|||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.kind}
|
key={t.kind}
|
||||||
onClick={() => { setKind(t.kind); setQuery(''); }}
|
onClick={() => switchTab(t.kind)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
||||||
kind === t.kind ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink',
|
kind === t.kind ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink',
|
||||||
@@ -82,6 +134,9 @@ export function CompendiumPage() {
|
|||||||
placeholder={`Search ${kind}…`}
|
placeholder={`Search ${kind}…`}
|
||||||
aria-label="Search compendium"
|
aria-label="Search compendium"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CompendiumFilters kind={kind} data={data} filters={filters} onChange={setFilters} />
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-muted">
|
<p className="mt-1 text-xs text-muted">
|
||||||
{loading ? 'Loading…' : `${results.length}${results.length === 300 ? '+' : ''} results`}
|
{loading ? 'Loading…' : `${results.length}${results.length === 300 ? '+' : ''} results`}
|
||||||
</p>
|
</p>
|
||||||
@@ -115,6 +170,91 @@ export function CompendiumPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CompendiumFilters({
|
||||||
|
kind,
|
||||||
|
data,
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
kind: CompendiumKind;
|
||||||
|
data: AnyEntry[];
|
||||||
|
filters: Filters;
|
||||||
|
onChange: (f: Filters) => void;
|
||||||
|
}) {
|
||||||
|
const options = useMemo(() => {
|
||||||
|
if (kind === 'monsters') {
|
||||||
|
const m = data as Monster[];
|
||||||
|
const crs = [...new Set(m.map((x) => x.cr).filter((c): c is number => c !== undefined))].sort((a, b) => a - b);
|
||||||
|
return { types: distinct(m, (x) => x.type), crs };
|
||||||
|
}
|
||||||
|
if (kind === 'spells') {
|
||||||
|
const s = data as Spell[];
|
||||||
|
const levels = [...new Set(s.map((x) => x.level_int).filter((l): l is number => l !== undefined))].sort((a, b) => a - b);
|
||||||
|
return { schools: distinct(s, (x) => x.school), levels };
|
||||||
|
}
|
||||||
|
const i = data as MagicItem[];
|
||||||
|
return { rarities: distinct(i, (x) => x.rarity), itemTypes: distinct(i, (x) => x.type) };
|
||||||
|
}, [kind, data]);
|
||||||
|
|
||||||
|
const setKey = <K extends keyof Filters>(key: K, value: Filters[K] | undefined) => {
|
||||||
|
const next: Filters = { ...filters };
|
||||||
|
if (value === undefined) delete next[key];
|
||||||
|
else next[key] = value;
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
const numOrUndef = (v: string) => (v === '' ? undefined : Number(v));
|
||||||
|
const strOrUndef = (v: string) => v || undefined;
|
||||||
|
const active = Object.values(filters).some((v) => v !== undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{kind === 'monsters' && (
|
||||||
|
<>
|
||||||
|
<Select className="w-auto py-1 text-xs" aria-label="Filter by type" value={filters.type ?? ''} onChange={(e) => setKey('type', strOrUndef(e.target.value))}>
|
||||||
|
<option value="">Any type</option>
|
||||||
|
{options.types!.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</Select>
|
||||||
|
<Select className="w-auto py-1 text-xs" aria-label="Minimum CR" value={filters.crMin ?? ''} onChange={(e) => setKey('crMin', numOrUndef(e.target.value))}>
|
||||||
|
<option value="">CR ≥ any</option>
|
||||||
|
{options.crs!.map((c) => <option key={c} value={c}>CR ≥ {crNumLabel(c)}</option>)}
|
||||||
|
</Select>
|
||||||
|
<Select className="w-auto py-1 text-xs" aria-label="Maximum CR" value={filters.crMax ?? ''} onChange={(e) => setKey('crMax', numOrUndef(e.target.value))}>
|
||||||
|
<option value="">CR ≤ any</option>
|
||||||
|
{options.crs!.map((c) => <option key={c} value={c}>CR ≤ {crNumLabel(c)}</option>)}
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === 'spells' && (
|
||||||
|
<>
|
||||||
|
<Select className="w-auto py-1 text-xs" aria-label="Filter by level" value={filters.level ?? ''} onChange={(e) => setKey('level', numOrUndef(e.target.value))}>
|
||||||
|
<option value="">Any level</option>
|
||||||
|
{options.levels!.map((l) => <option key={l} value={l}>{l === 0 ? 'Cantrip' : `Level ${l}`}</option>)}
|
||||||
|
</Select>
|
||||||
|
<Select className="w-auto py-1 text-xs" aria-label="Filter by school" value={filters.school ?? ''} onChange={(e) => setKey('school', strOrUndef(e.target.value))}>
|
||||||
|
<option value="">Any school</option>
|
||||||
|
{options.schools!.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === 'items' && (
|
||||||
|
<>
|
||||||
|
<Select className="w-auto py-1 text-xs" aria-label="Filter by rarity" value={filters.rarity ?? ''} onChange={(e) => setKey('rarity', strOrUndef(e.target.value))}>
|
||||||
|
<option value="">Any rarity</option>
|
||||||
|
{options.rarities!.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</Select>
|
||||||
|
<Select className="w-auto py-1 text-xs" aria-label="Filter by item type" value={filters.type ?? ''} onChange={(e) => setKey('type', strOrUndef(e.target.value))}>
|
||||||
|
<option value="">Any type</option>
|
||||||
|
{options.itemTypes!.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{active && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onChange({})}>Clear</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EntryMeta({ kind, entry }: { kind: CompendiumKind; entry: AnyEntry }) {
|
function EntryMeta({ kind, entry }: { kind: CompendiumKind; entry: AnyEntry }) {
|
||||||
if (kind === 'monsters') return <span className="text-xs text-muted">CR {crLabel(entry as Monster)}</span>;
|
if (kind === 'monsters') return <span className="text-xs text-muted">CR {crLabel(entry as Monster)}</span>;
|
||||||
if (kind === 'spells') {
|
if (kind === 'spells') {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { rollDice, DiceParseError, type RollResult } from '@/lib/dice/notation';
|
import { rollDice, DiceParseError, type RollResult } from '@/lib/dice/notation';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
@@ -7,6 +7,7 @@ import { useUiStore } from '@/stores/uiStore';
|
|||||||
import { Page, PageHeader } from '@/components/ui/Page';
|
import { Page, PageHeader } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'];
|
const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'];
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export function DicePage() {
|
|||||||
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
|
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
|
||||||
const [expr, setExpr] = useState('1d20');
|
const [expr, setExpr] = useState('1d20');
|
||||||
const [last, setLast] = useState<RollResult | null>(null);
|
const [last, setLast] = useState<RollResult | null>(null);
|
||||||
|
const [rollCount, setRollCount] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []);
|
const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []);
|
||||||
@@ -22,6 +24,7 @@ export function DicePage() {
|
|||||||
try {
|
try {
|
||||||
const result = rollDice(expression, createRng());
|
const result = rollDice(expression, createRng());
|
||||||
setLast(result);
|
setLast(result);
|
||||||
|
setRollCount((n) => n + 1);
|
||||||
setError(null);
|
setError(null);
|
||||||
await diceRepo.add({
|
await diceRepo.add({
|
||||||
...(campaignId ? { campaignId } : {}),
|
...(campaignId ? { campaignId } : {}),
|
||||||
@@ -83,9 +86,12 @@ export function DicePage() {
|
|||||||
{error && <p className="mt-4 text-sm text-danger">{error}</p>}
|
{error && <p className="mt-4 text-sm text-danger">{error}</p>}
|
||||||
|
|
||||||
{last && (
|
{last && (
|
||||||
<div className="mt-6 rounded-lg border border-line bg-panel p-6 text-center" aria-live="polite">
|
<div className="mt-6 rounded-lg border border-line bg-panel p-6 text-center">
|
||||||
<div className="font-display text-6xl font-bold text-accent">{last.total}</div>
|
<AnimatedTotal key={rollCount} result={last} />
|
||||||
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
|
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
|
||||||
|
<span className="sr-only" aria-live="polite">
|
||||||
|
Rolled {last.expression}: {last.total}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -115,3 +121,42 @@ export function DicePage() {
|
|||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'font-display text-6xl font-bold text-accent tabular-nums',
|
||||||
|
rolling ? 'animate-dice-rolling opacity-80' : 'animate-dice-settle',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ export const charactersRepo = {
|
|||||||
await db.characters.update(id, { ...patch, updatedAt: now() });
|
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<void> {
|
||||||
|
await db.characters.add(characterSchema.parse(character));
|
||||||
|
},
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await db.characters.delete(id);
|
await db.characters.delete(id);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>),
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string | null> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -84,3 +84,43 @@ body {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--app-line) transparent;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"}
|
{"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"}
|
||||||
Reference in New Issue
Block a user