Fix combat advisor schema mismatch — accept "creatures" alias for add

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:48:39 +02:00
parent 9a81c04199
commit 71bca6e70a
2 changed files with 39 additions and 6 deletions
+13
View File
@@ -49,4 +49,17 @@ describe('balanceSuggestionSchema', () => {
expect(r.success).toBe(true); expect(r.success).toBe(true);
if (r.success) expect(r.data.add[0]!.count).toBe(2); 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');
}
});
}); });
+26 -6
View File
@@ -7,7 +7,23 @@ import type { CampaignContext, CreatureCandidate } from './context';
// over-eager suggestion is accepted rather than rejecting the whole response. // over-eager suggestion is accepted rather than rejecting the whole response.
const monsterCount = z.coerce.number().int().min(1).max(12); 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<string, unknown>) };
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(), reasoning: z.string(),
add: z.array(z.object({ add: z.array(z.object({
name: z.string(), name: z.string(),
@@ -20,7 +36,7 @@ export const balanceSuggestionSchema = z.object({
count: monsterCount, count: monsterCount,
})).optional(), })).optional(),
targetDifficulty: z.string(), targetDifficulty: z.string(),
}); }));
export type BalanceSuggestion = z.infer<typeof balanceSuggestionSchema>; export type BalanceSuggestion = z.infer<typeof balanceSuggestionSchema>;
function partyLine(ctx: CampaignContext): string { function partyLine(ctx: CampaignContext): string {
@@ -38,10 +54,14 @@ export function buildBalancePrompt(
const system = const system =
`${ctx.systemConstraint}\n\n` + `${ctx.systemConstraint}\n\n` +
`You are an encounter-balancing assistant for a ${ctx.systemLabel} game master. ` + `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. ` + `You MUST choose only from the provided candidate creatures (by exact name). Do not invent creatures or use any from another system. ` +
`Prefer 13 distinct creatures with sensible counts. Keep the reasoning to one or two sentences. ` + `Prefer 13 distinct creatures with sensible counts.\n\n` +
`Each "count" must be a plain integer (e.g. 2, not "2"). Always include "reasoning" and "targetDifficulty".`; `Respond with a JSON object with EXACTLY these keys:\n` +
` "add": array of objects, each { "name": <exact candidate name>, "count": <integer ≥ 1> }\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 const candidateList = current.candidates
.map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`) .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` + `Current encounter difficulty: ${current.difficulty}.\n` +
`Target difficulty: ${current.targetDifficulty}.\n\n` + `Target difficulty: ${current.targetDifficulty}.\n\n` +
`Candidate creatures (choose only from these):\n${candidateList}\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 }; return { system, user };
} }