+
+ {!!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.