From ce9bbc8220375dfe8633b5425262c162cbec4489 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 03:07:16 +0200 Subject: [PATCH] =?UTF-8?q?D2:=20AI=20Director=20apply=20bridge=20?= =?UTF-8?q?=E2=80=94=20approve-each,=20routed=20through=20the=20rules=20ke?= =?UTF-8?q?rnel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The director's proposed state changes are now one-click "Apply" actions. Nothing mutates a Character or Encounter until the human clicks Apply — the AI never writes game state on its own. - useDirectorAction (the 3rd anti-hallucination gate): re-resolves every action's target against the live roster/encounter and rejects unknowns, then routes through the pure kernel + combat engine — applyDamage/applyHealing/setTempHp/ updateCombatant (damage/heal/tempHp/condition), nextTurn (advanceTurn), logEvent (log), and castSpell/spendResource for caster actions. Persists via the transactional encountersRepo.mutate / charactersRepo.update. - No-auto-roll preserved: damaging a concentrating creature SURFACES a concentration save (concentrationDC) as a roll button — it is never auto-rolled. Massive-damage death is flagged. castSpell mirrors new concentration onto the combatant so later saves surface. - ActionCard gains a working Apply button (idempotent: disables after applying, shows the result/skip reason); applied actions append a system transcript entry so the next turn sees the new ground truth. 8 integration tests against a real Dexie cover each action→kernel patch, the concentration-save surfacing, and rejection of off-roster targets. 386 unit tests green; lint clean; build OK; verified the page renders + runs a turn on a clean load. Co-Authored-By: Claude Opus 4.8 --- .../assistant/director/ActionCard.tsx | 45 ++++- .../assistant/director/DirectorPage.tsx | 10 +- .../director/useDirectorAction.test.ts | 112 ++++++++++++ .../assistant/director/useDirectorAction.ts | 168 ++++++++++++++++++ .../assistant/director/useDirectorSession.ts | 27 ++- tsconfig.app.tsbuildinfo | 2 +- 6 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 src/features/assistant/director/useDirectorAction.test.ts create mode 100644 src/features/assistant/director/useDirectorAction.ts diff --git a/src/features/assistant/director/ActionCard.tsx b/src/features/assistant/director/ActionCard.tsx index 504881f..86424e7 100644 --- a/src/features/assistant/director/ActionCard.tsx +++ b/src/features/assistant/director/ActionCard.tsx @@ -1,16 +1,49 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Codex'; import type { DirectorAction } from '@/lib/assistant/director'; import { actionSummary } from './actionSummary'; +import type { ApplyResult } from './useDirectorAction'; /** - * D1: a passive preview of a proposed change. The one-click Apply (routed through - * the rules kernel, approve-each) arrives in phase D2. + * A proposed state change. Approve-each: it only mutates the game when the human + * clicks Apply, which routes through the rules kernel. If no `onApply` is given it + * renders as a passive preview. */ -export function ActionCard({ action }: { action: DirectorAction }) { +export function ActionCard({ + action, + onApply, +}: { + action: DirectorAction; + onApply?: (a: DirectorAction) => Promise; +}) { + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + + const apply = async () => { + if (!onApply) return; + setBusy(true); + try { + setResult(await onApply(action)); + } finally { + setBusy(false); + } + }; + return ( -
- proposed - {actionSummary(action)} +
+
+ + {result ? (result.ok ? 'applied' : 'skipped') : 'change'} + + {actionSummary(action)} + {onApply && !result && ( + + )} +
+ {result && !result.ok &&

{result.message}

}
); } diff --git a/src/features/assistant/director/DirectorPage.tsx b/src/features/assistant/director/DirectorPage.tsx index 1972bec..8ca83c4 100644 --- a/src/features/assistant/director/DirectorPage.tsx +++ b/src/features/assistant/director/DirectorPage.tsx @@ -14,7 +14,7 @@ export function DirectorPage() { } function Director({ campaign }: { campaign: Campaign }) { - const { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } = + const { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } = useDirectorSession(campaign); const entries = session?.transcript ?? []; const started = entries.length > 0; @@ -71,14 +71,10 @@ function Director({ campaign }: { campaign: Campaign }) { {lastTurn && lastTurn.actions.length > 0 && (
-
Proposed changes
+
Proposed changes — you approve each
{lastTurn.actions.map((a, i) => ( - + ))} -

- One-click Apply (routed through the rules engine) arrives in the next update — for now, resolve these on - your sheet and tracker. -

)} diff --git a/src/features/assistant/director/useDirectorAction.test.ts b/src/features/assistant/director/useDirectorAction.test.ts new file mode 100644 index 0000000..e77a694 --- /dev/null +++ b/src/features/assistant/director/useDirectorAction.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { db } from '@/lib/db/db'; +import { charactersRepo, encountersRepo } from '@/lib/db/repositories'; +import { buildDirectorScene, type SceneActor } from '@/lib/assistant/director'; +import { characterSchema, encounterSchema, newSpellEntry, type Campaign, type Character, type Encounter } from '@/lib/schemas'; +import { useDirectorAction } from './useDirectorAction'; + +const campaign: Campaign = { id: 'c1', name: 'T', system: '5e', description: '', createdAt: 't', updatedAt: 't' }; + +function seedCaster(): Character { + return characterSchema.parse({ + id: 'ch1', campaignId: 'c1', system: '5e', kind: 'pc', name: 'Mira', + abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 16, cha: 12 }, + hp: { current: 20, max: 20, temp: 0 }, + spellcasting: { slots: [{ level: 1, max: 2, current: 2 }], spells: [newSpellEntry({ id: 'sp1', name: 'Bless', level: 1, concentration: true })] }, + resources: [{ id: 'r1', name: 'Channel Divinity', current: 1, max: 1, recovery: 'short' }], + createdAt: 't', updatedAt: 't', + }); +} + +function seedEncounter(): Encounter { + return encounterSchema.parse({ + id: 'e1', campaignId: 'c1', name: 'Fight', status: 'active', round: 1, turnIndex: 0, + combatants: [ + { id: 'cb-mira', name: 'Mira', kind: 'pc', characterId: 'ch1', initiative: 15, ac: 14, hp: { current: 20, max: 20, temp: 0 }, concentrating: 'Bless' }, + { id: 'cb-gob', name: 'Goblin', kind: 'monster', initiative: 12, ac: 13, hp: { current: 7, max: 7, temp: 0 } }, + ], + createdAt: 't', updatedAt: 't', + }); +} + +let roster: SceneActor[]; +let characters: Character[]; + +beforeEach(async () => { + await db.characters.clear(); + await db.encounters.clear(); + const caster = seedCaster(); + const enc = seedEncounter(); + await charactersRepo.insert(caster); + await encountersRepo.save(enc); + characters = [caster]; + roster = buildDirectorScene({ campaign, characters, encounter: enc, persona: 'dm', transcriptWindow: [] }).roster; +}); + +function hook() { + return renderHook(() => useDirectorAction({ system: '5e', encounterId: 'e1', roster, characters })).result; +} + +describe('useDirectorAction — approve-each apply bridge', () => { + it('applies damage through the engine and reports the new HP', async () => { + const r = await hook().current({ kind: 'damage', target: 'Goblin', amount: 5, damageType: 'slashing' }); + expect(r.ok).toBe(true); + const enc = await encountersRepo.get('e1'); + expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.hp.current).toBe(2); + }); + + it('surfaces a concentration save (never auto-rolls) when damaging a concentrating creature', async () => { + const r = await hook().current({ kind: 'damage', target: 'Mira', amount: 10 }); + expect(r.ok).toBe(true); + expect(r.rollRequests).toHaveLength(1); + expect(r.rollRequests![0]).toMatchObject({ kind: 'save', dc: 10 }); // concentrationDC(10) = max(10, 5) + const enc = await encountersRepo.get('e1'); + expect(enc!.combatants.find((c) => c.id === 'cb-mira')!.hp.current).toBe(10); + }); + + it('rejects an action targeting an entity not on the board', async () => { + const r = await hook().current({ kind: 'damage', target: 'Smaug', amount: 99 }); + expect(r.ok).toBe(false); + const enc = await encountersRepo.get('e1'); + expect(enc!.combatants.every((c) => c.hp.current === c.hp.max)).toBe(true); // nothing changed + }); + + it('adds and removes conditions', async () => { + await hook().current({ kind: 'condition', target: 'Goblin', op: 'add', name: 'prone' }); + let enc = await encountersRepo.get('e1'); + expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.conditions.map((x) => x.name)).toContain('prone'); + await hook().current({ kind: 'condition', target: 'Goblin', op: 'remove', name: 'prone' }); + enc = await encountersRepo.get('e1'); + expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.conditions.map((x) => x.name)).not.toContain('prone'); + }); + + it('advances the turn through the engine', async () => { + const r = await hook().current({ kind: 'advanceTurn' }); + expect(r.ok).toBe(true); + const enc = await encountersRepo.get('e1'); + expect(enc!.turnIndex).toBe(1); + }); + + it('casts a known spell, consuming a slot and mirroring concentration to the combatant', async () => { + const r = await hook().current({ kind: 'castSpell', caster: 'Mira', spell: 'Bless', atLevel: 1 }); + expect(r.ok).toBe(true); + const c = await charactersRepo.get('ch1'); + expect(c!.spellcasting.slots[0]!.current).toBe(1); // one slot spent + expect(c!.concentration?.spellName).toBe('Bless'); + const enc = await encountersRepo.get('e1'); + expect(enc!.combatants.find((x) => x.id === 'cb-mira')!.concentrating).toBe('Bless'); + }); + + it("rejects a spell the caster doesn't have", async () => { + const r = await hook().current({ kind: 'castSpell', caster: 'Mira', spell: 'Meteor Swarm' }); + expect(r.ok).toBe(false); + }); + + it('spends a resource through the kernel', async () => { + const r = await hook().current({ kind: 'spendResource', actor: 'Mira', resource: 'Channel Divinity', amount: 1 }); + expect(r.ok).toBe(true); + const c = await charactersRepo.get('ch1'); + expect(c!.resources[0]!.current).toBe(0); + }); +}); diff --git a/src/features/assistant/director/useDirectorAction.ts b/src/features/assistant/director/useDirectorAction.ts new file mode 100644 index 0000000..1b59a63 --- /dev/null +++ b/src/features/assistant/director/useDirectorAction.ts @@ -0,0 +1,168 @@ +import { useCallback } from 'react'; +import { newId } from '@/lib/ids'; +import type { SystemId } from '@/lib/rules'; +import type { Character } from '@/lib/schemas'; +import { charactersRepo, encountersRepo } from '@/lib/db/repositories'; +import { + applyDamage, + applyHealing, + setTempHp, + updateCombatant, + nextTurn, + logEvent, + isMassiveDamageDeath, +} from '@/lib/combat/engine'; +import { castSpell, spendResource, concentrationDC } from '@/lib/mechanics'; +import { + resolveCombatant, + resolveActor, + resolveSpellByName, + resolveResourceByName, + type DirectorAction, + type RollRequest, + type SceneActor, +} from '@/lib/assistant/director'; + +export interface ApplyResult { + ok: boolean; + message: string; + /** follow-up rolls a human must make (e.g. a concentration save) — surfaced, never auto-rolled */ + rollRequests?: RollRequest[]; +} + +function concentrationSave(name: string, spellName: string, damage: number): RollRequest { + return { id: newId(), actor: name, label: `Concentration save (${spellName})`, kind: 'save', expression: '1d20', dc: concentrationDC(damage) }; +} + +/** + * The third anti-hallucination gate and the only writer of game state for the + * director. Every action re-resolves its target against the live roster/encounter + * and routes through the pure rules kernel + combat engine — approve-each, so it + * runs solely from an ActionCard's Apply click. Damage to a concentrating creature + * SURFACES a save (it never auto-resolves it). + */ +export function useDirectorAction(opts: { + system: SystemId; + encounterId?: string | undefined; + roster: SceneActor[]; + characters: Character[]; +}) { + const { system, encounterId, roster, characters } = opts; + + return useCallback( + async (action: DirectorAction): Promise => { + const noCombat: ApplyResult = { ok: false, message: 'No active encounter to apply this to.' }; + const pcFor = (name: string): Character | undefined => { + const actor = resolveActor(roster, name); + return actor?.characterId ? characters.find((c) => c.id === actor.characterId) : undefined; + }; + + switch (action.kind) { + case 'damage': { + if (!encounterId) return noCombat; + let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` }; + const followUps: RollRequest[] = []; + await encountersRepo.mutate(encounterId, (e) => { + const c = resolveCombatant(e, action.target); + if (!c) return e; + const after = applyDamage(c, action.amount, action.damageType); + const massive = isMassiveDamageDeath(c.hp.max, after.hp.current); + out = { + ok: true, + message: `${c.name}: ${action.amount}${action.damageType ? ` ${action.damageType}` : ''} damage → ${after.hp.current}/${after.hp.max} HP${massive ? ' (massive damage — instant death!)' : ''}`, + }; + if (system === '5e' && c.concentrating) followUps.push(concentrationSave(c.name, c.concentrating, action.amount)); + return updateCombatant(e, c.id, { hp: after.hp }); + }); + return followUps.length ? { ...out, rollRequests: followUps } : out; + } + + case 'heal': { + if (!encounterId) return noCombat; + let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` }; + await encountersRepo.mutate(encounterId, (e) => { + const c = resolveCombatant(e, action.target); + if (!c) return e; + const after = applyHealing(c, action.amount); + out = { ok: true, message: `${c.name} healed ${action.amount} → ${after.hp.current}/${after.hp.max} HP` }; + return updateCombatant(e, c.id, { hp: after.hp }); + }); + return out; + } + + case 'tempHp': { + if (!encounterId) return noCombat; + let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` }; + await encountersRepo.mutate(encounterId, (e) => { + const c = resolveCombatant(e, action.target); + if (!c) return e; + const after = setTempHp(c, action.amount); + out = { ok: true, message: `${c.name} gains ${action.amount} temporary HP` }; + return updateCombatant(e, c.id, { hp: after.hp }); + }); + return out; + } + + case 'condition': { + if (!encounterId) return noCombat; + let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` }; + await encountersRepo.mutate(encounterId, (e) => { + const c = resolveCombatant(e, action.target); + if (!c) return e; + const others = c.conditions.filter((x) => x.name.trim().toLowerCase() !== action.name.trim().toLowerCase()); + const conditions = + action.op === 'add' + ? [...others, { name: action.name, ...(action.value && action.value > 0 ? { value: action.value } : {}) }] + : others; + out = { ok: true, message: `${action.op === 'add' ? 'Added' : 'Removed'} ${action.name}${action.value ? ` ${action.value}` : ''} ${action.op === 'add' ? 'on' : 'from'} ${c.name}` }; + return updateCombatant(e, c.id, { conditions }); + }); + return out; + } + + case 'advanceTurn': { + if (!encounterId) return noCombat; + await encountersRepo.mutate(encounterId, (e) => nextTurn(e, system)); + return { ok: true, message: 'Advanced to the next turn.' }; + } + + case 'log': { + if (encounterId) await encountersRepo.mutate(encounterId, (e) => logEvent(e, action.text)); + return { ok: true, message: action.text }; + } + + case 'castSpell': { + const c = pcFor(action.caster); + if (!c) return { ok: false, message: `"${action.caster}" isn't a tracked character.` }; + const spell = resolveSpellByName(c, action.spell); + if (!spell) return { ok: false, message: `${c.name} doesn't have "${action.spell}" prepared.` }; + const r = castSpell(c, spell.id, action.atLevel); + if (!r.ok) return { ok: false, message: r.reason }; + await charactersRepo.update(c.id, r.patch); + // Mirror new concentration onto the combatant so post-damage saves surface. + const conc = r.patch.concentration; + const actor = resolveActor(roster, action.caster); + if (encounterId && actor?.combatantId && conc) { + await encountersRepo.mutate(encounterId, (e) => updateCombatant(e, actor.combatantId!, { concentrating: conc.spellName })); + } + return { ok: true, message: r.log.join(' ') || `${c.name} cast ${spell.name}.` }; + } + + case 'spendResource': { + const c = pcFor(action.actor); + if (!c) return { ok: false, message: `"${action.actor}" isn't a tracked character.` }; + const res = resolveResourceByName(c, action.resource); + if (!res) return { ok: false, message: `${c.name} has no "${action.resource}" resource.` }; + const r = spendResource(c, res.id, action.amount); + if (!r.ok) return { ok: false, message: r.reason }; + await charactersRepo.update(c.id, r.patch); + return { ok: true, message: r.log.join(' ') || `${c.name} spent ${action.amount} ${res.name}.` }; + } + + case 'addCombatant': + return { ok: false, message: 'Adding monsters from the director arrives in a later update.' }; + } + }, + [system, encounterId, roster, characters], + ); +} diff --git a/src/features/assistant/director/useDirectorSession.ts b/src/features/assistant/director/useDirectorSession.ts index 645f785..996e40a 100644 --- a/src/features/assistant/director/useDirectorSession.ts +++ b/src/features/assistant/director/useDirectorSession.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { Campaign } from '@/lib/schemas'; import { aiSessionsRepo } from '@/lib/db/repositories'; import { newId } from '@/lib/ids'; @@ -9,6 +9,7 @@ import { useEncounters } from '@/features/combat/hooks'; import { buildDirectorScene, runDirectorTurn, type DirectorAction, type RollRequest } from '@/lib/assistant/director'; import type { Degree } from '@/lib/dice/check'; import { useLatestAiSession } from './hooks'; +import { useDirectorAction, type ApplyResult } from './useDirectorAction'; const nowIso = (): string => new Date().toISOString(); @@ -44,6 +45,13 @@ export function useDirectorSession(campaign: Campaign) { const [message, setMessage] = useState(null); const [lastTurn, setLastTurn] = useState(null); + // Roster for name-resolution when applying actions (rebuilt from live data). + const roster = useMemo( + () => buildDirectorScene({ campaign, characters, encounter: activeEnc, persona, transcriptWindow: [] }).roster, + [campaign, characters, activeEnc, persona], + ); + const apply = useDirectorAction({ system: campaign.system, encounterId: activeEnc?.id, roster, characters }); + const run = useCallback( async (input?: string) => { setBusy(true); @@ -103,6 +111,21 @@ export function useDirectorSession(campaign: Campaign) { [session], ); + const applyAction = useCallback( + async (action: DirectorAction): Promise => { + const r = await apply(action); + if (r.ok && session) { + await aiSessionsRepo.appendEntries(session.id, [{ id: newId(), role: 'system', text: `Applied: ${r.message}`, ts: nowIso() }]); + } + if (r.rollRequests?.length) { + setLastTurn((prev) => (prev ? { ...prev, rollRequests: [...prev.rollRequests, ...r.rollRequests!] } : prev)); + } + if (!r.ok) setMessage(r.message); + return r; + }, + [apply, session], + ); + const restart = useCallback(async () => { if (!session) return; await aiSessionsRepo.mutate(session.id, (s) => ({ ...s, transcript: [], summary: '', summarizedThrough: 0 })); @@ -111,5 +134,5 @@ export function useDirectorSession(campaign: Campaign) { setMessage(null); }, [session]); - return { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } as const; + return { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } as const; } diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 811cfb1..a761995 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/NotFound.tsx","./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/components/ui/useConfirm.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/assistant/director/ActionCard.tsx","./src/features/assistant/director/DirectorPage.tsx","./src/features/assistant/director/RollRequestCard.tsx","./src/features/assistant/director/SuggestionChips.tsx","./src/features/assistant/director/TranscriptPane.tsx","./src/features/assistant/director/actionSummary.ts","./src/features/assistant/director/hooks.ts","./src/features/assistant/director/useDirectorSession.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/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassFeaturesSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatPickerModal.tsx","./src/features/characters/sheet/FeatsSection.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/WeaponPickerModal.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.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/DirectorSettings.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/assistant/director/context.ts","./src/lib/assistant/director/director.test.ts","./src/lib/assistant/director/engine.ts","./src/lib/assistant/director/fallback.ts","./src/lib/assistant/director/index.ts","./src/lib/assistant/director/no-auto-roll.test.ts","./src/lib/assistant/director/prompt.ts","./src/lib/assistant/director/resolve.ts","./src/lib/assistant/director/schema.ts","./src/lib/assistant/director/types.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/mpmb.test.ts","./src/lib/compendium/mpmb.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/multiclass.test.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/features/collect.ts","./src/lib/rules/features/data.5e.ts","./src/lib/rules/features/data.pf2e.ts","./src/lib/rules/features/features.test.ts","./src/lib/rules/features/subclass.5e.ts","./src/lib/rules/features/types.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.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/aiSession.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/connectivityStore.ts","./src/stores/directorStore.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/NotFound.tsx","./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/components/ui/useConfirm.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/assistant/director/ActionCard.tsx","./src/features/assistant/director/DirectorPage.tsx","./src/features/assistant/director/RollRequestCard.tsx","./src/features/assistant/director/SuggestionChips.tsx","./src/features/assistant/director/TranscriptPane.tsx","./src/features/assistant/director/actionSummary.ts","./src/features/assistant/director/hooks.ts","./src/features/assistant/director/useDirectorAction.test.ts","./src/features/assistant/director/useDirectorAction.ts","./src/features/assistant/director/useDirectorSession.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/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassFeaturesSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatPickerModal.tsx","./src/features/characters/sheet/FeatsSection.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/WeaponPickerModal.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.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/DirectorSettings.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/assistant/director/context.ts","./src/lib/assistant/director/director.test.ts","./src/lib/assistant/director/engine.ts","./src/lib/assistant/director/fallback.ts","./src/lib/assistant/director/index.ts","./src/lib/assistant/director/no-auto-roll.test.ts","./src/lib/assistant/director/prompt.ts","./src/lib/assistant/director/resolve.ts","./src/lib/assistant/director/schema.ts","./src/lib/assistant/director/types.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/mpmb.test.ts","./src/lib/compendium/mpmb.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/multiclass.test.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/features/collect.ts","./src/lib/rules/features/data.5e.ts","./src/lib/rules/features/data.pf2e.ts","./src/lib/rules/features/features.test.ts","./src/lib/rules/features/subclass.5e.ts","./src/lib/rules/features/types.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.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/aiSession.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/connectivityStore.ts","./src/stores/directorStore.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