# Research: Encounter Difficulty Indicator **Date**: 2026-03-27 | **Feature**: 008-encounter-difficulty ## Research Questions & Findings ### 1. Where does the CR-to-XP mapping come from? **Decision**: Create a static lookup table in the domain layer as a `Record`. **Rationale**: No CR-to-XP mapping exists in the codebase. The bestiary index stores CR as a string but does not include XP. The standard 5e CR-to-XP table is fixed (published rules), so a static lookup is the simplest approach. The existing `proficiencyBonus(cr)` function in `creature-types.ts` demonstrates the pattern: a pure function that maps a CR string to a derived value. **Alternatives considered**: - Adding XP to the bestiary index: Would require rebuilding the index generation script and inflating the index size. Unnecessary since XP is deterministically derived from CR. - Computing XP from CR via formula: No clean formula exists for the 5e table — it's an irregular curve. A lookup table is more reliable and readable. ### 2. How does the difficulty component access creature CR at runtime? **Decision**: The difficulty hook (`useDifficulty`) will consume `useBestiary()` to access the creature map, then look up CR for each combatant with a `creatureId`. **Rationale**: The `useBestiary()` hook already provides `getCreature(id): Creature | undefined`, which returns the full Creature including `cr`. This is the established pattern — `CombatantRow` and `StatBlockPanel` already use it. No new adapter or port is needed. **Data flow**: ``` useDifficulty hook ├── useEncounterContext() → encounter.combatants[] ├── usePlayerCharactersContext() → characters[] (for level lookup) └── useBestiary() → getCreature(creatureId) → creature.cr └── domain: crToXp(cr) → xp └── domain: calculateDifficulty({ partyLevels, monsterXp }) → DifficultyResult ``` **Alternatives considered**: - Passing CR through the application layer port: Would add a `BestiarySourceCache` dependency to the difficulty calculation, breaking domain purity. Better to resolve CRs to XP values in the hook (adapter layer) and pass pure data to the domain function. ### 3. How should the domain difficulty function be structured? **Decision**: A single pure function that takes resolved inputs (party levels as `number[]`, monster XP values as `number[]`) and returns a `DifficultyResult`. **Rationale**: Keeping the domain function agnostic to how levels and XP are obtained preserves purity and testability. The function doesn't need to know about `PlayerCharacter`, `Combatant`, or `Creature` types — just numbers. This follows the pattern of `advanceTurn(encounter)` and `proficiencyBonus(cr)`: pure inputs → pure outputs. **Function signature** (domain): ``` calculateEncounterDifficulty(partyLevels: number[], monsterCrs: string[]): DifficultyResult ``` Takes party levels (already filtered to only PCs with levels) and monster CR strings (already filtered to only bestiary-linked combatants). Returns the tier, total XP, and budget thresholds. **Alternatives considered**: - Taking full `Combatant[]` + `PlayerCharacter[]`: Would couple the difficulty module to combatant/PC types unnecessarily. - Separate functions for budget and XP: The calculation is simple enough for one function. Internal helpers can split the logic without exposing multiple public functions. ### 4. How does `level` integrate with the PlayerCharacter CRUD flow? **Decision**: Add `level?: number` to `PlayerCharacter` interface. Validation in `createPlayerCharacter` and `editPlayerCharacter` domain functions. Passed through use cases and context like existing fields. **Rationale**: The existing pattern for optional fields (e.g., `color`, `icon`) is well-established: - Domain type: optional field on interface - Create function: validate if provided, skip if undefined - Edit function: validate in `validateFields()`, apply in `applyFields()` - Use case: pass through from adapter - Context: expose in create/edit methods - UI: optional input field in modal Following this exact pattern for `level` minimizes risk and code churn. ### 5. Does adding `level` to PlayerCharacter affect export compatibility? **Decision**: No version bump needed. The field is optional, so existing exports (version 1) import correctly — `level` will be `undefined` for old data. **Rationale**: The `ExportBundle` includes `playerCharacters: readonly PlayerCharacter[]`. Adding an optional field to `PlayerCharacter` is backward-compatible: old exports simply lack the field, which TypeScript treats as `undefined`. The `validateImportBundle()` function doesn't validate individual PlayerCharacter fields beyond basic structure checks. ### 6. Should the difficulty calculation live in a new domain module or extend an existing one? **Decision**: New module `encounter-difficulty.ts` in `packages/domain/src/`. **Rationale**: The difficulty calculation is a self-contained concern with its own lookup tables and types. It doesn't naturally belong in `creature-types.ts` (which is about creature data structures) or `types.ts` (which is about encounter/combatant structure). A dedicated module keeps concerns separated and makes the feature easy to find, test, and potentially remove. ### 7. How should the 3-bar indicator be rendered? **Decision**: Simple `div` elements with Tailwind CSS classes for color and fill state. No external icon or SVG needed. **Rationale**: The bars are simple rectangles with conditional fill colors (green/yellow/red). Tailwind's `bg-*` utilities handle this trivially. The existing codebase uses Tailwind for all styling with no CSS-in-JS or external style libraries. A native HTML tooltip (`title` attribute) handles the hover tooltip requirement. **Alternatives considered**: - Lucide icons: No suitable "signal bars" icon exists. Custom SVG would be overkill for 3 rectangles. - CSS custom properties for colors: Unnecessary abstraction for 3 fixed states.