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:
+5
-1
@@ -3,7 +3,7 @@ import type { Campaign } from '@/lib/schemas/campaign';
|
||||
import type { Character } from '@/lib/schemas/character';
|
||||
import type { Encounter } from '@/lib/schemas/encounter';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -24,6 +24,7 @@ export class TtrpgDatabase extends Dexie {
|
||||
quests!: EntityTable<Quest, 'id'>;
|
||||
calendars!: EntityTable<Calendar, 'campaignId'>;
|
||||
maps!: EntityTable<BattleMap, 'id'>;
|
||||
homebrew!: EntityTable<Homebrew, 'id'>;
|
||||
|
||||
constructor() {
|
||||
super('ttrpg-manager');
|
||||
@@ -66,6 +67,9 @@ export class TtrpgDatabase extends Dexie {
|
||||
|
||||
// v5 — Phase 7 maps/VTT.
|
||||
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 { 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 () => {
|
||||
await db.delete();
|
||||
@@ -21,6 +21,7 @@ describe('campaign cascade delete', () => {
|
||||
await npcsRepo.create(a.id, 'Strahd');
|
||||
await questsRepo.create(a.id, 'Escape the mists');
|
||||
await calendarRepo.save({ campaignId: a.id, currentDay: 3, events: [] });
|
||||
await homebrewRepo.create(a.id, '5e', 'monster', 'Strahd Zombie');
|
||||
|
||||
// unrelated campaign's data must survive
|
||||
await charactersRepo.create(b.id, {
|
||||
@@ -36,6 +37,7 @@ describe('campaign cascade delete', () => {
|
||||
expect(await notesRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await npcsRepo.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 charactersRepo.listByCampaign(b.id)).toHaveLength(1);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { int } from './common';
|
||||
import { int, systemIdSchema } from './common';
|
||||
|
||||
// ---------------- Notes / Wiki ----------------
|
||||
export const noteSchema = z.object({
|
||||
@@ -67,6 +67,24 @@ export const calendarSchema = z.object({
|
||||
});
|
||||
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) ----------------
|
||||
export const mapTokenSchema = z.object({
|
||||
id: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user