Implement the 013-hp-status-indicators feature that adds visual HP status indicators to combatant rows with a pure domain function deriving bloodied/unconscious states
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|||||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input)
|
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input)
|
||||||
- N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input)
|
- N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input)
|
||||||
- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation)
|
- N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation)
|
||||||
|
- N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) (013-hp-status-indicators)
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CombatantId } from "@initiative/domain";
|
import { type CombatantId, deriveHpStatus } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
@@ -127,10 +127,12 @@ function CurrentHpInput({
|
|||||||
currentHp,
|
currentHp,
|
||||||
maxHp,
|
maxHp,
|
||||||
onCommit,
|
onCommit,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
currentHp: number | undefined;
|
currentHp: number | undefined;
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
onCommit: (value: number) => void;
|
onCommit: (value: number) => void;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState(currentHp?.toString() ?? "");
|
const [draft, setDraft] = useState(currentHp?.toString() ?? "");
|
||||||
const prev = useRef(currentHp);
|
const prev = useRef(currentHp);
|
||||||
@@ -161,7 +163,7 @@ function CurrentHpInput({
|
|||||||
value={draft}
|
value={draft}
|
||||||
placeholder="HP"
|
placeholder="HP"
|
||||||
disabled={maxHp === undefined}
|
disabled={maxHp === undefined}
|
||||||
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
className={cn("h-7 w-[7ch] text-center text-sm tabular-nums", className)}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -181,6 +183,7 @@ export function CombatantRow({
|
|||||||
onAdjustHp,
|
onAdjustHp,
|
||||||
}: CombatantRowProps) {
|
}: CombatantRowProps) {
|
||||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||||
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -189,6 +192,7 @@ export function CombatantRow({
|
|||||||
isActive
|
isActive
|
||||||
? "border-l-2 border-l-accent bg-accent/10"
|
? "border-l-2 border-l-accent bg-accent/10"
|
||||||
: "border-l-2 border-l-transparent",
|
: "border-l-2 border-l-transparent",
|
||||||
|
status === "unconscious" && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
@@ -219,6 +223,10 @@ export function CombatantRow({
|
|||||||
<CurrentHpInput
|
<CurrentHpInput
|
||||||
currentHp={currentHp}
|
currentHp={currentHp}
|
||||||
maxHp={maxHp}
|
maxHp={maxHp}
|
||||||
|
className={cn(
|
||||||
|
status === "bloodied" && "text-amber-400",
|
||||||
|
status === "unconscious" && "text-red-400",
|
||||||
|
)}
|
||||||
onCommit={(value) => {
|
onCommit={(value) => {
|
||||||
if (currentHp === undefined) return;
|
if (currentHp === undefined) return;
|
||||||
const delta = value - currentHp;
|
const delta = value - currentHp;
|
||||||
|
|||||||
55
packages/domain/src/__tests__/hp-status.test.ts
Normal file
55
packages/domain/src/__tests__/hp-status.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { deriveHpStatus } from "../hp-status.js";
|
||||||
|
|
||||||
|
describe("deriveHpStatus", () => {
|
||||||
|
it("returns healthy when currentHp >= maxHp / 2", () => {
|
||||||
|
expect(deriveHpStatus(10, 20)).toBe("healthy");
|
||||||
|
expect(deriveHpStatus(15, 20)).toBe("healthy");
|
||||||
|
expect(deriveHpStatus(20, 20)).toBe("healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns bloodied when 0 < currentHp < maxHp / 2", () => {
|
||||||
|
expect(deriveHpStatus(9, 20)).toBe("bloodied");
|
||||||
|
expect(deriveHpStatus(1, 20)).toBe("bloodied");
|
||||||
|
expect(deriveHpStatus(5, 20)).toBe("bloodied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unconscious when currentHp <= 0", () => {
|
||||||
|
expect(deriveHpStatus(0, 20)).toBe("unconscious");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unconscious with negative HP", () => {
|
||||||
|
expect(deriveHpStatus(-5, 20)).toBe("unconscious");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when maxHp is undefined", () => {
|
||||||
|
expect(deriveHpStatus(10, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when currentHp is undefined", () => {
|
||||||
|
expect(deriveHpStatus(undefined, 20)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when both are undefined", () => {
|
||||||
|
expect(deriveHpStatus(undefined, undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles maxHp=1 (no bloodied state possible)", () => {
|
||||||
|
expect(deriveHpStatus(1, 1)).toBe("healthy");
|
||||||
|
expect(deriveHpStatus(0, 1)).toBe("unconscious");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles maxHp=2 (1/2 is healthy, not bloodied)", () => {
|
||||||
|
expect(deriveHpStatus(1, 2)).toBe("healthy");
|
||||||
|
expect(deriveHpStatus(0, 2)).toBe("unconscious");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles odd maxHp=21 (10 is bloodied since 10 < 10.5)", () => {
|
||||||
|
expect(deriveHpStatus(10, 21)).toBe("bloodied");
|
||||||
|
expect(deriveHpStatus(11, 21)).toBe("healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns healthy when currentHp exceeds maxHp", () => {
|
||||||
|
expect(deriveHpStatus(25, 20)).toBe("healthy");
|
||||||
|
});
|
||||||
|
});
|
||||||
11
packages/domain/src/hp-status.ts
Normal file
11
packages/domain/src/hp-status.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type HpStatus = "healthy" | "bloodied" | "unconscious";
|
||||||
|
|
||||||
|
export function deriveHpStatus(
|
||||||
|
currentHp: number | undefined,
|
||||||
|
maxHp: number | undefined,
|
||||||
|
): HpStatus | undefined {
|
||||||
|
if (currentHp === undefined || maxHp === undefined) return undefined;
|
||||||
|
if (currentHp <= 0) return "unconscious";
|
||||||
|
if (currentHp < maxHp / 2) return "bloodied";
|
||||||
|
return "healthy";
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export type {
|
|||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
TurnRetreated,
|
TurnRetreated,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
|
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||||
export {
|
export {
|
||||||
type RemoveCombatantSuccess,
|
type RemoveCombatantSuccess,
|
||||||
removeCombatant,
|
removeCombatant,
|
||||||
|
|||||||
36
specs/013-hp-status-indicators/checklists/requirements.md
Normal file
36
specs/013-hp-status-indicators/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: HP Status Indicators
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-05
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
|
- FR-001 defines the exact thresholds for each status, making the boundary conditions fully testable.
|
||||||
|
- Edge cases cover odd max HP values, min max HP (1 and 2), over-max HP, and unset max HP.
|
||||||
46
specs/013-hp-status-indicators/data-model.md
Normal file
46
specs/013-hp-status-indicators/data-model.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Data Model: HP Status Indicators
|
||||||
|
|
||||||
|
**Feature**: 013-hp-status-indicators
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### HpStatus (derived type -- not stored)
|
||||||
|
|
||||||
|
A discriminated string union representing the health state of a combatant.
|
||||||
|
|
||||||
|
| Value | Condition | Description |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `"healthy"` | `currentHp >= maxHp / 2` | Combatant is above half HP |
|
||||||
|
| `"bloodied"` | `0 < currentHp < maxHp / 2` | Combatant is below half HP but still conscious |
|
||||||
|
| `"unconscious"` | `currentHp <= 0` | Combatant is at or below zero HP |
|
||||||
|
|
||||||
|
**Derivation rules**:
|
||||||
|
- Only meaningful when both `currentHp` and `maxHp` are defined
|
||||||
|
- Returns `undefined` when either value is missing (HP tracking not enabled)
|
||||||
|
- Evaluation order: check `unconscious` first (currentHp <= 0), then `bloodied` (currentHp < maxHp / 2), else `healthy`
|
||||||
|
|
||||||
|
### Combatant (existing -- unchanged)
|
||||||
|
|
||||||
|
No modifications to the `Combatant` interface. HP status is computed on demand from existing fields:
|
||||||
|
|
||||||
|
| Field | Type | Used for status |
|
||||||
|
|-------|------|----------------|
|
||||||
|
| `currentHp` | `number \| undefined` | Compared against thresholds |
|
||||||
|
| `maxHp` | `number \| undefined` | Defines the "half HP" bloodied threshold |
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
HP status changes are a side effect of existing HP operations (adjustHp, setHp). No new state transitions are introduced.
|
||||||
|
|
||||||
|
```text
|
||||||
|
healthy ──[take damage below half]──> bloodied
|
||||||
|
bloodied ──[take damage to 0]──────> unconscious
|
||||||
|
unconscious ──[heal above 0]───────> bloodied or healthy
|
||||||
|
bloodied ──[heal above half]───────> healthy
|
||||||
|
healthy ──[take massive damage]────> unconscious (skip bloodied)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Impact
|
||||||
|
|
||||||
|
None. HP status is derived at render time from existing persisted `currentHp` and `maxHp` values. No localStorage schema changes.
|
||||||
64
specs/013-hp-status-indicators/plan.md
Normal file
64
specs/013-hp-status-indicators/plan.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Implementation Plan: HP Status Indicators
|
||||||
|
|
||||||
|
**Branch**: `013-hp-status-indicators` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/013-hp-status-indicators/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add visual HP status indicators to combatant rows. A pure domain function derives the HP status ("healthy", "bloodied", "unconscious") from currentHp and maxHp. The UI applies color treatments: amber for bloodied HP text, red + muted row for unconscious/dead combatants. No new stored state -- status is computed on render.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons)
|
||||||
|
**Storage**: N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged)
|
||||||
|
**Testing**: Vitest (unit tests for domain function, existing layer boundary test)
|
||||||
|
**Target Platform**: Modern browsers (Vite dev server, production build)
|
||||||
|
**Project Type**: Web application (monorepo: domain + application + web adapter)
|
||||||
|
**Performance Goals**: Synchronous render -- indicator updates in the same React render cycle as HP changes
|
||||||
|
**Constraints**: Domain layer must remain pure (no UI imports); adapter layer handles all visual presentation
|
||||||
|
**Scale/Scope**: Single new domain function + UI changes to one component (combatant-row)
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | `deriveHpStatus` is a pure function: same inputs always produce the same output. No I/O, randomness, or clocks. |
|
||||||
|
| II. Layered Architecture | PASS | Domain exports the pure function; UI adapter calls it in the component. No reverse dependency. |
|
||||||
|
| III. Agent Boundary | N/A | No agent involvement in this feature. |
|
||||||
|
| IV. Clarification-First | PASS | No ambiguity -- thresholds are defined in spec (bloodied = 0 < currentHp < maxHp/2, unconscious = currentHp <= 0). |
|
||||||
|
| V. Escalation Gates | PASS | All behavior is within spec scope. |
|
||||||
|
| VI. MVP Baseline Language | PASS | Assumptions section uses "MVP baseline does not include" language. |
|
||||||
|
| VII. No Gameplay Rules in Constitution | PASS | Bloodied/unconscious thresholds are in feature spec, not constitution. |
|
||||||
|
| Development Workflow | PASS | `pnpm check` must pass before commit. Domain testable via pure-function assertions. |
|
||||||
|
|
||||||
|
No violations. Complexity Tracking section not needed.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/013-hp-status-indicators/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/domain/src/
|
||||||
|
├── hp-status.ts # NEW: deriveHpStatus pure function + HpStatus type
|
||||||
|
├── __tests__/hp-status.test.ts # NEW: unit tests for deriveHpStatus
|
||||||
|
├── index.ts # MODIFIED: export new function and type
|
||||||
|
|
||||||
|
apps/web/src/components/
|
||||||
|
├── combatant-row.tsx # MODIFIED: apply status-based styling
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows existing monorepo layout. New domain function in its own module (consistent with adjust-hp.ts, set-hp.ts pattern). UI changes confined to the existing combatant-row component. No new application-layer code needed since status is derived at render time, not via a use case.
|
||||||
46
specs/013-hp-status-indicators/quickstart.md
Normal file
46
specs/013-hp-status-indicators/quickstart.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Quickstart: HP Status Indicators
|
||||||
|
|
||||||
|
**Feature**: 013-hp-status-indicators
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a pure domain function `deriveHpStatus` and apply visual status indicators (color treatments) to combatant rows in the encounter tracker.
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `packages/domain/src/hp-status.ts` | CREATE | Pure function `deriveHpStatus` + `HpStatus` type |
|
||||||
|
| `packages/domain/src/__tests__/hp-status.test.ts` | CREATE | Unit tests for all status thresholds and edge cases |
|
||||||
|
| `packages/domain/src/index.ts` | MODIFY | Export `deriveHpStatus` and `HpStatus` |
|
||||||
|
| `apps/web/src/components/combatant-row.tsx` | MODIFY | Call `deriveHpStatus` and apply conditional CSS classes |
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Domain function** (`packages/domain/src/hp-status.ts`):
|
||||||
|
- Define `type HpStatus = "healthy" | "bloodied" | "unconscious"`
|
||||||
|
- Implement `deriveHpStatus(currentHp: number | undefined, maxHp: number | undefined): HpStatus | undefined`
|
||||||
|
- Return `undefined` if either input is `undefined`
|
||||||
|
- Check `currentHp <= 0` first (unconscious), then `currentHp < maxHp / 2` (bloodied), else healthy
|
||||||
|
|
||||||
|
2. **Tests** (`packages/domain/src/__tests__/hp-status.test.ts`):
|
||||||
|
- Healthy: currentHp >= maxHp/2
|
||||||
|
- Bloodied: 0 < currentHp < maxHp/2
|
||||||
|
- Unconscious: currentHp <= 0 (including negative)
|
||||||
|
- Undefined inputs: returns undefined
|
||||||
|
- Edge cases: maxHp=1, maxHp=2, odd maxHp, currentHp > maxHp
|
||||||
|
|
||||||
|
3. **Export** (`packages/domain/src/index.ts`):
|
||||||
|
- Add `export { type HpStatus, deriveHpStatus } from "./hp-status.js"`
|
||||||
|
|
||||||
|
4. **UI styling** (`apps/web/src/components/combatant-row.tsx`):
|
||||||
|
- Import `deriveHpStatus` from `@initiative/domain`
|
||||||
|
- Call it with combatant's `currentHp` and `maxHp`
|
||||||
|
- Apply to HP text: `text-amber-400` for bloodied, `text-red-400` for unconscious
|
||||||
|
- Apply to row: `opacity-50` for unconscious combatants
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check # Must pass (knip + format + lint + typecheck + test)
|
||||||
|
```
|
||||||
60
specs/013-hp-status-indicators/research.md
Normal file
60
specs/013-hp-status-indicators/research.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Research: HP Status Indicators
|
||||||
|
|
||||||
|
**Feature**: 013-hp-status-indicators
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
## R1: HP Status Derivation Approach
|
||||||
|
|
||||||
|
**Decision**: Implement as a pure domain function `deriveHpStatus(currentHp, maxHp)` returning a discriminated union type `HpStatus = "healthy" | "bloodied" | "unconscious"`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The status is a derived value, not stored state. Computing it on demand avoids synchronization issues and keeps the data model unchanged.
|
||||||
|
- A standalone pure function in the domain layer is consistent with the project's architecture (e.g., `adjustHp`, `setHp` are each in their own module).
|
||||||
|
- Returning a simple string union (not an object or class) is the lightest representation and easy to pattern-match in the UI.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Storing status as a field on `Combatant`: Rejected -- introduces redundant state that must stay in sync with HP changes. Violates principle of derived data.
|
||||||
|
- Computing in the UI layer only: Rejected -- violates constitution principle I (domain logic must be pure domain functions) and makes the logic untestable without a UI.
|
||||||
|
|
||||||
|
## R2: Bloodied Threshold
|
||||||
|
|
||||||
|
**Decision**: Bloodied when `0 < currentHp < maxHp / 2` (strict less-than for both bounds, floating-point division).
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Follows D&D 4e/5e convention where "bloodied" means below half HP.
|
||||||
|
- Using strict less-than means at exactly half HP the combatant is still "healthy" -- this is the most common TTRPG interpretation.
|
||||||
|
- Floating-point division handles odd maxHp values correctly (e.g., maxHp=21: bloodied at 10 since 10 < 10.5).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- `currentHp <= maxHp / 2`: Would make combatants at exactly half HP appear bloodied. Less standard.
|
||||||
|
- `Math.floor(maxHp / 2)`: Would make maxHp=21 bloodied at 10 (same result) but maxHp=20 bloodied at 10 (also same). No practical difference but adds unnecessary complexity.
|
||||||
|
|
||||||
|
## R3: Visual Treatment Approach
|
||||||
|
|
||||||
|
**Decision**: Color the HP text and optionally mute the entire row for unconscious combatants:
|
||||||
|
- **Healthy**: Default foreground color (no change)
|
||||||
|
- **Bloodied**: Amber/orange HP text (`text-amber-400`) -- warm danger signal
|
||||||
|
- **Unconscious**: Red HP text (`text-red-400`) + reduced row opacity (`opacity-50`) -- clearly out of action
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Coloring the HP number is the most targeted indicator -- it draws the eye to the relevant data point.
|
||||||
|
- Row opacity reduction for unconscious combatants provides a strong "out of action" signal without removing the row (GM may still need to reference it).
|
||||||
|
- Amber and red are universally understood warning/danger colors and already used in the project (red for damage mode in quick-hp-input, `--color-destructive: #ef4444`).
|
||||||
|
- No new icons needed -- color alone is sufficient for two states and avoids visual clutter.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Full row background color: Too visually heavy, would conflict with the active-turn indicator (blue left border + bg-accent/10).
|
||||||
|
- Strikethrough on name for unconscious: Considered but opacity reduction is more modern and less ambiguous (strikethrough could imply "removed").
|
||||||
|
- Badge/pill indicator: Adds UI elements and visual weight. Color on existing text is simpler.
|
||||||
|
|
||||||
|
## R4: Function Signature & Return for Undefined HP
|
||||||
|
|
||||||
|
**Decision**: Return `undefined` when maxHp is not set (status is not applicable). Function signature: `deriveHpStatus(currentHp: number | undefined, maxHp: number | undefined): HpStatus | undefined`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- When maxHp is undefined, HP tracking is disabled for that combatant. No status can be derived.
|
||||||
|
- Returning `undefined` (not a fourth status value) keeps the type clean and matches the "no HP tracking" semantic.
|
||||||
|
- The UI simply skips styling when the result is `undefined`.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate `"none"` status: Adds a value that is semantically different from the three health states. `undefined` is more idiomatic TypeScript for "not applicable".
|
||||||
98
specs/013-hp-status-indicators/spec.md
Normal file
98
specs/013-hp-status-indicators/spec.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Feature Specification: HP Status Indicators
|
||||||
|
|
||||||
|
**Feature Branch**: `013-hp-status-indicators`
|
||||||
|
**Created**: 2026-03-05
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "When a combatant's health is lower than half, the creature is considered bloodied, which makes the UI show an indicator. If a combatant is at <=0 HP the creature is unconscious/dead, which should also be shown. Visual treatment at implementer's discretion to fit the existing modern UI style."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Bloodied Indicator (Priority: P1)
|
||||||
|
|
||||||
|
As a game master running an encounter, I want to see at a glance which combatants are bloodied (below half their maximum HP) so I can narrate the battle's intensity and make tactical decisions without mental math.
|
||||||
|
|
||||||
|
**Why this priority**: The bloodied condition is a core D&D/TTRPG concept that GMs check frequently during combat. Providing a visual indicator eliminates the need to compare current HP vs max HP for every combatant each round.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by setting a combatant's max HP and reducing current HP below half, then verifying the visual indicator appears. Delivers immediate at-a-glance awareness.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a combatant with max HP set to 20 and current HP at 10, **When** the combatant row is displayed, **Then** the HP area shows a bloodied visual indicator (e.g., amber/orange color treatment on the HP value)
|
||||||
|
2. **Given** a combatant with max HP set to 20 and current HP at 9, **When** the combatant row is displayed, **Then** the bloodied indicator is visible
|
||||||
|
3. **Given** a combatant with max HP set to 20 and current HP at 11, **When** the combatant row is displayed, **Then** no bloodied indicator is shown (normal appearance)
|
||||||
|
4. **Given** a combatant with max HP set to 21 (odd number) and current HP at 10, **When** the combatant row is displayed, **Then** the bloodied indicator is shown (10 < 21/2 = 10.5)
|
||||||
|
5. **Given** a combatant with no max HP set, **When** the combatant row is displayed, **Then** no status indicator is shown regardless of current HP value
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Unconscious/Dead Indicator (Priority: P1)
|
||||||
|
|
||||||
|
As a game master, I want to see which combatants have reached 0 HP or below so I can immediately identify who is down without scanning HP numbers.
|
||||||
|
|
||||||
|
**Why this priority**: Equally critical to bloodied -- knowing who is unconscious or dead is essential for combat flow and takes priority over other status information.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by reducing a combatant's current HP to 0 or below and verifying the visual indicator appears.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a combatant with max HP set to 20 and current HP at 0, **When** the combatant row is displayed, **Then** the HP area shows an unconscious/dead visual indicator (e.g., red color treatment and/or a skull/death icon)
|
||||||
|
2. **Given** a combatant with max HP set to 20 and current HP at -5, **When** the combatant row is displayed, **Then** the unconscious/dead indicator is shown (negative HP still counts as down)
|
||||||
|
3. **Given** a combatant with max HP set to 20 and current HP at 1, **When** the combatant row is displayed, **Then** the unconscious/dead indicator is NOT shown (1 HP is bloodied, not dead)
|
||||||
|
4. **Given** a combatant at 0 HP whose HP is then healed above 0, **When** the HP changes, **Then** the unconscious/dead indicator is removed and replaced with the appropriate status (bloodied or healthy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Status Transitions (Priority: P2)
|
||||||
|
|
||||||
|
As a game master adjusting HP during combat, I want the visual indicators to update in real time as I apply damage or healing so I always have an accurate picture of the battlefield.
|
||||||
|
|
||||||
|
**Why this priority**: Indicator transitions are a natural consequence of the first two stories but need explicit attention to ensure smooth, responsive visual feedback.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by using the quick HP input to deal damage across thresholds and observing indicator changes.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a combatant at full HP (20/20), **When** I deal 11 damage (reducing to 9/20), **Then** the indicator transitions from healthy to bloodied
|
||||||
|
2. **Given** a bloodied combatant (5/20), **When** I deal 5 damage (reducing to 0/20), **Then** the indicator transitions from bloodied to unconscious/dead
|
||||||
|
3. **Given** an unconscious combatant (0/20), **When** I heal 15 HP (restoring to 15/20), **Then** the indicator transitions from unconscious/dead to healthy (skipping bloodied since 15 > 10)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when max HP is 1? At 1/1 HP the combatant is healthy; at 0/1 HP they are unconscious/dead. There is no bloodied state since 0 < 0.5 rounds to unconscious/dead.
|
||||||
|
- What happens when max HP is 2? At 1/2 HP the combatant is healthy (1 is not less than 2/2 = 1); at 0/2 they are unconscious/dead. Bloodied only applies when currentHp < maxHp / 2 (strict less-than with exact division).
|
||||||
|
- What happens when current HP exceeds max HP (e.g., temporary HP or manual override)? The combatant shows healthy status -- no special indicator for over-max.
|
||||||
|
- What happens when max HP is set but current HP has not been modified? Current HP equals max HP by default, so the combatant shows healthy status.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST derive a combatant's HP status from their current HP and max HP values: "healthy" (currentHp >= maxHp / 2), "bloodied" (0 < currentHp < maxHp / 2), or "unconscious" (currentHp <= 0)
|
||||||
|
- **FR-002**: System MUST display a visual indicator on the combatant row when a combatant is bloodied -- the HP value text MUST use a warm/amber color to signal danger
|
||||||
|
- **FR-003**: System MUST display a visual indicator on the combatant row when a combatant is unconscious/dead -- the HP value text MUST use a red/destructive color and the row MUST appear visually muted (reduced opacity or strikethrough name) to signal the combatant is out of action
|
||||||
|
- **FR-004**: System MUST NOT show any status indicator when max HP is not set for a combatant
|
||||||
|
- **FR-005**: System MUST update the visual indicator immediately when current HP changes (no delay or animation lag)
|
||||||
|
- **FR-006**: The HP status derivation MUST be a pure domain-level computation with no UI dependencies
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **HP Status**: A derived value (not stored) computed from a combatant's currentHp and maxHp. Possible values: "healthy", "bloodied", "unconscious". Only meaningful when maxHp is defined.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can identify bloodied combatants at a glance without reading HP numbers -- 100% of combatants below half HP display a distinct visual treatment
|
||||||
|
- **SC-002**: Users can identify unconscious/dead combatants at a glance -- 100% of combatants at 0 HP or below display a distinct visual treatment that differs from the bloodied indicator
|
||||||
|
- **SC-003**: Visual indicators update within the same interaction frame as HP changes -- no perceptible delay between HP adjustment and indicator update
|
||||||
|
- **SC-004**: Status indicators are visually consistent with the existing UI design system (colors, spacing, typography match the established style)
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- "Bloodied" uses the D&D 4e/5e convention: strictly below half max HP (not at exactly half)
|
||||||
|
- The unconscious/dead label is intentionally combined into one state -- the system does not distinguish between unconscious and dead (this is a GM decision, not a system decision)
|
||||||
|
- No sound effects or animation beyond color/opacity changes for MVP baseline
|
||||||
|
- MVP baseline does not include a tooltip or label explaining what "bloodied" means
|
||||||
|
- The status is purely visual/derived -- it does not affect game mechanics or stored data
|
||||||
122
specs/013-hp-status-indicators/tasks.md
Normal file
122
specs/013-hp-status-indicators/tasks.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Tasks: HP Status Indicators
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/013-hp-status-indicators/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Included -- domain function requires unit tests per project convention.
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story. US1 and US2 share the same domain function (foundational), then diverge at the UI layer.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundational (Domain Function + Tests)
|
||||||
|
|
||||||
|
**Purpose**: Create the pure domain function that all user stories depend on
|
||||||
|
|
||||||
|
**CRITICAL**: No UI work can begin until this phase is complete
|
||||||
|
|
||||||
|
- [x] T001 Create `HpStatus` type and `deriveHpStatus` pure function in `packages/domain/src/hp-status.ts` -- returns `"healthy" | "bloodied" | "unconscious" | undefined` based on `currentHp` and `maxHp` inputs. Check `currentHp <= 0` first (unconscious), then `currentHp < maxHp / 2` (bloodied), else healthy. Return `undefined` when either input is `undefined`.
|
||||||
|
- [x] T002 Write unit tests for `deriveHpStatus` in `packages/domain/src/__tests__/hp-status.test.ts` covering: healthy (currentHp >= maxHp/2), bloodied (0 < currentHp < maxHp/2), unconscious (currentHp <= 0), unconscious with negative HP, undefined when maxHp undefined, undefined when currentHp undefined, undefined when both undefined, edge case maxHp=1 (no bloodied state possible), edge case maxHp=2 (1/2 is healthy not bloodied), odd maxHp=21 (10 is bloodied since 10 < 10.5), currentHp exceeding maxHp (healthy).
|
||||||
|
- [x] T003 Export `deriveHpStatus` and `HpStatus` type from `packages/domain/src/index.ts` -- add `export { type HpStatus, deriveHpStatus } from "./hp-status.js";`
|
||||||
|
|
||||||
|
**Checkpoint**: `pnpm check` passes. Domain function is tested and exported.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 - Bloodied Indicator (Priority: P1) + User Story 2 - Unconscious/Dead Indicator (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Apply visual status indicators to the combatant row -- amber HP text for bloodied, red HP text + muted row for unconscious/dead
|
||||||
|
|
||||||
|
**Independent Test**: Set a combatant's max HP, reduce current HP below half -- verify amber text. Reduce to 0 -- verify red text and muted row. Heal above half -- verify normal appearance restored.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T004 [US1] [US2] Modify `apps/web/src/components/combatant-row.tsx` to import `deriveHpStatus` from `@initiative/domain` and call it with the combatant's `currentHp` and `maxHp` at the top of the `CombatantRow` component. Store result in a `status` variable.
|
||||||
|
- [x] T005 [US1] [US2] In `apps/web/src/components/combatant-row.tsx`, apply conditional CSS classes based on `status`: (1) On the `CurrentHpInput` wrapper/value -- add `text-amber-400` when bloodied, `text-red-400` when unconscious. (2) On the row's outer `<div>` -- add `opacity-50` when unconscious. Ensure the active-turn border styling still takes precedence for active combatants. Use the existing `cn()` utility for conditional class merging.
|
||||||
|
|
||||||
|
**Checkpoint**: `pnpm check` passes. Bloodied and unconscious indicators are visible in the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 3 - Status Transitions (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Ensure visual indicators update in real time as HP changes through quick HP input, direct entry, and +/- controls
|
||||||
|
|
||||||
|
**Independent Test**: Use the quick HP input to deal damage across thresholds (healthy -> bloodied -> unconscious) and heal back. Verify indicator changes instantly with no flicker or delay.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T006 [US3] Verify in `apps/web/src/components/combatant-row.tsx` that `deriveHpStatus` is called with the current combatant props (not stale state) so status updates in the same render cycle as HP changes. No additional code should be needed if T004-T005 are implemented correctly -- this task is a manual verification and visual QA pass.
|
||||||
|
|
||||||
|
**Checkpoint**: All transitions (healthy->bloodied->unconscious->healthy) work smoothly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [x] T007 Run `pnpm check` to validate all automated checks pass (knip, format, lint, typecheck, test)
|
||||||
|
- [x] T008 Verify layer boundary test still passes (domain must not import from React/UI) in `packages/domain/src/__tests__/layer-boundaries.test.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Foundational (Phase 1)**: No dependencies -- can start immediately
|
||||||
|
- **US1+US2 (Phase 2)**: Depends on Phase 1 completion (needs domain function)
|
||||||
|
- **US3 (Phase 3)**: Depends on Phase 2 completion (needs UI indicators in place)
|
||||||
|
- **Polish (Phase 4)**: Depends on all phases complete
|
||||||
|
|
||||||
|
### Within Phases
|
||||||
|
|
||||||
|
- T001 before T002 (need function to test it)
|
||||||
|
- T002 before T003 (tests pass before exporting)
|
||||||
|
- T004 before T005 (import before styling)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T001 and T002 can be done together if writing tests alongside implementation (TDD)
|
||||||
|
- US1 and US2 are implemented together in Phase 2 since they modify the same file and component
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: Phase 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# T001 and T002 can be written together (TDD style):
|
||||||
|
Task: "Create deriveHpStatus in packages/domain/src/hp-status.ts"
|
||||||
|
Task: "Write tests in packages/domain/src/__tests__/hp-status.test.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (Phase 1 + Phase 2)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Domain function + tests + export
|
||||||
|
2. Complete Phase 2: UI styling for both bloodied and unconscious
|
||||||
|
3. **STOP and VALIDATE**: Manually test all scenarios from spec
|
||||||
|
4. Run `pnpm check`
|
||||||
|
|
||||||
|
### Full Delivery
|
||||||
|
|
||||||
|
1. Phase 1: Domain function (T001-T003)
|
||||||
|
2. Phase 2: UI indicators (T004-T005)
|
||||||
|
3. Phase 3: Transition verification (T006)
|
||||||
|
4. Phase 4: Final validation (T007-T008)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- US1 (bloodied) and US2 (unconscious) are combined into Phase 2 because they modify the same component and the domain function handles both statuses. Implementing them separately would require touching the same lines twice.
|
||||||
|
- T006 is a verification task, not a code change -- if the React component re-renders on prop changes (which it does by default), transitions work automatically.
|
||||||
|
- Total: 8 tasks across 4 phases. Minimal scope -- no new application layer, no storage changes.
|
||||||
Reference in New Issue
Block a user