diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx
index a963477..6c10328 100644
--- a/src/app/RootLayout.tsx
+++ b/src/app/RootLayout.tsx
@@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
- ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles,
+ ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2,
type LucideIcon,
} from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
@@ -31,6 +31,7 @@ const NAV: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
{ to: '/characters', label: 'Characters', icon: UserRound, exact: false, group: 'play' },
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
+ { to: '/director', label: 'AI Director', icon: Wand2, exact: false, group: 'play' },
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
{ to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' },
diff --git a/src/features/assistant/director/ActionCard.tsx b/src/features/assistant/director/ActionCard.tsx
new file mode 100644
index 0000000..504881f
--- /dev/null
+++ b/src/features/assistant/director/ActionCard.tsx
@@ -0,0 +1,16 @@
+import { Badge } from '@/components/ui/Codex';
+import type { DirectorAction } from '@/lib/assistant/director';
+import { actionSummary } from './actionSummary';
+
+/**
+ * D1: a passive preview of a proposed change. The one-click Apply (routed through
+ * the rules kernel, approve-each) arrives in phase D2.
+ */
+export function ActionCard({ action }: { action: DirectorAction }) {
+ return (
+
+ proposed
+ {actionSummary(action)}
+
+ );
+}
diff --git a/src/features/assistant/director/DirectorPage.tsx b/src/features/assistant/director/DirectorPage.tsx
new file mode 100644
index 0000000..1972bec
--- /dev/null
+++ b/src/features/assistant/director/DirectorPage.tsx
@@ -0,0 +1,106 @@
+import { Link } from '@tanstack/react-router';
+import type { Campaign } from '@/lib/schemas';
+import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
+import { Button } from '@/components/ui/Button';
+import { Badge } from '@/components/ui/Codex';
+import { useDirectorSession } from './useDirectorSession';
+import { TranscriptPane } from './TranscriptPane';
+import { RollRequestCard } from './RollRequestCard';
+import { ActionCard } from './ActionCard';
+import { SuggestionChips } from './SuggestionChips';
+
+export function DirectorPage() {
+ return {(c) => } ;
+}
+
+function Director({ campaign }: { campaign: Campaign }) {
+ const { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } =
+ useDirectorSession(campaign);
+ const entries = session?.transcript ?? [];
+ const started = entries.length > 0;
+
+ return (
+
+
+ {source && {source === 'llm' ? 'AI' : 'deterministic'} }
+ {started && (
+ void restart()}>
+ Restart
+
+ )}
+
+ }
+ />
+
+ {!llmReady && (
+
+ No AI key configured — the director runs in deterministic mode. Add a key in{' '}
+
+ Settings
+ {' '}
+ for full narration.
+
+ )}
+ {message && (
+
+ {message}
+
+ )}
+ {activeEnc && (
+
+ Running combat: {activeEnc.name} (round {activeEnc.round}).
+
+ )}
+
+
+
+
+
+ {lastTurn && lastTurn.rollRequests.length > 0 && (
+
+
Rolls — you roll these
+ {lastTurn.rollRequests.map((r) => (
+
+ ))}
+
+ )}
+
+ {lastTurn && lastTurn.actions.length > 0 && (
+
+
Proposed changes
+ {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/RollRequestCard.tsx b/src/features/assistant/director/RollRequestCard.tsx
new file mode 100644
index 0000000..dd42891
--- /dev/null
+++ b/src/features/assistant/director/RollRequestCard.tsx
@@ -0,0 +1,59 @@
+import { useState } from 'react';
+import { Dices } from 'lucide-react';
+import type { SystemId } from '@/lib/rules';
+import { rollAndShow } from '@/lib/useRoll';
+import type { Degree } from '@/lib/dice/check';
+import { Button } from '@/components/ui/Button';
+import { Badge } from '@/components/ui/Codex';
+import type { RollRequest } from '@/lib/assistant/director';
+
+/**
+ * The ONLY place the director feature touches the roll seam. A roll fires solely
+ * from this button's onClick — never automatically — honoring the hard no-auto-roll
+ * rule. The result is reported back so the director can adjudicate next turn.
+ */
+export function RollRequestCard({
+ req,
+ system,
+ onRolled,
+}: {
+ req: RollRequest;
+ system: SystemId;
+ onRolled: (req: RollRequest, result: { total: number; degree?: Degree }) => void;
+}) {
+ const [done, setDone] = useState<{ total: number; degree?: Degree } | null>(null);
+
+ const roll = () => {
+ const result = rollAndShow({
+ expression: req.expression,
+ label: req.label,
+ ...(req.dc !== undefined ? { dc: req.dc, system } : {}),
+ });
+ setDone(result);
+ onRolled(req, result);
+ };
+
+ return (
+
+
+
+
+ {req.label}
+ {req.against ? vs {req.against} : null}
+
+
+ {req.expression}
+ {req.dc !== undefined ? ` · DC ${req.dc}` : ''}
+
+
+ {done ? (
+
+ {done.total}
+ {done.degree ? ` · ${done.degree.includes('critical') ? 'Crit ' : ''}${done.degree.includes('success') ? 'Success' : 'Fail'}` : ''}
+
+ ) : (
+
Roll
+ )}
+
+ );
+}
diff --git a/src/features/assistant/director/SuggestionChips.tsx b/src/features/assistant/director/SuggestionChips.tsx
new file mode 100644
index 0000000..b93098e
--- /dev/null
+++ b/src/features/assistant/director/SuggestionChips.tsx
@@ -0,0 +1,52 @@
+import { useState } from 'react';
+import { Button } from '@/components/ui/Button';
+import { Textarea } from '@/components/ui/Input';
+
+/** Quick-reply suggestion chips + a free-text box for the human's action/intent. */
+export function SuggestionChips({
+ suggestions,
+ disabled,
+ onPick,
+}: {
+ suggestions: string[];
+ disabled: boolean;
+ onPick: (text: string) => void;
+}) {
+ const [text, setText] = useState('');
+ const send = () => {
+ const t = text.trim();
+ if (!t) return;
+ setText('');
+ onPick(t);
+ };
+
+ return (
+
+ {suggestions.length > 0 && (
+
+ {suggestions.map((s, i) => (
+ onPick(s)}>
+ {s}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/features/assistant/director/TranscriptPane.tsx b/src/features/assistant/director/TranscriptPane.tsx
new file mode 100644
index 0000000..5467c1b
--- /dev/null
+++ b/src/features/assistant/director/TranscriptPane.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useRef } from 'react';
+import { Dices } from 'lucide-react';
+import type { DirectorTranscriptEntry } from '@/lib/schemas';
+
+/** The narrative feed — narration, the human's inputs, roll results, and system notes. */
+export function TranscriptPane({ entries }: { entries: DirectorTranscriptEntry[] }) {
+ const endRef = useRef(null);
+ useEffect(() => {
+ endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
+ }, [entries.length]);
+
+ if (entries.length === 0) {
+ return (
+
+ The story begins when you start the scene.
+
+ );
+ }
+
+ return (
+
+ {entries.map((e) => (
+
+ ))}
+
+
+ );
+}
+
+function TranscriptLine({ entry }: { entry: DirectorTranscriptEntry }) {
+ switch (entry.role) {
+ case 'narration':
+ return (
+
+ {entry.speaker && {entry.speaker}. }
+ {entry.text}
+
+ );
+ case 'player-input':
+ return (
+
+
{entry.speaker ?? 'You'}
+
{entry.text}
+
+ );
+ case 'roll-result':
+ return (
+
+ {entry.text}
+
+ );
+ case 'system':
+ return {entry.text}
;
+ }
+}
diff --git a/src/features/assistant/director/actionSummary.ts b/src/features/assistant/director/actionSummary.ts
new file mode 100644
index 0000000..be0e03c
--- /dev/null
+++ b/src/features/assistant/director/actionSummary.ts
@@ -0,0 +1,26 @@
+import type { DirectorAction } from '@/lib/assistant/director';
+
+/** Human-readable one-liner for a proposed director action. Shared by the preview
+ * card and (phase D2) the apply bridge. */
+export function actionSummary(a: DirectorAction): string {
+ switch (a.kind) {
+ case 'damage':
+ return `${a.target} takes ${a.amount}${a.damageType ? ` ${a.damageType}` : ''} damage`;
+ case 'heal':
+ return `${a.target} heals ${a.amount}`;
+ case 'tempHp':
+ return `${a.target} gains ${a.amount} temp HP`;
+ case 'condition':
+ return `${a.op === 'add' ? 'Add' : 'Remove'} ${a.name}${a.value ? ` ${a.value}` : ''} ${a.op === 'add' ? 'on' : 'from'} ${a.target}`;
+ case 'castSpell':
+ return `${a.caster} casts ${a.spell}${a.atLevel ? ` (level ${a.atLevel})` : ''}`;
+ case 'spendResource':
+ return `${a.actor} spends ${a.amount} ${a.resource}`;
+ case 'advanceTurn':
+ return 'Advance to the next turn';
+ case 'addCombatant':
+ return `Add ${a.name} to combat`;
+ case 'log':
+ return a.text;
+ }
+}
diff --git a/src/features/assistant/director/hooks.ts b/src/features/assistant/director/hooks.ts
new file mode 100644
index 0000000..a437b3a
--- /dev/null
+++ b/src/features/assistant/director/hooks.ts
@@ -0,0 +1,12 @@
+import { useLiveQuery } from 'dexie-react-hooks';
+import { aiSessionsRepo } from '@/lib/db/repositories';
+import type { AiSession, DirectorPersona } from '@/lib/schemas';
+
+export function useAiSessions(campaignId: string): AiSession[] {
+ return useLiveQuery(() => aiSessionsRepo.listByCampaign(campaignId), [campaignId], []);
+}
+
+/** The most-recently-updated director session for a campaign + persona. */
+export function useLatestAiSession(campaignId: string, persona: DirectorPersona): AiSession | undefined {
+ return useLiveQuery(() => aiSessionsRepo.latest(campaignId, persona), [campaignId, persona], undefined);
+}
diff --git a/src/features/assistant/director/useDirectorSession.ts b/src/features/assistant/director/useDirectorSession.ts
new file mode 100644
index 0000000..645f785
--- /dev/null
+++ b/src/features/assistant/director/useDirectorSession.ts
@@ -0,0 +1,115 @@
+import { useCallback, useState } from 'react';
+import type { Campaign } from '@/lib/schemas';
+import { aiSessionsRepo } from '@/lib/db/repositories';
+import { newId } from '@/lib/ids';
+import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
+import { useDirectorStore } from '@/stores/directorStore';
+import { useCharacters } from '@/features/characters/hooks';
+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';
+
+const nowIso = (): string => new Date().toISOString();
+
+const DEGREE_LABEL: Record = {
+ 'critical-success': 'Critical Success',
+ success: 'Success',
+ failure: 'Failure',
+ 'critical-failure': 'Critical Failure',
+};
+
+export interface LatestTurn {
+ rollRequests: RollRequest[];
+ actions: DirectorAction[];
+ suggestions: string[];
+}
+
+/** Orchestrates one director session: building the grounded scene, running turns,
+ * recording the human's rolls, and persisting the transcript. UI-layer (it may
+ * touch the roll seam via the cards), never the pure engine. */
+export function useDirectorSession(campaign: Campaign) {
+ const persona = useDirectorStore((s) => s.persona);
+ const narrationStyle = useDirectorStore((s) => s.narrationStyle);
+ const windowSize = useDirectorStore((s) => s.windowSize);
+ const llmReady = useAssistantStore((s) => s.enabled && !!s.apiKey);
+
+ const session = useLatestAiSession(campaign.id, persona);
+ const characters = useCharacters(campaign.id);
+ const encounters = useEncounters(campaign.id);
+ const activeEnc = encounters.find((e) => e.status === 'active');
+
+ const [busy, setBusy] = useState(false);
+ const [source, setSource] = useState<'llm' | 'fallback' | null>(null);
+ const [message, setMessage] = useState(null);
+ const [lastTurn, setLastTurn] = useState(null);
+
+ const run = useCallback(
+ async (input?: string) => {
+ setBusy(true);
+ setMessage(null);
+ try {
+ let sid = session?.id;
+ if (!sid) sid = (await aiSessionsRepo.create(campaign.id, persona)).id;
+
+ if (input && input.trim()) {
+ await aiSessionsRepo.appendEntries(sid, [{ id: newId(), role: 'player-input', text: input.trim(), ts: nowIso() }]);
+ }
+
+ const fresh = await aiSessionsRepo.get(sid);
+ const transcriptWindow = (fresh?.transcript ?? []).slice(-windowSize);
+ const scene = buildDirectorScene({
+ campaign,
+ characters,
+ encounter: activeEnc,
+ persona,
+ narrationStyle,
+ transcriptWindow,
+ summary: fresh?.summary,
+ });
+
+ const res = await runDirectorTurn(getLlmConfig(), scene);
+ setSource(res.source);
+ if (res.message) setMessage(res.message);
+
+ if (res.turn.narration.trim()) {
+ await aiSessionsRepo.appendEntries(sid, [{
+ id: newId(),
+ role: 'narration',
+ ...(res.turn.speaker ? { speaker: res.turn.speaker } : {}),
+ text: res.turn.narration,
+ ts: nowIso(),
+ }]);
+ }
+ setLastTurn({ rollRequests: res.turn.rollRequests, actions: res.turn.actions, suggestions: res.turn.suggestions });
+ } finally {
+ setBusy(false);
+ }
+ },
+ [session?.id, campaign, persona, characters, activeEnc, narrationStyle, windowSize],
+ );
+
+ const recordRoll = useCallback(
+ async (req: RollRequest, result: { total: number; degree?: Degree }) => {
+ if (!session) return;
+ const dc = req.dc !== undefined ? ` (DC ${req.dc})` : '';
+ const deg = result.degree ? ` — ${DEGREE_LABEL[result.degree]}` : '';
+ const text = `${req.label}${dc}: ${result.total}${deg}`;
+ await aiSessionsRepo.appendEntries(session.id, [{
+ id: newId(), role: 'roll-result', ...(req.actor ? { speaker: req.actor } : {}), text, ts: nowIso(),
+ }]);
+ setLastTurn((prev) => (prev ? { ...prev, rollRequests: prev.rollRequests.filter((r) => r.id !== req.id) } : prev));
+ },
+ [session],
+ );
+
+ const restart = useCallback(async () => {
+ if (!session) return;
+ await aiSessionsRepo.mutate(session.id, (s) => ({ ...s, transcript: [], summary: '', summarizedThrough: 0 }));
+ setLastTurn(null);
+ setSource(null);
+ setMessage(null);
+ }, [session]);
+
+ return { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } as const;
+}
diff --git a/src/features/settings/DirectorSettings.tsx b/src/features/settings/DirectorSettings.tsx
new file mode 100644
index 0000000..95de6af
--- /dev/null
+++ b/src/features/settings/DirectorSettings.tsx
@@ -0,0 +1,49 @@
+import { Wand2 } from 'lucide-react';
+import { useDirectorStore } from '@/stores/directorStore';
+import { Input, Field } from '@/components/ui/Input';
+
+/** Tuning for the AI DM / AI Player. It reuses the assistant's AI key (Assistant
+ * section above) and never rolls dice or edits a sheet without your approval. */
+export function DirectorSettings() {
+ const narrationStyle = useDirectorStore((s) => s.narrationStyle);
+ const windowSize = useDirectorStore((s) => s.windowSize);
+ const setConfig = useDirectorStore((s) => s.setConfig);
+
+ return (
+
+ AI Director
+
+
+
+
+
+
+
AI Dungeon Master & Player
+
+ Runs scenes and pilots characters, grounded in your campaign data. It uses the Assistant AI key above; without
+ one it falls back to a deterministic mode. It never auto-rolls dice and never changes a sheet without your
+ approval.
+
+
+
+
+
+ setConfig({ narrationStyle: e.target.value })}
+ />
+
+
+ setConfig({ windowSize: Math.max(4, Math.min(60, Number(e.target.value) || 16)) })}
+ />
+
+
+
+ );
+}
diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx
index b2aed89..57e4d23 100644
--- a/src/features/settings/SettingsPage.tsx
+++ b/src/features/settings/SettingsPage.tsx
@@ -18,6 +18,7 @@ import { pickTextFile } from '@/lib/io/file';
import { seedSampleCampaign } from '@/lib/sample';
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
import { AssistantSettings } from './AssistantSettings';
+import { DirectorSettings } from './DirectorSettings';
import { CloudCampaigns } from './CloudCampaigns';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
@@ -125,6 +126,8 @@ export function SettingsPage() {
+
+
diff --git a/src/lib/assistant/director/context.ts b/src/lib/assistant/director/context.ts
new file mode 100644
index 0000000..cd2759a
--- /dev/null
+++ b/src/lib/assistant/director/context.ts
@@ -0,0 +1,100 @@
+import type { Campaign, Character, Encounter, Combatant } from '@/lib/schemas';
+import { getSystem } from '@/lib/rules';
+import { deriveState } from '@/lib/mechanics';
+import { currentCombatant } from '@/lib/combat/engine';
+import type { DirectorPersona, DirectorScene, SceneActor, SceneEncounter } from './types';
+
+/** Default base speed used for a bare combatant (it carries no speed field). */
+const DEFAULT_SPEED = 30;
+
+function buildSystemConstraint(systemLabel: string, system: string): string {
+ return (
+ `This is a ${systemLabel} (system id: ${system}) campaign. ` +
+ `Only use ${systemLabel} rules, creatures, classes, feats, and options. ` +
+ `Never bring in content from any other game system.`
+ );
+}
+
+function combatantActor(system: Campaign['system'], c: Combatant): SceneActor {
+ const conditions = c.conditions.map((cn) => (cn.value ? `${cn.name} ${cn.value}` : cn.name));
+ const badges = deriveState(system, DEFAULT_SPEED, c.conditions).badges;
+ return {
+ name: c.name,
+ kind: c.kind,
+ ...(c.characterId ? { characterId: c.characterId } : {}),
+ combatantId: c.id,
+ hp: c.hp,
+ ac: c.ac,
+ conditions,
+ ...(badges.length ? { badges } : {}),
+ };
+}
+
+function pcActor(c: Character): SceneActor {
+ const conditions = c.conditions.map((cn) => (cn.value ? `${cn.name} ${cn.value}` : cn.name));
+ const badges = deriveState(c.system, c.speed, c.conditions).badges;
+ return {
+ name: c.name,
+ kind: c.kind === 'npc' ? 'npc' : 'pc',
+ characterId: c.id,
+ hp: c.hp,
+ conditions,
+ ...(badges.length ? { badges } : {}),
+ };
+}
+
+/**
+ * Assemble the grounded scene the director may reference. The roster + addCandidates
+ * are a CLOSED set: when an encounter is active the roster is its live combatants;
+ * otherwise it is the party (plus tracked NPCs). Pure and synchronous.
+ */
+export function buildDirectorScene(input: {
+ campaign: Campaign;
+ characters: Character[];
+ encounter?: Encounter | undefined;
+ persona: DirectorPersona;
+ narrationStyle?: string | undefined;
+ controlledCharacterId?: string | undefined;
+ transcriptWindow: DirectorScene['transcriptWindow'];
+ summary?: string | undefined;
+ addCandidates?: string[] | undefined;
+}): DirectorScene {
+ const { campaign, characters, encounter, persona } = input;
+ const system = campaign.system;
+ const systemLabel = getSystem(system).label;
+
+ let roster: SceneActor[];
+ let sceneEncounter: SceneEncounter | undefined;
+
+ if (encounter && encounter.status === 'active') {
+ const cur = currentCombatant(encounter);
+ roster = encounter.combatants.map((c) => combatantActor(system, c));
+ sceneEncounter = {
+ name: encounter.name,
+ round: encounter.round,
+ ...(cur ? { currentActorName: cur.name } : {}),
+ combatants: encounter.combatants.map((c) => ({ name: c.name, kind: c.kind, initiative: c.initiative, current: c.id === cur?.id })),
+ };
+ } else {
+ roster = characters.filter((c) => c.kind === 'pc' || c.kind === 'npc').map(pcActor);
+ }
+
+ const controlled = input.controlledCharacterId
+ ? roster.find((a) => a.characterId === input.controlledCharacterId)
+ : undefined;
+
+ return {
+ system,
+ systemLabel,
+ systemConstraint: buildSystemConstraint(systemLabel, system),
+ persona,
+ campaignName: campaign.name,
+ ...(input.narrationStyle ? { narrationStyle: input.narrationStyle } : {}),
+ roster,
+ ...(sceneEncounter ? { encounter: sceneEncounter } : {}),
+ ...(controlled ? { controlledName: controlled.name } : {}),
+ addCandidates: input.addCandidates ?? [],
+ transcriptWindow: input.transcriptWindow,
+ ...(input.summary ? { summary: input.summary } : {}),
+ };
+}
diff --git a/src/lib/assistant/director/director.test.ts b/src/lib/assistant/director/director.test.ts
new file mode 100644
index 0000000..c5248fb
--- /dev/null
+++ b/src/lib/assistant/director/director.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect } from 'vitest';
+import { directorTurnSchema } from './schema';
+import { sanitizeTurn, runDirectorTurn } from './engine';
+import { fallbackDirectorTurn } from './fallback';
+import { buildDirectorScene } from './context';
+import type { DirectorScene } from './types';
+import type { LlmConfig } from '@/lib/llm/types';
+import type { Campaign, Character, Encounter } from '@/lib/schemas';
+import { characterSchema, encounterSchema } from '@/lib/schemas';
+
+// ---- fixtures ----
+const campaign5e: Campaign = { id: 'c1', name: 'Test', system: '5e', description: '', createdAt: 't', updatedAt: 't' };
+
+function pc(name: string, hp = 20): Character {
+ return characterSchema.parse({
+ id: `pc-${name}`, campaignId: 'c1', system: '5e', kind: 'pc', name,
+ abilities: { str: 14, dex: 12, con: 13, int: 10, wis: 11, cha: 10 },
+ hp: { current: hp, max: 20, temp: 0 }, createdAt: 't', updatedAt: 't',
+ });
+}
+
+function scene(overrides: Partial = {}): DirectorScene {
+ return {
+ system: '5e', systemLabel: 'D&D 5e', systemConstraint: 'x', persona: 'dm',
+ campaignName: 'Test',
+ roster: [
+ { name: 'Lia', kind: 'pc', conditions: [], hp: { current: 8, max: 20, temp: 0 } },
+ { name: 'Goblin', kind: 'monster', conditions: [], hp: { current: 7, max: 7, temp: 0 } },
+ ],
+ addCandidates: ['Orc'],
+ transcriptWindow: [],
+ ...overrides,
+ };
+}
+
+describe('directorTurnSchema — DeepSeek tolerance', () => {
+ it('parses a clean turn', () => {
+ const t = directorTurnSchema.parse({
+ narration: 'You enter.', rollRequests: [{ actor: 'Lia', label: 'Stealth', kind: 'check', expression: '1d20+5', dc: 15 }],
+ actions: [{ kind: 'damage', target: 'Goblin', amount: 7, damageType: 'slashing' }],
+ suggestions: ['Sneak', 'Charge'],
+ });
+ expect(t.narration).toBe('You enter.');
+ expect(t.rollRequests[0]).toMatchObject({ actor: 'Lia', kind: 'check', dc: 15 });
+ expect(t.actions[0]).toEqual({ kind: 'damage', target: 'Goblin', amount: 7, damageType: 'slashing' });
+ expect(t.suggestions).toEqual(['Sneak', 'Charge']);
+ });
+
+ it('recovers a renamed actions key, type→kind, and stringified numbers', () => {
+ const t = directorTurnSchema.parse({
+ text: 'The goblin lunges.', // narration alias
+ effects: [{ type: 'applyDamage', creature: 'Lia', value: '5' }], // renamed key + verb + alias + string number
+ rolls: [{ dice: '1d20+3', name: 'Scimitar', type: 'attack', who: 'Goblin' }],
+ });
+ expect(t.narration).toBe('The goblin lunges.');
+ expect(t.actions[0]).toEqual({ kind: 'damage', target: 'Lia', amount: 5 });
+ expect(t.rollRequests[0]).toMatchObject({ expression: '1d20+3', label: 'Scimitar', kind: 'attack', actor: 'Goblin' });
+ });
+
+ it('parses a code-fenced object is handled upstream; here drops one bad action among good ones', () => {
+ const t = directorTurnSchema.parse({
+ narration: 'x',
+ actions: [
+ { kind: 'damage', target: 'Goblin', amount: 3 },
+ { kind: 'totally-unknown-verb', foo: 1 }, // unmappable → dropped
+ { kind: 'advanceTurn' },
+ ],
+ });
+ expect(t.actions).toHaveLength(2);
+ expect(t.actions.map((a) => a.kind)).toEqual(['damage', 'advanceTurn']);
+ });
+
+ it('tolerates a completely empty/garbage object', () => {
+ expect(directorTurnSchema.parse({}).narration).toBe('');
+ expect(directorTurnSchema.parse('just a string').narration).toBe('just a string');
+ const t = directorTurnSchema.parse({ narration: 'x' });
+ expect(t.rollRequests).toEqual([]);
+ expect(t.actions).toEqual([]);
+ });
+});
+
+describe('sanitizeTurn — anti-hallucination gate', () => {
+ it('drops actions targeting entities not in the roster, keeps grounded ones', () => {
+ const t = directorTurnSchema.parse({
+ narration: 'x',
+ actions: [
+ { kind: 'damage', target: 'Goblin', amount: 4 }, // in roster → keep
+ { kind: 'damage', target: 'Gandalf', amount: 99 }, // not in roster → drop
+ { kind: 'condition', target: 'Lia', op: 'add', name: 'prone' }, // in roster → keep
+ { kind: 'advanceTurn' }, // always keep
+ ],
+ });
+ const out = sanitizeTurn(t, scene());
+ expect(out.actions.map((a) => (a.kind === 'damage' || a.kind === 'condition' ? a.target : a.kind))).toEqual(['Goblin', 'Lia', 'advanceTurn']);
+ });
+
+ it('drops addCombatant unless the monster is in addCandidates', () => {
+ const t = directorTurnSchema.parse({ narration: 'x', actions: [
+ { kind: 'addCombatant', name: 'Orc' }, // candidate → keep
+ { kind: 'addCombatant', name: 'Tarrasque' }, // not a candidate → drop
+ ] });
+ const out = sanitizeTurn(t, scene());
+ expect(out.actions).toHaveLength(1);
+ expect(out.actions[0]).toMatchObject({ kind: 'addCombatant', name: 'Orc' });
+ });
+
+ it('mints roll ids and blanks an off-board roll target', () => {
+ const t = directorTurnSchema.parse({ narration: 'x', rollRequests: [
+ { actor: 'Goblin', label: 'Attack', kind: 'attack', expression: '1d20+4', against: 'Sauron' },
+ ] });
+ const out = sanitizeTurn(t, scene());
+ expect(out.rollRequests[0]!.id).toBeTruthy();
+ expect(out.rollRequests[0]!.against).toBe(''); // off-board target blanked, roll kept
+ });
+});
+
+describe('runDirectorTurn — fallback', () => {
+ const off: LlmConfig = { enabled: false, provider: 'anthropic', model: 'm', baseUrl: 'b', apiKey: '', rememberKey: false };
+
+ it('returns a deterministic turn when the LLM is disabled', async () => {
+ const r = await runDirectorTurn(off, scene());
+ expect(r.source).toBe('fallback');
+ expect(r.turn.narration.length).toBeGreaterThan(0);
+ });
+
+ it("surfaces an attack roll on an enemy's turn (no auto-roll)", async () => {
+ const s = scene({
+ encounter: { name: 'Ambush', round: 1, currentActorName: 'Goblin', combatants: [
+ { name: 'Lia', kind: 'pc', initiative: 12, current: false },
+ { name: 'Goblin', kind: 'monster', initiative: 15, current: true },
+ ] },
+ });
+ const r = await runDirectorTurn(off, s);
+ expect(r.turn.rollRequests.length).toBeGreaterThanOrEqual(1);
+ expect(r.turn.rollRequests[0]!.kind).toBe('attack');
+ expect(r.turn.actions).toEqual([]); // proposes nothing destructive before the human rolls
+ });
+
+ it('fallback never references an entity outside the roster', () => {
+ const t = fallbackDirectorTurn(scene());
+ const names = new Set(['Lia', 'Goblin']);
+ for (const a of t.actions) {
+ if (a.kind === 'damage' || a.kind === 'heal' || a.kind === 'tempHp' || a.kind === 'condition') expect(names.has(a.target)).toBe(true);
+ }
+ });
+});
+
+describe('buildDirectorScene — grounding', () => {
+ it('uses the active encounter combatants as the roster', () => {
+ const enc: Encounter = encounterSchema.parse({
+ id: 'e1', campaignId: 'c1', name: 'Fight', status: 'active', round: 2, turnIndex: 1,
+ combatants: [
+ { id: 'a', name: 'Lia', kind: 'pc', initiative: 18, ac: 15, hp: { current: 8, max: 20, temp: 0 } },
+ { id: 'b', name: 'Goblin', kind: 'monster', initiative: 14, ac: 13, hp: { current: 7, max: 7, temp: 0 } },
+ ],
+ createdAt: 't', updatedAt: 't',
+ });
+ const s = buildDirectorScene({ campaign: campaign5e, characters: [pc('Lia')], encounter: enc, persona: 'dm', transcriptWindow: [] });
+ expect(s.roster.map((a) => a.name)).toEqual(['Lia', 'Goblin']);
+ expect(s.encounter?.currentActorName).toBe('Goblin'); // turnIndex 1
+ expect(s.systemConstraint).toContain('5e');
+ });
+
+ it('falls back to the party PCs out of combat', () => {
+ const s = buildDirectorScene({ campaign: campaign5e, characters: [pc('Lia'), pc('Borin')], persona: 'dm', transcriptWindow: [] });
+ expect(s.roster.map((a) => a.name).sort()).toEqual(['Borin', 'Lia']);
+ });
+});
diff --git a/src/lib/assistant/director/engine.ts b/src/lib/assistant/director/engine.ts
new file mode 100644
index 0000000..daea8cd
--- /dev/null
+++ b/src/lib/assistant/director/engine.ts
@@ -0,0 +1,72 @@
+import { newId } from '@/lib/ids';
+import { complete } from '@/lib/llm/client';
+import type { LlmConfig } from '@/lib/llm/types';
+import { directorTurnSchema, type DirectorTurn } from './schema';
+import { buildDirectorPrompt, buildDirectorMessages } from './prompt';
+import { fallbackDirectorTurn } from './fallback';
+import { inRoster } from './resolve';
+import type { DirectorScene } from './types';
+
+export interface DirectorTurnResult {
+ turn: DirectorTurn;
+ source: 'llm' | 'fallback';
+ message?: string;
+}
+
+/**
+ * The anti-hallucination gate (the second of three). Mint missing roll ids, blank
+ * roll targets that aren't on the board, and DROP any state-changing action that
+ * references an entity outside the closed roster (or an ungrounded monster for
+ * addCombatant). Roll requests are kept (they never mutate state — a human still
+ * clicks to roll) but cleaned. The third gate is `useDirectorAction`, which
+ * re-resolves names before touching the kernel.
+ */
+export function sanitizeTurn(turn: DirectorTurn, scene: DirectorScene): DirectorTurn {
+ const candidates = new Set(scene.addCandidates.map((c) => c.trim().toLowerCase()));
+
+ const rollRequests = turn.rollRequests.map((r) => ({
+ ...r,
+ id: r.id || newId(),
+ ...(r.against && !inRoster(scene.roster, r.against) ? { against: '' } : {}),
+ }));
+
+ const actions = turn.actions.filter((a) => {
+ switch (a.kind) {
+ case 'damage':
+ case 'heal':
+ case 'tempHp':
+ case 'condition':
+ return inRoster(scene.roster, a.target);
+ case 'castSpell':
+ return inRoster(scene.roster, a.caster);
+ case 'spendResource':
+ return inRoster(scene.roster, a.actor);
+ case 'addCombatant':
+ return candidates.has(a.name.trim().toLowerCase()) || (!!a.monsterRef && candidates.has(a.monsterRef.trim().toLowerCase()));
+ case 'advanceTurn':
+ case 'log':
+ return true;
+ }
+ });
+
+ return { ...turn, rollRequests, actions };
+}
+
+/**
+ * Run one director turn. Uses the LLM when configured (grounded, multi-turn), and
+ * always degrades to a deterministic turn otherwise. The returned turn is already
+ * sanitized against the scene's closed roster.
+ */
+export async function runDirectorTurn(cfg: LlmConfig, scene: DirectorScene): Promise {
+ if (cfg.enabled && cfg.apiKey.trim()) {
+ const { system, user } = buildDirectorPrompt(scene);
+ const messages = buildDirectorMessages(scene);
+ const res = await complete(cfg, { system, user, messages, schema: directorTurnSchema, maxTokens: 2048 });
+ if (res.ok && 'data' in res) return { turn: sanitizeTurn(res.data, scene), source: 'llm' };
+ const message = res.ok
+ ? 'The AI returned an unexpected response. Showing a deterministic turn.'
+ : `AI unavailable (${res.error}). Showing a deterministic turn.`;
+ return { turn: fallbackDirectorTurn(scene), source: 'fallback', message };
+ }
+ return { turn: fallbackDirectorTurn(scene), source: 'fallback' };
+}
diff --git a/src/lib/assistant/director/fallback.ts b/src/lib/assistant/director/fallback.ts
new file mode 100644
index 0000000..b829daa
--- /dev/null
+++ b/src/lib/assistant/director/fallback.ts
@@ -0,0 +1,80 @@
+import { newId } from '@/lib/ids';
+import type { DirectorScene, SceneActor } from './types';
+import type { DirectorTurn, RollRequest } from './schema';
+
+const roll = (r: Omit): RollRequest => ({ id: newId(), ...r });
+
+function lowestHpPc(roster: SceneActor[]): SceneActor | undefined {
+ return roster
+ .filter((a) => a.kind === 'pc' && a.hp)
+ .sort((x, y) => (x.hp!.current - y.hp!.current))[0];
+}
+
+/**
+ * Deterministic director used when the LLM is off/keyless/errored. It still honors
+ * every constraint: it surfaces rolls as buttons (never rolls), and proposes no
+ * state changes on its own. Intentionally plain — it keeps the loop usable offline.
+ */
+export function fallbackDirectorTurn(scene: DirectorScene): DirectorTurn {
+ // Player persona: declare a simple action with the controlled PC's first attack.
+ if (scene.persona === 'player') {
+ const me = scene.roster.find((a) => a.name === scene.controlledName);
+ const atk = me?.attacks?.[0];
+ if (atk) {
+ return {
+ narration: `${me!.name} steps up and strikes with ${atk.name}.`,
+ speaker: me!.name,
+ rollRequests: [roll({ actor: me!.name, label: `${atk.name} attack`, kind: 'attack', expression: atk.expression })],
+ actions: [],
+ suggestions: ['Roll damage if it hits', 'Take a different action', 'Hold and assess'],
+ };
+ }
+ return {
+ narration: `${scene.controlledName ?? 'Your character'} sizes up the situation, ready to act.`,
+ rollRequests: [],
+ actions: [],
+ suggestions: ['Attack', 'Cast a spell', 'Move and take cover'],
+ };
+ }
+
+ // DM persona, in combat, on an enemy's turn: surface an attack roll for the GM.
+ const cur = scene.encounter?.currentActorName
+ ? scene.roster.find((a) => a.name === scene.encounter!.currentActorName)
+ : undefined;
+ if (cur && cur.kind !== 'pc') {
+ const target = lowestHpPc(scene.roster);
+ return {
+ narration: target
+ ? `${cur.name} bears down on ${target.name}, weapon raised.`
+ : `${cur.name} looks for an opening.`,
+ speaker: cur.name,
+ rollRequests: [
+ roll({
+ actor: cur.name,
+ label: `${cur.name} attack`,
+ kind: 'attack',
+ expression: '1d20',
+ ...(target ? { against: target.name } : {}),
+ }),
+ ],
+ actions: [],
+ suggestions: ['Roll the attack, then damage on a hit', "Advance to the next turn"],
+ };
+ }
+
+ // DM persona, a PC's turn or out of combat.
+ if (scene.encounter) {
+ return {
+ narration: cur ? `The moment hangs — it is ${cur.name}'s turn to act.` : 'The fight pauses, tension thick in the air.',
+ rollRequests: [],
+ actions: [],
+ suggestions: ['Describe your action', 'Make an attack', 'Cast a spell', 'Move'],
+ };
+ }
+ return {
+ narration: 'The scene waits on you. The air is still, and the path ahead is yours to choose.',
+ rollRequests: [],
+ actions: [],
+ suggestions: ['Look around', 'Talk to someone here', 'Search the area', 'Travel onward'],
+ };
+}
diff --git a/src/lib/assistant/director/index.ts b/src/lib/assistant/director/index.ts
new file mode 100644
index 0000000..857abec
--- /dev/null
+++ b/src/lib/assistant/director/index.ts
@@ -0,0 +1,8 @@
+export { directorTurnSchema, rollRequestSchema, directorActionSchema } from './schema';
+export type { DirectorTurn, RollRequest, DirectorAction, RollKind } from './schema';
+export type { DirectorScene, SceneActor, SceneEncounter, DirectorPersona, DirectorTranscriptEntry } from './types';
+export { buildDirectorScene } from './context';
+export { buildDirectorPrompt, buildDirectorMessages, cueFor } from './prompt';
+export { fallbackDirectorTurn } from './fallback';
+export { runDirectorTurn, sanitizeTurn, type DirectorTurnResult } from './engine';
+export { resolveCombatant, resolveActor, resolveSpellByName, resolveResourceByName, inRoster } from './resolve';
diff --git a/src/lib/assistant/director/no-auto-roll.test.ts b/src/lib/assistant/director/no-auto-roll.test.ts
new file mode 100644
index 0000000..51c0982
--- /dev/null
+++ b/src/lib/assistant/director/no-auto-roll.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest';
+
+/**
+ * The hard no-auto-roll rule, enforced structurally: the pure director engine must
+ * never reach the roll seam. A roll may fire ONLY from a user click in
+ * RollRequestCard. If any engine module imports `rollAndShow`/`rollDice`/`createRng`
+ * (directly or transitively via `@/lib/useRoll` / `@/lib/dice/notation`), this fails —
+ * making automatic dice rolls impossible in this layer, not merely avoided.
+ */
+const sources = import.meta.glob('./*.ts', { query: '?raw', eager: true, import: 'default' }) as Record;
+
+const FORBIDDEN = ['useRoll', 'rollAndShow', 'rollDice', 'createRng', '@/lib/dice/notation'];
+
+describe('director engine — no-auto-roll import boundary', () => {
+ const engineFiles = Object.entries(sources).filter(([path]) => !path.endsWith('.test.ts'));
+
+ it('covers the engine modules', () => {
+ expect(engineFiles.length).toBeGreaterThanOrEqual(6);
+ });
+
+ for (const [path, src] of engineFiles) {
+ it(`${path} never imports the roll seam`, () => {
+ for (const token of FORBIDDEN) {
+ expect(src.includes(token), `${path} must not reference ${token}`).toBe(false);
+ }
+ });
+ }
+});
diff --git a/src/lib/assistant/director/prompt.ts b/src/lib/assistant/director/prompt.ts
new file mode 100644
index 0000000..5bd4592
--- /dev/null
+++ b/src/lib/assistant/director/prompt.ts
@@ -0,0 +1,125 @@
+import type { DirectorScene, SceneActor } from './types';
+
+/** The exact JSON shape we want back, described for the model (DeepSeek-proofing). */
+const SHAPE = `Respond with ONE JSON object, no prose, no markdown fences:
+{
+ "narration": string, // vivid prose for the table (2-4 sentences)
+ "speaker": string, // optional: the NPC/PC name speaking, if any
+ "rollRequests": [ // rolls a HUMAN must make — you NEVER roll
+ { "actor": string, "label": string, "kind": "check"|"save"|"attack"|"damage"|"custom",
+ "expression": string, // dice notation, e.g. "1d20+5"
+ "dc": number, // optional target number
+ "against": string } // optional target name
+ ],
+ "actions": [ // typed state changes a human will APPROVE — you never apply them
+ { "kind": "damage", "target": string, "amount": number, "damageType": string },
+ { "kind": "condition", "target": string, "op": "add"|"remove", "name": string, "value": number },
+ { "kind": "advanceTurn" },
+ { "kind": "log", "text": string }
+ ],
+ "suggestions": [string] // 2-4 short next-step options for the human
+}
+Numbers are plain integers (5, not "5"). Omit arrays you don't need (use []).`;
+
+function actorLine(a: SceneActor): string {
+ const bits: string[] = [`${a.name} (${a.kind})`];
+ if (a.hp) bits.push(`HP ${a.hp.current}/${a.hp.max}${a.hp.temp ? ` +${a.hp.temp} temp` : ''}`);
+ if (a.ac !== undefined) bits.push(`AC ${a.ac}`);
+ if (a.conditions.length) bits.push(`conditions: ${a.conditions.join(', ')}`);
+ if (a.badges?.length) bits.push(`[${a.badges.join('; ')}]`);
+ return `- ${bits.join(' · ')}`;
+}
+
+function controlledDetail(a: SceneActor): string {
+ const lines: string[] = [];
+ if (a.attacks?.length) lines.push(`Attacks: ${a.attacks.map((w) => `${w.name} (${w.expression} to hit, ${w.damage} ${w.damageType})`).join('; ')}`);
+ if (a.spells?.length) lines.push(`Spells: ${a.spells.map((s) => `${s.name} (lvl ${s.level}${s.concentration ? ', concentration' : ''})`).join('; ')}`);
+ if (a.slots?.length) lines.push(`Spell slots: ${a.slots.filter((s) => s.max > 0).map((s) => `L${s.level} ${s.current}/${s.max}`).join(', ') || 'none'}`);
+ if (a.resources?.length) lines.push(`Resources: ${a.resources.map((r) => `${r.name} ${r.current}/${r.max}`).join(', ')}`);
+ return lines.join('\n');
+}
+
+/** Serialize the closed, grounded scene for the system prompt. */
+function serializeScene(scene: DirectorScene): string {
+ const parts: string[] = [];
+ parts.push(`Campaign: ${scene.campaignName} (${scene.systemLabel}).`);
+ if (scene.encounter) {
+ const e = scene.encounter;
+ parts.push(`Active combat: "${e.name}", round ${e.round}.${e.currentActorName ? ` It is ${e.currentActorName}'s turn.` : ''}`);
+ parts.push(`Initiative: ${e.combatants.map((c) => `${c.name}${c.current ? ' ←' : ''}`).join(', ')}.`);
+ }
+ parts.push('Roster (the ONLY entities you may name — use these exact names):');
+ parts.push(scene.roster.map(actorLine).join('\n') || '- (no one present)');
+ if (scene.controlledName) {
+ const c = scene.roster.find((a) => a.name === scene.controlledName);
+ if (c) {
+ const detail = controlledDetail(c);
+ parts.push(`You control ${c.name}. ${detail ? `\n${detail}` : ''}`);
+ }
+ }
+ if (scene.addCandidates.length) {
+ parts.push(`Monsters you MAY add to combat (choose only from these, by exact name): ${scene.addCandidates.join(', ')}.`);
+ }
+ if (scene.summary) parts.push(`Story so far: ${scene.summary}`);
+ return parts.join('\n');
+}
+
+const RULES = [
+ 'You NEVER roll dice. When a roll is due, emit a rollRequest with the dice expression and DC; a human rolls it and reports the result back to you.',
+ 'You NEVER change game state directly. Propose typed actions; a human approves each one before it takes effect.',
+ 'Only reference creatures, PCs, NPCs, and monsters listed in the roster, by their EXACT names. Do not invent entities, locations as characters, or stats. To add a monster, use an addCombatant action with a name from the candidate list only.',
+].join(' ');
+
+function personaRole(scene: DirectorScene): string {
+ if (scene.persona === 'player') {
+ return (
+ `You are role-playing ${scene.controlledName ?? 'a party member'} — a single player character in this ${scene.systemLabel} party. ` +
+ `Speak and act in the first person AS this character. You control ONLY this character; never act for, narrate, or decide for anyone else. ` +
+ `Use only this character's listed attacks, spells, and resources.`
+ );
+ }
+ return (
+ `You are the Dungeon Master for this ${scene.systemLabel} game. Narrate the world vividly, voice the NPCs and monsters, ` +
+ `set scenes, and run enemies in combat. Adjudicate fairly using ${scene.systemLabel} rules.`
+ );
+}
+
+/** The closing cue (also the last user turn). */
+export function cueFor(scene: DirectorScene): string {
+ if (scene.persona === 'player') return `It is your turn. Decide what ${scene.controlledName ?? 'your character'} does and say it in character.`;
+ if (scene.encounter?.currentActorName) {
+ const cur = scene.roster.find((a) => a.name === scene.encounter!.currentActorName);
+ if (cur && cur.kind !== 'pc') return `It is ${cur.name}'s turn (an enemy you control). Take its turn — describe it and surface any attack rolls.`;
+ }
+ return 'Continue the scene, responding to the latest input.';
+}
+
+/** Build the system + user (cue) prompt for a director turn. */
+export function buildDirectorPrompt(scene: DirectorScene): { system: string; user: string } {
+ const style = scene.narrationStyle ? `\nNarration style: ${scene.narrationStyle}` : '';
+ const system = [
+ scene.systemConstraint,
+ '',
+ personaRole(scene),
+ '',
+ RULES,
+ style,
+ '',
+ serializeScene(scene),
+ '',
+ SHAPE,
+ ].join('\n');
+ return { system, user: cueFor(scene) };
+}
+
+/** Map the transcript window to provider turns, ending with the cue as the final user turn. */
+export function buildDirectorMessages(scene: DirectorScene): { role: 'user' | 'assistant'; content: string }[] {
+ const msgs = scene.transcriptWindow.map((e) => {
+ if (e.role === 'narration') return { role: 'assistant' as const, content: e.speaker ? `${e.speaker}: ${e.text}` : e.text };
+ if (e.role === 'roll-result') return { role: 'user' as const, content: `[Roll] ${e.text}` };
+ if (e.role === 'system') return { role: 'user' as const, content: `[Update] ${e.text}` };
+ return { role: 'user' as const, content: e.speaker ? `${e.speaker}: ${e.text}` : e.text };
+ });
+ msgs.push({ role: 'user', content: cueFor(scene) });
+ return msgs;
+}
diff --git a/src/lib/assistant/director/resolve.ts b/src/lib/assistant/director/resolve.ts
new file mode 100644
index 0000000..f3001b8
--- /dev/null
+++ b/src/lib/assistant/director/resolve.ts
@@ -0,0 +1,35 @@
+import type { Character, CharacterResource, Combatant, Encounter, SpellEntry } from '@/lib/schemas';
+import type { SceneActor } from './types';
+
+const norm = (s: string): string => s.trim().toLowerCase();
+
+/** Exact (case-insensitive) match first, then a unique prefix match either direction. */
+function match(items: T[], name: string, nameOf: (t: T) => string): T | undefined {
+ const n = norm(name);
+ if (!n) return undefined;
+ const exact = items.find((t) => norm(nameOf(t)) === n);
+ if (exact) return exact;
+ const partial = items.filter((t) => norm(nameOf(t)).startsWith(n) || n.startsWith(norm(nameOf(t))));
+ return partial.length === 1 ? partial[0] : undefined;
+}
+
+export function resolveCombatant(enc: Encounter, name: string): Combatant | undefined {
+ return match(enc.combatants, name, (c) => c.name);
+}
+
+export function resolveActor(roster: SceneActor[], name: string): SceneActor | undefined {
+ return match(roster, name, (a) => a.name);
+}
+
+export function resolveSpellByName(c: Character, spell: string): SpellEntry | undefined {
+ return match(c.spellcasting.spells, spell, (s) => s.name);
+}
+
+export function resolveResourceByName(c: Character, resource: string): CharacterResource | undefined {
+ return match(c.resources, resource, (r) => r.name);
+}
+
+/** True when `name` is in the roster (the closed set the director may reference). */
+export function inRoster(roster: SceneActor[], name: string): boolean {
+ return resolveActor(roster, name) !== undefined;
+}
diff --git a/src/lib/assistant/director/schema.ts b/src/lib/assistant/director/schema.ts
new file mode 100644
index 0000000..c06a90c
--- /dev/null
+++ b/src/lib/assistant/director/schema.ts
@@ -0,0 +1,163 @@
+import { z } from 'zod';
+
+/**
+ * DeepSeek-tolerant schema for a director "turn". The model is asked for a single
+ * JSON object, but real-world models (DeepSeek especially) rename keys, stringify
+ * numbers, and wrap arrays oddly. We follow the same pattern as
+ * `normalizeBalanceShape` in prompts.ts: `z.preprocess` to repair the shape
+ * structurally, `z.coerce` on every number, `.catch()` on enums, and per-element
+ * `safeParse`+drop so one malformed action never discards the whole turn.
+ */
+
+const str = z.coerce.string();
+const coerceInt = z.coerce.number().int();
+const coerceAmount = z.coerce.number().int().min(0).max(9999);
+
+function asRecord(v: unknown): Record | undefined {
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : undefined;
+}
+
+function looksLikeRoll(v: unknown): boolean {
+ const o = asRecord(v);
+ return !!o && ('expression' in o || 'dice' in o || 'roll' in o || 'notation' in o);
+}
+function looksLikeAction(v: unknown): boolean {
+ const o = asRecord(v);
+ // Use 'kind'/'action'/'op' — NOT 'type', which roll requests use for their roll kind.
+ return !!o && ('kind' in o || 'action' in o || 'op' in o);
+}
+
+/** First top-level array under a named key, else the first array whose elements match `pred`. */
+function findArrayOf(o: Record, pred: (v: unknown) => boolean, names: string[]): unknown[] | undefined {
+ for (const n of names) if (Array.isArray(o[n])) return o[n] as unknown[];
+ for (const v of Object.values(o)) if (Array.isArray(v) && v.some(pred)) return v;
+ return undefined;
+}
+
+function coerceStringArray(v: unknown): string[] {
+ if (!Array.isArray(v)) return [];
+ return v
+ .map((x) => {
+ if (typeof x === 'string') return x;
+ const o = asRecord(x);
+ return o && typeof o.text === 'string' ? o.text : o && typeof o.label === 'string' ? o.label : '';
+ })
+ .filter((s) => s.trim() !== '');
+}
+
+/** Map free-form action verbs to our discriminant. */
+const KIND_SYNONYMS: Record = {
+ damage: 'damage', dealdamage: 'damage', applydamage: 'damage', hurt: 'damage', hit: 'damage',
+ heal: 'heal', healing: 'heal', restore: 'heal', cure: 'heal',
+ temphp: 'tempHp', temporaryhp: 'tempHp', temphitpoints: 'tempHp', temphealth: 'tempHp',
+ condition: 'condition', applycondition: 'condition', addcondition: 'condition', setcondition: 'condition', status: 'condition',
+ castspell: 'castSpell', cast: 'castSpell', spell: 'castSpell',
+ spendresource: 'spendResource', useresource: 'spendResource', spend: 'spendResource', use: 'spendResource',
+ advanceturn: 'advanceTurn', nextturn: 'advanceTurn', endturn: 'advanceTurn', next: 'advanceTurn', advance: 'advanceTurn',
+ addcombatant: 'addCombatant', spawn: 'addCombatant', summon: 'addCombatant', addmonster: 'addCombatant',
+ log: 'log', note: 'log', message: 'log',
+};
+
+function normalizeRoll(v: unknown): unknown {
+ const r = asRecord(v);
+ if (!r) return { expression: typeof v === 'string' ? v : '1d20', label: 'Roll', kind: 'custom', actor: '' };
+ const out = { ...r };
+ if (out.expression == null) out.expression = out.dice ?? out.roll ?? out.notation ?? '1d20';
+ if (out.label == null) out.label = out.name ?? out.description ?? out.check ?? 'Roll';
+ if (out.kind == null) out.kind = out.type ?? 'custom';
+ if (out.actor == null) out.actor = out.who ?? out.by ?? out.source ?? out.roller ?? '';
+ if (out.dc == null && out.difficulty != null) out.dc = out.difficulty;
+ if (out.against == null && out.vs != null) out.against = out.vs;
+ return out;
+}
+
+function normalizeAction(v: unknown): unknown {
+ const a = asRecord(v);
+ if (!a) return null;
+ const out = { ...a };
+ let kind = out.kind ?? out.action ?? out.type;
+ if (typeof kind === 'string') {
+ const mapped = KIND_SYNONYMS[kind.trim().toLowerCase().replace(/[\s_-]/g, '')];
+ kind = mapped ?? kind.trim();
+ }
+ out.kind = kind;
+ // Field aliasing (best-effort; the discriminated schema keeps only the right ones).
+ if (out.target == null) out.target = out.creature ?? out.combatant ?? out.enemy ?? out.recipient ?? out.who;
+ if (out.amount == null) out.amount = out.value ?? out.hp ?? out.points ?? out.damage;
+ if (out.caster == null) out.caster = out.by ?? out.who ?? out.actor;
+ if (out.actor == null) out.actor = out.by ?? out.who;
+ if (out.resource == null) out.resource = out.resourceName ?? out.pool;
+ if (out.name == null) out.name = out.condition ?? out.monster ?? out.creature;
+ if (out.text == null) out.text = out.message ?? out.note;
+ return out;
+}
+
+function normalizeTurn(raw: unknown): unknown {
+ const o = asRecord(raw);
+ if (!o) return { narration: typeof raw === 'string' ? raw : '' };
+ const out = { ...o };
+ if (out.narration == null) out.narration = out.text ?? out.story ?? out.description ?? out.message ?? out.content ?? '';
+ if (out.speaker == null && out.character != null) out.speaker = out.character;
+
+ const rolls = Array.isArray(out.rollRequests)
+ ? out.rollRequests
+ : findArrayOf(out, looksLikeRoll, ['rollRequests', 'rolls', 'checks', 'requiredRolls', 'rollsRequired']);
+ const actions = Array.isArray(out.actions)
+ ? out.actions
+ : findArrayOf(out, looksLikeAction, ['actions', 'effects', 'mutations', 'updates', 'changes']);
+
+ out.rollRequests = (rolls ?? []).map(normalizeRoll);
+ out.actions = (actions ?? []).map(normalizeAction).filter((x) => x !== null);
+ out.suggestions = coerceStringArray(out.suggestions ?? out.options ?? out.choices ?? out.nextSteps);
+ return out;
+}
+
+export const rollRequestSchema = z.object({
+ /** stable within a turn; minted by the engine when the model omits it */
+ id: str.default(''),
+ actor: str.default(''),
+ label: str.default('Roll'),
+ kind: z.enum(['check', 'save', 'attack', 'damage', 'custom']).catch('custom'),
+ expression: str.default('1d20'),
+ dc: coerceInt.optional(),
+ against: str.optional(),
+});
+export type RollRequest = z.infer;
+export type RollKind = RollRequest['kind'];
+
+export const directorActionSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('damage'), target: str, amount: coerceAmount, damageType: str.optional() }),
+ z.object({ kind: z.literal('heal'), target: str, amount: coerceAmount }),
+ z.object({ kind: z.literal('tempHp'), target: str, amount: coerceAmount }),
+ z.object({ kind: z.literal('condition'), target: str, op: z.enum(['add', 'remove']).catch('add'), name: str, value: coerceInt.optional() }),
+ z.object({ kind: z.literal('castSpell'), caster: str, spell: str, atLevel: coerceInt.optional() }),
+ z.object({ kind: z.literal('spendResource'), actor: str, resource: str, amount: coerceInt.default(1) }),
+ z.object({ kind: z.literal('advanceTurn') }),
+ z.object({ kind: z.literal('addCombatant'), name: str, monsterRef: str.optional() }),
+ z.object({ kind: z.literal('log'), text: str }),
+]);
+export type DirectorAction = z.infer;
+
+export const directorTurnSchema = z.preprocess(
+ normalizeTurn,
+ z.object({
+ narration: str.default(''),
+ speaker: str.optional(),
+ rollRequests: z
+ .array(z.unknown())
+ .default([])
+ .transform((xs) => xs.flatMap((x) => {
+ const p = rollRequestSchema.safeParse(x);
+ return p.success ? [p.data] : [];
+ })),
+ actions: z
+ .array(z.unknown())
+ .default([])
+ .transform((xs) => xs.flatMap((x) => {
+ const p = directorActionSchema.safeParse(x);
+ return p.success ? [p.data] : [];
+ })),
+ suggestions: z.array(str).default([]),
+ }),
+);
+export type DirectorTurn = z.infer;
diff --git a/src/lib/assistant/director/types.ts b/src/lib/assistant/director/types.ts
new file mode 100644
index 0000000..0ade1eb
--- /dev/null
+++ b/src/lib/assistant/director/types.ts
@@ -0,0 +1,55 @@
+import type { SystemId } from '@/lib/rules';
+import type { DirectorPersona, DirectorTranscriptEntry } from '@/lib/schemas';
+
+export type { DirectorPersona, DirectorTranscriptEntry };
+export type { RollRequest, DirectorAction, DirectorTurn, RollKind } from './schema';
+
+/** A single actor the director is allowed to reference. Names are the resolution keys. */
+export interface SceneActor {
+ name: string;
+ kind: 'pc' | 'npc' | 'monster';
+ /** links to a Character sheet (full detail for an AI-piloted PC) */
+ characterId?: string;
+ /** links to a live Encounter combatant (HP/conditions) */
+ combatantId?: string;
+ hp?: { current: number; max: number; temp: number };
+ ac?: number;
+ conditions: string[];
+ /** derived mechanical badges (Speed 0, Disadv. attacks, …) from deriveState */
+ badges?: string[];
+ /** PC detail injected for the actor the director pilots (player persona / AI seat) */
+ attacks?: { name: string; expression: string; damage: string; damageType: string }[];
+ spells?: { name: string; level: number; concentration: boolean }[];
+ resources?: { name: string; current: number; max: number }[];
+ slots?: { level: number; current: number; max: number }[];
+}
+
+export interface SceneEncounter {
+ name: string;
+ round: number;
+ currentActorName?: string;
+ combatants: { name: string; kind: string; initiative: number; current: boolean }[];
+}
+
+/** Everything the director needs for one grounded turn. The roster + addCandidates
+ * form a CLOSED set: the director may reference no entity outside it. */
+export interface DirectorScene {
+ system: SystemId;
+ systemLabel: string;
+ /** anti-cross-system anchor — leads the prompt */
+ systemConstraint: string;
+ persona: DirectorPersona;
+ campaignName: string;
+ /** optional tone hint from settings */
+ narrationStyle?: string;
+ roster: SceneActor[];
+ encounter?: SceneEncounter;
+ /** the PC the director pilots (player persona, or a DM auto-running an NPC turn) */
+ controlledName?: string;
+ /** grounded monster names the director may ADD via addCombatant (DM only) */
+ addCandidates: string[];
+ /** recent transcript turns (token-budgeted window) */
+ transcriptWindow: DirectorTranscriptEntry[];
+ /** rolling summary of older, evicted turns */
+ summary?: string;
+}
diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts
index 644cfe3..95cdf8b 100644
--- a/src/lib/db/db.ts
+++ b/src/lib/db/db.ts
@@ -5,6 +5,7 @@ import type { Encounter } from '@/lib/schemas/encounter';
import type { DiceRoll } from '@/lib/schemas/dice';
import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas/world';
import type { SessionLogEntry } from '@/lib/schemas/sessionLog';
+import type { AiSession } from '@/lib/schemas/aiSession';
import { characterDefaults } from '@/lib/schemas/character';
/**
@@ -27,6 +28,7 @@ export class TtrpgDatabase extends Dexie {
maps!: EntityTable;
homebrew!: EntityTable;
sessionLog!: EntityTable;
+ aiSessions!: EntityTable;
constructor() {
super('ttrpg-manager');
@@ -188,6 +190,10 @@ export class TtrpgDatabase extends Dexie {
if (c.choices === undefined) c.choices = [];
});
});
+
+ // v18 — AI DM / AI Player "director" sessions: a structured transcript per
+ // campaign + persona. New table, so no backfill is needed.
+ this.version(18).stores({ aiSessions: 'id, campaignId, [campaignId+persona]' });
}
}
diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts
index 709dfb0..6431c1c 100644
--- a/src/lib/db/repositories.ts
+++ b/src/lib/db/repositories.ts
@@ -12,6 +12,10 @@ import {
battleMapSchema,
homebrewSchema,
sessionLogEntrySchema,
+ aiSessionSchema,
+ type AiSession,
+ type DirectorPersona,
+ type DirectorTranscriptEntry,
type SessionLogEntry,
type HomebrewKind,
type Homebrew,
@@ -65,7 +69,7 @@ export const campaignsRepo = {
async remove(id: string): Promise {
await db.transaction(
'rw',
- [db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew, db.sessionLog],
+ [db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew, db.sessionLog, db.aiSessions],
async () => {
await db.characters.where('campaignId').equals(id).delete();
await db.encounters.where('campaignId').equals(id).delete();
@@ -76,6 +80,7 @@ export const campaignsRepo = {
await db.maps.where('campaignId').equals(id).delete();
await db.homebrew.where('campaignId').equals(id).delete();
await db.sessionLog.where('campaignId').equals(id).delete();
+ await db.aiSessions.where('campaignId').equals(id).delete();
await db.calendars.delete(id);
await db.campaigns.delete(id);
},
@@ -240,6 +245,56 @@ export const sessionLogRepo = {
},
};
+// ---------------- AI director sessions ----------------
+export const aiSessionsRepo = {
+ listByCampaign(campaignId: string): Promise {
+ return db.aiSessions.where('campaignId').equals(campaignId).toArray();
+ },
+ get(id: string): Promise {
+ return db.aiSessions.get(id);
+ },
+ /** The most-recently-updated session for a campaign + persona, if any. */
+ async latest(campaignId: string, persona: DirectorPersona): Promise {
+ const rows = await db.aiSessions.where('[campaignId+persona]').equals([campaignId, persona]).toArray();
+ return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
+ },
+ async create(campaignId: string, persona: DirectorPersona, opts?: Partial>): Promise {
+ const ts = now();
+ const session = aiSessionSchema.parse({
+ id: newId(), campaignId, persona,
+ title: opts?.title ?? (persona === 'dm' ? 'AI Dungeon Master' : 'AI Player'),
+ ...(opts?.controlledCharacterId ? { controlledCharacterId: opts.controlledCharacterId } : {}),
+ ...(opts?.encounterId ? { encounterId: opts.encounterId } : {}),
+ transcript: [], summary: '', summarizedThrough: 0,
+ createdAt: ts, updatedAt: ts,
+ });
+ await db.aiSessions.add(session);
+ return session;
+ },
+ /** Transactional read-modify-write (mirrors encountersRepo.mutate). */
+ async mutate(id: string, fn: (s: AiSession) => AiSession): Promise {
+ await db.transaction('rw', db.aiSessions, async () => {
+ const current = await db.aiSessions.get(id);
+ if (!current) return;
+ await db.aiSessions.put(aiSessionSchema.parse({ ...fn(current), updatedAt: now() }));
+ });
+ },
+ /** Append durable transcript entries in one transaction. */
+ async appendEntries(id: string, entries: DirectorTranscriptEntry[]): Promise {
+ if (entries.length === 0) return;
+ await this.mutate(id, (s) => ({ ...s, transcript: [...s.transcript, ...entries] }));
+ },
+ async setSummary(id: string, summary: string, summarizedThrough: number): Promise {
+ await this.mutate(id, (s) => ({ ...s, summary, summarizedThrough }));
+ },
+ async update(id: string, patch: Partial>): Promise {
+ await db.aiSessions.update(id, { ...patch, updatedAt: now() });
+ },
+ async remove(id: string): Promise {
+ await db.aiSessions.delete(id);
+ },
+};
+
// ---------------- Notes ----------------
export const notesRepo = {
listByCampaign(campaignId: string): Promise {
diff --git a/src/lib/schemas/aiSession.ts b/src/lib/schemas/aiSession.ts
new file mode 100644
index 0000000..28de26e
--- /dev/null
+++ b/src/lib/schemas/aiSession.ts
@@ -0,0 +1,43 @@
+import { z } from 'zod';
+import { int } from './common';
+
+/** Whether the director is running the world (DM) or piloting one PC (player). */
+export const directorPersonaSchema = z.enum(['dm', 'player']);
+export type DirectorPersona = z.infer;
+
+export const directorTranscriptRoleSchema = z.enum(['narration', 'player-input', 'roll-result', 'system']);
+export type DirectorTranscriptRole = z.infer;
+
+/** One durable line of the director transcript (narration, a human input, a roll result, a system note). */
+export const directorTranscriptEntrySchema = z.object({
+ id: z.string(),
+ role: directorTranscriptRoleSchema,
+ speaker: z.string().max(120).optional(),
+ text: z.string().max(8000),
+ ts: z.string(),
+});
+export type DirectorTranscriptEntry = z.infer;
+
+/**
+ * A persisted AI DM / AI Player ("director") session for a campaign. Holds the
+ * structured transcript plus a rolling `summary` of evicted turns (token budget,
+ * filled in a later phase). One natural document per campaign + persona.
+ */
+export const aiSessionSchema = z.object({
+ id: z.string(),
+ campaignId: z.string(),
+ persona: directorPersonaSchema.default('dm'),
+ /** AI-seat PC when persona === 'player' (or a DM piloting an absent player's PC). */
+ controlledCharacterId: z.string().optional(),
+ /** Bound encounter, if the session is running a specific fight. */
+ encounterId: z.string().optional(),
+ title: z.string().max(120).default('Session'),
+ transcript: z.array(directorTranscriptEntrySchema).default([]),
+ /** Rolling summary of turns folded out of the live window (token budget; phase D4). */
+ summary: z.string().max(8000).default(''),
+ /** Number of transcript entries already represented by `summary`. */
+ summarizedThrough: int.nonnegative().default(0),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+});
+export type AiSession = z.infer;
diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts
index 8618778..db8be87 100644
--- a/src/lib/schemas/index.ts
+++ b/src/lib/schemas/index.ts
@@ -5,3 +5,4 @@ export * from './encounter';
export * from './dice';
export * from './world';
export * from './sessionLog';
+export * from './aiSession';
diff --git a/src/router.tsx b/src/router.tsx
index 4c3b97e..a08b032 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -18,6 +18,7 @@ import { PlayerSetupPage } from '@/features/player/PlayerSetupPage';
import { HomebrewPage } from '@/features/world/HomebrewPage';
import { SettingsPage } from '@/features/settings/SettingsPage';
import { AssistantPage } from '@/features/assistant/AssistantPage';
+import { DirectorPage } from '@/features/assistant/director/DirectorPage';
const rootRoute = createRootRoute({ component: RootLayout });
@@ -42,6 +43,7 @@ const playerRoute = createRoute({ getParentRoute: () => rootRoute, path: '/playe
const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage });
const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
+const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage });
const routeTree = rootRoute.addChildren([
indexRoute,
@@ -61,6 +63,7 @@ const routeTree = rootRoute.addChildren([
homebrewRoute,
settingsRoute,
assistantRoute,
+ directorRoute,
]);
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
diff --git a/src/stores/directorStore.ts b/src/stores/directorStore.ts
new file mode 100644
index 0000000..c9247dd
--- /dev/null
+++ b/src/stores/directorStore.ts
@@ -0,0 +1,25 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import type { DirectorPersona } from '@/lib/schemas';
+
+interface DirectorState {
+ /** which role the director plays by default */
+ persona: DirectorPersona;
+ /** free-text tone hint fed to the narrator (e.g. "grim, terse") */
+ narrationStyle: string;
+ /** how many recent transcript entries to send each turn (token budget) */
+ windowSize: number;
+ setConfig: (patch: Partial>) => void;
+}
+
+export const useDirectorStore = create()(
+ persist(
+ (set) => ({
+ persona: 'dm',
+ narrationStyle: '',
+ windowSize: 16,
+ setConfig: (patch) => set(patch),
+ }),
+ { name: 'ttrpg-director' },
+ ),
+);
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
index fefcb18..811cfb1 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/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/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/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/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/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/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