diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..919b892 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,29 @@ +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('command palette navigates', async ({ page }) => { + await page.keyboard.press('Control+k'); + const input = page.getByLabel('Command search'); + await expect(input).toBeVisible(); + await input.fill('Dice'); + await input.press('Enter'); + await expect(page.getByRole('heading', { name: 'Dice' })).toBeVisible(); +}); + +test('settings loads the sample campaign', async ({ page }) => { + await page.getByLabel('Settings').click(); + await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible(); + await page.getByRole('button', { name: 'Load sample campaign' }).click(); + await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); + // sample seeded characters (use the nav link, not the dashboard card) + await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click(); + await expect(page.getByText('Lia the Brave')).toBeVisible(); +}); diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 718d041..3802def 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -1,5 +1,7 @@ +import { useEffect, useState } from 'react'; import { Link, Outlet, useRouterState } from '@tanstack/react-router'; import { useUiStore } from '@/stores/uiStore'; +import { CommandPalette } from '@/components/CommandPalette'; import { useActiveCampaign, useCampaigns } from '@/features/campaigns/hooks'; import { cn } from '@/lib/cn'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; @@ -53,6 +55,19 @@ function ThemeToggle() { export function RootLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); + const [paletteOpen, setPaletteOpen] = useState(false); + + // Global Ctrl/Cmd+K opens the command palette. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setPaletteOpen((v) => !v); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); return (
@@ -78,6 +93,10 @@ export function RootLayout() { })}
+ +
@@ -90,6 +109,7 @@ export function RootLayout() { + {paletteOpen && setPaletteOpen(false)} />}
); } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..abaaa96 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import Fuse from 'fuse.js'; +import { useUiStore } from '@/stores/uiStore'; +import { useActiveCampaign } from '@/features/campaigns/hooks'; +import { useCharacters } from '@/features/characters/hooks'; +import { useNotes, useNpcs, useQuests } from '@/features/world/hooks'; +import { cn } from '@/lib/cn'; + +interface Command { + id: string; + label: string; + hint?: string; + run: () => void; +} + +const NAV: { label: string; to: string }[] = [ + { label: 'Campaigns', to: '/' }, + { label: 'Dashboard', to: '/dashboard' }, + { label: 'Characters', to: '/characters' }, + { label: 'Combat', to: '/combat' }, + { label: 'Dice', to: '/dice' }, + { label: 'Compendium', to: '/compendium' }, + { label: 'Notes', to: '/notes' }, + { label: 'NPCs', to: '/npcs' }, + { label: 'Quests', to: '/quests' }, + { label: 'Maps', to: '/maps' }, + { label: 'Homebrew', to: '/homebrew' }, + { label: 'Player View', to: '/play' }, + { label: 'Settings', to: '/settings' }, +]; + +export function CommandPalette({ onClose }: { onClose: () => void }) { + const navigate = useNavigate(); + const toggleTheme = useUiStore((s) => s.toggleTheme); + const campaign = useActiveCampaign(); + const cid = campaign?.id ?? ''; + const characters = useCharacters(cid); + const notes = useNotes(cid); + const npcs = useNpcs(cid); + const quests = useQuests(cid); + + const [q, setQ] = useState(''); + const [idx, setIdx] = useState(0); + const inputRef = useRef(null); + + useEffect(() => { inputRef.current?.focus(); }, []); + + const go = (to: string, params?: Record) => () => { + void navigate(params ? { to, params } : { to }); + onClose(); + }; + + const commands = useMemo(() => { + const list: Command[] = NAV.map((n) => ({ id: `nav:${n.to}`, label: n.label, hint: 'Go', run: go(n.to) })); + list.push({ id: 'act:theme', label: 'Toggle theme', hint: 'Action', run: () => { toggleTheme(); onClose(); } }); + for (const c of characters) list.push({ id: `char:${c.id}`, label: c.name, hint: 'Character', run: go('/characters/$characterId', { characterId: c.id }) }); + for (const n of notes) list.push({ id: `note:${n.id}`, label: n.title, hint: 'Note', run: go('/notes') }); + for (const n of npcs) list.push({ id: `npc:${n.id}`, label: n.name, hint: 'NPC', run: go('/npcs') }); + for (const qu of quests) list.push({ id: `quest:${qu.id}`, label: qu.title, hint: 'Quest', run: go('/quests') }); + return list; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [characters, notes, npcs, quests, toggleTheme]); + + const fuse = useMemo(() => new Fuse(commands, { keys: ['label'], threshold: 0.4 }), [commands]); + const results = useMemo(() => (q.trim() ? fuse.search(q.trim()).map((r) => r.item) : commands).slice(0, 50), [q, fuse, commands]); + const clampedIdx = Math.min(idx, Math.max(0, results.length - 1)); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(results.length - 1, i + 1)); } + else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(0, i - 1)); } + else if (e.key === 'Enter') { e.preventDefault(); results[clampedIdx]?.run(); } + else if (e.key === 'Escape') { e.preventDefault(); onClose(); } + }; + + return ( +
+
+
+ { setQ(e.target.value); setIdx(0); }} + onKeyDown={onKeyDown} + placeholder="Search pages, characters, notes…" + aria-label="Command search" + className="w-full border-b border-line bg-transparent px-4 py-3 text-sm text-ink placeholder:text-muted focus:outline-none" + /> +
    + {results.length === 0 ? ( +
  • No matches.
  • + ) : ( + results.map((c, i) => ( +
  • + +
  • + )) + )} +
