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:
2026-06-08 01:44:47 +02:00
parent 9647e6b3d6
commit ed3d967526
18 changed files with 866 additions and 9 deletions
+13
View File
@@ -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',
});
}
}
+9 -1
View File
@@ -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);
});
+99 -6
View File
@@ -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));
},
};
+1
View File
@@ -3,3 +3,4 @@ export * from './campaign';
export * from './character';
export * from './encounter';
export * from './dice';
export * from './world';
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
import { int } from './common';
// ---------------- Notes / Wiki ----------------
export const noteSchema = z.object({
id: z.string(),
campaignId: z.string(),
title: z.string().min(1).max(200),
body: z.string().max(100000).default(''),
tags: z.array(z.string().min(1)).default([]),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Note = z.infer<typeof noteSchema>;
// ---------------- NPCs ----------------
export const npcSchema = z.object({
id: z.string(),
campaignId: z.string(),
name: z.string().min(1).max(120),
role: z.string().max(120).default(''),
location: z.string().max(120).default(''),
faction: z.string().max(120).default(''),
status: z.enum(['alive', 'dead', 'unknown']).default('alive'),
description: z.string().max(20000).default(''),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Npc = z.infer<typeof npcSchema>;
// ---------------- Quests ----------------
export const objectiveSchema = z.object({
id: z.string(),
text: z.string().min(1).max(500),
done: z.boolean().default(false),
});
export type Objective = z.infer<typeof objectiveSchema>;
export const questSchema = z.object({
id: z.string(),
campaignId: z.string(),
title: z.string().min(1).max(200),
status: z.enum(['active', 'completed', 'failed', 'on-hold']).default('active'),
description: z.string().max(20000).default(''),
reward: z.string().max(500).default(''),
objectives: z.array(objectiveSchema).default([]),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Quest = z.infer<typeof questSchema>;
// ---------------- Calendar (in-world) ----------------
// Stored as a plain integer "day number" — no real Date, so no timezone/DST bugs.
export const calendarEventSchema = z.object({
id: z.string(),
day: int,
title: z.string().min(1).max(200),
});
export type CalendarEvent = z.infer<typeof calendarEventSchema>;
export const calendarSchema = z.object({
/** one calendar per campaign — campaignId is the primary key */
campaignId: z.string(),
/** current in-world day number (arbitrary epoch) */
currentDay: int.default(0),
events: z.array(calendarEventSchema).default([]),
});
export type Calendar = z.infer<typeof calendarSchema>;
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { extractWikiLinks, splitWikiLinks } from './wikilinks';
describe('extractWikiLinks', () => {
it('pulls distinct titles from [[links]]', () => {
expect(extractWikiLinks('See [[Strahd]] and [[Barovia]] and [[Strahd]] again')).toEqual(['Strahd', 'Barovia']);
});
it('trims whitespace and ignores empties', () => {
expect(extractWikiLinks('a [[ Ravenloft ]] b [[]] c')).toEqual(['Ravenloft']);
});
});
describe('splitWikiLinks', () => {
it('splits text into plain and link segments', () => {
const parts = splitWikiLinks('go to [[Town]] now');
expect(parts).toEqual([
{ text: 'go to ' },
{ text: 'Town', link: 'Town' },
{ text: ' now' },
]);
});
});
+23
View File
@@ -0,0 +1,23 @@
/** Extract the distinct titles referenced as [[Wiki Links]] in a body of text. */
export function extractWikiLinks(text: string): string[] {
const set = new Set<string>();
for (const m of text.matchAll(/\[\[([^\]]+)\]\]/g)) {
const title = m[1]?.trim();
if (title) set.add(title);
}
return [...set];
}
/** Split text into plain segments and [[link]] segments for rendering. */
export function splitWikiLinks(text: string): { text: string; link?: string }[] {
const parts: { text: string; link?: string }[] = [];
let last = 0;
for (const m of text.matchAll(/\[\[([^\]]+)\]\]/g)) {
const idx = m.index ?? 0;
if (idx > last) parts.push({ text: text.slice(last, idx) });
parts.push({ text: m[1]!.trim(), link: m[1]!.trim() });
last = idx + m[0].length;
}
if (last < text.length) parts.push({ text: text.slice(last) });
return parts;
}