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.