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