Phase 15: level-up strategy advisor + campaign insights

- Level-up build-route advisor (src/features/characters/sheet/LevelUpAdvisor.tsx +
  useLevelUpAdvisor): ~4 system-aware routes plus a custom one; choosing a route
  expands it into concrete next-level steps. AI when configured, deterministic
  fallback (src/lib/assistant/levelup.ts) otherwise. Embedded in the existing
  HP-only LevelUpModal, which stays primary.
- Level-up prompts/schemas added to prompts.ts (buildRoutes/buildSteps), each
  leading with the system constraint + class + next level.
- Campaign Insights section on the Assistant page renders deterministic themes,
  with an optional 'ask the assistant to expand' affordance when AI is on.

levelup + prompt unit tests (4 routes both systems, system vocabulary, custom
route); e2e for the deterministic route→steps flow and the insights section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:23:16 +02:00
parent dbbf68752e
commit e39def8f02
10 changed files with 443 additions and 2 deletions
@@ -0,0 +1,89 @@
import { useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useLevelUpAdvisor } from '@/features/assistant/useLevelUpAdvisor';
/**
* Build-route advisor shown alongside the HP-only level-up flow. Presents ~4
* system-aware routes (plus a custom one); choosing one expands it into concrete
* steps. AI-powered when configured, deterministic otherwise.
*/
export function LevelUpAdvisor({ campaign, character }: { campaign: Campaign; character: Character }) {
const { state, routes, steps, chosen, source, message, canUseLlm, fetchRoutes, chooseRoute } =
useLevelUpAdvisor(campaign, character);
const [custom, setCustom] = useState('');
if (state === 'idle') {
return (
<div className="rounded-md border border-line bg-surface p-3">
<div className="mb-2 text-sm text-muted">Not sure where to take this character?</div>
<Button size="sm" variant="secondary" onClick={fetchRoutes}>
{canUseLlm ? 'Suggest build routes (AI)' : 'Suggest build routes'}
</Button>
</div>
);
}
return (
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
{source === 'llm' ? 'AI build routes' : 'Build routes'}
</span>
{state === 'loading' && <span className="text-xs text-muted">Thinking</span>}
</div>
{message && <p className="mb-2 text-xs text-muted">{message}</p>}
{routes && (
<ul className="space-y-2">
{routes.map((r) => (
<li key={r.title} className="rounded-md border border-line p-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-ink">{r.title}</div>
<div className="text-xs text-muted">{r.summary}</div>
<div className="text-xs italic text-muted">{r.playstyle}</div>
</div>
<Button size="sm" variant={chosen === r.title ? 'primary' : 'secondary'} onClick={() => chooseRoute(r.title)}>
Choose
</Button>
</div>
{chosen === r.title && steps && (
<ol className="mt-2 space-y-1 border-t border-line pt-2">
{steps.map((s, i) => (
<li key={i} className="text-sm text-ink">
<span className="font-medium">{s.label}:</span> <span className="text-muted">{s.detail}</span>
</li>
))}
</ol>
)}
</li>
))}
</ul>
)}
<div className="mt-3 flex gap-2">
<Input
value={custom}
onChange={(e) => setCustom(e.target.value)}
placeholder="Or describe your own route…"
aria-label="Custom build route"
className="text-sm"
/>
<Button size="sm" variant="secondary" disabled={!custom.trim()} onClick={() => chooseRoute(custom.trim())}>
Plan it
</Button>
</div>
{chosen && !routes?.some((r) => r.title === chosen) && steps && (
<ol className="mt-2 space-y-1 rounded-md border border-line p-2" data-testid="custom-steps">
{steps.map((s, i) => (
<li key={i} className="text-sm text-ink">
<span className="font-medium">{s.label}:</span> <span className="text-muted">{s.detail}</span>
</li>
))}
</ol>
)}
</div>
);
}