Files
initiative/specs/008-encounter-difficulty/research.md
Lukas ef76b9c90b
All checks were successful
CI / check (push) Successful in 1m13s
CI / build-image (push) Successful in 16s
Add encounter difficulty indicator (5.5e XP budget)
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>
2026-03-27 22:55:48 +01:00

5.9 KiB

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.