+
+
+ ); +} diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx new file mode 100644 index 0000000..98f5fe8 --- /dev/null +++ b/src/features/settings/SettingsPage.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useUiStore, type Theme } from '@/stores/uiStore'; +import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup'; +import { pickTextFile } from '@/lib/io/file'; +import { seedSampleCampaign } from '@/lib/sample'; +import { Page, PageHeader } from '@/components/ui/Page'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { cn } from '@/lib/cn'; + +export function SettingsPage() { + const theme = useUiStore((s) => s.theme); + const setTheme = useUiStore((s) => s.setTheme); + const setActiveCampaign = useUiStore((s) => s.setActiveCampaign); + const navigate = useNavigate(); + const [msg, setMsg] = useState(null); + const [confirmRestore, setConfirmRestore] = useState(null); + const [confirmClear, setConfirmClear] = useState(false); + + const doImport = async () => { + const text = await pickTextFile(); + if (text) setConfirmRestore(text); + }; + const runRestore = async () => { + if (!confirmRestore) return; + try { + await restoreBackup(confirmRestore); + setMsg('Backup restored.'); + } catch (e) { + setMsg(e instanceof BackupError ? e.message : 'Restore failed.'); + } + setConfirmRestore(null); + }; + const loadSample = async () => { + const id = await seedSampleCampaign(); + setActiveCampaign(id); + void navigate({ to: '/dashboard' }); + }; + + return ( + + + +
+

Appearance

+
+ {(['dark', 'light'] as Theme[]).map((t) => ( + + ))} +
+
+ +
+

Data

+
+ + + +
+

Backups include every campaign and all of its data, as a single JSON file.

+ {msg &&

{msg}

} +
+ +
+

Danger zone

+

Permanently delete all campaigns and data on this device.

+ +
+ + setConfirmRestore(null)} + title="Restore backup?" + footer={ + <> + + + + } + > +

Restoring replaces all current data with the backup. Export first if unsure.

+
+ + setConfirmClear(false)} + title="Clear all data?" + footer={ + <> + + + + } + > +

This deletes every campaign, character, encounter, note, map, and homebrew entry. This cannot be undone.

