Phase 14: LLM-grounded encounter-balancing tip
- src/lib/assistant/prompts.ts: balanceSuggestionSchema + buildBalancePrompt (system message leads with the grounding constraint; restricts choices to the provided compendium candidates). - useEncounterAdvisor hook: builds context → picks system-correct candidates → asks the LLM (when configured) for a structured pick, validates + filters names to the candidate set, else falls back to the deterministic buildSuggestedEncounter. Apply adds the creatures transactionally via encountersRepo.mutate. - EncounterTipCard in the combat tracker: leads with the detected difficulty tendency (or the current difficulty), offers a one-click grounded suggestion (AI or deterministic) with propose→confirm Apply. prompts unit tests + e2e for both the deterministic apply flow and the AI path (stubbed provider); the AI test also confirms candidate-grounding (only shortlisted creatures survive). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBalancePrompt, balanceSuggestionSchema } from './prompts';
|
||||
import { buildCampaignContext } from './context';
|
||||
import { characterDefaults, type Campaign, type Character } from '@/lib/schemas';
|
||||
|
||||
function ctxFor(system: Campaign['system']) {
|
||||
const campaign: Campaign = { id: 'c', name: 'T', system, description: '', createdAt: '', updatedAt: '' };
|
||||
const pc: Character = {
|
||||
id: 'p', campaignId: 'c', system, kind: 'pc', name: 'Hero', ancestry: '', className: 'Wizard', level: 4,
|
||||
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
|
||||
hp: { current: 20, max: 20, temp: 0 }, speed: 30, armorBonus: 0,
|
||||
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
|
||||
};
|
||||
return buildCampaignContext({ campaign, characters: [pc], encounters: [], quests: [], notes: [] });
|
||||
}
|
||||
|
||||
describe('buildBalancePrompt', () => {
|
||||
it('leads with the system constraint and lists only the given candidates', () => {
|
||||
const ctx = ctxFor('pf2e');
|
||||
const { system, user } = buildBalancePrompt(ctx, {
|
||||
difficulty: 'low', targetDifficulty: 'Moderate',
|
||||
candidates: [{ name: 'Goblin Warrior', rating: 1 }, { name: 'Goblin Dog', rating: 1 }],
|
||||
});
|
||||
expect(system).toMatch(/pathfinder/i);
|
||||
expect(system.toLowerCase()).toContain('only from the provided candidate');
|
||||
expect(user).toContain('Goblin Warrior');
|
||||
expect(user).toContain('Moderate');
|
||||
});
|
||||
|
||||
it('does not leak the other system into a 5e prompt', () => {
|
||||
const ctx = ctxFor('5e');
|
||||
const { system } = buildBalancePrompt(ctx, { difficulty: 'easy', targetDifficulty: 'Medium', candidates: [{ name: 'Ogre', rating: 2 }] });
|
||||
expect(system).toMatch(/5e|dungeons/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balanceSuggestionSchema', () => {
|
||||
it('accepts a well-formed suggestion', () => {
|
||||
const r = balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 2 }], targetDifficulty: 'Medium' });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
it('rejects bad counts and missing fields', () => {
|
||||
expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], targetDifficulty: 'Medium' }).success).toBe(false);
|
||||
expect(balanceSuggestionSchema.safeParse({ add: [], targetDifficulty: 'Medium' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
import type { CampaignContext, CreatureCandidate } from './context';
|
||||
|
||||
export const balanceSuggestionSchema = z.object({
|
||||
reasoning: z.string(),
|
||||
add: z.array(z.object({
|
||||
name: z.string(),
|
||||
count: z.number().int().min(1).max(8),
|
||||
ref: z.string().optional(),
|
||||
})),
|
||||
targetDifficulty: z.string(),
|
||||
});
|
||||
export type BalanceSuggestion = z.infer<typeof balanceSuggestionSchema>;
|
||||
|
||||
function partyLine(ctx: CampaignContext): string {
|
||||
return ctx.party.map((p) => `${p.name} (level ${p.level} ${p.className})`).join(', ') || 'no PCs recorded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for rebalancing the current encounter. The system message leads with the
|
||||
* grounding constraint and restricts choices to the provided candidate list.
|
||||
*/
|
||||
export function buildBalancePrompt(
|
||||
ctx: CampaignContext,
|
||||
current: { difficulty: string; targetDifficulty: string; candidates: CreatureCandidate[] },
|
||||
): { system: string; user: string } {
|
||||
const system =
|
||||
`${ctx.systemConstraint}\n\n` +
|
||||
`You are an encounter-balancing assistant for a ${ctx.systemLabel} game master. ` +
|
||||
`Recommend which creatures to ADD to the current encounter to bring it to roughly "${current.targetDifficulty}" difficulty. ` +
|
||||
`You MUST choose only from the provided candidate creatures (by exact name). Do not invent creatures or use any from another system. ` +
|
||||
`Prefer 1–3 distinct creatures with sensible counts. Keep the reasoning to one or two sentences.`;
|
||||
|
||||
const candidateList = current.candidates
|
||||
.map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`)
|
||||
.join('\n');
|
||||
|
||||
const user =
|
||||
`Party: ${partyLine(ctx)}.\n` +
|
||||
`Current encounter difficulty: ${current.difficulty}.\n` +
|
||||
`Target difficulty: ${current.targetDifficulty}.\n\n` +
|
||||
`Candidate creatures (choose only from these):\n${candidateList}\n\n` +
|
||||
`Respond with the creatures to add (name + count), a short reasoning, and the targetDifficulty.`;
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
Reference in New Issue
Block a user