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 { 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(
<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(
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(
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
<HpAdjustPopover
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={onClose}
/>,
);
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();
});
});
});

View File

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

View File

@@ -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 (
<div className="relative">
<div className="relative flex items-center">
<button
type="button"
onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp} (${status})`}
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
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",
status === "bloodied" && "text-amber-400",
@@ -223,9 +230,21 @@ function ClickableHp({
>
{currentHp}
</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 && (
<HpAdjustPopover
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={() => 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<HTMLDivElement>(null);
const prevHpRef = useRef(currentHp);
const prevTempHpRef = useRef(combatant.tempHp);
const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(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({
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
tempHp={combatant.tempHp}
hasTempHp={hasTempHp}
onAdjust={(delta) => adjustHp(id, delta)}
onSetTempHp={(value) => setTempHp(id, value)}
dimmed={dimmed}
/>
{maxHp !== undefined && (

View File

@@ -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<HTMLDivElement>(null);
@@ -130,6 +135,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
>
<Heart size={14} />
</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>
);

View File

@@ -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,