Implement the 009-combatant-hp feature that adds optional max HP and current HP tracking per combatant with +/- controls, direct entry, and persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-05 17:18:03 +01:00
parent a9c280a6d6
commit 8185fde0e8
21 changed files with 1367 additions and 2 deletions

View File

@@ -58,6 +58,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19 (007-add-knip) - TypeScript 5.x (strict mode, verbatimModuleSyntax) + Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19 (007-add-knip)
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, existing domain/application packages (008-persist-encounter) - TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, existing domain/application packages (008-persist-encounter)
- Browser localStorage (adapter layer only) (008-persist-encounter) - Browser localStorage (adapter layer only) (008-persist-encounter)
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, Vites (009-combatant-hp)
## 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

View File

@@ -16,6 +16,10 @@ function formatEvent(e: ReturnType<typeof useEncounter>["events"][number]) {
return `Renamed combatant: ${e.oldName}${e.newName}`; return `Renamed combatant: ${e.oldName}${e.newName}`;
case "InitiativeSet": case "InitiativeSet":
return `Initiative: ${e.combatantId} ${e.previousValue ?? "unset"}${e.newValue ?? "unset"}`; return `Initiative: ${e.combatantId} ${e.previousValue ?? "unset"}${e.newValue ?? "unset"}`;
case "MaxHpSet":
return `Max HP: ${e.combatantId} ${e.previousMaxHp ?? "unset"}${e.newMaxHp ?? "unset"}`;
case "CurrentHpAdjusted":
return `HP: ${e.combatantId} ${e.previousHp}${e.newHp} (${e.delta > 0 ? "+" : ""}${e.delta})`;
} }
} }
@@ -71,6 +75,97 @@ function EditableName({
); );
} }
function MaxHpInput({
maxHp,
onCommit,
}: {
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const prev = useRef(maxHp);
// Sync draft when domain value changes externally (e.g. from another source)
if (maxHp !== prev.current) {
prev.current = maxHp;
setDraft(maxHp?.toString() ?? "");
}
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
return;
}
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 1) {
onCommit(n);
} else {
// Revert invalid input
setDraft(maxHp?.toString() ?? "");
}
}, [draft, maxHp, onCommit]);
return (
<input
type="number"
min={1}
value={draft}
placeholder="Max HP"
style={{ width: "5em" }}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
);
}
function CurrentHpInput({
currentHp,
maxHp,
onCommit,
}: {
currentHp: number | undefined;
maxHp: number | undefined;
onCommit: (value: number) => void;
}) {
const [draft, setDraft] = useState(currentHp?.toString() ?? "");
const prev = useRef(currentHp);
if (currentHp !== prev.current) {
prev.current = currentHp;
setDraft(currentHp?.toString() ?? "");
}
const commit = useCallback(() => {
if (draft === "" || currentHp === undefined) return;
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n)) {
onCommit(n);
} else {
setDraft(currentHp.toString());
}
}, [draft, currentHp, onCommit]);
return (
<input
type="number"
min={0}
max={maxHp}
value={draft}
placeholder="HP"
disabled={maxHp === undefined}
style={{ width: "4em" }}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
);
}
export function App() { export function App() {
const { const {
encounter, encounter,
@@ -80,6 +175,8 @@ export function App() {
removeCombatant, removeCombatant,
editCombatant, editCombatant,
setInitiative, setInitiative,
setHp,
adjustHp,
} = useEncounter(); } = useEncounter();
const activeCombatant = encounter.combatants[encounter.activeIndex]; const activeCombatant = encounter.combatants[encounter.activeIndex];
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
@@ -127,6 +224,27 @@ export function App() {
} }
}} }}
/>{" "} />{" "}
{c.maxHp !== undefined && c.currentHp !== undefined && (
<button type="button" onClick={() => adjustHp(c.id, -1)}>
-
</button>
)}
<CurrentHpInput
currentHp={c.currentHp}
maxHp={c.maxHp}
onCommit={(value) => {
if (c.currentHp === undefined) return;
const delta = value - c.currentHp;
if (delta !== 0) adjustHp(c.id, delta);
}}
/>
{c.maxHp !== undefined && <span>/</span>}
{c.maxHp !== undefined && c.currentHp !== undefined && (
<button type="button" onClick={() => adjustHp(c.id, 1)}>
+
</button>
)}{" "}
<MaxHpInput maxHp={c.maxHp} onCommit={(v) => setHp(c.id, v)} />{" "}
<button type="button" onClick={() => removeCombatant(c.id)}> <button type="button" onClick={() => removeCombatant(c.id)}>
Remove Remove
</button> </button>

View File

