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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user