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;
|
||||
});
|
||||
|
||||
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,3 +50,110 @@ export function buildSuggestedEncounter<T extends PoolMonster>(
|
||||
}
|
||||
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<string, unknown> & { 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<string>();
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user