Files
ttrpg_manager/src/lib/assistant/director/director.test.ts
T
NilsBriggen cac5979820 D6: AI Director polish + parity hardening
- pf2e parity: a unit test confirms the director carries the system through
  (systemConstraint + prompt say Pathfinder 2e, no cross-system leak) and still
  produces a grounded deterministic turn. (Degree-of-success/conditions already
  branch per system via the shared kernel.)
- UX: the transcript pane is now a bounded, scrollable region (max-h-[60vh]) so
  long sessions don't blow the page layout; it still auto-scrolls to the newest line.
- e2e: e2e/director.spec.ts exercises the full flow against a real browser — DM
  narrates a grounded scene with no API key (deterministic badge, empty-state
  clears, Continue appears), and the player persona seats a party character and
  takes a turn.

396 unit tests + 2 director e2e green; lint clean; build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 03:25:41 +02:00

219 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { directorTurnSchema } from './schema';
import { sanitizeTurn, runDirectorTurn } from './engine';
import { fallbackDirectorTurn } from './fallback';
import { buildDirectorScene } from './context';
import { buildDirectorPrompt, cueFor } from './prompt';
import type { DirectorScene } from './types';
import type { LlmConfig } from '@/lib/llm/types';
import type { Campaign, Character, Encounter } from '@/lib/schemas';
import { characterSchema, encounterSchema, newSpellEntry } 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']);
});
});
describe('AI Player (player persona)', () => {
function caster(): Character {
return characterSchema.parse({
id: 'pc-Mira', campaignId: 'c1', system: '5e', kind: 'pc', name: 'Mira', className: 'Cleric', level: 5,
abilities: { str: 12, dex: 12, con: 14, int: 10, wis: 16, cha: 10 },
hp: { current: 30, max: 30, temp: 0 },
attacks: [{ id: 'a1', name: 'Mace', ability: 'str', rank: 'trained', damageDice: '1d6', damageType: 'bludgeoning', itemBonus: 0, addAbilityToDamage: true }],
spellcasting: { slots: [{ level: 1, max: 4, current: 4 }], spells: [newSpellEntry({ id: 's1', name: 'Bless', level: 1, concentration: true })] },
resources: [{ id: 'r1', name: 'Channel Divinity', current: 1, max: 1, recovery: 'short' }],
createdAt: 't', updatedAt: 't',
});
}
it('injects the controlled PCs attacks, spells and resources into the scene', () => {
const s = buildDirectorScene({ campaign: campaign5e, characters: [caster()], persona: 'player', controlledCharacterId: 'pc-Mira', transcriptWindow: [] });
expect(s.controlledName).toBe('Mira');
const me = s.roster.find((a) => a.name === 'Mira')!;
expect(me.attacks?.[0]).toMatchObject({ name: 'Mace', damageType: 'bludgeoning' });
expect(me.attacks?.[0]!.expression).toMatch(/^1d20[+-]\d+$/); // derived to-hit
expect(me.spells?.map((sp) => sp.name)).toContain('Bless');
expect(me.resources?.map((r) => r.name)).toContain('Channel Divinity');
});
it('works for a Pathfinder 2e campaign (system carried through, no cross-system leak)', () => {
const campaignPf2e: Campaign = { id: 'c2', name: 'Crypt', system: 'pf2e', description: '', createdAt: 't', updatedAt: 't' };
const pf2ePc = characterSchema.parse({
id: 'pf-Kar', campaignId: 'c2', system: 'pf2e', kind: 'pc', name: 'Kara', className: 'Fighter', level: 3,
abilities: { str: 16, dex: 12, con: 14, int: 10, wis: 11, cha: 10 }, hp: { current: 40, max: 40, temp: 0 },
createdAt: 't', updatedAt: 't',
});
const s = buildDirectorScene({ campaign: campaignPf2e, characters: [pf2ePc], persona: 'dm', transcriptWindow: [] });
expect(s.system).toBe('pf2e');
expect(s.systemConstraint.toLowerCase()).toContain('pathfinder');
const { system } = buildDirectorPrompt(s);
expect(system.toLowerCase()).toContain('pathfinder');
// a deterministic turn is still produced and grounded to the party
const t = fallbackDirectorTurn(s);
expect(t.narration.length).toBeGreaterThan(0);
});
it('prompts in first person as the controlled character and cues its turn', () => {
const s = buildDirectorScene({ campaign: campaign5e, characters: [caster()], persona: 'player', controlledCharacterId: 'pc-Mira', transcriptWindow: [] });
const { system } = buildDirectorPrompt(s);
expect(system).toMatch(/role-playing Mira|playing Mira/i);
expect(system).toMatch(/first person/i);
expect(cueFor(s)).toMatch(/your turn/i);
});
});