diff --git a/src/features/assistant/useEncounterAdvisor.ts b/src/features/assistant/useEncounterAdvisor.ts index eae059e..5571e9a 100644 --- a/src/features/assistant/useEncounterAdvisor.ts +++ b/src/features/assistant/useEncounterAdvisor.ts @@ -108,6 +108,11 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) { const existing: ReinforceMonster[] = monsterCombatants.map((c) => ({ name: c.name, cr: c.cr, level: c.level })); const current = computeBudget(campaign.system, partyLevels, existing.map((m) => ({ cr: m.cr, level: m.level }))).difficulty; const target = nextTarget(campaign.system, current); + // When the fight is already at (or above) the hardest tier, "add creatures to + // reach " is a contradiction — it makes reasoning models spiral and burn + // their whole token budget. Only ask the AI when the target is genuinely harder; + // otherwise fall through to the deterministic path (which eases an over-tuned fight). + const targetIsHarder = (ORDINAL[target.toLowerCase()] ?? 99) > (ORDINAL[current.toLowerCase()] ?? -1); // Candidates the AI may choose from: the creatures already in the fight // (so it can say "add another goblin") plus level-appropriate bestiary picks. @@ -125,7 +130,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) { const candidates = [...existingCands, ...poolCands]; if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; } - if (canUseLlm) { + if (canUseLlm && targetIsHarder) { 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) { diff --git a/src/lib/llm/client.ts b/src/lib/llm/client.ts index e8945db..c098bb7 100644 --- a/src/lib/llm/client.ts +++ b/src/lib/llm/client.ts @@ -1,7 +1,10 @@ import type { CompleteOptions, LlmConfig, LlmErrorKind, LlmResult } from './types'; const DEFAULT_TIMEOUT = 30_000; -const DEFAULT_MAX_TOKENS = 1024; +// Generous so reasoning models (e.g. DeepSeek, whose chain-of-thought counts +// against the completion budget) have room to finish thinking AND emit the JSON. +// A small cap truncates them mid-reasoning, leaving an empty `content`. +const DEFAULT_MAX_TOKENS = 4096; function err(error: LlmErrorKind, message: string): LlmResult { return { ok: false, error, message }; @@ -176,6 +179,9 @@ export async function complete(cfg: LlmConfig, opts: CompleteOptions): Pro const text = extractText(cfg.provider, json); if (!wantJson) return { ok: true, text }; + if (text.trim() === '') { + return err('parse', 'The model returned an empty response — it likely ran out of output tokens before answering (raise max tokens, or the model spent its whole budget reasoning).'); + } const parsed = tryParseJson(text); if (parsed === undefined) return err('parse', 'The model did not return valid JSON.'); const result = opts.schema!.safeParse(parsed);