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
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(async () => {
indexedDB.deleteDatabase('ttrpg-manager');
localStorage.clear();
});
await page.reload();
});
test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', async ({ page }) => {
// Create campaign, then open its dashboard
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Barovia');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await expect(page.getByRole('heading', { name: 'Barovia' })).toBeVisible();
// Notes: create two and link them
await page.getByRole('link', { name: 'Notes & Wiki' }).click();
await page.getByRole('button', { name: '+ New note' }).first().click();
await page.getByLabel('Note title').fill('Strahd');
await page.getByPlaceholder(/Write here/).fill('Lord of [[Castle Ravenloft]].');
// The link is "missing" until created; click it to create the target note
await page.getByRole('button', { name: 'Castle Ravenloft' }).click();
await expect(page.getByLabel('Note title')).toHaveValue('Castle Ravenloft');
// Backlink from Strahd should appear
await expect(page.getByText('Linked from')).toBeVisible();
// NPC
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'NPCs' }).click();
await page.getByRole('button', { name: '+ New NPC' }).first().click();
await expect(page.getByLabel('NPC name')).toBeVisible();
// Quest with an objective
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Quests' }).click();
await page.getByRole('button', { name: '+ New quest' }).first().click();
await page.getByPlaceholder('Add objective…').fill('Find the Tome');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await expect(page.locator('input[value="Find the Tome"]')).toBeVisible();
// Calendar advances days
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Calendar' }).click();
await page.getByRole('button', { name: '+1 week' }).click();
await expect(page.getByText('7', { exact: true })).toBeVisible();
});
+1
View File
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button';
const NAV = [ const NAV = [
{ to: '/', label: 'Campaigns', exact: true }, { to: '/', label: 'Campaigns', exact: true },
{ to: '/dashboard', label: 'Dashboard', exact: false },
{ to: '/characters', label: 'Characters', exact: false }, { to: '/characters', label: 'Characters', exact: false },
{ to: '/combat', label: 'Combat', exact: false }, { to: '/combat', label: 'Combat', exact: false },
{ to: '/dice', label: 'Dice', exact: false }, { to: '/dice', label: 'Dice', exact: false },
+1 -1
View File
@@ -59,7 +59,7 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () =>
const open = () => { const open = () => {
setActive(campaign.id); setActive(campaign.id);
void navigate({ to: '/characters' }); void navigate({ to: '/dashboard' });
}; };
return ( return (
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import type { Calendar, Campaign } from '@/lib/schemas';
import { calendarRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { useCalendar } from './hooks';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
export function CalendarPage() {
return <RequireCampaign>{(c) => <CalendarView campaign={c} />}</RequireCampaign>;
}
function CalendarView({ campaign }: { campaign: Campaign }) {
const stored = useCalendar(campaign.id);
const [eventTitle, setEventTitle] = useState('');
// The row is created on first save; until then we work with an in-memory default.
const calendar: Calendar = stored ?? { campaignId: campaign.id, currentDay: 0, events: [] };
const save = (next: Calendar) => void calendarRepo.save(next);
const advance = (delta: number) => save({ ...calendar, currentDay: calendar.currentDay + delta });
const addEvent = () => {
if (eventTitle.trim() === '') return;
save({ ...calendar, events: [...calendar.events, { id: newId(), day: calendar.currentDay, title: eventTitle.trim() }] });
setEventTitle('');
};
const upcoming = [...calendar.events].sort((a, b) => a.day - b.day);
return (
<Page>
<PageHeader title="Calendar" subtitle={`${campaign.name} · in-world timeline`} />
<div className="mb-6 flex items-center gap-3 rounded-lg border border-line bg-panel p-4">
<Button variant="secondary" onClick={() => advance(-1)} aria-label="Previous day">1 day</Button>
<div className="text-center">
<div className="text-xs uppercase tracking-wide text-muted">Day</div>
<div className="font-display text-3xl font-bold text-accent">{calendar.currentDay}</div>
</div>
<Button variant="secondary" onClick={() => advance(1)} aria-label="Next day">+1 day</Button>
<Button variant="ghost" onClick={() => advance(7)}>+1 week</Button>
</div>
<div className="mb-3 flex gap-2">
<Input value={eventTitle} onChange={(e) => setEventTitle(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addEvent()} placeholder={`Add event on day ${calendar.currentDay}`} aria-label="Event title" />
<Button variant="primary" onClick={addEvent}>Add event</Button>
</div>
{upcoming.length === 0 ? (
<p className="text-sm text-muted">No events yet.</p>
) : (
<ul className="space-y-1">
{upcoming.map((ev) => (
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
<span className="flex-1 text-ink">{ev.title}</span>
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}></button>
</li>
))}
</ul>
)}
</Page>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import type { Campaign } from '@/lib/schemas';
import { diceRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { useNotes, useNpcs, useQuests, useCalendar } from './hooks';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
export function DashboardPage() {
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
}
const LINKS = [
{ to: '/characters', label: 'Characters', icon: '🧝' },
{ to: '/combat', label: 'Combat', icon: '⚔' },
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' },
{ to: '/npcs', label: 'NPCs', icon: '🎭' },
{ to: '/quests', label: 'Quests', icon: '🗺' },
{ to: '/calendar', label: 'Calendar', icon: '📅' },
{ to: '/compendium', label: 'Compendium', icon: '📚' },
{ to: '/dice', label: 'Dice', icon: '🎲' },
] as const;
function Dashboard({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id);
const notes = useNotes(campaign.id);
const npcs = useNpcs(campaign.id);
const quests = useQuests(campaign.id);
const calendar = useCalendar(campaign.id);
const rolls = useLiveQuery(() => diceRepo.recent(campaign.id, 8), [campaign.id], []);
const sys = getSystem(campaign.system);
const pcs = characters.filter((c) => c.kind === 'pc');
const activeQuests = quests.filter((q) => q.status === 'active').length;
return (
<Page>
<PageHeader
title={campaign.name}
subtitle={`${sys.label}${calendar ? ` · in-world day ${calendar.currentDay}` : ''}`}
/>
{campaign.description && <p className="mb-6 max-w-3xl text-sm text-muted">{campaign.description}</p>}
{/* Quick stats */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Stat label="Characters" value={characters.length} />
<Stat label="Encounters" value={encounters.length} />
<Stat label="Active quests" value={activeQuests} />
<Stat label="NPCs" value={npcs.length} />
<Stat label="Notes" value={notes.length} />
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* Navigation cards */}
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Jump to</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{LINKS.map((l) => (
<Link
key={l.to}
to={l.to}
className="flex flex-col items-center gap-1 rounded-lg border border-line bg-panel p-4 text-center transition-colors hover:border-accent/50"
>
<span className="text-2xl" aria-hidden>{l.icon}</span>
<span className="text-sm text-ink">{l.label}</span>
</Link>
))}
</div>
{/* Party overview */}
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Party</h2>
{pcs.length === 0 ? (
<p className="text-sm text-muted">No player characters yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{pcs.map((c) => (
<Link
key={c.id}
to="/characters/$characterId"
params={{ characterId: c.id }}
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-2 text-sm hover:border-accent/40"
>
<span className="truncate text-ink">{c.name} <span className="text-muted">Lv {c.level}</span></span>
<span className="shrink-0 text-muted">
HP {c.hp.current}/{c.hp.max} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
</span>
</Link>
))}
</div>
)}
</section>
{/* Recent rolls */}
<aside>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent rolls</h2>
{rolls.length === 0 ? (
<p className="text-sm text-muted">No rolls yet.</p>
) : (
<ul className="space-y-1">
{rolls.map((r) => (
<li key={r.id} className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
<span className="truncate text-muted">{r.label ? `${r.label}: ` : ''}<span className="font-mono">{r.expression}</span></span>
<span className="font-display font-semibold text-ink">{r.total}</span>
</li>
))}
</ul>
)}
</aside>
</div>
</Page>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="font-display text-3xl font-bold text-accent">{value}</div>
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useMemo, useState } from 'react';
import type { Campaign, Note } from '@/lib/schemas';
import { notesRepo } from '@/lib/db/repositories';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { extractWikiLinks, splitWikiLinks } from '@/lib/wikilinks';
import { useNotes } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
export function NotesPage() {
return <RequireCampaign>{(c) => <Notes campaign={c} />}</RequireCampaign>;
}
function Notes({ campaign }: { campaign: Campaign }) {
const notes = useNotes(campaign.id);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const selected = notes.find((n) => n.id === selectedId) ?? null;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q
? notes.filter((n) => n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q) || n.tags.some((t) => t.toLowerCase().includes(q)))
: notes;
return [...list].sort((a, b) => a.title.localeCompare(b.title));
}, [notes, query]);
const createNote = async (title = 'Untitled') => {
const note = await notesRepo.create(campaign.id, title);
setSelectedId(note.id);
};
const openByTitle = async (title: string) => {
const existing = notes.find((n) => n.title.toLowerCase() === title.toLowerCase());
if (existing) setSelectedId(existing.id);
else await createNote(title);
};
return (
<Page>
<PageHeader
title="Notes & Wiki"
subtitle={`${campaign.name} · link entries with [[double brackets]]`}
actions={<Button variant="primary" onClick={() => createNote()}>+ New note</Button>}
/>
{notes.length === 0 ? (
<EmptyState
title="No notes yet"
hint="Capture lore, session recaps, and locations. Use [[wiki links]] to connect them."
action={<Button variant="primary" onClick={() => createNote()}>+ New note</Button>}
/>
) : (
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<div>
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search notes…" aria-label="Search notes" />
<ul className="mt-2 max-h-[65vh] space-y-1 overflow-auto">
{filtered.map((n) => (
<li key={n.id}>
<button
onClick={() => setSelectedId(n.id)}
className={cn(
'w-full truncate rounded-md border px-3 py-2 text-left text-sm',
selected?.id === n.id ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
)}
>
{n.title}
</button>
</li>
))}
</ul>
</div>
{selected ? (
<NoteEditor key={selected.id} note={selected} allNotes={notes} onOpenTitle={openByTitle} onSelect={setSelectedId} />
) : (
<div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a note to edit.</div>
)}
</div>
)}
</Page>
);
}
function NoteEditor({
note,
allNotes,
onOpenTitle,
onSelect,
}: {
note: Note;
allNotes: Note[];
onOpenTitle: (title: string) => void;
onSelect: (id: string | null) => void;
}) {
const [title, setTitle] = useState(note.title);
const [body, setBody] = useState(note.body);
const [tags, setTags] = useState(note.tags.join(', '));
const save = useDebouncedCallback((patch: Partial<Note>) => void notesRepo.update(note.id, patch), 350);
const backlinks = allNotes.filter(
(n) => n.id !== note.id && extractWikiLinks(n.body).some((t) => t.toLowerCase() === note.title.toLowerCase()),
);
const titlesLower = new Set(allNotes.map((n) => n.title.toLowerCase()));
return (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="mb-3 flex items-center gap-2">
<Input
className="font-display text-lg font-semibold"
value={title}
aria-label="Note title"
onChange={(e) => { setTitle(e.target.value); save({ title: e.target.value }); }}
/>
<Button
variant="ghost"
className="text-danger"
onClick={async () => { await notesRepo.remove(note.id); onSelect(null); }}
>
Delete
</Button>
</div>
<label className="mb-3 block text-xs text-muted">
Tags (comma-separated)
<Input
value={tags}
onChange={(e) => {
setTags(e.target.value);
save({ tags: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) });
}}
placeholder="lore, barovia, npc"
/>
</label>
<textarea
value={body}
onChange={(e) => { setBody(e.target.value); save({ body: e.target.value }); }}
placeholder="Write here… link other notes with [[Note Title]]."
className="min-h-48 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
{/* Rendered preview with clickable links */}
<div className="mt-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Preview</h3>
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
{splitWikiLinks(body).map((part, i) =>
part.link ? (
<button
key={i}
onClick={() => onOpenTitle(part.link!)}
className={cn('underline', titlesLower.has(part.link.toLowerCase()) ? 'text-accent' : 'text-warning')}
title={titlesLower.has(part.link.toLowerCase()) ? 'Open note' : 'Create this note'}
>
{part.text}
</button>
) : (
<span key={i}>{part.text}</span>
),
)}
</p>
</div>
{backlinks.length > 0 && (
<div className="mt-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Linked from</h3>
<div className="flex flex-wrap gap-1">
{backlinks.map((n) => (
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
{n.title}
</button>
))}
</div>
</div>
)}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react';
import type { Campaign, Npc } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useNpcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
const STATUS: Npc['status'][] = ['alive', 'dead', 'unknown'];
export function NpcsPage() {
return <RequireCampaign>{(c) => <Npcs campaign={c} />}</RequireCampaign>;
}
function Npcs({ campaign }: { campaign: Campaign }) {
const npcs = useNpcs(campaign.id);
const [query, setQuery] = useState('');
const filtered = npcs
.filter((n) => n.name.toLowerCase().includes(query.toLowerCase()) || n.faction.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name));
return (
<Page>
<PageHeader
title="NPCs"
subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => npcsRepo.create(campaign.id, 'New NPC')}>+ New NPC</Button>}
/>
{npcs.length === 0 ? (
<EmptyState title="No NPCs yet" hint="Track the people your party meets — allies, villains, shopkeepers." action={<Button variant="primary" onClick={() => npcsRepo.create(campaign.id, 'New NPC')}>+ New NPC</Button>} />
) : (
<>
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
<div className="grid gap-3 md:grid-cols-2">
{filtered.map((n) => <NpcCard key={n.id} npc={n} />)}
</div>
</>
)}
</Page>
);
}
function NpcCard({ npc }: { npc: Npc }) {
const [n, setN] = useState(npc);
const save = useDebouncedCallback((patch: Partial<Npc>) => void npcsRepo.update(npc.id, patch), 350);
const update = (patch: Partial<Npc>) => { setN((p) => ({ ...p, ...patch })); save(patch); };
return (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2">
<Input className="font-display font-semibold" value={n.name} onChange={(e) => update({ name: e.target.value })} aria-label="NPC name" />
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}></Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
<Input className="text-xs" value={n.location} onChange={(e) => update({ location: e.target.value })} placeholder="Location" aria-label="Location" />
<Input className="text-xs" value={n.faction} onChange={(e) => update({ faction: e.target.value })} placeholder="Faction" aria-label="Faction" />
</div>
<textarea
value={n.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="Appearance, motives, voice, secrets…"
className="mt-2 min-h-20 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react';
import type { Campaign, Quest, Objective } from '@/lib/schemas';
import { questsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useQuests } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed'];
const STATUS_COLOR: Record<Quest['status'], string> = {
active: 'text-info', 'on-hold': 'text-muted', completed: 'text-success', failed: 'text-danger',
};
export function QuestsPage() {
return <RequireCampaign>{(c) => <Quests campaign={c} />}</RequireCampaign>;
}
function Quests({ campaign }: { campaign: Campaign }) {
const quests = useQuests(campaign.id);
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
return (
<Page>
<PageHeader
title="Quests"
subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>}
/>
{quests.length === 0 ? (
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />
) : (
<div className="space-y-3">
{sorted.map((q) => <QuestCard key={q.id} quest={q} />)}
</div>
)}
</Page>
);
}
function QuestCard({ quest }: { quest: Quest }) {
const [q, setQ] = useState(quest);
const [newObj, setNewObj] = useState('');
const save = useDebouncedCallback((patch: Partial<Quest>) => void questsRepo.update(quest.id, patch), 350);
const update = (patch: Partial<Quest>) => { setQ((p) => ({ ...p, ...patch })); save(patch); };
const setObjective = (id: string, patch: Partial<Objective>) =>
update({ objectives: q.objectives.map((o) => (o.id === id ? { ...o, ...patch } : o)) });
const addObjective = () => {
if (newObj.trim() === '') return;
update({ objectives: [...q.objectives, { id: newId(), text: newObj.trim(), done: false }] });
setNewObj('');
};
const done = q.objectives.filter((o) => o.done).length;
return (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex flex-wrap items-center gap-2">
<Input className="flex-1 font-display font-semibold" value={q.title} onChange={(e) => update({ title: e.target.value })} aria-label="Quest title" />
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}></Button>
</div>
<textarea
value={q.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="What's this quest about?"
className="mt-2 min-h-16 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
<label className="mt-2 block text-xs text-muted">
Reward
<Input value={q.reward} onChange={(e) => update({ reward: e.target.value })} placeholder="500 gp, a favour, a magic sword…" />
</label>
<div className="mt-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
</div>
<ul className="space-y-1">
{q.objectives.map((o) => (
<li key={o.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={o.done} onChange={(e) => setObjective(o.id, { done: e.target.checked })} aria-label={o.text} />
<input
className={cn('flex-1 bg-transparent text-sm focus:outline-none', o.done ? 'text-muted line-through' : 'text-ink')}
value={o.text}
onChange={(e) => setObjective(o.id, { text: e.target.value })}
/>
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"></button>
</li>
))}
</ul>
<div className="mt-1 flex gap-2">
<Input className="h-8 text-sm" value={newObj} onChange={(e) => setNewObj(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addObjective()} placeholder="Add objective…" />
<Button size="sm" onClick={addObjective}>Add</Button>
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useLiveQuery } from 'dexie-react-hooks';
import { notesRepo, npcsRepo, questsRepo, calendarRepo } from '@/lib/db/repositories';
import type { Note, Npc, Quest, Calendar } from '@/lib/schemas';
export function useNotes(campaignId: string): Note[] {
return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useNpcs(campaignId: string): Npc[] {
return useLiveQuery(() => npcsRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useQuests(campaignId: string): Quest[] {
return useLiveQuery(() => questsRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useCalendar(campaignId: string): Calendar | undefined {
return useLiveQuery(() => calendarRepo.get(campaignId), [campaignId], undefined);
}
+13
View File
@@ -3,6 +3,7 @@ import type { Campaign } from '@/lib/schemas/campaign';
import type { Character } from '@/lib/schemas/character'; import type { Character } from '@/lib/schemas/character';
import type { Encounter } from '@/lib/schemas/encounter'; import type { Encounter } from '@/lib/schemas/encounter';
import type { DiceRoll } from '@/lib/schemas/dice'; import type { DiceRoll } from '@/lib/schemas/dice';
import type { Note, Npc, Quest, Calendar } from '@/lib/schemas/world';
import { characterDefaults } from '@/lib/schemas/character'; import { characterDefaults } from '@/lib/schemas/character';
/** /**
@@ -18,6 +19,10 @@ export class TtrpgDatabase extends Dexie {
characters!: EntityTable<Character, 'id'>; characters!: EntityTable<Character, 'id'>;
encounters!: EntityTable<Encounter, 'id'>; encounters!: EntityTable<Encounter, 'id'>;
diceRolls!: EntityTable<DiceRoll, 'id'>; diceRolls!: EntityTable<DiceRoll, 'id'>;
notes!: EntityTable<Note, 'id'>;
npcs!: EntityTable<Npc, 'id'>;
quests!: EntityTable<Quest, 'id'>;
calendars!: EntityTable<Calendar, 'campaignId'>;
constructor() { constructor() {
super('ttrpg-manager'); super('ttrpg-manager');
@@ -49,6 +54,14 @@ export class TtrpgDatabase extends Dexie {
if (e.log === undefined) e.log = []; 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 { describe, it, expect, beforeEach } from 'vitest';
import { db } from './db'; import { db } from './db';
import { campaignsRepo, charactersRepo, encountersRepo, diceRepo } from './repositories'; import { campaignsRepo, charactersRepo, encountersRepo, diceRepo, notesRepo, npcsRepo, questsRepo, calendarRepo } from './repositories';
beforeEach(async () => { beforeEach(async () => {
await db.delete(); await db.delete();
@@ -17,6 +17,10 @@ describe('campaign cascade delete', () => {
}); });
await encountersRepo.create(a.id, 'Death House'); await encountersRepo.create(a.id, 'Death House');
await diceRepo.add({ campaignId: a.id, label: 'attack', expression: '1d20', total: 14, breakdown: '' }); 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 // unrelated campaign's data must survive
await charactersRepo.create(b.id, { await charactersRepo.create(b.id, {
@@ -29,6 +33,10 @@ describe('campaign cascade delete', () => {
expect(await charactersRepo.listByCampaign(a.id)).toHaveLength(0); expect(await charactersRepo.listByCampaign(a.id)).toHaveLength(0);
expect(await encountersRepo.listByCampaign(a.id)).toHaveLength(0); expect(await encountersRepo.listByCampaign(a.id)).toHaveLength(0);
expect(await diceRepo.recent(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); expect(await charactersRepo.listByCampaign(b.id)).toHaveLength(1);
}); });
+99 -6
View File
@@ -5,11 +5,19 @@ import {
characterSchema, characterSchema,
encounterSchema, encounterSchema,
diceRollSchema, diceRollSchema,
noteSchema,
npcSchema,
questSchema,
calendarSchema,
type Campaign, type Campaign,
type CampaignDraft, type CampaignDraft,
type Character, type Character,
type Encounter, type Encounter,
type DiceRoll, type DiceRoll,
type Note,
type Npc,
type Quest,
type Calendar,
} from '@/lib/schemas'; } from '@/lib/schemas';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
@@ -47,12 +55,20 @@ export const campaignsRepo = {
/** Delete a campaign and ALL its children atomically (real cascade). */ /** Delete a campaign and ALL its children atomically (real cascade). */
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
await db.transaction('rw', db.campaigns, db.characters, db.encounters, db.diceRolls, async () => { await db.transaction(
await db.characters.where('campaignId').equals(id).delete(); 'rw',
await db.encounters.where('campaignId').equals(id).delete(); [db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars],
await db.diceRolls.where('campaignId').equals(id).delete(); async () => {
await db.campaigns.delete(id); 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(); 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 './character';
export * from './encounter'; export * from './encounter';
export * from './dice'; 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;
}
+15
View File
@@ -6,6 +6,11 @@ import { CharacterSheetPage } from '@/features/characters/CharacterSheetPage';
import { CombatPage } from '@/features/combat/CombatPage'; import { CombatPage } from '@/features/combat/CombatPage';
import { DicePage } from '@/features/dice/DicePage'; import { DicePage } from '@/features/dice/DicePage';
import { CompendiumPage } from '@/features/compendium/CompendiumPage'; import { CompendiumPage } from '@/features/compendium/CompendiumPage';
import { DashboardPage } from '@/features/world/DashboardPage';
import { NotesPage } from '@/features/world/NotesPage';
import { NpcsPage } from '@/features/world/NpcsPage';
import { QuestsPage } from '@/features/world/QuestsPage';
import { CalendarPage } from '@/features/world/CalendarPage';
const rootRoute = createRootRoute({ component: RootLayout }); const rootRoute = createRootRoute({ component: RootLayout });
@@ -19,14 +24,24 @@ const characterSheetRoute = createRoute({
const combatRoute = createRoute({ getParentRoute: () => rootRoute, path: '/combat', component: CombatPage }); const combatRoute = createRoute({ getParentRoute: () => rootRoute, path: '/combat', component: CombatPage });
const diceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dice', component: DicePage }); const diceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dice', component: DicePage });
const compendiumRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compendium', component: CompendiumPage }); const compendiumRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compendium', component: CompendiumPage });
const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/dashboard', component: DashboardPage });
const notesRoute = createRoute({ getParentRoute: () => rootRoute, path: '/notes', component: NotesPage });
const npcsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/npcs', component: NpcsPage });
const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quests', component: QuestsPage });
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
dashboardRoute,
charactersRoute, charactersRoute,
characterSheetRoute, characterSheetRoute,
combatRoute, combatRoute,
diceRoute, diceRoute,
compendiumRoute, compendiumRoute,
notesRoute,
npcsRoute,
questsRoute,
calendarRoute,
]); ]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' }); export const router = createRouter({ routeTree, defaultPreload: 'intent' });
+1 -1
View File
@@ -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/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.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/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/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/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/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} {"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/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.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/world/CalendarPage.tsx","./src/features/world/DashboardPage.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/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/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}