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
@@ -1,9 +1,11 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Creature } from "@initiative/domain";
import { creatureId } from "@initiative/domain";
import type { Creature, HpVariant } from "@initiative/domain";
import { combatantId, creatureId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
@@ -128,6 +130,19 @@ function renderStatBlock(creature: Creature) {
return render(<StatBlock creature={creature} />);
}
/** Owns the variant the way the encounter state does in the real app. */
function HpVariantHarness({ creature }: Readonly<{ creature: Creature }>) {
const [variant, setVariant] = useState<HpVariant | undefined>(undefined);
return (
<StatBlock
creature={creature}
combatantId={combatantId("c-1")}
hpVariant={variant}
onSetHpVariant={(_id, next) => setVariant(next)}
/>
);
}
describe("StatBlock", () => {
describe("header", () => {
it("renders creature name", () => {
@@ -175,6 +190,81 @@ describe("StatBlock", () => {
});
});
describe("hit point variant", () => {
it("offers no variant buttons while browsing without a combatant", () => {
renderStatBlock(GOBLIN);
expect(
screen.queryByRole("button", { name: "Max" }),
).not.toBeInTheDocument();
});
it("hides the variant buttons when the HP formula is not a dice pool", () => {
render(
<HpVariantHarness
creature={{
...GOBLIN,
hp: { average: 50, formula: "special" },
}}
/>,
);
expect(
screen.queryByRole("button", { name: "Max" }),
).not.toBeInTheDocument();
expect(screen.getByText("50")).toBeInTheDocument();
});
it("shows the printed average until a variant is picked", () => {
render(<HpVariantHarness creature={GOBLIN} />);
expect(screen.getByText("7")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Avg" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("shows the maximum roll after picking Max", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Max" }));
expect(screen.getByText("12")).toBeInTheDocument();
expect(screen.queryByText("7")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Max" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("shows the minimum roll after picking Min", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Min" }));
expect(screen.getByText("2")).toBeInTheDocument();
});
it("returns to the average after picking Avg again", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Max" }));
await user.click(screen.getByRole("button", { name: "Avg" }));
expect(screen.getByText("7")).toBeInTheDocument();
});
it("keeps showing the formula for every variant", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Min" }));
expect(screen.getByText("(2d6)")).toBeInTheDocument();
});
});
describe("ability scores", () => {
it("renders all 6 ability labels", () => {
renderStatBlock(GOBLIN);