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:
@@ -27,6 +27,7 @@ export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
||||
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setHpVariantUseCase } from "./set-hp-variant-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setSideUseCase } from "./set-side-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
type HpRange,
|
||||
type HpVariant,
|
||||
setHpVariant,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setHpVariantUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
): DomainEvent[] | DomainError {
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setHpVariant(encounter, combatantId, variant, range),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user