From 8bf69fd47d219de0e08dcfd4903a8b08980d2006 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 23 Mar 2026 11:39:47 +0100 Subject: [PATCH] Add temporary hit points as a separate damage buffer Temp HP absorbs damage before current HP, cannot be healed, and does not stack (higher value wins). Displayed as cyan +N after current HP with a Shield button in the HP adjustment popover. Column space is reserved across all rows only when any combatant has temp HP. Concentration pulse fires on any damage, including damage fully absorbed by temp HP. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/combatant-row.test.tsx | 121 +++++++++++- .../__tests__/hp-adjust-popover.test.tsx | 37 +++- .../__tests__/turn-navigation.test.tsx | 2 + apps/web/src/components/combatant-row.tsx | 45 ++++- apps/web/src/components/hp-adjust-popover.tsx | 24 ++- apps/web/src/hooks/use-encounter.ts | 20 ++ packages/application/src/index.ts | 1 + .../application/src/set-temp-hp-use-case.ts | 24 +++ .../domain/src/__tests__/adjust-hp.test.ts | 102 +++++++++- packages/domain/src/__tests__/set-hp.test.ts | 28 +++ .../domain/src/__tests__/set-temp-hp.test.ts | 182 ++++++++++++++++++ packages/domain/src/adjust-hp.ts | 50 +++-- packages/domain/src/events.ts | 8 + packages/domain/src/index.ts | 2 + packages/domain/src/set-hp.ts | 7 +- packages/domain/src/set-temp-hp.ts | 78 ++++++++ packages/domain/src/types.ts | 1 + specs/003-combatant-state/spec.md | 28 ++- 18 files changed, 731 insertions(+), 29 deletions(-) create mode 100644 packages/application/src/set-temp-hp-use-case.ts create mode 100644 packages/domain/src/__tests__/set-temp-hp.test.ts create mode 100644 packages/domain/src/set-temp-hp.ts diff --git a/apps/web/src/components/__tests__/combatant-row.test.tsx b/apps/web/src/components/__tests__/combatant-row.test.tsx index 13ca899..f16b7af 100644 --- a/apps/web/src/components/__tests__/combatant-row.test.tsx +++ b/apps/web/src/components/__tests__/combatant-row.test.tsx @@ -9,9 +9,12 @@ import { AllProviders } from "../../__tests__/test-providers.js"; import { CombatantRow } from "../combatant-row.js"; import { PLAYER_COLOR_HEX } from "../player-icon-map.js"; +const TEMP_HP_REGEX = /^\+\d/; + // Mock persistence — no localStorage interaction +const mockLoadEncounter = vi.fn<() => unknown>(() => null); vi.mock("../../persistence/encounter-storage.js", () => ({ - loadEncounter: () => null, + loadEncounter: () => mockLoadEncounter(), saveEncounter: () => {}, })); @@ -193,4 +196,120 @@ describe("CombatantRow", () => { screen.getByRole("button", { name: "Roll initiative" }), ).toBeInTheDocument(); }); + + describe("concentration pulse", () => { + it("pulses when currentHp drops on a concentrating combatant", () => { + const combatant = { + id: combatantId("1"), + name: "Goblin", + maxHp: 20, + currentHp: 15, + isConcentrating: true, + }; + const { rerender, container } = renderRow({ combatant }); + rerender( + , + ); + const row = container.firstElementChild; + expect(row?.className).toContain("animate-concentration-pulse"); + }); + + it("does not pulse when not concentrating", () => { + const combatant = { + id: combatantId("1"), + name: "Goblin", + maxHp: 20, + currentHp: 15, + isConcentrating: false, + }; + const { rerender, container } = renderRow({ combatant }); + rerender( + , + ); + const row = container.firstElementChild; + expect(row?.className).not.toContain("animate-concentration-pulse"); + }); + + it("pulses when temp HP absorbs all damage on a concentrating combatant", () => { + const combatant = { + id: combatantId("1"), + name: "Goblin", + maxHp: 20, + currentHp: 15, + tempHp: 8, + isConcentrating: true, + }; + mockLoadEncounter.mockReturnValueOnce({ + combatants: [combatant], + activeIndex: 0, + roundNumber: 1, + }); + const { rerender, container } = renderRow({ combatant }); + // Temp HP absorbs all damage, currentHp unchanged + rerender( + , + ); + const row = container.firstElementChild; + expect(row?.className).toContain("animate-concentration-pulse"); + }); + }); + + describe("temp HP display", () => { + it("shows +N when combatant has temp HP", () => { + const combatant = { + id: combatantId("1"), + name: "Goblin", + maxHp: 20, + currentHp: 15, + tempHp: 5, + }; + // Provide encounter with tempHp so hasTempHp is true + mockLoadEncounter.mockReturnValueOnce({ + combatants: [combatant], + activeIndex: 0, + roundNumber: 1, + }); + renderRow({ combatant }); + expect(screen.getByText("+5")).toBeInTheDocument(); + }); + + it("does not show +N when combatant has no temp HP", () => { + renderRow({ + combatant: { + id: combatantId("1"), + name: "Goblin", + maxHp: 20, + currentHp: 15, + }, + }); + expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument(); + }); + + it("temp HP display uses cyan color", () => { + const combatant = { + id: combatantId("1"), + name: "Goblin", + maxHp: 20, + currentHp: 15, + tempHp: 8, + }; + mockLoadEncounter.mockReturnValueOnce({ + combatants: [combatant], + activeIndex: 0, + roundNumber: 1, + }); + renderRow({ combatant }); + const tempHpEl = screen.getByText("+8"); + expect(tempHpEl.className).toContain("text-cyan-400"); + }); + }); }); diff --git a/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx b/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx index eb078bf..387a935 100644 --- a/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx +++ b/apps/web/src/components/__tests__/hp-adjust-popover.test.tsx @@ -11,15 +11,21 @@ afterEach(cleanup); function renderPopover( overrides: Partial<{ onAdjust: (delta: number) => void; + onSetTempHp: (value: number) => void; onClose: () => void; }> = {}, ) { const onAdjust = overrides.onAdjust ?? vi.fn(); + const onSetTempHp = overrides.onSetTempHp ?? vi.fn(); const onClose = overrides.onClose ?? vi.fn(); const result = render( - , + , ); - return { ...result, onAdjust, onClose }; + return { ...result, onAdjust, onSetTempHp, onClose }; } describe("HpAdjustPopover", () => { @@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => { await user.type(input, "12abc34"); expect(input).toHaveValue("1234"); }); + + describe("temp HP", () => { + it("shield button calls onSetTempHp with entered value and closes", async () => { + const user = userEvent.setup(); + const { onSetTempHp, onClose } = renderPopover(); + await user.type(screen.getByPlaceholderText("HP"), "8"); + await user.click(screen.getByRole("button", { name: "Set temp HP" })); + expect(onSetTempHp).toHaveBeenCalledWith(8); + expect(onClose).toHaveBeenCalled(); + }); + + it("shield button is disabled when input is empty", () => { + renderPopover(); + expect( + screen.getByRole("button", { name: "Set temp HP" }), + ).toBeDisabled(); + }); + + it("shield button is disabled when input is '0'", async () => { + const user = userEvent.setup(); + renderPopover(); + await user.type(screen.getByPlaceholderText("HP"), "0"); + expect( + screen.getByRole("button", { name: "Set temp HP" }), + ).toBeDisabled(); + }); + }); }); diff --git a/apps/web/src/components/__tests__/turn-navigation.test.tsx b/apps/web/src/components/__tests__/turn-navigation.test.tsx index 70e2082..52fd548 100644 --- a/apps/web/src/components/__tests__/turn-navigation.test.tsx +++ b/apps/web/src/components/__tests__/turn-navigation.test.tsx @@ -46,6 +46,8 @@ function mockContext(overrides: Partial = {}) { setInitiative: vi.fn(), setHp: vi.fn(), adjustHp: vi.fn(), + setTempHp: vi.fn(), + hasTempHp: false, setAc: vi.fn(), toggleCondition: vi.fn(), toggleConcentration: vi.fn(), diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index 94606c1..764ac35 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -29,6 +29,7 @@ interface Combatant { readonly initiative?: number; readonly maxHp?: number; readonly currentHp?: number; + readonly tempHp?: number; readonly ac?: number; readonly conditions?: readonly ConditionId[]; readonly isConcentrating?: boolean; @@ -181,12 +182,18 @@ function MaxHpDisplay({ function ClickableHp({ currentHp, maxHp, + tempHp, + hasTempHp, onAdjust, + onSetTempHp, dimmed, }: Readonly<{ currentHp: number | undefined; maxHp: number | undefined; + tempHp: number | undefined; + hasTempHp: boolean; onAdjust: (delta: number) => void; + onSetTempHp: (value: number) => void; dimmed?: boolean; }>) { const [popoverOpen, setPopoverOpen] = useState(false); @@ -208,11 +215,11 @@ function ClickableHp({ } return ( -
+
+ {!!hasTempHp && ( + + {tempHp ? `+${tempHp}` : ""} + + )} {!!popoverOpen && ( setPopoverOpen(false)} /> )} @@ -443,6 +462,8 @@ export function CombatantRow({ removeCombatant, setHp, adjustHp, + setTempHp, + hasTempHp, setAc, toggleCondition, toggleConcentration, @@ -475,24 +496,27 @@ export function CombatantRow({ const conditionAnchorRef = useRef(null); const prevHpRef = useRef(currentHp); + const prevTempHpRef = useRef(combatant.tempHp); const [isPulsing, setIsPulsing] = useState(false); const pulseTimerRef = useRef>(undefined); useEffect(() => { const prevHp = prevHpRef.current; + const prevTempHp = prevTempHpRef.current; prevHpRef.current = currentHp; + prevTempHpRef.current = combatant.tempHp; - if ( - prevHp !== undefined && - currentHp !== undefined && - currentHp < prevHp && - combatant.isConcentrating - ) { + const realHpDropped = + prevHp !== undefined && currentHp !== undefined && currentHp < prevHp; + const tempHpDropped = + prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp; + + if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) { setIsPulsing(true); clearTimeout(pulseTimerRef.current); pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200); } - }, [currentHp, combatant.isConcentrating]); + }, [currentHp, combatant.tempHp, combatant.isConcentrating]); useEffect(() => { if (!combatant.isConcentrating) { @@ -595,7 +619,10 @@ export function CombatantRow({ adjustHp(id, delta)} + onSetTempHp={(value) => setTempHp(id, value)} dimmed={dimmed} /> {maxHp !== undefined && ( diff --git a/apps/web/src/components/hp-adjust-popover.tsx b/apps/web/src/components/hp-adjust-popover.tsx index 7f73d49..3bcdcbf 100644 --- a/apps/web/src/components/hp-adjust-popover.tsx +++ b/apps/web/src/components/hp-adjust-popover.tsx @@ -1,4 +1,4 @@ -import { Heart, Sword } from "lucide-react"; +import { Heart, ShieldPlus, Sword } from "lucide-react"; import { useCallback, useEffect, @@ -12,10 +12,15 @@ const DIGITS_ONLY_REGEX = /^\d+$/; interface HpAdjustPopoverProps { readonly onAdjust: (delta: number) => void; + readonly onSetTempHp: (value: number) => void; readonly onClose: () => void; } -export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) { +export function HpAdjustPopover({ + onAdjust, + onSetTempHp, + onClose, +}: HpAdjustPopoverProps) { const [inputValue, setInputValue] = useState(""); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const ref = useRef(null); @@ -130,6 +135,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) { > +
); diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index cc69412..0a23967 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -10,6 +10,7 @@ import { setAcUseCase, setHpUseCase, setInitiativeUseCase, + setTempHpUseCase, toggleConcentrationUseCase, toggleConditionUseCase, } from "@initiative/application"; @@ -215,6 +216,19 @@ export function useEncounter() { [makeStore], ); + const setTempHp = useCallback( + (id: CombatantId, tempHp: number | undefined) => { + const result = setTempHpUseCase(makeStore(), id, tempHp); + + if (isDomainError(result)) { + return; + } + + setEvents((prev) => [...prev, ...result]); + }, + [makeStore], + ); + const setAc = useCallback( (id: CombatantId, value: number | undefined) => { const result = setAcUseCase(makeStore(), id, value); @@ -376,6 +390,10 @@ export function useEncounter() { [makeStore], ); + const hasTempHp = encounter.combatants.some( + (c) => c.tempHp !== undefined && c.tempHp > 0, + ); + const isEmpty = encounter.combatants.length === 0; const hasCreatureCombatants = encounter.combatants.some( (c) => c.creatureId != null, @@ -388,6 +406,7 @@ export function useEncounter() { encounter, events, isEmpty, + hasTempHp, hasCreatureCombatants, canRollAllInitiative, advanceTurn, @@ -399,6 +418,7 @@ export function useEncounter() { setInitiative, setHp, adjustHp, + setTempHp, setAc, toggleCondition, toggleConcentration, diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 77ae634..78fc030 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js"; export { setAcUseCase } from "./set-ac-use-case.js"; export { setHpUseCase } from "./set-hp-use-case.js"; export { setInitiativeUseCase } from "./set-initiative-use-case.js"; +export { setTempHpUseCase } from "./set-temp-hp-use-case.js"; export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js"; export { toggleConditionUseCase } from "./toggle-condition-use-case.js"; diff --git a/packages/application/src/set-temp-hp-use-case.ts b/packages/application/src/set-temp-hp-use-case.ts new file mode 100644 index 0000000..8edf9ff --- /dev/null +++ b/packages/application/src/set-temp-hp-use-case.ts @@ -0,0 +1,24 @@ +import { + type CombatantId, + type DomainError, + type DomainEvent, + isDomainError, + setTempHp, +} from "@initiative/domain"; +import type { EncounterStore } from "./ports.js"; + +export function setTempHpUseCase( + store: EncounterStore, + combatantId: CombatantId, + tempHp: number | undefined, +): DomainEvent[] | DomainError { + const encounter = store.get(); + const result = setTempHp(encounter, combatantId, tempHp); + + if (isDomainError(result)) { + return result; + } + + store.save(result.encounter); + return result.events; +} diff --git a/packages/domain/src/__tests__/adjust-hp.test.ts b/packages/domain/src/__tests__/adjust-hp.test.ts index bbf2b2d..c23340c 100644 --- a/packages/domain/src/__tests__/adjust-hp.test.ts +++ b/packages/domain/src/__tests__/adjust-hp.test.ts @@ -6,12 +6,18 @@ import { expectDomainError } from "./test-helpers.js"; function makeCombatant( name: string, - opts?: { maxHp: number; currentHp: number }, + opts?: { maxHp: number; currentHp: number; tempHp?: number }, ): Combatant { return { id: combatantId(name), name, - ...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}), + ...(opts + ? { + maxHp: opts.maxHp, + currentHp: opts.currentHp, + tempHp: opts.tempHp, + } + : {}), }; } @@ -152,4 +158,96 @@ describe("adjustHp", () => { expect(encounter.combatants[0].currentHp).toBe(5); }); }); + + describe("temporary HP absorption", () => { + it("damage fully absorbed by temp HP — currentHp unchanged", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }), + ]); + const { encounter } = successResult(e, "A", -5); + expect(encounter.combatants[0].currentHp).toBe(15); + expect(encounter.combatants[0].tempHp).toBe(3); + }); + + it("damage partially absorbed by temp HP — overflow reduces currentHp", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }), + ]); + const { encounter } = successResult(e, "A", -10); + expect(encounter.combatants[0].tempHp).toBeUndefined(); + expect(encounter.combatants[0].currentHp).toBe(8); + }); + + it("damage exceeding both temp HP and currentHp — both reach minimum", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 5, tempHp: 3 }), + ]); + const { encounter } = successResult(e, "A", -50); + expect(encounter.combatants[0].tempHp).toBeUndefined(); + expect(encounter.combatants[0].currentHp).toBe(0); + }); + + it("healing does not restore temp HP", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }), + ]); + const { encounter } = successResult(e, "A", 5); + expect(encounter.combatants[0].currentHp).toBe(15); + expect(encounter.combatants[0].tempHp).toBe(3); + }); + + it("temp HP cleared to undefined when fully depleted", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 5 }), + ]); + const { encounter } = successResult(e, "A", -5); + expect(encounter.combatants[0].tempHp).toBeUndefined(); + expect(encounter.combatants[0].currentHp).toBe(15); + }); + + it("emits only TempHpSet when damage fully absorbed", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }), + ]); + const { events } = successResult(e, "A", -3); + expect(events).toEqual([ + { + type: "TempHpSet", + combatantId: combatantId("A"), + previousTempHp: 8, + newTempHp: 5, + }, + ]); + }); + + it("emits both TempHpSet and CurrentHpAdjusted when damage overflows", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }), + ]); + const { events } = successResult(e, "A", -10); + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ + type: "TempHpSet", + combatantId: combatantId("A"), + previousTempHp: 3, + newTempHp: undefined, + }); + expect(events[1]).toEqual({ + type: "CurrentHpAdjusted", + combatantId: combatantId("A"), + previousHp: 15, + newHp: 8, + delta: -10, + }); + }); + + it("damage with no temp HP works as before", () => { + const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); + const { encounter, events } = successResult(e, "A", -5); + expect(encounter.combatants[0].currentHp).toBe(10); + expect(encounter.combatants[0].tempHp).toBeUndefined(); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("CurrentHpAdjusted"); + }); + }); }); diff --git a/packages/domain/src/__tests__/set-hp.test.ts b/packages/domain/src/__tests__/set-hp.test.ts index 8fb9fad..6d446d6 100644 --- a/packages/domain/src/__tests__/set-hp.test.ts +++ b/packages/domain/src/__tests__/set-hp.test.ts @@ -69,6 +69,34 @@ describe("setHp", () => { expect(encounter.combatants[0].maxHp).toBeUndefined(); expect(encounter.combatants[0].currentHp).toBeUndefined(); }); + + it("clears tempHp when maxHp is cleared", () => { + const e = enc([ + { + id: combatantId("A"), + name: "A", + maxHp: 20, + currentHp: 15, + tempHp: 5, + }, + ]); + const { encounter } = successResult(e, "A", undefined); + expect(encounter.combatants[0].tempHp).toBeUndefined(); + }); + + it("preserves tempHp when maxHp is updated", () => { + const e = enc([ + { + id: combatantId("A"), + name: "A", + maxHp: 20, + currentHp: 15, + tempHp: 5, + }, + ]); + const { encounter } = successResult(e, "A", 25); + expect(encounter.combatants[0].tempHp).toBe(5); + }); }); describe("invariants", () => { diff --git a/packages/domain/src/__tests__/set-temp-hp.test.ts b/packages/domain/src/__tests__/set-temp-hp.test.ts new file mode 100644 index 0000000..f23856a --- /dev/null +++ b/packages/domain/src/__tests__/set-temp-hp.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { setTempHp } from "../set-temp-hp.js"; +import type { Combatant, Encounter } from "../types.js"; +import { combatantId, isDomainError } from "../types.js"; +import { expectDomainError } from "./test-helpers.js"; + +function makeCombatant( + name: string, + opts?: { maxHp: number; currentHp: number; tempHp?: number }, +): Combatant { + return { + id: combatantId(name), + name, + ...(opts + ? { + maxHp: opts.maxHp, + currentHp: opts.currentHp, + tempHp: opts.tempHp, + } + : {}), + }; +} + +function enc(combatants: Combatant[]): Encounter { + return { combatants, activeIndex: 0, roundNumber: 1 }; +} + +function successResult( + encounter: Encounter, + id: string, + tempHp: number | undefined, +) { + const result = setTempHp(encounter, combatantId(id), tempHp); + if (isDomainError(result)) { + throw new Error(`Expected success, got error: ${result.message}`); + } + return result; +} + +describe("setTempHp", () => { + describe("acceptance scenarios", () => { + it("sets temp HP on a combatant with HP tracking enabled", () => { + const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); + const { encounter } = successResult(e, "A", 8); + expect(encounter.combatants[0].tempHp).toBe(8); + }); + + it("keeps higher value when existing temp HP is greater", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }), + ]); + const { encounter } = successResult(e, "A", 3); + expect(encounter.combatants[0].tempHp).toBe(5); + }); + + it("replaces when new value is higher", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }), + ]); + const { encounter } = successResult(e, "A", 7); + expect(encounter.combatants[0].tempHp).toBe(7); + }); + + it("clears temp HP when set to undefined", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }), + ]); + const { encounter } = successResult(e, "A", undefined); + expect(encounter.combatants[0].tempHp).toBeUndefined(); + }); + }); + + describe("invariants", () => { + it("is pure — same input produces same output", () => { + const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); + const r1 = setTempHp(e, combatantId("A"), 5); + const r2 = setTempHp(e, combatantId("A"), 5); + expect(r1).toEqual(r2); + }); + + it("does not mutate input encounter", () => { + const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); + const original = JSON.parse(JSON.stringify(e)); + setTempHp(e, combatantId("A"), 5); + expect(e).toEqual(original); + }); + + it("emits TempHpSet event with correct shape", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }), + ]); + const { events } = successResult(e, "A", 7); + expect(events).toEqual([ + { + type: "TempHpSet", + combatantId: combatantId("A"), + previousTempHp: 3, + newTempHp: 7, + }, + ]); + }); + + 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", 5); + 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 = setTempHp(e, combatantId("Z"), 5); + expectDomainError(result, "combatant-not-found"); + }); + + it("returns error when HP tracking is not enabled", () => { + const e = enc([makeCombatant("A")]); + const result = setTempHp(e, combatantId("A"), 5); + expectDomainError(result, "no-hp-tracking"); + }); + + it("rejects temp HP of 0", () => { + const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); + const result = setTempHp(e, combatantId("A"), 0); + expectDomainError(result, "invalid-temp-hp"); + }); + + it("rejects negative temp HP", () => { + const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); + const result = setTempHp(e, combatantId("A"), -3); + expectDomainError(result, "invalid-temp-hp"); + }); + + it("rejects non-integer temp HP", () => { + const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]); + const result = setTempHp(e, combatantId("A"), 2.5); + expectDomainError(result, "invalid-temp-hp"); + }); + }); + + describe("edge cases", () => { + it("does not affect other combatants", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 15 }), + makeCombatant("B", { maxHp: 30, currentHp: 25, tempHp: 4 }), + ]); + const { encounter } = successResult(e, "A", 5); + expect(encounter.combatants[1].tempHp).toBe(4); + }); + + it("does not affect currentHp or maxHp", () => { + const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]); + const { encounter } = successResult(e, "A", 8); + expect(encounter.combatants[0].maxHp).toBe(20); + expect(encounter.combatants[0].currentHp).toBe(15); + }); + + it("event reflects no change when existing value equals new value", () => { + const e = enc([ + makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }), + ]); + const { events } = successResult(e, "A", 5); + expect(events).toEqual([ + { + type: "TempHpSet", + combatantId: combatantId("A"), + previousTempHp: 5, + newTempHp: 5, + }, + ]); + }); + }); +}); diff --git a/packages/domain/src/adjust-hp.ts b/packages/domain/src/adjust-hp.ts index 396d419..62a9cdd 100644 --- a/packages/domain/src/adjust-hp.ts +++ b/packages/domain/src/adjust-hp.ts @@ -54,24 +54,52 @@ export function adjustHp( } const previousHp = target.currentHp; - const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta)); + const previousTempHp = target.tempHp ?? 0; + let newTempHp = previousTempHp; + let effectiveDelta = delta; + + if (delta < 0 && previousTempHp > 0) { + const absorbed = Math.min(previousTempHp, Math.abs(delta)); + newTempHp = previousTempHp - absorbed; + effectiveDelta = delta + absorbed; + } + + const newHp = Math.max( + 0, + Math.min(target.maxHp, previousHp + effectiveDelta), + ); + + const events: DomainEvent[] = []; + + if (newTempHp !== previousTempHp) { + events.push({ + type: "TempHpSet", + combatantId, + previousTempHp: previousTempHp || undefined, + newTempHp: newTempHp || undefined, + }); + } + + if (newHp !== previousHp) { + events.push({ + type: "CurrentHpAdjusted", + combatantId, + previousHp, + newHp, + delta, + }); + } return { encounter: { combatants: encounter.combatants.map((c) => - c.id === combatantId ? { ...c, currentHp: newHp } : c, + c.id === combatantId + ? { ...c, currentHp: newHp, tempHp: newTempHp || undefined } + : c, ), activeIndex: encounter.activeIndex, roundNumber: encounter.roundNumber, }, - events: [ - { - type: "CurrentHpAdjusted", - combatantId, - previousHp, - newHp, - delta, - }, - ], + events, }; } diff --git a/packages/domain/src/events.ts b/packages/domain/src/events.ts index c2f9c61..1e0dac8 100644 --- a/packages/domain/src/events.ts +++ b/packages/domain/src/events.ts @@ -58,6 +58,13 @@ export interface CurrentHpAdjusted { readonly delta: number; } +export interface TempHpSet { + readonly type: "TempHpSet"; + readonly combatantId: CombatantId; + readonly previousTempHp: number | undefined; + readonly newTempHp: number | undefined; +} + export interface TurnRetreated { readonly type: "TurnRetreated"; readonly previousCombatantId: CombatantId; @@ -132,6 +139,7 @@ export type DomainEvent = | InitiativeSet | MaxHpSet | CurrentHpAdjusted + | TempHpSet | TurnRetreated | RoundRetreated | AcSet diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index e82864d..c48ab2d 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -60,6 +60,7 @@ export type { PlayerCharacterUpdated, RoundAdvanced, RoundRetreated, + TempHpSet, TurnAdvanced, TurnRetreated, } from "./events.js"; @@ -95,6 +96,7 @@ export { type SetInitiativeSuccess, setInitiative, } from "./set-initiative.js"; +export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js"; export { type ToggleConcentrationSuccess, toggleConcentration, diff --git a/packages/domain/src/set-hp.ts b/packages/domain/src/set-hp.ts index 7c5a3c2..3edc00c 100644 --- a/packages/domain/src/set-hp.ts +++ b/packages/domain/src/set-hp.ts @@ -66,7 +66,12 @@ export function setHp( encounter: { combatants: encounter.combatants.map((c) => c.id === combatantId - ? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp } + ? { + ...c, + maxHp: newMaxHp, + currentHp: newCurrentHp, + tempHp: newMaxHp === undefined ? undefined : c.tempHp, + } : c, ), activeIndex: encounter.activeIndex, diff --git a/packages/domain/src/set-temp-hp.ts b/packages/domain/src/set-temp-hp.ts new file mode 100644 index 0000000..9233ea1 --- /dev/null +++ b/packages/domain/src/set-temp-hp.ts @@ -0,0 +1,78 @@ +import type { DomainEvent } from "./events.js"; +import type { CombatantId, DomainError, Encounter } from "./types.js"; + +export interface SetTempHpSuccess { + readonly encounter: Encounter; + readonly events: DomainEvent[]; +} + +/** + * Pure function that sets or clears a combatant's temporary HP. + * + * - Setting tempHp when the combatant already has tempHp keeps the higher value. + * - Clearing tempHp (undefined) removes temp HP entirely. + * - Requires HP tracking to be enabled (maxHp must be set). + */ +export function setTempHp( + encounter: Encounter, + combatantId: CombatantId, + tempHp: number | undefined, +): SetTempHpSuccess | 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 (tempHp !== undefined && (!Number.isInteger(tempHp) || tempHp < 1)) { + return { + kind: "domain-error", + code: "invalid-temp-hp", + message: `Temp HP must be a positive integer, got ${tempHp}`, + }; + } + + const previousTempHp = target.tempHp; + + // Higher value wins when both are defined + let newTempHp: number | undefined; + if (tempHp === undefined) { + newTempHp = undefined; + } else if (previousTempHp === undefined) { + newTempHp = tempHp; + } else { + newTempHp = Math.max(previousTempHp, tempHp); + } + + return { + encounter: { + combatants: encounter.combatants.map((c) => + c.id === combatantId ? { ...c, tempHp: newTempHp } : c, + ), + activeIndex: encounter.activeIndex, + roundNumber: encounter.roundNumber, + }, + events: [ + { + type: "TempHpSet", + combatantId, + previousTempHp, + newTempHp, + }, + ], + }; +} diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts index 0ef5f29..8cbfb2e 100644 --- a/packages/domain/src/types.ts +++ b/packages/domain/src/types.ts @@ -15,6 +15,7 @@ export interface Combatant { readonly initiative?: number; readonly maxHp?: number; readonly currentHp?: number; + readonly tempHp?: number; readonly ac?: number; readonly conditions?: readonly ConditionId[]; readonly isConcentrating?: boolean; diff --git a/specs/003-combatant-state/spec.md b/specs/003-combatant-state/spec.md index 884cbce..b32e18b 100644 --- a/specs/003-combatant-state/spec.md +++ b/specs/003-combatant-state/spec.md @@ -21,6 +21,7 @@ interface Combatant { readonly initiative?: number; // integer, undefined = unset readonly maxHp?: number; // positive integer readonly currentHp?: number; // 0..maxHp + readonly tempHp?: number; // positive integer, damage buffer readonly ac?: number; // non-negative integer readonly conditions?: readonly ConditionId[]; readonly isConcentrating?: boolean; @@ -96,6 +97,19 @@ As a game master, I want HP values to survive page reloads so that I do not lose Acceptance scenarios: 1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly. +**Story HP-7 — Temporary Hit Points (P1)** +As a game master, I want to grant temporary HP to a combatant so that I can track damage buffers from spells like Heroism or False Life without manual bookkeeping. + +Acceptance scenarios: +1. **Given** a combatant has 15/20 HP and no temp HP, **When** the user sets 8 temp HP via the popover, **Then** the combatant displays `15+8 / 20`. +2. **Given** a combatant has 15+8/20 HP, **When** 5 damage is dealt, **Then** temp HP decreases to 3 and current HP remains 15 → display `15+3 / 20`. +3. **Given** a combatant has 15+3/20 HP, **When** 10 damage is dealt, **Then** temp HP is fully consumed (3 absorbed) and current HP decreases by the remaining 7 → display `8 / 20`. +4. **Given** a combatant has 15+5/20 HP, **When** 8 healing is applied, **Then** current HP increases to 20 and temp HP remains 5 → display `20+5 / 20`. +5. **Given** a combatant has 10+5/20 HP, **When** the user sets 3 temp HP, **Then** temp HP remains 5 (higher value kept). +6. **Given** a combatant has 10+3/20 HP, **When** the user sets 7 temp HP, **Then** temp HP becomes 7. +7. **Given** no combatant has temp HP, **When** viewing the encounter, **Then** no extra space is reserved for temp HP display. +8. **Given** one combatant has temp HP, **When** viewing the encounter, **Then** all rows reserve space for the temp HP display to maintain column alignment. + ### Requirements - **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant. @@ -120,6 +134,15 @@ Acceptance scenarios: - **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`. - **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set. - **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay. +- **FR-023**: Each combatant MAY have an optional `tempHp` value (positive integer >= 1). Temp HP is independent of regular HP tracking but requires HP tracking to be enabled. +- **FR-024**: When damage is applied, temp HP MUST absorb damage first. Any remaining damage after temp HP is depleted MUST reduce `currentHp`. +- **FR-025**: Healing MUST NOT restore temp HP. Healing applies only to `currentHp`. +- **FR-026**: When setting temp HP on a combatant that already has temp HP, the system MUST keep the higher of the two values. +- **FR-027**: When `maxHp` is cleared (HP tracking disabled), `tempHp` MUST also be cleared. +- **FR-028**: The temp HP value MUST be displayed as a cyan `+N` immediately after the current HP value, only when temp HP > 0. +- **FR-029**: When any combatant in the encounter has temp HP > 0, all rows MUST reserve space for the temp HP display to maintain column alignment. When no combatant has temp HP, no space is reserved. +- **FR-030**: The HP adjustment popover MUST include a third button (Shield icon) for setting temp HP. +- **FR-031**: Temp HP MUST persist across page reloads via the existing persistence mechanism. ### Edge Cases @@ -131,7 +154,10 @@ Acceptance scenarios: - Submitting an empty delta input applies no change; the input remains ready. - When the user rapidly applies multiple deltas, each is applied sequentially; none are lost. - HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown. -- There is no temporary HP in the MVP baseline. +- Setting temp HP to 0 or clearing it removes temp HP entirely. +- Temp HP does not affect `HpStatus` derivation — a combatant with 5 current HP, 5 temp HP, and 20 max HP is still bloodied. +- When a concentrating combatant takes damage, the concentration pulse MUST trigger regardless of whether temp HP absorbs the damage — "taking damage" is the trigger, not losing real HP. +- A combatant at 0 currentHp with temp HP remaining is still unconscious. - There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only. - There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline. - There is no undo/redo for HP changes in the MVP baseline.