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
@@ -266,6 +266,45 @@ describe("round-trip: export then import", () => {
expect(imported.encounter.combatants[1].side).toBe("enemy");
});
it("round-trips a combatant with hpVariant field", () => {
const encounterWithVariant: Encounter = {
combatants: [
{
id: combatantId("c-1"),
name: "Ogre",
maxHp: 24,
currentHp: 24,
hpVariant: "max",
},
{
id: combatantId("c-2"),
name: "Goblin",
maxHp: 2,
currentHp: 2,
hpVariant: "min",
},
],
activeIndex: 0,
roundNumber: 1,
};
const emptyUndoRedo: UndoRedoState = {
undoStack: [],
redoStack: [],
};
const bundle = assembleExportBundle(
encounterWithVariant,
emptyUndoRedo,
[],
);
const serialized = JSON.parse(JSON.stringify(bundle));
const result = validateImportBundle(serialized);
expect(typeof result).toBe("object");
const imported = result as ExportBundle;
expect(imported.encounter.combatants[0].hpVariant).toBe("max");
expect(imported.encounter.combatants[1].hpVariant).toBe("min");
});
it("round-trips a combatant without side field as undefined", () => {
const encounterNoSide: Encounter = {
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
@@ -63,8 +63,11 @@ describe("SettingsModal", () => {
renderModal();
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
await user.click(btn5e);
// After clicking 5e, it should have the active style
expect(btn5e.className).toContain("bg-accent");
expect(btn5e).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "5.5e (2024)" })).toHaveAttribute(
"aria-pressed",
"false",
);
});
it("clicking a theme button switches the active theme", async () => {
@@ -72,7 +75,7 @@ describe("SettingsModal", () => {
renderModal();
const darkBtn = screen.getByRole("button", { name: "Dark" });
await user.click(darkBtn);
expect(darkBtn.className).toContain("bg-accent");
expect(darkBtn).toHaveAttribute("aria-pressed", "true");
});
it("close button calls onClose", async () => {
@@ -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);
+63 -3
View File
@@ -1,17 +1,34 @@
import type { Creature } from "@initiative/domain";
import type {
CombatantId,
Creature,
HpRange,
HpVariant,
} from "@initiative/domain";
import {
calculateInitiative,
formatInitiativeModifier,
hpForVariant,
hpRange,
} from "@initiative/domain";
import { cn } from "../lib/utils.js";
import {
PropertyLine,
SectionDivider,
TraitEntry,
TraitSection,
} from "./stat-block-parts.js";
import type { SegmentedOption } from "./ui/segmented-control.js";
import { SegmentedControl } from "./ui/segmented-control.js";
interface DndStatBlockProps {
creature: Creature;
combatantId?: CombatantId;
hpVariant?: HpVariant;
onSetHpVariant?: (
id: CombatantId,
variant: HpVariant | undefined,
range: HpRange,
) => void;
}
function abilityMod(score: number): string {
@@ -19,7 +36,25 @@ function abilityMod(score: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
/** Text color for a max HP that no longer shows the printed average. */
function hpVariantColor(variant: HpVariant | undefined): string {
if (variant === "max") return "text-blue-400";
if (variant === "min") return "text-red-400";
return "";
}
const HP_VARIANT_OPTIONS: SegmentedOption<HpVariant | undefined>[] = [
{ value: "min", label: "Min" },
{ value: undefined, label: "Avg" },
{ value: "max", label: "Max" },
];
export function DndStatBlock({
creature,
combatantId,
hpVariant,
onSetHpVariant,
}: Readonly<DndStatBlockProps>) {
const abilities = [
{ label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex },
@@ -35,6 +70,17 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
initiativeProficiency: creature.initiativeProficiency,
});
// Only offer min/max HP when the formula is a real dice pool with a spread.
const range = hpRange(creature.hp);
const canPickVariant =
range !== null &&
range.min !== range.max &&
combatantId != null &&
onSetHpVariant != null;
const displayedHp = range
? hpForVariant(range, hpVariant)
: creature.hp.average;
return (
<div className="space-y-1 text-foreground">
{/* Header */}
@@ -68,8 +114,22 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
</div>
<div>
<span className="font-semibold">Hit Points</span>{" "}
{creature.hp.average}{" "}
<span className={cn("font-semibold", hpVariantColor(hpVariant))}>
{displayedHp}
</span>{" "}
<span className="text-muted-foreground">({creature.hp.formula})</span>
{canPickVariant ? (
<SegmentedControl
options={HP_VARIANT_OPTIONS}
value={hpVariant}
onChange={(variant) =>
onSetHpVariant(combatantId, variant, range)
}
size="xs"
label="Hit point variant"
className="mt-1"
/>
) : null}
</div>
<div>
<span className="font-semibold">Speed</span> {creature.speed}
+18 -23
View File
@@ -15,6 +15,8 @@ import {
SectionDivider,
TraitSection,
} from "./stat-block-parts.js";
import type { SegmentedOption } from "./ui/segmented-control.js";
import { SegmentedControl } from "./ui/segmented-control.js";
interface Pf2eStatBlockProps {
creature: Pf2eCreature;
@@ -52,6 +54,12 @@ function formatMod(mod: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}
const ADJUSTMENT_OPTIONS: SegmentedOption<"weak" | "elite" | undefined>[] = [
{ value: "weak", label: "Weak" },
{ value: undefined, label: "Normal" },
{ value: "elite", label: "Elite" },
];
/** Returns the text color class for stats affected by weak/elite adjustment. */
function adjustmentColor(adjustment: "weak" | "elite" | undefined): string {
if (adjustment === "elite") return "text-blue-400";
@@ -213,29 +221,16 @@ export function Pf2eStatBlock({
{combatantId != null &&
onSetAdjustment != null &&
baseCreature != null && (
<div className="mt-1 flex gap-1">
{(["weak", "normal", "elite"] as const).map((opt) => {
const value = opt === "normal" ? undefined : opt;
const isActive = adjustment === value;
return (
<button
key={opt}
type="button"
className={cn(
"rounded px-2 py-0.5 font-medium text-xs capitalize",
isActive
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-accent/30",
)}
onClick={() =>
onSetAdjustment(combatantId, value, baseCreature)
}
>
{opt}
</button>
);
})}
</div>
<SegmentedControl
options={ADJUSTMENT_OPTIONS}
value={adjustment}
onChange={(value) =>
onSetAdjustment(combatantId, value, baseCreature)
}
size="xs"
label="Creature adjustment"
className="mt-1"
/>
)}
<div className="mt-1 flex flex-wrap gap-1">
{displayTraits(creature.traits).map((trait) => (
+47 -48
View File
@@ -2,28 +2,51 @@ import type { RulesEdition } from "@initiative/domain";
import { Monitor, Moon, Sun } from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useThemeContext } from "../contexts/theme-context.js";
import { cn } from "../lib/utils.js";
import { Dialog, DialogHeader } from "./ui/dialog.js";
import type { SegmentedOption } from "./ui/segmented-control.js";
import { SegmentedControl } from "./ui/segmented-control.js";
interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
const EDITION_OPTIONS: SegmentedOption<RulesEdition>[] = [
{ value: "5e", label: "5e (2014)" },
{ value: "5.5e", label: "5.5e (2024)" },
{ value: "pf2e", label: "Pathfinder 2e" },
];
const THEME_OPTIONS: {
value: "system" | "light" | "dark";
label: string;
icon: typeof Sun;
}[] = [
{ value: "system", label: "System", icon: Monitor },
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
type ThemePreference = "system" | "light" | "dark";
const THEME_OPTIONS: SegmentedOption<ThemePreference>[] = [
{
value: "system",
label: (
<>
<Monitor size={14} />
System
</>
),
},
{
value: "light",
label: (
<>
<Sun size={14} />
Light
</>
),
},
{
value: "dark",
label: (
<>
<Moon size={14} />
Dark
</>
),
},
];
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
@@ -39,50 +62,26 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
<span className="mb-2 block font-medium text-muted-foreground text-sm">
Game System
</span>
<div className="flex gap-1">
{EDITION_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
edition === opt.value
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
)}
onClick={() => setEdition(opt.value)}
>
{opt.label}
</button>
))}
</div>
<SegmentedControl
options={EDITION_OPTIONS}
value={edition}
onChange={setEdition}
stretch
label="Game System"
/>
</div>
<div>
<span className="mb-2 block font-medium text-muted-foreground text-sm">
Theme
</span>
<div className="flex gap-1">
{THEME_OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
className={cn(
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
preference === opt.value
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
)}
onClick={() => setPreference(opt.value)}
>
<Icon size={14} />
{opt.label}
</button>
);
})}
</div>
<SegmentedControl
options={THEME_OPTIONS}
value={preference}
onChange={setPreference}
stretch
label="Theme"
/>
</div>
</div>
</Dialog>
+32 -8
View File
@@ -4,6 +4,8 @@ import type {
CombatantId,
Creature,
CreatureId,
HpRange,
HpVariant,
Pf2eCreature,
} from "@initiative/domain";
import { applyPf2eAdjustment } from "@initiative/domain";
@@ -225,7 +227,8 @@ function MobileDrawer({
function usePanelRole(panelRole: "browse" | "pinned") {
const sidePanel = useSidePanelContext();
const { getCreature } = useBestiaryContext();
const { encounter, setCreatureAdjustment } = useEncounterContext();
const { encounter, setCreatureAdjustment, setHpVariant } =
useEncounterContext();
const creatureId =
panelRole === "browse"
@@ -245,6 +248,7 @@ function usePanelRole(panelRole: "browse" | "pinned") {
creature,
combatant,
setCreatureAdjustment,
setHpVariant,
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
@@ -256,14 +260,23 @@ function usePanelRole(panelRole: "browse" | "pinned") {
};
}
function renderStatBlock(
creature: AnyCreature,
combatant: Combatant | null,
interface StatBlockHandlers {
setCreatureAdjustment: (
id: CombatantId,
adj: "weak" | "elite" | undefined,
base: Pf2eCreature,
) => void,
) => void;
setHpVariant: (
id: CombatantId,
variant: HpVariant | undefined,
range: HpRange,
) => void;
}
function renderStatBlock(
creature: AnyCreature,
combatant: Combatant | null,
handlers: StatBlockHandlers,
) {
if ("system" in creature && creature.system === "pf2e") {
const baseCreature = creature;
@@ -276,11 +289,18 @@ function renderStatBlock(
adjustment={combatant?.creatureAdjustment}
combatantId={combatant?.id}
baseCreature={baseCreature}
onSetAdjustment={setCreatureAdjustment}
onSetAdjustment={handlers.setCreatureAdjustment}
/>
);
}
return <DndStatBlock creature={creature as Creature} />;
return (
<DndStatBlock
creature={creature as Creature}
combatantId={combatant?.id}
hpVariant={combatant?.hpVariant}
onSetHpVariant={handlers.setHpVariant}
/>
);
}
export function StatBlockPanel({
@@ -292,6 +312,7 @@ export function StatBlockPanel({
creature,
combatant,
setCreatureAdjustment,
setHpVariant,
isCollapsed,
onToggleCollapse,
onDismiss,
@@ -363,7 +384,10 @@ export function StatBlockPanel({
}
if (creature) {
return renderStatBlock(creature, combatant, setCreatureAdjustment);
return renderStatBlock(creature, combatant, {
setCreatureAdjustment,
setHpVariant,
});
}
if (needsFetch && sourceCode) {
@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { cn } from "../../lib/utils.js";
export interface SegmentedOption<T> {
readonly value: T;
readonly label: ReactNode;
}
interface SegmentedControlProps<T> {
options: readonly SegmentedOption<T>[];
value: T;
onChange: (value: T) => void;
/** "sm" for standalone controls, "xs" for controls inline in a stat block. */
size?: "sm" | "xs";
/** Segments share the full width of the row instead of hugging their label. */
stretch?: boolean;
label?: string;
className?: string;
}
/**
* A row of mutually exclusive options where exactly one is active — game
* system, theme, PF2e adjustment, D&D hit point variant.
*/
export function SegmentedControl<T>({
options,
value,
onChange,
size = "sm",
stretch = false,
label,
className,
}: Readonly<SegmentedControlProps<T>>) {
return (
<fieldset aria-label={label} className={cn("flex gap-1", className)}>
{options.map((option) => {
const isActive = option.value === value;
return (
<button
key={String(option.value)}
type="button"
aria-pressed={isActive}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors",
size === "xs" ? "px-2 py-0.5 text-xs" : "px-3 py-1.5 text-sm",
stretch ? "flex-1" : "",
isActive
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
)}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
);
})}
</fieldset>
);
}
@@ -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);
});
});
+23
View File
@@ -15,6 +15,7 @@ import {
setConditionValueUseCase,
setCrUseCase,
setHpUseCase,
setHpVariantUseCase,
setInitiativeUseCase,
setSideUseCase,
setTempHpUseCase,
@@ -30,6 +31,8 @@ import type {
DomainError,
DomainEvent,
Encounter,
HpRange,
HpVariant,
PersistentDamageType,
Pf2eCreature,
PlayerCharacter,
@@ -59,6 +62,12 @@ type EncounterAction =
| { type: "edit-combatant"; id: CombatantId; newName: string }
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
| {
type: "set-hp-variant";
id: CombatantId;
variant: HpVariant | undefined;
range: HpRange;
}
| { type: "adjust-hp"; id: CombatantId; delta: number }
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
| { type: "set-ac"; id: CombatantId; value: number | undefined }
@@ -432,6 +441,7 @@ function dispatchEncounterAction(
| { type: "edit-combatant" }
| { type: "set-initiative" }
| { type: "set-hp" }
| { type: "set-hp-variant" }
| { type: "adjust-hp" }
| { type: "set-temp-hp" }
| { type: "set-ac" }
@@ -472,6 +482,14 @@ function dispatchEncounterAction(
case "set-hp":
result = setHpUseCase(store, action.id, action.maxHp);
break;
case "set-hp-variant":
result = setHpVariantUseCase(
store,
action.id,
action.variant,
action.range,
);
break;
case "adjust-hp":
result = adjustHpUseCase(store, action.id, action.delta);
break;
@@ -706,6 +724,11 @@ export function useEncounter() {
}),
[],
),
setHpVariant: useCallback(
(id: CombatantId, variant: HpVariant | undefined, range: HpRange) =>
dispatch({ type: "set-hp-variant", id, variant, range }),
[],
),
clearEncounter: useCallback(
() => dispatch({ type: "clear-encounter" }),
[],