Add encounter difficulty indicator (5.5e XP budget)
All checks were successful
CI / check (push) Successful in 1m13s
CI / build-image (push) Successful in 16s

Live 3-bar difficulty indicator in the top bar showing encounter
difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP
budget system. Automatically derived from PC levels and bestiary
creature CRs.

- Add optional level field (1-20) to PlayerCharacter
- Add CR-to-XP and XP Budget per Character lookup tables in domain
- Add calculateEncounterDifficulty pure function
- Add DifficultyIndicator component with color-coded bars and tooltip
- Add useDifficulty hook composing encounter, PC, and bestiary contexts
- Indicator hidden when no PCs with levels or no bestiary-linked monsters
- Level field in PC create/edit forms, persisted in storage

Closes #18

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-27 22:55:48 +01:00
parent 36122b500b
commit ef76b9c90b
32 changed files with 1648 additions and 11 deletions

View File

@@ -0,0 +1,87 @@
# 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<string, number>`.
**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.