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>
This commit is contained in:
Lukas
2026-08-05 17:06:01 +02:00
co-authored by Claude Opus 5
parent 7b599e9ad9
commit be3778a0c9
22 changed files with 942 additions and 88 deletions
@@ -0,0 +1,95 @@
import type { HpRange, HpVariant } from "@initiative/domain";
import {
combatantId,
creatureId,
EMPTY_UNDO_REDO_STATE,
} from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { type EncounterState, encounterReducer } from "../use-encounter.js";
// An ogre with 3d6 + 6 hit points.
const RANGE: HpRange = { min: 9, average: 16, max: 24 };
function stateWithCreature(
maxHp: number,
currentHp: number,
hpVariant?: HpVariant,
): EncounterState {
return {
encounter: {
combatants: [
{
id: combatantId("c-1"),
name: "Ogre",
maxHp,
currentHp,
creatureId: creatureId("mm:ogre"),
...(hpVariant !== undefined && { hpVariant }),
},
],
activeIndex: 0,
roundNumber: 1,
},
undoRedoState: EMPTY_UNDO_REDO_STATE,
events: [],
nextId: 1,
lastCreatureId: null,
};
}
function setVariant(state: EncounterState, variant: HpVariant | undefined) {
return encounterReducer(state, {
type: "set-hp-variant",
id: combatantId("c-1"),
variant,
range: RANGE,
});
}
describe("set-hp-variant", () => {
it("raises max HP to the maximum roll and stores the variant", () => {
const next = setVariant(stateWithCreature(16, 16), "max");
const c = next.encounter.combatants[0];
expect(c.maxHp).toBe(24);
expect(c.currentHp).toBe(24);
expect(c.hpVariant).toBe("max");
});
it("lowers max HP to the minimum roll", () => {
const next = setVariant(stateWithCreature(16, 16), "min");
const c = next.encounter.combatants[0];
expect(c.maxHp).toBe(9);
expect(c.hpVariant).toBe("min");
});
it("returns to the average and clears the variant", () => {
const next = setVariant(stateWithCreature(24, 24, "max"), undefined);
const c = next.encounter.combatants[0];
expect(c.maxHp).toBe(16);
expect(c.hpVariant).toBeUndefined();
});
it("does not rename the combatant", () => {
const next = setVariant(stateWithCreature(16, 16), "max");
expect(next.encounter.combatants[0].name).toBe("Ogre");
});
it("is undoable", () => {
const start = stateWithCreature(16, 16);
const next = setVariant(start, "max");
const undone = encounterReducer(next, { type: "undo" });
expect(undone.encounter.combatants[0].maxHp).toBe(16);
expect(undone.encounter.combatants[0].hpVariant).toBeUndefined();
});
it("leaves state untouched for an unknown combatant", () => {
const start = stateWithCreature(16, 16);
const next = encounterReducer(start, {
type: "set-hp-variant",
id: combatantId("c-99"),
variant: "max",
range: RANGE,
});
expect(next).toBe(start);
});
});