diff --git a/src/lib/assistant/prompts.test.ts b/src/lib/assistant/prompts.test.ts index f7a2f60..18d0d69 100644 --- a/src/lib/assistant/prompts.test.ts +++ b/src/lib/assistant/prompts.test.ts @@ -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); + }); }); diff --git a/src/lib/assistant/prompts.ts b/src/lib/assistant/prompts.ts index 4a3d0f1..b3309cc 100644 --- a/src/lib/assistant/prompts.ts +++ b/src/lib/assistant/prompts.ts @@ -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[] { + 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) }; - 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; }