) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; });
return (
diff --git a/src/features/world/QuestsPage.tsx b/src/features/world/QuestsPage.tsx
index 81c8640..dec9499 100644
--- a/src/features/world/QuestsPage.tsx
+++ b/src/features/world/QuestsPage.tsx
@@ -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) => void questsRepo.update(quest.id, patch), 350);
- const update = (patch: Partial) => { 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) => setQ((p) => { const next = { ...p, ...patch }; save(next); return next; });
const setObjective = (id: string, patch: Partial) =>
update({ objectives: q.objectives.map((o) => (o.id === id ? { ...o, ...patch } : o)) });
diff --git a/src/features/world/homebrew.ts b/src/features/world/homebrew.ts
new file mode 100644
index 0000000..19a892e
--- /dev/null
+++ b/src/features/world/homebrew.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ '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 } : {}),
+ };
+}
diff --git a/src/features/world/hooks.ts b/src/features/world/hooks.ts
index adacee0..42c82ab 100644
--- a/src/features/world/hooks.ts
+++ b/src/features/world/hooks.ts
@@ -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], []);
+}
diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts
index 1cc2bb1..7e7c3db 100644
--- a/src/lib/db/db.ts
+++ b/src/lib/db/db.ts
@@ -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;
calendars!: EntityTable;
maps!: EntityTable;
+ homebrew!: EntityTable;
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]' });
}
}
diff --git a/src/lib/db/repositories.test.ts b/src/lib/db/repositories.test.ts
index c71f29e..90ba1e4 100644
--- a/src/lib/db/repositories.test.ts
+++ b/src/lib/db/repositories.test.ts
@@ -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);
diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts
index f099fbb..c129fc0 100644
--- a/src/lib/db/repositories.ts
+++ b/src/lib/db/repositories.ts
@@ -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 {
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 {
+ return db.homebrew.where('campaignId').equals(campaignId).toArray();
+ },
+ byKind(campaignId: string, kind: HomebrewKind): Promise {
+ return db.homebrew.where('[campaignId+kind]').equals([campaignId, kind]).toArray();
+ },
+ async create(campaignId: string, system: Homebrew['system'], kind: HomebrewKind, name: string): Promise {
+ 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>): Promise {
+ await db.homebrew.update(id, { ...patch, updatedAt: now() });
+ },
+ async remove(id: string): Promise {
+ await db.homebrew.delete(id);
+ },
+ /** Insert a batch (from a content pack import), reassigning ids + campaign. */
+ async importMany(campaignId: string, entries: Homebrew[]): Promise {
+ 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;
+ },
+};
diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts
index 907049d..f7acaa3 100644
--- a/src/lib/schemas/world.ts
+++ b/src/lib/schemas/world.ts
@@ -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;
+// ---------------- Homebrew content ----------------
+export const homebrewKindSchema = z.enum(['monster', 'spell', 'item', 'feat', 'condition']);
+export type HomebrewKind = z.infer;
+
+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;
+
// ---------------- Battle maps (VTT) ----------------
export const mapTokenSchema = z.object({
id: z.string(),
diff --git a/src/router.tsx b/src/router.tsx
index 0622f17..065a0ba 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -13,6 +13,7 @@ import { QuestsPage } from '@/features/world/QuestsPage';
import { CalendarPage } from '@/features/world/CalendarPage';
import { MapsPage } from '@/features/world/MapsPage';
import { PlayerViewPage } from '@/features/player/PlayerViewPage';
+import { HomebrewPage } from '@/features/world/HomebrewPage';
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 mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage });
+const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage });
const routeTree = rootRoute.addChildren([
indexRoute,
@@ -48,6 +50,7 @@ const routeTree = rootRoute.addChildren([
calendarRoute,
mapsRoute,
playRoute,
+ homebrewRoute,
]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
index 3a736b3..d92dbd4 100644
--- a/tsconfig.app.tsbuildinfo
+++ b/tsconfig.app.tsbuildinfo
@@ -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"}
\ No newline at end of file
+{"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"}
\ No newline at end of file