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
+36 -1
View File
@@ -10,6 +10,9 @@ import {
questSchema,
calendarSchema,
battleMapSchema,
homebrewSchema,
type HomebrewKind,
type Homebrew,
type Campaign,
type CampaignDraft,
type Character,
@@ -59,7 +62,7 @@ export const campaignsRepo = {
async remove(id: string): Promise<void> {
await db.transaction(
'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 () => {
await db.characters.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.quests.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.campaigns.delete(id);
},
@@ -302,3 +306,34 @@ export const mapsRepo = {
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;
},
};