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:
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user