Find the advisor additions array by shape, not a fixed alias list

DeepSeek keeps inventing its own key for the additions array — first
"creatures", then "addCreatures". Chasing named aliases is whack-a-mole, so
detect it structurally: a known synonym first, else any top-level array of
{name, …} objects that isn't the "remove" list. Any invented key now resolves
to "add".

Tests cover creatures/addCreatures/monsters/an invented key, and that a lone
"remove" list is not mistaken for additions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:59:26 +02:00
parent bde21992bd
commit 53854deb81
2 changed files with 43 additions and 18 deletions
+23 -11
View File
@@ -49,17 +49,29 @@ 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', () => { it('finds the add array whatever the model names it', () => {
// Exact shape DeepSeek returned in the wild — array keyed "creatures", not "add". // Exact shapes DeepSeek returned in the wild: "creatures", then "addCreatures".
const r = balanceSuggestionSchema.safeParse({ for (const key of ['creatures', 'addCreatures', 'monsters', 'someInventedKey'] as const) {
creatures: [{ name: 'Berserker', count: 1 }], const r = balanceSuggestionSchema.safeParse({
reasoning: 'Adding one CR 2 Berserker makes this deadly for a lone level 1 paladin.', [key]: [{ name: 'Goblin', count: 2 }],
targetDifficulty: 'deadly', reasoning: 'Two goblins push this to Deadly for a lone level 1 paladin.',
}); targetDifficulty: 'Deadly',
expect(r.success).toBe(true); });
if (r.success) { expect(r.success, `key="${key}"`).toBe(true);
expect(r.data.add).toHaveLength(1); if (r.success) {
expect(r.data.add[0]!.name).toBe('Berserker'); expect(r.data.add).toHaveLength(1);
expect(r.data.add[0]!.name).toBe('Goblin');
expect(r.data.add[0]!.count).toBe(2);
}
} }
}); });
it('does not mistake the remove list for additions', () => {
const r = balanceSuggestionSchema.safeParse({
remove: [{ name: 'Ogre', count: 1 }],
reasoning: 'Drop the ogre to ease this fight.',
targetDifficulty: 'Medium',
});
// No additions array at all → add is required and absent → reject.
expect(r.success).toBe(false);
});
}); });
+20 -7
View File
@@ -7,18 +7,31 @@ 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);
/** An array of at least one `{ name, … }` object — the shape of an additions list. */
function looksLikeCreatureList(v: unknown): v is Record<string, unknown>[] {
return Array.isArray(v) && v.length > 0 &&
v.every((x) => !!x && typeof x === 'object' && typeof (x as { name?: unknown }).name === 'string');
}
/** /**
* Models sometimes name the additions array "creatures"/"monsters" instead of the * Models keep inventing their own name for the additions array — DeepSeek has
* expected "add" (DeepSeek does this). Normalize those synonyms before validation * returned "creatures", "addCreatures", … instead of the expected "add". Rather
* so a correct suggestion isn't discarded over a key-name mismatch. * than chase a fixed alias list, find the additions array structurally: a known
* synonym first, else any other top-level array of `{ name, … }` objects that
* isn't the "remove" list. Keeps a correct suggestion from being discarded over a
* key-name mismatch.
*/ */
function normalizeBalanceShape(raw: unknown): unknown { function normalizeBalanceShape(raw: unknown): unknown {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
const o = { ...(raw as Record<string, unknown>) }; const o = { ...(raw as Record<string, unknown>) };
if (o.add === undefined) { if (Array.isArray(o.add)) return o;
for (const alias of ['creatures', 'monsters', 'additions', 'addCreatures', 'add_creatures']) {
if (Array.isArray(o[alias])) { o.add = o[alias]; break; } for (const alias of ['creatures', 'monsters', 'additions', 'addCreatures', 'add_creatures', 'toAdd']) {
} if (looksLikeCreatureList(o[alias])) { o.add = o[alias]; return o; }
}
for (const [key, value] of Object.entries(o)) {
if (key === 'add' || key === 'remove') continue;
if (looksLikeCreatureList(value)) { o.add = value; return o; }
} }
return o; return o;
} }