From 961fe8655a6c62ae1ae0d4a2df47a8cff4bd0d01 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 02:55:42 +0200 Subject: [PATCH] =?UTF-8?q?D1:=20AI=20Director=20(MVP)=20=E2=80=94=20groun?= =?UTF-8?q?ded=20AI=20DM=20narration=20+=20no-auto-roll=20dice=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first user-visible slice of the AI DM / AI Player "director": a new /director page where the AI narrates the scene, voices NPCs/monsters, and runs enemies — grounded entirely in the campaign's real party and active encounter, and degrading to a deterministic narrator when no AI key is set. Engine (pure, framework-free) in src/lib/assistant/director/: - schema.ts: DeepSeek-tolerant directorTurnSchema (z.preprocess structural repair + z.coerce + .catch() enums + per-element safeParse-drop) → a single validated turn { narration, rollRequests[], actions[], suggestions[] }. - context.ts: buildDirectorScene assembles a CLOSED roster from the active encounter (or the party) with deriveState badges — the only entities the director may name. - prompt.ts: persona-aware system prompt (DM/player) leading with the systemConstraint; multi-turn message mapping from the transcript. - engine.ts: runDirectorTurn + sanitizeTurn — the anti-hallucination gate that drops any action referencing an off-roster entity (and ungrounded addCombatant), mirroring the encounter advisor's candidate filter. - fallback.ts: deterministic director that still surfaces roll buttons. Hard rules, enforced: - NEVER auto-roll: a roll fires only from RollRequestCard's onClick (rollAndShow). A unit test asserts the engine layer never imports the roll seam, making auto-roll structurally impossible. - Approve-each: D1 is read-only (actions render as previews; the Apply bridge lands in D2). The director never writes game state. - Always-on fallback: works with no API key. Also: Dexie v18 `aiSessions` table + aiSessionsRepo (+ cascade delete), a small directorStore, a Settings card, and the /director route + nav entry. Verified in-app on the sample 5e campaign: grounded narration named the real current combatant; on an enemy's turn a "Aller Rosk attack vs Pip Underbough" roll card appeared with diceRolls UNCHANGED until the button was clicked, which then fired exactly one roll and recorded the result back into the transcript. 381 unit tests green; lint clean (3 pre-existing); build OK. Co-Authored-By: Claude Opus 4.8 --- src/app/RootLayout.tsx | 3 +- .../assistant/director/ActionCard.tsx | 16 ++ .../assistant/director/DirectorPage.tsx | 106 +++++++++++ .../assistant/director/RollRequestCard.tsx | 59 ++++++ .../assistant/director/SuggestionChips.tsx | 52 ++++++ .../assistant/director/TranscriptPane.tsx | 55 ++++++ .../assistant/director/actionSummary.ts | 26 +++ src/features/assistant/director/hooks.ts | 12 ++ .../assistant/director/useDirectorSession.ts | 115 ++++++++++++ src/features/settings/DirectorSettings.tsx | 49 +++++ src/features/settings/SettingsPage.tsx | 3 + src/lib/assistant/director/context.ts | 100 +++++++++++ src/lib/assistant/director/director.test.ts | 168 ++++++++++++++++++ src/lib/assistant/director/engine.ts | 72 ++++++++ src/lib/assistant/director/fallback.ts | 80 +++++++++ src/lib/assistant/director/index.ts | 8 + .../assistant/director/no-auto-roll.test.ts | 28 +++ src/lib/assistant/director/prompt.ts | 125 +++++++++++++ src/lib/assistant/director/resolve.ts | 35 ++++ src/lib/assistant/director/schema.ts | 163 +++++++++++++++++ src/lib/assistant/director/types.ts | 55 ++++++ src/lib/db/db.ts | 6 + src/lib/db/repositories.ts | 57 +++++- src/lib/schemas/aiSession.ts | 43 +++++ src/lib/schemas/index.ts | 1 + src/router.tsx | 3 + src/stores/directorStore.ts | 25 +++ tsconfig.app.tsbuildinfo | 2 +- 28 files changed, 1464 insertions(+), 3 deletions(-) create mode 100644 src/features/assistant/director/ActionCard.tsx create mode 100644 src/features/assistant/director/DirectorPage.tsx create mode 100644 src/features/assistant/director/RollRequestCard.tsx create mode 100644 src/features/assistant/director/SuggestionChips.tsx create mode 100644 src/features/assistant/director/TranscriptPane.tsx create mode 100644 src/features/assistant/director/actionSummary.ts create mode 100644 src/features/assistant/director/hooks.ts create mode 100644 src/features/assistant/director/useDirectorSession.ts create mode 100644 src/features/settings/DirectorSettings.tsx create mode 100644 src/lib/assistant/director/context.ts create mode 100644 src/lib/assistant/director/director.test.ts create mode 100644 src/lib/assistant/director/engine.ts create mode 100644 src/lib/assistant/director/fallback.ts create mode 100644 src/lib/assistant/director/index.ts create mode 100644 src/lib/assistant/director/no-auto-roll.test.ts create mode 100644 src/lib/assistant/director/prompt.ts create mode 100644 src/lib/assistant/director/resolve.ts create mode 100644 src/lib/assistant/director/schema.ts create mode 100644 src/lib/assistant/director/types.ts create mode 100644 src/lib/schemas/aiSession.ts create mode 100644 src/stores/directorStore.ts 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 && ( + + )} + + } + /> + + {!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'}` : ''} + + ) : ( + + )} +
+ ); +} 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) => ( + + ))} +
+ )} +