Files
ttrpg_manager/src/lib/db/repositories.ts
T
NilsBriggen 7100ea8dd4 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>
2026-06-08 07:54:53 +02:00

340 lines
11 KiB
TypeScript

import { db } from './db';
import { newId } from '@/lib/ids';
import {
campaignSchema,
characterSchema,
encounterSchema,
diceRollSchema,
noteSchema,
npcSchema,
questSchema,
calendarSchema,
battleMapSchema,
homebrewSchema,
type HomebrewKind,
type Homebrew,
type Campaign,
type CampaignDraft,
type Character,
type Encounter,
type DiceRoll,
type Note,
type Npc,
type Quest,
type Calendar,
type BattleMap,
} from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
function now(): string {
return new Date().toISOString();
}
// ---------------- Campaigns ----------------
export const campaignsRepo = {
async list(): Promise<Campaign[]> {
const all = await db.campaigns.toArray();
return all.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
},
get(id: string): Promise<Campaign | undefined> {
return db.campaigns.get(id);
},
async create(draft: CampaignDraft): Promise<Campaign> {
const ts = now();
const campaign = campaignSchema.parse({
...draft,
id: newId(),
description: draft.description ?? '',
createdAt: ts,
updatedAt: ts,
});
await db.campaigns.add(campaign);
return campaign;
},
async update(id: string, patch: Partial<Omit<Campaign, 'id' | 'createdAt'>>): Promise<void> {
await db.campaigns.update(id, { ...patch, updatedAt: now() });
},
/** Delete a campaign and ALL its children atomically (real cascade). */
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.homebrew],
async () => {
await db.characters.where('campaignId').equals(id).delete();
await db.encounters.where('campaignId').equals(id).delete();
await db.diceRolls.where('campaignId').equals(id).delete();
await db.notes.where('campaignId').equals(id).delete();
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);
},
);
},
};
// ---------------- Characters ----------------
export const charactersRepo = {
listByCampaign(campaignId: string): Promise<Character[]> {
return db.characters.where('campaignId').equals(campaignId).toArray();
},
get(id: string): Promise<Character | undefined> {
return db.characters.get(id);
},
async create(
campaignId: string,
draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & {
system: Character['system'];
},
): Promise<Character> {
const ts = now();
const sys = getSystem(draft.system);
const character = characterSchema.parse({
id: newId(),
campaignId,
system: draft.system,
kind: draft.kind,
name: draft.name,
ancestry: draft.ancestry,
className: draft.className,
level: draft.level,
abilities: sys.defaultAbilityScores(),
hp: { current: 1, max: 1, temp: 0 },
speed: 30,
armorBonus: 0,
skillRanks: {},
saveRanks: {},
perceptionRank: 'trained',
notes: '',
createdAt: ts,
updatedAt: ts,
});
await db.characters.add(character);
return character;
},
async update(id: string, patch: Partial<Omit<Character, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.characters.update(id, { ...patch, updatedAt: now() });
},
/** Insert a fully-formed character (e.g. from an import). Validates on write. */
async insert(character: Character): Promise<void> {
await db.characters.add(characterSchema.parse(character));
},
async remove(id: string): Promise<void> {
await db.characters.delete(id);
},
};
// ---------------- Encounters ----------------
export const encountersRepo = {
listByCampaign(campaignId: string): Promise<Encounter[]> {
return db.encounters.where('campaignId').equals(campaignId).toArray();
},
get(id: string): Promise<Encounter | undefined> {
return db.encounters.get(id);
},
async create(campaignId: string, name: string): Promise<Encounter> {
const ts = now();
const encounter = encounterSchema.parse({
id: newId(),
campaignId,
name,
status: 'planning',
round: 0,
turnIndex: 0,
combatants: [],
createdAt: ts,
updatedAt: ts,
});
await db.encounters.add(encounter);
return encounter;
},
/** Persist a full encounter (combat state lives as one document). */
async save(encounter: Encounter): Promise<void> {
const validated = encounterSchema.parse({ ...encounter, updatedAt: now() });
await db.encounters.put(validated);
},
/**
* Apply a transition by reading the freshest committed state inside a
* transaction, so two rapid mutations can't overwrite each other (the
* read-modify-write race the old app shipped — bug class C19).
*/
async mutate(id: string, fn: (e: Encounter) => Encounter): Promise<void> {
await db.transaction('rw', db.encounters, async () => {
const current = await db.encounters.get(id);
if (!current) return;
await db.encounters.put(encounterSchema.parse({ ...fn(current), updatedAt: now() }));
});
},
async remove(id: string): Promise<void> {
await db.encounters.delete(id);
},
};
// ---------------- Dice rolls ----------------
export const diceRepo = {
async recent(campaignId: string | undefined, limit = 50): Promise<DiceRoll[]> {
const coll = campaignId
? db.diceRolls.where('campaignId').equals(campaignId)
: db.diceRolls.toCollection();
const all = await coll.toArray();
return all.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit);
},
async add(roll: Omit<DiceRoll, 'id' | 'createdAt'>): Promise<DiceRoll> {
const record = diceRollSchema.parse({ ...roll, id: newId(), createdAt: now() });
await db.diceRolls.add(record);
return record;
},
async clear(campaignId?: string): Promise<void> {
if (campaignId) await db.diceRolls.where('campaignId').equals(campaignId).delete();
else await db.diceRolls.clear();
},
};
// ---------------- Notes ----------------
export const notesRepo = {
listByCampaign(campaignId: string): Promise<Note[]> {
return db.notes.where('campaignId').equals(campaignId).toArray();
},
get(id: string): Promise<Note | undefined> {
return db.notes.get(id);
},
async create(campaignId: string, title: string): Promise<Note> {
const ts = now();
const note = noteSchema.parse({ id: newId(), campaignId, title, body: '', tags: [], createdAt: ts, updatedAt: ts });
await db.notes.add(note);
return note;
},
async update(id: string, patch: Partial<Omit<Note, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.notes.update(id, { ...patch, updatedAt: now() });
},
async remove(id: string): Promise<void> {
await db.notes.delete(id);
},
};
// ---------------- NPCs ----------------
export const npcsRepo = {
listByCampaign(campaignId: string): Promise<Npc[]> {
return db.npcs.where('campaignId').equals(campaignId).toArray();
},
async create(campaignId: string, name: string): Promise<Npc> {
const ts = now();
const npc = npcSchema.parse({
id: newId(), campaignId, name, role: '', location: '', faction: '',
status: 'alive', description: '', createdAt: ts, updatedAt: ts,
});
await db.npcs.add(npc);
return npc;
},
async update(id: string, patch: Partial<Omit<Npc, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.npcs.update(id, { ...patch, updatedAt: now() });
},
async remove(id: string): Promise<void> {
await db.npcs.delete(id);
},
};
// ---------------- Quests ----------------
export const questsRepo = {
listByCampaign(campaignId: string): Promise<Quest[]> {
return db.quests.where('campaignId').equals(campaignId).toArray();
},
async create(campaignId: string, title: string): Promise<Quest> {
const ts = now();
const quest = questSchema.parse({
id: newId(), campaignId, title, status: 'active', description: '', reward: '',
objectives: [], createdAt: ts, updatedAt: ts,
});
await db.quests.add(quest);
return quest;
},
async update(id: string, patch: Partial<Omit<Quest, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.quests.update(id, { ...patch, updatedAt: now() });
},
async remove(id: string): Promise<void> {
await db.quests.delete(id);
},
};
// ---------------- Calendar (one per campaign) ----------------
export const calendarRepo = {
/** Read-only — safe to call inside a Dexie liveQuery. Missing = undefined. */
get(campaignId: string): Promise<Calendar | undefined> {
return db.calendars.get(campaignId);
},
async save(calendar: Calendar): Promise<void> {
await db.calendars.put(calendarSchema.parse(calendar));
},
};
// ---------------- Battle maps ----------------
export const mapsRepo = {
listByCampaign(campaignId: string): Promise<BattleMap[]> {
return db.maps.where('campaignId').equals(campaignId).toArray();
},
async create(campaignId: string, name: string, image: string): Promise<BattleMap> {
const ts = now();
const map = battleMapSchema.parse({
id: newId(), campaignId, name, image, gridSize: 50, showGrid: true,
fogEnabled: false, revealed: [], tokens: [], createdAt: ts, updatedAt: ts,
});
await db.maps.add(map);
return map;
},
async save(map: BattleMap): Promise<void> {
await db.maps.put(battleMapSchema.parse({ ...map, updatedAt: now() }));
},
async remove(id: string): Promise<void> {
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;
},
};