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>
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
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,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
});
|