diff --git a/src/features/characters/sheet/ResourcesSection.tsx b/src/features/characters/sheet/ResourcesSection.tsx index 2d5c60c..51c4229 100644 --- a/src/features/characters/sheet/ResourcesSection.tsx +++ b/src/features/characters/sheet/ResourcesSection.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { newId } from '@/lib/ids'; import { getSystem, applyRest } from '@/lib/rules'; +import { spendResource, regainResource } from '@/lib/mechanics'; import type { CharacterResource } from '@/lib/schemas'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; @@ -61,12 +62,12 @@ export function ResourcesSection({ c, update }: SectionProps) { {c.resources.map((r) => (
  • patch(r.id, { name: e.target.value })} aria-label="Resource name" /> - + {r.current} /{r.max} - + patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" /> patchSpell(s.id, { prepared: e.target.checked })} /> Prepared )} +
  • ))} 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} + {c.concentration && ( +
    + + Concentrating on {c.concentration.spellName} + +
    + )} +
    {/* 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