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 { 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 PC’s 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); }); });