From cac5979820356d8b238597479d8b2ee4f06bd5d5 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 03:25:41 +0200 Subject: [PATCH] D6: AI Director polish + parity hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- e2e/director.spec.ts | 44 +++++++++++++++++++ .../assistant/director/TranscriptPane.tsx | 2 +- src/lib/assistant/director/director.test.ts | 17 +++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 e2e/director.spec.ts diff --git a/e2e/director.spec.ts b/e2e/director.spec.ts new file mode 100644 index 0000000..3227fde --- /dev/null +++ b/e2e/director.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(async () => { + indexedDB.deleteDatabase('ttrpg-manager'); + localStorage.clear(); + }); + await page.reload(); + // Load the sample campaign (gives us a party + an active encounter). + await page.getByLabel('Settings').click(); + await page.getByRole('button', { name: 'Load sample campaign' }).click(); + await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); +}); + +test('AI Director narrates a grounded scene with no API key (deterministic)', async ({ page }) => { + await page.getByLabel('Primary').getByRole('link', { name: 'AI Director' }).click(); + await expect(page.getByRole('heading', { name: 'AI Director' })).toBeVisible(); + await expect(page.getByText(/deterministic mode/)).toBeVisible(); + + // Empty until we start. + await expect(page.getByText('The story begins when you start the scene.')).toBeVisible(); + + await page.getByRole('button', { name: 'Begin the scene' }).click(); + + // A narration entry appears; the empty-state is gone; source badge shows deterministic. + await expect(page.getByText('The story begins when you start the scene.')).toBeHidden(); + await expect(page.getByText('deterministic', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible(); +}); + +test('the director can play a chosen party character (player persona)', async ({ page }) => { + await page.getByLabel('Primary').getByRole('link', { name: 'AI Director' }).click(); + + await page.getByRole('button', { name: 'Player', exact: true }).click(); + // Subtitle reflects the mode. + await expect(page.getByText(/· Player/)).toBeVisible(); + + // Seat the AI on the first party character. + await page.getByLabel('Character the AI plays').selectOption({ index: 1 }); + + await page.getByRole('button', { name: /Take the first action|Take turn/ }).click(); + await expect(page.getByText('deterministic', { exact: true })).toBeVisible(); +}); diff --git a/src/features/assistant/director/TranscriptPane.tsx b/src/features/assistant/director/TranscriptPane.tsx index 5467c1b..221cd92 100644 --- a/src/features/assistant/director/TranscriptPane.tsx +++ b/src/features/assistant/director/TranscriptPane.tsx @@ -18,7 +18,7 @@ export function TranscriptPane({ entries }: { entries: DirectorTranscriptEntry[] } return ( -
+
{entries.map((e) => ( ))} diff --git a/src/lib/assistant/director/director.test.ts b/src/lib/assistant/director/director.test.ts index fda78c9..87fb7e5 100644 --- a/src/lib/assistant/director/director.test.ts +++ b/src/lib/assistant/director/director.test.ts @@ -191,6 +191,23 @@ describe('AI Player (player persona)', () => { 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);