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) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-23 11:39:47 +01:00
parent 7b83e3c3ea
commit 8bf69fd47d
18 changed files with 731 additions and 29 deletions

View File

@@ -9,9 +9,12 @@ import { AllProviders } from "../../__tests__/test-providers.js";
import { CombatantRow } from "../combatant-row.js"; import { CombatantRow } from "../combatant-row.js";
import { PLAYER_COLOR_HEX } from "../player-icon-map.js"; import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
const TEMP_HP_REGEX = /^\+\d/;
// Mock persistence — no localStorage interaction // Mock persistence — no localStorage interaction
const mockLoadEncounter = vi.fn<() => unknown>(() => null);
vi.mock("../../persistence/encounter-storage.js", () => ({ vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => null, loadEncounter: () => mockLoadEncounter(),
saveEncounter: () => {}, saveEncounter: () => {},
})); }));
@@ -193,4 +196,120 @@ describe("CombatantRow", () => {
screen.getByRole("button", { name: "Roll initiative" }), screen.getByRole("button", { name: "Roll initiative" }),
).toBeInTheDocument(); ).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(
<CombatantRow
combatant={{ ...combatant, currentHp: 10 }}
isActive={false}
/>,
);
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(
<CombatantRow
combatant={{ ...combatant, currentHp: 10 }}
isActive={false}
/>,
);
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(
<CombatantRow
combatant={{ ...combatant, tempHp: 3 }}
isActive={false}
/>,
);
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");
});
});
}); });

View File

@@ -11,15 +11,21 @@ afterEach(cleanup);
function renderPopover( function renderPopover(
overrides: Partial<{ overrides: Partial<{
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void;
onClose: () => void; onClose: () => void;
}> = {}, }> = {},
) { ) {
const onAdjust = overrides.onAdjust ?? vi.fn(); const onAdjust = overrides.onAdjust ?? vi.fn();
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn(); const onClose = overrides.onClose ?? vi.fn();
const result = render( const result = render(
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />, <HpAdjustPopover
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={onClose}
/>,
); );
return { ...result, onAdjust, onClose }; return { ...result, onAdjust, onSetTempHp, onClose };
} }
describe("HpAdjustPopover", () => { describe("HpAdjustPopover", () => {
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
await user.type(input, "12abc34"); await user.type(input, "12abc34");
expect(input).toHaveValue("1234"); 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();
});
});
}); });

View File

