Phase 9: homebrew content + packs
- Homebrew entity (monster/spell/item/feat/condition) — Dexie v6, repo, hook, cascade-deleted with campaign - Homebrew editor page: per-kind fields + description, create/edit/delete - Compendium merges campaign homebrew of the matching kind (HB badge, generic HomebrewDetail) with the same cross-links (add monster->combat, spell/item->character) - Content packs: export/import homebrew as JSON (validated, ids reassigned) - System extensibility documented via the existing RulesSystem registry seam - Fix: card editors (homebrew/npc/quest/note) debounce-save the FULL snapshot, not partial patches — editing two fields fast no longer drops an edit New homebrew e2e + cascade test coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
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('homebrew: create a custom monster and find it in the compendium', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Brew');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Homebrew' }).click();
|
||||||
|
|
||||||
|
// Create a homebrew monster and name it
|
||||||
|
await page.getByRole('button', { name: /New monster/ }).click();
|
||||||
|
const nameInput = page.getByLabel('Homebrew name');
|
||||||
|
await nameInput.fill('Dire Frog');
|
||||||
|
await page.getByLabel('Hit Points').fill('22');
|
||||||
|
|
||||||
|
// It shows up in the compendium bestiary with an HB badge
|
||||||
|
await page.getByRole('link', { name: 'Compendium' }).click();
|
||||||
|
await page.getByPlaceholder(/Search bestiary/i).fill('Dire Frog');
|
||||||
|
await page.getByRole('button', { name: /Dire Frog/ }).first().click();
|
||||||
|
await expect(page.getByText(/Homebrew monster/)).toBeVisible();
|
||||||
|
await expect(page.getByText('HB').first()).toBeVisible();
|
||||||
|
});
|
||||||
@@ -12,6 +12,9 @@ import type { Character, InventoryItem, SpellEntry } from '@/lib/schemas';
|
|||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
|
import { useHomebrew } from '@/features/world/hooks';
|
||||||
|
import { homebrewToEntry, isHomebrew, homebrewCombatant, CATEGORY_HOMEBREW_KIND } from '@/features/world/homebrew';
|
||||||
|
import { HomebrewDetail } from './details';
|
||||||
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, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -51,16 +54,25 @@ export function CompendiumPage() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [category]);
|
}, [category]);
|
||||||
|
|
||||||
|
// Merge the campaign's homebrew of this category's kind into the dataset.
|
||||||
|
const homebrew = useHomebrew(activeCampaign?.id ?? '');
|
||||||
|
const allData = useMemo(() => {
|
||||||
|
const kind = CATEGORY_HOMEBREW_KIND[category.id];
|
||||||
|
if (!kind) return data;
|
||||||
|
const hb = homebrew.filter((h) => h.system === system && h.kind === kind).map(homebrewToEntry);
|
||||||
|
return [...hb, ...data];
|
||||||
|
}, [data, homebrew, category, system]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const active = Object.entries(filters).filter(([, v]) => v !== '');
|
const active = Object.entries(filters).filter(([, v]) => v !== '');
|
||||||
if (active.length === 0) return data;
|
if (active.length === 0) return allData;
|
||||||
return data.filter((e) =>
|
return allData.filter((e) =>
|
||||||
active.every(([key, value]) => {
|
active.every(([key, value]) => {
|
||||||
const def = category.filters.find((f) => f.key === key);
|
const def = category.filters.find((f) => f.key === key);
|
||||||
return def ? filterMatches(def, e, value) : true;
|
return def ? filterMatches(def, e, value) : true;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [data, filters, category]);
|
}, [allData, filters, category]);
|
||||||
|
|
||||||
const fuse = useMemo(
|
const fuse = useMemo(
|
||||||
() => new Fuse(filtered, { keys: category.searchKeys, threshold: 0.34, ignoreLocation: true }),
|
() => new Fuse(filtered, { keys: category.searchKeys, threshold: 0.34, ignoreLocation: true }),
|
||||||
@@ -212,8 +224,11 @@ export function CompendiumPage() {
|
|||||||
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{isHomebrew(entry) && <span className="shrink-0 rounded bg-accent/20 px-1 text-[10px] font-semibold uppercase text-accent">HB</span>}
|
||||||
<span className="truncate text-ink">{entry.name}</span>
|
<span className="truncate text-ink">{entry.name}</span>
|
||||||
<span className="shrink-0 text-xs text-muted">{category.meta(entry)}</span>
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted">{isHomebrew(entry) ? 'Homebrew' : category.meta(entry)}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -226,7 +241,7 @@ export function CompendiumPage() {
|
|||||||
{selected ? (
|
{selected ? (
|
||||||
<div>
|
<div>
|
||||||
<DetailActions category={category} entry={selected} system={system} />
|
<DetailActions category={category} entry={selected} system={system} />
|
||||||
{category.detail(selected)}
|
{isHomebrew(selected) ? <HomebrewDetail entry={selected} /> : category.detail(selected)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted">Select an entry to view details.</p>
|
<p className="text-sm text-muted">Select an entry to view details.</p>
|
||||||
@@ -238,16 +253,27 @@ export function CompendiumPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DetailActions({ category, entry, system }: { category: CategoryDef; entry: Entry; system: SystemId }) {
|
function DetailActions({ category, entry, system }: { category: CategoryDef; entry: Entry; system: SystemId }) {
|
||||||
const hasActions = category.toCombatant || category.linkAs;
|
// Homebrew entries derive their actions from their kind, not the category.
|
||||||
if (!hasActions) return null;
|
if (isHomebrew(entry)) {
|
||||||
|
const kind = entry.__kind;
|
||||||
|
if (kind === 'monster') return <ActionBar><AddToCombat stats={homebrewCombatant(entry)} /></ActionBar>;
|
||||||
|
if (kind === 'spell') return <ActionBar><AddToCharacter kind="spell" entry={entry} system={system} /></ActionBar>;
|
||||||
|
if (kind === 'item') return <ActionBar><AddToCharacter kind="item" entry={entry} system={system} /></ActionBar>;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!category.toCombatant && !category.linkAs) return null;
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2 border-b border-line pb-3">
|
<ActionBar>
|
||||||
{category.toCombatant && <AddToCombat stats={category.toCombatant(entry)} />}
|
{category.toCombatant && <AddToCombat stats={category.toCombatant(entry)} />}
|
||||||
{category.linkAs && <AddToCharacter kind={category.linkAs} entry={entry} system={system} />}
|
{category.linkAs && <AddToCharacter kind={category.linkAs} entry={entry} system={system} />}
|
||||||
</div>
|
</ActionBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActionBar({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="mb-3 flex flex-wrap items-center gap-2 border-b border-line pb-3">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) {
|
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) {
|
||||||
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ export function Condition5eDetail({ entry: c }: { entry: Condition5e }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renderer for homebrew entries: header, field facts, description. */
|
||||||
|
export function HomebrewDetail({ entry }: { entry: CompendiumEntry }) {
|
||||||
|
const skip = new Set(['name', 'slug', 'description', '__homebrew', '__kind']);
|
||||||
|
const facts = Object.entries(entry).filter(([k, v]) => !skip.has(k) && v !== undefined && v !== '');
|
||||||
|
const kind = typeof entry.__kind === 'string' ? entry.__kind : 'homebrew';
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Header title={entry.name} subtitle={`Homebrew ${kind}`} />
|
||||||
|
{facts.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted sm:grid-cols-3">
|
||||||
|
{facts.map(([k, v]) => (
|
||||||
|
<span key={k}><strong className="text-ink">{k}</strong> {String(v)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entry.description ? <p className="whitespace-pre-wrap text-sm text-ink">{String(entry.description)}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Generic renderer for PF2e (AoN) entries: header, traits, key facts, text body. */
|
/** Generic renderer for PF2e (AoN) entries: header, traits, key facts, text body. */
|
||||||
export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) {
|
export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) {
|
||||||
const traits = (entry.trait as string[] | undefined) ?? undefined;
|
const traits = (entry.trait as string[] | undefined) ?? undefined;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const LINKS = [
|
|||||||
{ to: '/maps', label: 'Maps', icon: '🗺️' },
|
{ to: '/maps', label: 'Maps', icon: '🗺️' },
|
||||||
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
||||||
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
||||||
|
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' },
|
||||||
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
||||||
{ to: '/play', label: 'Player View', icon: '📺' },
|
{ to: '/play', label: 'Player View', icon: '📺' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Campaign, Homebrew, HomebrewKind } from '@/lib/schemas';
|
||||||
|
import { homebrewSchema } from '@/lib/schemas';
|
||||||
|
import { homebrewRepo } from '@/lib/db/repositories';
|
||||||
|
import { downloadJson, pickTextFile, safeFilename } from '@/lib/io/file';
|
||||||
|
import { useHomebrew } from './hooks';
|
||||||
|
import { HOMEBREW_FIELDS, HOMEBREW_KIND_LABEL } from './homebrew';
|
||||||
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||||
|
|
||||||
|
const KINDS: HomebrewKind[] = ['monster', 'spell', 'item', 'feat', 'condition'];
|
||||||
|
const PACK_FORMAT = 'ttrpg-manager:homebrew-pack';
|
||||||
|
|
||||||
|
export function HomebrewPage() {
|
||||||
|
return <RequireCampaign>{(c) => <HomebrewView campaign={c} />}</RequireCampaign>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomebrewView({ campaign }: { campaign: Campaign }) {
|
||||||
|
const entries = useHomebrew(campaign.id);
|
||||||
|
const [kind, setKind] = useState<HomebrewKind>('monster');
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const exportPack = () => {
|
||||||
|
downloadJson(`${safeFilename(campaign.name)}.homebrew.json`, { format: PACK_FORMAT, version: 1, entries });
|
||||||
|
setMsg(`Exported ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPack = async () => {
|
||||||
|
setMsg(null);
|
||||||
|
const text = await pickTextFile();
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(text) as { entries?: unknown };
|
||||||
|
const list = Array.isArray(raw.entries) ? raw.entries : Array.isArray(raw) ? (raw as unknown[]) : [];
|
||||||
|
const valid = list.map((e) => homebrewSchema.safeParse(e)).filter((r) => r.success).map((r) => r.data);
|
||||||
|
if (valid.length === 0) { setMsg('No valid homebrew entries in that file.'); return; }
|
||||||
|
const n = await homebrewRepo.importMany(campaign.id, valid as Homebrew[]);
|
||||||
|
setMsg(`Imported ${n} entr${n === 1 ? 'y' : 'ies'}.`);
|
||||||
|
} catch {
|
||||||
|
setMsg('That file is not a valid homebrew pack.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const byKind = (k: HomebrewKind) => entries.filter((e) => e.kind === k).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader
|
||||||
|
title="Homebrew"
|
||||||
|
subtitle={`${campaign.name} · custom content shows in the Compendium with an HB badge`}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={importPack}>Import pack</Button>
|
||||||
|
<Button variant="ghost" disabled={entries.length === 0} onClick={exportPack}>Export pack</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Select className="w-auto" value={kind} onChange={(e) => setKind(e.target.value as HomebrewKind)} aria-label="Homebrew kind">
|
||||||
|
{KINDS.map((k) => <option key={k} value={k}>{HOMEBREW_KIND_LABEL[k]}</option>)}
|
||||||
|
</Select>
|
||||||
|
<Button variant="primary" onClick={() => homebrewRepo.create(campaign.id, campaign.system, kind, `New ${HOMEBREW_KIND_LABEL[kind]}`)}>
|
||||||
|
+ New {HOMEBREW_KIND_LABEL[kind].toLowerCase()}
|
||||||
|
</Button>
|
||||||
|
{msg && <span className="text-sm text-success" aria-live="polite">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<EmptyState title="No homebrew yet" hint="Create custom monsters, spells, items, feats, and conditions — or import a pack." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{KINDS.map((k) => {
|
||||||
|
const list = byKind(k);
|
||||||
|
if (list.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section key={k}>
|
||||||
|
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{HOMEBREW_KIND_LABEL[k]}s</h2>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomebrewCard({ hb }: { hb: Homebrew }) {
|
||||||
|
const [h, setH] = useState(hb);
|
||||||
|
// Save the full snapshot (not partial patches) so editing several fields
|
||||||
|
// quickly doesn't drop earlier edits under the debounce.
|
||||||
|
const save = useDebouncedCallback((next: Homebrew) => void homebrewRepo.update(next.id, { name: next.name, fields: next.fields, description: next.description }), 350);
|
||||||
|
const update = (patch: Partial<Homebrew>) => setH((p) => { const next = { ...p, ...patch }; save(next); return next; });
|
||||||
|
const setField = (key: string, value: string | number) => update({ fields: { ...h.fields, [key]: value } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
|
||||||
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}>✕</Button>
|
||||||
|
</div>
|
||||||
|
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
{HOMEBREW_FIELDS[hb.kind].map((f) => (
|
||||||
|
<label key={f.key} className="text-xs text-muted">
|
||||||
|
{f.label}
|
||||||
|
<Input
|
||||||
|
type={f.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={String(h.fields[f.key] ?? '')}
|
||||||
|
onChange={(e) => setField(f.key, f.type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
|
||||||
|
aria-label={f.label}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={h.description}
|
||||||
|
onChange={(e) => update({ description: e.target.value })}
|
||||||
|
placeholder="Description, abilities, rules text…"
|
||||||
|
className="mt-2 min-h-20 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,7 +99,16 @@ function NoteEditor({
|
|||||||
const [body, setBody] = useState(note.body);
|
const [body, setBody] = useState(note.body);
|
||||||
const [tags, setTags] = useState(note.tags.join(', '));
|
const [tags, setTags] = useState(note.tags.join(', '));
|
||||||
|
|
||||||
const save = useDebouncedCallback((patch: Partial<Note>) => void notesRepo.update(note.id, patch), 350);
|
// Save the full snapshot so editing title + body quickly doesn't drop an edit.
|
||||||
|
const save = useDebouncedCallback((next: { title: string; body: string; tags: string }) => {
|
||||||
|
void notesRepo.update(note.id, {
|
||||||
|
title: next.title,
|
||||||
|
body: next.body,
|
||||||
|
tags: next.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||||
|
});
|
||||||
|
}, 350);
|
||||||
|
const persist = (over: Partial<{ title: string; body: string; tags: string }>) =>
|
||||||
|
save({ title, body, tags, ...over });
|
||||||
|
|
||||||
const backlinks = allNotes.filter(
|
const backlinks = allNotes.filter(
|
||||||
(n) => n.id !== note.id && extractWikiLinks(n.body).some((t) => t.toLowerCase() === note.title.toLowerCase()),
|
(n) => n.id !== note.id && extractWikiLinks(n.body).some((t) => t.toLowerCase() === note.title.toLowerCase()),
|
||||||
@@ -113,7 +122,7 @@ function NoteEditor({
|
|||||||
className="font-display text-lg font-semibold"
|
className="font-display text-lg font-semibold"
|
||||||
value={title}
|
value={title}
|
||||||
aria-label="Note title"
|
aria-label="Note title"
|
||||||
onChange={(e) => { setTitle(e.target.value); save({ title: e.target.value }); }}
|
onChange={(e) => { setTitle(e.target.value); persist({ title: e.target.value }); }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -128,17 +137,14 @@ function NoteEditor({
|
|||||||
Tags (comma-separated)
|
Tags (comma-separated)
|
||||||
<Input
|
<Input
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => {
|
onChange={(e) => { setTags(e.target.value); persist({ tags: e.target.value }); }}
|
||||||
setTags(e.target.value);
|
|
||||||
save({ tags: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) });
|
|
||||||
}}
|
|
||||||
placeholder="lore, barovia, npc"
|
placeholder="lore, barovia, npc"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => { setBody(e.target.value); save({ body: e.target.value }); }}
|
onChange={(e) => { setBody(e.target.value); persist({ body: e.target.value }); }}
|
||||||
placeholder="Write here… link other notes with [[Note Title]]."
|
placeholder="Write here… link other notes with [[Note Title]]."
|
||||||
className="min-h-48 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
className="min-h-48 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ function Npcs({ campaign }: { campaign: Campaign }) {
|
|||||||
|
|
||||||
function NpcCard({ npc }: { npc: Npc }) {
|
function NpcCard({ npc }: { npc: Npc }) {
|
||||||
const [n, setN] = useState(npc);
|
const [n, setN] = useState(npc);
|
||||||
const save = useDebouncedCallback((patch: Partial<Npc>) => void npcsRepo.update(npc.id, patch), 350);
|
const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, {
|
||||||
const update = (patch: Partial<Npc>) => { setN((p) => ({ ...p, ...patch })); save(patch); };
|
name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
|
||||||
|
}), 350);
|
||||||
|
const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-line bg-panel p-4">
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ function Quests({ campaign }: { campaign: Campaign }) {
|
|||||||
function QuestCard({ quest }: { quest: Quest }) {
|
function QuestCard({ quest }: { quest: Quest }) {
|
||||||
const [q, setQ] = useState(quest);
|
const [q, setQ] = useState(quest);
|
||||||
const [newObj, setNewObj] = useState('');
|
const [newObj, setNewObj] = useState('');
|
||||||
const save = useDebouncedCallback((patch: Partial<Quest>) => void questsRepo.update(quest.id, patch), 350);
|
const save = useDebouncedCallback((next: Quest) => void questsRepo.update(next.id, {
|
||||||
const update = (patch: Partial<Quest>) => { setQ((p) => ({ ...p, ...patch })); save(patch); };
|
title: next.title, status: next.status, description: next.description, reward: next.reward, objectives: next.objectives,
|
||||||
|
}), 350);
|
||||||
|
const update = (patch: Partial<Quest>) => setQ((p) => { const next = { ...p, ...patch }; save(next); return next; });
|
||||||
|
|
||||||
const setObjective = (id: string, patch: Partial<Objective>) =>
|
const setObjective = (id: string, patch: Partial<Objective>) =>
|
||||||
update({ objectives: q.objectives.map((o) => (o.id === id ? { ...o, ...patch } : o)) });
|
update({ objectives: q.objectives.map((o) => (o.id === id ? { ...o, ...patch } : o)) });
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Homebrew, HomebrewKind } from '@/lib/schemas';
|
||||||
|
import { abilityModifier } from '@/lib/rules';
|
||||||
|
import type { Entry } from '@/features/compendium/registry';
|
||||||
|
|
||||||
|
/** Editable scalar fields offered per homebrew kind. */
|
||||||
|
export interface FieldDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'number' | 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOMEBREW_FIELDS: Record<HomebrewKind, FieldDef[]> = {
|
||||||
|
monster: [
|
||||||
|
{ key: 'AC', label: 'Armor Class', type: 'number' },
|
||||||
|
{ key: 'HP', label: 'Hit Points', type: 'number' },
|
||||||
|
{ key: 'CR', label: 'Challenge Rating', type: 'number' },
|
||||||
|
{ key: 'Dexterity', label: 'Dexterity', type: 'number' },
|
||||||
|
],
|
||||||
|
spell: [
|
||||||
|
{ key: 'Level', label: 'Level', type: 'number' },
|
||||||
|
{ key: 'School', label: 'School', type: 'text' },
|
||||||
|
],
|
||||||
|
item: [
|
||||||
|
{ key: 'Type', label: 'Type', type: 'text' },
|
||||||
|
{ key: 'Rarity', label: 'Rarity', type: 'text' },
|
||||||
|
],
|
||||||
|
feat: [],
|
||||||
|
condition: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HOMEBREW_KIND_LABEL: Record<HomebrewKind, string> = {
|
||||||
|
monster: 'Monster',
|
||||||
|
spell: 'Spell',
|
||||||
|
item: 'Item',
|
||||||
|
feat: 'Feat',
|
||||||
|
condition: 'Condition',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Compendium category id -> homebrew kind it should merge in. */
|
||||||
|
export const CATEGORY_HOMEBREW_KIND: Record<string, HomebrewKind> = {
|
||||||
|
'5e-monsters': 'monster', '5e-spells': 'spell', '5e-items': 'item', '5e-feats': 'feat', '5e-conditions': 'condition',
|
||||||
|
'pf2e-creatures': 'monster', 'pf2e-spells': 'spell', 'pf2e-equipment': 'item', 'pf2e-feats': 'feat', 'pf2e-conditions': 'condition',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Convert a homebrew record into a compendium Entry (fields spread to top level). */
|
||||||
|
export function homebrewToEntry(hb: Homebrew): Entry {
|
||||||
|
return {
|
||||||
|
...hb.fields,
|
||||||
|
name: hb.name,
|
||||||
|
slug: `hb-${hb.id}`,
|
||||||
|
description: hb.description,
|
||||||
|
__homebrew: true,
|
||||||
|
__kind: hb.kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHomebrew(entry: Entry): boolean {
|
||||||
|
return entry.__homebrew === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combat stats for a homebrew monster entry. */
|
||||||
|
export function homebrewCombatant(entry: Entry): { name: string; ac: number; hp: number; initBonus: number; cr?: number } {
|
||||||
|
const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||||
|
const cr = entry.CR;
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
ac: num(entry.AC, 10),
|
||||||
|
hp: num(entry.HP, 1),
|
||||||
|
initBonus: abilityModifier(num(entry.Dexterity, 10)),
|
||||||
|
...(typeof cr === 'number' ? { cr } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { notesRepo, npcsRepo, questsRepo, calendarRepo, mapsRepo } from '@/lib/db/repositories';
|
import { notesRepo, npcsRepo, questsRepo, calendarRepo, mapsRepo, homebrewRepo } from '@/lib/db/repositories';
|
||||||
import type { Note, Npc, Quest, Calendar, BattleMap } from '@/lib/schemas';
|
import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas';
|
||||||
|
|
||||||
export function useNotes(campaignId: string): Note[] {
|
export function useNotes(campaignId: string): Note[] {
|
||||||
return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []);
|
return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []);
|
||||||
@@ -17,3 +17,6 @@ export function useCalendar(campaignId: string): Calendar | undefined {
|
|||||||
export function useMaps(campaignId: string): BattleMap[] {
|
export function useMaps(campaignId: string): BattleMap[] {
|
||||||
return useLiveQuery(() => mapsRepo.listByCampaign(campaignId), [campaignId], []);
|
return useLiveQuery(() => mapsRepo.listByCampaign(campaignId), [campaignId], []);
|
||||||
}
|
}
|
||||||
|
export function useHomebrew(campaignId: string): Homebrew[] {
|
||||||
|
return useLiveQuery(() => homebrewRepo.listByCampaign(campaignId), [campaignId], []);
|
||||||
|
}
|
||||||
|
|||||||
+5
-1
@@ -3,7 +3,7 @@ import type { Campaign } from '@/lib/schemas/campaign';
|
|||||||
import type { Character } from '@/lib/schemas/character';
|
import type { Character } from '@/lib/schemas/character';
|
||||||
import type { Encounter } from '@/lib/schemas/encounter';
|
import type { Encounter } from '@/lib/schemas/encounter';
|
||||||
import type { DiceRoll } from '@/lib/schemas/dice';
|
import type { DiceRoll } from '@/lib/schemas/dice';
|
||||||
import type { Note, Npc, Quest, Calendar, BattleMap } from '@/lib/schemas/world';
|
import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas/world';
|
||||||
import { characterDefaults } from '@/lib/schemas/character';
|
import { characterDefaults } from '@/lib/schemas/character';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +24,7 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
quests!: EntityTable<Quest, 'id'>;
|
quests!: EntityTable<Quest, 'id'>;
|
||||||
calendars!: EntityTable<Calendar, 'campaignId'>;
|
calendars!: EntityTable<Calendar, 'campaignId'>;
|
||||||
maps!: EntityTable<BattleMap, 'id'>;
|
maps!: EntityTable<BattleMap, 'id'>;
|
||||||
|
homebrew!: EntityTable<Homebrew, 'id'>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('ttrpg-manager');
|
super('ttrpg-manager');
|
||||||
@@ -66,6 +67,9 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
|
|
||||||
// v5 — Phase 7 maps/VTT.
|
// v5 — Phase 7 maps/VTT.
|
||||||
this.version(5).stores({ maps: 'id, campaignId' });
|
this.version(5).stores({ maps: 'id, campaignId' });
|
||||||
|
|
||||||
|
// v6 — Phase 9 homebrew content.
|
||||||
|
this.version(6).stores({ homebrew: 'id, campaignId, [campaignId+kind]' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo, notesRepo, npcsRepo, questsRepo, calendarRepo } from './repositories';
|
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo, notesRepo, npcsRepo, questsRepo, calendarRepo, homebrewRepo } from './repositories';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await db.delete();
|
await db.delete();
|
||||||
@@ -21,6 +21,7 @@ describe('campaign cascade delete', () => {
|
|||||||
await npcsRepo.create(a.id, 'Strahd');
|
await npcsRepo.create(a.id, 'Strahd');
|
||||||
await questsRepo.create(a.id, 'Escape the mists');
|
await questsRepo.create(a.id, 'Escape the mists');
|
||||||
await calendarRepo.save({ campaignId: a.id, currentDay: 3, events: [] });
|
await calendarRepo.save({ campaignId: a.id, currentDay: 3, events: [] });
|
||||||
|
await homebrewRepo.create(a.id, '5e', 'monster', 'Strahd Zombie');
|
||||||
|
|
||||||
// unrelated campaign's data must survive
|
// unrelated campaign's data must survive
|
||||||
await charactersRepo.create(b.id, {
|
await charactersRepo.create(b.id, {
|
||||||
@@ -36,6 +37,7 @@ describe('campaign cascade delete', () => {
|
|||||||
expect(await notesRepo.listByCampaign(a.id)).toHaveLength(0);
|
expect(await notesRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||||
expect(await npcsRepo.listByCampaign(a.id)).toHaveLength(0);
|
expect(await npcsRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||||
expect(await questsRepo.listByCampaign(a.id)).toHaveLength(0);
|
expect(await questsRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||||
|
expect(await homebrewRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||||
expect(await db.calendars.get(a.id)).toBeUndefined();
|
expect(await db.calendars.get(a.id)).toBeUndefined();
|
||||||
|
|
||||||
expect(await charactersRepo.listByCampaign(b.id)).toHaveLength(1);
|
expect(await charactersRepo.listByCampaign(b.id)).toHaveLength(1);
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
questSchema,
|
questSchema,
|
||||||
calendarSchema,
|
calendarSchema,
|
||||||
battleMapSchema,
|
battleMapSchema,
|
||||||
|
homebrewSchema,
|
||||||
|
type HomebrewKind,
|
||||||
|
type Homebrew,
|
||||||
type Campaign,
|
type Campaign,
|
||||||
type CampaignDraft,
|
type CampaignDraft,
|
||||||
type Character,
|
type Character,
|
||||||
@@ -59,7 +62,7 @@ export const campaignsRepo = {
|
|||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await db.transaction(
|
await db.transaction(
|
||||||
'rw',
|
'rw',
|
||||||
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps],
|
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew],
|
||||||
async () => {
|
async () => {
|
||||||
await db.characters.where('campaignId').equals(id).delete();
|
await db.characters.where('campaignId').equals(id).delete();
|
||||||
await db.encounters.where('campaignId').equals(id).delete();
|
await db.encounters.where('campaignId').equals(id).delete();
|
||||||
@@ -68,6 +71,7 @@ export const campaignsRepo = {
|
|||||||
await db.npcs.where('campaignId').equals(id).delete();
|
await db.npcs.where('campaignId').equals(id).delete();
|
||||||
await db.quests.where('campaignId').equals(id).delete();
|
await db.quests.where('campaignId').equals(id).delete();
|
||||||
await db.maps.where('campaignId').equals(id).delete();
|
await db.maps.where('campaignId').equals(id).delete();
|
||||||
|
await db.homebrew.where('campaignId').equals(id).delete();
|
||||||
await db.calendars.delete(id);
|
await db.calendars.delete(id);
|
||||||
await db.campaigns.delete(id);
|
await db.campaigns.delete(id);
|
||||||
},
|
},
|
||||||
@@ -302,3 +306,34 @@ export const mapsRepo = {
|
|||||||
await db.maps.delete(id);
|
await db.maps.delete(id);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------- Homebrew ----------------
|
||||||
|
export const homebrewRepo = {
|
||||||
|
listByCampaign(campaignId: string): Promise<Homebrew[]> {
|
||||||
|
return db.homebrew.where('campaignId').equals(campaignId).toArray();
|
||||||
|
},
|
||||||
|
byKind(campaignId: string, kind: HomebrewKind): Promise<Homebrew[]> {
|
||||||
|
return db.homebrew.where('[campaignId+kind]').equals([campaignId, kind]).toArray();
|
||||||
|
},
|
||||||
|
async create(campaignId: string, system: Homebrew['system'], kind: HomebrewKind, name: string): Promise<Homebrew> {
|
||||||
|
const ts = now();
|
||||||
|
const hb = homebrewSchema.parse({ id: newId(), campaignId, system, kind, name, fields: {}, description: '', createdAt: ts, updatedAt: ts });
|
||||||
|
await db.homebrew.add(hb);
|
||||||
|
return hb;
|
||||||
|
},
|
||||||
|
async update(id: string, patch: Partial<Omit<Homebrew, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
||||||
|
await db.homebrew.update(id, { ...patch, updatedAt: now() });
|
||||||
|
},
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await db.homebrew.delete(id);
|
||||||
|
},
|
||||||
|
/** Insert a batch (from a content pack import), reassigning ids + campaign. */
|
||||||
|
async importMany(campaignId: string, entries: Homebrew[]): Promise<number> {
|
||||||
|
const ts = now();
|
||||||
|
const records = entries.map((e) =>
|
||||||
|
homebrewSchema.parse({ ...e, id: newId(), campaignId, createdAt: ts, updatedAt: ts }),
|
||||||
|
);
|
||||||
|
await db.homebrew.bulkAdd(records);
|
||||||
|
return records.length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { int } from './common';
|
import { int, systemIdSchema } from './common';
|
||||||
|
|
||||||
// ---------------- Notes / Wiki ----------------
|
// ---------------- Notes / Wiki ----------------
|
||||||
export const noteSchema = z.object({
|
export const noteSchema = z.object({
|
||||||
@@ -67,6 +67,24 @@ export const calendarSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Calendar = z.infer<typeof calendarSchema>;
|
export type Calendar = z.infer<typeof calendarSchema>;
|
||||||
|
|
||||||
|
// ---------------- Homebrew content ----------------
|
||||||
|
export const homebrewKindSchema = z.enum(['monster', 'spell', 'item', 'feat', 'condition']);
|
||||||
|
export type HomebrewKind = z.infer<typeof homebrewKindSchema>;
|
||||||
|
|
||||||
|
export const homebrewSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
campaignId: z.string(),
|
||||||
|
system: systemIdSchema,
|
||||||
|
kind: homebrewKindSchema,
|
||||||
|
name: z.string().min(1).max(160),
|
||||||
|
/** category-specific scalar fields (AC, HP, CR, Level, School, Rarity, …) */
|
||||||
|
fields: z.record(z.string(), z.union([z.string(), z.number()])).default({}),
|
||||||
|
description: z.string().max(20000).default(''),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
export type Homebrew = z.infer<typeof homebrewSchema>;
|
||||||
|
|
||||||
// ---------------- Battle maps (VTT) ----------------
|
// ---------------- Battle maps (VTT) ----------------
|
||||||
export const mapTokenSchema = z.object({
|
export const mapTokenSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { QuestsPage } from '@/features/world/QuestsPage';
|
|||||||
import { CalendarPage } from '@/features/world/CalendarPage';
|
import { CalendarPage } from '@/features/world/CalendarPage';
|
||||||
import { MapsPage } from '@/features/world/MapsPage';
|
import { MapsPage } from '@/features/world/MapsPage';
|
||||||
import { PlayerViewPage } from '@/features/player/PlayerViewPage';
|
import { PlayerViewPage } from '@/features/player/PlayerViewPage';
|
||||||
|
import { HomebrewPage } from '@/features/world/HomebrewPage';
|
||||||
|
|
||||||
const rootRoute = createRootRoute({ component: RootLayout });
|
const rootRoute = createRootRoute({ component: RootLayout });
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quest
|
|||||||
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
|
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
|
||||||
const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
|
const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
|
||||||
const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage });
|
const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage });
|
||||||
|
const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage });
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
@@ -48,6 +50,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
calendarRoute,
|
calendarRoute,
|
||||||
mapsRoute,
|
mapsRoute,
|
||||||
playRoute,
|
playRoute,
|
||||||
|
homebrewRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||||
|
|||||||
@@ -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/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/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.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/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/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/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/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/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"}
|
||||||
Reference in New Issue
Block a user