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:
@@ -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 };
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { cn } from '@/lib/cn';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
|
||||
|
||||
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
||||
const characters = useCharacters(campaign.id);
|
||||
@@ -129,6 +130,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EncounterTipCard campaign={campaign} encounter={encounter} />
|
||||
|
||||
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
|
||||
|
||||
{/* Combatant list */}
|
||||
|
||||
Reference in New Issue
Block a user