From 7100ea8dd4877e21f240fbb8c1ac8cbc9c7b0413 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 07:54:53 +0200 Subject: [PATCH] Phase 9: homebrew content + packs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- e2e/homebrew.spec.ts | 31 +++++ src/features/compendium/CompendiumPage.tsx | 46 ++++++-- src/features/compendium/details.tsx | 20 ++++ src/features/world/DashboardPage.tsx | 1 + src/features/world/HomebrewPage.tsx | 130 +++++++++++++++++++++ src/features/world/NotesPage.tsx | 20 ++-- src/features/world/NpcsPage.tsx | 6 +- src/features/world/QuestsPage.tsx | 6 +- src/features/world/homebrew.ts | 72 ++++++++++++ src/features/world/hooks.ts | 7 +- src/lib/db/db.ts | 6 +- src/lib/db/repositories.test.ts | 4 +- src/lib/db/repositories.ts | 37 +++++- src/lib/schemas/world.ts | 20 +++- src/router.tsx | 3 + tsconfig.app.tsbuildinfo | 2 +- 16 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 e2e/homebrew.spec.ts create mode 100644 src/features/world/HomebrewPage.tsx create mode 100644 src/features/world/homebrew.ts diff --git a/e2e/homebrew.spec.ts b/e2e/homebrew.spec.ts new file mode 100644 index 0000000..7b77d32 --- /dev/null +++ b/e2e/homebrew.spec.ts @@ -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(); +}); diff --git a/src/features/compendium/CompendiumPage.tsx b/src/features/compendium/CompendiumPage.tsx index 34c7c2d..96e71a6 100644 --- a/src/features/compendium/CompendiumPage.tsx +++ b/src/features/compendium/CompendiumPage.tsx @@ -12,6 +12,9 @@ import type { Character, InventoryItem, SpellEntry } from '@/lib/schemas'; import { useUiStore } from '@/stores/uiStore'; import { useActiveCampaign } from '@/features/campaigns/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 { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; @@ -51,16 +54,25 @@ export function CompendiumPage() { return () => { cancelled = true; }; }, [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 active = Object.entries(filters).filter(([, v]) => v !== ''); - if (active.length === 0) return data; - return data.filter((e) => + if (active.length === 0) return allData; + return allData.filter((e) => active.every(([key, value]) => { const def = category.filters.find((f) => f.key === key); return def ? filterMatches(def, e, value) : true; }), ); - }, [data, filters, category]); + }, [allData, filters, category]); const fuse = useMemo( () => 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', )} > - {entry.name} - {category.meta(entry)} + + {isHomebrew(entry) && HB} + {entry.name} + + {isHomebrew(entry) ? 'Homebrew' : category.meta(entry)} ); @@ -226,7 +241,7 @@ export function CompendiumPage() { {selected ? (
- {category.detail(selected)} + {isHomebrew(selected) ? : category.detail(selected)}
) : (

Select an entry to view details.

@@ -238,16 +253,27 @@ export function CompendiumPage() { } function DetailActions({ category, entry, system }: { category: CategoryDef; entry: Entry; system: SystemId }) { - const hasActions = category.toCombatant || category.linkAs; - if (!hasActions) return null; + // Homebrew entries derive their actions from their kind, not the category. + if (isHomebrew(entry)) { + const kind = entry.__kind; + if (kind === 'monster') return ; + if (kind === 'spell') return ; + if (kind === 'item') return ; + return null; + } + if (!category.toCombatant && !category.linkAs) return null; return ( -
+ {category.toCombatant && } {category.linkAs && } -
+ ); } +function ActionBar({ children }: { children: React.ReactNode }) { + return
{children}
; +} + function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) { const activeEncounterId = useUiStore((s) => s.activeEncounterId); const [msg, setMsg] = useState(null); diff --git a/src/features/compendium/details.tsx b/src/features/compendium/details.tsx index 4a03a88..700e3f6 100644 --- a/src/features/compendium/details.tsx +++ b/src/features/compendium/details.tsx @@ -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 ( +
+
+ {facts.length > 0 && ( +
+ {facts.map(([k, v]) => ( + {k} {String(v)} + ))} +
+ )} + {entry.description ?

{String(entry.description)}

: null} +
+ ); +} + /** Generic renderer for PF2e (AoN) entries: header, traits, key facts, text body. */ export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) { const traits = (entry.trait as string[] | undefined) ?? undefined; diff --git a/src/features/world/DashboardPage.tsx b/src/features/world/DashboardPage.tsx index a91cdec..66d4493 100644 --- a/src/features/world/DashboardPage.tsx +++ b/src/features/world/DashboardPage.tsx @@ -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; diff --git a/src/features/world/HomebrewPage.tsx b/src/features/world/HomebrewPage.tsx new file mode 100644 index 0000000..1829b52 --- /dev/null +++ b/src/features/world/HomebrewPage.tsx @@ -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 {(c) => }; +} + +function HomebrewView({ campaign }: { campaign: Campaign }) { + const entries = useHomebrew(campaign.id); + const [kind, setKind] = useState('monster'); + const [msg, setMsg] = useState(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 ( + + + + + + } + /> + +
+ + + {msg && {msg}} +
+ + {entries.length === 0 ? ( + + ) : ( +
+ {KINDS.map((k) => { + const list = byKind(k); + if (list.length === 0) return null; + return ( +
+

{HOMEBREW_KIND_LABEL[k]}s

+
+ {list.map((hb) => )} +
+
+ ); + })} +
+ )} +
+ ); +} + +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) => setH((p) => { const next = { ...p, ...patch }; save(next); return next; }); + const setField = (key: string, value: string | number) => update({ fields: { ...h.fields, [key]: value } }); + + return ( +
+
+ update({ name: e.target.value })} aria-label="Homebrew name" /> + +
+ {HOMEBREW_FIELDS[hb.kind].length > 0 && ( +
+ {HOMEBREW_FIELDS[hb.kind].map((f) => ( + + ))} +
+ )} +