@@ -1,9 +1,11 @@
import type { EncounterStore } from "@initiative/application"; import type { EncounterStore } from "@initiative/application";
import { import {
addCombatantUseCase, addCombatantUseCase,
adjustHpUseCase,
advanceTurnUseCase, advanceTurnUseCase,
editCombatantUseCase, editCombatantUseCase,
removeCombatantUseCase, removeCombatantUseCase,
setHpUseCase,
setInitiativeUseCase, setInitiativeUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { CombatantId, DomainEvent, Encounter } from "@initiative/domain"; import type { CombatantId, DomainEvent, Encounter } from "@initiative/domain";
@@ -132,6 +134,32 @@ export function useEncounter() {
[makeStore], [makeStore],
); );
const setHp = useCallback(
(id: CombatantId, maxHp: number | undefined) => {
const result = setHpUseCase(makeStore(), id, maxHp);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
const adjustHp = useCallback(
(id: CombatantId, delta: number) => {
const result = adjustHpUseCase(makeStore(), id, delta);
if (isDomainError(result)) {
return;
}
setEvents((prev) => [...prev, ...result]);
},
[makeStore],
);
return { return {
encounter, encounter,
events, events,
@@ -140,5 +168,7 @@ export function useEncounter() {
removeCombatant, removeCombatant,
editCombatant, editCombatant,
setInitiative, setInitiative,
setHp,
adjustHp,
} as const; } as const;
} }

View File

@@ -41,12 +41,30 @@ export function loadEncounter(): Encounter | null {
const rehydrated = combatants.map((c) => { const rehydrated = combatants.map((c) => {
const entry = c as Record<string, unknown>; const entry = c as Record<string, unknown>;
return { const base = {
id: combatantId(entry.id as string), id: combatantId(entry.id as string),
name: entry.name as string, name: entry.name as string,
initiative: initiative:
typeof entry.initiative === "number" ? entry.initiative : undefined, typeof entry.initiative === "number" ? entry.initiative : undefined,
}; };
// Validate and attach HP fields if valid
const maxHp = entry.maxHp;
const currentHp = entry.currentHp;
if (typeof maxHp === "number" && Number.isInteger(maxHp) && maxHp >= 1) {
const validCurrentHp =
typeof currentHp === "number" &&
Number.isInteger(currentHp) &&
currentHp >= 0 &&
currentHp <= maxHp;
return {
...base,
maxHp,
currentHp: validCurrentHp ? currentHp : maxHp,
};
}
return base;
}); });
const result = createEncounter( const result = createEncounter(

View File

@@ -0,0 +1,24 @@
import {
adjustHp,
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function adjustHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
delta: number,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = adjustHp(encounter, combatantId, delta);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -1,6 +1,8 @@
export { addCombatantUseCase } from "./add-combatant-use-case.js"; export { addCombatantUseCase } from "./add-combatant-use-case.js";
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
export { advanceTurnUseCase } from "./advance-turn-use-case.js"; export { advanceTurnUseCase } from "./advance-turn-use-case.js";
export { editCombatantUseCase } from "./edit-combatant-use-case.js"; export { editCombatantUseCase } from "./edit-combatant-use-case.js";
export type { EncounterStore } from "./ports.js"; export type { EncounterStore } from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js"; export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js";
export { setInitiativeUseCase } from "./set-initiative-use-case.js"; export { setInitiativeUseCase } from "./set-initiative-use-case.js";

View File

@@ -0,0 +1,24 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
setHp,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function setHpUseCase(
store: EncounterStore,
combatantId: CombatantId,
maxHp: number | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = setHp(encounter, combatantId, maxHp);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { adjustHp } from "../adjust-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(
name: string,
opts?: { maxHp: number; currentHp: number },
): Combatant {
return {
id: combatantId(name),
name,
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}),
};
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function successResult(encounter: Encounter, id: string, delta: number) {
const result = adjustHp(encounter, combatantId(id), delta);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("adjustHp", () => {
describe("acceptance scenarios", () => {
it("+1 increases currentHp by 1", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", 1);
expect(encounter.combatants[0].currentHp).toBe(16);
});
it("-1 decreases currentHp by 1", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", -1);
expect(encounter.combatants[0].currentHp).toBe(14);
});
it("clamps at 0 — cannot go below zero", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 3 })]);
const { encounter } = successResult(e, "A", -10);
expect(encounter.combatants[0].currentHp).toBe(0);
});
it("clamps at maxHp — cannot exceed max", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].currentHp).toBe(20);
});
});
describe("invariants", () => {
it("is pure — same input produces same output", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const r1 = adjustHp(e, combatantId("A"), -5);
const r2 = adjustHp(e, combatantId("A"), -5);
expect(r1).toEqual(r2);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const original = JSON.parse(JSON.stringify(e));
adjustHp(e, combatantId("A"), -3);
expect(e).toEqual(original);
});
it("emits CurrentHpAdjusted event with delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { events } = successResult(e, "A", -5);
expect(events).toEqual([
{
type: "CurrentHpAdjusted",
combatantId: combatantId("A"),
previousHp: 15,
newHp: 10,
delta: -5,
},
]);
});
it("preserves activeIndex and roundNumber", () => {
const e = {
combatants: [
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
makeCombatant("B"),
],
activeIndex: 1,
roundNumber: 5,
};
const { encounter } = successResult(e, "A", -3);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(5);
});
});
describe("error cases", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("Z"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("returns error when combatant has no HP tracking", () => {
const e = enc([makeCombatant("A")]);
const result = adjustHp(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("no-hp-tracking");
}
});
it("returns error for zero delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("zero-delta");
}
});
it("returns error for non-integer delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 1.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-delta");
}
});
});
describe("edge cases", () => {
it("large negative delta beyond currentHp clamps to 0", () => {
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
const { encounter } = successResult(e, "A", -9999);
expect(encounter.combatants[0].currentHp).toBe(0);
});
it("large positive delta beyond maxHp clamps to maxHp", () => {
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
const { encounter } = successResult(e, "A", 9999);
expect(encounter.combatants[0].currentHp).toBe(100);
});
it("does not affect other combatants", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
makeCombatant("B", { maxHp: 30, currentHp: 25 }),
]);
const { encounter } = successResult(e, "A", -5);
expect(encounter.combatants[1].currentHp).toBe(25);
});
it("adjusting from 0 upward works", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 0 })]);
const { encounter } = successResult(e, "A", 5);
expect(encounter.combatants[0].currentHp).toBe(5);
});
});
});

View File

