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:
@@ -50,8 +50,11 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
mutate((e) => logEvent(applyInitiatives(e, rolls), 'Rolled initiative for all combatants'));
|
mutate((e) => logEvent(applyInitiatives(e, rolls), 'Rolled initiative for all combatants'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Difficulty budget from monster combatants vs the campaign's PCs.
|
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat
|
||||||
const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
|
// has started, rate against the levels captured at that time (snapshot) so the
|
||||||
|
// reading — and the assistant's history — reflects the party as it was.
|
||||||
|
const currentLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
|
||||||
|
const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
|
||||||
const monsters = encounter.combatants
|
const monsters = encounter.combatants
|
||||||
.filter((c) => c.kind === 'monster')
|
.filter((c) => c.kind === 'monster')
|
||||||
.map((c) => ({ cr: c.cr, level: c.level }));
|
.map((c) => ({ cr: c.cr, level: c.level }));
|
||||||
@@ -97,7 +100,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={encounter.combatants.length === 0}
|
disabled={encounter.combatants.length === 0}
|
||||||
onClick={() => mutate(startEncounter)}
|
onClick={() => mutate((e) => ({
|
||||||
|
...startEncounter(e),
|
||||||
|
...(currentLevels.length ? { partyLevelsSnapshot: currentLevels } : {}),
|
||||||
|
}))}
|
||||||
>
|
>
|
||||||
Start combat
|
Start combat
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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.
|
// v6 — Phase 9 homebrew content.
|
||||||
this.version(6).stores({ homebrew: 'id, campaignId, [campaignId+kind]' });
|
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([]),
|
combatants: z.array(combatantSchema).default([]),
|
||||||
/** newest-last event feed for the fight */
|
/** newest-last event feed for the fight */
|
||||||
log: z.array(logEntrySchema).default([]),
|
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(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/encounter.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user