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
+4
View File
@@ -17,6 +17,7 @@ import { useEncounters } from '@/features/combat/hooks';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { CampaignInsights } from './CampaignInsights';
export function AssistantPage() {
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
@@ -126,6 +127,9 @@ function Assistant({ campaign }: { campaign: Campaign }) {
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2>
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2>
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
</section>
</div>
</Page>
@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react';
import type { Campaign, Character, Encounter, Note, Quest } from '@/lib/schemas';
import { buildCampaignContext } from '@/lib/assistant/context';
import { detectThemes, type CampaignTheme } from '@/lib/assistant/patterns';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
const SEV_CLS = { info: 'border-line', warn: 'border-warning/50', danger: 'border-danger/50' } as const;
/** Deterministic campaign themes as cards, with an optional AI "expand" affordance. */
export function CampaignInsights({ campaign, characters, encounters, quests, notes }: {
campaign: Campaign;
characters: Character[];
encounters: Encounter[];
quests: Quest[];
notes: Note[];
}) {
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const ctx = useMemo(
() => buildCampaignContext({ campaign, characters, encounters, quests, notes }),
[campaign, characters, encounters, quests, notes],
);
const themes = useMemo(() => detectThemes(ctx), [ctx]);
if (themes.length === 0) {
return <p className="text-sm text-muted">No trends detected yet play a few more sessions.</p>;
}
return (
<ul className="space-y-2">
{themes.map((t) => (
<InsightCard key={t.kind} theme={t} ctx={ctx} canUseLlm={canUseLlm} />
))}
</ul>
);
}
function InsightCard({ theme, ctx, canUseLlm }: { theme: CampaignTheme; ctx: ReturnType<typeof buildCampaignContext>; canUseLlm: boolean }) {
const [expanded, setExpanded] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const expand = async () => {
setLoading(true);
const res = await complete(getLlmConfig(), {
system: `${ctx.systemConstraint}\n\nYou are a concise ${ctx.systemLabel} GM coach. Give 23 sentences of practical advice.`,
user: `Trend: ${theme.tendency} ${theme.evidence} How should the GM respond?`,
maxTokens: 400,
});
setExpanded(res.ok && 'text' in res ? res.text : 'Could not reach the assistant.');
setLoading(false);
};
return (
<li className={cn('rounded-lg border bg-panel p-3', SEV_CLS[theme.severity])}>
<div className="text-sm font-medium text-ink">{theme.tendency}</div>
<div className="text-xs text-muted">{theme.evidence}</div>
{theme.recommendation && <div className="mt-1 text-sm text-ink">{theme.recommendation}</div>}
{canUseLlm && (
<div className="mt-2">
<Button size="sm" variant="ghost" onClick={expand} disabled={loading}>
{loading ? 'Thinking…' : 'Ask the assistant to expand'}
</Button>
</div>
)}
{expanded && <p className="mt-2 whitespace-pre-wrap rounded-md border border-line bg-surface p-2 text-sm text-ink">{expanded}</p>}
</li>
);
}
@@ -0,0 +1,70 @@
import { useMemo, useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildCampaignContext } from '@/lib/assistant/context';
import {
buildLevelUpRoutesPrompt, buildLevelUpStepsPrompt, buildRoutesSchema, buildStepsSchema,
type BuildRoute, type BuildStep,
} from '@/lib/assistant/prompts';
import { deterministicRoutes, deterministicSteps } from '@/lib/assistant/levelup';
export type AdvisorState = 'idle' | 'loading' | 'ready' | 'error';
export function useLevelUpAdvisor(campaign: Campaign, character: Character) {
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const nextLevel = Math.min(20, character.level + 1);
const ctx = useMemo(
() => buildCampaignContext({ campaign, characters: [character], encounters: [], quests: [], notes: [] }),
[campaign, character],
);
const [state, setState] = useState<AdvisorState>('idle');
const [routes, setRoutes] = useState<BuildRoute[] | undefined>();
const [steps, setSteps] = useState<BuildStep[] | undefined>();
const [chosen, setChosen] = useState<string | undefined>();
const [source, setSource] = useState<'llm' | 'deterministic'>('deterministic');
const [message, setMessage] = useState<string | null>(null);
const fetchRoutes = async () => {
setState('loading');
setMessage(null);
setSteps(undefined);
setChosen(undefined);
if (canUseLlm) {
const prompt = buildLevelUpRoutesPrompt(ctx, character, nextLevel);
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: buildRoutesSchema, maxTokens: 700 });
if (res.ok && 'data' in res && res.data.routes.length) {
setRoutes(res.data.routes);
setSource('llm');
setState('ready');
return;
}
if (!res.ok) setMessage(`AI unavailable (${res.error}); showing general routes.`);
}
setRoutes(deterministicRoutes(campaign.system, character.className));
setSource('deterministic');
setState('ready');
};
const chooseRoute = async (title: string) => {
setChosen(title);
setSteps(undefined);
if (canUseLlm) {
const prompt = buildLevelUpStepsPrompt(ctx, character, nextLevel, title);
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: buildStepsSchema, maxTokens: 700 });
if (res.ok && 'data' in res && res.data.steps.length) {
setSteps(res.data.steps);
setSource('llm');
return;
}
}
setSteps(deterministicSteps(campaign.system, character.className, title, nextLevel));
setSource('deterministic');
};
return { state, routes, steps, chosen, source, message, canUseLlm, nextLevel, fetchRoutes, chooseRoute };
}
@@ -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>
);
}
@@ -1,11 +1,12 @@
import { useState } from 'react';
import type { Character } from '@/lib/schemas';
import type { Campaign, Character } from '@/lib/schemas';
import { abilityModifier } from '@/lib/rules';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Input';
import { LevelUpAdvisor } from './LevelUpAdvisor';
const HIT_DICE = [6, 8, 10, 12] as const;
@@ -31,6 +32,11 @@ export function LevelUpModal({ character, onApply, onClose }: {
const preview = (method === 'average' ? average : `1d${die}`) + (conMod !== 0 ? ` ${conMod >= 0 ? '+' : ''}${conMod}` : '');
// The advisor only needs the system + id; synthesize a campaign from the character.
const campaign: Campaign = {
id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '',
};
return (
<Modal
open
@@ -61,6 +67,8 @@ export function LevelUpModal({ character, onApply, onClose }: {
HP gained: <span className="font-medium text-ink">{preview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})
</p>
{character.level >= 20 && <p className="text-sm text-warning">Already at level 20.</p>}
{character.level < 20 && <LevelUpAdvisor campaign={campaign} character={character} />}
</div>
</Modal>
);