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