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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>();
|
||||
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<string, unknown> & { 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<string, unknown> & { 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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user