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);
|
||||
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');
|
||||
it('finds the add array whatever the model names it', () => {
|
||||
// Exact shapes DeepSeek returned in the wild: "creatures", then "addCreatures".
|
||||
for (const key of ['creatures', 'addCreatures', 'monsters', 'someInventedKey'] as const) {
|
||||
const r = balanceSuggestionSchema.safeParse({
|
||||
[key]: [{ name: 'Goblin', count: 2 }],
|
||||
reasoning: 'Two goblins push this to Deadly for a lone level 1 paladin.',
|
||||
targetDifficulty: 'Deadly',
|
||||
});
|
||||
expect(r.success, `key="${key}"`).toBe(true);
|
||||
if (r.success) {
|
||||
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.
|
||||
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
|
||||
* expected "add" (DeepSeek does this). Normalize those synonyms before validation
|
||||
* so a correct suggestion isn't discarded over a key-name mismatch.
|
||||
* Models keep inventing their own name for the additions array — DeepSeek has
|
||||
* returned "creatures", "addCreatures", … instead of the expected "add". Rather
|
||||
* 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 {
|
||||
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; }
|
||||
}
|
||||
if (Array.isArray(o.add)) return o;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user