Phase 5: campaign management & worldbuilding
- New entities (note/npc/quest/calendar): schemas, Dexie v4 tables, repos, hooks; campaign cascade-delete now covers them all - Dashboard hub: campaign stats, navigation cards, party overview, recent rolls; opening a campaign lands here - Notes/Wiki: tags, [[wiki-links]] with click-to-open/create, backlinks panel, full-text search, autosave - NPC manager (role/location/faction/status) and Quest tracker (status + objectives checklist + reward) - In-world Calendar: integer day counter + events (no real Date -> no timezone bug) - Fix: calendarRepo.get is read-only (creating-on-read crashed inside liveQuery) 8 new unit tests (wikilinks + extended cascade), worldbuilding e2e. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +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 } from '@/lib/schemas/world';
|
||||
import { characterDefaults } from '@/lib/schemas/character';
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,10 @@ export class TtrpgDatabase extends Dexie {
|
||||
characters!: EntityTable<Character, 'id'>;
|
||||
encounters!: EntityTable<Encounter, 'id'>;
|
||||
diceRolls!: EntityTable<DiceRoll, 'id'>;
|
||||
notes!: EntityTable<Note, 'id'>;
|
||||
npcs!: EntityTable<Npc, 'id'>;
|
||||
quests!: EntityTable<Quest, 'id'>;
|
||||
calendars!: EntityTable<Calendar, 'campaignId'>;
|
||||
|
||||
constructor() {
|
||||
super('ttrpg-manager');
|
||||
@@ -49,6 +54,14 @@ export class TtrpgDatabase extends Dexie {
|
||||
if (e.log === undefined) e.log = [];
|
||||
});
|
||||
});
|
||||
|
||||
// v4 — Phase 5 worldbuilding: notes, NPCs, quests, calendar.
|
||||
this.version(4).stores({
|
||||
notes: 'id, campaignId',
|
||||
npcs: 'id, campaignId',
|
||||
quests: 'id, campaignId, status',
|
||||
calendars: 'campaignId',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { db } from './db';
|
||||
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo } from './repositories';
|
||||
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo, notesRepo, npcsRepo, questsRepo, calendarRepo } from './repositories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete();
|
||||
@@ -17,6 +17,10 @@ describe('campaign cascade delete', () => {
|
||||
});
|
||||
await encountersRepo.create(a.id, 'Death House');
|
||||
await diceRepo.add({ campaignId: a.id, label: 'attack', expression: '1d20', total: 14, breakdown: '' });
|
||||
await notesRepo.create(a.id, 'Barovia');
|
||||
await npcsRepo.create(a.id, 'Strahd');
|
||||
await questsRepo.create(a.id, 'Escape the mists');
|
||||
await calendarRepo.save({ campaignId: a.id, currentDay: 3, events: [] });
|
||||
|
||||
// unrelated campaign's data must survive
|
||||
await charactersRepo.create(b.id, {
|
||||
@@ -29,6 +33,10 @@ describe('campaign cascade delete', () => {
|
||||
expect(await charactersRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await encountersRepo.listByCampaign(a.id)).toHaveLength(0);
|
||||
expect(await diceRepo.recent(a.id)).toHaveLength(0);
|
||||
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 db.calendars.get(a.id)).toBeUndefined();
|
||||
|
||||
expect(await charactersRepo.listByCampaign(b.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -5,11 +5,19 @@ import {
|
||||
characterSchema,
|
||||
encounterSchema,
|
||||
diceRollSchema,
|
||||
noteSchema,
|
||||
npcSchema,
|
||||
questSchema,
|
||||
calendarSchema,
|
||||
type Campaign,
|
||||
type CampaignDraft,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type DiceRoll,
|
||||
type Note,
|
||||
type Npc,
|
||||
type Quest,
|
||||
type Calendar,
|
||||
} from '@/lib/schemas';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
|
||||
@@ -47,12 +55,20 @@ export const campaignsRepo = {
|
||||
|
||||
/** 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, 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.campaigns.delete(id);
|
||||
});
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars],
|
||||
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.calendars.delete(id);
|
||||
await db.campaigns.delete(id);
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,3 +187,80 @@ export const diceRepo = {
|
||||
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));
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user