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 };
}