From 7271978d7d7c69e4ce69fc28888db7bced6c7dee Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 09:33:50 +0200 Subject: [PATCH] Assistant: deterministic encounter advisor reinforces the existing fight Instead of proposing a fresh alternative encounter, the non-AI path now: - adds MORE of the creatures already present ('Add 2 more Goblin'), computing the exact count needed and accounting for the 5e encounter multiplier, or - adds a thematically related stronger creature from the bestiary (shared name token, level/CR-appropriate) when that reaches the target with fewer bodies. Only builds a fresh group when the encounter is empty. Apply clones the existing combatant (works for homebrew/custom too) or pulls from the pool. Existing creatures are also offered to the AI as candidates so it can likewise say 'add another goblin'. New suggestReinforcements() + baseName() in encounter.ts with unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/features/assistant/useEncounterAdvisor.ts | 57 +++++++--- src/lib/assistant/advisors.test.ts | 25 +++- src/lib/assistant/encounter.ts | 107 ++++++++++++++++++ 3 files changed, 175 insertions(+), 14 deletions(-) diff --git a/src/features/assistant/useEncounterAdvisor.ts b/src/features/assistant/useEncounterAdvisor.ts index 09da60d..83e53fd 100644 --- a/src/features/assistant/useEncounterAdvisor.ts +++ b/src/features/assistant/useEncounterAdvisor.ts @@ -10,11 +10,10 @@ import { loadMonsters, loadPf2e } from '@/lib/compendium'; import { complete } from '@/lib/llm/client'; import { getLlmConfig } from '@/stores/assistantStore'; import { useAssistantStore } from '@/stores/assistantStore'; -import { buildCampaignContext } from '@/lib/assistant/context'; -import { pickCreatureCandidates } from '@/lib/assistant/context'; +import { buildCampaignContext, pickCreatureCandidates, type CreatureCandidate } from '@/lib/assistant/context'; import { detectThemes, type CampaignTheme } from '@/lib/assistant/patterns'; import { buildBalancePrompt, balanceSuggestionSchema, type BalanceSuggestion } from '@/lib/assistant/prompts'; -import { buildSuggestedEncounter } from '@/lib/assistant/encounter'; +import { buildSuggestedEncounter, suggestReinforcements, baseName, type ReinforceMonster } from '@/lib/assistant/encounter'; import { useCharacters } from '@/features/characters/hooks'; import { useNotes, useQuests } from '@/features/world/hooks'; import { useEncounters } from '@/features/combat/hooks'; @@ -105,10 +104,25 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) { const partyLevels = ctx.partyLevels; if (partyLevels.length === 0) { setMessage('Add player characters first.'); setState('error'); return; } - const monsters = encounter.combatants.filter((c) => c.kind === 'monster').map((c) => ({ cr: c.cr, level: c.level })); - const current = computeBudget(campaign.system, partyLevels, monsters).difficulty; + const monsterCombatants = encounter.combatants.filter((c) => c.kind === 'monster'); + const existing: ReinforceMonster[] = monsterCombatants.map((c) => ({ name: c.name, cr: c.cr, level: c.level })); + const current = computeBudget(campaign.system, partyLevels, existing.map((m) => ({ cr: m.cr, level: m.level }))).difficulty; const target = nextTarget(campaign.system, current); - const candidates = pickCreatureCandidates(campaign.system, partyLevels, raw, target, 14); + + // Candidates the AI may choose from: the creatures already in the fight + // (so it can say "add another goblin") plus level-appropriate bestiary picks. + const existingCands: CreatureCandidate[] = []; + const seenBase = new Set(); + for (const c of monsterCombatants) { + const bn = baseName(c.name); + const rating = campaign.system === '5e' ? c.cr : c.level; + if (rating === undefined || seenBase.has(bn.toLowerCase())) continue; + seenBase.add(bn.toLowerCase()); + existingCands.push({ name: bn, rating, ac: c.ac, hp: c.hp.max }); + } + const poolCands = pickCreatureCandidates(campaign.system, partyLevels, raw, target, 14) + .filter((c) => !seenBase.has(c.name.toLowerCase())); + const candidates = [...existingCands, ...poolCands]; if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; } if (canUseLlm) { @@ -127,10 +141,15 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) { } } - // Deterministic fallback - const chosen = buildSuggestedEncounter(campaign.system, partyLevels, raw as (Record & { cr?: number; level?: number })[], target); - if (!chosen.length) { setMessage('Could not assemble a balanced suggestion.'); setState('error'); return; } - setSuggestion({ reasoning: `A level-appropriate pick to reach ${target} difficulty.`, add: countByName(chosen), targetDifficulty: target }); + // Deterministic: reinforce the existing fight; only build fresh if it's empty. + const typedPool = raw as (Record & { cr?: number; level?: number })[]; + let plan = suggestReinforcements(campaign.system, partyLevels, existing, typedPool, target); + if (!plan) { + const chosen = buildSuggestedEncounter(campaign.system, partyLevels, typedPool, target); + if (!chosen.length) { setMessage('Could not assemble a balanced suggestion.'); setState('error'); return; } + plan = { reasoning: `A level-appropriate group to reach ${target} difficulty.`, add: countByName(chosen), targetDifficulty: target }; + } + setSuggestion(plan); setSource('deterministic'); setState('ready'); } catch { @@ -144,9 +163,21 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) { await encountersRepo.mutate(encounter.id, (e) => { let next = e; for (const a of suggestion.add) { - const m = poolRef.current.find((p) => String(p.name) === a.name); - if (!m) continue; - for (let i = 0; i < a.count; i++) next = addCombatant(next, toCombatant(campaign.system, m)); + // Prefer cloning a creature already in the fight (handles "add another goblin" + // and works even for custom/homebrew combatants); otherwise pull from the pool. + const template = e.combatants.find((c) => c.kind === 'monster' && baseName(c.name) === a.name); + const make = template + ? () => ({ + ...template, id: newId(), name: baseName(template.name), + hp: { current: template.hp.max, max: template.hp.max, temp: 0 }, + conditions: [], initiative: rollDice('1d20', createRng()).total + template.initBonus, + }) + : (() => { + const m = poolRef.current.find((p) => String(p.name) === a.name); + return m ? () => toCombatant(campaign.system, m) : null; + })(); + if (!make) continue; + for (let i = 0; i < a.count; i++) next = addCombatant(next, make()); } return next; }); diff --git a/src/lib/assistant/advisors.test.ts b/src/lib/assistant/advisors.test.ts index 0f392b2..0adb277 100644 --- a/src/lib/assistant/advisors.test.ts +++ b/src/lib/assistant/advisors.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { resourceSuggestions, planningSuggestions, combatSuggestions } from './advisors'; -import { buildSuggestedEncounter } from './encounter'; +import { buildSuggestedEncounter, suggestReinforcements } from './encounter'; import { characterDefaults, type Character, type Note, type Encounter } from '@/lib/schemas'; function pc(name: string, cur: number, max: number, over: Partial = {}): Character { @@ -72,3 +72,26 @@ describe('buildSuggestedEncounter', () => { expect(buildSuggestedEncounter('5e', [1, 1, 1, 1], [{ cr: 25 }], 'Hard')).toEqual([]); }); }); + +describe('suggestReinforcements', () => { + const pool = [{ name: 'Goblin', cr: 0.25 }, { name: 'Hobgoblin', cr: 0.5 }, { name: 'Ogre', cr: 2 }]; + + it('adds more of the existing creature to raise difficulty', () => { + // 3 goblins vs two level-3 PCs is "medium"; bump toward Hard. + const existing = [{ name: 'Goblin', cr: 0.25 }, { name: 'Goblin 2', cr: 0.25 }, { name: 'Goblin 3', cr: 0.25 }]; + const plan = suggestReinforcements('5e', [3, 3], existing, pool, 'Hard'); + expect(plan).not.toBeNull(); + expect(plan!.add[0]!.name).toBe('Goblin'); + expect(plan!.add[0]!.count).toBeGreaterThanOrEqual(1); + expect(plan!.reasoning).toMatch(/goblin/i); + }); + + it('uses the existing creature name without the auto-number suffix', () => { + const plan = suggestReinforcements('5e', [3, 3], [{ name: 'Goblin 2', cr: 0.25 }], pool, 'Hard'); + expect(plan!.add[0]!.name).toBe('Goblin'); + }); + + it('returns null for an empty encounter (caller builds fresh instead)', () => { + expect(suggestReinforcements('5e', [3, 3], [], pool, 'Hard')).toBeNull(); + }); +}); diff --git a/src/lib/assistant/encounter.ts b/src/lib/assistant/encounter.ts index e22baa5..b881afe 100644 --- a/src/lib/assistant/encounter.ts +++ b/src/lib/assistant/encounter.ts @@ -50,3 +50,110 @@ export function buildSuggestedEncounter( } return chosen; } + +// ---------------- Reinforcing an existing encounter ---------------- + +export interface ReinforceMonster { + name: string; + cr?: number | undefined; + level?: number | undefined; +} + +export interface ReinforcePlan { + reasoning: string; + add: { name: string; count: number }[]; + targetDifficulty: string; +} + +/** Strip a combat auto-number suffix, e.g. "Goblin 2" → "Goblin". */ +export function baseName(name: string): string { + return name.replace(/\s+\d+$/, '').trim(); +} + +function tokens(name: string): string[] { + return baseName(name).toLowerCase().split(/\s+/).filter((t) => t.length >= 3); +} + +function ratingOf(system: SystemId, m: { cr?: number | undefined; level?: number | undefined }): number | undefined { + return system === '5e' ? m.cr : m.level; +} + +/** + * Suggest how to raise an EXISTING encounter to a target difficulty by reinforcing + * what's already there — more of the same creatures, or a thematically related + * stronger creature from the bestiary — rather than proposing a fresh encounter. + * Accounts for the 5e encounter multiplier (which grows with monster count). + * Returns null when there are no existing monsters to build on. + */ +export function suggestReinforcements( + system: SystemId, + partyLevels: number[], + existing: ReinforceMonster[], + pool: (Record & { cr?: number; level?: number })[], + targetDifficulty: string, + maxAdds = 10, +): ReinforcePlan | null { + if (existing.length === 0) return null; + const thresholds = computeBudget(system, partyLevels, []).thresholds; + const target = thresholds.find((t) => t.label.toLowerCase() === targetDifficulty.toLowerCase())?.value ?? 0; + if (target <= 0) return null; + + const minCountToReach = (rating: number): number | null => { + for (let n = 1; n <= maxAdds; n++) { + const extra = Array.from({ length: n }, () => (system === '5e' ? { cr: rating } : { level: rating })); + if (computeBudget(system, partyLevels, [...existing, ...extra]).ratingXp >= target) return n; + } + return null; + }; + + // Candidate reinforcements: distinct existing creatures, plus thematic upgrades + // from the pool (sharing a name token, level/CR-appropriate, not already present). + type Cand = { name: string; rating: number; thematic: boolean }; + const cands: Cand[] = []; + const seen = new Set(); + for (const m of existing) { + const bn = baseName(m.name); + const r = ratingOf(system, m); + if (r === undefined || seen.has(bn.toLowerCase())) continue; + seen.add(bn.toLowerCase()); + cands.push({ name: bn, rating: r, thematic: false }); + } + + const avg = partyLevels.length ? Math.round(partyLevels.reduce((a, b) => a + b, 0) / partyLevels.length) : 1; + const eligible = (r: number) => (system === '5e' ? r <= avg + 1 && r >= Math.max(0, avg - 6) : r <= avg + 2 && r >= avg - 4); + const existingTokens = new Set(existing.flatMap((m) => tokens(m.name))); + for (const p of pool) { + const r = ratingOf(system, p); + const name = typeof p.name === 'string' ? p.name : undefined; + if (r === undefined || !name || !eligible(r) || seen.has(name.toLowerCase())) continue; + if (tokens(name).some((t) => existingTokens.has(t))) { + cands.push({ name, rating: r, thematic: true }); + seen.add(name.toLowerCase()); + } + } + + // Pick the plan that reaches the target with the fewest added bodies; on ties, + // prefer the higher-rated (more "boss-like") and thematic option. + let best: { name: string; count: number; rating: number; thematic: boolean } | null = null; + for (const c of cands) { + const n = minCountToReach(c.rating); + if (n === null) continue; + const better = + !best || n < best.count || + (n === best.count && (c.rating > best.rating || (c.rating === best.rating && c.thematic && !best.thematic))); + if (better) best = { name: c.name, count: n, rating: c.rating, thematic: c.thematic }; + } + + // If nothing reaches within the cap, pile on the strongest existing creature. + if (!best) { + const strongest = cands.reduce((a, b) => (b.rating > a.rating ? b : a)); + best = { name: strongest.name, count: maxAdds, rating: strongest.rating, thematic: strongest.thematic }; + } + + const more = best.thematic ? '' : ' more'; + const reasoning = + best.count === 1 + ? `Add 1 ${best.name} to push this fight toward ${targetDifficulty}.` + : `Add ${best.count}${more} ${best.name} to push this fight toward ${targetDifficulty}.`; + return { reasoning, add: [{ name: best.name, count: best.count }], targetDifficulty }; +}