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:
2026-06-08 09:08:14 +02:00
parent 7fd5fcd9af
commit f84e429ca4
8 changed files with 367 additions and 4 deletions
+9 -3
View File
@@ -50,8 +50,11 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
mutate((e) => logEvent(applyInitiatives(e, rolls), 'Rolled initiative for all combatants'));
};
// Difficulty budget from monster combatants vs the campaign's PCs.
const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat
// 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
.filter((c) => c.kind === 'monster')
.map((c) => ({ cr: c.cr, level: c.level }));
@@ -97,7 +100,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
<Button
variant="primary"
disabled={encounter.combatants.length === 0}
onClick={() => mutate(startEncounter)}
onClick={() => mutate((e) => ({
...startEncounter(e),
...(currentLevels.length ? { partyLevelsSnapshot: currentLevels } : {}),
}))}
>
Start combat
</Button>