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:
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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(),
|
||||
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<typeof balanceSuggestionSchema>;
|
||||
|
||||
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": <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
|
||||
.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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user