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:
2026-06-08 07:54:53 +02:00
parent 744e91f703
commit 7100ea8dd4
16 changed files with 383 additions and 28 deletions
+31
View File
@@ -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();
});
+35 -9
View File
@@ -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);
+20
View File
@@ -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;
+1
View File
@@ -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;
+130
View File
@@ -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>
);
}
+13 -7
View File
@@ -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"
/> />
+4 -2
View File
@@ -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">
+4 -2
View File
@@ -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)) });
+72
View File
@@ -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 } : {}),
};
}
+5 -2
View File
@@ -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
View File
@@ -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]' });
} }
} }
+3 -1
View File
@@ -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);
+36 -1
View File
@@ -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;
},
};
+19 -1
View File
@@ -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(),
+3
View File
@@ -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
View File
@@ -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"}