Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9fb271607 | ||
|
|
064af16f95 |
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 { buildCreature } from "./build-creature.js";
|
||||
export { buildEncounter } from "./build-encounter.js";
|
||||
export { buildPf2eCreature } from "./build-pf2e-creature.js";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
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 {
|
||||
cleanup,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||
@@ -52,7 +57,7 @@ const goblinCreature = buildCreature({
|
||||
function renderPanel(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
@@ -357,4 +362,157 @@ describe("DifficultyBreakdownPanel", () => {
|
||||
|
||||
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,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
TIER_LABELS_PF2E,
|
||||
} from "../difficulty-indicator.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -23,6 +24,7 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,4 +127,64 @@ describe("DifficultyIndicator", () => {
|
||||
const element = container.querySelector("[role='img']");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -618,14 +618,17 @@ export function CombatantRow({
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
{isPf2e && (
|
||||
<PersistentDamageTags
|
||||
entries={combatant.persistentDamage}
|
||||
onRemove={(damageType) => removePersistentDamage(id, damageType)}
|
||||
onRemove={(damageType) =>
|
||||
removePersistentDamage(id, damageType)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ConditionTags>
|
||||
</div>
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
anchorRef={conditionAnchorRef}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
EarOff,
|
||||
Eclipse,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
@@ -57,6 +58,7 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
EarOff,
|
||||
Eclipse,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
@@ -92,6 +94,7 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
purple: "text-purple-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ interface ConditionTagsProps {
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
onDecrement: (conditionId: ConditionId) => void;
|
||||
onOpenPicker: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConditionTags({
|
||||
@@ -25,6 +27,7 @@ export function ConditionTags({
|
||||
onRemove,
|
||||
onDecrement,
|
||||
onOpenPicker,
|
||||
children,
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
const { edition } = useRulesEditionContext();
|
||||
return (
|
||||
@@ -69,6 +72,7 @@ export function ConditionTags({
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
title="Add condition"
|
||||
|
||||
@@ -19,12 +19,21 @@ const TIER_LABEL_MAP: Partial<
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "High", color: "text-red-500" },
|
||||
4: { label: "High", color: "text-red-500" },
|
||||
},
|
||||
"5e": {
|
||||
0: { label: "Easy", color: "text-muted-foreground" },
|
||||
1: { label: "Medium", color: "text-green-500" },
|
||||
2: { label: "Hard", color: "text-yellow-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>> = {
|
||||
Moderate: "Mod",
|
||||
Medium: "Med",
|
||||
Trivial: "Triv",
|
||||
Severe: "Sev",
|
||||
Extreme: "Ext",
|
||||
};
|
||||
|
||||
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 }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
@@ -128,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const isPC = (entry: BreakdownCombatant) =>
|
||||
entry.combatant.playerCharacterId != null;
|
||||
|
||||
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -142,6 +204,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="mb-1 text-muted-foreground text-xs">
|
||||
Party Budget ({breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||
{breakdown.partyLevel !== undefined && (
|
||||
<> · Party Level: {breakdown.partyLevel}</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
{breakdown.thresholds.map((t) => (
|
||||
@@ -166,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -186,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -218,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "High",
|
||||
4: "High",
|
||||
};
|
||||
|
||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
@@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
1: "Medium",
|
||||
2: "Hard",
|
||||
3: "Deadly",
|
||||
4: "Deadly",
|
||||
};
|
||||
|
||||
const TIER_COLORS: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string }
|
||||
> = {
|
||||
0: { filledBars: 0, color: "" },
|
||||
1: { filledBars: 1, color: "bg-green-500" },
|
||||
2: { filledBars: 2, color: "bg-yellow-500" },
|
||||
3: { filledBars: 3, color: "bg-red-500" },
|
||||
export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = {
|
||||
0: "Trivial",
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "Severe",
|
||||
4: "Extreme",
|
||||
};
|
||||
|
||||
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({
|
||||
result,
|
||||
labels,
|
||||
barCount = 3,
|
||||
onClick,
|
||||
}: {
|
||||
result: DifficultyResult;
|
||||
labels: Record<DifficultyTier, string>;
|
||||
barCount?: 3 | 4;
|
||||
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 tooltip = `${label} encounter difficulty`;
|
||||
|
||||
@@ -54,13 +74,13 @@ export function DifficultyIndicator({
|
||||
onClick={onClick}
|
||||
type={onClick ? "button" : undefined}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
{barHeights.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
i < filledBars ? colorMap[i + 1] : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
TIER_LABELS_PF2E,
|
||||
} from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
@@ -26,7 +27,13 @@ export function TurnNavigation() {
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
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 hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -87,6 +94,7 @@ export function TurnNavigation() {
|
||||
<DifficultyIndicator
|
||||
result={difficulty}
|
||||
labels={tierLabels}
|
||||
barCount={barCount}
|
||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||
/>
|
||||
{showBreakdown ? (
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// @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 { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
||||
@@ -42,7 +47,7 @@ const goblinCreature = buildCreature({
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
@@ -345,4 +350,115 @@ describe("useDifficultyBreakdown", () => {
|
||||
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
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type {
|
||||
AnyCreature,
|
||||
CreatureId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
@@ -43,7 +48,7 @@ const goblinCreature = buildCreature({
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
@@ -424,4 +429,134 @@ describe("useDifficulty", () => {
|
||||
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 {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyThreshold,
|
||||
DifficultyTier,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -21,6 +27,10 @@ export interface BreakdownCombatant {
|
||||
readonly editable: boolean;
|
||||
readonly side: "party" | "enemy";
|
||||
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 {
|
||||
@@ -30,6 +40,7 @@ interface DifficultyBreakdown {
|
||||
readonly encounterMultiplier: number | undefined;
|
||||
readonly adjustedXp: number | undefined;
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
readonly partyLevel: number | undefined;
|
||||
readonly pcCount: number;
|
||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||
@@ -48,9 +59,16 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
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);
|
||||
|
||||
@@ -65,6 +83,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
|
||||
type CreatureInfo = {
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
source: string;
|
||||
sourceDisplayName: string;
|
||||
};
|
||||
@@ -74,6 +93,7 @@ function buildBreakdownEntry(
|
||||
side: "party" | "enemy",
|
||||
level: number | undefined,
|
||||
creature: CreatureInfo | undefined,
|
||||
partyLevel: number | undefined,
|
||||
): BreakdownCombatant {
|
||||
if (c.playerCharacterId) {
|
||||
return {
|
||||
@@ -84,6 +104,29 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
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) {
|
||||
@@ -96,6 +139,8 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
if (c.cr) {
|
||||
@@ -107,6 +152,8 @@ function buildBreakdownEntry(
|
||||
editable: true,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -117,6 +164,8 @@ function buildBreakdownEntry(
|
||||
editable: !c.creatureId,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,41 +177,91 @@ function resolveLevel(
|
||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||
}
|
||||
|
||||
function resolveCr(
|
||||
function resolveCreatureInfo(
|
||||
c: Combatant,
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const cr = creature?.cr ?? c.cr ?? null;
|
||||
return { cr, creature };
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
): {
|
||||
cr: string | null;
|
||||
creatureLevel: number | undefined;
|
||||
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(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
) {
|
||||
const partyCombatants: BreakdownCombatant[] = [];
|
||||
const enemyCombatants: BreakdownCombatant[] = [];
|
||||
const descriptors: {
|
||||
level?: number;
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
side: "party" | "enemy";
|
||||
}[] = [];
|
||||
let pcCount = 0;
|
||||
const partyLevel = collectPartyLevel(combatants, characters);
|
||||
|
||||
for (const c of combatants) {
|
||||
const side = resolveSide(c);
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) pcCount++;
|
||||
|
||||
const { cr, creature } = resolveCr(c, getCreature);
|
||||
const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature);
|
||||
|
||||
if (level !== undefined || cr != null) {
|
||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||
if (level !== undefined || cr != null || creatureLevel !== undefined) {
|
||||
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;
|
||||
target.push(entry);
|
||||
}
|
||||
|
||||
@@ -33,9 +33,17 @@ function buildDescriptors(
|
||||
const creatureCr =
|
||||
creature && !("system" in creature) ? creature.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) {
|
||||
descriptors.push({ level, cr, side });
|
||||
if (
|
||||
level !== undefined ||
|
||||
cr !== undefined ||
|
||||
creatureLevel !== undefined
|
||||
) {
|
||||
descriptors.push({ level, cr, creatureLevel, side });
|
||||
}
|
||||
}
|
||||
return descriptors;
|
||||
@@ -48,8 +56,6 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
return useMemo(() => {
|
||||
if (edition === "pf2e") return null;
|
||||
|
||||
const descriptors = buildDescriptors(
|
||||
encounter.combatants,
|
||||
characters,
|
||||
@@ -59,9 +65,16 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
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);
|
||||
}, [encounter.combatants, characters, getCreature, edition]);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "../encounter-difficulty.js";
|
||||
|
||||
describe("crToXp", () => {
|
||||
@@ -386,3 +388,234 @@ describe("calculateEncounterDifficulty — 2014 edition", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { encounter } = success(e, "A", "blinded");
|
||||
|
||||
expect(encounter.combatants[0].conditions).toEqual([
|
||||
{ id: "blinded" },
|
||||
{ id: "poisoned" },
|
||||
{ id: "blinded" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -109,15 +109,16 @@ describe("toggleCondition", () => {
|
||||
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);
|
||||
// Add in reverse order
|
||||
// Add in reverse order — result should be reverse order (insertion order)
|
||||
const reversed = [...order].reverse();
|
||||
let e = enc([makeCombatant("A")]);
|
||||
for (const cond of [...order].reverse()) {
|
||||
for (const cond of reversed) {
|
||||
const result = success(e, "A", cond);
|
||||
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: "",
|
||||
descriptionPf2e:
|
||||
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
|
||||
iconName: "Ghost",
|
||||
color: "violet",
|
||||
iconName: "EyeClosed",
|
||||
color: "slate",
|
||||
systems: ["pf2e"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RulesEdition } from "./rules-edition.js";
|
||||
|
||||
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3;
|
||||
/** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
export interface DifficultyThreshold {
|
||||
readonly label: string;
|
||||
@@ -18,6 +18,8 @@ export interface DifficultyResult {
|
||||
readonly adjustedXp: number | undefined;
|
||||
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||||
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). */
|
||||
@@ -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. */
|
||||
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 {
|
||||
readonly level?: number;
|
||||
readonly cr?: string;
|
||||
readonly creatureLevel?: number;
|
||||
readonly side: "party" | "enemy";
|
||||
}
|
||||
|
||||
@@ -247,6 +377,41 @@ export function calculateEncounterDifficulty(
|
||||
combatants: readonly CombatantDescriptor[],
|
||||
edition: RulesEdition,
|
||||
): 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 } =
|
||||
scanCombatants(combatants);
|
||||
|
||||
@@ -268,6 +433,7 @@ export function calculateEncounterDifficulty(
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,5 +460,6 @@ export function calculateEncounterDifficulty(
|
||||
encounterMultiplier,
|
||||
adjustedXp,
|
||||
partySizeAdjusted,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export {
|
||||
type DifficultyResult,
|
||||
type DifficultyThreshold,
|
||||
type DifficultyTier,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
VALID_CR_VALUES,
|
||||
} from "./encounter-difficulty.js";
|
||||
export type {
|
||||
|
||||
@@ -70,7 +70,7 @@ export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[
|
||||
color: "pink",
|
||||
},
|
||||
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
||||
{ type: "void", label: "Void", iconName: "Eclipse", color: "slate" },
|
||||
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
|
||||
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
||||
{
|
||||
type: "vitality",
|
||||
|
||||
@@ -14,12 +14,6 @@ export interface ToggleConditionSuccess {
|
||||
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 {
|
||||
if (!VALID_CONDITION_IDS.has(conditionId)) {
|
||||
return {
|
||||
@@ -67,8 +61,7 @@ export function toggleCondition(
|
||||
newConditions = filtered.length > 0 ? filtered : undefined;
|
||||
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
|
||||
} else {
|
||||
const added = sortByDefinitionOrder([...current, { id: conditionId }]);
|
||||
newConditions = added;
|
||||
newConditions = [...current, { id: conditionId }];
|
||||
event = { type: "ConditionAdded", combatantId, condition: conditionId };
|
||||
}
|
||||
|
||||
@@ -125,10 +118,7 @@ export function setConditionValue(
|
||||
};
|
||||
}
|
||||
|
||||
const added = sortByDefinitionOrder([
|
||||
...current,
|
||||
{ id: conditionId, value: clampedValue },
|
||||
]);
|
||||
const added = [...current, { id: conditionId, value: clampedValue }];
|
||||
return {
|
||||
encounter: applyConditions(encounter, combatantId, added),
|
||||
events: [
|
||||
|
||||
Reference in New Issue
Block a user