D1: AI Director (MVP) — grounded AI DM narration + no-auto-roll dice buttons

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 02:55:42 +02:00
parent 375e22ad6f
commit 961fe8655a
28 changed files with 1464 additions and 3 deletions
@@ -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 (
<div className="flex items-center gap-2 rounded-md border border-dashed border-line bg-panel/60 p-2 text-sm">
<Badge tone="arcane">proposed</Badge>
<span className="text-muted">{actionSummary(action)}</span>
</div>
);
}
@@ -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 <RequireCampaign>{(c) => <Director campaign={c} />}</RequireCampaign>;
}
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 (
<Page>
<PageHeader
title="AI Director"
subtitle={`${campaign.name} · ${persona === 'dm' ? 'Dungeon Master' : 'Player'}`}
actions={
<div className="flex items-center gap-2">
{source && <Badge tone={source === 'llm' ? 'arcane' : 'default'}>{source === 'llm' ? 'AI' : 'deterministic'}</Badge>}
{started && (
<Button size="sm" variant="ghost" onClick={() => void restart()}>
Restart
</Button>
)}
</div>
}
/>
{!llmReady && (
<p className="mb-3 text-xs text-muted">
No AI key configured the director runs in deterministic mode. Add a key in{' '}
<Link to="/settings" className="text-accent underline-offset-2 hover:underline">
Settings
</Link>{' '}
for full narration.
</p>
)}
{message && (
<p className="mb-3 text-sm text-muted" aria-live="polite">
{message}
</p>
)}
{activeEnc && (
<p className="mb-3 text-xs text-muted">
Running combat: <span className="text-ink">{activeEnc.name}</span> (round {activeEnc.round}).
</p>
)}
<div className="grid gap-6 lg:grid-cols-[1fr_330px]">
<section className="space-y-4">
<TranscriptPane entries={entries} />
{lastTurn && lastTurn.rollRequests.length > 0 && (
<div className="space-y-2">
<div className="smallcaps">Rolls you roll these</div>
{lastTurn.rollRequests.map((r) => (
<RollRequestCard key={r.id} req={r} system={campaign.system} onRolled={recordRoll} />
))}
</div>
)}
{lastTurn && lastTurn.actions.length > 0 && (
<div className="space-y-2">
<div className="smallcaps">Proposed changes</div>
{lastTurn.actions.map((a, i) => (
<ActionCard key={i} action={a} />
))}
<p className="text-[11px] text-muted">
One-click Apply (routed through the rules engine) arrives in the next update for now, resolve these on
your sheet and tracker.
</p>
</div>
)}
</section>
<aside className="space-y-4">
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 smallcaps">Your move</div>
{!started ? (
<Button className="w-full" disabled={busy} onClick={() => void run()}>
{busy ? 'Starting…' : 'Begin the scene'}
</Button>
) : (
<div className="space-y-3">
<Button className="w-full" disabled={busy} onClick={() => void run()}>
{busy ? 'Thinking…' : 'Continue'}
</Button>
<SuggestionChips suggestions={lastTurn?.suggestions ?? []} disabled={busy} onPick={(t) => void run(t)} />
</div>
)}
</div>
</aside>
</div>
</Page>
);
}
@@ -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 (
<div className="flex items-center gap-3 rounded-md border border-line bg-surface-2 p-2.5">
<Dices size={16} className="shrink-0 text-accent" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-ink">
{req.label}
{req.against ? <span className="text-muted"> vs {req.against}</span> : null}
</div>
<div className="font-mono text-xs text-muted">
{req.expression}
{req.dc !== undefined ? ` · DC ${req.dc}` : ''}
</div>
</div>
{done ? (
<Badge tone={done.degree ? (done.degree.includes('success') ? 'success' : 'ember') : 'gold'}>
{done.total}
{done.degree ? ` · ${done.degree.includes('critical') ? 'Crit ' : ''}${done.degree.includes('success') ? 'Success' : 'Fail'}` : ''}
</Badge>
) : (
<Button size="sm" onClick={roll}>Roll</Button>
)}
</div>
);
}
@@ -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 (
<div className="space-y-2">
{suggestions.length > 0 && (
<div className="flex flex-wrap gap-2">
{suggestions.map((s, i) => (
<Button key={i} size="sm" variant="subtle" disabled={disabled} onClick={() => onPick(s)}>
{s}
</Button>
))}
</div>
)}
<Textarea
rows={2}
value={text}
disabled={disabled}
placeholder="Say or do something… (Ctrl/Cmd+Enter to send)"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
send();
}
}}
/>
<Button className="w-full" disabled={disabled || !text.trim()} onClick={send}>
Send
</Button>
</div>
);
}
@@ -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<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [entries.length]);
if (entries.length === 0) {
return (
<p className="rounded-lg border border-dashed border-line bg-panel/50 p-8 text-center text-sm text-muted">
The story begins when you start the scene.
</p>
);
}
return (
<div className="space-y-3">
{entries.map((e) => (
<TranscriptLine key={e.id} entry={e} />
))}
<div ref={endRef} />
</div>
);
}
function TranscriptLine({ entry }: { entry: DirectorTranscriptEntry }) {
switch (entry.role) {
case 'narration':
return (
<div className="rounded-lg border border-line bg-panel p-3">
{entry.speaker && <span className="font-display text-sm font-semibold text-accent">{entry.speaker}. </span>}
<span className="text-sm leading-relaxed text-ink">{entry.text}</span>
</div>
);
case 'player-input':
return (
<div className="ml-6 rounded-lg border border-accent/30 bg-accent-glow/40 p-2.5">
<span className="text-[11px] uppercase tracking-wide text-accent-deep">{entry.speaker ?? 'You'}</span>
<p className="text-sm text-ink">{entry.text}</p>
</div>
);
case 'roll-result':
return (
<p className="flex items-center gap-1.5 pl-1 font-mono text-xs text-accent-deep">
<Dices size={13} aria-hidden /> {entry.text}
</p>
);
case 'system':
return <p className="pl-1 text-xs italic text-muted">{entry.text}</p>;
}
}
@@ -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;
}
}
+12
View File
@@ -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);
}
@@ -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<Degree, string> = {
'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<string | null>(null);
const [lastTurn, setLastTurn] = useState<LatestTurn | null>(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;
}