From 9a81c04199a09b139cad934cd83a93e967bd7df8 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 23:43:03 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20combat=20advisor=20"AI=20unavailable=20(p?= =?UTF-8?q?arse)"=20=E2=80=94=20coerce=20numeric=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encounter-balance schema was the only AI schema with a numeric field. LLMs routinely emit numbers as JSON strings ("count": "2"), which z.number() rejects, failing the whole safeParse and falling back to deterministic with a "parse" error. The all-string NPC/session/quest schemas never hit this. - Coerce monster counts (z.coerce.number) and widen the upper bound 8->12 so a string or slightly over-eager count is accepted, not rejected. min(1) stays. - Tell the model in the prompt that count is a plain integer and reasoning / targetDifficulty are required. - Surface the real failure message in the advisor instead of the opaque kind. - Add a regression test that string counts coerce to numbers. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/features/assistant/useEncounterAdvisor.ts | 2 +- src/lib/assistant/prompts.test.ts | 5 +++++ src/lib/assistant/prompts.ts | 12 +++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/features/assistant/useEncounterAdvisor.ts b/src/features/assistant/useEncounterAdvisor.ts index f3529c6..6b1ce20 100644 --- a/src/features/assistant/useEncounterAdvisor.ts +++ b/src/features/assistant/useEncounterAdvisor.ts @@ -137,7 +137,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) { return; } } else if (!res.ok) { - setMessage(`AI unavailable (${res.error}); showing a deterministic pick.`); + setMessage(`AI unavailable: ${res.message} Showing a deterministic pick.`); } } diff --git a/src/lib/assistant/prompts.test.ts b/src/lib/assistant/prompts.test.ts index e97e080..96af236 100644 --- a/src/lib/assistant/prompts.test.ts +++ b/src/lib/assistant/prompts.test.ts @@ -44,4 +44,9 @@ describe('balanceSuggestionSchema', () => { expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], targetDifficulty: 'Medium' }).success).toBe(false); expect(balanceSuggestionSchema.safeParse({ add: [], targetDifficulty: 'Medium' }).success).toBe(false); }); + it('coerces string counts (a common model quirk) instead of rejecting', () => { + const r = balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: '2' }], targetDifficulty: 'Medium' }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.add[0]!.count).toBe(2); + }); }); diff --git a/src/lib/assistant/prompts.ts b/src/lib/assistant/prompts.ts index 7cacd0f..bca4048 100644 --- a/src/lib/assistant/prompts.ts +++ b/src/lib/assistant/prompts.ts @@ -2,17 +2,22 @@ import { z } from 'zod'; import type { Character } from '@/lib/schemas'; import type { CampaignContext, CreatureCandidate } from './context'; +// Counts are coerced (models routinely return numbers as JSON strings, e.g. "2") +// and allow a wider upper bound than the prompt asks for, so a slightly +// over-eager suggestion is accepted rather than rejecting the whole response. +const monsterCount = z.coerce.number().int().min(1).max(12); + export const balanceSuggestionSchema = z.object({ reasoning: z.string(), add: z.array(z.object({ name: z.string(), - count: z.number().int().min(1).max(8), + count: monsterCount, ref: z.string().optional(), })), /** Monsters to take out when the fight is over-tuned (already at/above target). */ remove: z.array(z.object({ name: z.string(), - count: z.number().int().min(1).max(8), + count: monsterCount, })).optional(), targetDifficulty: z.string(), }); @@ -35,7 +40,8 @@ export function buildBalancePrompt( `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.`; + `Prefer 1–3 distinct creatures with sensible counts. Keep the reasoning to one or two sentences. ` + + `Each "count" must be a plain integer (e.g. 2, not "2"). Always include "reasoning" and "targetDifficulty".`; const candidateList = current.candidates .map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`)