Files
initiative/packages/domain/src/__tests__/set-hp-variant.test.ts
T
LukasandClaude Opus 5 be3778a0c9 Add min/max hit point variants for D&D creatures
A D&D stat block opened for a combatant now offers a Min/Avg/Max toggle
below the Hit Points line, deriving both ends from the Hit Dice formula
(3d6 + 6 -> 9 / 16 / 24). The switch is applied as a delta, so manual HP
edits and damage already taken survive it. Unlike the PF2e weak/elite
adjustment this is a convenience tool, not a rules mechanic: nothing but
HP changes and the combatant is not renamed. The toggle is hidden when
the HP field carries prose instead of a dice pool.

Also extracts a SegmentedControl primitive. Game system, theme and the
PF2e weak/elite toggle were three hand-rolled copies of the same markup,
which is why the D&D toggle first came out chunkier and in the wrong
accent color. All four now share one definition, and segments expose
aria-pressed instead of signalling the active state by color alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:06:01 +02:00

160 lines
4.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { HpRange, HpVariant } from "../hp-range.js";
import { setHpVariant } from "../set-hp-variant.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
import { expectDomainError } from "./test-helpers.js";
// A 3d6 + 6 creature: 9 / 16 / 24.
const RANGE: HpRange = { min: 9, average: 16, max: 24 };
function makeCombatant(opts?: Partial<Combatant>): Combatant {
return {
id: combatantId("c-1"),
name: "Ogre",
maxHp: 16,
currentHp: 16,
...opts,
};
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function apply(
encounter: Encounter,
variant: HpVariant | undefined,
range: HpRange = RANGE,
) {
const result = setHpVariant(encounter, combatantId("c-1"), variant, range);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setHpVariant", () => {
describe("acceptance scenarios", () => {
it("average → max raises maxHp and currentHp to the maximum roll", () => {
const { encounter } = apply(enc([makeCombatant()]), "max");
const c = encounter.combatants[0];
expect(c.maxHp).toBe(24);
expect(c.currentHp).toBe(24);
expect(c.hpVariant).toBe("max");
});
it("average → min lowers maxHp and currentHp to the minimum roll", () => {
const { encounter } = apply(enc([makeCombatant()]), "min");
const c = encounter.combatants[0];
expect(c.maxHp).toBe(9);
expect(c.currentHp).toBe(9);
expect(c.hpVariant).toBe("min");
});
it("max → average restores the printed average", () => {
const start = enc([
makeCombatant({ maxHp: 24, currentHp: 24, hpVariant: "max" }),
]);
const { encounter } = apply(start, undefined);
const c = encounter.combatants[0];
expect(c.maxHp).toBe(16);
expect(c.currentHp).toBe(16);
expect(c.hpVariant).toBeUndefined();
});
it("min → max spans the full range in one step", () => {
const start = enc([
makeCombatant({ maxHp: 9, currentHp: 9, hpVariant: "min" }),
]);
const { encounter } = apply(start, "max");
expect(encounter.combatants[0].maxHp).toBe(24);
});
});
describe("damage already taken", () => {
it("keeps the amount of damage taken when raising max HP", () => {
// 5 damage taken: 11/16 → 19/24
const start = enc([makeCombatant({ maxHp: 16, currentHp: 11 })]);
const { encounter } = apply(start, "max");
const c = encounter.combatants[0];
expect(c.maxHp).toBe(24);
expect(c.currentHp).toBe(19);
});
it("floors currentHp at 0 when lowering max HP past the damage taken", () => {
const start = enc([makeCombatant({ maxHp: 16, currentHp: 3 })]);
const { encounter } = apply(start, "min");
const c = encounter.combatants[0];
expect(c.maxHp).toBe(9);
expect(c.currentHp).toBe(0);
});
it("clamps currentHp to the new maxHp", () => {
const start = enc([makeCombatant({ maxHp: 100, currentHp: 100 })]);
const { encounter } = apply(start, "min");
const c = encounter.combatants[0];
expect(c.maxHp).toBe(93);
expect(c.currentHp).toBe(93);
});
});
describe("edge cases", () => {
it("preserves a manual max HP edit as an offset", () => {
// User bumped the ogre to 20 HP; max is 8 above average.
const start = enc([makeCombatant({ maxHp: 20, currentHp: 20 })]);
const { encounter } = apply(start, "max");
expect(encounter.combatants[0].maxHp).toBe(28);
});
it("never lets maxHp drop below 1", () => {
const start = enc([makeCombatant({ maxHp: 2, currentHp: 2 })]);
const { encounter } = apply(start, "min");
expect(encounter.combatants[0].maxHp).toBe(1);
});
it("leaves a combatant without HP untouched but records the variant", () => {
const start = enc([
makeCombatant({ maxHp: undefined, currentHp: undefined }),
]);
const { encounter } = apply(start, "max");
const c = encounter.combatants[0];
expect(c.maxHp).toBeUndefined();
expect(c.currentHp).toBeUndefined();
expect(c.hpVariant).toBe("max");
});
it("is a no-op when the variant is already active", () => {
const start = enc([
makeCombatant({ maxHp: 24, currentHp: 20, hpVariant: "max" }),
]);
const { encounter, events } = apply(start, "max");
expect(encounter.combatants[0].currentHp).toBe(20);
expect(events).toEqual([]);
});
it("returns a domain error for an unknown combatant", () => {
const result = setHpVariant(
enc([makeCombatant()]),
combatantId("c-99"),
"max",
RANGE,
);
expectDomainError(result, "combatant-not-found");
});
});
it("emits an HpVariantSet event carrying the HP change", () => {
const { events } = apply(enc([makeCombatant()]), "max");
expect(events).toEqual([
{
type: "HpVariantSet",
combatantId: combatantId("c-1"),
variant: "max",
previousMaxHp: 16,
newMaxHp: 24,
},
]);
});
});