Files
ttrpg_manager/e2e/assistant-llm.spec.ts
NilsBriggen dbbf68752e Phase 14: LLM-grounded encounter-balancing tip
- src/lib/assistant/prompts.ts: balanceSuggestionSchema + buildBalancePrompt
  (system message leads with the grounding constraint; restricts choices to the
  provided compendium candidates).
- useEncounterAdvisor hook: builds context → picks system-correct candidates →
  asks the LLM (when configured) for a structured pick, validates + filters names
  to the candidate set, else falls back to the deterministic buildSuggestedEncounter.
  Apply adds the creatures transactionally via encountersRepo.mutate.
- EncounterTipCard in the combat tracker: leads with the detected difficulty
  tendency (or the current difficulty), offers a one-click grounded suggestion
  (AI or deterministic) with propose→confirm Apply.

prompts unit tests + e2e for both the deterministic apply flow and the AI path
(stubbed provider); the AI test also confirms candidate-grounding (only
shortlisted creatures survive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:17:23 +02:00

74 lines
3.1 KiB
TypeScript
Raw Permalink 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 { 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();
});
test('encounter tip suggests and applies a deterministic balance fix', async ({ page }) => {
// Sample campaign: 2 level-3 PCs + a "Goblin Ambush" with 3 goblins.
await page.getByLabel('Settings').click();
await page.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
// Open the seeded encounter in the combat tracker.
await page.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click();
await page.getByText('Goblin Ambush').click();
// The contextual balance tip is present (LLM off → deterministic path).
const tip = page.getByTestId('encounter-tip');
await expect(tip).toBeVisible();
// Each combatant row has a "Dmg" button — use it to count combatants.
const hpControls = page.getByRole('button', { name: 'Dmg' });
const before = await hpControls.count();
await tip.getByRole('button', { name: 'Suggest a fix' }).click();
await expect(tip.getByRole('button', { name: 'Add to encounter' })).toBeVisible();
await tip.getByRole('button', { name: 'Add to encounter' }).click();
// Suggestion closes and at least one combatant was added.
await expect(tip.getByRole('button', { name: 'Add to encounter' })).toBeHidden();
await expect.poll(async () => hpControls.count()).toBeGreaterThan(before);
});
test('encounter tip uses the AI path when a provider is configured', async ({ page }) => {
// Stub the Anthropic endpoint with a canned structured response.
await page.route('**/v1/messages', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
content: [{ type: 'text', text: JSON.stringify({
reasoning: 'An ettin raises the threat to match the party.',
add: [{ name: 'Ettin', count: 1 }],
targetDifficulty: 'Hard',
}) }],
}),
});
});
await page.getByLabel('Settings').click();
await page.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
// Enable the assistant with a dummy key (network is stubbed).
await page.getByLabel('Settings').click();
await page.getByLabel('Enable AI assistant').check();
await page.getByLabel('API key', { exact: true }).fill('sk-stub');
await page.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click();
await page.getByText('Goblin Ambush').click();
const tip = page.getByTestId('encounter-tip');
await tip.getByRole('button', { name: 'Suggest a fix (AI)' }).click();
// The canned AI reasoning + the "AI suggestion" label render.
await expect(tip.getByText('AI suggestion')).toBeVisible();
await expect(tip.getByText('An ettin raises the threat to match the party.')).toBeVisible();
await expect(tip.getByText('1× Ettin')).toBeVisible();
});