@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import { setHp } from "../set-hp.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(
name: string,
opts?: { maxHp?: number; currentHp?: number },
): Combatant {
return {
id: combatantId(name),
name,
...(opts?.maxHp !== undefined
? { maxHp: opts.maxHp, currentHp: opts.currentHp ?? opts.maxHp }
: {}),
};
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function successResult(
encounter: Encounter,
id: string,
maxHp: number | undefined,
) {
const result = setHp(encounter, combatantId(id), maxHp);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setHp", () => {
describe("acceptance scenarios", () => {
it("sets maxHp on a combatant with no HP — currentHp defaults to maxHp", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", 20);
expect(encounter.combatants[0].maxHp).toBe(20);
expect(encounter.combatants[0].currentHp).toBe(20);
});
it("increases maxHp while at full health — currentHp stays synced", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 20 })]);
const { encounter } = successResult(e, "A", 30);
expect(encounter.combatants[0].maxHp).toBe(30);
expect(encounter.combatants[0].currentHp).toBe(30);
});
it("increases maxHp while not at full health — currentHp unchanged", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 12 })]);
const { encounter } = successResult(e, "A", 30);
expect(encounter.combatants[0].maxHp).toBe(30);
expect(encounter.combatants[0].currentHp).toBe(12);
});
it("reduces maxHp below currentHp — clamps currentHp", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].maxHp).toBe(10);
expect(encounter.combatants[0].currentHp).toBe(10);
});
it("clears maxHp — both maxHp and currentHp become undefined", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", undefined);
expect(encounter.combatants[0].maxHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).toBeUndefined();
});
});
describe("invariants", () => {
it("is pure — same input produces same output", () => {
const e = enc([makeCombatant("A")]);
const r1 = setHp(e, combatantId("A"), 10);
const r2 = setHp(e, combatantId("A"), 10);
expect(r1).toEqual(r2);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A")]);
const original = JSON.parse(JSON.stringify(e));
setHp(e, combatantId("A"), 10);
expect(e).toEqual(original);
});
it("emits MaxHpSet event with correct shape", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { events } = successResult(e, "A", 10);
expect(events).toEqual([
{
type: "MaxHpSet",
combatantId: combatantId("A"),
previousMaxHp: 20,
newMaxHp: 10,
previousCurrentHp: 18,
newCurrentHp: 10,
},
]);
});
it("preserves activeIndex and roundNumber", () => {
const e = {
combatants: [makeCombatant("A"), makeCombatant("B")],
activeIndex: 1,
roundNumber: 3,
};
const { encounter } = successResult(e, "A", 10);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(3);
});
});
describe("error cases", () => {
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("Z"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("rejects maxHp of 0", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 0);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects negative maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), -5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
it("rejects non-integer maxHp", () => {
const e = enc([makeCombatant("A")]);
const result = setHp(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-max-hp");
}
});
});
describe("edge cases", () => {
it("maxHp=1 is valid", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", 1);
expect(encounter.combatants[0].maxHp).toBe(1);
expect(encounter.combatants[0].currentHp).toBe(1);
});
it("setting same maxHp does not change currentHp", () => {
const e = enc([makeCombatant("A", { maxHp: 10, currentHp: 7 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].currentHp).toBe(7);
});
it("clear then re-set loses currentHp — UI must commit on blur", () => {
// Simulates: user clears max HP field then retypes a new value
// If the domain sees clear→set as two calls, currentHp resets.
// This is why the UI commits max HP only on blur, not per-keystroke.
const e = enc([makeCombatant("A", { maxHp: 22, currentHp: 12 })]);
const cleared = successResult(e, "A", undefined);
expect(cleared.encounter.combatants[0].currentHp).toBeUndefined();
const retyped = successResult(cleared.encounter, "A", 122);
// currentHp resets to 122 (first-set path) — original 12 is lost
expect(retyped.encounter.combatants[0].currentHp).toBe(122);
});
it("single committed change preserves currentHp", () => {
// The blur-commit approach: domain only sees 22→122, not 22→undefined→122
const e = enc([makeCombatant("A", { maxHp: 22, currentHp: 12 })]);
const { encounter } = successResult(e, "A", 122);
expect(encounter.combatants[0].maxHp).toBe(122);
expect(encounter.combatants[0].currentHp).toBe(12);
});
it("does not affect other combatants", () => {
const e = enc([
makeCombatant("A"),
makeCombatant("B", { maxHp: 30, currentHp: 25 }),
]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[1].maxHp).toBe(30);
expect(encounter.combatants[1].currentHp).toBe(25);
});
});
});

View File

@@ -0,0 +1,77 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface AdjustHpSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that adjusts a combatant's current HP by a delta.
*
* The result is clamped to [0, maxHp]. Requires the combatant to have
* HP tracking enabled (maxHp must be set).
*/
export function adjustHp(
encounter: Encounter,
combatantId: CombatantId,
delta: number,
): AdjustHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
if (target.maxHp === undefined || target.currentHp === undefined) {
return {
kind: "domain-error",
code: "no-hp-tracking",
message: `Combatant "${combatantId}" does not have HP tracking enabled`,
};
}
if (delta === 0) {
return {
kind: "domain-error",
code: "zero-delta",
message: "Delta must not be zero",
};
}
if (!Number.isInteger(delta)) {
return {
kind: "domain-error",
code: "invalid-delta",
message: `Delta must be an integer, got ${delta}`,
};
}
const previousHp = target.currentHp;
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta));
return {
encounter: {
combatants: encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, currentHp: newHp } : c,
),
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "CurrentHpAdjusted",
combatantId,
previousHp,
newHp,
delta,
},
],
};
}

View File

@@ -39,10 +39,29 @@ export interface InitiativeSet {
readonly newValue: number | undefined; readonly newValue: number | undefined;
} }
export interface MaxHpSet {
readonly type: "MaxHpSet";
readonly combatantId: CombatantId;
readonly previousMaxHp: number | undefined;
readonly newMaxHp: number | undefined;
readonly previousCurrentHp: number | undefined;
readonly newCurrentHp: number | undefined;
}
export interface CurrentHpAdjusted {
readonly type: "CurrentHpAdjusted";
readonly combatantId: CombatantId;
readonly previousHp: number;
readonly newHp: number;
readonly delta: number;
}
export type DomainEvent = export type DomainEvent =
| TurnAdvanced | TurnAdvanced
| RoundAdvanced | RoundAdvanced
| CombatantAdded | CombatantAdded
| CombatantRemoved | CombatantRemoved
| CombatantUpdated | CombatantUpdated
| InitiativeSet; | InitiativeSet
| MaxHpSet
| CurrentHpAdjusted;

View File

@@ -1,4 +1,5 @@
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js"; export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
export { advanceTurn } from "./advance-turn.js"; export { advanceTurn } from "./advance-turn.js";
export { export {
type EditCombatantSuccess, type EditCombatantSuccess,
@@ -8,8 +9,10 @@ export type {
CombatantAdded, CombatantAdded,
CombatantRemoved, CombatantRemoved,
CombatantUpdated, CombatantUpdated,
CurrentHpAdjusted,
DomainEvent, DomainEvent,
InitiativeSet, InitiativeSet,
MaxHpSet,
RoundAdvanced, RoundAdvanced,
TurnAdvanced, TurnAdvanced,
} from "./events.js"; } from "./events.js";
@@ -17,6 +20,7 @@ export {
type RemoveCombatantSuccess, type RemoveCombatantSuccess,
removeCombatant, removeCombatant,
} from "./remove-combatant.js"; } from "./remove-combatant.js";
export { type SetHpSuccess, setHp } from "./set-hp.js";
export { export {
type SetInitiativeSuccess, type SetInitiativeSuccess,
setInitiative, setInitiative,

View File

@@ -0,0 +1,88 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface SetHpSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that sets, updates, or clears a combatant's max HP.
*
* - Setting maxHp initializes currentHp to maxHp (full health).
* - Updating maxHp clamps currentHp to the new maxHp if needed.
* - Clearing maxHp (undefined) also clears currentHp.
*/
export function setHp(
encounter: Encounter,
combatantId: CombatantId,
maxHp: number | undefined,
): SetHpSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
if (maxHp !== undefined) {
if (!Number.isInteger(maxHp) || maxHp < 1) {
return {
kind: "domain-error",
code: "invalid-max-hp",
message: `Max HP must be a positive integer, got ${maxHp}`,
};
}
}
const target = encounter.combatants[targetIdx];
const previousMaxHp = target.maxHp;
const previousCurrentHp = target.currentHp;
let newMaxHp: number | undefined;
let newCurrentHp: number | undefined;
if (maxHp === undefined) {
newMaxHp = undefined;
newCurrentHp = undefined;
} else if (previousMaxHp === undefined) {
// First time setting HP — full health
newMaxHp = maxHp;
newCurrentHp = maxHp;
} else {
// Updating existing maxHp
newMaxHp = maxHp;
if (previousCurrentHp === previousMaxHp) {
// At full health — stay at full health
newCurrentHp = maxHp;
} else {
// Clamp currentHp to new max
newCurrentHp = Math.min(previousCurrentHp ?? maxHp, maxHp);
}
}
return {
encounter: {
combatants: encounter.combatants.map((c) =>
c.id === combatantId
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp }
: c,
),
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "MaxHpSet",
combatantId,
previousMaxHp,
newMaxHp,
previousCurrentHp,
newCurrentHp,
},
],
};
}

