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>
This commit is contained in:
2026-06-08 09:17:23 +02:00
parent f84e429ca4
commit dbbf68752e
7 changed files with 407 additions and 1 deletions
+73
View File
@@ -0,0 +1,73 @@
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();
});