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");
|
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", () => {
|
it("round-trips a combatant without side field as undefined", () => {
|
||||||
const encounterNoSide: Encounter = {
|
const encounterNoSide: Encounter = {
|
||||||
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||||
await user.click(btn5e);
|
await user.click(btn5e);
|
||||||
// After clicking 5e, it should have the active style
|
expect(btn5e).toHaveAttribute("aria-pressed", "true");
|
||||||
expect(btn5e.className).toContain("bg-accent");
|
expect(screen.getByRole("button", { name: "5.5e (2024)" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clicking a theme button switches the active theme", async () => {
|
it("clicking a theme button switches the active theme", async () => {
|
||||||
@@ -72,7 +75,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||||
await user.click(darkBtn);
|
await user.click(darkBtn);
|
||||||
expect(darkBtn.className).toContain("bg-accent");
|
expect(darkBtn).toHaveAttribute("aria-pressed", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("close button calls onClose", async () => {
|
it("close button calls onClose", async () => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import type { Creature } from "@initiative/domain";
|
import type { Creature, HpVariant } from "@initiative/domain";
|
||||||
import { creatureId } from "@initiative/domain";
|
import { combatantId, creatureId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
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 { afterEach, describe, expect, it } from "vitest";
|
||||||
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||||
|
|
||||||
@@ -128,6 +130,19 @@ function renderStatBlock(creature: Creature) {
|
|||||||
return render(<StatBlock 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("StatBlock", () => {
|
||||||
describe("header", () => {
|
describe("header", () => {
|
||||||
it("renders creature name", () => {
|
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", () => {
|
describe("ability scores", () => {
|
||||||
it("renders all 6 ability labels", () => {
|
it("renders all 6 ability labels", () => {
|
||||||
renderStatBlock(GOBLIN);
|
renderStatBlock(GOBLIN);
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import type { Creature } from "@initiative/domain";
|
import type {
|
||||||
|
CombatantId,
|
||||||
|
Creature,
|
||||||
|
HpRange,
|
||||||
|
HpVariant,
|
||||||
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
|
hpForVariant,
|
||||||
|
hpRange,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
import {
|
import {
|
||||||
PropertyLine,
|
PropertyLine,
|
||||||
SectionDivider,
|
SectionDivider,
|
||||||
TraitEntry,
|
TraitEntry,
|
||||||
TraitSection,
|
TraitSection,
|
||||||
} from "./stat-block-parts.js";
|
} from "./stat-block-parts.js";
|
||||||
|
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||||
|
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||||
|
|
||||||
interface DndStatBlockProps {
|
interface DndStatBlockProps {
|
||||||
creature: Creature;
|
creature: Creature;
|
||||||
|
combatantId?: CombatantId;
|
||||||
|
hpVariant?: HpVariant;
|
||||||
|
onSetHpVariant?: (
|
||||||
|
id: CombatantId,
|
||||||
|
variant: HpVariant | undefined,
|
||||||
|
range: HpRange,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function abilityMod(score: number): string {
|
function abilityMod(score: number): string {
|
||||||
@@ -19,7 +36,25 @@ function abilityMod(score: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
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 = [
|
const abilities = [
|
||||||
{ label: "STR", score: creature.abilities.str },
|
{ label: "STR", score: creature.abilities.str },
|
||||||
{ label: "DEX", score: creature.abilities.dex },
|
{ label: "DEX", score: creature.abilities.dex },
|
||||||
@@ -35,6 +70,17 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
initiativeProficiency: creature.initiativeProficiency,
|
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 (
|
return (
|
||||||
<div className="space-y-1 text-foreground">
|
<div className="space-y-1 text-foreground">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -68,8 +114,22 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Hit Points</span>{" "}
|
<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>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Speed</span> {creature.speed}
|
<span className="font-semibold">Speed</span> {creature.speed}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
SectionDivider,
|
SectionDivider,
|
||||||
TraitSection,
|
TraitSection,
|
||||||
} from "./stat-block-parts.js";
|
} from "./stat-block-parts.js";
|
||||||
|
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||||
|
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||||
|
|
||||||
interface Pf2eStatBlockProps {
|
interface Pf2eStatBlockProps {
|
||||||
creature: Pf2eCreature;
|
creature: Pf2eCreature;
|
||||||
@@ -52,6 +54,12 @@ function formatMod(mod: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
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. */
|
/** Returns the text color class for stats affected by weak/elite adjustment. */
|
||||||
function adjustmentColor(adjustment: "weak" | "elite" | undefined): string {
|
function adjustmentColor(adjustment: "weak" | "elite" | undefined): string {
|
||||||
if (adjustment === "elite") return "text-blue-400";
|
if (adjustment === "elite") return "text-blue-400";
|
||||||
@@ -213,29 +221,16 @@ export function Pf2eStatBlock({
|
|||||||
{combatantId != null &&
|
{combatantId != null &&
|
||||||
onSetAdjustment != null &&
|
onSetAdjustment != null &&
|
||||||
baseCreature != null && (
|
baseCreature != null && (
|
||||||
<div className="mt-1 flex gap-1">
|
<SegmentedControl
|
||||||
{(["weak", "normal", "elite"] as const).map((opt) => {
|
options={ADJUSTMENT_OPTIONS}
|
||||||
const value = opt === "normal" ? undefined : opt;
|
value={adjustment}
|
||||||
const isActive = adjustment === value;
|
onChange={(value) =>
|
||||||
return (
|
onSetAdjustment(combatantId, value, baseCreature)
|
||||||
<button
|
}
|
||||||
key={opt}
|
size="xs"
|
||||||
type="button"
|
label="Creature adjustment"
|
||||||
className={cn(
|
className="mt-1"
|
||||||
"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>
|
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{displayTraits(creature.traits).map((trait) => (
|
{displayTraits(creature.traits).map((trait) => (
|
||||||
|
|||||||
@@ -2,28 +2,51 @@ import type { RulesEdition } from "@initiative/domain";
|
|||||||
import { Monitor, Moon, Sun } from "lucide-react";
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useThemeContext } from "../contexts/theme-context.js";
|
import { useThemeContext } from "../contexts/theme-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
|
||||||
import { Dialog, DialogHeader } from "./ui/dialog.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 {
|
interface SettingsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
const EDITION_OPTIONS: SegmentedOption<RulesEdition>[] = [
|
||||||
{ value: "5e", label: "5e (2014)" },
|
{ value: "5e", label: "5e (2014)" },
|
||||||
{ value: "5.5e", label: "5.5e (2024)" },
|
{ value: "5.5e", label: "5.5e (2024)" },
|
||||||
{ value: "pf2e", label: "Pathfinder 2e" },
|
{ value: "pf2e", label: "Pathfinder 2e" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const THEME_OPTIONS: {
|
type ThemePreference = "system" | "light" | "dark";
|
||||||
value: "system" | "light" | "dark";
|
|
||||||
label: string;
|
const THEME_OPTIONS: SegmentedOption<ThemePreference>[] = [
|
||||||
icon: typeof Sun;
|
{
|
||||||
}[] = [
|
value: "system",
|
||||||
{ value: "system", label: "System", icon: Monitor },
|
label: (
|
||||||
{ value: "light", label: "Light", icon: Sun },
|
<>
|
||||||
{ value: "dark", label: "Dark", icon: Moon },
|
<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>) {
|
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">
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
Game System
|
Game System
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<SegmentedControl
|
||||||
{EDITION_OPTIONS.map((opt) => (
|
options={EDITION_OPTIONS}
|
||||||
<button
|
value={edition}
|
||||||
key={opt.value}
|
onChange={setEdition}
|
||||||
type="button"
|
stretch
|
||||||
className={cn(
|
label="Game System"
|
||||||
"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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
Theme
|
Theme
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<SegmentedControl
|
||||||
{THEME_OPTIONS.map((opt) => {
|
options={THEME_OPTIONS}
|
||||||
const Icon = opt.icon;
|
value={preference}
|
||||||
return (
|
onChange={setPreference}
|
||||||
<button
|
stretch
|
||||||
key={opt.value}
|
label="Theme"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
CombatantId,
|
CombatantId,
|
||||||
Creature,
|
Creature,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
HpRange,
|
||||||
|
HpVariant,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { applyPf2eAdjustment } from "@initiative/domain";
|
import { applyPf2eAdjustment } from "@initiative/domain";
|
||||||
@@ -225,7 +227,8 @@ function MobileDrawer({
|
|||||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||||
const sidePanel = useSidePanelContext();
|
const sidePanel = useSidePanelContext();
|
||||||
const { getCreature } = useBestiaryContext();
|
const { getCreature } = useBestiaryContext();
|
||||||
const { encounter, setCreatureAdjustment } = useEncounterContext();
|
const { encounter, setCreatureAdjustment, setHpVariant } =
|
||||||
|
useEncounterContext();
|
||||||
|
|
||||||
const creatureId =
|
const creatureId =
|
||||||
panelRole === "browse"
|
panelRole === "browse"
|
||||||
@@ -245,6 +248,7 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
|||||||
creature,
|
creature,
|
||||||
combatant,
|
combatant,
|
||||||
setCreatureAdjustment,
|
setCreatureAdjustment,
|
||||||
|
setHpVariant,
|
||||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||||
@@ -256,14 +260,23 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatBlock(
|
interface StatBlockHandlers {
|
||||||
creature: AnyCreature,
|
|
||||||
combatant: Combatant | null,
|
|
||||||
setCreatureAdjustment: (
|
setCreatureAdjustment: (
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
adj: "weak" | "elite" | undefined,
|
adj: "weak" | "elite" | undefined,
|
||||||
base: Pf2eCreature,
|
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") {
|
if ("system" in creature && creature.system === "pf2e") {
|
||||||
const baseCreature = creature;
|
const baseCreature = creature;
|
||||||
@@ -276,11 +289,18 @@ function renderStatBlock(
|
|||||||
adjustment={combatant?.creatureAdjustment}
|
adjustment={combatant?.creatureAdjustment}
|
||||||
combatantId={combatant?.id}
|
combatantId={combatant?.id}
|
||||||
baseCreature={baseCreature}
|
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({
|
export function StatBlockPanel({
|
||||||
@@ -292,6 +312,7 @@ export function StatBlockPanel({
|
|||||||
creature,
|
creature,
|
||||||
combatant,
|
combatant,
|
||||||
setCreatureAdjustment,
|
setCreatureAdjustment,
|
||||||
|
setHpVariant,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
@@ -363,7 +384,10 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (creature) {
|
if (creature) {
|
||||||
return renderStatBlock(creature, combatant, setCreatureAdjustment);
|
return renderStatBlock(creature, combatant, {
|
||||||
|
setCreatureAdjustment,
|
||||||
|
setHpVariant,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsFetch && sourceCode) {
|
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,
|
setConditionValueUseCase,
|
||||||
setCrUseCase,
|
setCrUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
|
setHpVariantUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
setSideUseCase,
|
setSideUseCase,
|
||||||
setTempHpUseCase,
|
setTempHpUseCase,
|
||||||
@@ -30,6 +31,8 @@ import type {
|
|||||||
DomainError,
|
DomainError,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
|
HpRange,
|
||||||
|
HpVariant,
|
||||||
PersistentDamageType,
|
PersistentDamageType,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -59,6 +62,12 @@ type EncounterAction =
|
|||||||
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
||||||
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
||||||
| { type: "set-hp"; id: CombatantId; maxHp: 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: "adjust-hp"; id: CombatantId; delta: number }
|
||||||
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||||
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||||
@@ -432,6 +441,7 @@ function dispatchEncounterAction(
|
|||||||
| { type: "edit-combatant" }
|
| { type: "edit-combatant" }
|
||||||
| { type: "set-initiative" }
|
| { type: "set-initiative" }
|
||||||
| { type: "set-hp" }
|
| { type: "set-hp" }
|
||||||
|
| { type: "set-hp-variant" }
|
||||||
| { type: "adjust-hp" }
|
| { type: "adjust-hp" }
|
||||||
| { type: "set-temp-hp" }
|
| { type: "set-temp-hp" }
|
||||||
| { type: "set-ac" }
|
| { type: "set-ac" }
|
||||||
@@ -472,6 +482,14 @@ function dispatchEncounterAction(
|
|||||||
case "set-hp":
|
case "set-hp":
|
||||||
result = setHpUseCase(store, action.id, action.maxHp);
|
result = setHpUseCase(store, action.id, action.maxHp);
|
||||||
break;
|
break;
|
||||||
|
case "set-hp-variant":
|
||||||
|
result = setHpVariantUseCase(
|
||||||
|
store,
|
||||||
|
action.id,
|
||||||
|
action.variant,
|
||||||
|
action.range,
|
||||||
|
);
|
||||||
|
break;
|
||||||
case "adjust-hp":
|
case "adjust-hp":
|
||||||
result = adjustHpUseCase(store, action.id, action.delta);
|
result = adjustHpUseCase(store, action.id, action.delta);
|
||||||
break;
|
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(
|
clearEncounter: useCallback(
|
||||||
() => dispatch({ type: "clear-encounter" }),
|
() => dispatch({ type: "clear-encounter" }),
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export { setAcUseCase } from "./set-ac-use-case.js";
|
|||||||
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
||||||
export { setCrUseCase } from "./set-cr-use-case.js";
|
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-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 { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
export { setSideUseCase } from "./set-side-use-case.js";
|
export { setSideUseCase } from "./set-side-use-case.js";
|
||||||
export { setTempHpUseCase } from "./set-temp-hp-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();
|
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", () => {
|
it("preserves valid persistent damage entries", () => {
|
||||||
const result = rehydrateCombatant({
|
const result = rehydrateCombatant({
|
||||||
...minimalCombatant(),
|
...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;
|
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 {
|
export interface EncounterCleared {
|
||||||
readonly type: "EncounterCleared";
|
readonly type: "EncounterCleared";
|
||||||
readonly combatantCount: number;
|
readonly combatantCount: number;
|
||||||
@@ -198,6 +206,7 @@ export type DomainEvent =
|
|||||||
| PersistentDamageAdded
|
| PersistentDamageAdded
|
||||||
| PersistentDamageRemoved
|
| PersistentDamageRemoved
|
||||||
| CreatureAdjustmentSet
|
| CreatureAdjustmentSet
|
||||||
|
| HpVariantSet
|
||||||
| EncounterCleared
|
| EncounterCleared
|
||||||
| PlayerCharacterCreated
|
| PlayerCharacterCreated
|
||||||
| PlayerCharacterUpdated
|
| 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,
|
CurrentHpAdjusted,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
EncounterCleared,
|
EncounterCleared,
|
||||||
|
HpVariantSet,
|
||||||
InitiativeSet,
|
InitiativeSet,
|
||||||
MaxHpSet,
|
MaxHpSet,
|
||||||
PersistentDamageAdded,
|
PersistentDamageAdded,
|
||||||
@@ -97,6 +98,13 @@ export type {
|
|||||||
TurnRetreated,
|
TurnRetreated,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
export type { ExportBundle } from "./export-bundle.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 { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||||
export {
|
export {
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
@@ -153,6 +161,10 @@ export type { RulesEdition } from "./rules-edition.js";
|
|||||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||||
export { type SetCrSuccess, setCr } from "./set-cr.js";
|
export { type SetCrSuccess, setCr } from "./set-cr.js";
|
||||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||||
|
export {
|
||||||
|
type SetHpVariantSuccess,
|
||||||
|
setHpVariant,
|
||||||
|
} from "./set-hp-variant.js";
|
||||||
export {
|
export {
|
||||||
type SetInitiativeSuccess,
|
type SetInitiativeSuccess,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { ConditionEntry, ConditionId } from "./conditions.js";
|
|||||||
import { VALID_CONDITION_IDS } from "./conditions.js";
|
import { VALID_CONDITION_IDS } from "./conditions.js";
|
||||||
import { creatureId } from "./creature-types.js";
|
import { creatureId } from "./creature-types.js";
|
||||||
import { VALID_CR_VALUES } from "./encounter-difficulty.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 type { PersistentDamageEntry } from "./persistent-damage-types.js";
|
||||||
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage-types.js";
|
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage-types.js";
|
||||||
import {
|
import {
|
||||||
@@ -144,6 +146,9 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
|||||||
entry.creatureAdjustment,
|
entry.creatureAdjustment,
|
||||||
VALID_ADJUSTMENTS,
|
VALID_ADJUSTMENTS,
|
||||||
) as "weak" | "elite" | undefined,
|
) as "weak" | "elite" | undefined,
|
||||||
|
hpVariant: validateSetMember(entry.hpVariant, VALID_HP_VARIANTS) as
|
||||||
|
| HpVariant
|
||||||
|
| undefined,
|
||||||
cr: validateCr(entry.cr),
|
cr: validateCr(entry.cr),
|
||||||
side: validateSide(entry.side),
|
side: validateSide(entry.side),
|
||||||
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
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 { ConditionEntry } from "./conditions.js";
|
||||||
import type { CreatureId } from "./creature-types.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 { PersistentDamageEntry } from "./persistent-damage-types.js";
|
||||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export interface Combatant {
|
|||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly creatureId?: CreatureId;
|
readonly creatureId?: CreatureId;
|
||||||
readonly creatureAdjustment?: "weak" | "elite";
|
readonly creatureAdjustment?: "weak" | "elite";
|
||||||
|
readonly hpVariant?: HpVariant;
|
||||||
readonly cr?: string;
|
readonly cr?: string;
|
||||||
readonly side?: "party" | "enemy";
|
readonly side?: "party" | "enemy";
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ As a DM running a PF2e encounter, I want to toggle a weak or elite adjustment on
|
|||||||
|
|
||||||
When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in the header. Selecting "Elite" or "Weak" applies the standard PF2e adjustments: ±2 to AC, saves, Perception, attack rolls, and strike damage; HP adjusted by the standard level bracket table; level shifted. The combatant's stored HP and AC update accordingly (see `specs/003-combatant-state/spec.md`, FR-113–FR-116), and its name gains a prefix (see `specs/001-combatant-management/spec.md`, FR-041–FR-042). The toggle defaults to "Normal" and is not shown for D&D creatures. A visual indicator (the same icon used in the toggle) appears next to the creature name in the header.
|
When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in the header. Selecting "Elite" or "Weak" applies the standard PF2e adjustments: ±2 to AC, saves, Perception, attack rolls, and strike damage; HP adjusted by the standard level bracket table; level shifted. The combatant's stored HP and AC update accordingly (see `specs/003-combatant-state/spec.md`, FR-113–FR-116), and its name gains a prefix (see `specs/001-combatant-management/spec.md`, FR-041–FR-042). The toggle defaults to "Normal" and is not shown for D&D creatures. A visual indicator (the same icon used in the toggle) appears next to the creature name in the header.
|
||||||
|
|
||||||
|
**US-D8 — Set a D&D Combatant to Minimum or Maximum Hit Points (P2)**
|
||||||
|
As a DM running a D&D encounter, I want to switch a bestiary-linked combatant between the minimum, average and maximum result of its Hit Dice so I can make a single monster tougher or flimsier without hand-editing HP.
|
||||||
|
|
||||||
|
When viewing a D&D creature's stat block for a combatant, a Min/Avg/Max toggle appears under the Hit Points line. The stat block shows the printed average by default; selecting "Min" or "Max" shows the value the Hit Dice would produce if every die rolled its lowest or highest face (e.g., `3d6 + 6` → 9 or 24) and updates the combatant's stored max HP by the same delta. This is a convenience tool, not a rules mechanic — no other stat changes and the combatant is not renamed.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
|
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
|
||||||
@@ -151,6 +156,12 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
|
|||||||
- **FR-106**: Toggling the adjustment MUST update the combatant's name with the appropriate prefix — "Weak" or "Elite" — or remove the prefix when returning to "Normal" (see `specs/001-combatant-management/spec.md`, FR-041–FR-042).
|
- **FR-106**: Toggling the adjustment MUST update the combatant's name with the appropriate prefix — "Weak" or "Elite" — or remove the prefix when returning to "Normal" (see `specs/001-combatant-management/spec.md`, FR-041–FR-042).
|
||||||
- **FR-107**: The stat block header MUST display a visual indicator (the same icon used in the toggle) next to the creature name when the creature has a weak or elite adjustment.
|
- **FR-107**: The stat block header MUST display a visual indicator (the same icon used in the toggle) next to the creature name when the creature has a weak or elite adjustment.
|
||||||
- **FR-108**: The adjustment MUST be stored on the combatant as a `creatureAdjustment` field and persist across page reloads.
|
- **FR-108**: The adjustment MUST be stored on the combatant as a `creatureAdjustment` field and persist across page reloads.
|
||||||
|
- **FR-109**: D&D stat blocks MUST include a Min/Avg/Max hit point toggle below the Hit Points line, defaulting to "Avg".
|
||||||
|
- **FR-110**: The Min/Avg/Max toggle MUST only be shown for a bestiary-linked combatant whose HP formula is a plain Hit Dice pool (`NdM`, optionally `± K`) with a spread — it MUST NOT be shown while browsing a creature without a combatant, for PF2e creatures, or when the HP field carries prose instead of dice.
|
||||||
|
- **FR-111**: "Min" MUST use the result of every Hit Die rolling 1 (dice count + modifier); "Max" MUST use the result of every Hit Die rolling its highest face (dice count × faces + modifier). Both MUST be at least 1. "Avg" MUST use the printed average.
|
||||||
|
- **FR-112**: Selecting a variant MUST shift the combatant's stored maxHp by the delta between the previously selected and newly selected value, preserving manual HP edits. The combatant's currentHp MUST shift by the same delta, clamped to [0, new maxHp].
|
||||||
|
- **FR-113**: Selecting a variant MUST NOT change the combatant's name or any stat other than HP.
|
||||||
|
- **FR-114**: The selected variant MUST be stored on the combatant as an `hpVariant` field (`"min" | "max"`, absent for average) and persist across page reloads and JSON export/import.
|
||||||
|
|
||||||
### Acceptance Scenarios
|
### Acceptance Scenarios
|
||||||
|
|
||||||
@@ -194,6 +205,11 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
|
|||||||
38. **Given** a PF2e creature with level 1 stat block is open, **When** the DM selects "Weak", **Then** the level decreases by 2 (to −1, not 0).
|
38. **Given** a PF2e creature with level 1 stat block is open, **When** the DM selects "Weak", **Then** the level decreases by 2 (to −1, not 0).
|
||||||
39. **Given** a PF2e combatant was set to "Elite" and the page is reloaded, **When** the DM opens the stat block, **Then** the toggle shows "Elite" and the stat block displays adjusted stats.
|
39. **Given** a PF2e combatant was set to "Elite" and the page is reloaded, **When** the DM opens the stat block, **Then** the toggle shows "Elite" and the stat block displays adjusted stats.
|
||||||
40. **Given** a PF2e combatant was set to "Elite", **When** the DM toggles back to "Normal", **Then** the stat block reverts to base stats, the combatant's HP/AC revert, and the name prefix is removed.
|
40. **Given** a PF2e combatant was set to "Elite", **When** the DM toggles back to "Normal", **Then** the stat block reverts to base stats, the combatant's HP/AC revert, and the name prefix is removed.
|
||||||
|
41. **Given** a D&D combatant's stat block is open, **When** the DM views the Hit Points line, **Then** a Min/Avg/Max toggle is visible, set to "Avg".
|
||||||
|
42. **Given** a D&D combatant with `3d6 + 6` hit points (average 16) is at full health, **When** the DM selects "Max", **Then** the stat block shows 24 hit points and the combatant's HP becomes 24/24.
|
||||||
|
43. **Given** the same combatant, **When** the DM selects "Min", **Then** the stat block shows 9 hit points and the combatant's HP becomes 9/9.
|
||||||
|
44. **Given** a D&D combatant set to "Max" has taken 5 damage (19/24), **When** the DM selects "Avg", **Then** the combatant's HP becomes 11/16 — the damage taken is preserved.
|
||||||
|
45. **Given** a D&D combatant was set to "Max" and the page is reloaded, **When** the DM opens the stat block, **Then** the toggle shows "Max" and the maximum hit points are displayed.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
@@ -216,6 +232,11 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
|
|||||||
- Attack with multiple on-hit effects (e.g., `["grab", "knockdown"]`): all effects shown, joined with "and" (e.g., "plus Grab and Knockdown").
|
- Attack with multiple on-hit effects (e.g., `["grab", "knockdown"]`): all effects shown, joined with "and" (e.g., "plus Grab and Knockdown").
|
||||||
- Attack effect slug with creature-name prefix (e.g., `"lich-siphon-life"` on a Lich): the creature-name prefix is stripped, rendering as "Siphon Life".
|
- Attack effect slug with creature-name prefix (e.g., `"lich-siphon-life"` on a Lich): the creature-name prefix is stripped, rendering as "Siphon Life".
|
||||||
- Frequency `per` value variations (e.g., "day", "round", "turn"): the value is rendered as-is in the "(N/per)" format.
|
- Frequency `per` value variations (e.g., "day", "round", "turn"): the value is rendered as-is in the "(N/per)" format.
|
||||||
|
- HP formula that is not a dice pool (empty, prose such as "equal to the summoner's", or a flat number): the Min/Avg/Max toggle is omitted and the printed average is shown unchanged.
|
||||||
|
- HP formula written with an en dash or em dash instead of a hyphen (present in bundled source data, e.g. `2d6 – 2`): parsed as a negative modifier.
|
||||||
|
- Hit Dice modifier larger than the dice pool (e.g. `1d4 - 10`): the resulting hit points are floored at 1.
|
||||||
|
- Toggling from Min to Max: applies the full swing in a single operation.
|
||||||
|
- Combatant whose max HP was edited by hand before toggling: the edit is preserved as an offset, since the toggle applies the delta between variants rather than an absolute value.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -396,7 +417,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
|||||||
- **Source** (`BestiarySource`): A D&D or PF2e publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level.
|
- **Source** (`BestiarySource`): A D&D or PF2e publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level.
|
||||||
- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`. For PF2e creatures, each spell entry inside `spellcasting` carries full per-spell data (slug, level, traits, range, action cost, target/area, duration, defense, description, heightening) extracted from the embedded `items[type=spell]` data on the source NPC, enabling inline spell description display without additional fetches. PF2e creatures also carry an `equipment` list of carried items (weapons, consumables) extracted from `items[type=weapon]` and `items[type=consumable]` entries, each with name, level, traits, description, and (for scrolls) embedded spell data. PF2e attack entries carry an optional `attackEffects` list of on-hit effect names. PF2e ability entries carry an optional `frequency` with `max` and `per` fields. PF2e creature perception carries an optional `details` string (e.g., "smoke vision").
|
- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`. For PF2e creatures, each spell entry inside `spellcasting` carries full per-spell data (slug, level, traits, range, action cost, target/area, duration, defense, description, heightening) extracted from the embedded `items[type=spell]` data on the source NPC, enabling inline spell description display without additional fetches. PF2e creatures also carry an `equipment` list of carried items (weapons, consumables) extracted from `items[type=weapon]` and `items[type=consumable]` entries, each with name, level, traits, description, and (for scrolls) embedded spell data. PF2e attack entries carry an optional `attackEffects` list of on-hit effect names. PF2e ability entries carry an optional `frequency` with `max` and `per` fields. PF2e creature perception carries an optional `details` string (e.g., "smoke vision").
|
||||||
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
|
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
|
||||||
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation. PF2e bestiary-linked combatants may also carry a `creatureAdjustment` (`"weak" | "elite"`) indicating the active PF2e weak/elite adjustment, persisted across reloads.
|
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation. PF2e bestiary-linked combatants may also carry a `creatureAdjustment` (`"weak" | "elite"`) indicating the active PF2e weak/elite adjustment, persisted across reloads. D&D bestiary-linked combatants may carry an `hpVariant` (`"min" | "max"`) indicating that their max HP was set to the lowest or highest possible Hit Dice result instead of the printed average, likewise persisted across reloads.
|
||||||
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
||||||
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
|
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
|
||||||
- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
|
- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
|
||||||
|
|||||||
Reference in New Issue
Block a user