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:
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deterministicRoutes, deterministicSteps } from './levelup';
|
||||
import { buildRoutesSchema, buildStepsSchema, buildLevelUpRoutesPrompt, buildLevelUpStepsPrompt } from './prompts';
|
||||
import { buildCampaignContext } from './context';
|
||||
import { characterDefaults, type Campaign, type Character } from '@/lib/schemas';
|
||||
|
||||
function ctxAndChar(system: Campaign['system']) {
|
||||
const campaign: Campaign = { id: 'c', name: 'T', system, description: '', createdAt: '', updatedAt: '' };
|
||||
const c: Character = {
|
||||
id: 'p', campaignId: 'c', system, kind: 'pc', name: 'Hero', ancestry: '', className: 'Cleric', level: 3,
|
||||
abilities: { str: 10, dex: 12, con: 13, int: 8, wis: 16, cha: 11 },
|
||||
hp: { current: 20, max: 20, temp: 0 }, speed: 30, armorBonus: 0,
|
||||
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
|
||||
};
|
||||
return { ctx: buildCampaignContext({ campaign, characters: [c], encounters: [], quests: [], notes: [] }), c };
|
||||
}
|
||||
|
||||
describe('deterministic level-up fallback', () => {
|
||||
it('returns 4 schema-valid routes for both systems', () => {
|
||||
for (const system of ['5e', 'pf2e'] as const) {
|
||||
const routes = deterministicRoutes(system, 'Cleric');
|
||||
expect(routes).toHaveLength(4);
|
||||
expect(buildRoutesSchema.safeParse({ routes }).success).toBe(true);
|
||||
}
|
||||
});
|
||||
it('produces system-appropriate steps (pf2e vs 5e vocabulary)', () => {
|
||||
const pf = deterministicSteps('pf2e', 'Cleric', 'Offense', 4);
|
||||
const dnd = deterministicSteps('5e', 'Cleric', 'Offense', 4);
|
||||
expect(buildStepsSchema.safeParse({ steps: pf }).success).toBe(true);
|
||||
expect(JSON.stringify(pf)).toMatch(/ability boosts|skill feat|skill increase/i);
|
||||
expect(JSON.stringify(dnd)).toMatch(/ability score improvement|feat/i);
|
||||
});
|
||||
it('handles a custom route title', () => {
|
||||
const steps = deterministicSteps('5e', 'Wizard', 'Blaster control mage', 5);
|
||||
expect(steps.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(steps)).toContain('Blaster control mage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('level-up prompts', () => {
|
||||
it('routes prompt names the system, class, and next level', () => {
|
||||
const { ctx, c } = ctxAndChar('pf2e');
|
||||
const { system, user } = buildLevelUpRoutesPrompt(ctx, c, 4);
|
||||
expect(system).toMatch(/pathfinder/i);
|
||||
expect(user).toContain('Cleric');
|
||||
expect(user).toContain('4');
|
||||
});
|
||||
it('steps prompt includes the chosen route and constrains to the system', () => {
|
||||
const { ctx, c } = ctxAndChar('5e');
|
||||
const { system, user } = buildLevelUpStepsPrompt(ctx, c, 4, 'Defense');
|
||||
expect(system).toMatch(/5e|dungeons/i);
|
||||
expect(user).toContain('Defense');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import type { BuildRoute, BuildStep } from './prompts';
|
||||
|
||||
/**
|
||||
* Deterministic, system-aware build-route advice used when no LLM is configured.
|
||||
* Generic archetypes keyed off the system's advancement vocabulary — not a
|
||||
* substitute for the LLM's class-specific depth, but always available offline.
|
||||
*/
|
||||
|
||||
const ARCHETYPES: { title: string; summary: string; playstyle: string }[] = [
|
||||
{ title: 'Offense', summary: 'Lean into raw damage and accuracy.', playstyle: 'Strike first, strike hard' },
|
||||
{ title: 'Defense', summary: 'Shore up survivability and staying power.', playstyle: 'Outlast the fight' },
|
||||
{ title: 'Utility', summary: 'Broaden what you can do outside combat.', playstyle: 'A tool for every problem' },
|
||||
{ title: 'Support', summary: "Amplify and protect your allies.", playstyle: 'Make the party better' },
|
||||
];
|
||||
|
||||
function vocab(system: SystemId) {
|
||||
return system === 'pf2e'
|
||||
? { boost: 'ability boosts', feat: 'a class or skill feat', train: 'a skill increase' }
|
||||
: { boost: 'an Ability Score Improvement', feat: 'a feat', train: 'proficiency or expertise' };
|
||||
}
|
||||
|
||||
export function deterministicRoutes(_system: SystemId, className: string): BuildRoute[] {
|
||||
const cls = className || 'character';
|
||||
return ARCHETYPES.map((a) => ({
|
||||
title: a.title,
|
||||
summary: `${a.summary} (${cls})`,
|
||||
playstyle: a.playstyle,
|
||||
}));
|
||||
}
|
||||
|
||||
export function deterministicSteps(system: SystemId, className: string, routeTitle: string, nextLevel: number): BuildStep[] {
|
||||
const v = vocab(system);
|
||||
const cls = className || 'character';
|
||||
const byRoute: Record<string, BuildStep[]> = {
|
||||
Offense: [
|
||||
{ label: 'Raise your attack stat', detail: `Put ${v.boost} into your primary attack ability.` },
|
||||
{ label: 'Pick a damage feat', detail: `Take ${v.feat} that adds damage or extra attacks for a ${cls}.` },
|
||||
{ label: 'Sharpen accuracy', detail: `Spend ${v.train} on your main weapon or attack proficiency.` },
|
||||
],
|
||||
Defense: [
|
||||
{ label: 'Boost durability', detail: `Invest ${v.boost} in Constitution (and Dexterity for AC).` },
|
||||
{ label: 'Take a defensive feat', detail: `Choose ${v.feat} that improves AC, saves, or damage mitigation.` },
|
||||
{ label: 'Train a key save', detail: `Use ${v.train} on your weakest defense.` },
|
||||
],
|
||||
Utility: [
|
||||
{ label: 'Expand your skills', detail: `Apply ${v.train} to skills that fit your concept.` },
|
||||
{ label: 'Pick a versatile feat', detail: `Take ${v.feat} that grants new options or actions.` },
|
||||
{ label: 'Round out abilities', detail: `Use ${v.boost} to cover a glaring weakness.` },
|
||||
],
|
||||
Support: [
|
||||
{ label: 'Improve your key ability', detail: `Put ${v.boost} into your spellcasting or support ability.` },
|
||||
{ label: 'Take a team feat', detail: `Choose ${v.feat} that buffs, heals, or protects allies.` },
|
||||
{ label: 'Train coordination skills', detail: `Spend ${v.train} on social or healing-adjacent skills.` },
|
||||
],
|
||||
};
|
||||
const steps = byRoute[routeTitle] ?? [
|
||||
{ label: 'Pick complementary options', detail: `Choose ${v.feat} and ${v.boost} that support "${routeTitle}".` },
|
||||
{ label: 'Train toward the goal', detail: `Apply ${v.train} where it advances this plan.` },
|
||||
];
|
||||
return [{ label: `Level ${nextLevel} focus`, detail: `Build toward "${routeTitle}" as a ${cls}.` }, ...steps];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import type { CampaignContext, CreatureCandidate } from './context';
|
||||
|
||||
export const balanceSuggestionSchema = z.object({
|
||||
@@ -44,3 +45,43 @@ export function buildBalancePrompt(
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
// ---------------- Level-up advisor ----------------
|
||||
|
||||
export const buildRoutesSchema = z.object({
|
||||
routes: z.array(z.object({
|
||||
title: z.string(),
|
||||
summary: z.string(),
|
||||
playstyle: z.string(),
|
||||
})).min(3).max(4),
|
||||
});
|
||||
export type BuildRoute = z.infer<typeof buildRoutesSchema>['routes'][number];
|
||||
|
||||
export const buildStepsSchema = z.object({
|
||||
steps: z.array(z.object({ label: z.string(), detail: z.string() })).min(1),
|
||||
});
|
||||
export type BuildStep = z.infer<typeof buildStepsSchema>['steps'][number];
|
||||
|
||||
function charLine(c: Character): string {
|
||||
const abilities = Object.entries(c.abilities).map(([k, v]) => `${k.toUpperCase()} ${v}`).join(', ');
|
||||
return `${c.name}, a level ${c.level} ${c.className || 'adventurer'} (${abilities})`;
|
||||
}
|
||||
|
||||
export function buildLevelUpRoutesPrompt(ctx: CampaignContext, c: Character, nextLevel: number): { system: string; user: string } {
|
||||
const system =
|
||||
`${ctx.systemConstraint}\n\n` +
|
||||
`You are a character-building advisor for ${ctx.systemLabel}. Propose 4 distinct build routes for the next level. ` +
|
||||
`Each route needs a short title, a one-sentence summary, and a one-phrase playstyle. ` +
|
||||
`Use only ${ctx.systemLabel} mechanics, feats, and class options — never another system's.`;
|
||||
const user = `Character: ${charLine(c)}. Leveling up to ${nextLevel}. Suggest 4 build routes.`;
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
export function buildLevelUpStepsPrompt(ctx: CampaignContext, c: Character, nextLevel: number, chosenRoute: string): { system: string; user: string } {
|
||||
const system =
|
||||
`${ctx.systemConstraint}\n\n` +
|
||||
`You are a character-building advisor for ${ctx.systemLabel}. Give concrete, ordered steps to pursue the chosen build route at this level. ` +
|
||||
`Each step has a short label and a one-sentence detail. Reference only ${ctx.systemLabel} options (feats, ability boosts, spells, skill increases as appropriate). Keep to 3–5 steps.`;
|
||||
const user = `Character: ${charLine(c)}. Leveling up to ${nextLevel}. Chosen route: "${chosenRoute}". How should they achieve it?`;
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user