@@ -46,6 +46,8 @@ function mockContext(overrides: Partial<Encounter> = {}) {
setInitiative: vi.fn(), setInitiative: vi.fn(),
setHp: vi.fn(), setHp: vi.fn(),
adjustHp: vi.fn(), adjustHp: vi.fn(),
setTempHp: vi.fn(),
hasTempHp: false,
setAc: vi.fn(), setAc: vi.fn(),
toggleCondition: vi.fn(), toggleCondition: vi.fn(),
toggleConcentration: vi.fn(), toggleConcentration: vi.fn(),

View File

@@ -29,6 +29,7 @@ interface Combatant {
readonly initiative?: number; readonly initiative?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly currentHp?: number; readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
@@ -181,12 +182,18 @@ function MaxHpDisplay({
function ClickableHp({ function ClickableHp({
currentHp, currentHp,
maxHp, maxHp,
tempHp,
hasTempHp,
onAdjust, onAdjust,
onSetTempHp,
dimmed, dimmed,
}: Readonly<{ }: Readonly<{
currentHp: number | undefined; currentHp: number | undefined;
maxHp: number | undefined; maxHp: number | undefined;
tempHp: number | undefined;
hasTempHp: boolean;
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void;
dimmed?: boolean; dimmed?: boolean;
}>) { }>) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
@@ -208,11 +215,11 @@ function ClickableHp({
} }
return ( return (
<div className="relative"> <div className="relative flex items-center">
<button <button
type="button" type="button"
onClick={() => setPopoverOpen(true)} onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp} (${status})`} aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
className={cn( className={cn(
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral", "inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400", status === "bloodied" && "text-amber-400",
@@ -223,9 +230,21 @@ function ClickableHp({
> >
{currentHp} {currentHp}
</button> </button>
{!!hasTempHp && (
<span
className={cn(
"inline-block min-w-[3ch] text-center text-sm tabular-nums leading-7",
tempHp ? "font-medium text-cyan-400" : "invisible",
dimmed && "opacity-50",
)}
>
{tempHp ? `+${tempHp}` : ""}
</span>
)}
{!!popoverOpen && ( {!!popoverOpen && (
<HpAdjustPopover <HpAdjustPopover
onAdjust={onAdjust} onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={() => setPopoverOpen(false)} onClose={() => setPopoverOpen(false)}
/> />
)} )}
@@ -443,6 +462,8 @@ export function CombatantRow({
removeCombatant, removeCombatant,
setHp, setHp,
adjustHp, adjustHp,
setTempHp,
hasTempHp,
setAc, setAc,
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,
@@ -475,24 +496,27 @@ export function CombatantRow({
const conditionAnchorRef = useRef<HTMLDivElement>(null); const conditionAnchorRef = useRef<HTMLDivElement>(null);
const prevHpRef = useRef(currentHp); const prevHpRef = useRef(currentHp);
const prevTempHpRef = useRef(combatant.tempHp);
const [isPulsing, setIsPulsing] = useState(false); const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => { useEffect(() => {
const prevHp = prevHpRef.current; const prevHp = prevHpRef.current;
const prevTempHp = prevTempHpRef.current;
prevHpRef.current = currentHp; prevHpRef.current = currentHp;
prevTempHpRef.current = combatant.tempHp;
if ( const realHpDropped =
prevHp !== undefined && prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
currentHp !== undefined && const tempHpDropped =
currentHp < prevHp && prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
combatant.isConcentrating
) { if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
setIsPulsing(true); setIsPulsing(true);
clearTimeout(pulseTimerRef.current); clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200); pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
} }
}, [currentHp, combatant.isConcentrating]); }, [currentHp, combatant.tempHp, combatant.isConcentrating]);
useEffect(() => { useEffect(() => {
if (!combatant.isConcentrating) { if (!combatant.isConcentrating) {
@@ -595,7 +619,10 @@ export function CombatantRow({
<ClickableHp <ClickableHp
currentHp={currentHp} currentHp={currentHp}
maxHp={maxHp} maxHp={maxHp}
tempHp={combatant.tempHp}
hasTempHp={hasTempHp}
onAdjust={(delta) => adjustHp(id, delta)} onAdjust={(delta) => adjustHp(id, delta)}
onSetTempHp={(value) => setTempHp(id, value)}
dimmed={dimmed} dimmed={dimmed}
/> />
{maxHp !== undefined && ( {maxHp !== undefined && (

View File

@@ -1,4 +1,4 @@
import { Heart, Sword } from "lucide-react"; import { Heart, ShieldPlus, Sword } from "lucide-react";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -12,10 +12,15 @@ const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps { interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void; readonly onAdjust: (delta: number) => void;
readonly onSetTempHp: (value: number) => void;
readonly onClose: () => void; readonly onClose: () => void;
} }
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) { export function HpAdjustPopover({
onAdjust,
onSetTempHp,
onClose,
}: HpAdjustPopoverProps) {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -130,6 +135,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
> >
<Heart size={14} /> <Heart size={14} />
</button> </button>
<button
type="button"
disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => {
if (isValid && parsedValue) {
onSetTempHp(parsedValue);
onClose();
}
}}
title="Set temp HP"
aria-label="Set temp HP"
>
<ShieldPlus size={14} />
</button>
</div> </div>
</div> </div>
); );

View File

@@ -10,6 +10,7 @@ import {
setAcUseCase, setAcUseCase,
setHpUseCase, setHpUseCase,
setInitiativeUseCase, setInitiativeUseCase,
setTempHpUseCase,
toggleConcentrationUseCase, toggleConcentrationUseCase,
toggleConditionUseCase, toggleConditionUseCase,
} from "@initiative/application"; } from "@initiative/application";
@@ -215,6 +216,19 @@ export function useEncounter() {
[makeStore], [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( const setAc = useCallback(
(id: CombatantId, value: number | undefined) => { (id: CombatantId, value: number | undefined) => {
const result = setAcUseCase(makeStore(), id, value); const result = setAcUseCase(makeStore(), id, value);
@@ -376,6 +390,10 @@ export function useEncounter() {
[makeStore], [makeStore],
); );
const hasTempHp = encounter.combatants.some(
(c) => c.tempHp !== undefined && c.tempHp > 0,
);
const isEmpty = encounter.combatants.length === 0; const isEmpty = encounter.combatants.length === 0;
const hasCreatureCombatants = encounter.combatants.some( const hasCreatureCombatants = encounter.combatants.some(
(c) => c.creatureId != null, (c) => c.creatureId != null,
@@ -388,6 +406,7 @@ export function useEncounter() {
encounter, encounter,
events, events,
isEmpty, isEmpty,
hasTempHp,
hasCreatureCombatants, hasCreatureCombatants,
canRollAllInitiative, canRollAllInitiative,
advanceTurn, advanceTurn,
@@ -399,6 +418,7 @@ export function useEncounter() {
setInitiative, setInitiative,
setHp, setHp,
adjustHp, adjustHp,
setTempHp,
setAc, setAc,
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,

View File

@@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js"; export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-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";
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js"; export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
export { toggleConditionUseCase } from "./toggle-condition-use-case.js"; export { toggleConditionUseCase } from "./toggle-condition-use-case.js";

View File

@@ -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;
}

View File

@@ -6,12 +6,18 @@ import { expectDomainError } from "./test-helpers.js";
function makeCombatant( function makeCombatant(
name: string, name: string,
opts?: { maxHp: number; currentHp: number }, opts?: { maxHp: number; currentHp: number; tempHp?: number },
): Combatant { ): Combatant {
return { return {
id: combatantId(name), id: combatantId(name),
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); 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");
});
});
}); });

View File

@@ -69,6 +69,34 @@ describe("setHp", () => {
expect(encounter.combatants[0].maxHp).toBeUndefined(); expect(encounter.combatants[0].maxHp).toBeUndefined();
expect(encounter.combatants[0].currentHp).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", () => { describe("invariants", () => {

View File

@@ -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,
},
]);
});
});
});

View File

@@ -54,24 +54,52 @@ export function adjustHp(
} }
const previousHp = target.currentHp; 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 { return {
encounter: { encounter: {
combatants: encounter.combatants.map((c) => 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, activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber, roundNumber: encounter.roundNumber,
}, },
events: [ events,
{
type: "CurrentHpAdjusted",
combatantId,
previousHp,
newHp,
delta,
},
],
}; };
} }

View File

@@ -58,6 +58,13 @@ export interface CurrentHpAdjusted {
readonly delta: number; readonly delta: number;
} }
export interface TempHpSet {
readonly type: "TempHpSet";
readonly combatantId: CombatantId;
readonly previousTempHp: number | undefined;
readonly newTempHp: number | undefined;
}
export interface TurnRetreated { export interface TurnRetreated {
readonly type: "TurnRetreated"; readonly type: "TurnRetreated";
readonly previousCombatantId: CombatantId; readonly previousCombatantId: CombatantId;
@@ -132,6 +139,7 @@ export type DomainEvent =
| InitiativeSet | InitiativeSet
| MaxHpSet | MaxHpSet
| CurrentHpAdjusted | CurrentHpAdjusted
| TempHpSet
| TurnRetreated | TurnRetreated
| RoundRetreated | RoundRetreated
| AcSet | AcSet

View File

@@ -60,6 +60,7 @@ export type {
PlayerCharacterUpdated, PlayerCharacterUpdated,
RoundAdvanced, RoundAdvanced,
RoundRetreated, RoundRetreated,
TempHpSet,
TurnAdvanced, TurnAdvanced,
TurnRetreated, TurnRetreated,
} from "./events.js"; } from "./events.js";
@@ -95,6 +96,7 @@ export {
type SetInitiativeSuccess, type SetInitiativeSuccess,
setInitiative, setInitiative,
} from "./set-initiative.js"; } from "./set-initiative.js";
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
export { export {
type ToggleConcentrationSuccess, type ToggleConcentrationSuccess,
toggleConcentration, toggleConcentration,

View File

@@ -66,7 +66,12 @@ export function setHp(
encounter: { encounter: {
combatants: encounter.combatants.map((c) => combatants: encounter.combatants.map((c) =>
c.id === combatantId c.id === combatantId
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp } ? {
...c,
maxHp: newMaxHp,
currentHp: newCurrentHp,
tempHp: newMaxHp === undefined ? undefined : c.tempHp,
}
: c, : c,
), ),
activeIndex: encounter.activeIndex, activeIndex: encounter.activeIndex,

View File

@@ -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,
},
],
};
}

View File

@@ -15,6 +15,7 @@ export interface Combatant {
readonly initiative?: number; readonly initiative?: number;
readonly maxHp?: number; readonly maxHp?: number;
readonly currentHp?: number; readonly currentHp?: number;
readonly tempHp?: number;
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;

View File

@@ -21,6 +21,7 @@ interface Combatant {
readonly initiative?: number; // integer, undefined = unset readonly initiative?: number; // integer, undefined = unset
readonly maxHp?: number; // positive integer readonly maxHp?: number; // positive integer
readonly currentHp?: number; // 0..maxHp readonly currentHp?: number; // 0..maxHp
readonly tempHp?: number; // positive integer, damage buffer
readonly ac?: number; // non-negative integer readonly ac?: number; // non-negative integer
readonly conditions?: readonly ConditionId[]; readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean; 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: 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. 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 ### Requirements
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant. - **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-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-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-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 ### Edge Cases
@@ -131,7 +154,10 @@ Acceptance scenarios:
- Submitting an empty delta input applies no change; the input remains ready. - 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. - 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. - 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 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 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. - There is no undo/redo for HP changes in the MVP baseline.