Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9fb271607 | ||
|
|
064af16f95 | ||
|
|
0f640601b6 | ||
|
|
4b1c1deda2 |
28
apps/web/src/__tests__/factories/build-pf2e-creature.ts
Normal file
28
apps/web/src/__tests__/factories/build-pf2e-creature.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildPf2eCreature(
|
||||||
|
overrides?: Partial<Pf2eCreature>,
|
||||||
|
): Pf2eCreature {
|
||||||
|
const id = ++counter;
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId(`pf2e-creature-${id}`),
|
||||||
|
name: `PF2e Creature ${id}`,
|
||||||
|
source: "crb",
|
||||||
|
sourceDisplayName: "Core Rulebook",
|
||||||
|
level: 1,
|
||||||
|
traits: ["humanoid"],
|
||||||
|
perception: 5,
|
||||||
|
abilityMods: { str: 2, dex: 1, con: 2, int: 0, wis: 1, cha: -1 },
|
||||||
|
ac: 15,
|
||||||
|
saveFort: 7,
|
||||||
|
saveRef: 4,
|
||||||
|
saveWill: 5,
|
||||||
|
hp: 20,
|
||||||
|
speed: "25 ft.",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { buildCombatant } from "./build-combatant.js";
|
export { buildCombatant } from "./build-combatant.js";
|
||||||
export { buildCreature } from "./build-creature.js";
|
export { buildCreature } from "./build-creature.js";
|
||||||
export { buildEncounter } from "./build-encounter.js";
|
export { buildEncounter } from "./build-encounter.js";
|
||||||
|
export { buildPf2eCreature } from "./build-pf2e-creature.js";
|
||||||
|
|||||||
@@ -5,43 +5,73 @@ import {
|
|||||||
type ConditionEntry,
|
type ConditionEntry,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
type RulesEdition,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import 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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RulesEditionProvider } from "../../contexts/index.js";
|
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||||
|
import { useRulesEditionContext } from "../../contexts/rules-edition-context.js";
|
||||||
import { ConditionPicker } from "../condition-picker";
|
import { ConditionPicker } from "../condition-picker";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function EditionSetter({
|
||||||
|
edition,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
edition: RulesEdition;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { setEdition } = useRulesEditionContext();
|
||||||
|
useEffect(() => {
|
||||||
|
setEdition(edition);
|
||||||
|
}, [edition, setEdition]);
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPicker(
|
function renderPicker(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
activeConditions: readonly ConditionEntry[];
|
activeConditions: readonly ConditionEntry[];
|
||||||
|
activePersistentDamage: readonly PersistentDamageEntry[];
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
|
onAddPersistentDamage: (
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
edition: RulesEdition;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const onToggle = overrides.onToggle ?? vi.fn();
|
const onToggle = overrides.onToggle ?? vi.fn();
|
||||||
const onSetValue = overrides.onSetValue ?? vi.fn();
|
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||||
|
const onAddPersistentDamage = overrides.onAddPersistentDamage ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const edition = overrides.edition ?? "5.5e";
|
||||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||||
const anchor = document.createElement("div");
|
const anchor = document.createElement("div");
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||||
const result = render(
|
const result = render(
|
||||||
<RulesEditionProvider>
|
<RulesEditionProvider>
|
||||||
<ConditionPicker
|
<EditionSetter edition={edition}>
|
||||||
anchorRef={anchorRef}
|
<ConditionPicker
|
||||||
activeConditions={overrides.activeConditions ?? []}
|
anchorRef={anchorRef}
|
||||||
onToggle={onToggle}
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
onSetValue={onSetValue}
|
activePersistentDamage={overrides.activePersistentDamage}
|
||||||
onClose={onClose}
|
onToggle={onToggle}
|
||||||
/>
|
onSetValue={onSetValue}
|
||||||
|
onAddPersistentDamage={onAddPersistentDamage}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
</EditionSetter>
|
||||||
</RulesEditionProvider>,
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onToggle, onSetValue, onClose };
|
return { ...result, onToggle, onSetValue, onAddPersistentDamage, onClose };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ConditionPicker", () => {
|
describe("ConditionPicker", () => {
|
||||||
@@ -77,4 +107,111 @@ describe("ConditionPicker", () => {
|
|||||||
const label = screen.getByText("Charmed");
|
const label = screen.getByText("Charmed");
|
||||||
expect(label.className).toContain("text-foreground");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
CreatureId,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
cleanup,
|
cleanup,
|
||||||
@@ -17,6 +21,7 @@ import {
|
|||||||
buildCombatant,
|
buildCombatant,
|
||||||
buildCreature,
|
buildCreature,
|
||||||
buildEncounter,
|
buildEncounter,
|
||||||
|
buildPf2eCreature,
|
||||||
} from "../../__tests__/factories/index.js";
|
} from "../../__tests__/factories/index.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||||
@@ -52,7 +57,7 @@ const goblinCreature = buildCreature({
|
|||||||
function renderPanel(options: {
|
function renderPanel(options: {
|
||||||
encounter: ReturnType<typeof buildEncounter>;
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
playerCharacters?: PlayerCharacter[];
|
playerCharacters?: PlayerCharacter[];
|
||||||
creatures?: Map<CreatureId, Creature>;
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const adapters = createTestAdapters({
|
const adapters = createTestAdapters({
|
||||||
@@ -357,4 +362,157 @@ describe("DifficultyBreakdownPanel", () => {
|
|||||||
|
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PF2e edition", () => {
|
||||||
|
const orcWarrior = buildPf2eCreature({
|
||||||
|
id: creatureId("pf2e:orc-warrior"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
level: 3,
|
||||||
|
source: "crb",
|
||||||
|
sourceDisplayName: "Core Rulebook",
|
||||||
|
});
|
||||||
|
|
||||||
|
function pf2eEncounter() {
|
||||||
|
return buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: orcWarrior.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows PF2e tier label", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows party level", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Party Level: 5", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows creature level and level difference", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Orc Warrior level 3, party level 5 → diff −2
|
||||||
|
expect(
|
||||||
|
screen.getByText("Lv 3 (-2)", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 5 thresholds with short labels", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Triv:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Low:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Mod:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Sev:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Ext:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Net Creature XP label in PF2e mode", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Net Creature XP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DifficultyIndicator,
|
DifficultyIndicator,
|
||||||
TIER_LABELS_5_5E,
|
TIER_LABELS_5_5E,
|
||||||
TIER_LABELS_2014,
|
TIER_LABELS_2014,
|
||||||
|
TIER_LABELS_PF2E,
|
||||||
} from "../difficulty-indicator.js";
|
} from "../difficulty-indicator.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -23,6 +24,7 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
|||||||
encounterMultiplier: undefined,
|
encounterMultiplier: undefined,
|
||||||
adjustedXp: undefined,
|
adjustedXp: undefined,
|
||||||
partySizeAdjusted: undefined,
|
partySizeAdjusted: undefined,
|
||||||
|
partyLevel: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,4 +127,64 @@ describe("DifficultyIndicator", () => {
|
|||||||
const element = container.querySelector("[role='img']");
|
const element = container.querySelector("[role='img']");
|
||||||
expect(element?.tagName).toBe("BUTTON");
|
expect(element?.tagName).toBe("BUTTON");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders 4 bars when barCount is 4", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
expect(bars).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 0 filled bars for tier 0 with 4 bars", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(0)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
for (const bar of bars) {
|
||||||
|
expect(bar.className).toContain("bg-muted");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct PF2e tooltip for Severe tier", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(3)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Severe encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct PF2e tooltip for Extreme tier", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(4)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Extreme encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("D&D indicator still renders 3 bars (no regression)", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
expect(bars).toHaveLength(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ConditionEntry,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
|
type PersistentDamageEntry,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
type RollMode,
|
type RollMode,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
@@ -19,6 +20,7 @@ import { ConditionPicker } from "./condition-picker.js";
|
|||||||
import { ConditionTags } from "./condition-tags.js";
|
import { ConditionTags } from "./condition-tags.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { HpAdjustPopover } from "./hp-adjust-popover.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 { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
@@ -33,6 +35,7 @@ interface Combatant {
|
|||||||
readonly tempHp?: number;
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionEntry[];
|
readonly conditions?: readonly ConditionEntry[];
|
||||||
|
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
@@ -454,6 +457,8 @@ export function CombatantRow({
|
|||||||
setConditionValue,
|
setConditionValue,
|
||||||
decrementCondition,
|
decrementCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
|
addPersistentDamage,
|
||||||
|
removePersistentDamage,
|
||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
const {
|
const {
|
||||||
selectedCreatureId,
|
selectedCreatureId,
|
||||||
@@ -613,16 +618,29 @@ export function CombatantRow({
|
|||||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
>
|
||||||
|
{isPf2e && (
|
||||||
|
<PersistentDamageTags
|
||||||
|
entries={combatant.persistentDamage}
|
||||||
|
onRemove={(damageType) =>
|
||||||
|
removePersistentDamage(id, damageType)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ConditionTags>
|
||||||
</div>
|
</div>
|
||||||
{!!pickerOpen && (
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
anchorRef={conditionAnchorRef}
|
anchorRef={conditionAnchorRef}
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
|
activePersistentDamage={combatant.persistentDamage}
|
||||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onSetValue={(conditionId, value) =>
|
onSetValue={(conditionId, value) =>
|
||||||
setConditionValue(id, conditionId, value)
|
setConditionValue(id, conditionId, value)
|
||||||
}
|
}
|
||||||
|
onAddPersistentDamage={(damageType, formula) =>
|
||||||
|
addPersistentDamage(id, damageType, formula)
|
||||||
|
}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Check, Minus, Plus } from "lucide-react";
|
import { Check, Flame, Minus, Plus } from "lucide-react";
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
@@ -14,21 +16,29 @@ import {
|
|||||||
CONDITION_COLOR_CLASSES,
|
CONDITION_COLOR_CLASSES,
|
||||||
CONDITION_ICON_MAP,
|
CONDITION_ICON_MAP,
|
||||||
} from "./condition-styles.js";
|
} from "./condition-styles.js";
|
||||||
|
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
activeConditions: readonly ConditionEntry[] | undefined;
|
activeConditions: readonly ConditionEntry[] | undefined;
|
||||||
|
activePersistentDamage?: readonly PersistentDamageEntry[];
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
|
onAddPersistentDamage?: (
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionPicker({
|
export function ConditionPicker({
|
||||||
anchorRef,
|
anchorRef,
|
||||||
activeConditions,
|
activeConditions,
|
||||||
|
activePersistentDamage,
|
||||||
onToggle,
|
onToggle,
|
||||||
onSetValue,
|
onSetValue,
|
||||||
|
onAddPersistentDamage,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<ConditionPickerProps>) {
|
}: Readonly<ConditionPickerProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -42,6 +52,7 @@ export function ConditionPicker({
|
|||||||
id: ConditionId;
|
id: ConditionId;
|
||||||
value: number;
|
value: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
@@ -71,6 +82,51 @@ export function ConditionPicker({
|
|||||||
const activeMap = new Map(
|
const activeMap = new Map(
|
||||||
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
(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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -82,7 +138,7 @@ export function ConditionPicker({
|
|||||||
: { visibility: "hidden" as const }
|
: { visibility: "hidden" as const }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{conditions.map((def) => {
|
{conditions.map((def, index) => {
|
||||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const isActive = activeMap.has(def.id);
|
const isActive = activeMap.has(def.id);
|
||||||
@@ -104,111 +160,116 @@ export function ConditionPicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<React.Fragment key={def.id}>
|
||||||
key={def.id}
|
{index === persistentDamageInsertIndex && persistentDamageEntry}
|
||||||
content={getConditionDescription(def, edition)}
|
<Tooltip
|
||||||
className="block"
|
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",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={cn(
|
||||||
className="flex flex-1 items-center gap-2"
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||||
onClick={handleClick}
|
(isActive || isEditing) && "bg-card/50",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<button
|
||||||
size={14}
|
type="button"
|
||||||
className={
|
className="flex flex-1 items-center gap-2"
|
||||||
isActive || isEditing ? colorClass : "text-muted-foreground"
|
onClick={handleClick}
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive || isEditing
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{def.label}
|
<Icon
|
||||||
</span>
|
size={14}
|
||||||
</button>
|
className={
|
||||||
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
isActive || 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">
|
? colorClass
|
||||||
{activeValue}
|
: "text-muted-foreground"
|
||||||
</span>
|
}
|
||||||
)}
|
/>
|
||||||
{isEditing && (
|
<span
|
||||||
<div className="flex items-center gap-0.5">
|
className={
|
||||||
<button
|
isActive || isEditing
|
||||||
type="button"
|
? "text-foreground"
|
||||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
: "text-muted-foreground"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
}
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (editing.value > 1) {
|
|
||||||
setEditing({
|
|
||||||
...editing,
|
|
||||||
value: editing.value - 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Minus className="h-3 w-3" />
|
{def.label}
|
||||||
</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>
|
</span>
|
||||||
{(() => {
|
</button>
|
||||||
const atMax =
|
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
||||||
def.maxValue !== undefined &&
|
<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.maxValue;
|
{activeValue}
|
||||||
return (
|
</span>
|
||||||
<button
|
)}
|
||||||
type="button"
|
{isEditing && (
|
||||||
className={cn(
|
<div className="flex items-center gap-0.5">
|
||||||
"rounded p-0.5",
|
<button
|
||||||
atMax
|
type="button"
|
||||||
? "cursor-not-allowed text-muted-foreground opacity-50"
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
: "text-foreground hover:bg-accent/40",
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
)}
|
onClick={(e) => {
|
||||||
disabled={atMax}
|
e.stopPropagation();
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
if (editing.value > 1) {
|
||||||
onClick={(e) => {
|
setEditing({
|
||||||
e.stopPropagation();
|
...editing,
|
||||||
if (!atMax) {
|
value: 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">
|
||||||
<Plus className="h-3 w-3" />
|
{editing.value}
|
||||||
</button>
|
</span>
|
||||||
);
|
{(() => {
|
||||||
})()}
|
const atMax =
|
||||||
<button
|
def.maxValue !== undefined &&
|
||||||
type="button"
|
editing.value >= def.maxValue;
|
||||||
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
return (
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation();
|
className={cn(
|
||||||
onSetValue(editing.id, editing.value);
|
"rounded p-0.5",
|
||||||
setEditing(null);
|
atMax
|
||||||
}}
|
? "cursor-not-allowed text-muted-foreground opacity-50"
|
||||||
>
|
: "text-foreground hover:bg-accent/40",
|
||||||
<Check className="h-3.5 w-3.5" />
|
)}
|
||||||
</button>
|
disabled={atMax}
|
||||||
</div>
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
)}
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
</Tooltip>
|
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>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import {
|
|||||||
Droplet,
|
Droplet,
|
||||||
Droplets,
|
Droplets,
|
||||||
EarOff,
|
EarOff,
|
||||||
|
Eclipse,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeClosed,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Flame,
|
||||||
|
FlaskConical,
|
||||||
Footprints,
|
Footprints,
|
||||||
Gem,
|
Gem,
|
||||||
Ghost,
|
Ghost,
|
||||||
@@ -22,15 +26,20 @@ import {
|
|||||||
HeartPulse,
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
Orbit,
|
||||||
PersonStanding,
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Siren,
|
Siren,
|
||||||
Skull,
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
|
Snowflake,
|
||||||
|
Sparkle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Sun,
|
Sun,
|
||||||
|
Sword,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
Wind,
|
||||||
Zap,
|
Zap,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -47,8 +56,12 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Droplet,
|
Droplet,
|
||||||
Droplets,
|
Droplets,
|
||||||
EarOff,
|
EarOff,
|
||||||
|
Eclipse,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeClosed,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Flame,
|
||||||
|
FlaskConical,
|
||||||
Footprints,
|
Footprints,
|
||||||
Gem,
|
Gem,
|
||||||
Ghost,
|
Ghost,
|
||||||
@@ -58,15 +71,20 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
HeartPulse,
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
Orbit,
|
||||||
PersonStanding,
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Siren,
|
Siren,
|
||||||
Skull,
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
|
Snowflake,
|
||||||
|
Sparkle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Sun,
|
Sun,
|
||||||
|
Sword,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
Wind,
|
||||||
Zap,
|
Zap,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
};
|
};
|
||||||
@@ -76,11 +94,13 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
|||||||
pink: "text-pink-400",
|
pink: "text-pink-400",
|
||||||
amber: "text-amber-400",
|
amber: "text-amber-400",
|
||||||
orange: "text-orange-400",
|
orange: "text-orange-400",
|
||||||
|
purple: "text-purple-400",
|
||||||
gray: "text-gray-400",
|
gray: "text-gray-400",
|
||||||
violet: "text-violet-400",
|
violet: "text-violet-400",
|
||||||
yellow: "text-yellow-400",
|
yellow: "text-yellow-400",
|
||||||
slate: "text-slate-400",
|
slate: "text-slate-400",
|
||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
|
lime: "text-lime-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
sky: "text-sky-400",
|
sky: "text-sky-400",
|
||||||
red: "text-red-400",
|
red: "text-red-400",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ interface ConditionTagsProps {
|
|||||||
onRemove: (conditionId: ConditionId) => void;
|
onRemove: (conditionId: ConditionId) => void;
|
||||||
onDecrement: (conditionId: ConditionId) => void;
|
onDecrement: (conditionId: ConditionId) => void;
|
||||||
onOpenPicker: () => void;
|
onOpenPicker: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionTags({
|
export function ConditionTags({
|
||||||
@@ -25,6 +27,7 @@ export function ConditionTags({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onDecrement,
|
onDecrement,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
|
children,
|
||||||
}: Readonly<ConditionTagsProps>) {
|
}: Readonly<ConditionTagsProps>) {
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +72,7 @@ export function ConditionTags({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{children}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Add condition"
|
title="Add condition"
|
||||||
|
|||||||
@@ -19,12 +19,21 @@ const TIER_LABEL_MAP: Partial<
|
|||||||
1: { label: "Low", color: "text-green-500" },
|
1: { label: "Low", color: "text-green-500" },
|
||||||
2: { label: "Moderate", color: "text-yellow-500" },
|
2: { label: "Moderate", color: "text-yellow-500" },
|
||||||
3: { label: "High", color: "text-red-500" },
|
3: { label: "High", color: "text-red-500" },
|
||||||
|
4: { label: "High", color: "text-red-500" },
|
||||||
},
|
},
|
||||||
"5e": {
|
"5e": {
|
||||||
0: { label: "Easy", color: "text-muted-foreground" },
|
0: { label: "Easy", color: "text-muted-foreground" },
|
||||||
1: { label: "Medium", color: "text-green-500" },
|
1: { label: "Medium", color: "text-green-500" },
|
||||||
2: { label: "Hard", color: "text-yellow-500" },
|
2: { label: "Hard", color: "text-yellow-500" },
|
||||||
3: { label: "Deadly", color: "text-red-500" },
|
3: { label: "Deadly", color: "text-red-500" },
|
||||||
|
4: { label: "Deadly", color: "text-red-500" },
|
||||||
|
},
|
||||||
|
pf2e: {
|
||||||
|
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||||
|
1: { label: "Low", color: "text-green-500" },
|
||||||
|
2: { label: "Moderate", color: "text-yellow-500" },
|
||||||
|
3: { label: "Severe", color: "text-orange-500" },
|
||||||
|
4: { label: "Extreme", color: "text-red-500" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,6 +41,9 @@ const TIER_LABEL_MAP: Partial<
|
|||||||
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||||
Moderate: "Mod",
|
Moderate: "Mod",
|
||||||
Medium: "Med",
|
Medium: "Med",
|
||||||
|
Trivial: "Triv",
|
||||||
|
Severe: "Sev",
|
||||||
|
Extreme: "Ext",
|
||||||
};
|
};
|
||||||
|
|
||||||
function shortLabel(label: string): string {
|
function shortLabel(label: string): string {
|
||||||
@@ -107,6 +119,54 @@ function NpcRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Pf2eNpcRow({
|
||||||
|
entry,
|
||||||
|
onToggleSide,
|
||||||
|
}: {
|
||||||
|
entry: BreakdownCombatant;
|
||||||
|
onToggleSide: () => void;
|
||||||
|
}) {
|
||||||
|
const isParty = entry.side === "party";
|
||||||
|
const targetSide = isParty ? "enemy" : "party";
|
||||||
|
|
||||||
|
let xpDisplay: string;
|
||||||
|
if (entry.xp == null) {
|
||||||
|
xpDisplay = "\u2014";
|
||||||
|
} else if (isParty) {
|
||||||
|
xpDisplay = `\u2212${formatXp(entry.xp)}`;
|
||||||
|
} else {
|
||||||
|
xpDisplay = formatXp(entry.xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
let levelDisplay: string;
|
||||||
|
if (entry.creatureLevel === undefined) {
|
||||||
|
levelDisplay = "\u2014";
|
||||||
|
} else if (entry.levelDifference === undefined) {
|
||||||
|
levelDisplay = `Lv ${entry.creatureLevel}`;
|
||||||
|
} else {
|
||||||
|
const sign = entry.levelDifference >= 0 ? "+" : "";
|
||||||
|
levelDisplay = `Lv ${entry.creatureLevel} (${sign}${entry.levelDifference})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||||
|
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||||
|
{entry.combatant.name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleSide}
|
||||||
|
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground">{levelDisplay}</span>
|
||||||
|
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useClickOutside(ref, onClose);
|
useClickOutside(ref, onClose);
|
||||||
@@ -128,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
const isPC = (entry: BreakdownCombatant) =>
|
const isPC = (entry: BreakdownCombatant) =>
|
||||||
entry.combatant.playerCharacterId != null;
|
entry.combatant.playerCharacterId != null;
|
||||||
|
|
||||||
|
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -142,6 +204,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="mb-1 text-muted-foreground text-xs">
|
<div className="mb-1 text-muted-foreground text-xs">
|
||||||
Party Budget ({breakdown.pcCount}{" "}
|
Party Budget ({breakdown.pcCount}{" "}
|
||||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||||
|
{breakdown.partyLevel !== undefined && (
|
||||||
|
<> · Party Level: {breakdown.partyLevel}</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 text-xs">
|
<div className="flex gap-3 text-xs">
|
||||||
{breakdown.thresholds.map((t) => (
|
{breakdown.thresholds.map((t) => (
|
||||||
@@ -166,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
isPC(entry) ? (
|
isPC(entry) ? (
|
||||||
<PcRow key={entry.combatant.id} entry={entry} />
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
) : (
|
) : (
|
||||||
<NpcRow
|
<CreatureRow
|
||||||
key={entry.combatant.id}
|
key={entry.combatant.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
onToggleSide={() => handleToggle(entry)}
|
onToggleSide={() => handleToggle(entry)}
|
||||||
@@ -186,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
isPC(entry) ? (
|
isPC(entry) ? (
|
||||||
<PcRow key={entry.combatant.id} entry={entry} />
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
) : (
|
) : (
|
||||||
<NpcRow
|
<CreatureRow
|
||||||
key={entry.combatant.id}
|
key={entry.combatant.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
onToggleSide={() => handleToggle(entry)}
|
onToggleSide={() => handleToggle(entry)}
|
||||||
@@ -218,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||||
<span>Net Monster XP</span>
|
<span>
|
||||||
|
{edition === "pf2e" ? "Net Creature XP" : "Net Monster XP"}
|
||||||
|
</span>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{formatXp(breakdown.totalMonsterXp)}
|
{formatXp(breakdown.totalMonsterXp)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
|||||||
1: "Low",
|
1: "Low",
|
||||||
2: "Moderate",
|
2: "Moderate",
|
||||||
3: "High",
|
3: "High",
|
||||||
|
4: "High",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||||
@@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
|||||||
1: "Medium",
|
1: "Medium",
|
||||||
2: "Hard",
|
2: "Hard",
|
||||||
3: "Deadly",
|
3: "Deadly",
|
||||||
|
4: "Deadly",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TIER_COLORS: Record<
|
export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = {
|
||||||
DifficultyTier,
|
0: "Trivial",
|
||||||
{ filledBars: number; color: string }
|
1: "Low",
|
||||||
> = {
|
2: "Moderate",
|
||||||
0: { filledBars: 0, color: "" },
|
3: "Severe",
|
||||||
1: { filledBars: 1, color: "bg-green-500" },
|
4: "Extreme",
|
||||||
2: { filledBars: 2, color: "bg-yellow-500" },
|
|
||||||
3: { filledBars: 3, color: "bg-red-500" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
const BAR_HEIGHTS_3 = ["h-2", "h-3", "h-4"] as const;
|
||||||
|
const BAR_HEIGHTS_4 = ["h-1.5", "h-2", "h-3", "h-4"] as const;
|
||||||
|
|
||||||
|
/** Color for the Nth filled bar (1-indexed) in 4-bar mode. */
|
||||||
|
const BAR_COLORS: Record<number, string> = {
|
||||||
|
1: "bg-green-500",
|
||||||
|
2: "bg-yellow-500",
|
||||||
|
3: "bg-orange-500",
|
||||||
|
4: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** For 3-bar mode, bar 3 uses red directly (skip orange). */
|
||||||
|
const BAR_COLORS_3: Record<number, string> = {
|
||||||
|
1: "bg-green-500",
|
||||||
|
2: "bg-yellow-500",
|
||||||
|
3: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
export function DifficultyIndicator({
|
export function DifficultyIndicator({
|
||||||
result,
|
result,
|
||||||
labels,
|
labels,
|
||||||
|
barCount = 3,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
result: DifficultyResult;
|
result: DifficultyResult;
|
||||||
labels: Record<DifficultyTier, string>;
|
labels: Record<DifficultyTier, string>;
|
||||||
|
barCount?: 3 | 4;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const config = TIER_COLORS[result.tier];
|
const barHeights = barCount === 4 ? BAR_HEIGHTS_4 : BAR_HEIGHTS_3;
|
||||||
|
const colorMap = barCount === 4 ? BAR_COLORS : BAR_COLORS_3;
|
||||||
|
const filledBars = result.tier;
|
||||||
const label = labels[result.tier];
|
const label = labels[result.tier];
|
||||||
const tooltip = `${label} encounter difficulty`;
|
const tooltip = `${label} encounter difficulty`;
|
||||||
|
|
||||||
@@ -54,13 +74,13 @@ export function DifficultyIndicator({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
type={onClick ? "button" : undefined}
|
type={onClick ? "button" : undefined}
|
||||||
>
|
>
|
||||||
{BAR_HEIGHTS.map((height, i) => (
|
{barHeights.map((height, i) => (
|
||||||
<div
|
<div
|
||||||
key={height}
|
key={height}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-1 rounded-sm",
|
"w-1 rounded-sm",
|
||||||
height,
|
height,
|
||||||
i < config.filledBars ? config.color : "bg-muted",
|
i < filledBars ? colorMap[i + 1] : "bg-muted",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DifficultyIndicator,
|
DifficultyIndicator,
|
||||||
TIER_LABELS_5_5E,
|
TIER_LABELS_5_5E,
|
||||||
TIER_LABELS_2014,
|
TIER_LABELS_2014,
|
||||||
|
TIER_LABELS_PF2E,
|
||||||
} from "./difficulty-indicator.js";
|
} from "./difficulty-indicator.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
@@ -26,7 +27,13 @@ export function TurnNavigation() {
|
|||||||
|
|
||||||
const difficulty = useDifficulty();
|
const difficulty = useDifficulty();
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
|
const TIER_LABELS_BY_EDITION = {
|
||||||
|
pf2e: TIER_LABELS_PF2E,
|
||||||
|
"5e": TIER_LABELS_2014,
|
||||||
|
"5.5e": TIER_LABELS_5_5E,
|
||||||
|
} as const;
|
||||||
|
const tierLabels = TIER_LABELS_BY_EDITION[edition];
|
||||||
|
const barCount = edition === "pf2e" ? 4 : 3;
|
||||||
const [showBreakdown, setShowBreakdown] = useState(false);
|
const [showBreakdown, setShowBreakdown] = useState(false);
|
||||||
const hasCombatants = encounter.combatants.length > 0;
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
@@ -87,6 +94,7 @@ export function TurnNavigation() {
|
|||||||
<DifficultyIndicator
|
<DifficultyIndicator
|
||||||
result={difficulty}
|
result={difficulty}
|
||||||
labels={tierLabels}
|
labels={tierLabels}
|
||||||
|
barCount={barCount}
|
||||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
{showBreakdown ? (
|
{showBreakdown ? (
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
CreatureId,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -9,6 +13,7 @@ import {
|
|||||||
buildCombatant,
|
buildCombatant,
|
||||||
buildCreature,
|
buildCreature,
|
||||||
buildEncounter,
|
buildEncounter,
|
||||||
|
buildPf2eCreature,
|
||||||
} from "../../__tests__/factories/index.js";
|
} from "../../__tests__/factories/index.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
||||||
@@ -42,7 +47,7 @@ const goblinCreature = buildCreature({
|
|||||||
function makeWrapper(options: {
|
function makeWrapper(options: {
|
||||||
encounter: ReturnType<typeof buildEncounter>;
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
playerCharacters?: PlayerCharacter[];
|
playerCharacters?: PlayerCharacter[];
|
||||||
creatures?: Map<CreatureId, Creature>;
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
}) {
|
}) {
|
||||||
const adapters = createTestAdapters({
|
const adapters = createTestAdapters({
|
||||||
encounter: options.encounter,
|
encounter: options.encounter,
|
||||||
@@ -345,4 +350,115 @@ describe("useDifficultyBreakdown", () => {
|
|||||||
editionResult.current.setEdition("5.5e");
|
editionResult.current.setEdition("5.5e");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PF2e edition", () => {
|
||||||
|
const orcWarrior = buildPf2eCreature({
|
||||||
|
id: creatureId("pf2e:orc-warrior"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
level: 3,
|
||||||
|
source: "crb",
|
||||||
|
sourceDisplayName: "Core Rulebook",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns breakdown with creatureLevel, levelDifference, and XP for PF2e creatures", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: orcWarrior.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
|
||||||
|
// Party level should be 5
|
||||||
|
expect(breakdown?.partyLevel).toBe(5);
|
||||||
|
|
||||||
|
// Orc Warrior: level 3, party level 5 → diff −2 → 20 XP
|
||||||
|
const orc = breakdown?.enemyCombatants[0];
|
||||||
|
expect(orc?.creatureLevel).toBe(3);
|
||||||
|
expect(orc?.levelDifference).toBe(-2);
|
||||||
|
expect(orc?.xp).toBe(20);
|
||||||
|
expect(orc?.cr).toBeNull();
|
||||||
|
expect(orc?.source).toBe("Core Rulebook");
|
||||||
|
|
||||||
|
// PC should have no creature level
|
||||||
|
const pc = breakdown?.partyCombatants[0];
|
||||||
|
expect(pc?.creatureLevel).toBeUndefined();
|
||||||
|
expect(pc?.levelDifference).toBeUndefined();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns partyLevel in result", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: orcWarrior.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
expect(result.current?.partyLevel).toBe(5);
|
||||||
|
// 5 thresholds for PF2e
|
||||||
|
expect(result.current?.thresholds).toHaveLength(5);
|
||||||
|
expect(result.current?.thresholds[0].label).toBe("Trivial");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
CreatureId,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -9,6 +13,7 @@ import {
|
|||||||
buildCombatant,
|
buildCombatant,
|
||||||
buildCreature,
|
buildCreature,
|
||||||
buildEncounter,
|
buildEncounter,
|
||||||
|
buildPf2eCreature,
|
||||||
} from "../../__tests__/factories/index.js";
|
} from "../../__tests__/factories/index.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useDifficulty } from "../use-difficulty.js";
|
import { useDifficulty } from "../use-difficulty.js";
|
||||||
@@ -43,7 +48,7 @@ const goblinCreature = buildCreature({
|
|||||||
function makeWrapper(options: {
|
function makeWrapper(options: {
|
||||||
encounter: ReturnType<typeof buildEncounter>;
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
playerCharacters?: PlayerCharacter[];
|
playerCharacters?: PlayerCharacter[];
|
||||||
creatures?: Map<CreatureId, Creature>;
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
}) {
|
}) {
|
||||||
const adapters = createTestAdapters({
|
const adapters = createTestAdapters({
|
||||||
encounter: options.encounter,
|
encounter: options.encounter,
|
||||||
@@ -424,4 +429,134 @@ describe("useDifficulty", () => {
|
|||||||
expect(result.current?.totalMonsterXp).toBe(0);
|
expect(result.current?.totalMonsterXp).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PF2e edition", () => {
|
||||||
|
const pf2eCreature = buildPf2eCreature({
|
||||||
|
id: creatureId("pf2e:orc-warrior"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
level: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
function makePf2eWrapper(options: {
|
||||||
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
|
}) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
encounter: options.encounter,
|
||||||
|
playerCharacters: options.playerCharacters ?? [],
|
||||||
|
creatures: options.creatures,
|
||||||
|
});
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns result for PF2e with leveled PCs and PF2e creatures", async () => {
|
||||||
|
const wrapper = makePf2eWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: pf2eCreature.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[pf2eCreature.id, pf2eCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// Creature level 5, party level 5 → diff 0 → 40 XP
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(40);
|
||||||
|
expect(result.current?.partyLevel).toBe(5);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for PF2e when no PF2e creatures with level", () => {
|
||||||
|
const wrapper = makePf2eWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Custom Monster",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for PF2e when no PCs with level", () => {
|
||||||
|
const wrapper = makePf2eWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: pf2eCreature.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||||
|
creatures: new Map([[pf2eCreature.id, pf2eCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyCreature,
|
||||||
Combatant,
|
Combatant,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
DifficultyThreshold,
|
DifficultyThreshold,
|
||||||
DifficultyTier,
|
DifficultyTier,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
import {
|
||||||
|
calculateEncounterDifficulty,
|
||||||
|
crToXp,
|
||||||
|
derivePartyLevel,
|
||||||
|
pf2eCreatureXp,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
@@ -21,6 +27,10 @@ export interface BreakdownCombatant {
|
|||||||
readonly editable: boolean;
|
readonly editable: boolean;
|
||||||
readonly side: "party" | "enemy";
|
readonly side: "party" | "enemy";
|
||||||
readonly level: number | undefined;
|
readonly level: number | undefined;
|
||||||
|
/** PF2e only: the creature's level from bestiary data. */
|
||||||
|
readonly creatureLevel: number | undefined;
|
||||||
|
/** PF2e only: creature level minus party level. */
|
||||||
|
readonly levelDifference: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DifficultyBreakdown {
|
interface DifficultyBreakdown {
|
||||||
@@ -30,6 +40,7 @@ interface DifficultyBreakdown {
|
|||||||
readonly encounterMultiplier: number | undefined;
|
readonly encounterMultiplier: number | undefined;
|
||||||
readonly adjustedXp: number | undefined;
|
readonly adjustedXp: number | undefined;
|
||||||
readonly partySizeAdjusted: boolean | undefined;
|
readonly partySizeAdjusted: boolean | undefined;
|
||||||
|
readonly partyLevel: number | undefined;
|
||||||
readonly pcCount: number;
|
readonly pcCount: number;
|
||||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||||
@@ -48,9 +59,16 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
|||||||
const hasPartyLevel = descriptors.some(
|
const hasPartyLevel = descriptors.some(
|
||||||
(d) => d.side === "party" && d.level !== undefined,
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
);
|
);
|
||||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
|
||||||
|
|
||||||
if (!hasPartyLevel || !hasCr) return null;
|
if (edition === "pf2e") {
|
||||||
|
const hasCreatureLevel = descriptors.some(
|
||||||
|
(d) => d.creatureLevel !== undefined,
|
||||||
|
);
|
||||||
|
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||||
|
} else {
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
}
|
||||||
|
|
||||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||||
|
|
||||||
@@ -65,6 +83,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
|||||||
|
|
||||||
type CreatureInfo = {
|
type CreatureInfo = {
|
||||||
cr?: string;
|
cr?: string;
|
||||||
|
creatureLevel?: number;
|
||||||
source: string;
|
source: string;
|
||||||
sourceDisplayName: string;
|
sourceDisplayName: string;
|
||||||
};
|
};
|
||||||
@@ -74,6 +93,7 @@ function buildBreakdownEntry(
|
|||||||
side: "party" | "enemy",
|
side: "party" | "enemy",
|
||||||
level: number | undefined,
|
level: number | undefined,
|
||||||
creature: CreatureInfo | undefined,
|
creature: CreatureInfo | undefined,
|
||||||
|
partyLevel: number | undefined,
|
||||||
): BreakdownCombatant {
|
): BreakdownCombatant {
|
||||||
if (c.playerCharacterId) {
|
if (c.playerCharacterId) {
|
||||||
return {
|
return {
|
||||||
@@ -84,6 +104,29 @@ function buildBreakdownEntry(
|
|||||||
editable: false,
|
editable: false,
|
||||||
side,
|
side,
|
||||||
level,
|
level,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
levelDifference: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (creature && creature.creatureLevel !== undefined) {
|
||||||
|
const levelDiff =
|
||||||
|
partyLevel === undefined
|
||||||
|
? undefined
|
||||||
|
: creature.creatureLevel - partyLevel;
|
||||||
|
const xp =
|
||||||
|
partyLevel === undefined
|
||||||
|
? null
|
||||||
|
: pf2eCreatureXp(creature.creatureLevel, partyLevel);
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr: null,
|
||||||
|
xp,
|
||||||
|
source: creature.sourceDisplayName ?? creature.source,
|
||||||
|
editable: false,
|
||||||
|
side,
|
||||||
|
level: undefined,
|
||||||
|
creatureLevel: creature.creatureLevel,
|
||||||
|
levelDifference: levelDiff,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (creature) {
|
if (creature) {
|
||||||
@@ -96,6 +139,8 @@ function buildBreakdownEntry(
|
|||||||
editable: false,
|
editable: false,
|
||||||
side,
|
side,
|
||||||
level: undefined,
|
level: undefined,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
levelDifference: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (c.cr) {
|
if (c.cr) {
|
||||||
@@ -107,6 +152,8 @@ function buildBreakdownEntry(
|
|||||||
editable: true,
|
editable: true,
|
||||||
side,
|
side,
|
||||||
level: undefined,
|
level: undefined,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
levelDifference: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -117,6 +164,8 @@ function buildBreakdownEntry(
|
|||||||
editable: !c.creatureId,
|
editable: !c.creatureId,
|
||||||
side,
|
side,
|
||||||
level: undefined,
|
level: undefined,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
levelDifference: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,41 +177,91 @@ function resolveLevel(
|
|||||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCr(
|
function resolveCreatureInfo(
|
||||||
c: Combatant,
|
c: Combatant,
|
||||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
): {
|
||||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
cr: string | null;
|
||||||
const cr = creature?.cr ?? c.cr ?? null;
|
creatureLevel: number | undefined;
|
||||||
return { cr, creature };
|
creature: CreatureInfo | undefined;
|
||||||
|
} {
|
||||||
|
const rawCreature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||||
|
if (!rawCreature) {
|
||||||
|
return {
|
||||||
|
cr: c.cr ?? null,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
creature: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if ("system" in rawCreature && rawCreature.system === "pf2e") {
|
||||||
|
return {
|
||||||
|
cr: null,
|
||||||
|
creatureLevel: rawCreature.level,
|
||||||
|
creature: {
|
||||||
|
creatureLevel: rawCreature.level,
|
||||||
|
source: rawCreature.source,
|
||||||
|
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cr = "cr" in rawCreature ? rawCreature.cr : undefined;
|
||||||
|
return {
|
||||||
|
cr: cr ?? c.cr ?? null,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
creature: {
|
||||||
|
cr,
|
||||||
|
source: rawCreature.source,
|
||||||
|
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPartyLevel(
|
||||||
|
combatants: readonly Combatant[],
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
): number | undefined {
|
||||||
|
const partyLevels: number[] = [];
|
||||||
|
for (const c of combatants) {
|
||||||
|
if (resolveSide(c) !== "party") continue;
|
||||||
|
const level = resolveLevel(c, characters);
|
||||||
|
if (level !== undefined) partyLevels.push(level);
|
||||||
|
}
|
||||||
|
return partyLevels.length > 0 ? derivePartyLevel(partyLevels) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyCombatants(
|
function classifyCombatants(
|
||||||
combatants: readonly Combatant[],
|
combatants: readonly Combatant[],
|
||||||
characters: readonly PlayerCharacter[],
|
characters: readonly PlayerCharacter[],
|
||||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
) {
|
) {
|
||||||
const partyCombatants: BreakdownCombatant[] = [];
|
const partyCombatants: BreakdownCombatant[] = [];
|
||||||
const enemyCombatants: BreakdownCombatant[] = [];
|
const enemyCombatants: BreakdownCombatant[] = [];
|
||||||
const descriptors: {
|
const descriptors: {
|
||||||
level?: number;
|
level?: number;
|
||||||
cr?: string;
|
cr?: string;
|
||||||
|
creatureLevel?: number;
|
||||||
side: "party" | "enemy";
|
side: "party" | "enemy";
|
||||||
}[] = [];
|
}[] = [];
|
||||||
let pcCount = 0;
|
let pcCount = 0;
|
||||||
|
const partyLevel = collectPartyLevel(combatants, characters);
|
||||||
|
|
||||||
for (const c of combatants) {
|
for (const c of combatants) {
|
||||||
const side = resolveSide(c);
|
const side = resolveSide(c);
|
||||||
const level = resolveLevel(c, characters);
|
const level = resolveLevel(c, characters);
|
||||||
if (level !== undefined) pcCount++;
|
if (level !== undefined) pcCount++;
|
||||||
|
|
||||||
const { cr, creature } = resolveCr(c, getCreature);
|
const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature);
|
||||||
|
|
||||||
if (level !== undefined || cr != null) {
|
if (level !== undefined || cr != null || creatureLevel !== undefined) {
|
||||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
descriptors.push({
|
||||||
|
level,
|
||||||
|
cr: cr ?? undefined,
|
||||||
|
creatureLevel,
|
||||||
|
side,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = buildBreakdownEntry(c, side, level, creature);
|
const entry = buildBreakdownEntry(c, side, level, creature, partyLevel);
|
||||||
const target = side === "party" ? partyCombatants : enemyCombatants;
|
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||||
target.push(entry);
|
target.push(entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,17 @@ function buildDescriptors(
|
|||||||
const creatureCr =
|
const creatureCr =
|
||||||
creature && !("system" in creature) ? creature.cr : undefined;
|
creature && !("system" in creature) ? creature.cr : undefined;
|
||||||
const cr = creatureCr ?? c.cr ?? undefined;
|
const cr = creatureCr ?? c.cr ?? undefined;
|
||||||
|
const creatureLevel =
|
||||||
|
creature && "system" in creature && creature.system === "pf2e"
|
||||||
|
? creature.level
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (level !== undefined || cr !== undefined) {
|
if (
|
||||||
descriptors.push({ level, cr, side });
|
level !== undefined ||
|
||||||
|
cr !== undefined ||
|
||||||
|
creatureLevel !== undefined
|
||||||
|
) {
|
||||||
|
descriptors.push({ level, cr, creatureLevel, side });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return descriptors;
|
return descriptors;
|
||||||
@@ -48,8 +56,6 @@ export function useDifficulty(): DifficultyResult | null {
|
|||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (edition === "pf2e") return null;
|
|
||||||
|
|
||||||
const descriptors = buildDescriptors(
|
const descriptors = buildDescriptors(
|
||||||
encounter.combatants,
|
encounter.combatants,
|
||||||
characters,
|
characters,
|
||||||
@@ -59,9 +65,16 @@ export function useDifficulty(): DifficultyResult | null {
|
|||||||
const hasPartyLevel = descriptors.some(
|
const hasPartyLevel = descriptors.some(
|
||||||
(d) => d.side === "party" && d.level !== undefined,
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
);
|
);
|
||||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
|
||||||
|
|
||||||
if (!hasPartyLevel || !hasCr) return null;
|
if (edition === "pf2e") {
|
||||||
|
const hasCreatureLevel = descriptors.some(
|
||||||
|
(d) => d.creatureLevel !== undefined,
|
||||||
|
);
|
||||||
|
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||||
|
} else {
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
}
|
||||||
|
|
||||||
return calculateEncounterDifficulty(descriptors, edition);
|
return calculateEncounterDifficulty(descriptors, edition);
|
||||||
}, [encounter.combatants, characters, getCreature, edition]);
|
}, [encounter.combatants, characters, getCreature, edition]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||||
import {
|
import {
|
||||||
addCombatantUseCase,
|
addCombatantUseCase,
|
||||||
|
addPersistentDamageUseCase,
|
||||||
adjustHpUseCase,
|
adjustHpUseCase,
|
||||||
advanceTurnUseCase,
|
advanceTurnUseCase,
|
||||||
clearEncounterUseCase,
|
clearEncounterUseCase,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
redoUseCase,
|
redoUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
|
removePersistentDamageUseCase,
|
||||||
retreatTurnUseCase,
|
retreatTurnUseCase,
|
||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
setConditionValueUseCase,
|
setConditionValueUseCase,
|
||||||
@@ -28,6 +30,7 @@ import type {
|
|||||||
DomainError,
|
DomainError,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
|
PersistentDamageType,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
UndoRedoState,
|
UndoRedoState,
|
||||||
@@ -78,6 +81,17 @@ type EncounterAction =
|
|||||||
conditionId: ConditionId;
|
conditionId: ConditionId;
|
||||||
}
|
}
|
||||||
| { type: "toggle-concentration"; id: CombatantId }
|
| { 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: "clear-encounter" }
|
||||||
| { type: "undo" }
|
| { type: "undo" }
|
||||||
| { type: "redo" }
|
| { type: "redo" }
|
||||||
@@ -427,6 +441,8 @@ function dispatchEncounterAction(
|
|||||||
| { type: "set-condition-value" }
|
| { type: "set-condition-value" }
|
||||||
| { type: "decrement-condition" }
|
| { type: "decrement-condition" }
|
||||||
| { type: "toggle-concentration" }
|
| { type: "toggle-concentration" }
|
||||||
|
| { type: "add-persistent-damage" }
|
||||||
|
| { type: "remove-persistent-damage" }
|
||||||
>,
|
>,
|
||||||
): EncounterState {
|
): EncounterState {
|
||||||
const { store, getEncounter } = makeStoreFromState(state);
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
@@ -488,6 +504,21 @@ function dispatchEncounterAction(
|
|||||||
case "toggle-concentration":
|
case "toggle-concentration":
|
||||||
result = toggleConcentrationUseCase(store, action.id);
|
result = toggleConcentrationUseCase(store, action.id);
|
||||||
break;
|
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;
|
if (isDomainError(result)) return state;
|
||||||
@@ -651,6 +682,16 @@ export function useEncounter() {
|
|||||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
(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(
|
setCreatureAdjustment: useCallback(
|
||||||
(
|
(
|
||||||
id: CombatantId,
|
id: CombatantId,
|
||||||
|
|||||||
20
packages/application/src/add-persistent-damage-use-case.ts
Normal file
20
packages/application/src/add-persistent-damage-use-case.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
addPersistentDamage,
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
type PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function addPersistentDamageUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
addPersistentDamage(encounter, combatantId, damageType, formula),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||||
|
export { addPersistentDamageUseCase } from "./add-persistent-damage-use-case.js";
|
||||||
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
||||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||||
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||||
@@ -15,6 +16,7 @@ export type {
|
|||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
export { redoUseCase } from "./redo-use-case.js";
|
export { redoUseCase } from "./redo-use-case.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
|
export { removePersistentDamageUseCase } from "./remove-persistent-damage-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export {
|
export {
|
||||||
type RollAllResult,
|
type RollAllResult,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
type PersistentDamageType,
|
||||||
|
removePersistentDamage,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function removePersistentDamageUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
removePersistentDamage(encounter, combatantId, damageType),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
calculateEncounterDifficulty,
|
calculateEncounterDifficulty,
|
||||||
crToXp,
|
crToXp,
|
||||||
|
derivePartyLevel,
|
||||||
|
pf2eCreatureXp,
|
||||||
} from "../encounter-difficulty.js";
|
} from "../encounter-difficulty.js";
|
||||||
|
|
||||||
describe("crToXp", () => {
|
describe("crToXp", () => {
|
||||||
@@ -386,3 +388,234 @@ describe("calculateEncounterDifficulty — 2014 edition", () => {
|
|||||||
expect(result.adjustedXp).toBeUndefined();
|
expect(result.adjustedXp).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Helper to build a PF2e enemy-side descriptor with creature level. */
|
||||||
|
function pf2eEnemy(creatureLevel: number) {
|
||||||
|
return { creatureLevel, side: "enemy" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to build a PF2e party-side creature descriptor. */
|
||||||
|
function pf2eAlly(creatureLevel: number) {
|
||||||
|
return { creatureLevel, side: "party" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("derivePartyLevel", () => {
|
||||||
|
it("returns 0 for empty array", () => {
|
||||||
|
expect(derivePartyLevel([])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the level for a single PC", () => {
|
||||||
|
expect(derivePartyLevel([7])).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the unanimous level", () => {
|
||||||
|
expect(derivePartyLevel([5, 5, 5, 5])).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the mode when one level is most common", () => {
|
||||||
|
expect(derivePartyLevel([3, 3, 3, 5])).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns rounded average when mode is tied", () => {
|
||||||
|
// 3,3,5,5 → average 4
|
||||||
|
expect(derivePartyLevel([3, 3, 5, 5])).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns rounded average when all levels are different", () => {
|
||||||
|
// 2,4,6,8 → average 5
|
||||||
|
expect(derivePartyLevel([2, 4, 6, 8])).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds average to nearest integer", () => {
|
||||||
|
// 1,2 → average 1.5 → rounds to 2
|
||||||
|
expect(derivePartyLevel([1, 2])).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pf2eCreatureXp", () => {
|
||||||
|
it.each([
|
||||||
|
[-4, 10],
|
||||||
|
[-3, 15],
|
||||||
|
[-2, 20],
|
||||||
|
[-1, 30],
|
||||||
|
[0, 40],
|
||||||
|
[1, 60],
|
||||||
|
[2, 80],
|
||||||
|
[3, 120],
|
||||||
|
[4, 160],
|
||||||
|
])("level diff %i returns %i XP", (diff, expectedXp) => {
|
||||||
|
// partyLevel 5, creatureLevel = 5 + diff
|
||||||
|
expect(pf2eCreatureXp(5 + diff, 5)).toBe(expectedXp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps level diff below −4 to −4 (10 XP)", () => {
|
||||||
|
expect(pf2eCreatureXp(0, 10)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps level diff above +4 to +4 (160 XP)", () => {
|
||||||
|
expect(pf2eCreatureXp(15, 5)).toBe(160);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateEncounterDifficulty — pf2e edition", () => {
|
||||||
|
it("returns Trivial (tier 0) for 40 XP with party of 4", () => {
|
||||||
|
// 1 creature at party level = 40 XP, below Low (60)
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(0);
|
||||||
|
expect(result.totalMonsterXp).toBe(40);
|
||||||
|
expect(result.partyLevel).toBe(5);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Trivial", value: 40 },
|
||||||
|
{ label: "Low", value: 60 },
|
||||||
|
{ label: "Moderate", value: 80 },
|
||||||
|
{ label: "Severe", value: 120 },
|
||||||
|
{ label: "Extreme", value: 160 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Low (tier 1) for 60 XP", () => {
|
||||||
|
// 1 creature at party level +1 = 60 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(6)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(1);
|
||||||
|
expect(result.totalMonsterXp).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Moderate (tier 2) for 80 XP", () => {
|
||||||
|
// 1 creature at +2 = 80 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(7)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(2);
|
||||||
|
expect(result.totalMonsterXp).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Severe (tier 3) for 120 XP", () => {
|
||||||
|
// 1 creature at +3 = 120 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(8)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(3);
|
||||||
|
expect(result.totalMonsterXp).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Extreme (tier 4) for 160 XP", () => {
|
||||||
|
// 1 creature at +4 = 160 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(9)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(4);
|
||||||
|
expect(result.totalMonsterXp).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns tier 0 when XP is below Low threshold", () => {
|
||||||
|
// 1 creature at −4 = 10 XP, Low = 60
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(1)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(0);
|
||||||
|
expect(result.totalMonsterXp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts thresholds for 5 PCs (increases by adjustment)", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Trivial", value: 50 },
|
||||||
|
{ label: "Low", value: 75 },
|
||||||
|
{ label: "Moderate", value: 100 },
|
||||||
|
{ label: "Severe", value: 150 },
|
||||||
|
{ label: "Extreme", value: 200 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts thresholds for 3 PCs (decreases by adjustment)", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Trivial", value: 30 },
|
||||||
|
{ label: "Low", value: 45 },
|
||||||
|
{ label: "Moderate", value: 60 },
|
||||||
|
{ label: "Severe", value: 90 },
|
||||||
|
{ label: "Extreme", value: 120 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors thresholds at 0 for very small parties", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
// 1 PC: adjustment = −3
|
||||||
|
// Trivial: 40 + (−3 * 10) = 10
|
||||||
|
// Low: 60 + (−3 * 15) = 15
|
||||||
|
expect(result.thresholds[0].value).toBe(10);
|
||||||
|
expect(result.thresholds[1].value).toBe(15);
|
||||||
|
expect(result.thresholds[2].value).toBe(20); // 80 − 60
|
||||||
|
expect(result.thresholds[3].value).toBe(30); // 120 − 90
|
||||||
|
expect(result.thresholds[4].value).toBe(40); // 160 − 120
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts XP for party-side creatures", () => {
|
||||||
|
// 2 enemies at party level = 80 XP, 1 ally at party level = 40 XP
|
||||||
|
// Net = 80 − 40 = 40 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
party(5),
|
||||||
|
party(5),
|
||||||
|
party(5),
|
||||||
|
party(5),
|
||||||
|
pf2eEnemy(5),
|
||||||
|
pf2eEnemy(5),
|
||||||
|
pf2eAlly(5),
|
||||||
|
],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors net creature XP at 0", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(1), pf2eAlly(9)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives party level using mode", () => {
|
||||||
|
// 3x level 3, 1x level 5 → mode is 3
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(3), party(3), party(3), party(5), pf2eEnemy(3)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.partyLevel).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no encounterMultiplier, adjustedXp, or partySizeAdjusted", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.encounterMultiplier).toBeUndefined();
|
||||||
|
expect(result.adjustedXp).toBeUndefined();
|
||||||
|
expect(result.partySizeAdjusted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns partyLevel undefined for D&D editions", () => {
|
||||||
|
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
|
||||||
|
expect(result.partyLevel).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
237
packages/domain/src/__tests__/persistent-damage.test.ts
Normal file
237
packages/domain/src/__tests__/persistent-damage.test.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
addPersistentDamage,
|
||||||
|
type PersistentDamageType,
|
||||||
|
removePersistentDamage,
|
||||||
|
} from "../persistent-damage.js";
|
||||||
|
import type { Encounter } from "../types.js";
|
||||||
|
import { combatantId } from "../types.js";
|
||||||
|
|
||||||
|
const goblinId = combatantId("goblin-1");
|
||||||
|
|
||||||
|
function buildEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||||
|
return {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
...overrides.combatants?.[0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: overrides.activeIndex ?? 0,
|
||||||
|
roundNumber: overrides.roundNumber ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("addPersistentDamage", () => {
|
||||||
|
it("adds persistent fire damage to combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
const target = result.encounter.combatants[0];
|
||||||
|
expect(target.persistentDamage).toEqual([{ type: "fire", formula: "2d6" }]);
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{
|
||||||
|
type: "PersistentDamageAdded",
|
||||||
|
combatantId: goblinId,
|
||||||
|
damageType: "fire",
|
||||||
|
formula: "2d6",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces existing entry of same type with new formula", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "3d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "3d6" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows multiple different damage types", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "bleed", "1d4");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts entries by definition order", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "cold", formula: "1d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
const types = result.encounter.combatants[0].persistentDamage?.map(
|
||||||
|
(e) => e.type,
|
||||||
|
);
|
||||||
|
expect(types).toEqual(["fire", "cold"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for empty formula", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", " ");
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("empty-formula");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown damage type", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(
|
||||||
|
encounter,
|
||||||
|
goblinId,
|
||||||
|
"radiant" as PersistentDamageType,
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("unknown-damage-type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(
|
||||||
|
encounter,
|
||||||
|
combatantId("nonexistent"),
|
||||||
|
"fire",
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims formula whitespace", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", " 2d6 ");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage?.[0].formula).toBe(
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const originalCombatants = encounter.combatants;
|
||||||
|
addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toBe(originalCombatants);
|
||||||
|
expect(encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removePersistentDamage", () => {
|
||||||
|
it("removes existing persistent damage entry", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{
|
||||||
|
type: "PersistentDamageRemoved",
|
||||||
|
combatantId: goblinId,
|
||||||
|
damageType: "fire",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets persistentDamage to undefined when last entry removed", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when damage type not active", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("persistent-damage-not-active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = removePersistentDamage(
|
||||||
|
encounter,
|
||||||
|
combatantId("nonexistent"),
|
||||||
|
"fire",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -301,6 +301,52 @@ describe("rehydrateCombatant", () => {
|
|||||||
expect(result?.side).toBeUndefined();
|
expect(result?.side).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves valid persistent damage entries", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out invalid persistent damage entries", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "radiant", formula: "1d4" },
|
||||||
|
{ type: "bleed", formula: "" },
|
||||||
|
{ type: "acid" },
|
||||||
|
{ formula: "1d6" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined persistentDamage for non-array value", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: "fire",
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined persistentDamage for empty array", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("drops invalid tempHp — keeps combatant", () => {
|
it("drops invalid tempHp — keeps combatant", () => {
|
||||||
for (const tempHp of [-1, 1.5, "3"]) {
|
for (const tempHp of [-1, 1.5, "3"]) {
|
||||||
const result = rehydrateCombatant({
|
const result = rehydrateCombatant({
|
||||||
|
|||||||
@@ -60,13 +60,13 @@ describe("toggleCondition", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maintains definition order when adding conditions", () => {
|
it("appends new conditions to the end (insertion order)", () => {
|
||||||
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
|
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
|
||||||
const { encounter } = success(e, "A", "blinded");
|
const { encounter } = success(e, "A", "blinded");
|
||||||
|
|
||||||
expect(encounter.combatants[0].conditions).toEqual([
|
expect(encounter.combatants[0].conditions).toEqual([
|
||||||
{ id: "blinded" },
|
|
||||||
{ id: "poisoned" },
|
{ id: "poisoned" },
|
||||||
|
{ id: "blinded" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,15 +109,16 @@ describe("toggleCondition", () => {
|
|||||||
expect(encounter.combatants[0].conditions).toBeUndefined();
|
expect(encounter.combatants[0].conditions).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves order across all conditions", () => {
|
it("preserves insertion order across all conditions", () => {
|
||||||
const order = CONDITION_DEFINITIONS.map((d) => d.id);
|
const order = CONDITION_DEFINITIONS.map((d) => d.id);
|
||||||
// Add in reverse order
|
// Add in reverse order — result should be reverse order (insertion order)
|
||||||
|
const reversed = [...order].reverse();
|
||||||
let e = enc([makeCombatant("A")]);
|
let e = enc([makeCombatant("A")]);
|
||||||
for (const cond of [...order].reverse()) {
|
for (const cond of reversed) {
|
||||||
const result = success(e, "A", cond);
|
const result = success(e, "A", cond);
|
||||||
e = result.encounter;
|
e = result.encounter;
|
||||||
}
|
}
|
||||||
expect(e.combatants[0].conditions).toEqual(order.map((id) => ({ id })));
|
expect(e.combatants[0].conditions).toEqual(reversed.map((id) => ({ id })));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -500,8 +500,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
description5e: "",
|
description5e: "",
|
||||||
descriptionPf2e:
|
descriptionPf2e:
|
||||||
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
|
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
|
||||||
iconName: "Ghost",
|
iconName: "EyeClosed",
|
||||||
color: "violet",
|
color: "slate",
|
||||||
systems: ["pf2e"],
|
systems: ["pf2e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RulesEdition } from "./rules-edition.js";
|
import type { RulesEdition } from "./rules-edition.js";
|
||||||
|
|
||||||
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
|
/** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */
|
||||||
export type DifficultyTier = 0 | 1 | 2 | 3;
|
export type DifficultyTier = 0 | 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
export interface DifficultyThreshold {
|
export interface DifficultyThreshold {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -18,6 +18,8 @@ export interface DifficultyResult {
|
|||||||
readonly adjustedXp: number | undefined;
|
readonly adjustedXp: number | undefined;
|
||||||
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||||||
readonly partySizeAdjusted: boolean | undefined;
|
readonly partySizeAdjusted: boolean | undefined;
|
||||||
|
/** PF2e only: the derived party level used for XP calculation. */
|
||||||
|
readonly partyLevel: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps challenge rating strings to XP values (standard 5e). */
|
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||||
@@ -160,6 +162,133 @@ function getEncounterMultiplier(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PF2e: XP granted by a creature based on its level relative to party level.
|
||||||
|
* Key is (creature level − party level), clamped to [−4, +4].
|
||||||
|
*/
|
||||||
|
const PF2E_LEVEL_DIFF_XP: Readonly<Record<number, number>> = {
|
||||||
|
[-4]: 10,
|
||||||
|
[-3]: 15,
|
||||||
|
[-2]: 20,
|
||||||
|
[-1]: 30,
|
||||||
|
0: 40,
|
||||||
|
1: 60,
|
||||||
|
2: 80,
|
||||||
|
3: 120,
|
||||||
|
4: 160,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PF2e base encounter budget thresholds for a party of 4. */
|
||||||
|
const PF2E_THRESHOLDS_BASE = {
|
||||||
|
trivial: 40,
|
||||||
|
low: 60,
|
||||||
|
moderate: 80,
|
||||||
|
severe: 120,
|
||||||
|
extreme: 160,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** PF2e per-PC adjustment to each threshold (added per PC beyond 4, subtracted per PC fewer). */
|
||||||
|
const PF2E_THRESHOLD_ADJUSTMENTS = {
|
||||||
|
trivial: 10,
|
||||||
|
low: 15,
|
||||||
|
moderate: 20,
|
||||||
|
severe: 30,
|
||||||
|
extreme: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives PF2e party level from PC levels.
|
||||||
|
* Returns the mode (most common level). If no unique mode, returns
|
||||||
|
* the average rounded to the nearest integer.
|
||||||
|
*/
|
||||||
|
export function derivePartyLevel(levels: readonly number[]): number {
|
||||||
|
if (levels.length === 0) return 0;
|
||||||
|
if (levels.length === 1) return levels[0];
|
||||||
|
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const l of levels) {
|
||||||
|
counts.set(l, (counts.get(l) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxCount = 0;
|
||||||
|
let mode: number | undefined;
|
||||||
|
let isTied = false;
|
||||||
|
|
||||||
|
for (const [level, count] of counts) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
mode = level;
|
||||||
|
isTied = false;
|
||||||
|
} else if (count === maxCount) {
|
||||||
|
isTied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTied && mode !== undefined) return mode;
|
||||||
|
|
||||||
|
const sum = levels.reduce((a, b) => a + b, 0);
|
||||||
|
return Math.round(sum / levels.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns PF2e XP for a creature given its level and the party level. */
|
||||||
|
export function pf2eCreatureXp(
|
||||||
|
creatureLevel: number,
|
||||||
|
partyLevel: number,
|
||||||
|
): number {
|
||||||
|
const diff = Math.max(-4, Math.min(4, creatureLevel - partyLevel));
|
||||||
|
return PF2E_LEVEL_DIFF_XP[diff] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePf2eBudget(partySize: number) {
|
||||||
|
const adjustment = partySize - 4;
|
||||||
|
return {
|
||||||
|
trivial: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.trivial +
|
||||||
|
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.trivial,
|
||||||
|
),
|
||||||
|
low: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.low + adjustment * PF2E_THRESHOLD_ADJUSTMENTS.low,
|
||||||
|
),
|
||||||
|
moderate: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.moderate +
|
||||||
|
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.moderate,
|
||||||
|
),
|
||||||
|
severe: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.severe +
|
||||||
|
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.severe,
|
||||||
|
),
|
||||||
|
extreme: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.extreme +
|
||||||
|
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.extreme,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanCombatantsPf2e(
|
||||||
|
combatants: readonly CombatantDescriptor[],
|
||||||
|
partyLevel: number,
|
||||||
|
) {
|
||||||
|
let totalCreatureXp = 0;
|
||||||
|
|
||||||
|
for (const c of combatants) {
|
||||||
|
if (c.creatureLevel !== undefined) {
|
||||||
|
const xp = pf2eCreatureXp(c.creatureLevel, partyLevel);
|
||||||
|
if (c.side === "enemy") {
|
||||||
|
totalCreatureXp += xp;
|
||||||
|
} else {
|
||||||
|
totalCreatureXp -= xp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalCreatureXp: Math.max(0, totalCreatureXp) };
|
||||||
|
}
|
||||||
|
|
||||||
/** All standard 5e challenge rating strings, in ascending order. */
|
/** All standard 5e challenge rating strings, in ascending order. */
|
||||||
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
||||||
|
|
||||||
@@ -171,6 +300,7 @@ export function crToXp(cr: string): number {
|
|||||||
export interface CombatantDescriptor {
|
export interface CombatantDescriptor {
|
||||||
readonly level?: number;
|
readonly level?: number;
|
||||||
readonly cr?: string;
|
readonly cr?: string;
|
||||||
|
readonly creatureLevel?: number;
|
||||||
readonly side: "party" | "enemy";
|
readonly side: "party" | "enemy";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +377,41 @@ export function calculateEncounterDifficulty(
|
|||||||
combatants: readonly CombatantDescriptor[],
|
combatants: readonly CombatantDescriptor[],
|
||||||
edition: RulesEdition,
|
edition: RulesEdition,
|
||||||
): DifficultyResult {
|
): DifficultyResult {
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
const partyLevels: number[] = [];
|
||||||
|
for (const c of combatants) {
|
||||||
|
if (c.level !== undefined && c.side === "party") {
|
||||||
|
partyLevels.push(c.level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partyLevel = derivePartyLevel(partyLevels);
|
||||||
|
const { totalCreatureXp } = scanCombatantsPf2e(combatants, partyLevel);
|
||||||
|
const budget = calculatePf2eBudget(partyLevels.length);
|
||||||
|
const thresholds: DifficultyThreshold[] = [
|
||||||
|
{ label: "Trivial", value: budget.trivial },
|
||||||
|
{ label: "Low", value: budget.low },
|
||||||
|
{ label: "Moderate", value: budget.moderate },
|
||||||
|
{ label: "Severe", value: budget.severe },
|
||||||
|
{ label: "Extreme", value: budget.extreme },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier: determineTier(totalCreatureXp, [
|
||||||
|
budget.low,
|
||||||
|
budget.moderate,
|
||||||
|
budget.severe,
|
||||||
|
budget.extreme,
|
||||||
|
]),
|
||||||
|
totalMonsterXp: totalCreatureXp,
|
||||||
|
thresholds,
|
||||||
|
encounterMultiplier: undefined,
|
||||||
|
adjustedXp: undefined,
|
||||||
|
partySizeAdjusted: undefined,
|
||||||
|
partyLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { totalMonsterXp, monsterCount, partyLevels } =
|
const { totalMonsterXp, monsterCount, partyLevels } =
|
||||||
scanCombatants(combatants);
|
scanCombatants(combatants);
|
||||||
|
|
||||||
@@ -268,6 +433,7 @@ export function calculateEncounterDifficulty(
|
|||||||
encounterMultiplier: undefined,
|
encounterMultiplier: undefined,
|
||||||
adjustedXp: undefined,
|
adjustedXp: undefined,
|
||||||
partySizeAdjusted: undefined,
|
partySizeAdjusted: undefined,
|
||||||
|
partyLevel: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,5 +460,6 @@ export function calculateEncounterDifficulty(
|
|||||||
encounterMultiplier,
|
encounterMultiplier,
|
||||||
adjustedXp,
|
adjustedXp,
|
||||||
partySizeAdjusted,
|
partySizeAdjusted,
|
||||||
|
partyLevel: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ConditionId } from "./conditions.js";
|
import type { ConditionId } from "./conditions.js";
|
||||||
import type { CreatureId } from "./creature-types.js";
|
import type { CreatureId } from "./creature-types.js";
|
||||||
|
import type { PersistentDamageType } from "./persistent-damage.js";
|
||||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
import type { CombatantId } from "./types.js";
|
import type { CombatantId } from "./types.js";
|
||||||
|
|
||||||
@@ -132,6 +133,19 @@ export interface ConcentrationEnded {
|
|||||||
readonly combatantId: CombatantId;
|
readonly combatantId: CombatantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PersistentDamageAdded {
|
||||||
|
readonly type: "PersistentDamageAdded";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly damageType: PersistentDamageType;
|
||||||
|
readonly formula: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistentDamageRemoved {
|
||||||
|
readonly type: "PersistentDamageRemoved";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly damageType: PersistentDamageType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatureAdjustmentSet {
|
export interface CreatureAdjustmentSet {
|
||||||
readonly type: "CreatureAdjustmentSet";
|
readonly type: "CreatureAdjustmentSet";
|
||||||
readonly combatantId: CombatantId;
|
readonly combatantId: CombatantId;
|
||||||
@@ -181,6 +195,8 @@ export type DomainEvent =
|
|||||||
| ConditionRemoved
|
| ConditionRemoved
|
||||||
| ConcentrationStarted
|
| ConcentrationStarted
|
||||||
| ConcentrationEnded
|
| ConcentrationEnded
|
||||||
|
| PersistentDamageAdded
|
||||||
|
| PersistentDamageRemoved
|
||||||
| CreatureAdjustmentSet
|
| CreatureAdjustmentSet
|
||||||
| EncounterCleared
|
| EncounterCleared
|
||||||
| PlayerCharacterCreated
|
| PlayerCharacterCreated
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export {
|
|||||||
type DifficultyResult,
|
type DifficultyResult,
|
||||||
type DifficultyThreshold,
|
type DifficultyThreshold,
|
||||||
type DifficultyTier,
|
type DifficultyTier,
|
||||||
|
derivePartyLevel,
|
||||||
|
pf2eCreatureXp,
|
||||||
VALID_CR_VALUES,
|
VALID_CR_VALUES,
|
||||||
} from "./encounter-difficulty.js";
|
} from "./encounter-difficulty.js";
|
||||||
export type {
|
export type {
|
||||||
@@ -82,6 +84,8 @@ export type {
|
|||||||
EncounterCleared,
|
EncounterCleared,
|
||||||
InitiativeSet,
|
InitiativeSet,
|
||||||
MaxHpSet,
|
MaxHpSet,
|
||||||
|
PersistentDamageAdded,
|
||||||
|
PersistentDamageRemoved,
|
||||||
PlayerCharacterCreated,
|
PlayerCharacterCreated,
|
||||||
PlayerCharacterDeleted,
|
PlayerCharacterDeleted,
|
||||||
PlayerCharacterUpdated,
|
PlayerCharacterUpdated,
|
||||||
@@ -100,6 +104,17 @@ export {
|
|||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
type InitiativeResult,
|
type InitiativeResult,
|
||||||
} from "./initiative.js";
|
} from "./initiative.js";
|
||||||
|
export {
|
||||||
|
addPersistentDamage,
|
||||||
|
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||||
|
PERSISTENT_DAMAGE_TYPES,
|
||||||
|
type PersistentDamageDefinition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageSuccess,
|
||||||
|
type PersistentDamageType,
|
||||||
|
removePersistentDamage,
|
||||||
|
VALID_PERSISTENT_DAMAGE_TYPES,
|
||||||
|
} from "./persistent-damage.js";
|
||||||
export {
|
export {
|
||||||
acDelta,
|
acDelta,
|
||||||
adjustedLevel,
|
adjustedLevel,
|
||||||
|
|||||||
185
packages/domain/src/persistent-damage.ts
Normal file
185
packages/domain/src/persistent-damage.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export const PERSISTENT_DAMAGE_TYPES = [
|
||||||
|
"fire",
|
||||||
|
"bleed",
|
||||||
|
"acid",
|
||||||
|
"cold",
|
||||||
|
"electricity",
|
||||||
|
"poison",
|
||||||
|
"mental",
|
||||||
|
"force",
|
||||||
|
"void",
|
||||||
|
"spirit",
|
||||||
|
"vitality",
|
||||||
|
"piercing",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number];
|
||||||
|
|
||||||
|
export const VALID_PERSISTENT_DAMAGE_TYPES: ReadonlySet<string> = new Set(
|
||||||
|
PERSISTENT_DAMAGE_TYPES,
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface PersistentDamageEntry {
|
||||||
|
readonly type: PersistentDamageType;
|
||||||
|
readonly formula: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistentDamageDefinition {
|
||||||
|
readonly type: PersistentDamageType;
|
||||||
|
readonly label: string;
|
||||||
|
readonly iconName: string;
|
||||||
|
readonly color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[] =
|
||||||
|
[
|
||||||
|
{ type: "fire", label: "Fire", iconName: "Flame", color: "orange" },
|
||||||
|
{ type: "bleed", label: "Bleed", iconName: "Droplets", color: "red" },
|
||||||
|
{
|
||||||
|
type: "acid",
|
||||||
|
label: "Acid",
|
||||||
|
iconName: "FlaskConical",
|
||||||
|
color: "lime",
|
||||||
|
},
|
||||||
|
{ type: "cold", label: "Cold", iconName: "Snowflake", color: "sky" },
|
||||||
|
{
|
||||||
|
type: "electricity",
|
||||||
|
label: "Electricity",
|
||||||
|
iconName: "Zap",
|
||||||
|
color: "yellow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "poison",
|
||||||
|
label: "Poison",
|
||||||
|
iconName: "Droplet",
|
||||||
|
color: "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "mental",
|
||||||
|
label: "Mental",
|
||||||
|
iconName: "BrainCog",
|
||||||
|
color: "pink",
|
||||||
|
},
|
||||||
|
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
||||||
|
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
|
||||||
|
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
||||||
|
{
|
||||||
|
type: "vitality",
|
||||||
|
label: "Vitality",
|
||||||
|
iconName: "Sparkle",
|
||||||
|
color: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "piercing",
|
||||||
|
label: "Piercing",
|
||||||
|
iconName: "Sword",
|
||||||
|
color: "neutral",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface PersistentDamageSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPersistentDamage(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
newEntries: readonly PersistentDamageEntry[] | undefined,
|
||||||
|
): Encounter {
|
||||||
|
return {
|
||||||
|
combatants: encounter.combatants.map((c) =>
|
||||||
|
c.id === combatantId ? { ...c, persistentDamage: newEntries } : c,
|
||||||
|
),
|
||||||
|
activeIndex: encounter.activeIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPersistentDamage(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
): PersistentDamageSuccess | DomainError {
|
||||||
|
if (!VALID_PERSISTENT_DAMAGE_TYPES.has(damageType)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "unknown-damage-type",
|
||||||
|
message: `Unknown persistent damage type "${damageType}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (formula.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "empty-formula",
|
||||||
|
message: "Persistent damage formula must not be empty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
|
const { combatant: target } = found;
|
||||||
|
const current = target.persistentDamage ?? [];
|
||||||
|
|
||||||
|
// Replace existing entry of same type, or append
|
||||||
|
const filtered = current.filter((e) => e.type !== damageType);
|
||||||
|
const newEntries = [
|
||||||
|
...filtered,
|
||||||
|
{ type: damageType, formula: formula.trim() },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sort by definition order
|
||||||
|
const order = PERSISTENT_DAMAGE_DEFINITIONS.map((d) => d.type);
|
||||||
|
newEntries.sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: applyPersistentDamage(encounter, combatantId, newEntries),
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "PersistentDamageAdded",
|
||||||
|
combatantId,
|
||||||
|
damageType,
|
||||||
|
formula: formula.trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePersistentDamage(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
): PersistentDamageSuccess | DomainError {
|
||||||
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
|
const { combatant: target } = found;
|
||||||
|
const current = target.persistentDamage ?? [];
|
||||||
|
|
||||||
|
if (!current.some((e) => e.type === damageType)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "persistent-damage-not-active",
|
||||||
|
message: `Persistent ${damageType} damage is not active`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = current.filter((e) => e.type !== damageType);
|
||||||
|
return {
|
||||||
|
encounter: applyPersistentDamage(
|
||||||
|
encounter,
|
||||||
|
combatantId,
|
||||||
|
filtered.length > 0 ? filtered : undefined,
|
||||||
|
),
|
||||||
|
events: [{ type: "PersistentDamageRemoved", combatantId, damageType }],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { ConditionEntry, ConditionId } from "./conditions.js";
|
|||||||
import { VALID_CONDITION_IDS } from "./conditions.js";
|
import { VALID_CONDITION_IDS } from "./conditions.js";
|
||||||
import { creatureId } from "./creature-types.js";
|
import { creatureId } from "./creature-types.js";
|
||||||
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
|
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
|
||||||
|
import type { PersistentDamageEntry } from "./persistent-damage.js";
|
||||||
|
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage.js";
|
||||||
import {
|
import {
|
||||||
playerCharacterId,
|
playerCharacterId,
|
||||||
VALID_PLAYER_COLORS,
|
VALID_PLAYER_COLORS,
|
||||||
@@ -42,6 +44,32 @@ function validateConditions(value: unknown): ConditionEntry[] | undefined {
|
|||||||
return entries.length > 0 ? entries : undefined;
|
return entries.length > 0 ? entries : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validatePersistentDamage(
|
||||||
|
value: unknown,
|
||||||
|
): PersistentDamageEntry[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const entries: PersistentDamageEntry[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (
|
||||||
|
typeof item === "object" &&
|
||||||
|
item !== null &&
|
||||||
|
typeof (item as Record<string, unknown>).type === "string" &&
|
||||||
|
VALID_PERSISTENT_DAMAGE_TYPES.has(
|
||||||
|
(item as Record<string, unknown>).type as string,
|
||||||
|
) &&
|
||||||
|
typeof (item as Record<string, unknown>).formula === "string" &&
|
||||||
|
((item as Record<string, unknown>).formula as string).length > 0
|
||||||
|
) {
|
||||||
|
entries.push({
|
||||||
|
type: (item as Record<string, unknown>)
|
||||||
|
.type as PersistentDamageEntry["type"],
|
||||||
|
formula: (item as Record<string, unknown>).formula as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.length > 0 ? entries : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function validateHp(
|
function validateHp(
|
||||||
rawMaxHp: unknown,
|
rawMaxHp: unknown,
|
||||||
rawCurrentHp: unknown,
|
rawCurrentHp: unknown,
|
||||||
@@ -107,6 +135,7 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
|||||||
initiative: validateInteger(entry.initiative),
|
initiative: validateInteger(entry.initiative),
|
||||||
ac: validateAc(entry.ac),
|
ac: validateAc(entry.ac),
|
||||||
conditions: validateConditions(entry.conditions),
|
conditions: validateConditions(entry.conditions),
|
||||||
|
persistentDamage: validatePersistentDamage(entry.persistentDamage),
|
||||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||||
creatureId: validateNonEmptyString(entry.creatureId)
|
creatureId: validateNonEmptyString(entry.creatureId)
|
||||||
? creatureId(entry.creatureId as string)
|
? creatureId(entry.creatureId as string)
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ export interface ToggleConditionSuccess {
|
|||||||
readonly events: DomainEvent[];
|
readonly events: DomainEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortByDefinitionOrder(entries: ConditionEntry[]): ConditionEntry[] {
|
|
||||||
const order = CONDITION_DEFINITIONS.map((d) => d.id);
|
|
||||||
entries.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateConditionId(conditionId: ConditionId): DomainError | null {
|
function validateConditionId(conditionId: ConditionId): DomainError | null {
|
||||||
if (!VALID_CONDITION_IDS.has(conditionId)) {
|
if (!VALID_CONDITION_IDS.has(conditionId)) {
|
||||||
return {
|
return {
|
||||||
@@ -67,8 +61,7 @@ export function toggleCondition(
|
|||||||
newConditions = filtered.length > 0 ? filtered : undefined;
|
newConditions = filtered.length > 0 ? filtered : undefined;
|
||||||
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
|
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
|
||||||
} else {
|
} else {
|
||||||
const added = sortByDefinitionOrder([...current, { id: conditionId }]);
|
newConditions = [...current, { id: conditionId }];
|
||||||
newConditions = added;
|
|
||||||
event = { type: "ConditionAdded", combatantId, condition: conditionId };
|
event = { type: "ConditionAdded", combatantId, condition: conditionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +118,7 @@ export function setConditionValue(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const added = sortByDefinitionOrder([
|
const added = [...current, { id: conditionId, value: clampedValue }];
|
||||||
...current,
|
|
||||||
{ id: conditionId, value: clampedValue },
|
|
||||||
]);
|
|
||||||
return {
|
return {
|
||||||
encounter: applyConditions(encounter, combatantId, added),
|
encounter: applyConditions(encounter, combatantId, added),
|
||||||
events: [
|
events: [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
|
|||||||
|
|
||||||
import type { ConditionEntry } from "./conditions.js";
|
import type { ConditionEntry } from "./conditions.js";
|
||||||
import type { CreatureId } from "./creature-types.js";
|
import type { CreatureId } from "./creature-types.js";
|
||||||
|
import type { PersistentDamageEntry } from "./persistent-damage.js";
|
||||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
|
|
||||||
export interface Combatant {
|
export interface Combatant {
|
||||||
@@ -18,6 +19,7 @@ export interface Combatant {
|
|||||||
readonly tempHp?: number;
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionEntry[];
|
readonly conditions?: readonly ConditionEntry[];
|
||||||
|
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly creatureId?: CreatureId;
|
readonly creatureId?: CreatureId;
|
||||||
readonly creatureAdjustment?: "weak" | "elite";
|
readonly creatureAdjustment?: "weak" | "elite";
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface Combatant {
|
|||||||
readonly ac?: number; // non-negative integer
|
readonly ac?: number; // non-negative integer
|
||||||
readonly conditions?: readonly ConditionEntry[];
|
readonly conditions?: readonly ConditionEntry[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
|
readonly persistentDamage?: readonly PersistentDamageEntry[]; // PF2e only
|
||||||
readonly creatureId?: CreatureId; // link to bestiary entry
|
readonly creatureId?: CreatureId; // link to bestiary entry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,11 @@ interface ConditionEntry {
|
|||||||
readonly id: ConditionId;
|
readonly id: ConditionId;
|
||||||
readonly value?: number; // PF2e valued conditions (e.g., Clumsy 2); undefined for D&D
|
readonly value?: number; // PF2e valued conditions (e.g., Clumsy 2); undefined for D&D
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistentDamageEntry {
|
||||||
|
readonly type: PersistentDamageType; // "fire" | "bleed" | "acid" | "cold" | "electricity" | "poison" | "mental"
|
||||||
|
readonly formula: string; // e.g., "2d6", "1d4+2"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -346,6 +352,19 @@ Acceptance scenarios:
|
|||||||
4. **Given** the game system is D&D (5e or 5.5e), **When** interacting with conditions, **Then** no maximum enforcement is applied.
|
4. **Given** the game system is D&D (5e or 5.5e), **When** interacting with conditions, **Then** no maximum enforcement is applied.
|
||||||
5. **Given** a PF2e valued condition without a defined maximum (e.g., Frightened, Clumsy), **When** incrementing, **Then** no cap is enforced — the value can increase without limit.
|
5. **Given** a PF2e valued condition without a defined maximum (e.g., Frightened, Clumsy), **When** incrementing, **Then** no cap is enforced — the value can increase without limit.
|
||||||
|
|
||||||
|
**Story CC-11 — Persistent Damage Tags (P2)**
|
||||||
|
As a DM running a PF2e encounter, I want to apply persistent damage to a combatant as a compact tag showing a damage type icon and formula so I can track ongoing damage effects without manual bookkeeping.
|
||||||
|
|
||||||
|
Acceptance scenarios:
|
||||||
|
1. **Given** the game system is Pathfinder 2e and the condition picker is open, **When** the user clicks "Persistent Damage", **Then** a sub-picker opens with a damage type dropdown (fire, bleed, acid, cold, electricity, poison, mental, force, void, spirit, vitality, piercing) and a formula text input.
|
||||||
|
2. **Given** the sub-picker is open, **When** the user selects "fire" and types "2d6" and confirms, **Then** a compact tag appears on the combatant row showing a fire icon and "2d6".
|
||||||
|
3. **Given** a combatant has persistent fire 2d6, **When** the user adds persistent bleed 1d4, **Then** both tags appear on the row simultaneously.
|
||||||
|
4. **Given** a combatant has persistent fire 2d6, **When** the user adds persistent fire 3d6, **Then** the existing fire entry is replaced with 3d6 (one instance per type).
|
||||||
|
5. **Given** a combatant has a persistent damage tag, **When** the user clicks the tag on the row, **Then** the persistent damage entry is removed.
|
||||||
|
6. **Given** a combatant has a persistent damage tag, **When** the user hovers over it, **Then** a tooltip shows the full description (e.g., "Persistent Fire 2d6 — Take damage at end of turn. DC 15 flat check to end.").
|
||||||
|
7. **Given** the game system is D&D (5e or 5.5e), **When** viewing the condition picker, **Then** no "Persistent Damage" option is available.
|
||||||
|
8. **Given** a combatant has persistent damage entries, **When** the page is reloaded, **Then** all entries are restored exactly.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- **FR-032**: When a D&D game system is active, the system MUST support the following 15 standard D&D 5e/5.5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious. When Pathfinder 2e is active, the system MUST support the PF2e condition set (see FR-103).
|
- **FR-032**: When a D&D game system is active, the system MUST support the following 15 standard D&D 5e/5.5e conditions: blinded, charmed, deafened, exhaustion, frightened, grappled, incapacitated, invisible, paralyzed, petrified, poisoned, prone, restrained, stunned, unconscious. When Pathfinder 2e is active, the system MUST support the PF2e condition set (see FR-103).
|
||||||
@@ -401,6 +420,15 @@ Acceptance scenarios:
|
|||||||
- **FR-110**: Maximum value enforcement MUST only apply when the Pathfinder 2e game system is active. D&D conditions are unaffected.
|
- **FR-110**: Maximum value enforcement MUST only apply when the Pathfinder 2e game system is active. D&D conditions are unaffected.
|
||||||
- **FR-111**: When Pathfinder 2e is the active game system, the concentration UI (Brain icon toggle, purple left border accent, damage pulse animation) MUST be hidden entirely. The Brain icon MUST NOT be shown on hover or at rest, and the concentration toggle MUST NOT be interactive.
|
- **FR-111**: When Pathfinder 2e is the active game system, the concentration UI (Brain icon toggle, purple left border accent, damage pulse animation) MUST be hidden entirely. The Brain icon MUST NOT be shown on hover or at rest, and the concentration toggle MUST NOT be interactive.
|
||||||
- **FR-112**: Switching the game system MUST NOT clear or modify `isConcentrating` state on any combatant. The state MUST be preserved in storage and restored to the UI when switching back to a D&D game system.
|
- **FR-112**: Switching the game system MUST NOT clear or modify `isConcentrating` state on any combatant. The state MUST be preserved in storage and restored to the UI when switching back to a D&D game system.
|
||||||
|
- **FR-117**: When Pathfinder 2e is active, the condition picker MUST include a "Persistent Damage" entry that opens a sub-picker instead of toggling directly.
|
||||||
|
- **FR-118**: The persistent damage sub-picker MUST contain a dropdown of PF2e damage types (fire, bleed, acid, cold, electricity, poison, mental, force, void, spirit, vitality, piercing) and a text input for the damage formula (e.g., "2d6").
|
||||||
|
- **FR-119**: Each persistent damage entry MUST be displayed as a compact tag on the combatant row showing a damage type icon and the formula text (e.g., fire icon + "2d6").
|
||||||
|
- **FR-120**: Only one persistent damage entry per damage type is allowed per combatant. Adding the same damage type MUST replace the existing formula.
|
||||||
|
- **FR-121**: Clicking a persistent damage tag on the combatant row MUST remove that entry.
|
||||||
|
- **FR-122**: Hovering a persistent damage tag MUST show a tooltip with the full description: "{Type} {formula} — Take damage at end of turn. DC 15 flat check to end."
|
||||||
|
- **FR-123**: Persistent damage MUST NOT be available when a D&D game system is active.
|
||||||
|
- **FR-124**: Persistent damage entries MUST persist across page reloads via the existing persistence mechanism.
|
||||||
|
- **FR-125**: Persistent damage tags MUST be displayed inline after condition icons, following the same wrapping behavior as conditions (FR-041).
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
@@ -417,7 +445,11 @@ Acceptance scenarios:
|
|||||||
- When the game system is switched from D&D to PF2e, existing D&D conditions on combatants are hidden (not deleted). Switching back to D&D restores them.
|
- When the game system is switched from D&D to PF2e, existing D&D conditions on combatants are hidden (not deleted). Switching back to D&D restores them.
|
||||||
- PF2e valued condition at value 0 is treated as removed — it MUST NOT appear on the row.
|
- PF2e valued condition at value 0 is treated as removed — it MUST NOT appear on the row.
|
||||||
- Dying, doomed, wounded, and slowed have enforced maximum values in PF2e (4, 3, 3, 3 respectively). The `[+]` button is disabled at the cap. The dynamic dying cap based on doomed value (dying max = 4 − doomed) is not enforced — only the static maximum applies.
|
- Dying, doomed, wounded, and slowed have enforced maximum values in PF2e (4, 3, 3, 3 respectively). The `[+]` button is disabled at the cap. The dynamic dying cap based on doomed value (dying max = 4 − doomed) is not enforced — only the static maximum applies.
|
||||||
- Persistent damage is excluded from the PF2e MVP condition set. It can be added as a follow-up feature.
|
- Persistent damage tags are separate from the `conditions` array — they use a dedicated `persistentDamage` field on `Combatant`.
|
||||||
|
- Adding persistent damage with an empty formula is rejected; the formula field must be non-empty.
|
||||||
|
- When the game system is switched from PF2e to D&D, existing persistent damage entries are preserved in storage but hidden from display, consistent with condition behavior (FR-107).
|
||||||
|
- Persistent damage has no automation — the system does not auto-apply damage or prompt for flat checks. It is a visual reminder only.
|
||||||
|
- The persistent damage sub-picker closes when the user clicks outside of it or confirms an entry.
|
||||||
- When PF2e is active, concentration state (`isConcentrating`) is preserved in storage but the entire concentration UI is hidden. Switching back to D&D restores Brain icons, purple borders, and pulse behavior without data loss.
|
- When PF2e is active, concentration state (`isConcentrating`) is preserved in storage but the entire concentration UI is hidden. Switching back to D&D restores Brain icons, purple borders, and pulse behavior without data loss.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -622,3 +654,5 @@ Acceptance scenarios:
|
|||||||
- **SC-035**: PF2e valued conditions display their current value and can be incremented/decremented within 1 click each.
|
- **SC-035**: PF2e valued conditions display their current value and can be incremented/decremented within 1 click each.
|
||||||
- **SC-036**: Switching game system immediately changes the available conditions, bestiary search results, stat block layout, and initiative calculation — no page reload required.
|
- **SC-036**: Switching game system immediately changes the available conditions, bestiary search results, stat block layout, and initiative calculation — no page reload required.
|
||||||
- **SC-037**: The game system preference survives a full page reload.
|
- **SC-037**: The game system preference survives a full page reload.
|
||||||
|
- **SC-038**: A persistent damage entry can be added to a combatant in 3 clicks or fewer (click "+", click "Persistent Damage", select type + enter formula + confirm).
|
||||||
|
- **SC-039**: Persistent damage tags are visually distinguishable from conditions by their icon + formula format.
|
||||||
|
|||||||
Reference in New Issue
Block a user