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>
This commit is contained in:
2026-06-10 03:25:41 +02:00
parent ab8956f0cb
commit cac5979820
3 changed files with 62 additions and 1 deletions
+44
View File
@@ -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();
});
@@ -18,7 +18,7 @@ export function TranscriptPane({ entries }: { entries: DirectorTranscriptEntry[]
} }
return ( return (
<div className="space-y-3"> <div className="max-h-[60vh] space-y-3 overflow-y-auto pr-1">
{entries.map((e) => ( {entries.map((e) => (
<TranscriptLine key={e.id} entry={e} /> <TranscriptLine key={e.id} entry={e} />
))} ))}
@@ -191,6 +191,23 @@ describe('AI Player (player persona)', () => {
expect(me.resources?.map((r) => r.name)).toContain('Channel Divinity'); 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', () => { 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 s = buildDirectorScene({ campaign: campaign5e, characters: [caster()], persona: 'player', controlledCharacterId: 'pc-Mira', transcriptWindow: [] });
const { system } = buildDirectorPrompt(s); const { system } = buildDirectorPrompt(s);