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>
88 lines
5.9 KiB
Markdown
88 lines
5.9 KiB
Markdown
# 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.
|