From 1632018ce9755a9b9d428c4b73ef61bf92e2e3f8 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 02:01:29 +0200 Subject: [PATCH] Phase 7: maps & VTT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BattleMap entity (image dataURL, grid, tokens, fog) β€” Dexie v5, repo, hook, cascade-deleted with campaign - Maps page: upload image (8MB cap), per-campaign list - Map editor canvas: grid overlay (toggle + cell size), fog of war (canvas) with reveal/hide painting + reveal-all/hide-all, draggable tokens that snap to the grid (pointer events, touch+mouse), add/remove tokens; debounced autosave - Dashboard + routes get a Maps entry New maps e2e (upload, add token, toggle fog). Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/maps.spec.ts | 32 ++++ src/features/world/DashboardPage.tsx | 1 + src/features/world/MapsPage.tsx | 253 +++++++++++++++++++++++++++ src/features/world/hooks.ts | 7 +- src/lib/db/db.ts | 6 +- src/lib/db/repositories.ts | 27 ++- src/lib/schemas/world.ts | 28 +++ src/router.tsx | 3 + tsconfig.app.tsbuildinfo | 2 +- 9 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 e2e/maps.spec.ts create mode 100644 src/features/world/MapsPage.tsx diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts new file mode 100644 index 0000000..ed40d12 --- /dev/null +++ b/e2e/maps.spec.ts @@ -0,0 +1,32 @@ +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('maps: upload, place a token, toggle fog', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('VTT'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Maps' }).click(); + + // Upload an image into the hidden file input + await page.locator('input[type="file"]').setInputFiles('public/pwa-512x512.png'); + + // Editor loads (Move tool button present) + await expect(page.getByRole('button', { name: 'Move' })).toBeVisible(); + + // Add a token + await page.getByRole('button', { name: '+ Token' }).click(); + await expect(page.getByTitle(/double-click to remove/)).toBeVisible(); + + // Toggle fog on (no crash) + await page.getByText('Fog', { exact: true }).locator('input').check(); + await expect(page.getByRole('button', { name: 'Reveal all' })).toBeVisible(); +}); diff --git a/src/features/world/DashboardPage.tsx b/src/features/world/DashboardPage.tsx index 5741d1b..a06d816 100644 --- a/src/features/world/DashboardPage.tsx +++ b/src/features/world/DashboardPage.tsx @@ -18,6 +18,7 @@ const LINKS = [ { to: '/notes', label: 'Notes & Wiki', icon: 'πŸ“œ' }, { to: '/npcs', label: 'NPCs', icon: '🎭' }, { to: '/quests', label: 'Quests', icon: 'πŸ—Ί' }, + { to: '/maps', label: 'Maps', icon: 'πŸ—ΊοΈ' }, { to: '/calendar', label: 'Calendar', icon: 'πŸ“…' }, { to: '/compendium', label: 'Compendium', icon: 'πŸ“š' }, { to: '/dice', label: 'Dice', icon: '🎲' }, diff --git a/src/features/world/MapsPage.tsx b/src/features/world/MapsPage.tsx new file mode 100644 index 0000000..cc41e03 --- /dev/null +++ b/src/features/world/MapsPage.tsx @@ -0,0 +1,253 @@ +import { useEffect, useRef, useState } from 'react'; +import type { BattleMap, Campaign, MapToken } from '@/lib/schemas'; +import { mapsRepo } from '@/lib/db/repositories'; +import { newId } from '@/lib/ids'; +import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; +import { useMaps } from './hooks'; +import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { NumberField } from '@/components/ui/NumberField'; +import { cn } from '@/lib/cn'; + +const MAX_IMAGE_BYTES = 8 * 1024 * 1024; +const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726']; + +export function MapsPage() { + return {(c) => }; +} + +function Maps({ campaign }: { campaign: Campaign }) { + const maps = useMaps(campaign.id); + const [selectedId, setSelectedId] = useState(null); + const [error, setError] = useState(null); + const fileRef = useRef(null); + const selected = maps.find((m) => m.id === selectedId) ?? null; + + const upload = (file: File) => { + setError(null); + if (file.size > MAX_IMAGE_BYTES) { setError('Image is larger than 8 MB.'); return; } + const reader = new FileReader(); + reader.onload = async () => { + const dataUrl = typeof reader.result === 'string' ? reader.result : ''; + if (!dataUrl) return; + const map = await mapsRepo.create(campaign.id, file.name.replace(/\.[^.]+$/, ''), dataUrl); + setSelectedId(map.id); + }; + reader.readAsDataURL(file); + }; + + return ( + + fileRef.current?.click()}>+ Upload map} + /> + { const f = e.target.files?.[0]; if (f) upload(f); e.target.value = ''; }} + /> + {error &&

{error}

} + + {maps.length === 0 ? ( + fileRef.current?.click()}>+ Upload map} /> + ) : ( +
+
    + {maps.map((m) => ( +
  • + + +
  • + ))} +
+ {selected ? :
Select a map.
} +
+ )} +
+ ); +} + +type Tool = 'move' | 'reveal' | 'hide'; + +function MapEditor({ map }: { map: BattleMap }) { + const [m, setM] = useState(map); + const [tool, setTool] = useState('move'); + const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); + const fogRef = useRef(null); + const painting = useRef(false); + + const save = useDebouncedCallback((next: BattleMap) => void mapsRepo.save(next), 400); + const update = (patch: Partial) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; }); + + // Load natural image size. + useEffect(() => { + if (!m.image) return; + const img = new Image(); + img.onload = () => setNatural({ w: img.naturalWidth, h: img.naturalHeight }); + img.src = m.image; + }, [m.image]); + + const displayW = natural ? Math.min(natural.w, 880) : 0; + const scale = natural ? displayW / natural.w : 1; + const displayH = natural ? natural.h * scale : 0; + const cellPx = m.gridSize * scale; + const cols = natural ? Math.ceil(natural.w / m.gridSize) : 0; + const rows = natural ? Math.ceil(natural.h / m.gridSize) : 0; + + // Redraw fog whenever it changes. + useEffect(() => { + const canvas = fogRef.current; + if (!canvas || !natural) return; + canvas.width = displayW; + canvas.height = displayH; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, displayW, displayH); + if (!m.fogEnabled) return; + const revealed = new Set(m.revealed); + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + for (let c = 0; c < cols; c++) { + for (let r = 0; r < rows; r++) { + if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * cellPx, r * cellPx, cellPx, cellPx); + } + } + }, [m.revealed, m.fogEnabled, displayW, displayH, cellPx, cols, rows, natural]); + + // Stop painting on global pointer up. + useEffect(() => { + const stop = () => { painting.current = false; }; + window.addEventListener('pointerup', stop); + return () => window.removeEventListener('pointerup', stop); + }, []); + + const cellAt = (e: React.PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + return { col: Math.floor((e.clientX - rect.left) / cellPx), row: Math.floor((e.clientY - rect.top) / cellPx) }; + }; + const paint = (e: React.PointerEvent) => { + if (tool === 'move') return; + const { col, row } = cellAt(e); + if (col < 0 || row < 0 || col >= cols || row >= rows) return; + const key = `${col},${row}`; + const set = new Set(m.revealed); + if (tool === 'reveal') set.add(key); else set.delete(key); + update({ revealed: [...set] }); + }; + + const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0 }] }); + const patchToken = (id: string, p: Partial) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...p } : t)) }); + + return ( +
+ {/* Toolbar */} +
+ update({ name: e.target.value })} aria-label="Map name" /> +
+ {(['move', 'reveal', 'hide'] as Tool[]).map((t) => ( + + ))} +
+ + + + + + +
+ + {!natural ? ( +

Loading map…

+ ) : ( +
+
+ {m.name} + {m.showGrid && ( +
+ )} + {/* Fog + paint surface (also catches clicks in reveal/hide mode) */} + { painting.current = true; paint(e); }} + onPointerMove={(e) => { if (painting.current) paint(e); }} + /> + {/* Tokens */} + {m.tokens.map((t) => ( + patchToken(t.id, { col, row })} + onRemove={() => update({ tokens: m.tokens.filter((x) => x.id !== t.id) })} + /> + ))} +
+
+ )} +