View File

@@ -9,6 +9,8 @@ export interface Combatant {
readonly id: CombatantId; readonly id: CombatantId;
readonly name: string; readonly name: string;
readonly initiative?: number; readonly initiative?: number;
readonly maxHp?: number;
readonly currentHp?: number;
} }
export interface Encounter { export interface Encounter {

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Combatant HP Tracking
**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`.

View File

@@ -0,0 +1,83 @@
# Data Model: Combatant HP Tracking
**Feature**: 009-combatant-hp | **Date**: 2026-03-05
## Entities
### Combatant (extended)
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| id | CombatantId (branded string) | Yes | Unique within encounter |
| name | string | Yes | Non-empty |
| initiative | number or undefined | No | Integer when set |
| **maxHp** | **number or undefined** | **No** | **Positive integer (>= 1) when set** |
| **currentHp** | **number or undefined** | **No** | **Integer in [0, maxHp] when set; requires maxHp** |
**Invariants**:
- If `maxHp` is undefined, `currentHp` must also be undefined (no current HP without a max).
- If `maxHp` is defined, `currentHp` must be defined and satisfy `0 <= currentHp <= maxHp`.
- When `maxHp` is first set, `currentHp` defaults to `maxHp` (full health).
- When `maxHp` changes and `currentHp === maxHp` (full health), `currentHp` stays synced to the new `maxHp`.
- When `maxHp` changes and `currentHp < maxHp` (not full health), `currentHp` is unchanged unless it exceeds the new `maxHp`, in which case it is clamped.
- When `maxHp` is cleared (set to undefined), `currentHp` is also cleared.
### Encounter (unchanged structure)
No structural changes. The encounter continues to hold `readonly combatants: readonly Combatant[]`, `activeIndex`, and `roundNumber`. HP state lives on individual combatants.
## Domain Events (new)
### MaxHpSet
Emitted when a combatant's max HP is set, changed, or cleared.
| Field | Type | Description |
|-------|------|-------------|
| type | "MaxHpSet" | Event discriminant |
| combatantId | CombatantId | Target combatant |
| previousMaxHp | number or undefined | Max HP before change |
| newMaxHp | number or undefined | Max HP after change |
| previousCurrentHp | number or undefined | Current HP before change |
| newCurrentHp | number or undefined | Current HP after change (may differ due to clamping) |
### CurrentHpAdjusted
Emitted when a combatant's current HP is adjusted via +/- or direct entry.
| Field | Type | Description |
|-------|------|-------------|
| type | "CurrentHpAdjusted" | Event discriminant |
| combatantId | CombatantId | Target combatant |
| previousHp | number | Current HP before adjustment |
| newHp | number | Current HP after adjustment (clamped) |
| delta | number | Requested change amount (positive = heal, negative = damage) |
## State Transitions
### setHp(encounter, combatantId, maxHp)
```
Input: Encounter, CombatantId, number | undefined
Output: { encounter: Encounter, events: [MaxHpSet] } | DomainError
- combatant not found → DomainError("combatant-not-found")
- maxHp <= 0 or non-integer → DomainError("invalid-max-hp")
- maxHp = undefined → clear both maxHp and currentHp
- maxHp = N (new) → set maxHp=N, currentHp=N
- maxHp = N (changed, at full health: currentHp=prevMaxHp) → set maxHp=N, currentHp=N
- maxHp = N (changed, not full health) → set maxHp=N, currentHp=min(currentHp, N)
```
### adjustHp(encounter, combatantId, delta)
```
Input: Encounter, CombatantId, number
Output: { encounter: Encounter, events: [CurrentHpAdjusted] } | DomainError
- combatant not found → DomainError("combatant-not-found")
- combatant has no maxHp set → DomainError("no-hp-tracking")
- delta = 0 → DomainError("zero-delta")
- delta non-integer → DomainError("invalid-delta")
- result → currentHp = clamp(currentHp + delta, 0, maxHp)
```

View File

@@ -0,0 +1,79 @@
# Implementation Plan: Combatant HP Tracking
**Branch**: `009-combatant-hp` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/009-combatant-hp/spec.md`
## Summary
Add optional max HP and current HP tracking to combatants. The domain layer gains pure functions for setting max HP and adjusting current HP (clamped to 0..max). The application layer orchestrates via the existing `EncounterStore` port. The web adapter adds +/- controls and direct numeric entry per combatant row. Persistence extends the existing localStorage serialization to include HP fields.
## Technical Context
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
**Primary Dependencies**: React 19, Vite 6, Biome 2.0, Vitest
**Storage**: Browser localStorage (adapter layer only)
**Testing**: Vitest (pure function tests in domain, use case tests in application)
**Target Platform**: Modern web browsers (single-user, local-first)
**Project Type**: Web application (monorepo with domain/application/web layers)
**Performance Goals**: Standard web app responsiveness; HP adjustments must feel instant
**Constraints**: Offline-capable, single-user MVP, no server
**Scale/Scope**: Single encounter at a time, typically 5-20 combatants
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | HP set/adjust are pure functions: (Encounter, CombatantId, value) -> (Encounter, Events) or DomainError. No I/O in domain. |
| II. Layered Architecture | PASS | Domain: pure HP functions. Application: use cases via EncounterStore port. Web: React adapter with +/- controls. No layer violations. |
| III. Agent Boundary | N/A | No agent features in this feature. |
| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers; all decisions documented in Assumptions. |
| V. Escalation Gates | PASS | Implementation stays within spec scope. |
| VI. MVP Baseline Language | PASS | Spec uses "not in the MVP baseline" for temp HP, death states, custom damage amounts. |
| VII. No Gameplay Rules in Constitution | PASS | HP clamping is spec-level behavior, not constitution-level. |
## Project Structure
### Documentation (this feature)
```text
specs/009-combatant-hp/
├── plan.md # This file
├── spec.md # Feature specification
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
└── tasks.md # Phase 2 output (created by /speckit.tasks)
```
### Source Code (repository root)
```text
packages/domain/src/
├── types.ts # Extend Combatant with optional maxHp/currentHp
├── set-hp.ts # New: pure function for setting max HP
├── adjust-hp.ts # New: pure function for adjusting current HP (+/- delta)
├── events.ts # New events: MaxHpSet, CurrentHpAdjusted
├── index.ts # Re-export new functions
└── __tests__/
├── set-hp.test.ts # Tests for set-hp
└── adjust-hp.test.ts # Tests for adjust-hp
packages/application/src/
├── set-hp-use-case.ts # New: orchestrates set-hp via store
├── adjust-hp-use-case.ts # New: orchestrates adjust-hp via store
└── index.ts # Re-export new use cases
apps/web/src/
├── hooks/use-encounter.ts # Add setHp and adjustHp callbacks
├── persistence/
│ └── encounter-storage.ts # Extend validation to include HP fields
└── App.tsx # Add HP controls to combatant rows
```
**Structure Decision**: Follows existing monorepo layered architecture. New domain functions follow the established one-file-per-operation pattern (matching `edit-combatant.ts`, `set-initiative.ts`). Two separate domain functions (`set-hp` for max HP, `adjust-hp` for current HP delta) keep concerns separated and make the design extensible for richer damage/heal operations later.
## Complexity Tracking
No constitution violations to justify.

View File

@@ -0,0 +1,48 @@
# Quickstart: Combatant HP Tracking
**Feature**: 009-combatant-hp | **Date**: 2026-03-05
## Overview
This feature adds optional health point (HP) tracking to combatants. Each combatant can optionally have a max HP and current HP. Both HP fields are always visible per combatant row (current HP disabled until max HP is set). The current HP is adjusted via +/- controls or direct entry, always clamped to [0, maxHp]. When max HP changes and the combatant is at full health, current HP stays synced.
## Implementation Order
1. **Domain types** — Extend `Combatant` interface with optional `maxHp` and `currentHp`. Add new event types to `events.ts`.
2. **Domain functions** — Implement `setHp` (set/clear max HP) and `adjustHp` (apply delta to current HP) as pure functions following the existing pattern in `edit-combatant.ts`.
3. **Domain tests** — Write tests for both domain functions covering acceptance scenarios, invariants, error cases, and edge cases.
4. **Application use cases** — Create `setHpUseCase` and `adjustHpUseCase` following the existing get-call-save pattern.
5. **Persistence** — Extend `loadEncounter()` validation to handle optional `maxHp`/`currentHp` fields on combatants.
6. **Web hook** — Add `setHp` and `adjustHp` callbacks to `useEncounter`.
7. **UI components** — Add HP controls to combatant rows in `App.tsx`: both Current HP and Max HP inputs always visible (Current HP disabled until Max HP is set), +/- buttons shown only when HP tracking is active, direct current HP entry.
## Key Files to Modify
| File | Change |
|------|--------|
| `packages/domain/src/types.ts` | Add optional `maxHp` and `currentHp` to `Combatant` |
| `packages/domain/src/events.ts` | Add `MaxHpSet` and `CurrentHpAdjusted` event types |
| `packages/domain/src/set-hp.ts` | New file: pure function for setting max HP |
| `packages/domain/src/adjust-hp.ts` | New file: pure function for adjusting current HP |
| `packages/domain/src/index.ts` | Re-export new functions |
| `packages/application/src/set-hp-use-case.ts` | New file: set HP use case |
| `packages/application/src/adjust-hp-use-case.ts` | New file: adjust HP use case |
| `packages/application/src/index.ts` | Re-export new use cases |
| `apps/web/src/hooks/use-encounter.ts` | Add setHp/adjustHp callbacks |
| `apps/web/src/persistence/encounter-storage.ts` | Validate HP fields on load |
| `apps/web/src/App.tsx` | HP controls in combatant rows |
## Development Commands
```bash
pnpm test # Run all tests
pnpm vitest run packages/domain/src/__tests__/set-hp.test.ts # Single test
pnpm check # Full quality gate
pnpm --filter web dev # Dev server
```

View File

@@ -0,0 +1,73 @@
# Research: Combatant HP Tracking
**Feature**: 009-combatant-hp | **Date**: 2026-03-05
## Decision 1: Domain Function Granularity
**Decision**: Two separate domain functions — `setHp` (sets/updates max HP, initializes current HP, syncs full-health combatants) and `adjustHp` (applies a delta to current HP with clamping).
**Rationale**: The existing codebase follows a one-file-per-operation pattern (`edit-combatant.ts`, `set-initiative.ts`). Separating "set max HP" from "adjust current HP" keeps each function focused and testable. The `adjustHp` function accepting a delta (rather than an absolute value) is extensible: a future damage/heal dialog would call `adjustHp(encounter, id, -15)` for 15 damage, while the current +/- buttons call `adjustHp(encounter, id, -1)` or `adjustHp(encounter, id, +1)`.
**Alternatives considered**:
- Single `updateHp` function handling both max and current → rejected because it conflates two distinct user intents (configuring a combatant vs. tracking combat damage).
- Separate `damageHp` and `healHp` functions → rejected as premature; `adjustHp` with positive/negative delta covers both directions and is simpler for MVP.
## Decision 2: HP Field Placement on Combatant
**Decision**: Add optional `maxHp?: number` and `currentHp?: number` directly to the `Combatant` interface. Both are `undefined` when HP tracking is not active for a combatant.
**Rationale**: The `Combatant` type is small (id, name, initiative). Adding two optional fields keeps the type flat and simple. The existing persistence layer serializes the entire `Encounter` including all `Combatant` fields, so HP values persist automatically once added to the type.
**Alternatives considered**:
- Separate `HitPoints` value object → rejected as over-engineering for two fields. Can refactor later if more HP-related fields emerge (temp HP, resistances, etc.).
- HP stored in a separate map keyed by CombatantId → rejected because it breaks the colocation of combatant data and complicates persistence/serialization.
## Decision 3: Direct HP Entry Implementation
**Decision**: Direct entry sets `currentHp` to an absolute value (clamped to 0..maxHp). This is handled by `adjustHp` or a simple `setCurrentHp` path within the same function by computing the needed delta internally, or as a separate thin wrapper. Simplest: `adjustHp` accepts a delta, and the UI computes `newValue - currentHp` as the delta.
**Rationale**: Keeps the domain function consistent (always delta-based, always clamped). The UI adapter is responsible for translating "user typed 35" into a delta. This avoids a third domain function and keeps the domain API small.
**Alternatives considered**:
- Domain function that accepts absolute current HP → would duplicate clamping logic already in adjustHp. Not chosen.
## Decision 4: Event Design
**Decision**: Two event types — `MaxHpSet` (combatantId, previousMaxHp, newMaxHp, previousCurrentHp, newCurrentHp) and `CurrentHpAdjusted` (combatantId, previousHp, newHp, delta).
**Rationale**: Matches the existing event-per-operation pattern. `MaxHpSet` includes current HP in case it was clamped (the user needs to see that side effect). `CurrentHpAdjusted` includes the delta for future extensibility (a combat log might show "took 5 damage" vs. "healed 3").
**Alternatives considered**:
- Single `HpChanged` event → rejected because setting max HP and adjusting current HP are semantically different operations with different triggers.
## Decision 5: Full-Health Sync on Max HP Change
**Decision**: When `maxHp` changes and the combatant is at full health (`currentHp === previousMaxHp`), `currentHp` is updated to match the new `maxHp`. When not at full health, `currentHp` is only clamped downward (never increased).
**Rationale**: A combatant at full health should stay at full health when their max HP increases. This is a clean domain-level rule independent of UI concerns.
**Alternatives considered**:
- Always sync currentHp to maxHp on change → rejected because it would overwrite intentional HP adjustments (e.g., combatant at 5/20 HP would jump to 25 when max changes to 25).
## Decision 6: Always-Visible HP Fields + Blur-Commit for Max HP
**Decision**: Both Current HP and Max HP input fields are always visible in each combatant row. Current HP is disabled when Max HP is not set. The +/- buttons only appear when HP tracking is active (maxHp defined). The Max HP input uses local draft state and commits to the domain only on blur or Enter — not on every keystroke.
**Rationale**: Two related problems drove this decision:
1. **Focus loss**: Conditionally rendering the Max HP input caused focus loss. Typing the first digit triggered `setHp`, switching DOM branches and destroying the input element.
2. **Premature clearing**: Committing on every keystroke meant clearing the field to retype a value would call `setHp(undefined)`, wiping currentHp. Retyping then triggered the "first set" path, resetting currentHp to the new maxHp — losing the user's previous HP adjustment.
The blur-commit approach solves both: the input keeps a local draft string, so intermediate states (empty field, partial values) never reach the domain. The domain only sees the final intended value.
**Alternatives considered**:
- Conditional render with "Set HP" placeholder → rejected because it causes focus loss when the branch switches.
- Per-keystroke commit (matching the initiative input pattern) → rejected because it causes the premature-clearing bug. The initiative input doesn't have this problem since it has no dependent field like currentHp.
## Decision 7: Persistence Validation
**Decision**: Extend `loadEncounter()` validation (originally Decision 5) to check `maxHp` and `currentHp` fields on each combatant. If `maxHp` is present, validate it is a positive integer. If `currentHp` is present, validate it is an integer in [0, maxHp]. Missing fields are acceptable (optional). Invalid values cause the field to be stripped (combatant loads without HP rather than failing the entire encounter load).
**Rationale**: Follows the existing defensive validation pattern in `encounter-storage.ts`. Graceful degradation per-combatant is better than losing the entire encounter.
**Alternatives considered**:
- Reject entire encounter on HP validation failure → too aggressive; existing combatant data should survive.

View File

@@ -0,0 +1,122 @@
# Feature Specification: Combatant HP Tracking
**Feature Branch**: `009-combatant-hp`
**Created**: 2026-03-05
**Status**: Draft
**Input**: User description: "Track max HP and current HP per combatant with quick +/- controls (clamp 0..max); keep the design extensible for later richer damage/heal UI."
## User Scenarios & Testing
### User Story 1 - Set Max HP for a Combatant (Priority: P1)
As a game master, I want to assign a maximum HP value to a combatant so that I can track their health during the encounter.
**Why this priority**: Max HP is the foundation for all HP tracking. Without it, current HP has no upper bound and the feature has no value.
**Independent Test**: Can be fully tested by adding a combatant and setting their max HP, then verifying the value is stored and displayed.
**Acceptance Scenarios**:
1. **Given** a combatant exists in the encounter, **When** the user sets a max HP value (positive integer), **Then** the combatant's max HP is stored and displayed.
2. **Given** a combatant has no max HP set yet, **When** the combatant is displayed, **Then** both Current HP and Max HP fields are visible (Current HP is disabled until Max HP is set).
3. **Given** a combatant has a max HP of 20, **When** the user changes max HP to 30, **Then** the max HP updates to 30.
4. **Given** a combatant has max HP of 20 and current HP of 20, **When** the user lowers max HP to 15, **Then** current HP is clamped to the new max HP of 15.
5. **Given** a combatant has max HP of 20 and current HP of 20 (full health), **When** the user increases max HP to 30, **Then** current HP increases to 30 (stays at full health).
6. **Given** a combatant has max HP of 20 and current HP of 12 (not full health), **When** the user increases max HP to 30, **Then** current HP remains at 12 (unchanged).
---
### User Story 2 - Quick Adjust Current HP (Priority: P1)
As a game master, I want to quickly increase or decrease a combatant's current HP using +/- controls so that I can reflect damage and healing during combat without typing exact values.
**Why this priority**: This is the primary interaction loop during combat -- adjusting HP as damage/healing occurs. Equally critical as setting max HP.
**Independent Test**: Can be fully tested by setting a combatant's max HP, then using +/- controls and verifying the current HP changes correctly within bounds.
**Acceptance Scenarios**:
1. **Given** a combatant has max HP of 20 and current HP of 20, **When** the user presses the "-" control, **Then** current HP decreases by 1 to 19.
2. **Given** a combatant has max HP of 20 and current HP of 15, **When** the user presses the "+" control, **Then** current HP increases by 1 to 16.
3. **Given** a combatant has current HP of 0, **When** the user presses the "-" control, **Then** current HP remains at 0 (clamped to minimum).
4. **Given** a combatant has current HP equal to max HP, **When** the user presses the "+" control, **Then** current HP remains at max HP (clamped to maximum).
---
### User Story 3 - Direct HP Entry (Priority: P2)
As a game master, I want to type a specific current HP value directly so that I can apply large amounts of damage or healing in one action.
**Why this priority**: Complements the quick +/- controls for cases where the delta is large. Less critical than basic +/- since the same outcome can be achieved with repeated presses.
**Independent Test**: Can be fully tested by setting a combatant's max HP, typing a value in the current HP field, and verifying clamping behavior.
**Acceptance Scenarios**:
1. **Given** a combatant has max HP of 50, **When** the user types 35 into the current HP field, **Then** current HP is set to 35.
2. **Given** a combatant has max HP of 50, **When** the user types 60 into the current HP field, **Then** current HP is clamped to 50.
3. **Given** a combatant has max HP of 50, **When** the user types -5 into the current HP field, **Then** current HP is clamped to 0.
---
### User Story 4 - HP Persists Across Reloads (Priority: P2)
As a game master, I want HP values to survive page reloads so that I don't lose health tracking mid-session.
**Why this priority**: Losing HP data on reload would make the feature unreliable during a game session. Important but builds on existing persistence infrastructure.
**Independent Test**: Can be tested by setting HP values, reloading the page, and verifying values are restored.
**Acceptance Scenarios**:
1. **Given** a combatant has max HP of 30 and current HP of 18, **When** the page is reloaded, **Then** both max HP and current HP are restored.
---
### Edge Cases
- What happens when max HP is set to 0? System must reject 0 or negative max HP values; max HP must be a positive integer.
- What happens when a combatant is added without HP? The combatant is displayed without HP tracking. HP is optional -- not all combatants need HP (e.g., environmental effects, lair actions).
- What happens when the user enters a non-numeric value for HP? The input is rejected and the previous value is preserved.
- What happens when max HP is cleared/removed? Current HP is also cleared; the combatant returns to the "no HP" state. Clearing only takes effect on blur/Enter (not while typing), so temporarily emptying the field during editing does not wipe current HP.
- What happens when the user selects all text in the max HP field and retypes a new value? The field uses local draft state; the domain only sees the final committed value on blur/Enter. Current HP is preserved.
- What happens when HP is adjusted by 0? The system rejects a zero delta -- no change is made and no event is emitted.
## Requirements
### Functional Requirements
- **FR-001**: Each combatant MAY have an optional max HP value (positive integer).
- **FR-002**: Each combatant with a max HP MUST have a current HP value, defaulting to max HP when first set.
- **FR-003**: Current HP MUST be clamped to the range [0, max HP] at all times.
- **FR-004**: The system MUST provide "+" and "-" controls to adjust current HP by 1.
- **FR-005**: The system MUST allow direct numeric entry of current HP, applying clamping on confirmation.
- **FR-006**: The system MUST allow the user to set and edit the max HP value for any combatant. The max HP value is committed on blur or Enter (not per-keystroke) so that intermediate editing states (e.g., clearing the field to retype) do not affect current HP.
- **FR-007**: When max HP is reduced below current HP, current HP MUST be clamped to the new max HP.
- **FR-011**: When max HP changes and the combatant is at full health (current HP equals previous max HP), current HP MUST stay synced to the new max HP value.
- **FR-012**: When max HP changes and the combatant is NOT at full health, current HP MUST remain unchanged (unless clamped by FR-007).
- **FR-008**: Max HP MUST be a positive integer (>= 1). The system MUST reject zero, negative, or non-integer values.
- **FR-009**: HP values (max and current) MUST persist across page reloads using the existing persistence mechanism.
- **FR-010**: HP tracking MUST be optional per combatant. Combatants without max HP set have no HP display beyond the empty input fields.
- **FR-013**: Both Current HP and Max HP input fields MUST always be visible in each combatant row. The Current HP field is disabled when Max HP is not set. The +/- buttons only appear when HP tracking is active.
### Key Entities
- **Combatant** (extended): Gains optional `maxHp` (positive integer) and `currentHp` (integer, 0..maxHp) attributes. When `maxHp` is undefined, the combatant has no HP tracking.
## Success Criteria
### Measurable Outcomes
- **SC-001**: A user can set max HP and adjust current HP for any combatant in under 5 seconds per action.
- **SC-002**: Current HP never exceeds max HP or drops below 0, regardless of user input method.
- **SC-003**: HP values survive a full page reload without data loss.
- **SC-004**: Combatants without HP set display correctly with no HP controls cluttering the interface.
## Assumptions
- The increment/decrement step for +/- controls is 1. Larger step sizes (e.g., custom damage amounts) are not in the MVP baseline but the design should not preclude a richer damage/heal UI in the future.
- HP is always an integer (no fractional HP). This aligns with standard tabletop RPG conventions.
- There is no "temporary HP" concept in the MVP baseline.
- There is no death/unconscious state triggered by reaching 0 HP in the MVP baseline. The system simply displays 0.
- The +/- controls and direct entry are the only HP modification methods in the MVP baseline. A richer damage/heal dialog (e.g., entering a damage amount to subtract) is not included but the domain design should be extensible to support it.

View File

@@ -0,0 +1,156 @@
# Tasks: Combatant HP Tracking
**Input**: Design documents from `/specs/009-combatant-hp/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## 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: Setup (Shared Infrastructure)
**Purpose**: Extend domain types and events shared by all user stories
- [x] T001 Extend `Combatant` interface with optional `maxHp` and `currentHp` fields in `packages/domain/src/types.ts`
- [x] T002 Add `MaxHpSet` and `CurrentHpAdjusted` event types to `DomainEvent` union in `packages/domain/src/events.ts`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Domain pure functions that all user stories depend on
**CRITICAL**: No user story work can begin until this phase is complete
- [x] T003 Implement `setHp` pure function in `packages/domain/src/set-hp.ts` — accepts (Encounter, CombatantId, maxHp: number | undefined), returns {encounter, events} | DomainError. Handles: set new maxHp (currentHp defaults to maxHp), update maxHp (full-health sync: if currentHp === previousMaxHp then currentHp = newMaxHp; otherwise clamp currentHp), clear maxHp (clear both), validate positive integer, combatant-not-found error
- [x] T004 Write tests for `setHp` in `packages/domain/src/__tests__/set-hp.test.ts` — cover acceptance scenarios (set, update, full-health sync on increase, clamp on reduce, clear), invariants (pure, immutable, event shape), error cases (not found, invalid values), edge cases (maxHp=1, reduce below currentHp)
- [x] T005 Implement `adjustHp` pure function in `packages/domain/src/adjust-hp.ts` — accepts (Encounter, CombatantId, delta: number), returns {encounter, events} | DomainError. Clamps result to [0, maxHp]. Errors: combatant-not-found, no-hp-tracking, zero-delta, invalid-delta
- [x] T006 Write tests for `adjustHp` in `packages/domain/src/__tests__/adjust-hp.test.ts` — cover acceptance scenarios (+1, -1, clamp at 0, clamp at max), invariants (pure, immutable, event shape with delta), error cases, edge cases (large delta beyond bounds)
- [x] T007 Re-export `setHp` and `adjustHp` from `packages/domain/src/index.ts`
- [x] T008 [P] Create `setHpUseCase` in `packages/application/src/set-hp-use-case.ts` following get-call-save pattern via `EncounterStore`
- [x] T009 [P] Create `adjustHpUseCase` in `packages/application/src/adjust-hp-use-case.ts` following get-call-save pattern via `EncounterStore`
- [x] T010 Re-export new use cases from `packages/application/src/index.ts`
**Checkpoint**: Domain and application layers complete. `pnpm test` and `pnpm typecheck` pass.
---
## Phase 3: User Story 1 — Set Max HP for a Combatant (Priority: P1) + User Story 2 — Quick Adjust Current HP (Priority: P1) MVP
**Goal**: A game master can set max HP on a combatant and use +/- controls to adjust current HP during combat. These two P1 stories are combined because the UI naturally presents them together (max HP input + current HP with +/- buttons in one combatant row).
**Independent Test**: Add a combatant, set max HP, verify it displays. Press -/+ buttons, verify current HP changes within bounds. Reduce max HP below current HP, verify clamping.
### Implementation
- [x] T011 [US1] [US2] Add `setHp` and `adjustHp` callbacks to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — follow existing pattern (call use case, check error, update events)
- [x] T012 [US1] [US2] Add HP controls to combatant rows in `apps/web/src/App.tsx` — both Current HP and Max HP inputs always visible. Current HP input disabled when maxHp is undefined. +/- buttons only shown when HP tracking is active. Max HP input uses local draft state and commits on blur/Enter only (not per-keystroke) to prevent premature clearing of currentHp. Max HP input allows clearing (returning combatant to no-HP state). Clamp visual state matches domain invariants.
**Checkpoint**: US1 + US2 fully functional. User can set max HP, see current HP, and use +/- buttons. `pnpm check` passes.
---
## Phase 4: User Story 3 — Direct HP Entry (Priority: P2)
**Goal**: A game master can type a specific current HP value directly instead of using +/- buttons.
**Independent Test**: Set max HP to 50, type 35 in current HP field, verify it updates. Type 60, verify clamped to 50. Type -5, verify clamped to 0.
### Implementation
- [x] T013 [US3] Make current HP display editable (click-to-edit or inline input) in `apps/web/src/App.tsx` — on confirm, compute delta from current value and call `adjustHp`. Apply clamping via domain function.
**Checkpoint**: US3 functional. Direct numeric entry works alongside +/- controls. `pnpm check` passes.
---
## Phase 5: User Story 4 — HP Persists Across Reloads (Priority: P2)
**Goal**: HP values survive page reloads via existing localStorage persistence.
**Independent Test**: Set max HP and adjust current HP, reload the page, verify both values are restored.
### Implementation
- [x] T014 [US4] Extend `loadEncounter()` validation in `apps/web/src/persistence/encounter-storage.ts` — validate optional `maxHp` (positive integer) and `currentHp` (integer in [0, maxHp]) on each combatant during deserialization. Strip invalid HP fields per-combatant rather than failing the entire encounter.
**Checkpoint**: US4 functional. HP values persist across reloads. `pnpm check` passes.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final validation and cleanup
- [x] T015 Run `pnpm check` (knip + format + lint + typecheck + test) and fix any issues
- [x] T016 Verify layer boundary test still passes in `packages/domain/src/__tests__/layer-boundaries.test.ts`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 completion — BLOCKS all user stories
- **US1+US2 (Phase 3)**: Depends on Phase 2 completion
- **US3 (Phase 4)**: Depends on Phase 3 (extends the HP UI from US1+US2)
- **US4 (Phase 5)**: Can start after Phase 2 (independent of UI work), but naturally follows Phase 3
- **Polish (Phase 6)**: Depends on all previous phases
### Within Each Phase
- T001 and T002 are parallel (different files)
- T003 → T004 (implement then test setHp)
- T005 → T006 (implement then test adjustHp)
- T003 and T005 are parallel (different files, no dependency)
- T008 and T009 are parallel (different files)
- T008/T009 depend on T007 (need exports)
- T011 → T012 (hook before UI)
- T013 depends on T012 (extends existing HP UI)
### Parallel Opportunities
```text
Parallel group A (Phase 1): T001 || T002
Parallel group B (Phase 2): T003+T004 || T005+T006 (then T007, then T008 || T009, then T010)
Sequential (Phase 3): T011 → T012
Sequential (Phase 4): T013
Independent (Phase 5): T014
```
---
## Implementation Strategy
### MVP First (US1 + US2)
1. Complete Phase 1: Setup (types + events)
2. Complete Phase 2: Foundational (domain functions + use cases)
3. Complete Phase 3: US1 + US2 (set max HP + quick adjust)
4. **STOP and VALIDATE**: Can set HP and use +/- controls
5. Continue with US3 (direct entry) and US4 (persistence)
### Incremental Delivery
1. Phase 1 + 2 → Domain + application ready
2. Phase 3 → MVP: max HP + quick adjust functional
3. Phase 4 → Direct HP entry added
4. Phase 5 → Persistence extended
5. Phase 6 → Quality gate passes, ready to merge
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Commit after each phase checkpoint
- US1 and US2 are combined in Phase 3 because they share UI surface (HP controls on combatant row)
- Domain functions are designed for extensibility: `adjustHp` accepts any integer delta, so a future damage/heal dialog can call it directly