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"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^29.0.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ describe("App integration", () => {
|
|||||||
await addCombatant(user, "Ogre", { maxHp: "59" });
|
await addCombatant(user, "Ogre", { maxHp: "59" });
|
||||||
|
|
||||||
// Verify HP displays — currentHp and maxHp both show "59"
|
// Verify HP displays — currentHp and maxHp both show "59"
|
||||||
expect(screen.getByText("/")).toBeInTheDocument();
|
|
||||||
const hpButton = screen.getByRole("button", {
|
const hpButton = screen.getByRole("button", {
|
||||||
name: "Current HP: 59 (healthy)",
|
name: "Current HP: 59 (healthy)",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { AllProviders } from "../../__tests__/test-providers.js";
|
|||||||
import { CombatantRow } from "../combatant-row.js";
|
import { CombatantRow } from "../combatant-row.js";
|
||||||
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
|
||||||
|
|
||||||
|
const TEMP_HP_REGEX = /^\+\d/;
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
// Mock persistence — no localStorage interaction
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
vi.mock("../../persistence/encounter-storage.js", () => ({
|
||||||
loadEncounter: () => null,
|
loadEncounter: () => null,
|
||||||
@@ -123,14 +125,14 @@ describe("CombatantRow", () => {
|
|||||||
expect(nameContainer).not.toBeNull();
|
expect(nameContainer).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows '--' for current HP when no maxHp is set", () => {
|
it("shows 'Max' placeholder when no maxHp is set", () => {
|
||||||
renderRow({
|
renderRow({
|
||||||
combatant: {
|
combatant: {
|
||||||
id: combatantId("1"),
|
id: combatantId("1"),
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
|
expect(screen.getByText("Max")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows concentration icon when isConcentrating is true", () => {
|
it("shows concentration icon when isConcentrating is true", () => {
|
||||||
@@ -193,4 +195,106 @@ describe("CombatantRow", () => {
|
|||||||
screen.getByRole("button", { name: "Roll initiative" }),
|
screen.getByRole("button", { name: "Roll initiative" }),
|
||||||
).toBeInTheDocument();
|
).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(
|
function renderPopover(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
|
onSetTempHp: (value: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const onAdjust = overrides.onAdjust ?? vi.fn();
|
const onAdjust = overrides.onAdjust ?? vi.fn();
|
||||||
|
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
const result = render(
|
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", () => {
|
describe("HpAdjustPopover", () => {
|
||||||
@@ -112,4 +118,31 @@ describe("HpAdjustPopover", () => {
|
|||||||
await user.type(input, "12abc34");
|
await user.type(input, "12abc34");
|
||||||
expect(input).toHaveValue("1234");
|
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(),
|
setInitiative: vi.fn(),
|
||||||
setHp: vi.fn(),
|
setHp: vi.fn(),
|
||||||
adjustHp: vi.fn(),
|
adjustHp: vi.fn(),
|
||||||
|
setTempHp: vi.fn(),
|
||||||
|
hasTempHp: false,
|
||||||
setAc: vi.fn(),
|
setAc: vi.fn(),
|
||||||
toggleCondition: vi.fn(),
|
toggleCondition: vi.fn(),
|
||||||
toggleConcentration: vi.fn(),
|
toggleConcentration: vi.fn(),
|
||||||
@@ -92,7 +94,8 @@ describe("TurnNavigation", () => {
|
|||||||
renderNav();
|
renderNav();
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
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", () => {
|
it("updates the round badge when round changes", () => {
|
||||||
|
|||||||
@@ -19,17 +19,15 @@ export function AcShield({ value, onClick, className }: AcShieldProps) {
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 28 32"
|
viewBox="0 0 28 32"
|
||||||
fill="none"
|
fill="var(--color-border)"
|
||||||
stroke="currentColor"
|
fillOpacity={0.5}
|
||||||
strokeWidth={1.5}
|
stroke="none"
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
aria-hidden="true"
|
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" />
|
<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>
|
</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)}
|
{value == null ? "\u2014" : String(value)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -493,8 +493,17 @@ export function ActionBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleBrowseMode = () => {
|
const toggleBrowseMode = () => {
|
||||||
setBrowseMode((m) => !m);
|
setBrowseMode((prev) => {
|
||||||
clearInput();
|
const next = !prev;
|
||||||
|
setSuggestionIndex(-1);
|
||||||
|
setQueued(null);
|
||||||
|
if (next) {
|
||||||
|
handleBrowseSearch(nameInput);
|
||||||
|
} else {
|
||||||
|
handleAddSearch(nameInput);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
clearCustomFields();
|
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",
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
||||||
browseMode && "text-accent",
|
browseMode && "text-accent",
|
||||||
)}
|
)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={toggleBrowseMode}
|
onClick={toggleBrowseMode}
|
||||||
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
||||||
aria-label={
|
aria-label={
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Combatant {
|
|||||||
readonly initiative?: number;
|
readonly initiative?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
@@ -171,7 +172,12 @@ function MaxHpDisplay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startEditing}
|
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"}
|
{maxHp ?? "Max"}
|
||||||
</button>
|
</button>
|
||||||
@@ -181,51 +187,47 @@ function MaxHpDisplay({
|
|||||||
function ClickableHp({
|
function ClickableHp({
|
||||||
currentHp,
|
currentHp,
|
||||||
maxHp,
|
maxHp,
|
||||||
|
tempHp,
|
||||||
onAdjust,
|
onAdjust,
|
||||||
dimmed,
|
onSetTempHp,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
currentHp: number | undefined;
|
currentHp: number | undefined;
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
|
tempHp: number | undefined;
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
dimmed?: boolean;
|
onSetTempHp: (value: number) => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
if (maxHp === undefined) {
|
if (maxHp === undefined) {
|
||||||
return (
|
return null;
|
||||||
<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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
aria-label={`Current HP: ${currentHp} (${status})`}
|
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
||||||
className={cn(
|
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 === "bloodied" && "text-amber-400",
|
||||||
status === "unconscious" && "text-red-400",
|
status === "unconscious" && "text-red-400",
|
||||||
status === "healthy" && "text-foreground",
|
status === "healthy" && "text-foreground",
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{currentHp}
|
{currentHp}
|
||||||
</button>
|
</button>
|
||||||
|
{!!tempHp && (
|
||||||
|
<span className="font-medium text-cyan-400 text-sm leading-7">
|
||||||
|
+{tempHp}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!!popoverOpen && (
|
{!!popoverOpen && (
|
||||||
<HpAdjustPopover
|
<HpAdjustPopover
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
onClose={() => setPopoverOpen(false)}
|
onClose={() => setPopoverOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -346,7 +348,7 @@ function InitiativeDisplay({
|
|||||||
value={draft}
|
value={draft}
|
||||||
placeholder="--"
|
placeholder="--"
|
||||||
className={cn(
|
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",
|
dimmed && "opacity-50",
|
||||||
)}
|
)}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
@@ -443,6 +445,7 @@ export function CombatantRow({
|
|||||||
removeCombatant,
|
removeCombatant,
|
||||||
setHp,
|
setHp,
|
||||||
adjustHp,
|
adjustHp,
|
||||||
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
@@ -475,24 +478,27 @@ export function CombatantRow({
|
|||||||
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const prevHpRef = useRef(currentHp);
|
const prevHpRef = useRef(currentHp);
|
||||||
|
const prevTempHpRef = useRef(combatant.tempHp);
|
||||||
const [isPulsing, setIsPulsing] = useState(false);
|
const [isPulsing, setIsPulsing] = useState(false);
|
||||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevHp = prevHpRef.current;
|
const prevHp = prevHpRef.current;
|
||||||
|
const prevTempHp = prevTempHpRef.current;
|
||||||
prevHpRef.current = currentHp;
|
prevHpRef.current = currentHp;
|
||||||
|
prevTempHpRef.current = combatant.tempHp;
|
||||||
|
|
||||||
if (
|
const realHpDropped =
|
||||||
prevHp !== undefined &&
|
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
||||||
currentHp !== undefined &&
|
const tempHpDropped =
|
||||||
currentHp < prevHp &&
|
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||||
combatant.isConcentrating
|
|
||||||
) {
|
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||||
setIsPulsing(true);
|
setIsPulsing(true);
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
}
|
}
|
||||||
}, [currentHp, combatant.isConcentrating]);
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!combatant.isConcentrating) {
|
if (!combatant.isConcentrating) {
|
||||||
@@ -514,7 +520,7 @@ export function CombatantRow({
|
|||||||
isPulsing && "animate-concentration-pulse",
|
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 */}
|
{/* Concentration */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -530,13 +536,20 @@ export function CombatantRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
<InitiativeDisplay
|
<div className="rounded-md bg-muted/30 px-1">
|
||||||
initiative={initiative}
|
<InitiativeDisplay
|
||||||
combatantId={id}
|
initiative={initiative}
|
||||||
dimmed={dimmed}
|
combatantId={id}
|
||||||
onSetInitiative={setInitiative}
|
dimmed={dimmed}
|
||||||
onRollInitiative={onRollInitiative}
|
onSetInitiative={setInitiative}
|
||||||
/>
|
onRollInitiative={onRollInitiative}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AC */}
|
||||||
|
<div className={cn(dimmed && "opacity-50")}>
|
||||||
|
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Name + Conditions */}
|
{/* Name + Conditions */}
|
||||||
<div
|
<div
|
||||||
@@ -585,32 +598,27 @@ export function CombatantRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AC */}
|
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
|
||||||
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HP */}
|
{/* 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
|
<ClickableHp
|
||||||
currentHp={currentHp}
|
currentHp={currentHp}
|
||||||
maxHp={maxHp}
|
maxHp={maxHp}
|
||||||
|
tempHp={combatant.tempHp}
|
||||||
onAdjust={(delta) => adjustHp(id, delta)}
|
onAdjust={(delta) => adjustHp(id, delta)}
|
||||||
dimmed={dimmed}
|
onSetTempHp={(value) => setTempHp(id, value)}
|
||||||
/>
|
/>
|
||||||
{maxHp !== undefined && (
|
{maxHp !== undefined && (
|
||||||
<span
|
<span className="text-muted-foreground/50 text-xs">/</span>
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground text-sm tabular-nums",
|
|
||||||
dimmed && "opacity-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<div className={cn(dimmed && "opacity-50")}>
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
||||||
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -121,25 +122,28 @@ export function ConditionPicker({
|
|||||||
const isActive = active.has(def.id);
|
const isActive = active.has(def.id);
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip key={def.id} content={def.description} className="block">
|
||||||
key={def.id}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
"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",
|
isActive && "bg-card/50",
|
||||||
)}
|
)}
|
||||||
onClick={() => onToggle(def.id)}
|
onClick={() => onToggle(def.id)}
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
size={14}
|
|
||||||
className={isActive ? colorClass : "text-muted-foreground"}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={isActive ? "text-foreground" : "text-muted-foreground"}
|
|
||||||
>
|
>
|
||||||
{def.label}
|
<Icon
|
||||||
</span>
|
size={14}
|
||||||
</button>
|
className={isActive ? colorClass : "text-muted-foreground"}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isActive ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{def.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
const ICON_MAP: Record<string, LucideIcon> = {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -71,22 +72,22 @@ export function ConditionTags({
|
|||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip key={condId} content={`${def.label}: ${def.description}`}>
|
||||||
key={condId}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={def.label}
|
aria-label={`Remove ${def.label}`}
|
||||||
aria-label={`Remove ${def.label}`}
|
className={cn(
|
||||||
className={cn(
|
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
colorClass,
|
||||||
colorClass,
|
)}
|
||||||
)}
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
onRemove(condId);
|
||||||
onRemove(condId);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Icon size={14} />
|
||||||
<Icon size={14} />
|
</button>
|
||||||
</button>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Heart, Sword } from "lucide-react";
|
import { Heart, ShieldPlus, Sword } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -12,10 +12,15 @@ const DIGITS_ONLY_REGEX = /^\d+$/;
|
|||||||
|
|
||||||
interface HpAdjustPopoverProps {
|
interface HpAdjustPopoverProps {
|
||||||
readonly onAdjust: (delta: number) => void;
|
readonly onAdjust: (delta: number) => void;
|
||||||
|
readonly onSetTempHp: (value: number) => void;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
export function HpAdjustPopover({
|
||||||
|
onAdjust,
|
||||||
|
onSetTempHp,
|
||||||
|
onClose,
|
||||||
|
}: HpAdjustPopoverProps) {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -130,6 +135,21 @@ export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
|
|||||||
>
|
>
|
||||||
<Heart size={14} />
|
<Heart size={14} />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ export function TurnNavigation() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
<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">
|
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||||
R{encounter.roundNumber}
|
<span className="-mt-[3px] inline-block">
|
||||||
|
R{encounter.roundNumber}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
<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,
|
setAcUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
|
setTempHpUseCase,
|
||||||
toggleConcentrationUseCase,
|
toggleConcentrationUseCase,
|
||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
@@ -215,6 +216,19 @@ export function useEncounter() {
|
|||||||
[makeStore],
|
[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(
|
const setAc = useCallback(
|
||||||
(id: CombatantId, value: number | undefined) => {
|
(id: CombatantId, value: number | undefined) => {
|
||||||
const result = setAcUseCase(makeStore(), id, value);
|
const result = setAcUseCase(makeStore(), id, value);
|
||||||
@@ -376,6 +390,10 @@ export function useEncounter() {
|
|||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasTempHp = encounter.combatants.some(
|
||||||
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
const hasCreatureCombatants = encounter.combatants.some(
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
(c) => c.creatureId != null,
|
(c) => c.creatureId != null,
|
||||||
@@ -388,6 +406,7 @@ export function useEncounter() {
|
|||||||
encounter,
|
encounter,
|
||||||
events,
|
events,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
|
hasTempHp,
|
||||||
hasCreatureCombatants,
|
hasCreatureCombatants,
|
||||||
canRollAllInitiative,
|
canRollAllInitiative,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
@@ -399,6 +418,7 @@ export function useEncounter() {
|
|||||||
setInitiative,
|
setInitiative,
|
||||||
setHp,
|
setHp,
|
||||||
adjustHp,
|
adjustHp,
|
||||||
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
toggleConcentration,
|
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": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**",
|
"**",
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"undici": ">=7.24.0"
|
"undici": ">=7.24.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.7",
|
"@biomejs/biome": "2.4.8",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
"knip": "^5.85.0",
|
"knip": "^5.88.1",
|
||||||
"lefthook": "^1.11.0",
|
"lefthook": "^2.1.4",
|
||||||
"oxlint": "^1.55.0",
|
"oxlint": "^1.56.0",
|
||||||
"oxlint-tsgolint": "^0.16.0",
|
"oxlint-tsgolint": "^0.17.1",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^4.1.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "lefthook install",
|
"prepare": "lefthook install",
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
|||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-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 { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
export { toggleConditionUseCase } from "./toggle-condition-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(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
opts?: { maxHp: number; currentHp: number },
|
opts?: { maxHp: number; currentHp: number; tempHp?: number },
|
||||||
): Combatant {
|
): Combatant {
|
||||||
return {
|
return {
|
||||||
id: combatantId(name),
|
id: combatantId(name),
|
||||||
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);
|
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].maxHp).toBeUndefined();
|
||||||
expect(encounter.combatants[0].currentHp).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", () => {
|
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 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 {
|
return {
|
||||||
encounter: {
|
encounter: {
|
||||||
combatants: encounter.combatants.map((c) =>
|
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,
|
activeIndex: encounter.activeIndex,
|
||||||
roundNumber: encounter.roundNumber,
|
roundNumber: encounter.roundNumber,
|
||||||
},
|
},
|
||||||
events: [
|
events,
|
||||||
{
|
|
||||||
type: "CurrentHpAdjusted",
|
|
||||||
combatantId,
|
|
||||||
previousHp,
|
|
||||||
newHp,
|
|
||||||
delta,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,63 +18,127 @@ export type ConditionId =
|
|||||||
export interface ConditionDefinition {
|
export interface ConditionDefinition {
|
||||||
readonly id: ConditionId;
|
readonly id: ConditionId;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
readonly description: string;
|
||||||
readonly iconName: string;
|
readonly iconName: string;
|
||||||
readonly color: string;
|
readonly color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||||
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" },
|
{
|
||||||
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" },
|
id: "blinded",
|
||||||
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" },
|
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",
|
id: "exhaustion",
|
||||||
label: "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",
|
iconName: "BatteryLow",
|
||||||
color: "amber",
|
color: "amber",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "frightened",
|
id: "frightened",
|
||||||
label: "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",
|
iconName: "Siren",
|
||||||
color: "orange",
|
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",
|
id: "incapacitated",
|
||||||
label: "Incapacitated",
|
label: "Incapacitated",
|
||||||
|
description:
|
||||||
|
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
|
||||||
iconName: "Ban",
|
iconName: "Ban",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "invisible",
|
id: "invisible",
|
||||||
label: "Invisible",
|
label: "Invisible",
|
||||||
|
description:
|
||||||
|
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
iconName: "Ghost",
|
iconName: "Ghost",
|
||||||
color: "violet",
|
color: "violet",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "paralyzed",
|
id: "paralyzed",
|
||||||
label: "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",
|
iconName: "ZapOff",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "petrified",
|
id: "petrified",
|
||||||
label: "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",
|
iconName: "Gem",
|
||||||
color: "slate",
|
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",
|
id: "restrained",
|
||||||
label: "Restrained",
|
label: "Restrained",
|
||||||
|
description:
|
||||||
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
iconName: "Link",
|
iconName: "Link",
|
||||||
color: "neutral",
|
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",
|
id: "unconscious",
|
||||||
label: "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",
|
iconName: "Moon",
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ export interface CurrentHpAdjusted {
|
|||||||
readonly delta: number;
|
readonly delta: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TempHpSet {
|
||||||
|
readonly type: "TempHpSet";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly previousTempHp: number | undefined;
|
||||||
|
readonly newTempHp: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TurnRetreated {
|
export interface TurnRetreated {
|
||||||
readonly type: "TurnRetreated";
|
readonly type: "TurnRetreated";
|
||||||
readonly previousCombatantId: CombatantId;
|
readonly previousCombatantId: CombatantId;
|
||||||
@@ -132,6 +139,7 @@ export type DomainEvent =
|
|||||||
| InitiativeSet
|
| InitiativeSet
|
||||||
| MaxHpSet
|
| MaxHpSet
|
||||||
| CurrentHpAdjusted
|
| CurrentHpAdjusted
|
||||||
|
| TempHpSet
|
||||||
| TurnRetreated
|
| TurnRetreated
|
||||||
| RoundRetreated
|
| RoundRetreated
|
||||||
| AcSet
|
| AcSet
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type {
|
|||||||
PlayerCharacterUpdated,
|
PlayerCharacterUpdated,
|
||||||
RoundAdvanced,
|
RoundAdvanced,
|
||||||
RoundRetreated,
|
RoundRetreated,
|
||||||
|
TempHpSet,
|
||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
TurnRetreated,
|
TurnRetreated,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
@@ -95,6 +96,7 @@ export {
|
|||||||
type SetInitiativeSuccess,
|
type SetInitiativeSuccess,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "./set-initiative.js";
|
} from "./set-initiative.js";
|
||||||
|
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||||
export {
|
export {
|
||||||
type ToggleConcentrationSuccess,
|
type ToggleConcentrationSuccess,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ export function setHp(
|
|||||||
encounter: {
|
encounter: {
|
||||||
combatants: encounter.combatants.map((c) =>
|
combatants: encounter.combatants.map((c) =>
|
||||||
c.id === combatantId
|
c.id === combatantId
|
||||||
? { ...c, maxHp: newMaxHp, currentHp: newCurrentHp }
|
? {
|
||||||
|
...c,
|
||||||
|
maxHp: newMaxHp,
|
||||||
|
currentHp: newCurrentHp,
|
||||||
|
tempHp: newMaxHp === undefined ? undefined : c.tempHp,
|
||||||
|
}
|
||||||
: c,
|
: c,
|
||||||
),
|
),
|
||||||
activeIndex: encounter.activeIndex,
|
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 initiative?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
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 initiative?: number; // integer, undefined = unset
|
||||||
readonly maxHp?: number; // positive integer
|
readonly maxHp?: number; // positive integer
|
||||||
readonly currentHp?: number; // 0..maxHp
|
readonly currentHp?: number; // 0..maxHp
|
||||||
|
readonly tempHp?: number; // positive integer, damage buffer
|
||||||
readonly ac?: number; // non-negative integer
|
readonly ac?: number; // non-negative integer
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
readonly isConcentrating?: boolean;
|
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:
|
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.
|
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
|
### Requirements
|
||||||
|
|
||||||
- **FR-001**: Each combatant MAY have an optional `maxHp` value (positive integer >= 1). HP tracking is optional per combatant.
|
- **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-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-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-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
|
### Edge Cases
|
||||||
|
|
||||||
@@ -131,7 +154,10 @@ Acceptance scenarios:
|
|||||||
- Submitting an empty delta input applies no change; the input remains ready.
|
- 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.
|
- 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.
|
- 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 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 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.
|
- There is no undo/redo for HP changes in the MVP baseline.
|
||||||
|
|||||||
Reference in New Issue
Block a user