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:
2026-06-08 23:43:03 +02:00
parent 740cf20b93
commit 9a81c04199
3 changed files with 15 additions and 4 deletions
@@ -137,7 +137,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
return;
}
} else if (!res.ok) {
setMessage(`AI unavailable (${res.error}); showing a deterministic pick.`);
setMessage(`AI unavailable: ${res.message} Showing a deterministic pick.`);
}
}
+5
View File
@@ -44,4 +44,9 @@ describe('balanceSuggestionSchema', () => {
expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], 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);
});
});
+9 -3
View File
@@ -2,17 +2,22 @@ import { z } from 'zod';
import type { Character } from '@/lib/schemas';
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({
reasoning: z.string(),
add: z.array(z.object({
name: z.string(),
count: z.number().int().min(1).max(8),
count: monsterCount,
ref: z.string().optional(),
})),
/** Monsters to take out when the fight is over-tuned (already at/above target). */
remove: z.array(z.object({
name: z.string(),
count: z.number().int().min(1).max(8),
count: monsterCount,
})).optional(),
targetDifficulty: z.string(),
});
@@ -35,7 +40,8 @@ export function buildBalancePrompt(
`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. ` +
`You MUST choose only from the provided candidate creatures (by exact name). Do not invent creatures or use any from another system. ` +
`Prefer 13 distinct creatures with sensible counts. Keep the reasoning to one or two sentences.`;
`Prefer 13 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
.map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`)