diff --git a/e2e/interactive-dice.spec.ts b/e2e/interactive-dice.spec.ts new file mode 100644 index 0000000..c5de66d --- /dev/null +++ b/e2e/interactive-dice.spec.ts @@ -0,0 +1,38 @@ +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('rolling a skill from the sheet shows the roll tray', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Dice Camp'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: 'Characters' }).click(); + await page.getByRole('button', { name: '+ New character' }).first().click(); + await page.locator('input[data-autofocus]').fill('Roller'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: /Roller/ }).click(); + + // Click the Acrobatics modifier to roll it + await page.getByTitle('Roll Acrobatics (DEX)').click(); + // The shared roll tray appears (unique dismiss control) showing the roll + await expect(page.getByRole('button', { name: 'Dismiss roll' })).toBeVisible(); + await expect(page.locator('text=/= \\d+/').first()).toBeVisible(); +}); + +test('dice page: save and use a macro', async ({ page }) => { + await page.getByRole('link', { name: 'Dice' }).click(); + await page.getByLabel('Dice expression').fill('2d6+3'); + await page.getByRole('button', { name: /Save/ }).click(); + // Macro chip appears and rolls when clicked + const chip = page.getByRole('button', { name: '2d6+3', exact: true }); + await expect(chip).toBeVisible(); + await chip.click(); + await expect(page.locator('text=/= \\d+/').first()).toBeVisible(); +}); diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 0de2f1c..207c4f3 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -3,6 +3,7 @@ import { useUiStore } from '@/stores/uiStore'; import { useActiveCampaign, useCampaigns } from '@/features/campaigns/hooks'; import { cn } from '@/lib/cn'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { RollTray } from '@/components/ui/RollTray'; import { Select } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; @@ -86,6 +87,8 @@ export function RootLayout() { + + ); } diff --git a/src/components/ui/RollTray.tsx b/src/components/ui/RollTray.tsx new file mode 100644 index 0000000..e8f5611 --- /dev/null +++ b/src/components/ui/RollTray.tsx @@ -0,0 +1,34 @@ +import { useRollStore } from '@/stores/rollStore'; +import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check'; +import { cn } from '@/lib/cn'; + +/** Floating result of the most recent roll, shared across the whole app. */ +export function RollTray() { + const last = useRollStore((s) => s.last); + const dismiss = useRollStore((s) => s.dismiss); + if (!last) return null; + + return ( +
+
+
+
+ {last.label &&
{last.label}
} + {last.degree && ( +
+ {DEGREE_LABEL[last.degree]} + {last.dc !== undefined ? ` (DC ${last.dc})` : ''} +
+ )} +
+ +
+
{last.result.total}
+
{last.result.breakdown}
+
+
+ ); +} diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index d9239bd..f1257b4 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; import { Link } from '@tanstack/react-router'; import type { Character } from '@/lib/schemas'; -import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules'; +import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules'; import { getSystem, ABILITY_ABBR } from '@/lib/rules'; import { charactersRepo } from '@/lib/db/repositories'; import { PF2E_SAVES } from '@/lib/rules/pf2e/skills'; import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; +import { rollCheck } from '@/lib/useRoll'; import { Page } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; @@ -151,6 +152,7 @@ export function CharacterSheet({ character }: { character: Character }) { rank={(c.saveRanks[s.ability] as ProficiencyRank) ?? 'untrained'} ranks={ranks} onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [s.ability]: rank } })} + system={c.system} /> )) : ABILITIES.map((a) => ( @@ -161,6 +163,7 @@ export function CharacterSheet({ character }: { character: Character }) { rank={(c.saveRanks[a] as ProficiencyRank) ?? 'untrained'} ranks={ranks} onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [a]: rank } })} + system={c.system} /> ))} @@ -178,6 +181,7 @@ export function CharacterSheet({ character }: { character: Character }) { rank={(c.skillRanks[s.key] as ProficiencyRank) ?? 'untrained'} ranks={ranks} onRank={(rank) => update({ skillRanks: { ...c.skillRanks, [s.key]: rank } })} + system={c.system} /> ))} @@ -256,16 +260,24 @@ function RankRow({ rank, ranks, onRank, + system, }: { label: string; modifier: number; rank: ProficiencyRank; ranks: ProficiencyRank[]; onRank: (r: ProficiencyRank) => void; + system: SystemId; }) { return (
- {formatModifier(modifier)} + {label} - - to hit {formatModifier(result.toHit)} - · dmg {result.damage}{a.damageType ? ` ${a.damageType}` : ''} + + + to hit · + diff --git a/src/features/dice/DicePage.tsx b/src/features/dice/DicePage.tsx index 4cacc95..aa391e9 100644 --- a/src/features/dice/DicePage.tsx +++ b/src/features/dice/DicePage.tsx @@ -4,6 +4,7 @@ import { rollDice, DiceParseError, type RollResult } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { diceRepo } from '@/lib/db/repositories'; import { useUiStore } from '@/stores/uiStore'; +import { useMacroStore } from '@/stores/macroStore'; import { Page, PageHeader } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -20,6 +21,11 @@ export function DicePage() { const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []); + const macroKey = campaignId ?? ''; + const macros = useMacroStore((s) => s.byCampaign[macroKey]) ?? []; + const addMacro = useMacroStore((s) => s.add); + const removeMacro = useMacroStore((s) => s.remove); + const roll = async (expression: string, label = '') => { try { const result = rollDice(expression, createRng()); @@ -83,6 +89,42 @@ export function DicePage() {
+ {/* Saved rolls (macros) */} +
+
+ Saved rolls + +
+ {macros.length > 0 && ( +
+ {macros.map((m) => ( + + + + + ))} +
+ )} +
+ {error &&

{error}

} {last && ( diff --git a/src/lib/dice/check.test.ts b/src/lib/dice/check.test.ts new file mode 100644 index 0000000..ce634f2 --- /dev/null +++ b/src/lib/dice/check.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { degreeOfSuccess } from './check'; + +describe('degreeOfSuccess — pf2e', () => { + it('meets DC = success, +10 = critical success', () => { + expect(degreeOfSuccess({ total: 15, dc: 15, system: 'pf2e' })).toBe('success'); + expect(degreeOfSuccess({ total: 25, dc: 15, system: 'pf2e' })).toBe('critical-success'); + }); + it('below DC = failure, -10 = critical failure', () => { + expect(degreeOfSuccess({ total: 14, dc: 15, system: 'pf2e' })).toBe('failure'); + expect(degreeOfSuccess({ total: 5, dc: 15, system: 'pf2e' })).toBe('critical-failure'); + }); + it('natural 20 bumps the degree up one step', () => { + expect(degreeOfSuccess({ total: 14, dc: 15, system: 'pf2e', natural: 20 })).toBe('success'); // failure -> success + expect(degreeOfSuccess({ total: 16, dc: 15, system: 'pf2e', natural: 20 })).toBe('critical-success'); + }); + it('natural 1 drops the degree one step', () => { + expect(degreeOfSuccess({ total: 16, dc: 15, system: 'pf2e', natural: 1 })).toBe('failure'); // success -> failure + }); +}); + +describe('degreeOfSuccess — 5e', () => { + it('success/failure vs DC', () => { + expect(degreeOfSuccess({ total: 15, dc: 15, system: '5e' })).toBe('success'); + expect(degreeOfSuccess({ total: 14, dc: 15, system: '5e' })).toBe('failure'); + }); + it('nat 20/1 are critical regardless of DC', () => { + expect(degreeOfSuccess({ total: 3, dc: 30, system: '5e', natural: 20 })).toBe('critical-success'); + expect(degreeOfSuccess({ total: 40, dc: 5, system: '5e', natural: 1 })).toBe('critical-failure'); + }); +}); diff --git a/src/lib/dice/check.ts b/src/lib/dice/check.ts new file mode 100644 index 0000000..5f75d2f --- /dev/null +++ b/src/lib/dice/check.ts @@ -0,0 +1,44 @@ +import type { SystemId } from '@/lib/rules'; + +export type Degree = 'critical-success' | 'success' | 'failure' | 'critical-failure'; + +export const DEGREE_LABEL: Record = { + 'critical-success': 'Critical Success', + success: 'Success', + failure: 'Failure', + 'critical-failure': 'Critical Failure', +}; + +export const DEGREE_COLOR: Record = { + 'critical-success': 'text-success', + success: 'text-success', + failure: 'text-warning', + 'critical-failure': 'text-danger', +}; + +/** + * Evaluate a d20 result against a DC. + * - PF2e: ≥DC+10 crit success, ≥DC success, ≤DC-10 crit failure; a natural 20 + * improves the degree one step and a natural 1 worsens it one step. + * - 5e: success/failure vs DC, with natural 20/1 mapped to critical results. + */ +export function degreeOfSuccess(input: { + total: number; + dc: number; + system: SystemId; + /** the natural face of the d20, if known */ + natural?: number | undefined; +}): Degree { + const { total, dc, system, natural } = input; + if (system === 'pf2e') { + let step = total >= dc + 10 ? 2 : total >= dc ? 1 : total <= dc - 10 ? -1 : 0; + if (natural === 20) step += 1; + else if (natural === 1) step -= 1; + step = Math.max(-1, Math.min(2, step)); + return step === 2 ? 'critical-success' : step === 1 ? 'success' : step === 0 ? 'failure' : 'critical-failure'; + } + // 5e + if (natural === 20) return 'critical-success'; + if (natural === 1) return 'critical-failure'; + return total >= dc ? 'success' : 'failure'; +} diff --git a/src/lib/dice/notation.test.ts b/src/lib/dice/notation.test.ts index 94a73db..2686008 100644 --- a/src/lib/dice/notation.test.ts +++ b/src/lib/dice/notation.test.ts @@ -64,4 +64,33 @@ describe('rollDice', () => { const r = rollDice('1d6+5', createRng(1)); expect(r.total).toBe(r.terms[0]!.dice[0]!.value + 5); }); + + it('exploding dice add extra dice on a max roll', () => { + // d2 explodes very often; verify at least one expansion occurs and total = sum + let sawExplosion = false; + for (let i = 0; i < 50; i++) { + const r = rollDice('1d2!', createRng(i)); + if (r.terms[0]!.dice.length > 1) { + sawExplosion = true; + // all but the last die must be the max value (2) + const dice = r.terms[0]!.dice; + for (let j = 0; j < dice.length - 1; j++) expect(dice[j]!.value).toBe(2); + } + expect(r.total).toBe(r.terms[0]!.dice.reduce((s, d) => s + d.value, 0)); + } + expect(sawExplosion).toBe(true); + }); + + it('reroll-below rerolls low dice (so results trend higher)', () => { + // d6r5 keeps rerolling 1-5 once; values can still be low but parse + bound hold + for (let i = 0; i < 50; i++) { + const r = rollDice('1d6r2', createRng(i)); + expect(r.total).toBeGreaterThanOrEqual(1); + expect(r.total).toBeLessThanOrEqual(6); + } + }); + + it.each(['1d6r6', '1d20r20'])('rejects reroll threshold >= sides (%s)', (expr) => { + expect(() => parseDice(expr)).toThrow(DiceParseError); + }); }); diff --git a/src/lib/dice/notation.ts b/src/lib/dice/notation.ts index 1bcec5c..0682e9c 100644 --- a/src/lib/dice/notation.ts +++ b/src/lib/dice/notation.ts @@ -1,12 +1,16 @@ import type { Rng } from '@/lib/rng'; import { createRng } from '@/lib/rng'; -/** A single die-group term, e.g. `4d6kh3`. */ +/** A single die-group term, e.g. `4d6kh3`, `3d6!`, `2d20r1`. */ export interface DiceGroup { kind: 'dice'; sign: 1 | -1; count: number; sides: number; + /** explode: roll an extra die each time the max is rolled */ + explode?: boolean; + /** reroll (once) any die whose value is <= this threshold */ + rerollBelow?: number; /** keep/drop highest/lowest, e.g. { op: 'kh', n: 3 } */ keep?: { op: 'kh' | 'kl' | 'dh' | 'dl'; n: number }; } @@ -48,7 +52,8 @@ const MAX_DICE = 1000; const MAX_SIDES = 1000; const TERM_RE = - /^(\d*)d(\d+)(?:(kh|kl|dh|dl)(\d+))?$/i; + /^(\d*)d(\d+)(!)?(?:r(\d+))?(?:(kh|kl|dh|dl)(\d+))?$/i; +const EXPLODE_CAP = 100; /** * Parse a dice expression like `2d6+1d4+3` or `4d6kh3` into terms. @@ -90,13 +95,26 @@ export function parseDice(expression: string): DiceTerm[] { if (count < 1 || count > MAX_DICE) throw new DiceParseError(`Dice count out of range in "${raw}"`); if (sides < 2 || sides > MAX_SIDES) throw new DiceParseError(`Die size out of range in "${raw}"`); - let keep: DiceGroup['keep']; - if (m[3]) { - const n = Number(m[4]); - if (n < 1 || n > count) throw new DiceParseError(`keep/drop count out of range in "${raw}"`); - keep = { op: m[3].toLowerCase() as 'kh' | 'kl' | 'dh' | 'dl', n }; + const explode = m[3] === '!'; + let rerollBelow: number | undefined; + if (m[4] !== undefined) { + const r = Number(m[4]); + if (r < 1 || r >= sides) throw new DiceParseError(`reroll threshold out of range in "${raw}"`); + rerollBelow = r; } - terms.push({ kind: 'dice', sign, count, sides, ...(keep ? { keep } : {}) }); + + let keep: DiceGroup['keep']; + if (m[5]) { + const n = Number(m[6]); + if (n < 1 || n > count) throw new DiceParseError(`keep/drop count out of range in "${raw}"`); + keep = { op: m[5].toLowerCase() as 'kh' | 'kl' | 'dh' | 'dl', n }; + } + terms.push({ + kind: 'dice', sign, count, sides, + ...(explode ? { explode } : {}), + ...(rerollBelow !== undefined ? { rerollBelow } : {}), + ...(keep ? { keep } : {}), + }); } return terms; } @@ -129,7 +147,22 @@ export function rollDice(expression: string, rng: Rng = createRng()): RollResult continue; } const values: number[] = []; - for (let i = 0; i < term.count; i++) values.push(rng.int(1, term.sides)); + for (let i = 0; i < term.count; i++) { + let v = rng.int(1, term.sides); + // reroll once if at/under the threshold + if (term.rerollBelow !== undefined && v <= term.rerollBelow) v = rng.int(1, term.sides); + values.push(v); + // exploding: each max roll adds another die + if (term.explode) { + let last = v; + let guard = 0; + while (last === term.sides && guard < EXPLODE_CAP) { + last = rng.int(1, term.sides); + values.push(last); + guard++; + } + } + } const kept = applyKeep(values, term.keep); const dice: RolledDie[] = values.map((value, i) => ({ sides: term.sides, @@ -154,7 +187,9 @@ function formatTerm(tr: TermResult): string { const { term } = tr; if (term.kind === 'const') return String(term.value); const keep = term.keep ? `${term.keep.op}${term.keep.n}` : ''; - const label = `${term.count}d${term.sides}${keep}`; + const explode = term.explode ? '!' : ''; + const reroll = term.rerollBelow !== undefined ? `r${term.rerollBelow}` : ''; + const label = `${term.count}d${term.sides}${explode}${reroll}${keep}`; const dice = tr.dice .map((d) => (d.kept ? String(d.value) : `~~${d.value}~~`)) .join(', '); diff --git a/src/lib/useRoll.ts b/src/lib/useRoll.ts new file mode 100644 index 0000000..c7616af --- /dev/null +++ b/src/lib/useRoll.ts @@ -0,0 +1,69 @@ +import { rollDice } from '@/lib/dice/notation'; +import { createRng } from '@/lib/rng'; +import { degreeOfSuccess, type Degree } from '@/lib/dice/check'; +import { diceRepo } from '@/lib/db/repositories'; +import { useRollStore } from '@/stores/rollStore'; +import { useUiStore } from '@/stores/uiStore'; +import type { SystemId } from '@/lib/rules'; + +export interface RollOptions { + expression: string; + label?: string; + /** roll against a DC to compute a success degree */ + dc?: number; + system?: SystemId; +} + +/** Find the natural face of the (kept) d20, for crit/degree handling. */ +function naturalD20(result: ReturnType): number | undefined { + for (const t of result.terms) { + if (t.term.kind === 'dice' && t.term.sides === 20) { + const kept = t.dice.find((d) => d.kept); + if (kept) return kept.value; + } + } + return undefined; +} + +/** + * Roll an expression, show it in the global tray, and persist it to history. + * Returns the result + degree. Used by the dice page, sheets, and statblocks. + */ +export function rollAndShow(opts: RollOptions): { total: number; degree?: Degree } { + const result = rollDice(opts.expression, createRng()); + const campaignId = useUiStore.getState().activeCampaignId ?? undefined; + const label = opts.label ?? ''; + + let degree: Degree | undefined; + if (opts.dc !== undefined && opts.system) { + degree = degreeOfSuccess({ total: result.total, dc: opts.dc, system: opts.system, natural: naturalD20(result) }); + } + + useRollStore.getState().push({ + label, + result, + ...(opts.dc !== undefined ? { dc: opts.dc } : {}), + ...(degree ? { degree } : {}), + }); + + void diceRepo.add({ + ...(campaignId ? { campaignId } : {}), + label, + expression: opts.expression, + total: result.total, + breakdown: result.breakdown, + }); + + return { total: result.total, ...(degree ? { degree } : {}) }; +} + +/** Convenience: roll d20 + modifier, formatting the expression. */ +export function rollCheck(modifier: number, label: string, opts?: { dc?: number; system?: SystemId }): void { + const sign = modifier >= 0 ? `+${modifier}` : `${modifier}`; + rollAndShow({ + expression: `1d20${modifier === 0 ? '' : sign}`, + label, + ...(opts?.dc !== undefined ? { dc: opts.dc } : {}), + ...(opts?.system ? { system: opts.system } : {}), + }); +} diff --git a/src/stores/macroStore.ts b/src/stores/macroStore.ts new file mode 100644 index 0000000..5ffb0be --- /dev/null +++ b/src/stores/macroStore.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { newId } from '@/lib/ids'; + +export interface Macro { + id: string; + label: string; + expression: string; +} + +interface MacroState { + /** macros keyed by campaign id ('' for the no-campaign scratch list) */ + byCampaign: Record; + add: (campaignId: string, label: string, expression: string) => void; + remove: (campaignId: string, id: string) => void; +} + +export const useMacroStore = create()( + persist( + (set) => ({ + byCampaign: {}, + add: (campaignId, label, expression) => + set((s) => ({ + byCampaign: { + ...s.byCampaign, + [campaignId]: [...(s.byCampaign[campaignId] ?? []), { id: newId(), label, expression }], + }, + })), + remove: (campaignId, id) => + set((s) => ({ + byCampaign: { + ...s.byCampaign, + [campaignId]: (s.byCampaign[campaignId] ?? []).filter((m) => m.id !== id), + }, + })), + }), + { name: 'ttrpg-macros' }, + ), +); diff --git a/src/stores/rollStore.ts b/src/stores/rollStore.ts new file mode 100644 index 0000000..dc7a1f2 --- /dev/null +++ b/src/stores/rollStore.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; +import type { RollResult } from '@/lib/dice/notation'; +import type { Degree } from '@/lib/dice/check'; + +export interface TrayRoll { + /** increments each roll, used as an animation key */ + seq: number; + label: string; + result: RollResult; + dc?: number; + degree?: Degree; +} + +interface RollState { + last: TrayRoll | null; + push: (roll: Omit) => void; + dismiss: () => void; +} + +let seq = 0; +export const useRollStore = create((set) => ({ + last: null, + push: (roll) => set({ last: { ...roll, seq: ++seq } }), + dismiss: () => set({ last: null }), +})); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 492986c..66e1bcb 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/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/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/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/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/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"} \ No newline at end of file