diff --git a/apps/web/src/__tests__/export-import.test.ts b/apps/web/src/__tests__/export-import.test.ts index 7a2b309..88981f7 100644 --- a/apps/web/src/__tests__/export-import.test.ts +++ b/apps/web/src/__tests__/export-import.test.ts @@ -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" }], diff --git a/apps/web/src/components/__tests__/settings-modal.test.tsx b/apps/web/src/components/__tests__/settings-modal.test.tsx index 85cf9e9..6a26630 100644 --- a/apps/web/src/components/__tests__/settings-modal.test.tsx +++ b/apps/web/src/components/__tests__/settings-modal.test.tsx @@ -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 () => { diff --git a/apps/web/src/components/__tests__/stat-block.test.tsx b/apps/web/src/components/__tests__/stat-block.test.tsx index 07ca001..94aae60 100644 --- a/apps/web/src/components/__tests__/stat-block.test.tsx +++ b/apps/web/src/components/__tests__/stat-block.test.tsx @@ -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(); } +/** Owns the variant the way the encounter state does in the real app. */ +function HpVariantHarness({ creature }: Readonly<{ creature: Creature }>) { + const [variant, setVariant] = useState(undefined); + return ( + 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( + , + ); + expect( + screen.queryByRole("button", { name: "Max" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("50")).toBeInTheDocument(); + }); + + it("shows the printed average until a variant is picked", () => { + render(); + 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(); + + 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(); + + 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(); + + 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(); + + await user.click(screen.getByRole("button", { name: "Min" })); + + expect(screen.getByText("(2d6)")).toBeInTheDocument(); + }); + }); + describe("ability scores", () => { it("renders all 6 ability labels", () => { renderStatBlock(GOBLIN); diff --git a/apps/web/src/components/dnd-stat-block.tsx b/apps/web/src/components/dnd-stat-block.tsx index ea759b8..bf362d3 100644 --- a/apps/web/src/components/dnd-stat-block.tsx +++ b/apps/web/src/components/dnd-stat-block.tsx @@ -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) { +/** 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[] = [ + { value: "min", label: "Min" }, + { value: undefined, label: "Avg" }, + { value: "max", label: "Max" }, +]; + +export function DndStatBlock({ + creature, + combatantId, + hpVariant, + onSetHpVariant, +}: Readonly) { const abilities = [ { label: "STR", score: creature.abilities.str }, { label: "DEX", score: creature.abilities.dex }, @@ -35,6 +70,17 @@ export function DndStatBlock({ creature }: Readonly) { 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 (
{/* Header */} @@ -68,8 +114,22 @@ export function DndStatBlock({ creature }: Readonly) {
Hit Points{" "} - {creature.hp.average}{" "} + + {displayedHp} + {" "} ({creature.hp.formula}) + {canPickVariant ? ( + + onSetHpVariant(combatantId, variant, range) + } + size="xs" + label="Hit point variant" + className="mt-1" + /> + ) : null}
Speed {creature.speed} diff --git a/apps/web/src/components/pf2e-stat-block.tsx b/apps/web/src/components/pf2e-stat-block.tsx index 1682ccf..a6cbe93 100644 --- a/apps/web/src/components/pf2e-stat-block.tsx +++ b/apps/web/src/components/pf2e-stat-block.tsx @@ -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 && ( -
- {(["weak", "normal", "elite"] as const).map((opt) => { - const value = opt === "normal" ? undefined : opt; - const isActive = adjustment === value; - return ( - - ); - })} -
+ + onSetAdjustment(combatantId, value, baseCreature) + } + size="xs" + label="Creature adjustment" + className="mt-1" + /> )}
{displayTraits(creature.traits).map((trait) => ( diff --git a/apps/web/src/components/settings-modal.tsx b/apps/web/src/components/settings-modal.tsx index b6f9e96..088a187 100644 --- a/apps/web/src/components/settings-modal.tsx +++ b/apps/web/src/components/settings-modal.tsx @@ -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[] = [ { 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[] = [ + { + value: "system", + label: ( + <> + + System + + ), + }, + { + value: "light", + label: ( + <> + + Light + + ), + }, + { + value: "dark", + label: ( + <> + + Dark + + ), + }, ]; export function SettingsModal({ open, onClose }: Readonly) { @@ -39,50 +62,26 @@ export function SettingsModal({ open, onClose }: Readonly) { Game System -
- {EDITION_OPTIONS.map((opt) => ( - - ))} -
+
Theme -
- {THEME_OPTIONS.map((opt) => { - const Icon = opt.icon; - return ( - - ); - })} -
+
diff --git a/apps/web/src/components/stat-block-panel.tsx b/apps/web/src/components/stat-block-panel.tsx index c1bf242..6f4599c 100644 --- a/apps/web/src/components/stat-block-panel.tsx +++ b/apps/web/src/components/stat-block-panel.tsx @@ -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 ; + return ( + + ); } 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) { diff --git a/apps/web/src/components/ui/segmented-control.tsx b/apps/web/src/components/ui/segmented-control.tsx new file mode 100644 index 0000000..0b9b2c6 --- /dev/null +++ b/apps/web/src/components/ui/segmented-control.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import { cn } from "../../lib/utils.js"; + +export interface SegmentedOption { + readonly value: T; + readonly label: ReactNode; +} + +interface SegmentedControlProps { + options: readonly SegmentedOption[]; + 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({ + options, + value, + onChange, + size = "sm", + stretch = false, + label, + className, +}: Readonly>) { + return ( +
+ {options.map((option) => { + const isActive = option.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/hooks/__tests__/use-encounter-hp-variant.test.ts b/apps/web/src/hooks/__tests__/use-encounter-hp-variant.test.ts new file mode 100644 index 0000000..3f25aa4 --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-encounter-hp-variant.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index 3c8e972..00ef955 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -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" }), [], diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 3279bd2..e4b79e1 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -27,6 +27,7 @@ export { setAcUseCase } from "./set-ac-use-case.js"; export { setConditionValueUseCase } from "./set-condition-value-use-case.js"; export { setCrUseCase } from "./set-cr-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 { setSideUseCase } from "./set-side-use-case.js"; export { setTempHpUseCase } from "./set-temp-hp-use-case.js"; diff --git a/packages/application/src/set-hp-variant-use-case.ts b/packages/application/src/set-hp-variant-use-case.ts new file mode 100644 index 0000000..f3570a3 --- /dev/null +++ b/packages/application/src/set-hp-variant-use-case.ts @@ -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), + ); +} diff --git a/packages/domain/src/__tests__/hp-range.test.ts b/packages/domain/src/__tests__/hp-range.test.ts new file mode 100644 index 0000000..ed2f250 --- /dev/null +++ b/packages/domain/src/__tests__/hp-range.test.ts @@ -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); + }); +}); diff --git a/packages/domain/src/__tests__/rehydrate-combatant.test.ts b/packages/domain/src/__tests__/rehydrate-combatant.test.ts index 12f1615..bc81e69 100644 --- a/packages/domain/src/__tests__/rehydrate-combatant.test.ts +++ b/packages/domain/src/__tests__/rehydrate-combatant.test.ts @@ -301,6 +301,34 @@ describe("rehydrateCombatant", () => { 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", () => { const result = rehydrateCombatant({ ...minimalCombatant(), diff --git a/packages/domain/src/__tests__/set-hp-variant.test.ts b/packages/domain/src/__tests__/set-hp-variant.test.ts new file mode 100644 index 0000000..abf605f --- /dev/null +++ b/packages/domain/src/__tests__/set-hp-variant.test.ts @@ -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 { + 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, + }, + ]); + }); +}); diff --git a/packages/domain/src/events.ts b/packages/domain/src/events.ts index 5d5258a..d071bbd 100644 --- a/packages/domain/src/events.ts +++ b/packages/domain/src/events.ts @@ -152,6 +152,14 @@ export interface CreatureAdjustmentSet { 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 { readonly type: "EncounterCleared"; readonly combatantCount: number; @@ -198,6 +206,7 @@ export type DomainEvent = | PersistentDamageAdded | PersistentDamageRemoved | CreatureAdjustmentSet + | HpVariantSet | EncounterCleared | PlayerCharacterCreated | PlayerCharacterUpdated diff --git a/packages/domain/src/hp-range.ts b/packages/domain/src/hp-range.ts new file mode 100644 index 0000000..6048e10 --- /dev/null +++ b/packages/domain/src/hp-range.ts @@ -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 = 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; +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index ca76459..79dfe54 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -82,6 +82,7 @@ export type { CurrentHpAdjusted, DomainEvent, EncounterCleared, + HpVariantSet, InitiativeSet, MaxHpSet, PersistentDamageAdded, @@ -97,6 +98,13 @@ export type { TurnRetreated, } from "./events.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 { calculateInitiative, @@ -153,6 +161,10 @@ export type { RulesEdition } from "./rules-edition.js"; export { type SetAcSuccess, setAc } from "./set-ac.js"; export { type SetCrSuccess, setCr } from "./set-cr.js"; export { type SetHpSuccess, setHp } from "./set-hp.js"; +export { + type SetHpVariantSuccess, + setHpVariant, +} from "./set-hp-variant.js"; export { type SetInitiativeSuccess, setInitiative, diff --git a/packages/domain/src/rehydrate-combatant.ts b/packages/domain/src/rehydrate-combatant.ts index c25f3d8..5cdb58c 100644 --- a/packages/domain/src/rehydrate-combatant.ts +++ b/packages/domain/src/rehydrate-combatant.ts @@ -2,6 +2,8 @@ import type { ConditionEntry, ConditionId } from "./conditions.js"; import { VALID_CONDITION_IDS } from "./conditions.js"; import { creatureId } from "./creature-types.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 { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage-types.js"; import { @@ -144,6 +146,9 @@ function parseOptionalFields(entry: Record) { entry.creatureAdjustment, VALID_ADJUSTMENTS, ) as "weak" | "elite" | undefined, + hpVariant: validateSetMember(entry.hpVariant, VALID_HP_VARIANTS) as + | HpVariant + | undefined, cr: validateCr(entry.cr), side: validateSide(entry.side), color: validateSetMember(entry.color, VALID_PLAYER_COLORS), diff --git a/packages/domain/src/set-hp-variant.ts b/packages/domain/src/set-hp-variant.ts new file mode 100644 index 0000000..fc3c0d2 --- /dev/null +++ b/packages/domain/src/set-hp-variant.ts @@ -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, + }, + ], + }; +} diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts index bbdddc2..e383ae7 100644 --- a/packages/domain/src/types.ts +++ b/packages/domain/src/types.ts @@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId { import type { ConditionEntry } from "./conditions.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 { PlayerCharacterId } from "./player-character-types.js"; @@ -23,6 +24,7 @@ export interface Combatant { readonly isConcentrating?: boolean; readonly creatureId?: CreatureId; readonly creatureAdjustment?: "weak" | "elite"; + readonly hpVariant?: HpVariant; readonly cr?: string; readonly side?: "party" | "enemy"; readonly color?: string; diff --git a/specs/004-bestiary/spec.md b/specs/004-bestiary/spec.md index ccb51b3..584f961 100644 --- a/specs/004-bestiary/spec.md +++ b/specs/004-bestiary/spec.md @@ -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. +**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 - **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-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-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 @@ -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). 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. +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 @@ -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 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. +- 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. - **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. -- **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. - **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.