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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user