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,78 @@
import { describe, expect, it } from "vitest";
import { hpForVariant, hpRange } from "../hp-range.js";
describe("hpRange", () => {
it("derives min and max from a formula with a positive modifier", () => {
const range = hpRange({ average: 16, formula: "3d6 + 6" });
expect(range).toEqual({ min: 9, average: 16, max: 24 });
});
it("derives min and max from a formula without a modifier", () => {
const range = hpRange({ average: 40, formula: "9d8" });
expect(range).toEqual({ min: 9, average: 40, max: 72 });
});
it("subtracts a negative modifier", () => {
const range = hpRange({ average: 45, formula: "10d8 - 5" });
expect(range).toEqual({ min: 5, average: 45, max: 75 });
});
it("treats an en dash as a minus sign", () => {
// Bundled bestiary data contains e.g. "2d6 2" (U+2013).
const range = hpRange({ average: 5, formula: "2d6 2" });
expect(range).toEqual({ min: 1, average: 5, max: 10 });
});
it("never drops below 1 HP when the modifier exceeds the dice", () => {
const range = hpRange({ average: 1, formula: "1d4 - 10" });
expect(range?.min).toBe(1);
expect(range?.max).toBe(1);
});
it("handles large pools without whitespace", () => {
expect(hpRange({ average: 256, formula: "19d12+133" })).toEqual({
min: 152,
average: 256,
max: 361,
});
});
it("returns null for a missing formula", () => {
expect(hpRange({ average: 7, formula: "" })).toBeNull();
});
it("returns null for prose instead of a dice pool", () => {
expect(
hpRange({ average: 0, formula: "equal to the summoner's" }),
).toBeNull();
});
it("returns null for a flat number", () => {
expect(hpRange({ average: 50, formula: "50" })).toBeNull();
});
it("returns null for a pool with zero dice or zero sides", () => {
expect(hpRange({ average: 0, formula: "0d6" })).toBeNull();
expect(hpRange({ average: 0, formula: "2d0" })).toBeNull();
});
it("returns null for compound formulas it cannot reason about", () => {
expect(hpRange({ average: 20, formula: "2d6 + 2d8" })).toBeNull();
});
});
describe("hpForVariant", () => {
const range = { min: 9, average: 16, max: 24 };
it("returns the average when no variant is set", () => {
expect(hpForVariant(range, undefined)).toBe(16);
});
it("returns the minimum for the min variant", () => {
expect(hpForVariant(range, "min")).toBe(9);
});
it("returns the maximum for the max variant", () => {
expect(hpForVariant(range, "max")).toBe(24);
});
});
@@ -301,6 +301,34 @@ describe("rehydrateCombatant", () => {
expect(result?.side).toBeUndefined();
});
it("preserves valid hpVariant field", () => {
for (const hpVariant of ["min", "max"]) {
const result = rehydrateCombatant({
...minimalCombatant(),
hpVariant,
});
expect(result).not.toBeNull();
expect(result?.hpVariant).toBe(hpVariant);
}
});
it("drops invalid hpVariant field", () => {
for (const hpVariant of ["average", "", 42, null, true]) {
const result = rehydrateCombatant({
...minimalCombatant(),
hpVariant,
});
expect(result).not.toBeNull();
expect(result?.hpVariant).toBeUndefined();
}
});
it("combatant without hpVariant rehydrates as before", () => {
const result = rehydrateCombatant(minimalCombatant());
expect(result).not.toBeNull();
expect(result?.hpVariant).toBeUndefined();
});
it("preserves valid persistent damage entries", () => {
const result = rehydrateCombatant({
...minimalCombatant(),
@@ -0,0 +1,159 @@
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,
},
]);
});
});
+9
View File
@@ -152,6 +152,14 @@ export interface CreatureAdjustmentSet {
readonly adjustment: "weak" | "elite" | undefined;
}
export interface HpVariantSet {
readonly type: "HpVariantSet";
readonly combatantId: CombatantId;
readonly variant: "min" | "max" | undefined;
readonly previousMaxHp: number | undefined;
readonly newMaxHp: number | undefined;
}
export interface EncounterCleared {
readonly type: "EncounterCleared";
readonly combatantCount: number;
@@ -198,6 +206,7 @@ export type DomainEvent =
| PersistentDamageAdded
| PersistentDamageRemoved
| CreatureAdjustmentSet
| HpVariantSet
| EncounterCleared
| PlayerCharacterCreated
| PlayerCharacterUpdated
+58
View File
@@ -0,0 +1,58 @@
/** Which end of a creature's Hit Dice range its max HP is taken from. */
export type HpVariant = "min" | "max";
export const VALID_HP_VARIANTS: ReadonlySet<string> = new Set(["min", "max"]);
export interface HpRange {
/** Every Hit Die rolls a 1 (never below 1 HP). */
readonly min: number;
/** The statblock's printed average. */
readonly average: number;
/** Every Hit Die rolls its maximum. */
readonly max: number;
}
/**
* Matches a plain Hit Dice pool: "9d8", "3d6 + 6", "2d6 - 2".
* Bestiary data uses several dash characters, so hyphen, minus sign, en dash
* and em dash all count as a negative modifier.
*/
const DICE_FORMULA_REGEX =
/^\s*(\d+)\s*[dD]\s*(\d+)\s*(?:([-+−–—])\s*(\d+))?\s*$/;
/**
* Derives the min/average/max HP of a creature from its Hit Dice formula.
* Returns null when the formula is not a plain dice pool — some sources carry
* prose (`hp.special`) or an empty string instead.
*/
export function hpRange(
hp: Readonly<{ average: number; formula: string }>,
): HpRange | null {
const match = DICE_FORMULA_REGEX.exec(hp.formula);
if (!match) return null;
const count = Number(match[1]);
const sides = Number(match[2]);
if (count < 1 || sides < 1) return null;
const modifier =
match[4] === undefined
? 0
: (match[3] === "+" ? 1 : -1) * Number.parseInt(match[4], 10);
return {
min: Math.max(1, count + modifier),
average: hp.average,
max: Math.max(1, count * sides + modifier),
};
}
/** The HP value a variant selects; undefined means the printed average. */
export function hpForVariant(
range: HpRange,
variant: HpVariant | undefined,
): number {
if (variant === "min") return range.min;
if (variant === "max") return range.max;
return range.average;
}
+12
View File
@@ -82,6 +82,7 @@ export type {
CurrentHpAdjusted,
DomainEvent,
EncounterCleared,
HpVariantSet,
InitiativeSet,
MaxHpSet,
PersistentDamageAdded,
@@ -97,6 +98,13 @@ export type {
TurnRetreated,
} from "./events.js";
export type { ExportBundle } from "./export-bundle.js";
export {
type HpRange,
type HpVariant,
hpForVariant,
hpRange,
VALID_HP_VARIANTS,
} from "./hp-range.js";
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
export {
calculateInitiative,
@@ -153,6 +161,10 @@ export type { RulesEdition } from "./rules-edition.js";
export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetCrSuccess, setCr } from "./set-cr.js";
export { type SetHpSuccess, setHp } from "./set-hp.js";
export {
type SetHpVariantSuccess,
setHpVariant,
} from "./set-hp-variant.js";
export {
type SetInitiativeSuccess,
setInitiative,
@@ -2,6 +2,8 @@ import type { ConditionEntry, ConditionId } from "./conditions.js";
import { VALID_CONDITION_IDS } from "./conditions.js";
import { creatureId } from "./creature-types.js";
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
import type { HpVariant } from "./hp-range.js";
import { VALID_HP_VARIANTS } from "./hp-range.js";
import type { PersistentDamageEntry } from "./persistent-damage-types.js";
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage-types.js";
import {
@@ -144,6 +146,9 @@ function parseOptionalFields(entry: Record<string, unknown>) {
entry.creatureAdjustment,
VALID_ADJUSTMENTS,
) as "weak" | "elite" | undefined,
hpVariant: validateSetMember(entry.hpVariant, VALID_HP_VARIANTS) as
| HpVariant
| undefined,
cr: validateCr(entry.cr),
side: validateSide(entry.side),
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
+73
View File
@@ -0,0 +1,73 @@
import type { DomainEvent } from "./events.js";
import { type HpRange, type HpVariant, hpForVariant } from "./hp-range.js";
import {
type CombatantId,
type DomainError,
type Encounter,
findCombatant,
isDomainError,
} from "./types.js";
export interface SetHpVariantSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Switches a combatant's max HP between the minimum, average and maximum roll
* of its Hit Dice.
*
* The shift is applied as a delta so manual HP edits and damage already taken
* survive the switch: a creature missing 5 HP still misses 5 HP afterwards.
*/
export function setHpVariant(
encounter: Encounter,
combatantId: CombatantId,
variant: HpVariant | undefined,
range: HpRange,
): SetHpVariantSuccess | DomainError {
const found = findCombatant(encounter, combatantId);
if (isDomainError(found)) return found;
const { combatant } = found;
if (combatant.hpVariant === variant) {
return { encounter, events: [] };
}
const delta =
hpForVariant(range, variant) - hpForVariant(range, combatant.hpVariant);
const newMaxHp =
combatant.maxHp === undefined
? undefined
: Math.max(1, combatant.maxHp + delta);
const newCurrentHp =
combatant.currentHp === undefined || newMaxHp === undefined
? combatant.currentHp
: Math.max(0, Math.min(combatant.currentHp + delta, newMaxHp));
return {
encounter: {
...encounter,
combatants: encounter.combatants.map((c) =>
c.id === combatantId
? {
...c,
maxHp: newMaxHp,
currentHp: newCurrentHp,
hpVariant: variant,
}
: c,
),
},
events: [
{
type: "HpVariantSet",
combatantId,
variant,
previousMaxHp: combatant.maxHp,
newMaxHp,
},
],
};
}
+2
View File
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
import type { ConditionEntry } from "./conditions.js";
import type { CreatureId } from "./creature-types.js";
import type { HpVariant } from "./hp-range.js";
import type { PersistentDamageEntry } from "./persistent-damage-types.js";
import type { PlayerCharacterId } from "./player-character-types.js";
@@ -23,6 +24,7 @@ export interface Combatant {
readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId;
readonly creatureAdjustment?: "weak" | "elite";
readonly hpVariant?: HpVariant;
readonly cr?: string;
readonly side?: "party" | "enemy";
readonly color?: string;