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:
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
[],
|
||||
|
||||
Reference in New Issue
Block a user