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
+1
View File
@@ -21,6 +21,7 @@ const LINKS = [
{ to: '/maps', label: 'Maps', icon: '🗺️' },
{ to: '/calendar', label: 'Calendar', icon: '📅' },
{ to: '/compendium', label: 'Compendium', icon: '📚' },
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' },
{ to: '/dice', label: 'Dice', icon: '🎲' },
{ to: '/play', label: 'Player View', icon: '📺' },
] 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 [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(
(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"
value={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
variant="ghost"
@@ -128,17 +137,14 @@ function NoteEditor({
Tags (comma-separated)
<Input
value={tags}
onChange={(e) => {
setTags(e.target.value);
save({ tags: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) });
}}
onChange={(e) => { setTags(e.target.value); persist({ tags: e.target.value }); }}
placeholder="lore, barovia, npc"
/>
</label>
<textarea
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]]."
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 }) {
const [n, setN] = useState(npc);
const save = useDebouncedCallback((patch: Partial<Npc>) => void npcsRepo.update(npc.id, patch), 350);
const update = (patch: Partial<Npc>) => { setN((p) => ({ ...p, ...patch })); save(patch); };
const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, {
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 (
<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 }) {
const [q, setQ] = useState(quest);
const [newObj, setNewObj] = useState('');
const save = useDebouncedCallback((patch: Partial<Quest>) => void questsRepo.update(quest.id, patch), 350);
const update = (patch: Partial<Quest>) => { setQ((p) => ({ ...p, ...patch })); save(patch); };
const save = useDebouncedCallback((next: Quest) => void questsRepo.update(next.id, {
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>) =>
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 { notesRepo, npcsRepo, questsRepo, calendarRepo, mapsRepo } from '@/lib/db/repositories';
import type { Note, Npc, Quest, Calendar, BattleMap } from '@/lib/schemas';
import { notesRepo, npcsRepo, questsRepo, calendarRepo, mapsRepo, homebrewRepo } from '@/lib/db/repositories';
import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas';
export function useNotes(campaignId: string): Note[] {
return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []);
@@ -17,3 +17,6 @@ export function useCalendar(campaignId: string): Calendar | undefined {
export function useMaps(campaignId: string): BattleMap[] {
return useLiveQuery(() => mapsRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useHomebrew(campaignId: string): Homebrew[] {
return useLiveQuery(() => homebrewRepo.listByCampaign(campaignId), [campaignId], []);
}