+
+
+ ); +} diff --git a/src/lib/io/backup.test.ts b/src/lib/io/backup.test.ts new file mode 100644 index 0000000..1adbf95 --- /dev/null +++ b/src/lib/io/backup.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { db } from '@/lib/db/db'; +import { campaignsRepo, charactersRepo, notesRepo } from '@/lib/db/repositories'; +import { buildBackup, restoreBackup, clearAllData } from './backup'; + +beforeEach(async () => { + await db.delete(); + await db.open(); +}); + +describe('backup round-trip', () => { + it('exports all data and restores it after a wipe', async () => { + const c = await campaignsRepo.create({ name: 'Backed Up', system: '5e', description: '' }); + await charactersRepo.create(c.id, { system: '5e', name: 'Saved Hero', kind: 'pc', ancestry: '', className: '', level: 2 }); + await notesRepo.create(c.id, 'Saved Note'); + + const backup = await buildBackup(); + await clearAllData(); + expect(await campaignsRepo.list()).toHaveLength(0); + + await restoreBackup(JSON.stringify(backup)); + const campaigns = await campaignsRepo.list(); + expect(campaigns).toHaveLength(1); + expect(campaigns[0]!.name).toBe('Backed Up'); + expect(await charactersRepo.listByCampaign(c.id)).toHaveLength(1); + expect(await notesRepo.listByCampaign(c.id)).toHaveLength(1); + }); + + it('rejects a non-backup file', async () => { + await expect(restoreBackup('{"foo":1}')).rejects.toThrow(); + await expect(restoreBackup('not json')).rejects.toThrow(); + }); +}); diff --git a/src/lib/io/backup.ts b/src/lib/io/backup.ts new file mode 100644 index 0000000..2774e53 --- /dev/null +++ b/src/lib/io/backup.ts @@ -0,0 +1,53 @@ +import { db } from '@/lib/db/db'; +import { downloadJson } from './file'; + +const TABLES = [ + 'campaigns', 'characters', 'encounters', 'diceRolls', + 'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew', +] as const; + +const FORMAT = 'ttrpg-manager:backup'; + +export class BackupError extends Error {} + +/** Snapshot every table into a portable object. */ +export async function buildBackup(): Promise<{ format: string; version: number; data: Record }> { + const data: Record = {}; + for (const t of TABLES) data[t] = await db.table(t).toArray(); + return { format: FORMAT, version: 1, data }; +} + +/** Download a full backup of all campaigns and data. */ +export async function exportBackup(): Promise { + downloadJson('ttrpg-manager-backup.json', await buildBackup()); +} + +/** + * Restore a backup, REPLACING all current data. Runs in one transaction so a + * failure rolls back cleanly. + */ +export async function restoreBackup(text: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new BackupError('That file is not valid JSON.'); + } + const data = (parsed as { data?: Record })?.data; + if (!data || typeof data !== 'object') throw new BackupError('That file is not a TTRPG Manager backup.'); + + await db.transaction('rw', TABLES.map((t) => db.table(t)), async () => { + for (const t of TABLES) { + await db.table(t).clear(); + const rows = data[t]; + if (Array.isArray(rows) && rows.length) await db.table(t).bulkAdd(rows); + } + }); +} + +/** Wipe all data (danger zone). */ +export async function clearAllData(): Promise { + await db.transaction('rw', TABLES.map((t) => db.table(t)), async () => { + for (const t of TABLES) await db.table(t).clear(); + }); +} diff --git a/src/lib/sample.ts b/src/lib/sample.ts new file mode 100644 index 0000000..d42f280 --- /dev/null +++ b/src/lib/sample.ts @@ -0,0 +1,49 @@ +import { + campaignsRepo, + charactersRepo, + encountersRepo, + notesRepo, + npcsRepo, + questsRepo, +} from '@/lib/db/repositories'; +import { addCombatant } from '@/lib/combat/engine'; +import { newId } from '@/lib/ids'; + +/** Seed a small demo D&D 5e campaign so new users have something to explore. */ +export async function seedSampleCampaign(): Promise { + const campaign = await campaignsRepo.create({ + name: 'Sample: Lost Mine', + system: '5e', + description: 'A demo campaign to explore the app. Safe to delete.', + }); + + const hero = await charactersRepo.create(campaign.id, { + system: '5e', name: 'Lia the Brave', kind: 'pc', ancestry: 'Half-Elf', className: 'Fighter', level: 3, + }); + await charactersRepo.update(hero.id, { + abilities: { str: 16, dex: 14, con: 15, int: 10, wis: 12, cha: 8 }, + hp: { current: 28, max: 28, temp: 0 }, + skillRanks: { athletics: 'trained', perception: 'trained' }, + saveRanks: { str: 'trained', con: 'trained' }, + }); + await charactersRepo.create(campaign.id, { + system: '5e', name: 'Pip Underbough', kind: 'pc', ancestry: 'Halfling', className: 'Rogue', level: 3, + }); + + await npcsRepo.create(campaign.id, 'Sildar Hallwinter'); + await questsRepo.create(campaign.id, 'Find Gundren Rockseeker'); + await notesRepo.create(campaign.id, 'Phandalin'); + + const enc = await encountersRepo.create(campaign.id, 'Goblin Ambush'); + const withGoblins = [1, 2, 3].reduce( + (e, _) => + addCombatant(e, { + id: newId(), name: 'Goblin', kind: 'monster', initiative: 12, initBonus: 2, ac: 15, + hp: { current: 7, max: 7, temp: 0 }, conditions: [], notes: '', cr: 0.25, + }), + enc, + ); + await encountersRepo.save(withGoblins); + + return campaign.id; +} diff --git a/src/router.tsx b/src/router.tsx index 065a0ba..2298cb5 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -14,6 +14,7 @@ import { CalendarPage } from '@/features/world/CalendarPage'; import { MapsPage } from '@/features/world/MapsPage'; import { PlayerViewPage } from '@/features/player/PlayerViewPage'; import { HomebrewPage } from '@/features/world/HomebrewPage'; +import { SettingsPage } from '@/features/settings/SettingsPage'; const rootRoute = createRootRoute({ component: RootLayout }); @@ -35,6 +36,7 @@ const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/cal const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage }); const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage }); const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage }); +const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage }); const routeTree = rootRoute.addChildren([ indexRoute, @@ -51,6 +53,7 @@ const routeTree = rootRoute.addChildren([ mapsRoute, playRoute, homebrewRoute, + settingsRoute, ]); export const router = createRouter({ routeTree, defaultPreload: 'intent' }); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index d92dbd4..3166f2e 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/player/PlayerViewPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./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/lib/sync/index.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/CommandPalette.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/player/PlayerViewPage.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.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/backup.test.ts","./src/lib/io/backup.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/lib/sync/index.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