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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
78
packages/domain/src/set-temp-hp.ts
Normal file
78
packages/domain/src/set-temp-hp.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user