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:
@@ -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 2–3 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user