cac5979820
- 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>
219 lines
11 KiB
TypeScript
219 lines
11 KiB
TypeScript
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 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);
|
||
});
|
||
});
|