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,73 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
indexedDB.deleteDatabase('ttrpg-manager');
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encounter tip suggests and applies a deterministic balance fix', async ({ page }) => {
|
||||||
|
// Sample campaign: 2 level-3 PCs + a "Goblin Ambush" with 3 goblins.
|
||||||
|
await page.getByLabel('Settings').click();
|
||||||
|
await page.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
|
|
||||||
|
// Open the seeded encounter in the combat tracker.
|
||||||
|
await page.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click();
|
||||||
|
await page.getByText('Goblin Ambush').click();
|
||||||
|
|
||||||
|
// The contextual balance tip is present (LLM off → deterministic path).
|
||||||
|
const tip = page.getByTestId('encounter-tip');
|
||||||
|
await expect(tip).toBeVisible();
|
||||||
|
|
||||||
|
// Each combatant row has a "Dmg" button — use it to count combatants.
|
||||||
|
const hpControls = page.getByRole('button', { name: 'Dmg' });
|
||||||
|
const before = await hpControls.count();
|
||||||
|
|
||||||
|
await tip.getByRole('button', { name: 'Suggest a fix' }).click();
|
||||||
|
await expect(tip.getByRole('button', { name: 'Add to encounter' })).toBeVisible();
|
||||||
|
await tip.getByRole('button', { name: 'Add to encounter' }).click();
|
||||||
|
|
||||||
|
// Suggestion closes and at least one combatant was added.
|
||||||
|
await expect(tip.getByRole('button', { name: 'Add to encounter' })).toBeHidden();
|
||||||
|
await expect.poll(async () => hpControls.count()).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encounter tip uses the AI path when a provider is configured', async ({ page }) => {
|
||||||
|
// Stub the Anthropic endpoint with a canned structured response.
|
||||||
|
await page.route('**/v1/messages', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
reasoning: 'An ettin raises the threat to match the party.',
|
||||||
|
add: [{ name: 'Ettin', count: 1 }],
|
||||||
|
targetDifficulty: 'Hard',
|
||||||
|
}) }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByLabel('Settings').click();
|
||||||
|
await page.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
|
|
||||||
|
// Enable the assistant with a dummy key (network is stubbed).
|
||||||
|
await page.getByLabel('Settings').click();
|
||||||
|
await page.getByLabel('Enable AI assistant').check();
|
||||||
|
await page.getByLabel('API key', { exact: true }).fill('sk-stub');
|
||||||
|
|
||||||
|
await page.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click();
|
||||||
|
await page.getByText('Goblin Ambush').click();
|
||||||
|
|
||||||
|
const tip = page.getByTestId('encounter-tip');
|
||||||
|
await tip.getByRole('button', { name: 'Suggest a fix (AI)' }).click();
|
||||||
|
// The canned AI reasoning + the "AI suggestion" label render.
|
||||||
|
await expect(tip.getByText('AI suggestion')).toBeVisible();
|
||||||
|
await expect(tip.getByText('An ettin raises the threat to match the party.')).toBeVisible();
|
||||||
|
await expect(tip.getByText('1× Ettin')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Campaign, Encounter } from '@/lib/schemas';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { useEncounterAdvisor } from './useEncounterAdvisor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contextual encounter-balancing tip. Shows the detected difficulty tendency and,
|
||||||
|
* on demand, a grounded recommendation (AI when configured, deterministic otherwise)
|
||||||
|
* with one-click apply. Renders nothing when there's no tendency to report.
|
||||||
|
*/
|
||||||
|
export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign; encounter: Encounter }) {
|
||||||
|
const { theme, hasMonsters, currentDifficulty, state, suggestion, source, message, canUseLlm, run, apply } =
|
||||||
|
useEncounterAdvisor(campaign, encounter);
|
||||||
|
|
||||||
|
// Surface when there's a detected tendency, an encounter with monsters to balance,
|
||||||
|
// or an in-progress/ready suggestion. Stay out of the way otherwise.
|
||||||
|
if (!theme && !hasMonsters && state === 'idle') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('mb-4 rounded-lg border p-3', theme ? 'border-warning/40 bg-warning/5' : 'border-line bg-panel')}
|
||||||
|
data-testid="encounter-tip"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-lg" aria-hidden>🧠</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{theme ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium text-ink">{theme.tendency}</div>
|
||||||
|
<div className="text-xs text-muted">{theme.evidence}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium text-ink">Encounter balance</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{currentDifficulty ? (
|
||||||
|
<>Currently <span className="capitalize">{currentDifficulty}</span> for the party.</>
|
||||||
|
) : (
|
||||||
|
'Add a creature suggestion for your party.'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'ready' && suggestion && (
|
||||||
|
<div className="mt-2 rounded-md border border-line bg-surface p-2">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||||
|
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted">→ {suggestion.targetDifficulty}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-ink">{suggestion.reasoning}</p>
|
||||||
|
<ul className="mb-2 text-sm text-ink">
|
||||||
|
{suggestion.add.map((a) => (
|
||||||
|
<li key={a.name}>• {a.count}× {a.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="primary" onClick={apply}>Add to encounter</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={run}>Regenerate</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message && <p className="mt-1 text-xs text-muted">{message}</p>}
|
||||||
|
|
||||||
|
{state !== 'ready' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={run} disabled={state === 'loading'}>
|
||||||
|
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Suggest a fix (AI)' : 'Suggest a fix'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Campaign, Encounter } from '@/lib/schemas';
|
||||||
|
import { encountersRepo } from '@/lib/db/repositories';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
|
import { createRng } from '@/lib/rng';
|
||||||
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
|
import { addCombatant } from '@/lib/combat/engine';
|
||||||
|
import { computeBudget } from '@/lib/combat/budget';
|
||||||
|
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||||
|
import { complete } from '@/lib/llm/client';
|
||||||
|
import { getLlmConfig } from '@/stores/assistantStore';
|
||||||
|
import { useAssistantStore } from '@/stores/assistantStore';
|
||||||
|
import { buildCampaignContext } from '@/lib/assistant/context';
|
||||||
|
import { pickCreatureCandidates } from '@/lib/assistant/context';
|
||||||
|
import { detectThemes, type CampaignTheme } from '@/lib/assistant/patterns';
|
||||||
|
import { buildBalancePrompt, balanceSuggestionSchema, type BalanceSuggestion } from '@/lib/assistant/prompts';
|
||||||
|
import { buildSuggestedEncounter } from '@/lib/assistant/encounter';
|
||||||
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
|
import { useNotes, useQuests } from '@/features/world/hooks';
|
||||||
|
import { useEncounters } from '@/features/combat/hooks';
|
||||||
|
|
||||||
|
export type AdvisorState = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
const ORDINAL: Record<string, number> = {
|
||||||
|
trivial: 0, easy: 1, low: 1, medium: 2, moderate: 2, hard: 3, severe: 3, deadly: 4, extreme: 4,
|
||||||
|
};
|
||||||
|
const TIER_LABEL: Record<Campaign['system'], Record<number, string>> = {
|
||||||
|
'5e': { 2: 'Medium', 3: 'Hard', 4: 'Deadly' },
|
||||||
|
pf2e: { 2: 'Moderate', 3: 'Severe', 4: 'Extreme' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One tier above the current difficulty, floored at Medium/Moderate. */
|
||||||
|
function nextTarget(system: Campaign['system'], current: string): string {
|
||||||
|
const ord = ORDINAL[current] ?? 0;
|
||||||
|
const desired = Math.min(4, Math.max(2, ord + 1));
|
||||||
|
return TIER_LABEL[system][desired]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCombatant(system: Campaign['system'], m: Record<string, unknown>) {
|
||||||
|
const is5e = system === '5e';
|
||||||
|
const name = String(m.name);
|
||||||
|
const ac = Number(is5e ? m.armor_class : m.ac) || 10;
|
||||||
|
const hp = Number(is5e ? m.hit_points : m.hp) || 1;
|
||||||
|
const initBonus = is5e ? Math.floor(((Number(m.dexterity) || 10) - 10) / 2) : Number(m.perception) || 0;
|
||||||
|
const cr = Number(m.cr);
|
||||||
|
const level = Number(m.level);
|
||||||
|
return {
|
||||||
|
id: newId(), name, kind: 'monster' as const,
|
||||||
|
initiative: rollDice('1d20', createRng()).total + initBonus, initBonus,
|
||||||
|
ac, hp: { current: hp, max: hp, temp: 0 }, conditions: [], notes: '',
|
||||||
|
...(is5e && Number.isFinite(cr) ? { cr } : {}),
|
||||||
|
...(!is5e && Number.isFinite(level) ? { level } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByName(chosen: Record<string, unknown>[]): BalanceSuggestion['add'] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const m of chosen) {
|
||||||
|
const name = String(m.name);
|
||||||
|
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts].map(([name, count]) => ({ name, count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
||||||
|
const characters = useCharacters(campaign.id);
|
||||||
|
const notes = useNotes(campaign.id);
|
||||||
|
const quests = useQuests(campaign.id);
|
||||||
|
const encounters = useEncounters(campaign.id);
|
||||||
|
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||||
|
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||||
|
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => buildCampaignContext({ campaign, characters, encounters, quests, notes }),
|
||||||
|
[campaign, characters, encounters, quests, notes],
|
||||||
|
);
|
||||||
|
const theme = useMemo<CampaignTheme | undefined>(
|
||||||
|
() => detectThemes(ctx).find((t) => t.kind === 'encounter-difficulty'),
|
||||||
|
[ctx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hasMonsters, currentDifficulty } = useMemo(() => {
|
||||||
|
const monsters = encounter.combatants.filter((c) => c.kind === 'monster').map((c) => ({ cr: c.cr, level: c.level }));
|
||||||
|
const diff = monsters.length && ctx.partyLevels.length
|
||||||
|
? computeBudget(campaign.system, ctx.partyLevels, monsters).difficulty
|
||||||
|
: undefined;
|
||||||
|
return { hasMonsters: monsters.length > 0, currentDifficulty: diff };
|
||||||
|
}, [encounter.combatants, ctx.partyLevels, campaign.system]);
|
||||||
|
|
||||||
|
const [state, setState] = useState<AdvisorState>('idle');
|
||||||
|
const [suggestion, setSuggestion] = useState<BalanceSuggestion | undefined>();
|
||||||
|
const [source, setSource] = useState<'llm' | 'deterministic'>('deterministic');
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const poolRef = useRef<Record<string, unknown>[]>([]);
|
||||||
|
|
||||||
|
const canUseLlm = llmEnabled && hasKey;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setState('loading');
|
||||||
|
setMessage(null);
|
||||||
|
setSuggestion(undefined);
|
||||||
|
try {
|
||||||
|
const raw = (campaign.system === '5e' ? await loadMonsters() : await loadPf2e('creatures')) as unknown as Record<string, unknown>[];
|
||||||
|
poolRef.current = raw;
|
||||||
|
const partyLevels = ctx.partyLevels;
|
||||||
|
if (partyLevels.length === 0) { setMessage('Add player characters first.'); setState('error'); return; }
|
||||||
|
|
||||||
|
const monsters = encounter.combatants.filter((c) => c.kind === 'monster').map((c) => ({ cr: c.cr, level: c.level }));
|
||||||
|
const current = computeBudget(campaign.system, partyLevels, monsters).difficulty;
|
||||||
|
const target = nextTarget(campaign.system, current);
|
||||||
|
const candidates = pickCreatureCandidates(campaign.system, partyLevels, raw, target, 14);
|
||||||
|
if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; }
|
||||||
|
|
||||||
|
if (canUseLlm) {
|
||||||
|
const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates });
|
||||||
|
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
|
||||||
|
if (res.ok && 'data' in res) {
|
||||||
|
const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name));
|
||||||
|
if (valid.length) {
|
||||||
|
setSuggestion({ ...res.data, add: valid });
|
||||||
|
setSource('llm');
|
||||||
|
setState('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (!res.ok) {
|
||||||
|
setMessage(`AI unavailable (${res.error}); showing a deterministic pick.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic fallback
|
||||||
|
const chosen = buildSuggestedEncounter(campaign.system, partyLevels, raw as (Record<string, unknown> & { cr?: number; level?: number })[], target);
|
||||||
|
if (!chosen.length) { setMessage('Could not assemble a balanced suggestion.'); setState('error'); return; }
|
||||||
|
setSuggestion({ reasoning: `A level-appropriate pick to reach ${target} difficulty.`, add: countByName(chosen), targetDifficulty: target });
|
||||||
|
setSource('deterministic');
|
||||||
|
setState('ready');
|
||||||
|
} catch {
|
||||||
|
setMessage('Something went wrong building a suggestion.');
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
if (!suggestion) return;
|
||||||
|
await encountersRepo.mutate(encounter.id, (e) => {
|
||||||
|
let next = e;
|
||||||
|
for (const a of suggestion.add) {
|
||||||
|
const m = poolRef.current.find((p) => String(p.name) === a.name);
|
||||||
|
if (!m) continue;
|
||||||
|
for (let i = 0; i < a.count; i++) next = addCombatant(next, toCombatant(campaign.system, m));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setState('idle');
|
||||||
|
setSuggestion(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { theme, hasMonsters, currentDifficulty, state, suggestion, source, message, canUseLlm, run, apply };
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { cn } from '@/lib/cn';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
|
||||||
|
|
||||||
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
||||||
const characters = useCharacters(campaign.id);
|
const characters = useCharacters(campaign.id);
|
||||||
@@ -129,6 +130,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EncounterTipCard campaign={campaign} encounter={encounter} />
|
||||||
|
|
||||||
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
|
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
|
||||||
|
|
||||||
{/* Combatant list */}
|
{/* Combatant list */}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user