Tip: use Reveal/Hide to paint fog; Move to drag tokens.

+
+ ); +} + +function TokenChip({ token, cellPx, draggable, cols, rows, onMove, onRemove }: { + token: MapToken; cellPx: number; draggable: boolean; cols: number; rows: number; + onMove: (col: number, row: number) => void; onRemove: () => void; +}) { + const ref = useRef(null); + const drag = useRef<{ startX: number; startY: number; col: number; row: number } | null>(null); + const [drift, setDrift] = useState<{ dx: number; dy: number }>({ dx: 0, dy: 0 }); + + const onDown = (e: React.PointerEvent) => { + if (!draggable) return; + e.stopPropagation(); + ref.current?.setPointerCapture(e.pointerId); + drag.current = { startX: e.clientX, startY: e.clientY, col: token.col, row: token.row }; + }; + const onMovePtr = (e: React.PointerEvent) => { + if (!drag.current) return; + setDrift({ dx: e.clientX - drag.current.startX, dy: e.clientY - drag.current.startY }); + }; + const onUp = (e: React.PointerEvent) => { + if (!drag.current) return; + ref.current?.releasePointerCapture(e.pointerId); + const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / cellPx))); + const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / cellPx))); + drag.current = null; + setDrift({ dx: 0, dy: 0 }); + onMove(col, row); + }; + + return ( +
+ {token.label} +
+ ); +} diff --git a/src/features/world/hooks.ts b/src/features/world/hooks.ts index 51a622e..adacee0 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 } from '@/lib/db/repositories'; -import type { Note, Npc, Quest, Calendar } from '@/lib/schemas'; +import { notesRepo, npcsRepo, questsRepo, calendarRepo, mapsRepo } from '@/lib/db/repositories'; +import type { Note, Npc, Quest, Calendar, BattleMap } from '@/lib/schemas'; export function useNotes(campaignId: string): Note[] { return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []); @@ -14,3 +14,6 @@ export function useQuests(campaignId: string): Quest[] { export function useCalendar(campaignId: string): Calendar | undefined { return useLiveQuery(() => calendarRepo.get(campaignId), [campaignId], undefined); } +export function useMaps(campaignId: string): BattleMap[] { + return useLiveQuery(() => mapsRepo.listByCampaign(campaignId), [campaignId], []); +} diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index d342f46..1cc2bb1 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 } from '@/lib/schemas/world'; +import type { Note, Npc, Quest, Calendar, BattleMap } from '@/lib/schemas/world'; import { characterDefaults } from '@/lib/schemas/character'; /** @@ -23,6 +23,7 @@ export class TtrpgDatabase extends Dexie { npcs!: EntityTable; quests!: EntityTable; calendars!: EntityTable; + maps!: EntityTable; constructor() { super('ttrpg-manager'); @@ -62,6 +63,9 @@ export class TtrpgDatabase extends Dexie { quests: 'id, campaignId, status', calendars: 'campaignId', }); + + // v5 β€” Phase 7 maps/VTT. + this.version(5).stores({ maps: 'id, campaignId' }); } } diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index b5eec9a..c32c890 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -9,6 +9,7 @@ import { npcSchema, questSchema, calendarSchema, + battleMapSchema, type Campaign, type CampaignDraft, type Character, @@ -18,6 +19,7 @@ import { type Npc, type Quest, type Calendar, + type BattleMap, } from '@/lib/schemas'; import { getSystem } from '@/lib/rules'; @@ -57,7 +59,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.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps], async () => { await db.characters.where('campaignId').equals(id).delete(); await db.encounters.where('campaignId').equals(id).delete(); @@ -65,6 +67,7 @@ export const campaignsRepo = { 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.calendars.delete(id); await db.campaigns.delete(id); }, @@ -264,3 +267,25 @@ export const calendarRepo = { await db.calendars.put(calendarSchema.parse(calendar)); }, }; + +// ---------------- Battle maps ---------------- +export const mapsRepo = { + listByCampaign(campaignId: string): Promise { + return db.maps.where('campaignId').equals(campaignId).toArray(); + }, + async create(campaignId: string, name: string, image: string): Promise { + 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 { + await db.maps.put(battleMapSchema.parse({ ...map, updatedAt: now() })); + }, + async remove(id: string): Promise { + await db.maps.delete(id); + }, +}; diff --git a/src/lib/schemas/world.ts b/src/lib/schemas/world.ts index 95de2cd..907049d 100644 --- a/src/lib/schemas/world.ts +++ b/src/lib/schemas/world.ts @@ -66,3 +66,31 @@ export const calendarSchema = z.object({ events: z.array(calendarEventSchema).default([]), }); export type Calendar = z.infer; + +// ---------------- Battle maps (VTT) ---------------- +export const mapTokenSchema = z.object({ + id: z.string(), + label: z.string().max(40).default(''), + color: z.string().max(20).default('#d4af37'), + /** grid cell coordinates */ + col: int, + row: int, +}); +export type MapToken = z.infer; + +export const battleMapSchema = z.object({ + id: z.string(), + campaignId: z.string(), + name: z.string().min(1).max(120), + /** image as a data URL (stored locally) */ + image: z.string().default(''), + gridSize: int.min(10).max(400).default(50), + showGrid: z.boolean().default(true), + fogEnabled: z.boolean().default(false), + /** revealed cell keys "col,row"; everything else is fogged when enabled */ + revealed: z.array(z.string()).default([]), + tokens: z.array(mapTokenSchema).default([]), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type BattleMap = z.infer; diff --git a/src/router.tsx b/src/router.tsx index 847816d..104249c 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -11,6 +11,7 @@ import { NotesPage } from '@/features/world/NotesPage'; import { NpcsPage } from '@/features/world/NpcsPage'; import { QuestsPage } from '@/features/world/QuestsPage'; import { CalendarPage } from '@/features/world/CalendarPage'; +import { MapsPage } from '@/features/world/MapsPage'; const rootRoute = createRootRoute({ component: RootLayout }); @@ -29,6 +30,7 @@ const notesRoute = createRoute({ getParentRoute: () => rootRoute, path: '/notes' 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 mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage }); const routeTree = rootRoute.addChildren([ indexRoute, @@ -42,6 +44,7 @@ const routeTree = rootRoute.addChildren([ npcsRoute, questsRoute, calendarRoute, + mapsRoute, ]); export const router = createRouter({ routeTree, defaultPreload: 'intent' }); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 984060a..9c224e2 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/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/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/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/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/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file