diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index e9ae06f..793d107 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -19,6 +19,7 @@ import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster'; import { usePlayerConnection } from '@/features/play/usePlayerConnection'; import { useCloudAutosave } from '@/features/cloud/useCloudAutosave'; import { PlayerSessionBadge } from '@/features/play/PlayerConnection'; +import { SignalsBell } from '@/features/assistant/SignalsBell'; import { SessionSidebar } from '@/features/play/SessionSidebar'; import { useSessionStore } from '@/stores/sessionStore'; @@ -205,6 +206,7 @@ export function RootLayout() { {activeCampaign && } + diff --git a/src/features/assistant/AssistantPage.tsx b/src/features/assistant/AssistantPage.tsx index 99aa7e4..d6810fb 100644 --- a/src/features/assistant/AssistantPage.tsx +++ b/src/features/assistant/AssistantPage.tsx @@ -1,14 +1,15 @@ import { useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import type { Campaign } from '@/lib/schemas'; -import { charactersRepo, encountersRepo, notesRepo } from '@/lib/db/repositories'; -import { getSystem, applyRest } from '@/lib/rules'; +import { encountersRepo } from '@/lib/db/repositories'; import { newId } from '@/lib/ids'; import { createRng } from '@/lib/rng'; import { rollDice } from '@/lib/dice/notation'; import { addCombatant } from '@/lib/combat/engine'; import { loadMonsters, loadPf2e } from '@/lib/compendium'; -import { resourceSuggestions, planningSuggestions, combatSuggestions, type Suggestion, type SuggestAction } from '@/lib/assistant/advisors'; +import { type Suggestion, type SuggestAction } from '@/lib/assistant/advisors'; +import { buildSignals } from '@/lib/assistant/signals'; +import { useSignalAction } from './useSignalAction'; import { buildSuggestedEncounter } from '@/lib/assistant/encounter'; import { useUiStore } from '@/stores/uiStore'; import { useCharacters } from '@/features/characters/hooks'; @@ -43,25 +44,11 @@ function Assistant({ campaign }: { campaign: Campaign }) { const [msg, setMsg] = useState(null); const suggestions = useMemo( - () => [...combatSuggestions(activeEnc), ...resourceSuggestions(characters), ...planningSuggestions(notes, quests)], + () => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }), [activeEnc, characters, notes, quests], ); - const runAction = async (a: SuggestAction) => { - if (a.type === 'goto') { void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to }); return; } - if (a.type === 'longRest') { - const c = await charactersRepo.get(a.characterId); - if (!c) return; - const opt = getSystem(c.system).restOptions.find((o) => o.restoresHp); - if (opt) await charactersRepo.update(c.id, applyRest(c, opt)); - setMsg('Applied a long rest.'); - return; - } - if (a.type === 'createNote') { - await notesRepo.create(campaign.id, a.title); - void navigate({ to: '/notes' }); - } - }; + const runAction = useSignalAction(campaign.id, setMsg); const buildEncounter = async (difficulty: string) => { setBusy(true); diff --git a/src/features/assistant/SignalsBell.tsx b/src/features/assistant/SignalsBell.tsx new file mode 100644 index 0000000..b003ecf --- /dev/null +++ b/src/features/assistant/SignalsBell.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Bell, X } from 'lucide-react'; +import type { Campaign } from '@/lib/schemas'; +import { buildSignals } from '@/lib/assistant/signals'; +import type { Suggestion } from '@/lib/assistant/advisors'; +import { useActiveCampaign } from '@/features/campaigns/hooks'; +import { useCharacters } from '@/features/characters/hooks'; +import { useNotes, useQuests } from '@/features/world/hooks'; +import { useEncounters } from '@/features/combat/hooks'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/cn'; +import { useSignalAction } from './useSignalAction'; + +const DOT: Record = { + danger: 'bg-danger', + warn: 'bg-warning', + info: 'bg-info', +}; + +/** + * Ambient, app-wide "what needs my attention" surface. Watches the active + * campaign's state and surfaces one-click signals from anywhere — the proactive + * core of the assistant. Renders nothing without an active campaign. + */ +export function SignalsBell() { + const campaign = useActiveCampaign(); + if (!campaign) return null; + return ; +} + +function SignalsBellInner({ campaign }: { campaign: Campaign }) { + const characters = useCharacters(campaign.id); + const notes = useNotes(campaign.id); + const quests = useQuests(campaign.id); + const encounters = useEncounters(campaign.id); + const activeEnc = encounters.find((e) => e.status === 'active'); + + const [dismissed, setDismissed] = useState>(new Set()); + const [open, setOpen] = useState(false); + const [msg, setMsg] = useState(null); + const runAction = useSignalAction(campaign.id, setMsg); + const ref = useRef(null); + + const signals = useMemo( + () => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }).filter((s) => !dismissed.has(s.id)), + [activeEnc, characters, notes, quests, dismissed], + ); + // The badge counts only things that genuinely want attention. + const attention = signals.filter((s) => s.severity !== 'info').length; + + // Close the popover on an outside click or Escape. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); }; + }, [open]); + + return ( +
+ + + {open && ( +
+
+ Signals + {signals.length > 0 && ( + + )} +
+ {msg &&

{msg}

} + {signals.length === 0 ? ( +

Nothing needs your attention.

+ ) : ( +
    + {signals.map((s) => ( +
  • + +
    +
    {s.title}
    + {s.detail &&
    {s.detail}
    } + {s.action && ( + + )} +
    + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/features/assistant/useSignalAction.ts b/src/features/assistant/useSignalAction.ts new file mode 100644 index 0000000..1979917 --- /dev/null +++ b/src/features/assistant/useSignalAction.ts @@ -0,0 +1,41 @@ +import { useNavigate } from '@tanstack/react-router'; +import { charactersRepo, notesRepo, questsRepo } from '@/lib/db/repositories'; +import { getSystem, applyRest } from '@/lib/rules'; +import type { SuggestAction } from '@/lib/assistant/advisors'; + +/** + * One place that turns a signal's `SuggestAction` into a real mutation/navigation, + * shared by the ambient signals bell and the Assistant page so behaviour can't + * drift. `onDone` receives a short confirmation message for an aria-live region. + */ +export function useSignalAction(campaignId: string, onDone?: (msg: string) => void) { + const navigate = useNavigate(); + + return async (a: SuggestAction): Promise => { + switch (a.type) { + case 'goto': + void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to }); + return; + case 'longRest': + case 'shortRest': { + const c = await charactersRepo.get(a.characterId); + if (!c) return; + const opts = getSystem(c.system).restOptions; + const opt = a.type === 'longRest' + ? opts.find((o) => o.restoresHp) ?? opts.at(-1) + : opts.find((o) => !o.restoresHp) ?? opts[0]; + if (opt) await charactersRepo.update(c.id, applyRest(c, opt)); + onDone?.(`Applied ${opt?.label ?? 'a rest'} for ${c.name}.`); + return; + } + case 'completeQuest': + await questsRepo.update(a.questId, { status: 'completed' }); + onDone?.('Quest marked complete.'); + return; + case 'createNote': + await notesRepo.create(campaignId, a.title); + void navigate({ to: '/notes' }); + return; + } + }; +} diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 27ef1b7..437ebef 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -2,7 +2,6 @@ import { useRef, useState } from 'react'; import { ArrowDown, ArrowUp, - Check, ChevronLeft, ChevronRight, Dices, @@ -611,7 +610,7 @@ function ConditionPicker({ ))} diff --git a/src/lib/assistant/advisors.ts b/src/lib/assistant/advisors.ts index a8bda95..ababd5a 100644 --- a/src/lib/assistant/advisors.ts +++ b/src/lib/assistant/advisors.ts @@ -6,6 +6,8 @@ export type Severity = 'info' | 'warn' | 'danger'; export type SuggestAction = | { type: 'goto'; to: string; params?: Record; label: string } | { type: 'longRest'; characterId: string; label: string } + | { type: 'shortRest'; characterId: string; label: string } + | { type: 'completeQuest'; questId: string; label: string } | { type: 'createNote'; title: string; label: string }; export interface Suggestion { diff --git a/src/lib/assistant/signals.test.ts b/src/lib/assistant/signals.test.ts new file mode 100644 index 0000000..a335558 --- /dev/null +++ b/src/lib/assistant/signals.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { characterDefaults, type Character, type Quest } from '@/lib/schemas'; +import { questSignals, casterSignals, buildSignals } from './signals'; + +function pc(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: 30, max: 30, temp: 0 }, speed: 30, armorBonus: 0, + skillRanks: {}, saveRanks: {}, perceptionRank: 'trained', + ...characterDefaults(), notes: '', createdAt: '', updatedAt: '', + ...over, + }; +} +function quest(over: Partial = {}): Quest { + return { id: 'q', campaignId: 'c', title: 'Find the relic', status: 'active', description: '', reward: '', objectives: [], createdAt: '', updatedAt: '', ...over }; +} + +describe('questSignals', () => { + it('offers to complete a quest whose objectives are all done', () => { + const q = quest({ objectives: [{ id: 'o1', text: 'a', done: true }, { id: 'o2', text: 'b', done: true }] }); + const [s] = questSignals([q]); + expect(s?.action).toEqual({ type: 'completeQuest', questId: 'q', label: 'Mark complete' }); + }); + + it('stays quiet when an objective is unfinished or there are none', () => { + expect(questSignals([quest({ objectives: [{ id: 'o1', text: 'a', done: false }] })])).toEqual([]); + expect(questSignals([quest({ objectives: [] })])).toEqual([]); + expect(questSignals([quest({ status: 'completed', objectives: [{ id: 'o1', text: 'a', done: true }] })])).toEqual([]); + }); +}); + +describe('casterSignals', () => { + it('flags a caster with all slots expended and offers a short rest', () => { + const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 0 }, { level: 2, max: 2, current: 0 }], spells: [] } }); + const sig = casterSignals([c]).find((s) => s.id === 'no-slots-pc'); + expect(sig?.action).toEqual({ type: 'shortRest', characterId: 'pc', label: 'Short rest' }); + }); + + it('does not flag when some slots remain', () => { + const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 1 }], spells: [] } }); + expect(casterSignals([c]).some((s) => s.id === 'no-slots-pc')).toBe(false); + }); + + it('warns when concentrating while bloodied', () => { + const c = pc({ hp: { current: 10, max: 30, temp: 0 }, concentration: { spellId: 's', spellName: 'Bless' } }); + expect(casterSignals([c]).some((s) => s.id === 'conc-risk-pc')).toBe(true); + }); +}); + +describe('buildSignals', () => { + it('merges detectors, de-dupes, and sorts danger → warn → info', () => { + const downed = pc({ id: 'a', name: 'A', hp: { current: 0, max: 30, temp: 0 } }); + const noSlots = pc({ id: 'b', name: 'B', spellcasting: { slots: [{ level: 1, max: 2, current: 0 }], spells: [] } }); + const out = buildSignals({ characters: [downed, noSlots], notes: [], quests: [], activeEncounter: undefined }); + expect(out.length).toBeGreaterThan(0); + // severity is non-decreasing (danger=0 first) + const ranks = out.map((s) => ({ danger: 0, warn: 1, info: 2 })[s.severity]); + expect(ranks).toEqual([...ranks].sort((x, y) => x - y)); + // unique ids + expect(new Set(out.map((s) => s.id)).size).toBe(out.length); + }); +}); diff --git a/src/lib/assistant/signals.ts b/src/lib/assistant/signals.ts new file mode 100644 index 0000000..9c7606e --- /dev/null +++ b/src/lib/assistant/signals.ts @@ -0,0 +1,92 @@ +import type { Character, Encounter, Note, Quest } from '@/lib/schemas'; +import { + resourceSuggestions, + planningSuggestions, + combatSuggestions, + type Suggestion, +} from './advisors'; + +/** + * The Signals engine: pure detectors over campaign + combat + character state + * that emit actionable, one-click proposals. This is the "buttons, not chatbot" + * layer — it recognizes a situation and offers the fix; the user never has to + * formulate a request. It builds on the existing advisor detectors and adds + * kernel-aware ones now that combat/spellcasting state is enforced. + */ + +/** A quest whose objectives are all done but is still marked active → offer to complete it. */ +export function questSignals(quests: Quest[]): Suggestion[] { + const out: Suggestion[] = []; + for (const q of quests) { + if (q.status !== 'active' || q.objectives.length === 0) continue; + if (q.objectives.every((o) => o.done)) { + out.push({ + id: `quest-done-${q.id}`, + category: 'planning', + severity: 'info', + title: `“${q.title}” is finished`, + detail: 'Every objective is checked off — mark the quest complete?', + action: { type: 'completeQuest', questId: q.id, label: 'Mark complete' }, + }); + } + } + return out; +} + +/** Spellcasters who have burned through their slots, and concentration-at-risk nudges. */ +export function casterSignals(characters: Character[]): Suggestion[] { + const out: Suggestion[] = []; + for (const c of characters) { + if (c.kind !== 'pc' || c.hp.current <= 0) continue; + + const slots = c.spellcasting.slots.filter((s) => s.max > 0); + if (slots.length > 0 && slots.every((s) => s.current === 0)) { + out.push({ + id: `no-slots-${c.id}`, + category: 'resources', + severity: 'warn', + title: `${c.name} is out of spell slots`, + detail: 'No slots remaining — a rest would restore them.', + action: { type: 'shortRest', characterId: c.id, label: 'Short rest' }, + }); + } + + // Concentration while bloodied is fragile — surface it so the GM keeps it in mind. + if (c.concentration && c.hp.max > 0 && c.hp.current < c.hp.max * 0.5) { + out.push({ + id: `conc-risk-${c.id}`, + category: 'combat', + severity: 'info', + title: `${c.name} is concentrating on ${c.concentration.spellName} while bloodied`, + detail: 'Damage will force a Constitution save — watch for it.', + }); + } + } + return out; +} + +const SEVERITY_RANK: Record = { danger: 0, warn: 1, info: 2 }; + +export interface BuildSignalsInput { + characters: Character[]; + notes: Note[]; + quests: Quest[]; + activeEncounter?: Encounter | undefined; +} + +/** + * Aggregate every detector into one severity-sorted, de-duplicated list — the + * single source the ambient signals UI and the Assistant page both render. + */ +export function buildSignals(input: BuildSignalsInput): Suggestion[] { + const all = [ + ...combatSuggestions(input.activeEncounter), + ...casterSignals(input.characters), + ...resourceSuggestions(input.characters), + ...questSignals(input.quests), + ...planningSuggestions(input.notes, input.quests), + ]; + const seen = new Set(); + const deduped = all.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true))); + return deduped.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]); +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 6fe8aec..92edf1f 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/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.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 +{"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/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.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/assistant/signals.test.ts","./src/lib/assistant/signals.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/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.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