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
@@ -0,0 +1,79 @@
import type { Campaign, Encounter } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { useEncounterAdvisor } from './useEncounterAdvisor';
/**
* Contextual encounter-balancing tip. Shows the detected difficulty tendency and,
* on demand, a grounded recommendation (AI when configured, deterministic otherwise)
* with one-click apply. Renders nothing when there's no tendency to report.
*/
export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign; encounter: Encounter }) {
const { theme, hasMonsters, currentDifficulty, state, suggestion, source, message, canUseLlm, run, apply } =
useEncounterAdvisor(campaign, encounter);
// Surface when there's a detected tendency, an encounter with monsters to balance,
// or an in-progress/ready suggestion. Stay out of the way otherwise.
if (!theme && !hasMonsters && state === 'idle') return null;
return (
<div
className={cn('mb-4 rounded-lg border p-3', theme ? 'border-warning/40 bg-warning/5' : 'border-line bg-panel')}
data-testid="encounter-tip"
>
<div className="flex items-start gap-3">
<span className="text-lg" aria-hidden>🧠</span>
<div className="min-w-0 flex-1">
{theme ? (
<>
<div className="text-sm font-medium text-ink">{theme.tendency}</div>
<div className="text-xs text-muted">{theme.evidence}</div>
</>
) : (
<>
<div className="text-sm font-medium text-ink">Encounter balance</div>
<div className="text-xs text-muted">
{currentDifficulty ? (
<>Currently <span className="capitalize">{currentDifficulty}</span> for the party.</>
) : (
'Add a creature suggestion for your party.'
)}
</div>
</>
)}
{state === 'ready' && suggestion && (
<div className="mt-2 rounded-md border border-line bg-surface p-2">
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
</span>
<span className="text-xs text-muted"> {suggestion.targetDifficulty}</span>
</div>
<p className="mb-2 text-sm text-ink">{suggestion.reasoning}</p>
<ul className="mb-2 text-sm text-ink">
{suggestion.add.map((a) => (
<li key={a.name}> {a.count}× {a.name}</li>
))}
</ul>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={apply}>Add to encounter</Button>
<Button size="sm" variant="ghost" onClick={run}>Regenerate</Button>
</div>
</div>
)}
{message && <p className="mt-1 text-xs text-muted">{message}</p>}
{state !== 'ready' && (
<div className="mt-2">
<Button size="sm" variant="secondary" onClick={run} disabled={state === 'loading'}>
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Suggest a fix (AI)' : 'Suggest a fix'}
</Button>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,158 @@
import { useMemo, useRef, useState } from 'react';
import type { Campaign, Encounter } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { addCombatant } from '@/lib/combat/engine';
import { computeBudget } from '@/lib/combat/budget';
import { loadMonsters, loadPf2e } from '@/lib/compendium';
import { complete } from '@/lib/llm/client';
import { getLlmConfig } from '@/stores/assistantStore';
import { useAssistantStore } from '@/stores/assistantStore';
import { buildCampaignContext } from '@/lib/assistant/context';
import { pickCreatureCandidates } from '@/lib/assistant/context';
import { detectThemes, type CampaignTheme } from '@/lib/assistant/patterns';
import { buildBalancePrompt, balanceSuggestionSchema, type BalanceSuggestion } from '@/lib/assistant/prompts';
import { buildSuggestedEncounter } from '@/lib/assistant/encounter';
import { useCharacters } from '@/features/characters/hooks';
import { useNotes, useQuests } from '@/features/world/hooks';
import { useEncounters } from '@/features/combat/hooks';
export type AdvisorState = 'idle' | 'loading' | 'ready' | 'error';
const ORDINAL: Record<string, number> = {
trivial: 0, easy: 1, low: 1, medium: 2, moderate: 2, hard: 3, severe: 3, deadly: 4, extreme: 4,
};
const TIER_LABEL: Record<Campaign['system'], Record<number, string>> = {
'5e': { 2: 'Medium', 3: 'Hard', 4: 'Deadly' },
pf2e: { 2: 'Moderate', 3: 'Severe', 4: 'Extreme' },
};
/** One tier above the current difficulty, floored at Medium/Moderate. */
function nextTarget(system: Campaign['system'], current: string): string {
const ord = ORDINAL[current] ?? 0;
const desired = Math.min(4, Math.max(2, ord + 1));
return TIER_LABEL[system][desired]!;
}
function toCombatant(system: Campaign['system'], m: Record<string, unknown>) {
const is5e = system === '5e';
const name = String(m.name);
const ac = Number(is5e ? m.armor_class : m.ac) || 10;
const hp = Number(is5e ? m.hit_points : m.hp) || 1;
const initBonus = is5e ? Math.floor(((Number(m.dexterity) || 10) - 10) / 2) : Number(m.perception) || 0;
const cr = Number(m.cr);
const level = Number(m.level);
return {
id: newId(), name, kind: 'monster' as const,
initiative: rollDice('1d20', createRng()).total + initBonus, initBonus,
ac, hp: { current: hp, max: hp, temp: 0 }, conditions: [], notes: '',
...(is5e && Number.isFinite(cr) ? { cr } : {}),
...(!is5e && Number.isFinite(level) ? { level } : {}),
};
}
function countByName(chosen: Record<string, unknown>[]): BalanceSuggestion['add'] {
const counts = new Map<string, number>();
for (const m of chosen) {
const name = String(m.name);
counts.set(name, (counts.get(name) ?? 0) + 1);
}
return [...counts].map(([name, count]) => ({ name, count }));
}
export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const characters = useCharacters(campaign.id);
const notes = useNotes(campaign.id);
const quests = useQuests(campaign.id);
const encounters = useEncounters(campaign.id);
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const ctx = useMemo(
() => buildCampaignContext({ campaign, characters, encounters, quests, notes }),
[campaign, characters, encounters, quests, notes],
);
const theme = useMemo<CampaignTheme | undefined>(
() => detectThemes(ctx).find((t) => t.kind === 'encounter-difficulty'),
[ctx],
);
const { hasMonsters, currentDifficulty } = useMemo(() => {
const monsters = encounter.combatants.filter((c) => c.kind === 'monster').map((c) => ({ cr: c.cr, level: c.level }));
const diff = monsters.length && ctx.partyLevels.length
? computeBudget(campaign.system, ctx.partyLevels, monsters).difficulty
: undefined;
return { hasMonsters: monsters.length > 0, currentDifficulty: diff };
}, [encounter.combatants, ctx.partyLevels, campaign.system]);
const [state, setState] = useState<AdvisorState>('idle');
const [suggestion, setSuggestion] = useState<BalanceSuggestion | undefined>();
const [source, setSource] = useState<'llm' | 'deterministic'>('deterministic');
const [message, setMessage] = useState<string | null>(null);
const poolRef = useRef<Record<string, unknown>[]>([]);
const canUseLlm = llmEnabled && hasKey;
const run = async () => {
setState('loading');
setMessage(null);
setSuggestion(undefined);
try {
const raw = (campaign.system === '5e' ? await loadMonsters() : await loadPf2e('creatures')) as unknown as Record<string, unknown>[];
poolRef.current = raw;
const partyLevels = ctx.partyLevels;
if (partyLevels.length === 0) { setMessage('Add player characters first.'); setState('error'); return; }
const monsters = encounter.combatants.filter((c) => c.kind === 'monster').map((c) => ({ cr: c.cr, level: c.level }));
const current = computeBudget(campaign.system, partyLevels, monsters).difficulty;
const target = nextTarget(campaign.system, current);
const candidates = pickCreatureCandidates(campaign.system, partyLevels, raw, target, 14);
if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; }
if (canUseLlm) {
const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates });
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
if (res.ok && 'data' in res) {
const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name));
if (valid.length) {
setSuggestion({ ...res.data, add: valid });
setSource('llm');
setState('ready');
return;
}
} else if (!res.ok) {
setMessage(`AI unavailable (${res.error}); showing a deterministic pick.`);
}
}
// Deterministic fallback
const chosen = buildSuggestedEncounter(campaign.system, partyLevels, raw as (Record<string, unknown> & { cr?: number; level?: number })[], target);
if (!chosen.length) { setMessage('Could not assemble a balanced suggestion.'); setState('error'); return; }
setSuggestion({ reasoning: `A level-appropriate pick to reach ${target} difficulty.`, add: countByName(chosen), targetDifficulty: target });
setSource('deterministic');
setState('ready');
} catch {
setMessage('Something went wrong building a suggestion.');
setState('error');
}
};
const apply = async () => {
if (!suggestion) return;
await encountersRepo.mutate(encounter.id, (e) => {
let next = e;
for (const a of suggestion.add) {
const m = poolRef.current.find((p) => String(p.name) === a.name);
if (!m) continue;
for (let i = 0; i < a.count; i++) next = addCombatant(next, toCombatant(campaign.system, m));
}
return next;
});
setState('idle');
setSuggestion(undefined);
};
return { theme, hasMonsters, currentDifficulty, state, suggestion, source, message, canUseLlm, run, apply };
}