Add combatant side assignment for encounter difficulty
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s

Combatants can now be assigned to party or enemy side via a toggle
in the difficulty breakdown panel. Party-side NPCs subtract their XP
from the encounter total, letting allied NPCs reduce difficulty.
PCs default to party, non-PCs to enemy — users who don't use sides
see no change. Side persists across reload and export/import.

Closes #22

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-03 14:15:12 +02:00
parent 30e7ed4121
commit 94e1806112
23 changed files with 1359 additions and 455 deletions

View File

@@ -3,7 +3,7 @@
**Feature Branch**: `008-encounter-difficulty`
**Created**: 2026-03-27
**Status**: Draft
**Input**: Gitea issue #18 — "Encounter difficulty indicator (5.5e XP budget)"
**Input**: Gitea issue #18 — "Encounter difficulty indicator (5.5e XP budget)", Gitea issue #22 — "Combatant side assignment for encounter difficulty"
## User Scenarios & Testing *(mandatory)*
@@ -101,7 +101,7 @@ The difficulty calculation uses the 2024 5.5e XP Budget per Character table and
3. **Given** a party with PCs at different levels (e.g., three level 3 and one level 2), **When** the budget is calculated, **Then** each PC's budget is looked up individually by level and summed (not averaged).
4. **Given** an encounter with bestiary-linked combatants, custom combatants with CR assigned, and custom combatants without CR, **When** the XP total is calculated, **Then** bestiary-linked combatants contribute XP from their creature CR and custom combatants with CR contribute XP from their assigned CR. Custom combatants without CR are excluded.
4. **Given** an encounter with bestiary-linked combatants, custom combatants with CR assigned, and custom combatants without CR, **When** the XP total is calculated, **Then** enemy-side combatants with CR add XP to the monster total, party-side combatants with CR subtract XP from the monster total, and custom combatants without CR are excluded. The net monster XP is floored at 0.
5. **Given** a PC combatant whose player character has no level, **When** the budget is calculated, **Then** that PC is excluded from the budget (as if they are not in the party).
@@ -119,7 +119,7 @@ The game master taps the difficulty indicator to open a breakdown panel. The pan
**Acceptance Scenarios**:
1. **Given** the difficulty indicator is visible, **When** the user taps the indicator, **Then** a breakdown panel opens showing party budget, per-combatant XP contributions, and total monster XP.
1. **Given** the difficulty indicator is visible, **When** the user taps the indicator, **Then** a breakdown panel opens showing party budget, two columns (Party and Enemy) listing combatants with their XP contributions, a side toggle per combatant, and the net monster XP total.
2. **Given** the breakdown panel is open, **When** the user taps outside the panel or taps a close control, **Then** the panel closes.
@@ -131,6 +131,8 @@ The game master taps the difficulty indicator to open a breakdown panel. The pan
6. **Given** the breakdown panel is open, **When** a combatant is added or removed from the encounter, **Then** the panel content updates immediately.
7. **Given** the breakdown panel is open, **When** the user toggles a combatant's side, **Then** it moves to the other column and the difficulty tier, monster XP total, and party budget update immediately.
---
### Manual CR Assignment
@@ -177,6 +179,34 @@ Bestiary-linked combatants derive their CR from the creature data. The breakdown
---
### Side Assignment
**Story ED-8 — Assign combatants to party or enemy side (Priority: P2)**
A game master has allied NPCs fighting alongside the party. From the difficulty breakdown panel, they toggle an NPC to the party side. The NPC's XP is subtracted from the monster total instead of added, and the difficulty tier drops accordingly. PC combatants default to the party side and non-PC combatants default to the enemy side, so users who don't care about sides never interact with this feature.
**Why this priority**: Extends the breakdown panel (ED-5) with side assignment. Without sides, allied NPCs inflate difficulty artificially.
**Independent Test**: Can be tested by adding a leveled PC and two monsters, toggling one monster to party side, and verifying its XP is subtracted from the total.
**Acceptance Scenarios**:
1. **Given** the breakdown panel is open, **When** a non-PC combatant's side is toggled to party, **Then** its CR-derived XP is subtracted from the monster total instead of added, and the difficulty tier recalculates immediately.
2. **Given** a combatant with both a level (from its player character) and a CR on the party side, **When** the difficulty is calculated, **Then** it contributes to the party budget via its level AND subtracts its CR XP from the monster total — both effects apply independently.
3. **Given** party-side combatants whose total CR XP exceeds the enemy-side total, **When** the difficulty is calculated, **Then** the net monster XP is floored at 0 (difficulty cannot go negative).
4. **Given** the breakdown panel is open, **When** the user views a PC combatant, **Then** it appears in the Party column by default. **When** the user views a non-PC combatant, **Then** it appears in the Enemy column by default. Both can be toggled.
5. **Given** a combatant's side has been toggled, **When** the encounter is saved and the page is reloaded, **Then** the side assignment is restored.
6. **Given** a combatant's side has been toggled, **When** the encounter is exported to JSON and re-imported, **Then** the side assignment is preserved.
7. **Given** the breakdown panel is open, **Then** above the two columns a brief rules-oriented explanation is shown: "Allied NPC XP is subtracted from encounter difficulty" (tone is mechanical/rules-focused).
---
### Edge Cases
- **All bars empty (trivial)**: When total monster XP is greater than 0 but below the Low threshold, the indicator shows three empty bars. This communicates "we can calculate, but it's trivial."
@@ -190,6 +220,9 @@ Bestiary-linked combatants derive their CR from the creature data. The breakdown
- **PCs without level silently excluded**: PC combatants whose player character has no level do not contribute to the budget and are not flagged.
- **Indicator with empty encounter**: When the encounter has no combatants, the indicator is hidden (the top bar may not even render per existing behavior).
- **Level field on existing player characters**: Existing player characters created before this feature will have no level. They are treated as "no level assigned" — no migration or default is needed.
- **Net monster XP floored at 0**: If party-side combatant XP exceeds enemy-side combatant XP, the net monster XP is 0 (trivial), not negative.
- **Dual contribution (level + CR on party side)**: A combatant with both a level and a CR on the party side contributes to the party budget via level and subtracts from monster XP via CR. These are independent effects.
- **Side defaults preserve opt-in**: Because PCs default to party and others default to enemy, users who never assign sides see identical behavior to the pre-side-assignment calculation.
---
@@ -206,8 +239,8 @@ The system MUST contain a CR-to-XP lookup table mapping all standard 5e challeng
#### FR-003 — Party XP budget calculation
The system MUST calculate the party's XP budget by summing the per-character budget for each PC combatant whose player character has a level assigned. PCs without a level are excluded from the sum.
#### FR-004 — Monster XP total calculation
The system MUST calculate the total monster XP by summing the XP value (derived from CR) for each combatant that has a CR. For bestiary-linked combatants, CR is derived from the creature data via `creatureId`. For custom combatants, CR comes from the optional `cr` field. Combatants with neither `creatureId` nor `cr` are excluded.
#### FR-004 — Net monster XP calculation
The system MUST calculate the net monster XP by summing the XP value (derived from CR) for each enemy-side combatant that has a CR and subtracting the XP value for each party-side combatant that has a CR. For bestiary-linked combatants, CR is derived from the creature data via `creatureId`. For custom combatants, CR comes from the optional `cr` field. Combatants with neither `creatureId` nor `cr` are excluded. The net monster XP MUST be floored at 0.
#### FR-005 — Difficulty tier determination
The system MUST determine the encounter difficulty tier by comparing total monster XP against the party's Low, Moderate, and High thresholds. The tier is the highest threshold that the total XP meets or exceeds. If below Low, the encounter is trivial (no tier label).
@@ -239,24 +272,39 @@ The player character level MUST be persisted and restored across sessions, consi
#### FR-014 — High is the cap
When total monster XP exceeds the High threshold, the indicator MUST display the High state (three red bars). There is no tier above High.
#### FR-015 — Optional CR field on Combatant
The `Combatant` entity MUST support an optional `cr` field accepting standard 5e challenge rating strings ("0", "1/8", "1/4", "1/2", "1""30").
#### FR-015 — Optional CR and side fields on Combatant
The `Combatant` entity MUST support an optional `cr` field accepting standard 5e challenge rating strings ("0", "1/8", "1/4", "1/2", "1""30") and an optional `side` field accepting `"party"` or `"enemy"`.
#### FR-016 — Tappable difficulty indicator
The difficulty indicator MUST be tappable, opening a difficulty breakdown panel.
#### FR-017 — Breakdown panel content
The breakdown panel MUST display: the party XP budget (with Low, Moderate, High thresholds), a list of combatants showing name, CR, and XP contribution, and the total monster XP.
The breakdown panel MUST display: the party XP budget (with Low, Moderate, High thresholds), two stacked sections (Party and Enemy) using a columnar grid layout (name, toggle button, CR, XP) for aligned readability, the net monster XP total, and a brief rules-oriented explanation (e.g., "Allied NPC XP is subtracted from encounter difficulty"). Source names are omitted from the panel to conserve horizontal space.
#### FR-018 — CR picker for custom combatants
The breakdown panel MUST provide a CR picker for custom combatants (those without `creatureId`) offering all standard 5e CR values: 0, 1/8, 1/4, 1/2, 130.
#### FR-019 — Bestiary CR precedence
When a combatant has a `creatureId`, the system MUST derive CR from the linked creature data. The manual `cr` field MUST be ignored. The breakdown panel MUST display bestiary-linked CRs as read-only with the source name visible.
When a combatant has a `creatureId`, the system MUST derive CR from the linked creature data. The manual `cr` field MUST be ignored. The breakdown panel MUST display bestiary-linked CRs as read-only.
#### FR-020 — CR persistence
The `cr` field on `Combatant` MUST persist within the encounter across page reloads (via encounter storage) and MUST round-trip through JSON export/import.
#### FR-021 — Side defaults
When `side` is undefined, PC combatants MUST default to party side and all other combatants MUST default to enemy side. The `useDifficulty` hook resolves defaults before calling the domain function.
#### FR-022 — Party-side CR subtraction
Party-side combatants with CR MUST have their XP subtracted from the monster total. Party-side combatants with level MUST contribute to the party budget. These effects are independent — a combatant with both level and CR on party side contributes to budget AND subtracts from monster XP.
#### FR-023 — Side toggle in breakdown panel
The breakdown panel MUST provide a side toggle button per non-PC combatant to switch between party and enemy side. PC combatants are fixed to the party side and do not show a toggle. Toggling MUST immediately update the difficulty calculation. The toggle button uses an arrow icon with a hover background effect for discoverability.
#### FR-024 — Side persistence
The `side` field on `Combatant` MUST persist within the encounter across page reloads (via encounter storage) and MUST round-trip through JSON export/import.
#### FR-025 — Domain function signature
The `calculateEncounterDifficulty` domain function MUST accept combatant descriptors with `{ level?, cr?, side }` so it can partition combatants internally, replacing the current `partyLevels[]` / `monsterCrs[]` signature.
### Key Entities
- **XP Budget Table**: A lookup mapping character level (1-20) to three XP thresholds (Low, Moderate, High), sourced from the 2024 5.5e DMG.
@@ -265,6 +313,7 @@ The `cr` field on `Combatant` MUST persist within the encounter across page relo
- **DifficultyResult**: The output of the calculation containing the tier, total monster XP, and per-tier budget thresholds.
- **PlayerCharacter.level**: An optional integer (1-20) added to the existing `PlayerCharacter` entity defined in spec 005.
- **Combatant.cr**: An optional string field on the existing `Combatant` entity, accepting standard 5e CR values. Used for manual CR assignment on custom combatants. Ignored when the combatant has a `creatureId`.
- **Combatant.side**: An optional string field (`"party"` | `"enemy"`) on the existing `Combatant` entity. When undefined, defaults are resolved by the hook layer: PC combatants default to `"party"`, all others to `"enemy"`.
---
@@ -281,6 +330,7 @@ The `cr` field on `Combatant` MUST persist within the encounter across page relo
- **SC-007**: The optional level field integrates seamlessly into the existing player character create/edit workflow without disrupting existing functionality.
- **SC-008**: The difficulty breakdown panel correctly displays per-combatant XP contributions and party budget that sum to the values used for tier determination.
- **SC-009**: Custom combatants with manually assigned CR contribute correctly to the difficulty calculation, matching the same CR-to-XP mapping used for bestiary creatures.
- **SC-010**: Party-side combatants with CR correctly subtract their XP from the monster total, and the net XP is never negative.
---
@@ -293,6 +343,5 @@ The `cr` field on `Combatant` MUST persist within the encounter across page relo
- Existing player characters without a level are treated as "no level assigned" with no migration.
- The difficulty indicator occupies minimal horizontal space in the top bar and does not interfere with the combatant name truncation or other controls.
- The breakdown panel is the sole UI surface for manual CR assignment — there is no CR field in the combatant create/edit forms.
- MVP baseline does not include assigning combatants to party/enemy sides — all combatants with CR are counted as enemies.
- MVP baseline does not include the 2014 DMG encounter multiplier mechanic or the four-tier (Easy/Medium/Hard/Deadly) system.
- MVP baseline does not include per-combatant level overrides — level is always derived from the player character template.