Files
initiative/packages/domain/src/__tests__/adjust-hp.test.ts
Lukas 8bf69fd47d 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>
2026-03-23 11:39:47 +01:00

254 lines
8.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { adjustHp } from "../adjust-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, delta: number) {
const result = adjustHp(encounter, combatantId(id), delta);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("adjustHp", () => {
describe("acceptance scenarios", () => {
it("+1 increases currentHp by 1", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", 1);
expect(encounter.combatants[0].currentHp).toBe(16);
});
it("-1 decreases currentHp by 1", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { encounter } = successResult(e, "A", -1);
expect(encounter.combatants[0].currentHp).toBe(14);
});
it("clamps at 0 — cannot go below zero", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 3 })]);
const { encounter } = successResult(e, "A", -10);
expect(encounter.combatants[0].currentHp).toBe(0);
});
it("clamps at maxHp — cannot exceed max", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 18 })]);
const { encounter } = successResult(e, "A", 10);
expect(encounter.combatants[0].currentHp).toBe(20);
});
});
describe("invariants", () => {
it("is pure — same input produces same output", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const r1 = adjustHp(e, combatantId("A"), -5);
const r2 = adjustHp(e, combatantId("A"), -5);
expect(r1).toEqual(r2);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const original = JSON.parse(JSON.stringify(e));
adjustHp(e, combatantId("A"), -3);
expect(e).toEqual(original);
});
it("emits CurrentHpAdjusted event with delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
const { events } = successResult(e, "A", -5);
expect(events).toEqual([
{
type: "CurrentHpAdjusted",
combatantId: combatantId("A"),
previousHp: 15,
newHp: 10,
delta: -5,
},
]);
});
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", -3);
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 = adjustHp(e, combatantId("Z"), -1);
expectDomainError(result, "combatant-not-found");
});
it("returns error when combatant has no HP tracking", () => {
const e = enc([makeCombatant("A")]);
const result = adjustHp(e, combatantId("A"), -1);
expectDomainError(result, "no-hp-tracking");
});
it("returns error for zero delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 0);
expectDomainError(result, "zero-delta");
});
it("returns error for non-integer delta", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
const result = adjustHp(e, combatantId("A"), 1.5);
expectDomainError(result, "invalid-delta");
});
});
describe("edge cases", () => {
it("large negative delta beyond currentHp clamps to 0", () => {
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
const { encounter } = successResult(e, "A", -9999);
expect(encounter.combatants[0].currentHp).toBe(0);
});
it("large positive delta beyond maxHp clamps to maxHp", () => {
const e = enc([makeCombatant("A", { maxHp: 100, currentHp: 50 })]);
const { encounter } = successResult(e, "A", 9999);
expect(encounter.combatants[0].currentHp).toBe(100);
});
it("does not affect other combatants", () => {
const e = enc([
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
makeCombatant("B", { maxHp: 30, currentHp: 25 }),
]);
const { encounter } = successResult(e, "A", -5);
expect(encounter.combatants[1].currentHp).toBe(25);
});
it("adjusting from 0 upward works", () => {
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 0 })]);
const { encounter } = successResult(e, "A", 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");
});
});
});