Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968cc7239b | ||
|
|
d9562f850c | ||
|
|
ec9f2e7877 | ||
|
|
c4079c384b | ||
|
|
a4285fc415 | ||
|
|
9c0e3398f1 | ||
|
|
9cdf004c15 | ||
|
|
8bf69fd47d | ||
|
|
7b83e3c3ea | ||
|
|
c3c2cad798 | ||
|
|
3f6140303d | ||
|
|
fd30278474 | ||
|
|
278c06221f | ||
|
|
722e8cc627 | ||
|
|
64741956dd | ||
|
|
6336dec38a |
@@ -20,15 +20,15 @@
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^6.2.0"
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,6 @@ describe("App integration", () => {
|
||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||
|
||||
// Verify HP displays — currentHp and maxHp both show "59"
|
||||
expect(screen.getByText("/")).toBeInTheDocument();
|
||||
const hpButton = screen.getByRole("button", {
|
||||
name: "Current HP: 59 (healthy)",
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { CombatantRow } from "../combatant-row.js";
|
||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||
|
||||
const TEMP_HP_REGEX = /^\+\d/;
|
||||
|
||||
// Mock persistence — no localStorage interaction
|
||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||
loadEncounter: () => null,
|
||||
@@ -123,14 +125,14 @@ describe("CombatantRow", () => {
|
||||
expect(nameContainer).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows '--' for current HP when no maxHp is set", () => {
|
||||
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
||||
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows concentration icon when isConcentrating is true", () => {
|
||||
@@ -193,4 +195,106 @@ describe("CombatantRow", () => {
|
||||
screen.getByRole("button", { name: "Roll initiative" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("concentration pulse", () => {
|
||||
it("pulses when currentHp drops on a concentrating combatant", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
isConcentrating: true,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, currentHp: 10 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).toContain("animate-concentration-pulse");
|
||||
});
|
||||
|
||||
it("does not pulse when not concentrating", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
isConcentrating: false,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, currentHp: 10 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).not.toContain("animate-concentration-pulse");
|
||||
});
|
||||
|
||||
it("pulses when temp HP absorbs all damage on a concentrating combatant", () => {
|
||||
const combatant = {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 8,
|
||||
isConcentrating: true,
|
||||
};
|
||||
const { rerender, container } = renderRow({ combatant });
|
||||
// Temp HP absorbs all damage, currentHp unchanged
|
||||
rerender(
|
||||
<CombatantRow
|
||||
combatant={{ ...combatant, tempHp: 3 }}
|
||||
isActive={false}
|
||||
/>,
|
||||
);
|
||||
const row = container.firstElementChild;
|
||||
expect(row?.className).toContain("animate-concentration-pulse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("temp HP display", () => {
|
||||
it("shows +N when combatant has temp HP", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 5,
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("+5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show +N when combatant has no temp HP", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
},
|
||||
});
|
||||
expect(screen.queryByText(TEMP_HP_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("temp HP display uses cyan color", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 8,
|
||||
},
|
||||
});
|
||||
const tempHpEl = screen.getByText("+8");
|
||||
expect(tempHpEl.className).toContain("text-cyan-400");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,15 +11,21 @@ afterEach(cleanup);
|
||||
function renderPopover(
|
||||
overrides: Partial<{
|
||||
onAdjust: (delta: number) => void;
|
||||
onSetTempHp: (value: number) => void;
|
||||
onClose: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const result = render(
|
||||
<HpAdjustPopover onAdjust={onAdjust} onClose={onClose} />,
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onAdjust, onClose };
|
||||
return { ...result, onAdjust, onSetTempHp, onClose };
|
||||
}
|
||||
|
||||
describe("HpAdjustPopover", () => {
|
||||
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
|
||||
await user.type(input, "12abc34");
|
||||
expect(input).toHaveValue("1234");
|
||||
});
|
||||
|
||||
describe("temp HP", () => {
|
||||
it("shield button calls onSetTempHp with entered value and closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetTempHp, onClose } = renderPopover();
|
||||
await user.type(screen.getByPlaceholderText("HP"), "8");
|
||||
await user.click(screen.getByRole("button", { name: "Set temp HP" }));
|
||||
expect(onSetTempHp).toHaveBeenCalledWith(8);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shield button is disabled when input is empty", () => {
|
||||
renderPopover();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Set temp HP" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shield button is disabled when input is '0'", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPopover();
|
||||
await user.type(screen.getByPlaceholderText("HP"), "0");
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Set temp HP" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,8 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
setInitiative: vi.fn(),
|
||||
setHp: vi.fn(),
|
||||
adjustHp: vi.fn(),
|
||||
setTempHp: vi.fn(),
|
||||
hasTempHp: false,
|
||||
setAc: vi.fn(),
|
||||
toggleCondition: vi.fn(),
|
||||
toggleConcentration: vi.fn(),
|
||||
@@ -92,7 +94,8 @@ describe("TurnNavigation", () => {
|
||||
renderNav();
|
||||
const badge = screen.getByText("R1");
|
||||
const name = screen.getByText("Goblin");
|
||||
expect(badge.parentElement).toBe(name.parentElement);
|
||||
// badge text is inside inner span > outer span, name is a direct child
|
||||
expect(badge.closest(".flex")).toBe(name.parentElement);
|
||||
});
|
||||
|
||||
it("updates the round badge when round changes", () => {
|
||||
|
||||
@@ -19,17 +19,15 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 28 32"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="var(--color-border)"
|
||||
fillOpacity={0.5}
|
||||
stroke="none"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
|
||||
</svg>
|
||||
<span className="relative font-medium text-xs leading-none">
|
||||
<span className="relative -mt-0.5 font-medium text-xs leading-none">
|
||||
{value == null ? "\u2014" : String(value)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -493,8 +493,17 @@ export function ActionBar({
|
||||
};
|
||||
|
||||
const toggleBrowseMode = () => {
|
||||
setBrowseMode((m) => !m);
|
||||
clearInput();
|
||||
setBrowseMode((prev) => {
|
||||
const next = !prev;
|
||||
setSuggestionIndex(-1);
|
||||
setQueued(null);
|
||||
if (next) {
|
||||
handleBrowseSearch(nameInput);
|
||||
} else {
|
||||
handleAddSearch(nameInput);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
clearCustomFields();
|
||||
};
|
||||
|
||||
@@ -555,6 +564,7 @@ export function ActionBar({
|
||||
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||
browseMode && "text-accent",
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={toggleBrowseMode}
|
||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||
aria-label={
|
||||
|
||||
@@ -29,6 +29,7 @@ interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
@@ -171,7 +172,12 @@ function MaxHpDisplay({
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
|
||||
className={cn(
|
||||
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
|
||||
maxHp === undefined
|
||||
? "text-muted-foreground text-sm"
|
||||
: "text-muted-foreground text-xs",
|
||||
)}
|
||||
>
|
||||
{maxHp ?? "Max"}
|
||||
</button>
|
||||
@@ -181,51 +187,47 @@ function MaxHpDisplay({
|
||||
function ClickableHp({
|
||||
currentHp,
|
||||
maxHp,
|
||||
tempHp,
|
||||
onAdjust,
|
||||
dimmed,
|
||||
onSetTempHp,
|
||||
}: Readonly<{
|
||||
currentHp: number | undefined;
|
||||
maxHp: number | undefined;
|
||||
tempHp: number | undefined;
|
||||
onAdjust: (delta: number) => void;
|
||||
dimmed?: boolean;
|
||||
onSetTempHp: (value: number) => void;
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
if (maxHp === undefined) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
role="status"
|
||||
aria-label="No HP set"
|
||||
>
|
||||
--
|
||||
</span>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
aria-label={`Current HP: ${currentHp} (${status})`}
|
||||
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||
className={cn(
|
||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
||||
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
|
||||
status === "bloodied" && "text-amber-400",
|
||||
status === "unconscious" && "text-red-400",
|
||||
status === "healthy" && "text-foreground",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{currentHp}
|
||||
</button>
|
||||
{!!tempHp && (
|
||||
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||
+{tempHp}
|
||||
</span>
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -346,7 +348,7 @@ function InitiativeDisplay({
|
||||
value={draft}
|
||||
placeholder="--"
|
||||
className={cn(
|
||||
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
||||
"h-7 w-full text-center text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
@@ -443,6 +445,7 @@ export function CombatantRow({
|
||||
removeCombatant,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
@@ -475,24 +478,27 @@ export function CombatantRow({
|
||||
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const prevHpRef = useRef(currentHp);
|
||||
const prevTempHpRef = useRef(combatant.tempHp);
|
||||
const [isPulsing, setIsPulsing] = useState(false);
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const prevHp = prevHpRef.current;
|
||||
const prevTempHp = prevTempHpRef.current;
|
||||
prevHpRef.current = currentHp;
|
||||
prevTempHpRef.current = combatant.tempHp;
|
||||
|
||||
if (
|
||||
prevHp !== undefined &&
|
||||
currentHp !== undefined &&
|
||||
currentHp < prevHp &&
|
||||
combatant.isConcentrating
|
||||
) {
|
||||
const realHpDropped =
|
||||
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||
const tempHpDropped =
|
||||
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||
|
||||
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||
setIsPulsing(true);
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||
}
|
||||
}, [currentHp, combatant.isConcentrating]);
|
||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!combatant.isConcentrating) {
|
||||
@@ -514,7 +520,7 @@ export function CombatantRow({
|
||||
isPulsing && "animate-concentration-pulse",
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
||||
<div className="grid grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] items-center gap-3 py-2">
|
||||
{/* Concentration */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -530,13 +536,20 @@ export function CombatantRow({
|
||||
</button>
|
||||
|
||||
{/* Initiative */}
|
||||
<InitiativeDisplay
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
dimmed={dimmed}
|
||||
onSetInitiative={setInitiative}
|
||||
onRollInitiative={onRollInitiative}
|
||||
/>
|
||||
<div className="rounded-md bg-muted/30 px-1">
|
||||
<InitiativeDisplay
|
||||
initiative={initiative}
|
||||
combatantId={id}
|
||||
dimmed={dimmed}
|
||||
onSetInitiative={setInitiative}
|
||||
onRollInitiative={onRollInitiative}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* Name + Conditions */}
|
||||
<div
|
||||
@@ -585,32 +598,27 @@ export function CombatantRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AC */}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md tabular-nums",
|
||||
maxHp === undefined
|
||||
? ""
|
||||
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<ClickableHp
|
||||
currentHp={currentHp}
|
||||
maxHp={maxHp}
|
||||
tempHp={combatant.tempHp}
|
||||
onAdjust={(delta) => adjustHp(id, delta)}
|
||||
dimmed={dimmed}
|
||||
onSetTempHp={(value) => setTempHp(id, value)}
|
||||
/>
|
||||
{maxHp !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm tabular-nums",
|
||||
dimmed && "opacity-50",
|
||||
)}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||
)}
|
||||
<div className={cn(dimmed && "opacity-50")}>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||
</div>
|
||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
@@ -121,25 +122,28 @@ export function ConditionPicker({
|
||||
const isActive = active.has(def.id);
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
key={def.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
||||
<Tooltip key={def.id} content={def.description} className="block">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
@@ -71,22 +72,22 @@ export function ConditionTags({
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<button
|
||||
key={condId}
|
||||
type="button"
|
||||
title={def.label}
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Heart, Sword } from "lucide-react";
|
||||
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,10 +12,15 @@ const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
|
||||
interface HpAdjustPopoverProps {
|
||||
readonly onAdjust: (delta: number) => void;
|
||||
readonly onSetTempHp: (value: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
export function HpAdjustPopover({
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
onClose,
|
||||
}: HpAdjustPopoverProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -130,6 +135,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
||||
>
|
||||
<Heart size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-cyan-400 transition-colors hover:bg-cyan-400/10 hover:text-cyan-300 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (isValid && parsedValue) {
|
||||
onSetTempHp(parsedValue);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Set temp HP"
|
||||
aria-label="Set temp HP"
|
||||
>
|
||||
<ShieldPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,8 +25,10 @@ export function TurnNavigation() {
|
||||
</Button>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
R{encounter.roundNumber}
|
||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||
<span className="-mt-[3px] inline-block">
|
||||
R{encounter.roundNumber}
|
||||
</span>
|
||||
</span>
|
||||
{activeCombatant ? (
|
||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||
|
||||
55
apps/web/src/components/ui/tooltip.tsx
Normal file
55
apps/web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<TooltipProps>) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
function show() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPos({
|
||||
top: rect.top - 4,
|
||||
left: rect.left + rect.width / 2,
|
||||
});
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setPos(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
onPointerEnter={show}
|
||||
onPointerLeave={hide}
|
||||
className={className ?? "inline-flex"}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{pos !== null &&
|
||||
createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
className="pointer-events-none fixed z-[60] max-w-64 -translate-x-1/2 -translate-y-full rounded-md border border-border bg-background px-2.5 py-1.5 text-foreground text-xs leading-snug shadow-lg"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{content}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setAcUseCase,
|
||||
setHpUseCase,
|
||||
setInitiativeUseCase,
|
||||
setTempHpUseCase,
|
||||
toggleConcentrationUseCase,
|
||||
toggleConditionUseCase,
|
||||
} from "@initiative/application";
|
||||
@@ -215,6 +216,19 @@ export function useEncounter() {
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) => {
|
||||
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setAcUseCase(makeStore(), id, value);
|
||||
@@ -376,6 +390,10 @@ export function useEncounter() {
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const hasTempHp = encounter.combatants.some(
|
||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||
);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
(c) => c.creatureId != null,
|
||||
@@ -388,6 +406,7 @@ export function useEncounter() {
|
||||
encounter,
|
||||
events,
|
||||
isEmpty,
|
||||
hasTempHp,
|
||||
hasCreatureCombatants,
|
||||
canRollAllInitiative,
|
||||
advanceTurn,
|
||||
@@ -399,6 +418,7 @@ export function useEncounter() {
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
toggleConcentration,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
|
||||
16
package.json
16
package.json
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"undici": ">=7.24.0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.7",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"jscpd": "^4.0.8",
|
||||
"knip": "^5.85.0",
|
||||
"lefthook": "^1.11.0",
|
||||
"oxlint": "^1.55.0",
|
||||
"oxlint-tsgolint": "^0.16.0",
|
||||
"knip": "^5.88.1",
|
||||
"lefthook": "^2.1.4",
|
||||
"oxlint": "^1.56.0",
|
||||
"oxlint-tsgolint": "^0.17.1",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "lefthook install",
|
||||
|
||||
@@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||
|
||||
24
packages/application/src/set-temp-hp-use-case.ts
Normal file
24
packages/application/src/set-temp-hp-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
setTempHp,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export function setTempHpUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
tempHp: number | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = setTempHp(encounter, combatantId, tempHp);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
}
|
||||
@@ -6,12 +6,18 @@ import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(
|
||||
name: string,
|
||||
opts?: { maxHp: number; currentHp: number },
|
||||
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||
): Combatant {
|
||||
return {
|
||||
id: combatantId(name),
|
||||
name,
|
||||
...(opts ? { maxHp: opts.maxHp, currentHp: opts.currentHp } : {}),
|
||||
...(opts
|
||||
? {
|
||||
maxHp: opts.maxHp,
|
||||
currentHp: opts.currentHp,
|
||||
tempHp: opts.tempHp,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,4 +158,96 @@ describe("adjustHp", () => {
|
||||
expect(encounter.combatants[0].currentHp).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("temporary HP absorption", () => {
|
||||
it("damage fully absorbed by temp HP — currentHp unchanged", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", -5);
|
||||
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||
expect(encounter.combatants[0].tempHp).toBe(3);
|
||||
});
|
||||
|
||||
it("damage partially absorbed by temp HP — overflow reduces currentHp", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", -10);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
expect(encounter.combatants[0].currentHp).toBe(8);
|
||||
});
|
||||
|
||||
it("damage exceeding both temp HP and currentHp — both reach minimum", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 5, tempHp: 3 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", -50);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
expect(encounter.combatants[0].currentHp).toBe(0);
|
||||
});
|
||||
|
||||
it("healing does not restore temp HP", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 5);
|
||||
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||
expect(encounter.combatants[0].tempHp).toBe(3);
|
||||
});
|
||||
|
||||
it("temp HP cleared to undefined when fully depleted", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 5 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", -5);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||
});
|
||||
|
||||
it("emits only TempHpSet when damage fully absorbed", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 8 }),
|
||||
]);
|
||||
const { events } = successResult(e, "A", -3);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TempHpSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousTempHp: 8,
|
||||
newTempHp: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits both TempHpSet and CurrentHpAdjusted when damage overflows", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||
]);
|
||||
const { events } = successResult(e, "A", -10);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toEqual({
|
||||
type: "TempHpSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousTempHp: 3,
|
||||
newTempHp: undefined,
|
||||
});
|
||||
expect(events[1]).toEqual({
|
||||
type: "CurrentHpAdjusted",
|
||||
combatantId: combatantId("A"),
|
||||
previousHp: 15,
|
||||
newHp: 8,
|
||||
delta: -10,
|
||||
});
|
||||
});
|
||||
|
||||
it("damage with no temp HP works as before", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const { encounter, events } = successResult(e, "A", -5);
|
||||
expect(encounter.combatants[0].currentHp).toBe(10);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("CurrentHpAdjusted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,34 @@ describe("setHp", () => {
|
||||
expect(encounter.combatants[0].maxHp).toBeUndefined();
|
||||
expect(encounter.combatants[0].currentHp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears tempHp when maxHp is cleared", () => {
|
||||
const e = enc([
|
||||
{
|
||||
id: combatantId("A"),
|
||||
name: "A",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 5,
|
||||
},
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", undefined);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves tempHp when maxHp is updated", () => {
|
||||
const e = enc([
|
||||
{
|
||||
id: combatantId("A"),
|
||||
name: "A",
|
||||
maxHp: 20,
|
||||
currentHp: 15,
|
||||
tempHp: 5,
|
||||
},
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 25);
|
||||
expect(encounter.combatants[0].tempHp).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invariants", () => {
|
||||
|
||||
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
182
packages/domain/src/__tests__/set-temp-hp.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setTempHp } from "../set-temp-hp.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
function makeCombatant(
|
||||
name: string,
|
||||
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||
): Combatant {
|
||||
return {
|
||||
id: combatantId(name),
|
||||
name,
|
||||
...(opts
|
||||
? {
|
||||
maxHp: opts.maxHp,
|
||||
currentHp: opts.currentHp,
|
||||
tempHp: opts.tempHp,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function enc(combatants: Combatant[]): Encounter {
|
||||
return { combatants, activeIndex: 0, roundNumber: 1 };
|
||||
}
|
||||
|
||||
function successResult(
|
||||
encounter: Encounter,
|
||||
id: string,
|
||||
tempHp: number | undefined,
|
||||
) {
|
||||
const result = setTempHp(encounter, combatantId(id), tempHp);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("setTempHp", () => {
|
||||
describe("acceptance scenarios", () => {
|
||||
it("sets temp HP on a combatant with HP tracking enabled", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const { encounter } = successResult(e, "A", 8);
|
||||
expect(encounter.combatants[0].tempHp).toBe(8);
|
||||
});
|
||||
|
||||
it("keeps higher value when existing temp HP is greater", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 3);
|
||||
expect(encounter.combatants[0].tempHp).toBe(5);
|
||||
});
|
||||
|
||||
it("replaces when new value is higher", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 3 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 7);
|
||||
expect(encounter.combatants[0].tempHp).toBe(7);
|
||||
});
|
||||
|
||||
it("clears temp HP when set to undefined", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", undefined);
|
||||
expect(encounter.combatants[0].tempHp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invariants", () => {
|
||||
it("is pure — same input produces same output", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const r1 = setTempHp(e, combatantId("A"), 5);
|
||||
const r2 = setTempHp(e, combatantId("A"), 5);
|
||||
expect(r1).toEqual(r2);
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const original = JSON.parse(JSON.stringify(e));
|
||||
setTempHp(e, combatantId("A"), 5);
|
||||
expect(e).toEqual(original);
|
||||
});
|
||||
|
||||
it("emits TempHpSet event with correct shape", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15, tempHp: 3 }),
|
||||
]);
|
||||
const { events } = successResult(e, "A", 7);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TempHpSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousTempHp: 3,
|
||||
newTempHp: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves activeIndex and roundNumber", () => {
|
||||
const e = {
|
||||
combatants: [
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10 }),
|
||||
makeCombatant("B"),
|
||||
],
|
||||
activeIndex: 1,
|
||||
roundNumber: 5,
|
||||
};
|
||||
const { encounter } = successResult(e, "A", 5);
|
||||
expect(encounter.activeIndex).toBe(1);
|
||||
expect(encounter.roundNumber).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error cases", () => {
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = setTempHp(e, combatantId("Z"), 5);
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
|
||||
it("returns error when HP tracking is not enabled", () => {
|
||||
const e = enc([makeCombatant("A")]);
|
||||
const result = setTempHp(e, combatantId("A"), 5);
|
||||
expectDomainError(result, "no-hp-tracking");
|
||||
});
|
||||
|
||||
it("rejects temp HP of 0", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = setTempHp(e, combatantId("A"), 0);
|
||||
expectDomainError(result, "invalid-temp-hp");
|
||||
});
|
||||
|
||||
it("rejects negative temp HP", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = setTempHp(e, combatantId("A"), -3);
|
||||
expectDomainError(result, "invalid-temp-hp");
|
||||
});
|
||||
|
||||
it("rejects non-integer temp HP", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 10 })]);
|
||||
const result = setTempHp(e, combatantId("A"), 2.5);
|
||||
expectDomainError(result, "invalid-temp-hp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("does not affect other combatants", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 15 }),
|
||||
makeCombatant("B", { maxHp: 30, currentHp: 25, tempHp: 4 }),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 5);
|
||||
expect(encounter.combatants[1].tempHp).toBe(4);
|
||||
});
|
||||
|
||||
it("does not affect currentHp or maxHp", () => {
|
||||
const e = enc([makeCombatant("A", { maxHp: 20, currentHp: 15 })]);
|
||||
const { encounter } = successResult(e, "A", 8);
|
||||
expect(encounter.combatants[0].maxHp).toBe(20);
|
||||
expect(encounter.combatants[0].currentHp).toBe(15);
|
||||
});
|
||||
|
||||
it("event reflects no change when existing value equals new value", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", { maxHp: 20, currentHp: 10, tempHp: 5 }),
|
||||
]);
|
||||
const { events } = successResult(e, "A", 5);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TempHpSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousTempHp: 5,
|
||||
newTempHp: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,24 +54,52 @@ export function adjustHp(
|
||||
}
|
||||
|
||||
const previousHp = target.currentHp;
|
||||
const newHp = Math.max(0, Math.min(target.maxHp, previousHp + delta));
|
||||
const previousTempHp = target.tempHp ?? 0;
|
||||
let newTempHp = previousTempHp;
|
||||
let effectiveDelta = delta;
|
||||
|
||||
if (delta < 0 && previousTempHp > 0) {
|
||||
const absorbed = Math.min(previousTempHp, Math.abs(delta));
|
||||
newTempHp = previousTempHp - absorbed;
|
||||
effectiveDelta = delta + absorbed;
|
||||
}
|
||||
|
||||
const newHp = Math.max(
|
||||
0,
|
||||
Math.min(target.maxHp, previousHp + effectiveDelta),
|
||||
);
|
||||
|
||||
const events: DomainEvent[] = [];
|
||||
|
||||
if (newTempHp !== previousTempHp) {
|
||||
events.push({
|
||||
type: "TempHpSet",
|
||||
combatantId,
|
||||
previousTempHp: previousTempHp || undefined,
|
||||
newTempHp: newTempHp || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (newHp !== previousHp) {
|
||||
events.push({
|
||||
type: "CurrentHpAdjusted",
|
||||
combatantId,
|
||||
previousHp,
|
||||
newHp,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, currentHp: newHp } : c,
|
||||
c.id === combatantId
|
||||
? { ...c, currentHp: newHp, tempHp: newTempHp || undefined }
|
||||
: c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "CurrentHpAdjusted",
|
||||
combatantId,
|
||||
previousHp,
|
||||
newHp,
|
||||
delta,
|
||||
},
|
||||
],
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,63 +18,127 @@ export type ConditionId =
|
||||
export interface ConditionDefinition {
|
||||
readonly id: ConditionId;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" },
|
||||
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" },
|
||||
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" },
|
||||
{
|
||||
id: "blinded",
|
||||
label: "Blinded",
|
||||
description:
|
||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||
iconName: "EyeOff",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "charmed",
|
||||
label: "Charmed",
|
||||
description:
|
||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||
iconName: "Heart",
|
||||
color: "pink",
|
||||
},
|
||||
{
|
||||
id: "deafened",
|
||||
label: "Deafened",
|
||||
description: "Can't hear. Auto-fail hearing checks.",
|
||||
iconName: "EarOff",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "exhaustion",
|
||||
label: "Exhaustion",
|
||||
description:
|
||||
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
|
||||
iconName: "BatteryLow",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
id: "frightened",
|
||||
label: "Frightened",
|
||||
description:
|
||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||
iconName: "Siren",
|
||||
color: "orange",
|
||||
},
|
||||
{ id: "grappled", label: "Grappled", iconName: "Hand", color: "neutral" },
|
||||
{
|
||||
id: "grappled",
|
||||
label: "Grappled",
|
||||
description:
|
||||
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
|
||||
iconName: "Hand",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "incapacitated",
|
||||
label: "Incapacitated",
|
||||
description:
|
||||
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
||||
iconName: "Ban",
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
id: "invisible",
|
||||
label: "Invisible",
|
||||
description:
|
||||
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||
iconName: "Ghost",
|
||||
color: "violet",
|
||||
},
|
||||
{
|
||||
id: "paralyzed",
|
||||
label: "Paralyzed",
|
||||
description:
|
||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
iconName: "ZapOff",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
id: "petrified",
|
||||
label: "Petrified",
|
||||
description:
|
||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||
iconName: "Gem",
|
||||
color: "slate",
|
||||
},
|
||||
{ id: "poisoned", label: "Poisoned", iconName: "Droplet", color: "green" },
|
||||
{ id: "prone", label: "Prone", iconName: "ArrowDown", color: "neutral" },
|
||||
{
|
||||
id: "poisoned",
|
||||
label: "Poisoned",
|
||||
description: "Disadvantage on attack rolls and ability checks.",
|
||||
iconName: "Droplet",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
id: "prone",
|
||||
label: "Prone",
|
||||
description:
|
||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||
iconName: "ArrowDown",
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "restrained",
|
||||
label: "Restrained",
|
||||
description:
|
||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||
iconName: "Link",
|
||||
color: "neutral",
|
||||
},
|
||||
{ id: "stunned", label: "Stunned", iconName: "Sparkles", color: "yellow" },
|
||||
{
|
||||
id: "stunned",
|
||||
label: "Stunned",
|
||||
description:
|
||||
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||
iconName: "Sparkles",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
id: "unconscious",
|
||||
label: "Unconscious",
|
||||
description:
|
||||
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||
iconName: "Moon",
|
||||
color: "indigo",
|
||||
},
|
||||
|
||||
@@ -58,6 +58,13 @@ export interface CurrentHpAdjusted {
|
||||
readonly delta: number;
|
||||
}
|
||||
|
||||
export interface TempHpSet {
|
||||
readonly type: "TempHpSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly previousTempHp: number | undefined;
|
||||
readonly newTempHp: number | undefined;
|
||||
}
|
||||
|
||||
export interface TurnRetreated {
|
||||
readonly type: "TurnRetreated";
|
||||
readonly previousCombatantId: CombatantId;
|
||||
@@ -132,6 +139,7 @@ export type DomainEvent =
|
||||
| InitiativeSet
|
||||
| MaxHpSet
|
||||
| CurrentHpAdjusted
|
||||
| TempHpSet
|
||||
| TurnRetreated
|
||||
| RoundRetreated
|
||||
| AcSet
|
||||
|
||||
@@ -60,6 +60,7 @@ export type {
|
||||
PlayerCharacterUpdated,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
TempHpSet,
|
||||
TurnAdvanced,
|
||||
TurnRetreated,
|
||||
} from "./events.js";
|
||||
@@ -95,6 +96,7 @@ export {
|
||||
type SetInitiativeSuccess,
|
||||
setInitiative,
|
||||
} from "./set-initiative.js";
|
||||
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||
export {
|
||||
type ToggleConcentrationSuccess,
|
||||
toggleConcentration,
|
||||
|
||||
@@ -66,7 +66,12 @@ export function setHp(
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId
|
||||
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp }
|
||||
? {
|
||||
...c,
|
||||
maxHp: newMaxHp,
|
||||
currentHp: newCurrentHp,
|
||||
tempHp: newMaxHp === undefined ? undefined : c.tempHp,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
|
||||
78
packages/domain/src/set-temp-hp.ts
Normal file
78
packages/domain/src/set-temp-hp.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface SetTempHpSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that sets or clears a combatant's temporary HP.
|
||||
*
|
||||
* - Setting tempHp when the combatant already has tempHp keeps the higher value.
|
||||
* - Clearing tempHp (undefined) removes temp HP entirely.
|
||||
* - Requires HP tracking to be enabled (maxHp must be set).
|
||||
*/
|
||||
export function setTempHp(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
tempHp: number | undefined,
|
||||
): SetTempHpSuccess | DomainError {
|
||||
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||
|
||||
if (targetIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${combatantId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = encounter.combatants[targetIdx];
|
||||
|
||||
if (target.maxHp === undefined || target.currentHp === undefined) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "no-hp-tracking",
|
||||
message: `Combatant "${combatantId}" does not have HP tracking enabled`,
|
||||
};
|
||||
}
|
||||
|
||||
if (tempHp !== undefined && (!Number.isInteger(tempHp) || tempHp < 1)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-temp-hp",
|
||||
message: `Temp HP must be a positive integer, got ${tempHp}`,
|
||||
};
|
||||
}
|
||||
|
||||
const previousTempHp = target.tempHp;
|
||||
|
||||
// Higher value wins when both are defined
|
||||
let newTempHp: number | undefined;
|
||||
if (tempHp === undefined) {
|
||||
newTempHp = undefined;
|
||||
} else if (previousTempHp === undefined) {
|
||||
newTempHp = tempHp;
|
||||
} else {
|
||||
newTempHp = Math.max(previousTempHp, tempHp);
|
||||
}
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, tempHp: newTempHp } : c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "TempHpSet",
|
||||
combatantId,
|
||||
previousTempHp,
|
||||
newTempHp,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
|
||||
2255
pnpm-lock.yaml
generated
2255
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ interface Combatant {
|
||||
readonly initiative?: number; // integer, undefined = unset
|
||||
readonly maxHp?: number; // positive integer
|
||||
readonly currentHp?: number; // 0..maxHp
|
||||
readonly tempHp?: number; // positive integer, damage buffer
|
||||
readonly ac?: number; // non-negative integer
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
@@ -96,6 +97,19 @@ As a game master, I want HP values to survive page reloads so that I do not lose
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has max HP 30 and current HP 18, **When** the page is reloaded, **Then** both values are restored exactly.
|
||||
|
||||
**Story HP-7 — Temporary Hit Points (P1)**
|
||||
As a game master, I want to grant temporary HP to a combatant so that I can track damage buffers from spells like Heroism or False Life without manual bookkeeping.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** a combatant has 15/20 HP and no temp HP, **When** the user sets 8 temp HP via the popover, **Then** the combatant displays `15+8 / 20`.
|
||||
2. **Given** a combatant has 15+8/20 HP, **When** 5 damage is dealt, **Then** temp HP decreases to 3 and current HP remains 15 → display `15+3 / 20`.
|
||||
3. **Given** a combatant has 15+3/20 HP, **When** 10 damage is dealt, **Then** temp HP is fully consumed (3 absorbed) and current HP decreases by the remaining 7 → display `8 / 20`.
|
||||
4. **Given** a combatant has 15+5/20 HP, **When** 8 healing is applied, **Then** current HP increases to 20 and temp HP remains 5 → display `20+5 / 20`.
|
||||
5. **Given** a combatant has 10+5/20 HP, **When** the user sets 3 temp HP, **Then** temp HP remains 5 (higher value kept).
|
||||
6. **Given** a combatant has 10+3/20 HP, **When** the user sets 7 temp HP, **Then** temp HP becomes 7.
|
||||
7. **Given** no combatant has temp HP, **When** viewing the encounter, **Then** no extra space is reserved for temp HP display.
|
||||
8. **Given** one combatant has temp HP, **When** viewing the encounter, **Then** all rows reserve space for the temp HP display to maintain column alignment.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant.
|
||||
@@ -120,6 +134,15 @@ Acceptance scenarios:
|
||||
- **FR-020**: The HP area MUST display the unconscious color treatment (red) and the combatant row MUST appear visually muted when status is `unconscious`.
|
||||
- **FR-021**: Status indicators MUST NOT be shown when `maxHp` is not set.
|
||||
- **FR-022**: Visual status indicators MUST update within the same interaction frame as the HP change — no perceptible delay.
|
||||
- **FR-023**: Each combatant MAY have an optional `tempHp` value (positive integer >= 1). Temp HP is independent of regular HP tracking but requires HP tracking to be enabled.
|
||||
- **FR-024**: When damage is applied, temp HP MUST absorb damage first. Any remaining damage after temp HP is depleted MUST reduce `currentHp`.
|
||||
- **FR-025**: Healing MUST NOT restore temp HP. Healing applies only to `currentHp`.
|
||||
- **FR-026**: When setting temp HP on a combatant that already has temp HP, the system MUST keep the higher of the two values.
|
||||
- **FR-027**: When `maxHp` is cleared (HP tracking disabled), `tempHp` MUST also be cleared.
|
||||
- **FR-028**: The temp HP value MUST be displayed as a cyan `+N` immediately after the current HP value, only when temp HP > 0.
|
||||
- **FR-029**: When any combatant in the encounter has temp HP > 0, all rows MUST reserve space for the temp HP display to maintain column alignment. When no combatant has temp HP, no space is reserved.
|
||||
- **FR-030**: The HP adjustment popover MUST include a third button (Shield icon) for setting temp HP.
|
||||
- **FR-031**: Temp HP MUST persist across page reloads via the existing persistence mechanism.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@@ -131,7 +154,10 @@ Acceptance scenarios:
|
||||
- Submitting an empty delta input applies no change; the input remains ready.
|
||||
- When the user rapidly applies multiple deltas, each is applied sequentially; none are lost.
|
||||
- HP tracking is entirely absent for combatants with no `maxHp` set — no HP controls are shown.
|
||||
- There is no temporary HP in the MVP baseline.
|
||||
- Setting temp HP to 0 or clearing it removes temp HP entirely.
|
||||
- Temp HP does not affect `HpStatus` derivation — a combatant with 5 current HP, 5 temp HP, and 20 max HP is still bloodied.
|
||||
- When a concentrating combatant takes damage, the concentration pulse MUST trigger regardless of whether temp HP absorbs the damage — "taking damage" is the trigger, not losing real HP.
|
||||
- A combatant at 0 currentHp with temp HP remaining is still unconscious.
|
||||
- There is no death/unconscious game mechanic triggered at 0 HP; the system displays the state only.
|
||||
- There is no damage type tracking, resistance/vulnerability calculation, or hit log in the MVP baseline.
|
||||
- There is no undo/redo for HP changes in the MVP baseline.
|
||||
|
||||
Reference in New Issue
Block a user