Fix combat advisor "AI unavailable (parse)" — coerce numeric counts
The encounter-balance schema was the only AI schema with a numeric field.
LLMs routinely emit numbers as JSON strings ("count": "2"), which z.number()
rejects, failing the whole safeParse and falling back to deterministic with a
"parse" error. The all-string NPC/session/quest schemas never hit this.
- Coerce monster counts (z.coerce.number) and widen the upper bound 8->12 so a
string or slightly over-eager count is accepted, not rejected. min(1) stays.
- Tell the model in the prompt that count is a plain integer and reasoning /
targetDifficulty are required.
- Surface the real failure message in the advisor instead of the opaque kind.
- Add a regression test that string counts coerce to numbers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -137,7 +137,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (!res.ok) {
|
} else if (!res.ok) {
|
||||||
setMessage(`AI unavailable (${res.error}); showing a deterministic pick.`);
|
setMessage(`AI unavailable: ${res.message} Showing a deterministic pick.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,4 +44,9 @@ describe('balanceSuggestionSchema', () => {
|
|||||||
expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], targetDifficulty: 'Medium' }).success).toBe(false);
|
expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], targetDifficulty: 'Medium' }).success).toBe(false);
|
||||||
expect(balanceSuggestionSchema.safeParse({ add: [], targetDifficulty: 'Medium' }).success).toBe(false);
|
expect(balanceSuggestionSchema.safeParse({ add: [], targetDifficulty: 'Medium' }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
it('coerces string counts (a common model quirk) instead of rejecting', () => {
|
||||||
|
const r = balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: '2' }], targetDifficulty: 'Medium' });
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
if (r.success) expect(r.data.add[0]!.count).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ import { z } from 'zod';
|
|||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
import type { CampaignContext, CreatureCandidate } from './context';
|
import type { CampaignContext, CreatureCandidate } from './context';
|
||||||
|
|
||||||
|
// Counts are coerced (models routinely return numbers as JSON strings, e.g. "2")
|
||||||
|
// and allow a wider upper bound than the prompt asks for, so a slightly
|
||||||
|
// 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({
|
export const balanceSuggestionSchema = z.object({
|
||||||
reasoning: z.string(),
|
reasoning: z.string(),
|
||||||
add: z.array(z.object({
|
add: z.array(z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
count: z.number().int().min(1).max(8),
|
count: monsterCount,
|
||||||
ref: z.string().optional(),
|
ref: z.string().optional(),
|
||||||
})),
|
})),
|
||||||
/** Monsters to take out when the fight is over-tuned (already at/above target). */
|
/** Monsters to take out when the fight is over-tuned (already at/above target). */
|
||||||
remove: z.array(z.object({
|
remove: z.array(z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
count: z.number().int().min(1).max(8),
|
count: monsterCount,
|
||||||
})).optional(),
|
})).optional(),
|
||||||
targetDifficulty: z.string(),
|
targetDifficulty: z.string(),
|
||||||
});
|
});
|
||||||
@@ -35,7 +40,8 @@ export function buildBalancePrompt(
|
|||||||
`You are an encounter-balancing assistant for a ${ctx.systemLabel} game master. ` +
|
`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. ` +
|
`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.`;
|
`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".`;
|
||||||
|
|
||||||
const candidateList = current.candidates
|
const candidateList = current.candidates
|
||||||
.map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`)
|
.map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`)
|
||||||
|
|||||||
Reference in New Issue
Block a user