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
+168
View File
@@ -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> = {}): 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']);
});
});