@@ -124,13 +150,19 @@ export function SpellcastingSection({ c, update }: SectionProps) {
{spellsByLevel.map((s) => (
{s.level === 0 ? 'Cantrip' : `Lv ${s.level}`}
- {s.name}
+
+ {s.name}
+ {s.concentration && }
+
{s.level > 0 && (
)}
+
))}
diff --git a/src/features/player/MyCharacterPanel.tsx b/src/features/player/MyCharacterPanel.tsx
index 75aeffc..78ef941 100644
--- a/src/features/player/MyCharacterPanel.tsx
+++ b/src/features/player/MyCharacterPanel.tsx
@@ -1,5 +1,7 @@
import { useState } from 'react';
+import { Sparkles, BrainCircuit } from 'lucide-react';
import type { Character, Defenses } from '@/lib/schemas';
+import { castSpell, dropConcentration } from '@/lib/mechanics';
import { ActionGuide } from './ActionGuide';
import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
@@ -28,6 +30,14 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
Lv {c.level} {c.className} · {c.ancestry}
{/* HP */}
@@ -94,6 +104,27 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
)}
+ {/* Spells — cast enforces slot consumption + concentration */}
+ {c.spellcasting.spells.length > 0 && (
+
+
Spells
+
+ {[...c.spellcasting.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name)).map((s) => (
+
+ {s.level === 0 ? 'Cant' : `Lv ${s.level}`}
+
+ {s.name}
+ {s.concentration && }
+
+
+
+ ))}
+
+
+ )}
+
{/* Resources */}
{c.resources.length > 0 && (
diff --git a/src/lib/mechanics/cast.ts b/src/lib/mechanics/cast.ts
new file mode 100644
index 0000000..79a9c40
--- /dev/null
+++ b/src/lib/mechanics/cast.ts
@@ -0,0 +1,61 @@
+import type { Character, Spellcasting } from '@/lib/schemas/character';
+import type { EnforceResult } from './result';
+
+/** Consume one spell slot at `level` (regular slots first, then matching pact slot). */
+function consumeSlot(sc: Spellcasting, level: number): { spellcasting: Spellcasting; usedLevel: number } | null {
+ const slot = sc.slots.find((s) => s.level === level);
+ if (slot && slot.current > 0) {
+ return {
+ spellcasting: { ...sc, slots: sc.slots.map((s) => (s.level === level ? { ...s, current: s.current - 1 } : s)) },
+ usedLevel: level,
+ };
+ }
+ // Warlock pact magic — a single pooled slot at its own level.
+ if (sc.pact && sc.pact.level === level && sc.pact.current > 0) {
+ return {
+ spellcasting: { ...sc, pact: { ...sc.pact, current: sc.pact.current - 1 } },
+ usedLevel: level,
+ };
+ }
+ return null;
+}
+
+/**
+ * Cast a spell the character knows, at a chosen slot level (≥ the spell's own
+ * level, to allow upcasting). Pure: decrements a slot, sets/replaces
+ * concentration, and returns the patch + log. Cantrips cost no slot.
+ *
+ * Fails (no mutation) when no slot of the requested level is available — the UI
+ * surfaces `reason` and lets the player pick a different level.
+ */
+export function castSpell(c: Character, spellId: string, atLevel?: number): EnforceResult {
+ const spell = c.spellcasting.spells.find((s) => s.id === spellId);
+ if (!spell) return { ok: false, reason: 'That spell is not on this character.' };
+
+ const log: string[] = [];
+ const patch: Partial = {};
+
+ if (spell.level > 0) {
+ const useLevel = Math.max(atLevel ?? spell.level, spell.level);
+ const consumed = consumeSlot(c.spellcasting, useLevel);
+ if (!consumed) return { ok: false, reason: `No level-${useLevel} slot available for ${spell.name}.` };
+ patch.spellcasting = consumed.spellcasting;
+ log.push(`Cast ${spell.name} using a level-${consumed.usedLevel} slot.`);
+ } else {
+ log.push(`Cast ${spell.name} (cantrip).`);
+ }
+
+ if (spell.concentration) {
+ if (c.concentration && c.concentration.spellId !== spell.id) {
+ log.push(`Concentration on ${c.concentration.spellName} ends.`);
+ }
+ patch.concentration = { spellId: spell.id, spellName: spell.name };
+ }
+
+ return { ok: true, patch, log };
+}
+
+/** Clear active concentration (e.g. the player drops it, or it breaks). */
+export function dropConcentration(c: Character): Partial {
+ return c.concentration ? { concentration: null } : {};
+}
diff --git a/src/lib/mechanics/deathSaves.ts b/src/lib/mechanics/deathSaves.ts
new file mode 100644
index 0000000..3afdd6f
--- /dev/null
+++ b/src/lib/mechanics/deathSaves.ts
@@ -0,0 +1,48 @@
+import type { Character } from '@/lib/schemas/character';
+
+export interface DeathSaveOutcome {
+ patch: Partial;
+ /** 'pending' = still rolling; 'stable' = 3 successes; 'dead' = 3 failures; 'revived' = nat 20 */
+ outcome: 'pending' | 'stable' | 'dead' | 'revived';
+ log: string;
+}
+
+/**
+ * Apply one 5e death saving throw given a raw d20 face. Pure:
+ * - nat 20 → regain 1 HP, clear the death-save clock (back in the fight)
+ * - nat 1 → two failures
+ * - ≥10 → one success (3 → stable)
+ * - <10 → one failure (3 → dead)
+ */
+export function rollDeathSave(c: Character, d20: number): DeathSaveOutcome {
+ const ds = c.defenses.deathSaves;
+
+ if (d20 === 20) {
+ return {
+ patch: {
+ hp: { ...c.hp, current: 1 },
+ defenses: { ...c.defenses, deathSaves: { successes: 0, failures: 0 } },
+ },
+ outcome: 'revived',
+ log: `${c.name} rolls a natural 20 and regains 1 HP!`,
+ };
+ }
+
+ let successes = ds.successes;
+ let failures = ds.failures;
+ if (d20 === 1) failures = Math.min(3, failures + 2);
+ else if (d20 >= 10) successes = Math.min(3, successes + 1);
+ else failures = Math.min(3, failures + 1);
+
+ const patch: Partial = { defenses: { ...c.defenses, deathSaves: { successes, failures } } };
+
+ if (failures >= 3) return { patch, outcome: 'dead', log: `${c.name} fails a third death save and dies.` };
+ if (successes >= 3) return { patch, outcome: 'stable', log: `${c.name} stabilizes.` };
+ return {
+ patch,
+ outcome: 'pending',
+ log: d20 >= 10
+ ? `${c.name} succeeds a death save (${successes}/3).`
+ : `${c.name} fails a death save (${failures}/3).`,
+ };
+}
diff --git a/src/lib/mechanics/enforce.test.ts b/src/lib/mechanics/enforce.test.ts
new file mode 100644
index 0000000..6d53bad
--- /dev/null
+++ b/src/lib/mechanics/enforce.test.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect } from 'vitest';
+import { characterDefaults, newSpellEntry, type Character } from '@/lib/schemas';
+import { castSpell, dropConcentration } from './cast';
+import { spendResource, regainResource } from './resources';
+import { rollDeathSave } from './deathSaves';
+
+function mk(over: Partial = {}): Character {
+ return {
+ id: 'pc', campaignId: 'c', system: '5e', kind: 'pc', name: 'Mage', ancestry: '', className: 'Wizard', level: 5,
+ abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
+ hp: { current: 0, max: 24, temp: 0 }, speed: 30, armorBonus: 0,
+ skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
+ ...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
+ ...over,
+ };
+}
+
+describe('castSpell', () => {
+ const fireball = newSpellEntry({ id: 's1', name: 'Fireball', level: 3, save: { ability: 'dex', basis: 'half' } });
+ const haste = newSpellEntry({ id: 's2', name: 'Haste', level: 3, concentration: true });
+ const bless = newSpellEntry({ id: 's3', name: 'Bless', level: 1, concentration: true });
+ const firebolt = newSpellEntry({ id: 's4', name: 'Fire Bolt', level: 0 });
+
+ const base = mk({
+ spellcasting: { slots: [{ level: 1, max: 4, current: 4 }, { level: 3, max: 3, current: 2 }], spells: [fireball, haste, bless, firebolt] },
+ });
+
+ it('decrements the slot of the spell level', () => {
+ const r = castSpell(base, 's1');
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.patch.spellcasting!.slots.find((s) => s.level === 3)!.current).toBe(1);
+ expect(r.log[0]).toMatch(/level-3 slot/);
+ });
+
+ it('upcasts: casting a level-3 spell with a higher slot consumes that slot', () => {
+ const withHigh = mk({ spellcasting: { slots: [{ level: 3, max: 1, current: 0 }, { level: 5, max: 1, current: 1 }], spells: [fireball] } });
+ const r = castSpell(withHigh, 's1', 5);
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.patch.spellcasting!.slots.find((s) => s.level === 5)!.current).toBe(0);
+ });
+
+ it('fails when no slot of the requested level remains', () => {
+ const empty = mk({ spellcasting: { slots: [{ level: 3, max: 2, current: 0 }], spells: [fireball] } });
+ const r = castSpell(empty, 's1');
+ expect(r.ok).toBe(false);
+ if (r.ok) return;
+ expect(r.reason).toMatch(/No level-3 slot/);
+ });
+
+ it('cantrips cost no slot', () => {
+ const r = castSpell(base, 's4');
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.patch.spellcasting).toBeUndefined();
+ expect(r.log[0]).toMatch(/cantrip/);
+ });
+
+ it('sets concentration and replaces a prior concentration spell', () => {
+ const concentrating = mk({
+ concentration: { spellId: 's3', spellName: 'Bless' },
+ spellcasting: { slots: [{ level: 3, max: 3, current: 2 }], spells: [haste, bless] },
+ });
+ const r = castSpell(concentrating, 's2');
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.patch.concentration).toEqual({ spellId: 's2', spellName: 'Haste' });
+ expect(r.log.some((l) => /Concentration on Bless ends/.test(l))).toBe(true);
+ });
+
+ it('consumes a warlock pact slot when no regular slot matches', () => {
+ const warlock = mk({ spellcasting: { slots: [], pact: { level: 3, max: 2, current: 2 }, spells: [haste] } });
+ const r = castSpell(warlock, 's2', 3);
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.patch.spellcasting!.pact!.current).toBe(1);
+ });
+
+ it('dropConcentration clears the slot', () => {
+ const concentrating = mk({ concentration: { spellId: 's2', spellName: 'Haste' } });
+ expect(dropConcentration(concentrating)).toEqual({ concentration: null });
+ expect(dropConcentration(mk())).toEqual({});
+ });
+});
+
+describe('spendResource / regainResource', () => {
+ const base = mk({ resources: [{ id: 'ki', name: 'Ki', current: 2, max: 5, recovery: 'short' }] });
+
+ it('spends and fails when too low', () => {
+ const ok = spendResource(base, 'ki', 2);
+ expect(ok.ok).toBe(true);
+ if (ok.ok) expect(ok.patch.resources![0]!.current).toBe(0);
+ const bad = spendResource(base, 'ki', 3);
+ expect(bad.ok).toBe(false);
+ });
+
+ it('regains up to max', () => {
+ const r = regainResource(base, 'ki', 10);
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.patch.resources![0]!.current).toBe(5);
+ });
+});
+
+describe('rollDeathSave', () => {
+ it('nat 20 revives at 1 HP and clears the clock', () => {
+ const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 1, failures: 2 } } }), 20);
+ expect(r.outcome).toBe('revived');
+ expect(r.patch.hp!.current).toBe(1);
+ expect(r.patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
+ });
+
+ it('nat 1 adds two failures → dead at three', () => {
+ const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 0, failures: 1 } } }), 1);
+ expect(r.outcome).toBe('dead');
+ });
+
+ it('≥10 succeeds; third success stabilizes', () => {
+ const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 2, failures: 0 } } }), 12);
+ expect(r.outcome).toBe('stable');
+ });
+
+ it('<10 is a single failure, still pending', () => {
+ const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 0, failures: 0 } } }), 7);
+ expect(r.outcome).toBe('pending');
+ expect(r.patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 1 });
+ });
+});
diff --git a/src/lib/mechanics/index.ts b/src/lib/mechanics/index.ts
index be564d9..8b1218e 100644
--- a/src/lib/mechanics/index.ts
+++ b/src/lib/mechanics/index.ts
@@ -11,9 +11,13 @@ import { normalizeMonsterDefenses } from './normalize/monsterDefenses';
import type { ArmorMechanics, DamageDefenses, SpellMechanics } from './types';
export * from './types';
+export type { EnforceResult } from './result';
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
export { normalizeArmor5e } from './normalize/armor';
export { normalizeMonsterDefenses } from './normalize/monsterDefenses';
+export { castSpell, dropConcentration } from './cast';
+export { spendResource, regainResource } from './resources';
+export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
let spellMechCache: Map | null = null;
let armorMechCache: Map | null = null;
diff --git a/src/lib/mechanics/resources.ts b/src/lib/mechanics/resources.ts
new file mode 100644
index 0000000..c7bf8c7
--- /dev/null
+++ b/src/lib/mechanics/resources.ts
@@ -0,0 +1,29 @@
+import type { Character } from '@/lib/schemas/character';
+import type { EnforceResult } from './result';
+
+/**
+ * Spend `n` of a tracked resource pool (ki, rage, channel divinity, sorcery
+ * points, …). Pure; fails without mutation when the pool is too low.
+ */
+export function spendResource(c: Character, resourceId: string, n = 1): EnforceResult {
+ const r = c.resources.find((x) => x.id === resourceId);
+ if (!r) return { ok: false, reason: 'That resource does not exist.' };
+ if (n <= 0) return { ok: false, reason: 'Amount must be positive.' };
+ if (r.current < n) return { ok: false, reason: `Not enough ${r.name} (have ${r.current}, need ${n}).` };
+ return {
+ ok: true,
+ patch: { resources: c.resources.map((x) => (x.id === resourceId ? { ...x, current: x.current - n } : x)) },
+ log: [`Spent ${n} ${r.name}.`],
+ };
+}
+
+/** Restore `n` to a resource pool, capped at its max. */
+export function regainResource(c: Character, resourceId: string, n = 1): EnforceResult {
+ const r = c.resources.find((x) => x.id === resourceId);
+ if (!r) return { ok: false, reason: 'That resource does not exist.' };
+ return {
+ ok: true,
+ patch: { resources: c.resources.map((x) => (x.id === resourceId ? { ...x, current: Math.min(x.max, x.current + n) } : x)) },
+ log: [`Regained ${n} ${r.name}.`],
+ };
+}
diff --git a/src/lib/mechanics/result.ts b/src/lib/mechanics/result.ts
new file mode 100644
index 0000000..ed5196b
--- /dev/null
+++ b/src/lib/mechanics/result.ts
@@ -0,0 +1,10 @@
+import type { Character } from '@/lib/schemas/character';
+
+/**
+ * The result of an enforcement action. Pure functions return a `Partial`
+ * patch the caller persists (mirroring `applyRest`), plus log lines for the combat
+ * feed — or a failure with a human-readable reason the UI surfaces.
+ */
+export type EnforceResult =
+ | { ok: true; patch: Partial; log: string[] }
+ | { ok: false; reason: string };
diff --git a/src/lib/rules/depth.test.ts b/src/lib/rules/depth.test.ts
index 810febf..3fcef46 100644
--- a/src/lib/rules/depth.test.ts
+++ b/src/lib/rules/depth.test.ts
@@ -98,4 +98,12 @@ describe('applyRest', () => {
expect(patch.defenses!.exhaustion).toBe(1);
expect(patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
});
+
+ it('any rest ends ongoing concentration', () => {
+ const c = { ...makeCharacter(), concentration: { spellId: 's', spellName: 'Bless' } };
+ const short = dnd5e.restOptions.find((o) => o.id === 'short')!;
+ expect(applyRest(c, short).concentration).toBeNull();
+ // no concentration → no concentration key in the patch
+ expect('concentration' in applyRest(makeCharacter(), short)).toBe(false);
+ });
});
diff --git a/src/lib/rules/rest.ts b/src/lib/rules/rest.ts
index ca601e5..5bae7d5 100644
--- a/src/lib/rules/rest.ts
+++ b/src/lib/rules/rest.ts
@@ -15,6 +15,9 @@ export function applyRest(c: Character, opt: RestOption): Partial {
patch.hp = { ...c.hp, current: c.hp.max };
}
+ // Any rest ends ongoing concentration (you stop holding the spell to rest).
+ if (c.concentration) patch.concentration = null;
+
// Resources refresh when their recovery tag is covered by this rest.
patch.resources = c.resources.map((r) =>
opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r,
diff --git a/src/lib/sync/messages.test.ts b/src/lib/sync/messages.test.ts
index 91d7c65..12e20a2 100644
--- a/src/lib/sync/messages.test.ts
+++ b/src/lib/sync/messages.test.ts
@@ -33,6 +33,13 @@ describe('sync protocol', () => {
expect(serverMessageSchema.safeParse({ t: 'rollBroadcast', playerName: 'Lia', label: 'd20', expression: '1d20', total: 9, breakdown: '[9]' }).success).toBe(true);
});
+ it('carries concentration in a player diff (set and cleared)', () => {
+ const set = partialCharacterDiffSchema.parse({ concentration: { spellId: 's1', spellName: 'Bless' } });
+ expect(set.concentration).toEqual({ spellId: 's1', spellName: 'Bless' });
+ const cleared = partialCharacterDiffSchema.parse({ concentration: null });
+ expect(cleared.concentration).toBeNull();
+ });
+
it('strips identity fields from a player diff (players can never rewrite name/abilities)', () => {
const parsed = partialCharacterDiffSchema.parse({
hp: { current: 1, max: 24, temp: 0 },
diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts
index 55a1beb..5bc91e0 100644
--- a/src/lib/sync/messages.ts
+++ b/src/lib/sync/messages.ts
@@ -20,6 +20,10 @@ export const playerCharacterSchema = z.object({
imageId: z.string().optional(),
hp: hpSchema,
conditions: z.array(condSchema).default([]),
+ // enforced-state mirrors, so a player sees their own slots/concentration live
+ slots: z.array(z.object({ level: int, current: int, max: int })).optional(),
+ concentration: z.string().nullable().optional(),
+ resources: z.array(z.object({ name: z.string(), current: int, max: int })).optional(),
});
export const playerCombatantSchema = z.object({
@@ -94,6 +98,8 @@ export const partialCharacterDiffSchema = z.object({
spellcasting: spellcastingSchema.optional(),
resources: z.array(resourceSchema).optional(),
defenses: defensesSchema.optional(),
+ /** the spell a seated player is concentrating on (or null when they drop it) */
+ concentration: characterSchema.shape.concentration.optional(),
});
export type PartialCharacterDiff = z.infer;
diff --git a/src/lib/sync/snapshot.ts b/src/lib/sync/snapshot.ts
index 5e4e70f..371c302 100644
--- a/src/lib/sync/snapshot.ts
+++ b/src/lib/sync/snapshot.ts
@@ -38,10 +38,13 @@ export function buildSnapshot(input: {
id: c.id,
name: c.name,
level: c.level,
- ac: sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus }),
+ ac: sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) }),
...(c.portrait ? { imageId: charImageId(c.id) } : {}),
hp: c.hp,
conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })),
+ ...(c.spellcasting.slots.length ? { slots: c.spellcasting.slots.map((s) => ({ level: s.level, current: s.current, max: s.max })) } : {}),
+ ...(c.concentration ? { concentration: c.concentration.spellName } : {}),
+ ...(c.resources.length ? { resources: c.resources.map((r) => ({ name: r.name, current: r.current, max: r.max })) } : {}),
};
});
diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts
index 4e2888d..b6fd69f 100644
--- a/src/lib/sync/wsSync.ts
+++ b/src/lib/sync/wsSync.ts
@@ -93,6 +93,7 @@ function connect(): void {
...(d.spellcasting ? { spellcasting: d.spellcasting } : {}),
...(d.resources ? { resources: d.resources } : {}),
...(d.defenses ? { defenses: d.defenses } : {}),
+ ...(d.concentration !== undefined ? { concentration: d.concentration } : {}),
});
}
} else if (msg.t === 'rollBroadcast') {
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
index c9b6064..694fb85 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/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.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/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./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/builder/CreationWizard.tsx","./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/LevelUpAdvisor.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/cloud/useCloudAutosave.ts","./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/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.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/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.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/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.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/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.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/img/resize.test.ts","./src/lib/img/resize.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/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.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/progression.test.ts","./src/lib/rules/progression.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/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.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/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.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/Codex.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/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./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/builder/CreationWizard.tsx","./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/LevelUpAdvisor.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/cloud/useCloudAutosave.ts","./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/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.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/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.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/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.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/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.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/img/resize.test.ts","./src/lib/img/resize.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/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.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/progression.test.ts","./src/lib/rules/progression.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/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.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/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
\ No newline at end of file