3.8 KiB
Research: Combatant Concentration
Feature: 018-combatant-concentration | Date: 2026-03-06
R1: Domain Toggle Pattern
Decision: Mirror the toggleCondition pattern with a simpler toggleConcentration pure function.
Rationale: toggleCondition (in packages/domain/src/toggle-condition.ts) is the closest analogue. It takes an encounter + combatant ID, validates the combatant exists, toggles the state, and returns a new encounter + domain events. Concentration is simpler because it's a boolean (no condition ID validation needed).
Alternatives considered:
- Reuse condition system with a special "concentration" condition ID: Rejected because the spec explicitly requires concentration to be separate from conditions and not appear in the condition tag UI.
- Store concentration at the encounter level (map of combatant ID to boolean): Rejected because co-locating with the combatant is consistent with how all other per-combatant state (HP, AC, conditions) is stored.
R2: Storage Backward Compatibility
Decision: Add isConcentrating?: boolean as an optional field on the Combatant type. No migration needed.
Rationale: The localStorage adapter (apps/web/src/persistence/encounter-storage.ts) rehydrates combatants field-by-field with lenient validation. The AC field was added the same way (optional, defaults to undefined if absent). Old saved data without isConcentrating will load with the field absent (treated as false).
Alternatives considered:
- Versioned storage with explicit migration: Rejected because optional boolean fields don't require migration (same pattern used for AC).
R3: Damage Pulse Detection
Decision: Detect damage in the UI layer by comparing previous and current currentHp values, triggering the pulse animation when HP decreases on a concentrating combatant.
Rationale: The domain emits CurrentHpAdjusted events with a delta field, but the UI layer already receives updated combatant props. Comparing prevHp vs currentHp via a React ref or useEffect is the simplest approach and avoids threading domain events through additional channels. This keeps the pulse animation purely in the adapter layer (no domain logic for "should pulse" needed).
Alternatives considered:
- New domain event
ConcentrationCheckRequired: Rejected because it would encode gameplay rules (concentration saves) in the domain, violating Constitution Principle VII (no gameplay rules in domain/constitution). The pulse is purely a UI hint. - Pass domain events to CombatantRow: Rejected because events are consumed at the hook level and not currently threaded to individual row components. Adding event-based props would increase coupling.
R4: Pulse Animation Approach
Decision: Use CSS keyframe animation with a Tailwind utility class, triggered by a transient state flag.
Rationale: The project uses Tailwind CSS v4. A CSS @keyframes animation on the left border and icon can be triggered by adding/removing a CSS class. A short-lived React state flag (isPulsing) set on damage detection and auto-cleared after animation duration (~700ms) is the simplest approach.
Alternatives considered:
- JavaScript-driven animation (requestAnimationFrame): Rejected as over-engineered for a simple pulse effect.
- Framer Motion / React Spring: Rejected because neither is a project dependency, and a CSS keyframe animation is sufficient.
R5: Brain Icon Availability
Decision: Use Brain from lucide-react, already a project dependency.
Rationale: Confirmed lucide-react is listed in apps/web/package.json dependencies. The Brain icon is part of the standard Lucide icon set. Other icons used in the project (Swords, Heart, Shield, Plus, ChevronDown, etc.) follow the same import pattern.
Alternatives considered: None needed; icon is available.