Phase 13: grounding context + deterministic pattern detection
- src/lib/assistant/context.ts: buildCampaignContext() assembles a system-aware snapshot led by an explicit systemConstraint (anti cross-system hallucination), party, recent encounters with computed difficulty, quest/note summaries. pickCreatureCandidates() pulls level/CR-appropriate creatures from the correct bestiary so LLM tips are grounded in real data. - src/lib/assistant/patterns.ts: difficultyTendency() + detectThemes() classify whether the DM's encounters skew too easy/hard, rating each fight against its party-level snapshot. - Encounter schema gains optional partyLevelsSnapshot + outcome (Dexie v7, additive). Combat tracker snapshots PC levels at start and rates difficulty against them. 17 assistant unit tests (context + patterns). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildCampaignContext, pickCreatureCandidates } from './context';
|
||||
import { characterDefaults, type Campaign, type Character, type Encounter } from '@/lib/schemas';
|
||||
|
||||
function campaign(system: Campaign['system']): Campaign {
|
||||
return { id: 'c', name: 'Test', system, description: '', createdAt: '', updatedAt: '' };
|
||||
}
|
||||
function pc(name: string, level: number, system: Campaign['system']): Character {
|
||||
return {
|
||||
id: name, campaignId: 'c', system, kind: 'pc', name, ancestry: '', className: 'Fighter', level,
|
||||
abilities: { str: 14, dex: 12, con: 13, int: 10, wis: 11, cha: 9 },
|
||||
hp: { current: 20, max: 20, temp: 0 }, speed: 30, armorBonus: 0,
|
||||
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
|
||||
};
|
||||
}
|
||||
function enc(combatants: { cr?: number; level?: number }[], over: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'e', campaignId: 'c', name: 'Fight', status: 'ended', round: 1, turnIndex: 0,
|
||||
combatants: combatants.map((c, i) => ({
|
||||
id: `m${i}`, name: 'Mob', kind: 'monster', initiative: 10, initBonus: 0, ac: 12,
|
||||
hp: { current: 5, max: 5, temp: 0 }, conditions: [], notes: '', ...c,
|
||||
})),
|
||||
log: [], createdAt: '2026-01-01', updatedAt: '2026-01-01', ...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildCampaignContext', () => {
|
||||
it('states the right system constraint and forbids the other (pf2e)', () => {
|
||||
const ctx = buildCampaignContext({ campaign: campaign('pf2e'), characters: [pc('A', 3, 'pf2e')], encounters: [], quests: [], notes: [] });
|
||||
expect(ctx.systemLabel).toMatch(/pathfinder/i);
|
||||
expect(ctx.systemConstraint).toMatch(/pathfinder/i);
|
||||
expect(ctx.systemConstraint.toLowerCase()).toContain('never suggest content from any other game system');
|
||||
expect(ctx.partyLevels).toEqual([3]);
|
||||
});
|
||||
|
||||
it('rates recent encounters using the party-level snapshot when present', () => {
|
||||
const snap = enc([{ cr: 1 }, { cr: 1 }], { partyLevelsSnapshot: [1, 1, 1, 1] });
|
||||
const ctx = buildCampaignContext({ campaign: campaign('5e'), characters: [pc('A', 10, '5e')], encounters: [snap], quests: [], notes: [] });
|
||||
expect(ctx.recentEncounters).toHaveLength(1);
|
||||
expect(ctx.recentEncounters[0]!.basedOnSnapshot).toBe(true);
|
||||
// Two CR-1 monsters vs four level-1 PCs is a real fight, not trivial.
|
||||
expect(ctx.recentEncounters[0]!.difficulty).not.toBe('trivial');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickCreatureCandidates', () => {
|
||||
it('returns only level-appropriate 5e creatures, using cr', () => {
|
||||
const pool = [{ name: 'Rat', cr: 0, armor_class: 10, hit_points: 1 }, { name: 'Ogre', cr: 2 }, { name: 'Tarrasque', cr: 30 }];
|
||||
const got = pickCreatureCandidates('5e', [3, 3, 3, 3], pool, 'Medium');
|
||||
const names = got.map((c) => c.name);
|
||||
expect(names).toContain('Ogre');
|
||||
expect(names).not.toContain('Tarrasque');
|
||||
expect(got.find((c) => c.name === 'Rat')?.ac).toBe(10);
|
||||
});
|
||||
it('uses level (not cr) for pf2e', () => {
|
||||
const pool = [{ name: 'Goblin', level: 1, ac: 16, hp: 9 }, { name: 'Dragon', level: 18 }];
|
||||
const got = pickCreatureCandidates('pf2e', [2, 2, 2, 2], pool, 'Moderate');
|
||||
expect(got.map((c) => c.name)).toEqual(['Goblin']);
|
||||
expect(got[0]!.hp).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Campaign, Character, Encounter, Note, Quest } from '@/lib/schemas';
|
||||
import { getSystem, type SystemId } from '@/lib/rules';
|
||||
import { computeBudget } from '@/lib/combat/budget';
|
||||
|
||||
export interface PartyMember {
|
||||
name: string;
|
||||
className: string;
|
||||
level: number;
|
||||
abilities: Character['abilities'];
|
||||
keyResources: string[];
|
||||
}
|
||||
|
||||
export interface RecentEncounter {
|
||||
name: string;
|
||||
difficulty: string;
|
||||
ratingXp: number;
|
||||
basedOnSnapshot: boolean;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
export interface CampaignContext {
|
||||
system: SystemId;
|
||||
systemLabel: string;
|
||||
/** Leads every prompt — the anti-cross-system-hallucination anchor. */
|
||||
systemConstraint: string;
|
||||
partyLevels: number[];
|
||||
party: PartyMember[];
|
||||
recentEncounters: RecentEncounter[];
|
||||
questsSummary: string;
|
||||
notesSummary: string;
|
||||
}
|
||||
|
||||
export interface CreatureCandidate {
|
||||
name: string;
|
||||
rating: number;
|
||||
ac?: number;
|
||||
hp?: number;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** Assemble a grounded, system-aware snapshot of the campaign for LLM prompts. */
|
||||
export function buildCampaignContext(input: {
|
||||
campaign: Campaign;
|
||||
characters: Character[];
|
||||
encounters: Encounter[];
|
||||
quests: Quest[];
|
||||
notes: Note[];
|
||||
}): CampaignContext {
|
||||
const { campaign, characters, encounters, quests, notes } = input;
|
||||
const system = campaign.system;
|
||||
const systemLabel = getSystem(system).label;
|
||||
const systemConstraint =
|
||||
`This is a ${systemLabel} (system id: ${system}) campaign. ` +
|
||||
`Only recommend ${systemLabel} rules, creatures, classes, feats, and options. ` +
|
||||
`Never suggest content from any other game system (e.g. do not mention D&D 5e content in a Pathfinder 2e game, or vice versa).`;
|
||||
|
||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||
const partyLevels = pcs.map((c) => c.level);
|
||||
const party: PartyMember[] = pcs.map((c) => ({
|
||||
name: c.name,
|
||||
className: c.className || 'Adventurer',
|
||||
level: c.level,
|
||||
abilities: c.abilities,
|
||||
keyResources: c.resources.map((r) => r.name),
|
||||
}));
|
||||
|
||||
const recentEncounters: RecentEncounter[] = [...encounters]
|
||||
.filter((e) => e.status !== 'planning')
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 8)
|
||||
.map((e) => {
|
||||
const levels = e.partyLevelsSnapshot?.length ? e.partyLevelsSnapshot : partyLevels;
|
||||
const budget = computeBudget(system, levels, e.combatants);
|
||||
return {
|
||||
name: e.name,
|
||||
difficulty: budget.difficulty,
|
||||
ratingXp: budget.ratingXp,
|
||||
basedOnSnapshot: !!e.partyLevelsSnapshot?.length,
|
||||
...(e.outcome ? { outcome: e.outcome } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const activeQuests = quests.filter((q) => q.status === 'active');
|
||||
const questsSummary = activeQuests.length
|
||||
? `${activeQuests.length} active: ${activeQuests.map((q) => q.title).slice(0, 8).join('; ')}`
|
||||
: 'No active quests.';
|
||||
const notesSummary = notes.length
|
||||
? `${notes.length} notes: ${notes.map((n) => n.title).slice(0, 8).join('; ')}`
|
||||
: 'No notes yet.';
|
||||
|
||||
return { system, systemLabel, systemConstraint, partyLevels, party, recentEncounters, questsSummary, notesSummary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a shortlist of level/CR-appropriate creatures from the correct system's
|
||||
* bestiary, so LLM recommendations are grounded in real data (no hallucination).
|
||||
* Reuses the eligibility window from `buildSuggestedEncounter`.
|
||||
*/
|
||||
export function pickCreatureCandidates(
|
||||
system: SystemId,
|
||||
partyLevels: number[],
|
||||
pool: Record<string, unknown>[],
|
||||
targetDifficulty: string,
|
||||
limit = 12,
|
||||
): CreatureCandidate[] {
|
||||
const avg = partyLevels.length ? Math.round(partyLevels.reduce((a, b) => a + b, 0) / partyLevels.length) : 1;
|
||||
const is5e = system === '5e';
|
||||
|
||||
const normalized = pool
|
||||
.map((m): CreatureCandidate | null => {
|
||||
const rating = is5e ? num(m.cr) : num(m.level);
|
||||
const name = typeof m.name === 'string' ? m.name : undefined;
|
||||
if (rating === undefined || !name) return null;
|
||||
const c: CreatureCandidate = { name, rating };
|
||||
const ac = num(is5e ? m.armor_class : m.ac);
|
||||
const hp = num(is5e ? m.hit_points : m.hp);
|
||||
const ref = typeof m.slug === 'string' ? m.slug : name;
|
||||
if (ac !== undefined) c.ac = ac;
|
||||
if (hp !== undefined) c.hp = hp;
|
||||
c.ref = ref;
|
||||
return c;
|
||||
})
|
||||
.filter((c): c is CreatureCandidate => c !== null)
|
||||
.filter((c) =>
|
||||
is5e ? c.rating <= avg + 1 && c.rating >= Math.max(0, avg - 6) : c.rating <= avg + 2 && c.rating >= avg - 4,
|
||||
);
|
||||
|
||||
// Harder targets favour the top of the window; easier ones the bottom.
|
||||
const harder = /hard|deadly|severe|extreme/i.test(targetDifficulty);
|
||||
normalized.sort((a, b) => (harder ? b.rating - a.rating : a.rating - b.rating));
|
||||
return normalized.slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { difficultyTendency, detectThemes } from './patterns';
|
||||
import { buildCampaignContext } from './context';
|
||||
import type { Campaign, Character, Encounter } from '@/lib/schemas';
|
||||
import { characterDefaults } from '@/lib/schemas';
|
||||
|
||||
function enc(id: string, crs: number[], over: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id, campaignId: 'c', name: id, status: 'ended', round: 1, turnIndex: 0,
|
||||
combatants: crs.map((cr, i) => ({
|
||||
id: `${id}-${i}`, name: 'Mob', kind: 'monster', initiative: 10, initBonus: 0, ac: 12,
|
||||
hp: { current: 5, max: 5, temp: 0 }, conditions: [], notes: '', cr,
|
||||
})),
|
||||
log: [], createdAt: `2026-01-0${id}`, updatedAt: '', ...over,
|
||||
};
|
||||
}
|
||||
function campaign(): Campaign {
|
||||
return { id: 'c', name: 'T', system: '5e', description: '', createdAt: '', updatedAt: '' };
|
||||
}
|
||||
function pc(level: number): Character {
|
||||
return {
|
||||
id: 'p', campaignId: 'c', system: '5e', kind: 'pc', name: 'P', ancestry: '', className: 'Fighter', level,
|
||||
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 },
|
||||
hp: { current: 10, max: 10, temp: 0 }, speed: 30, armorBonus: 0,
|
||||
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('difficultyTendency', () => {
|
||||
it('flags a too-easy trend for a high-level party fighting weak mobs', () => {
|
||||
const fights = [enc('1', [0]), enc('2', [0.125]), enc('3', [0.25]), enc('4', [0])];
|
||||
const t = difficultyTendency(fights, '5e', [12, 12, 12, 12]);
|
||||
expect(t.skew).toBe('too-easy');
|
||||
expect(t.sampleSize).toBe(4);
|
||||
});
|
||||
it('flags a too-hard trend for a low-level party fighting strong mobs', () => {
|
||||
const fights = [enc('1', [8, 8]), enc('2', [9]), enc('3', [8, 8])];
|
||||
const t = difficultyTendency(fights, '5e', [1, 1, 1, 1]);
|
||||
expect(t.skew).toBe('too-hard');
|
||||
});
|
||||
it('returns balanced with low sample size', () => {
|
||||
const t = difficultyTendency([enc('1', [1])], '5e', [3, 3, 3, 3]);
|
||||
expect(t.skew).toBe('balanced');
|
||||
expect(t.sampleSize).toBe(1);
|
||||
});
|
||||
it('prefers the per-encounter snapshot over fallback levels', () => {
|
||||
// Snapshot says the party was level 1 when they fought CR-3 mobs → hard/deadly,
|
||||
// even though the fallback (current) levels are 20.
|
||||
const fights = [
|
||||
enc('1', [3, 3], { partyLevelsSnapshot: [1, 1, 1, 1] }),
|
||||
enc('2', [3, 3], { partyLevelsSnapshot: [1, 1, 1, 1] }),
|
||||
enc('3', [3], { partyLevelsSnapshot: [1, 1, 1, 1] }),
|
||||
];
|
||||
const t = difficultyTendency(fights, '5e', [20, 20, 20, 20]);
|
||||
expect(t.skew).toBe('too-hard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectThemes', () => {
|
||||
it('produces an encounter-difficulty theme from a too-easy context', () => {
|
||||
const fights = [enc('1', [0]), enc('2', [0.125]), enc('3', [0.25]), enc('4', [0])];
|
||||
const ctx = buildCampaignContext({ campaign: campaign(), characters: [pc(12)], encounters: fights, quests: [], notes: [] });
|
||||
const themes = detectThemes(ctx);
|
||||
expect(themes.find((t) => t.kind === 'encounter-difficulty')?.tendency).toMatch(/easy/i);
|
||||
expect(themes[0]!.recommendation).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Encounter } from '@/lib/schemas';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import { computeBudget } from '@/lib/combat/budget';
|
||||
import type { Severity } from './advisors';
|
||||
import type { CampaignContext } from './context';
|
||||
|
||||
export type ThemeKind = 'encounter-difficulty' | 'resource-attrition' | 'quest-stagnation';
|
||||
|
||||
export interface CampaignTheme {
|
||||
kind: ThemeKind;
|
||||
severity: Severity;
|
||||
tendency: string;
|
||||
evidence: string;
|
||||
recommendation?: string;
|
||||
}
|
||||
|
||||
export type Skew = 'too-easy' | 'balanced' | 'too-hard';
|
||||
|
||||
/** difficulty label → 0 (trivial) … 4 (deadly/extreme), shared across systems. */
|
||||
const ORDINAL: Record<string, number> = {
|
||||
trivial: 0, easy: 1, low: 1, medium: 2, moderate: 2, hard: 3, severe: 3, deadly: 4, extreme: 4,
|
||||
};
|
||||
|
||||
function classify(ords: number[]): { skew: Skew; sampleSize: number; detail: string } {
|
||||
const n = ords.length;
|
||||
if (n < 3) return { skew: 'balanced', sampleSize: n, detail: `Only ${n} rated encounter${n === 1 ? '' : 's'} so far — not enough to spot a trend.` };
|
||||
const avg = ords.reduce((a, b) => a + b, 0) / n;
|
||||
const easyCount = ords.filter((o) => o <= 1).length;
|
||||
const hardCount = ords.filter((o) => o >= 3).length;
|
||||
if (avg < 1.5) {
|
||||
return { skew: 'too-easy', sampleSize: n, detail: `${easyCount} of the last ${n} encounters were easy or trivial for the party.` };
|
||||
}
|
||||
if (avg > 2.75) {
|
||||
return { skew: 'too-hard', sampleSize: n, detail: `${hardCount} of the last ${n} encounters were hard or deadly for the party.` };
|
||||
}
|
||||
return { skew: 'balanced', sampleSize: n, detail: `Encounter difficulty is well balanced across the last ${n} encounters.` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether the DM's recent encounters skew easy/hard for the party,
|
||||
* rating each fight against its party-level snapshot (or the fallback levels).
|
||||
*/
|
||||
export function difficultyTendency(
|
||||
encounters: Encounter[],
|
||||
system: SystemId,
|
||||
fallbackLevels: number[],
|
||||
): { skew: Skew; sampleSize: number; detail: string } {
|
||||
const ords: number[] = [];
|
||||
for (const e of encounters) {
|
||||
if (e.status === 'planning') continue;
|
||||
const levels = e.partyLevelsSnapshot?.length ? e.partyLevelsSnapshot : fallbackLevels;
|
||||
const budget = computeBudget(system, levels, e.combatants);
|
||||
if (budget.monstersCounted > 0) ords.push(ORDINAL[budget.difficulty] ?? 2);
|
||||
}
|
||||
return classify(ords);
|
||||
}
|
||||
|
||||
/** Deterministic campaign "themes" surfaced as insight cards and used to ground LLM tips. */
|
||||
export function detectThemes(ctx: CampaignContext, window = 8): CampaignTheme[] {
|
||||
const themes: CampaignTheme[] = [];
|
||||
|
||||
const rated = ctx.recentEncounters.slice(0, window).filter((e) => e.ratingXp > 0);
|
||||
const t = classify(rated.map((e) => ORDINAL[e.difficulty] ?? 2));
|
||||
if (t.skew === 'too-easy') {
|
||||
themes.push({
|
||||
kind: 'encounter-difficulty', severity: 'warn',
|
||||
tendency: 'Your encounters skew easy for the party.',
|
||||
evidence: t.detail,
|
||||
recommendation: 'Add a stronger creature or two to your next fight to raise the challenge.',
|
||||
});
|
||||
} else if (t.skew === 'too-hard') {
|
||||
themes.push({
|
||||
kind: 'encounter-difficulty', severity: 'warn',
|
||||
tendency: 'Your encounters skew hard for the party.',
|
||||
evidence: t.detail,
|
||||
recommendation: 'Consider easing the next encounter, or give the party a chance to rest first.',
|
||||
});
|
||||
}
|
||||
|
||||
return themes;
|
||||
}
|
||||
@@ -70,6 +70,10 @@ export class TtrpgDatabase extends Dexie {
|
||||
|
||||
// v6 — Phase 9 homebrew content.
|
||||
this.version(6).stores({ homebrew: 'id, campaignId, [campaignId+kind]' });
|
||||
|
||||
// v7 — Phase 13 assistant: encounters gain optional partyLevelsSnapshot +
|
||||
// outcome. Both optional, so existing rows stay valid; no backfill needed.
|
||||
this.version(7).stores({});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ export const encounterSchema = z.object({
|
||||
combatants: z.array(combatantSchema).default([]),
|
||||
/** newest-last event feed for the fight */
|
||||
log: z.array(logEntrySchema).default([]),
|
||||
/** PC levels captured when combat started — lets the assistant rate this
|
||||
* encounter's difficulty against the party as it was, not as it is now. */
|
||||
partyLevelsSnapshot: z.array(int.positive()).optional(),
|
||||
/** how the fight resolved, if recorded */
|
||||
outcome: z.enum(['tpk', 'party-victory', 'fled', 'unknown']).optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user