Add PF2e persistent damage condition tags
Persistent damage displayed as compact tags with damage type icon and formula (e.g., Flame + "2d6"). Supports fire, bleed, acid, cold, electricity, poison, and mental types. One instance per type, added via sub-picker in the condition picker. PF2e only, persists across reload. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,43 +5,73 @@ import {
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionsForEdition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
type RulesEdition,
|
||||
} from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef, type RefObject } from "react";
|
||||
import { createRef, type ReactNode, type RefObject, useEffect } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||
import { useRulesEditionContext } from "../../contexts/rules-edition-context.js";
|
||||
import { ConditionPicker } from "../condition-picker";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function EditionSetter({
|
||||
edition,
|
||||
children,
|
||||
}: {
|
||||
edition: RulesEdition;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { setEdition } = useRulesEditionContext();
|
||||
useEffect(() => {
|
||||
setEdition(edition);
|
||||
}, [edition, setEdition]);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function renderPicker(
|
||||
overrides: Partial<{
|
||||
activeConditions: readonly ConditionEntry[];
|
||||
activePersistentDamage: readonly PersistentDamageEntry[];
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onAddPersistentDamage: (
|
||||
damageType: PersistentDamageType,
|
||||
formula: string,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
edition: RulesEdition;
|
||||
}> = {},
|
||||
) {
|
||||
const onToggle = overrides.onToggle ?? vi.fn();
|
||||
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||
const onAddPersistentDamage = overrides.onAddPersistentDamage ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const edition = overrides.edition ?? "5.5e";
|
||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||
const anchor = document.createElement("div");
|
||||
document.body.appendChild(anchor);
|
||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||
const result = render(
|
||||
<RulesEditionProvider>
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onSetValue={onSetValue}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<EditionSetter edition={edition}>
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
activePersistentDamage={overrides.activePersistentDamage}
|
||||
onToggle={onToggle}
|
||||
onSetValue={onSetValue}
|
||||
onAddPersistentDamage={onAddPersistentDamage}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</EditionSetter>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onToggle, onSetValue, onClose };
|
||||
return { ...result, onToggle, onSetValue, onAddPersistentDamage, onClose };
|
||||
}
|
||||
|
||||
describe("ConditionPicker", () => {
|
||||
@@ -77,4 +107,111 @@ describe("ConditionPicker", () => {
|
||||
const label = screen.getByText("Charmed");
|
||||
expect(label.className).toContain("text-foreground");
|
||||
});
|
||||
|
||||
describe("Valued conditions (PF2e)", () => {
|
||||
it("clicking a valued condition opens the counter editor", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker({ edition: "pf2e" });
|
||||
await user.click(screen.getByText("Frightened"));
|
||||
// Counter editor shows value badge and [-]/[+] buttons
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.some((b) => b.querySelector(".lucide-minus")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("increment and decrement adjust the counter value", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker({ edition: "pf2e" });
|
||||
await user.click(screen.getByText("Frightened"));
|
||||
// Value starts at 1; click [+] to go to 2
|
||||
const plusButtons = screen.getAllByRole("button");
|
||||
const plusButton = plusButtons.find((b) =>
|
||||
b.querySelector(".lucide-plus"),
|
||||
);
|
||||
if (!plusButton) throw new Error("Plus button not found");
|
||||
await user.click(plusButton);
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
// Click [-] to go back to 1
|
||||
const minusButton = plusButtons.find((b) =>
|
||||
b.querySelector(".lucide-minus"),
|
||||
);
|
||||
if (!minusButton) throw new Error("Minus button not found");
|
||||
await user.click(minusButton);
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirm button calls onSetValue with condition and value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSetValue } = renderPicker({ edition: "pf2e" });
|
||||
await user.click(screen.getByText("Frightened"));
|
||||
// Increment to 2, then confirm
|
||||
const plusButton = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.querySelector(".lucide-plus"));
|
||||
if (!plusButton) throw new Error("Plus button not found");
|
||||
await user.click(plusButton);
|
||||
const checkButton = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.querySelector(".lucide-check"));
|
||||
if (!checkButton) throw new Error("Check button not found");
|
||||
await user.click(checkButton);
|
||||
expect(onSetValue).toHaveBeenCalledWith("frightened", 2);
|
||||
});
|
||||
|
||||
it("shows active value badge for existing valued condition", () => {
|
||||
renderPicker({
|
||||
edition: "pf2e",
|
||||
activeConditions: [{ id: "frightened", value: 3 }],
|
||||
});
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pre-fills counter with existing value when editing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker({
|
||||
edition: "pf2e",
|
||||
activeConditions: [{ id: "frightened", value: 3 }],
|
||||
});
|
||||
await user.click(screen.getByText("Frightened"));
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables increment at maxValue", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker({
|
||||
edition: "pf2e",
|
||||
activeConditions: [{ id: "doomed", value: 3 }],
|
||||
});
|
||||
// Doomed has maxValue: 3, click to edit
|
||||
await user.click(screen.getByText("Doomed"));
|
||||
const plusButton = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.querySelector(".lucide-plus"));
|
||||
expect(plusButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Persistent Damage (PF2e)", () => {
|
||||
it("shows 'Persistent Damage' entry when edition is pf2e", () => {
|
||||
renderPicker({ edition: "pf2e" });
|
||||
expect(screen.getByText("Persistent Damage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking 'Persistent Damage' opens sub-picker", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker({ edition: "pf2e" });
|
||||
await user.click(screen.getByText("Persistent Damage"));
|
||||
expect(screen.getByPlaceholderText("2d6")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Persistent Damage (D&D)", () => {
|
||||
it("hides 'Persistent Damage' entry when edition is D&D", () => {
|
||||
renderPicker({ edition: "5.5e" });
|
||||
expect(screen.queryByText("Persistent Damage")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PersistentDamagePicker } from "../persistent-damage-picker.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderPicker(
|
||||
overrides: Partial<{
|
||||
activeEntries: { type: string; formula: string }[];
|
||||
onAdd: (damageType: string, formula: string) => void;
|
||||
onClose: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const onAdd = overrides.onAdd ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const result = render(
|
||||
<PersistentDamagePicker
|
||||
activeEntries={
|
||||
(overrides.activeEntries as Parameters<
|
||||
typeof PersistentDamagePicker
|
||||
>[0]["activeEntries"]) ?? undefined
|
||||
}
|
||||
onAdd={onAdd as Parameters<typeof PersistentDamagePicker>[0]["onAdd"]}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onAdd, onClose };
|
||||
}
|
||||
|
||||
describe("PersistentDamagePicker", () => {
|
||||
it("renders damage type dropdown and formula input", () => {
|
||||
renderPicker();
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("2d6")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirm button is disabled when formula is empty", () => {
|
||||
renderPicker();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add persistent damage" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("submitting calls onAdd with selected type and formula", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAdd } = renderPicker();
|
||||
await user.type(screen.getByPlaceholderText("2d6"), "3d6");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Add persistent damage" }),
|
||||
);
|
||||
expect(onAdd).toHaveBeenCalledWith("fire", "3d6");
|
||||
});
|
||||
|
||||
it("Enter in formula input confirms", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onAdd } = renderPicker();
|
||||
await user.type(screen.getByPlaceholderText("2d6"), "2d6{Enter}");
|
||||
expect(onAdd).toHaveBeenCalledWith("fire", "2d6");
|
||||
});
|
||||
|
||||
it("pre-fills formula for existing active entry", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPicker({
|
||||
activeEntries: [{ type: "fire", formula: "2d6" }],
|
||||
});
|
||||
expect(screen.getByPlaceholderText("2d6")).toHaveValue("2d6");
|
||||
|
||||
// Change type to one without active entry
|
||||
await user.selectOptions(screen.getByRole("combobox"), "bleed");
|
||||
expect(screen.getByPlaceholderText("2d6")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type {
|
||||
PersistentDamageEntry,
|
||||
PersistentDamageType,
|
||||
} from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PersistentDamageTags } from "../persistent-damage-tags.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderTags(
|
||||
entries: readonly PersistentDamageEntry[] | undefined,
|
||||
onRemove = vi.fn(),
|
||||
) {
|
||||
const result = render(
|
||||
<PersistentDamageTags entries={entries} onRemove={onRemove} />,
|
||||
);
|
||||
return { ...result, onRemove };
|
||||
}
|
||||
|
||||
describe("PersistentDamageTags", () => {
|
||||
it("renders nothing when entries undefined", () => {
|
||||
const { container } = renderTags(undefined);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when entries is empty array", () => {
|
||||
const { container } = renderTags([]);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders tag per entry with icon and formula text", () => {
|
||||
renderTags([
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
]);
|
||||
expect(screen.getByText("2d6")).toBeInTheDocument();
|
||||
expect(screen.getByText("1d4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("click calls onRemove with correct damage type", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onRemove } = renderTags([{ type: "fire", formula: "2d6" }]);
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove persistent Fire damage",
|
||||
}),
|
||||
);
|
||||
expect(onRemove).toHaveBeenCalledWith(
|
||||
"fire" satisfies PersistentDamageType,
|
||||
);
|
||||
});
|
||||
|
||||
it("tooltip shows full description", () => {
|
||||
renderTags([{ type: "fire", formula: "2d6" }]);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Remove persistent Fire damage",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type ConditionEntry,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PersistentDamageEntry,
|
||||
type PlayerIcon,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
@@ -19,6 +20,7 @@ import { ConditionPicker } from "./condition-picker.js";
|
||||
import { ConditionTags } from "./condition-tags.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||
import { PersistentDamageTags } from "./persistent-damage-tags.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
@@ -33,6 +35,7 @@ interface Combatant {
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionEntry[];
|
||||
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
@@ -454,6 +457,8 @@ export function CombatantRow({
|
||||
setConditionValue,
|
||||
decrementCondition,
|
||||
toggleConcentration,
|
||||
addPersistentDamage,
|
||||
removePersistentDamage,
|
||||
} = useEncounterContext();
|
||||
const {
|
||||
selectedCreatureId,
|
||||
@@ -615,14 +620,24 @@ export function CombatantRow({
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
{isPf2e && (
|
||||
<PersistentDamageTags
|
||||
entries={combatant.persistentDamage}
|
||||
onRemove={(damageType) => removePersistentDamage(id, damageType)}
|
||||
/>
|
||||
)}
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
anchorRef={conditionAnchorRef}
|
||||
activeConditions={combatant.conditions}
|
||||
activePersistentDamage={combatant.persistentDamage}
|
||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onSetValue={(conditionId, value) =>
|
||||
setConditionValue(id, conditionId, value)
|
||||
}
|
||||
onAddPersistentDamage={(damageType, formula) =>
|
||||
addPersistentDamage(id, damageType, formula)
|
||||
}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
} from "@initiative/domain";
|
||||
import { Check, Minus, Plus } from "lucide-react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { Check, Flame, Minus, Plus } from "lucide-react";
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
@@ -14,21 +16,29 @@ import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
interface ConditionPickerProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionEntry[] | undefined;
|
||||
activePersistentDamage?: readonly PersistentDamageEntry[];
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onAddPersistentDamage?: (
|
||||
damageType: PersistentDamageType,
|
||||
formula: string,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConditionPicker({
|
||||
anchorRef,
|
||||
activeConditions,
|
||||
activePersistentDamage,
|
||||
onToggle,
|
||||
onSetValue,
|
||||
onAddPersistentDamage,
|
||||
onClose,
|
||||
}: Readonly<ConditionPickerProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -42,6 +52,7 @@ export function ConditionPicker({
|
||||
id: ConditionId;
|
||||
value: number;
|
||||
} | null>(null);
|
||||
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
@@ -71,6 +82,51 @@ export function ConditionPicker({
|
||||
const activeMap = new Map(
|
||||
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
||||
);
|
||||
const showPersistentDamageEntry =
|
||||
edition === "pf2e" && !!onAddPersistentDamage;
|
||||
const persistentDamageInsertIndex = showPersistentDamageEntry
|
||||
? conditions.findIndex(
|
||||
(d) => d.label.localeCompare("Persistent Damage") > 0,
|
||||
)
|
||||
: -1;
|
||||
|
||||
const persistentDamageEntry = showPersistentDamageEntry ? (
|
||||
<React.Fragment key="persistent-damage">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||
showPersistentDamage && "bg-card/50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={() => setShowPersistentDamage((prev) => !prev)}
|
||||
>
|
||||
<Flame
|
||||
size={14}
|
||||
className={
|
||||
showPersistentDamage ? "text-orange-400" : "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
showPersistentDamage ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
Persistent Damage
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!!showPersistentDamage && (
|
||||
<PersistentDamagePicker
|
||||
activeEntries={activePersistentDamage}
|
||||
onAdd={onAddPersistentDamage}
|
||||
onClose={() => setShowPersistentDamage(false)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -82,7 +138,7 @@ export function ConditionPicker({
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
{conditions.map((def) => {
|
||||
{conditions.map((def, index) => {
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const isActive = activeMap.has(def.id);
|
||||
@@ -104,111 +160,116 @@ export function ConditionPicker({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||
(isActive || isEditing) && "bg-card/50",
|
||||
)}
|
||||
<React.Fragment key={def.id}>
|
||||
{index === persistentDamageInsertIndex && persistentDamageEntry}
|
||||
<Tooltip
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={handleClick}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||
(isActive || isEditing) && "bg-card/50",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={
|
||||
isActive || isEditing ? colorClass : "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive || isEditing
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||
{activeValue}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (editing.value > 1) {
|
||||
setEditing({
|
||||
...editing,
|
||||
value: editing.value - 1,
|
||||
});
|
||||
}
|
||||
}}
|
||||
<Icon
|
||||
size={14}
|
||||
className={
|
||||
isActive || isEditing
|
||||
? colorClass
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive || isEditing
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||
{editing.value}
|
||||
{def.label}
|
||||
</span>
|
||||
{(() => {
|
||||
const atMax =
|
||||
def.maxValue !== undefined &&
|
||||
editing.value >= def.maxValue;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded p-0.5",
|
||||
atMax
|
||||
? "cursor-not-allowed text-muted-foreground opacity-50"
|
||||
: "text-foreground hover:bg-accent/40",
|
||||
)}
|
||||
disabled={atMax}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!atMax) {
|
||||
setEditing({
|
||||
...editing,
|
||||
value: editing.value + 1,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetValue(editing.id, editing.value);
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</button>
|
||||
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||
{activeValue}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (editing.value > 1) {
|
||||
setEditing({
|
||||
...editing,
|
||||
value: editing.value - 1,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||
{editing.value}
|
||||
</span>
|
||||
{(() => {
|
||||
const atMax =
|
||||
def.maxValue !== undefined &&
|
||||
editing.value >= def.maxValue;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded p-0.5",
|
||||
atMax
|
||||
? "cursor-not-allowed text-muted-foreground opacity-50"
|
||||
: "text-foreground hover:bg-accent/40",
|
||||
)}
|
||||
disabled={atMax}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!atMax) {
|
||||
setEditing({
|
||||
...editing,
|
||||
value: editing.value + 1,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetValue(editing.id, editing.value);
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{persistentDamageInsertIndex === -1 && persistentDamageEntry}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EarOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Sun,
|
||||
TrendingDown,
|
||||
@@ -49,6 +52,8 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
EarOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
@@ -64,6 +69,7 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Sun,
|
||||
TrendingDown,
|
||||
@@ -81,6 +87,7 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
lime: "text-lime-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
red: "text-red-400",
|
||||
|
||||
97
apps/web/src/components/persistent-damage-picker.tsx
Normal file
97
apps/web/src/components/persistent-damage-picker.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
} from "@initiative/domain";
|
||||
import { Check } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface PersistentDamagePickerProps {
|
||||
activeEntries: readonly PersistentDamageEntry[] | undefined;
|
||||
onAdd: (damageType: PersistentDamageType, formula: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PersistentDamagePicker({
|
||||
activeEntries,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: Readonly<PersistentDamagePickerProps>) {
|
||||
const [selectedType, setSelectedType] = useState<PersistentDamageType>(
|
||||
PERSISTENT_DAMAGE_DEFINITIONS[0].type,
|
||||
);
|
||||
const activeFormula =
|
||||
activeEntries?.find((e) => e.type === selectedType)?.formula ?? "";
|
||||
const [formula, setFormula] = useState(activeFormula);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const existing = activeEntries?.find(
|
||||
(e) => e.type === selectedType,
|
||||
)?.formula;
|
||||
setFormula(existing ?? "");
|
||||
}, [selectedType, activeEntries]);
|
||||
|
||||
const canSubmit = formula.trim().length > 0;
|
||||
|
||||
function handleSubmit() {
|
||||
if (canSubmit) {
|
||||
onAdd(selectedType, formula);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 py-1 pr-2 pl-6">
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) =>
|
||||
setSelectedType(e.target.value as PersistentDamageType)
|
||||
}
|
||||
onKeyDown={handleEscape}
|
||||
className="h-7 rounded border border-border bg-background px-1 text-foreground text-xs"
|
||||
>
|
||||
{PERSISTENT_DAMAGE_DEFINITIONS.map((def) => (
|
||||
<option key={def.type} value={def.type}>
|
||||
{def.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={formula}
|
||||
placeholder="2d6"
|
||||
className="h-7 w-16 rounded border border-border bg-background px-1.5 text-foreground text-xs"
|
||||
onChange={(e) => setFormula(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
handleEscape(e);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
className="rounded p-0.5 text-foreground hover:bg-accent/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Add persistent damage"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/components/persistent-damage-tags.tsx
Normal file
63
apps/web/src/components/persistent-damage-tags.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
} from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
interface PersistentDamageTagsProps {
|
||||
entries: readonly PersistentDamageEntry[] | undefined;
|
||||
onRemove: (damageType: PersistentDamageType) => void;
|
||||
}
|
||||
|
||||
export function PersistentDamageTags({
|
||||
entries,
|
||||
onRemove,
|
||||
}: Readonly<PersistentDamageTagsProps>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => {
|
||||
const def = PERSISTENT_DAMAGE_DEFINITIONS.find(
|
||||
(d) => d.type === entry.type,
|
||||
);
|
||||
if (!def) return null;
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={entry.type}
|
||||
content={`Persistent ${def.label} ${entry.formula}\nTake damage at end of turn. DC 15 flat check to end.`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove persistent ${def.label} damage`}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(entry.type);
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span className="font-medium text-xs leading-none">
|
||||
{entry.formula}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||
import {
|
||||
addCombatantUseCase,
|
||||
addPersistentDamageUseCase,
|
||||
adjustHpUseCase,
|
||||
advanceTurnUseCase,
|
||||
clearEncounterUseCase,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
editCombatantUseCase,
|
||||
redoUseCase,
|
||||
removeCombatantUseCase,
|
||||
removePersistentDamageUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
setConditionValueUseCase,
|
||||
@@ -28,6 +30,7 @@ import type {
|
||||
DomainError,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PersistentDamageType,
|
||||
Pf2eCreature,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
@@ -78,6 +81,17 @@ type EncounterAction =
|
||||
conditionId: ConditionId;
|
||||
}
|
||||
| { type: "toggle-concentration"; id: CombatantId }
|
||||
| {
|
||||
type: "add-persistent-damage";
|
||||
id: CombatantId;
|
||||
damageType: PersistentDamageType;
|
||||
formula: string;
|
||||
}
|
||||
| {
|
||||
type: "remove-persistent-damage";
|
||||
id: CombatantId;
|
||||
damageType: PersistentDamageType;
|
||||
}
|
||||
| { type: "clear-encounter" }
|
||||
| { type: "undo" }
|
||||
| { type: "redo" }
|
||||
@@ -427,6 +441,8 @@ function dispatchEncounterAction(
|
||||
| { type: "set-condition-value" }
|
||||
| { type: "decrement-condition" }
|
||||
| { type: "toggle-concentration" }
|
||||
| { type: "add-persistent-damage" }
|
||||
| { type: "remove-persistent-damage" }
|
||||
>,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
@@ -488,6 +504,21 @@ function dispatchEncounterAction(
|
||||
case "toggle-concentration":
|
||||
result = toggleConcentrationUseCase(store, action.id);
|
||||
break;
|
||||
case "add-persistent-damage":
|
||||
result = addPersistentDamageUseCase(
|
||||
store,
|
||||
action.id,
|
||||
action.damageType,
|
||||
action.formula,
|
||||
);
|
||||
break;
|
||||
case "remove-persistent-damage":
|
||||
result = removePersistentDamageUseCase(
|
||||
store,
|
||||
action.id,
|
||||
action.damageType,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isDomainError(result)) return state;
|
||||
@@ -651,6 +682,16 @@ export function useEncounter() {
|
||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||
[],
|
||||
),
|
||||
addPersistentDamage: useCallback(
|
||||
(id: CombatantId, damageType: PersistentDamageType, formula: string) =>
|
||||
dispatch({ type: "add-persistent-damage", id, damageType, formula }),
|
||||
[],
|
||||
),
|
||||
removePersistentDamage: useCallback(
|
||||
(id: CombatantId, damageType: PersistentDamageType) =>
|
||||
dispatch({ type: "remove-persistent-damage", id, damageType }),
|
||||
[],
|
||||
),
|
||||
setCreatureAdjustment: useCallback(
|
||||
(
|
||||
id: CombatantId,
|
||||
|
||||
Reference in New Issue
Block a user