From 71bca6e70a3cf7dc7a28989dcb0de7566f45e655 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 23:48:39 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20combat=20advisor=20schema=20mismatch=20?= =?UTF-8?q?=E2=80=94=20accept=20"creatures"=20alias=20for=20add?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real cause of the advisor falling back to deterministic: DeepSeek returns the additions array under the key "creatures" (nudged by the prompt wording "the creatures to add"), but the schema requires "add" — so safeParse failed. - Spell out the exact JSON keys in the prompt ("add", not "creatures"/"monsters"). - Normalize creatures/monsters/additions aliases to "add" before validation, so a correct suggestion isn't discarded over a key-name synonym. - Regression test using DeepSeek's exact wild payload. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/assistant/prompts.test.ts | 13 +++++++++++++ src/lib/assistant/prompts.ts | 32 +++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/lib/assistant/prompts.test.ts b/src/lib/assistant/prompts.test.ts index 96af236..f7a2f60 100644 --- a/src/lib/assistant/prompts.test.ts +++ b/src/lib/assistant/prompts.test.ts @@ -49,4 +49,17 @@ describe('balanceSuggestionSchema', () => { expect(r.success).toBe(true); if (r.success) expect(r.data.add[0]!.count).toBe(2); }); + it('accepts the "creatures" alias some models use for the add array', () => { + // Exact shape DeepSeek returned in the wild — array keyed "creatures", not "add". + const r = balanceSuggestionSchema.safeParse({ + creatures: [{ name: 'Berserker', count: 1 }], + reasoning: 'Adding one CR 2 Berserker makes this deadly for a lone level 1 paladin.', + targetDifficulty: 'deadly', + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.add).toHaveLength(1); + expect(r.data.add[0]!.name).toBe('Berserker'); + } + }); }); diff --git a/src/lib/assistant/prompts.ts b/src/lib/assistant/prompts.ts index bca4048..4a3d0f1 100644 --- a/src/lib/assistant/prompts.ts +++ b/src/lib/assistant/prompts.ts @@ -7,7 +7,23 @@ import type { CampaignContext, CreatureCandidate } from './context'; // 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({ +/** + * Models sometimes name the additions array "creatures"/"monsters" instead of the + * expected "add" (DeepSeek does this). Normalize those synonyms before validation + * so a correct suggestion isn't discarded over a key-name mismatch. + */ +function normalizeBalanceShape(raw: unknown): unknown { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw; + const o = { ...(raw as Record) }; + if (o.add === undefined) { + for (const alias of ['creatures', 'monsters', 'additions', 'addCreatures', 'add_creatures']) { + if (Array.isArray(o[alias])) { o.add = o[alias]; break; } + } + } + return o; +} + +export const balanceSuggestionSchema = z.preprocess(normalizeBalanceShape, z.object({ reasoning: z.string(), add: z.array(z.object({ name: z.string(), @@ -20,7 +36,7 @@ export const balanceSuggestionSchema = z.object({ count: monsterCount, })).optional(), targetDifficulty: z.string(), -}); +})); export type BalanceSuggestion = z.infer; function partyLine(ctx: CampaignContext): string { @@ -38,10 +54,14 @@ export function buildBalancePrompt( 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. ` + + `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. ` + - `Each "count" must be a plain integer (e.g. 2, not "2"). Always include "reasoning" and "targetDifficulty".`; + `Prefer 1–3 distinct creatures with sensible counts.\n\n` + + `Respond with a JSON object with EXACTLY these keys:\n` + + ` "add": array of objects, each { "name": , "count": }\n` + + ` "reasoning": a one or two sentence string\n` + + ` "targetDifficulty": the target difficulty as a string\n` + + `Name the array "add" — not "creatures" or "monsters". Counts are plain integers (2, not "2").`; const candidateList = current.candidates .map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`) @@ -52,7 +72,7 @@ export function buildBalancePrompt( `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 the JSON object described above (keys: "add", "reasoning", "targetDifficulty").`; return { system, user }; }