Add 2014 DMG encounter difficulty calculation
Support the 2014 DMG encounter difficulty as an alternative to the 5.5e system behind the existing Rules Edition toggle. The 2014 system uses Easy/Medium/Hard/Deadly thresholds, an encounter multiplier based on monster count, and party size adjustment (×0.5–×5 range). - Extract RulesEdition to its own domain module - Refactor DifficultyTier to abstract numeric values (0–3) - Restructure DifficultyResult with thresholds array - Add 2014 XP thresholds table and encounter multiplier logic - Wire edition from context into difficulty hooks - Edition-aware labels in indicator and breakdown panel - Show multiplier, adjusted XP, and party size note for 2014 - Rename settings label from "Conditions" to "Rules Edition" - Update spec 008 with issue #23 requirements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,13 @@ import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
@@ -13,6 +19,7 @@ import {
|
||||
buildEncounter,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||
import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -279,6 +286,63 @@ describe("DifficultyBreakdownPanel", () => {
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("shows 4 threshold columns for 2014 edition", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Easy:", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("Med:", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText("Hard:", { exact: false })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Deadly:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows multiplier and adjusted XP for 2014 edition", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Monster XP")).toBeInTheDocument();
|
||||
// 1 PC (<3) triggers party size adjustment
|
||||
expect(screen.getByText("Adjusted for 1 PC")).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Net Monster XP for 5.5e edition (no multiplier)", async () => {
|
||||
renderPanel({
|
||||
encounter: defaultEncounter(),
|
||||
playerCharacters: defaultPCs,
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { DifficultyResult } 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 { DifficultyIndicator } from "../difficulty-indicator.js";
|
||||
import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
} from "../difficulty-indicator.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -11,50 +15,77 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||
return {
|
||||
tier,
|
||||
totalMonsterXp: 100,
|
||||
partyBudget: { low: 50, moderate: 100, high: 200 },
|
||||
thresholds: [
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 100 },
|
||||
{ label: "High", value: 200 },
|
||||
],
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DifficultyIndicator", () => {
|
||||
it("renders 3 bars", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult("moderate")} />,
|
||||
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
expect(bars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
|
||||
render(<DifficultyIndicator result={makeResult("trivial")} />);
|
||||
it("shows 'Trivial encounter difficulty' for 5.5e tier 0", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", {
|
||||
name: "Trivial encounter difficulty",
|
||||
}),
|
||||
screen.getByRole("img", { name: "Trivial encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Low encounter difficulty' label for low tier", () => {
|
||||
render(<DifficultyIndicator result={makeResult("low")} />);
|
||||
it("shows 'Low encounter difficulty' for 5.5e tier 1", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(1)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
|
||||
render(<DifficultyIndicator result={makeResult("moderate")} />);
|
||||
it("shows 'Moderate encounter difficulty' for 5.5e tier 2", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", {
|
||||
name: "Moderate encounter difficulty",
|
||||
}),
|
||||
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'High encounter difficulty' label for high tier", () => {
|
||||
render(<DifficultyIndicator result={makeResult("high")} />);
|
||||
it("shows 'High encounter difficulty' for 5.5e tier 3", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", {
|
||||
name: "High encounter difficulty",
|
||||
}),
|
||||
screen.getByRole("img", { name: "High encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Easy encounter difficulty' for 2014 tier 0", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_2014} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Easy encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Deadly encounter difficulty' for 2014 tier 3", () => {
|
||||
render(
|
||||
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_2014} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Deadly encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -63,22 +94,21 @@ describe("DifficultyIndicator", () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult("moderate")}
|
||||
result={makeResult(2)}
|
||||
labels={TIER_LABELS_5_5E}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("img", {
|
||||
name: "Moderate encounter difficulty",
|
||||
}),
|
||||
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||
);
|
||||
expect(handleClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders as div when onClick not provided", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult("moderate")} />,
|
||||
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
const element = container.querySelector("[role='img']");
|
||||
expect(element?.tagName).toBe("DIV");
|
||||
@@ -87,7 +117,8 @@ describe("DifficultyIndicator", () => {
|
||||
it("renders as button when onClick provided", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult("moderate")}
|
||||
result={makeResult(2)}
|
||||
labels={TIER_LABELS_5_5E}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -37,8 +37,9 @@ function renderModal(open = true) {
|
||||
}
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
it("renders edition toggle buttons", () => {
|
||||
it("renders edition section with 'Rules Edition' label", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Rules Edition")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5e (2014)" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { DifficultyTier } from "@initiative/domain";
|
||||
import type { DifficultyTier, RulesEdition } from "@initiative/domain";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import {
|
||||
type BreakdownCombatant,
|
||||
@@ -10,13 +11,34 @@ import {
|
||||
import { CrPicker } from "./cr-picker.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
const TIER_LABELS: Record<DifficultyTier, { label: string; color: string }> = {
|
||||
trivial: { label: "Trivial", color: "text-muted-foreground" },
|
||||
low: { label: "Low", color: "text-green-500" },
|
||||
moderate: { label: "Moderate", color: "text-yellow-500" },
|
||||
high: { label: "High", color: "text-red-500" },
|
||||
const TIER_LABEL_MAP: Record<
|
||||
RulesEdition,
|
||||
Record<DifficultyTier, { label: string; color: string }>
|
||||
> = {
|
||||
"5.5e": {
|
||||
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { 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" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Short labels for threshold display where horizontal space is limited. */
|
||||
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||
Moderate: "Mod",
|
||||
Medium: "Med",
|
||||
};
|
||||
|
||||
function shortLabel(label: string): string {
|
||||
return SHORT_LABELS[label] ?? label;
|
||||
}
|
||||
|
||||
function formatXp(xp: number): string {
|
||||
return xp.toLocaleString();
|
||||
}
|
||||
@@ -90,11 +112,12 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
const { setSide } = useEncounterContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
const breakdown = useDifficultyBreakdown();
|
||||
if (!breakdown) return null;
|
||||
|
||||
const tierConfig = TIER_LABELS[breakdown.tier];
|
||||
const tierConfig = TIER_LABEL_MAP[edition][breakdown.tier];
|
||||
|
||||
const handleToggle = (entry: BreakdownCombatant) => {
|
||||
const newSide = entry.side === "party" ? "enemy" : "party";
|
||||
@@ -120,15 +143,11 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<span>
|
||||
Low: <strong>{formatXp(breakdown.partyBudget.low)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Mod: <strong>{formatXp(breakdown.partyBudget.moderate)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
High: <strong>{formatXp(breakdown.partyBudget.high)}</strong>
|
||||
</span>
|
||||
{breakdown.thresholds.map((t) => (
|
||||
<span key={t.label}>
|
||||
{shortLabel(t.label)}: <strong>{formatXp(t.value)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,12 +195,34 @@ 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">
|
||||
<span>Net Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
</div>
|
||||
{breakdown.encounterMultiplier !== undefined &&
|
||||
breakdown.adjustedXp !== undefined ? (
|
||||
<div className="mt-2 border-border border-t pt-2">
|
||||
<div className="flex justify-between font-medium text-xs">
|
||||
<span>Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
×{breakdown.encounterMultiplier}
|
||||
</span>{" "}
|
||||
= {formatXp(breakdown.adjustedXp)}
|
||||
</span>
|
||||
</div>
|
||||
{breakdown.partySizeAdjusted === true ? (
|
||||
<div className="mt-0.5 text-muted-foreground text-xs italic">
|
||||
Adjusted for {breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Net Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
const TIER_CONFIG: Record<
|
||||
export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||
0: "Trivial",
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "High",
|
||||
};
|
||||
|
||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
0: "Easy",
|
||||
1: "Medium",
|
||||
2: "Hard",
|
||||
3: "Deadly",
|
||||
};
|
||||
|
||||
const TIER_COLORS: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string; label: string }
|
||||
{ filledBars: number; color: string }
|
||||
> = {
|
||||
trivial: { filledBars: 0, color: "", label: "Trivial" },
|
||||
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
|
||||
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
|
||||
high: { filledBars: 3, color: "bg-red-500", label: "High" },
|
||||
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" },
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
|
||||
export function DifficultyIndicator({
|
||||
result,
|
||||
labels,
|
||||
onClick,
|
||||
}: {
|
||||
result: DifficultyResult;
|
||||
labels: Record<DifficultyTier, string>;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const config = TIER_CONFIG[result.tier];
|
||||
const tooltip = `${config.label} encounter difficulty`;
|
||||
const config = TIER_COLORS[result.tier];
|
||||
const label = labels[result.tier];
|
||||
const tooltip = `${label} encounter difficulty`;
|
||||
|
||||
const Element = onClick ? "button" : "div";
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Conditions
|
||||
Rules Edition
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{EDITION_OPTIONS.map((opt) => (
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||
import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.js";
|
||||
import { DifficultyIndicator } from "./difficulty-indicator.js";
|
||||
import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
} from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
|
||||
@@ -20,6 +25,8 @@ export function TurnNavigation() {
|
||||
} = useEncounterContext();
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
|
||||
const [showBreakdown, setShowBreakdown] = useState(false);
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -79,6 +86,7 @@ export function TurnNavigation() {
|
||||
<div className="relative mr-1">
|
||||
<DifficultyIndicator
|
||||
result={difficulty}
|
||||
labels={tierLabels}
|
||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||
/>
|
||||
{showBreakdown ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
||||
import { useRulesEdition } from "../use-rules-edition.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
@@ -292,4 +293,56 @@ describe("useDifficultyBreakdown", () => {
|
||||
expect(breakdown?.enemyCombatants).toHaveLength(1);
|
||||
expect(breakdown?.enemyCombatants[0].combatant.name).toBe("Thug");
|
||||
});
|
||||
|
||||
it("exposes encounterMultiplier and adjustedXp for 5e edition", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-3"),
|
||||
name: "Thug",
|
||||
cr: "1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||
wrapper,
|
||||
});
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
// 2 enemy monsters, 1 PC (<3) → base x1.5, shift up → x2
|
||||
expect(breakdown?.encounterMultiplier).toBe(2);
|
||||
// CR 1/4 (50) + CR 1 (200) = 250, x2 = 500
|
||||
expect(breakdown?.totalMonsterXp).toBe(250);
|
||||
expect(breakdown?.adjustedXp).toBe(500);
|
||||
expect(breakdown?.thresholds).toHaveLength(4);
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
import { useRulesEdition } from "../use-rules-edition.js";
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
@@ -81,7 +82,7 @@ describe("useDifficulty", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.tier).toBe("low");
|
||||
expect(result.current?.tier).toBe(1);
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -223,9 +224,9 @@ describe("useDifficulty", () => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||
// CR 1/4 = 50 XP -> low (50 >= 50)
|
||||
expect(result.current?.tier).toBe("low");
|
||||
expect(result.current?.tier).toBe(1);
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
expect(result.current?.partyBudget.low).toBe(50);
|
||||
expect(result.current?.thresholds[0].value).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,7 +262,7 @@ describe("useDifficulty", () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// 2x level 1: budget low=100
|
||||
expect(result.current?.partyBudget.low).toBe(100);
|
||||
expect(result.current?.thresholds[0].value).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -304,7 +305,7 @@ describe("useDifficulty", () => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// Thug CR 1 = 200 XP, Allied Guard CR 1 = 200 XP subtracted, net = 0
|
||||
expect(result.current?.totalMonsterXp).toBe(0);
|
||||
expect(result.current?.tier).toBe("trivial");
|
||||
expect(result.current?.tier).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -336,12 +337,57 @@ describe("useDifficulty", () => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// Level 3 budget: low=150, mod=225, high=400
|
||||
// CR 1/4 = 50 XP -> trivial
|
||||
expect(result.current?.partyBudget.low).toBe(150);
|
||||
expect(result.current?.thresholds[0].value).toBe(150);
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
expect(result.current?.tier).toBe("trivial");
|
||||
expect(result.current?.tier).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 2014 difficulty when edition is 5e", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c2"),
|
||||
name: "Goblin",
|
||||
creatureId: crId1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[crId1, goblinCreature]]),
|
||||
});
|
||||
|
||||
// Set edition via the hook's external store
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||
wrapper,
|
||||
});
|
||||
editionResult.current.setEdition("5e");
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// 2014: 4 thresholds with Easy/Medium/Hard/Deadly labels
|
||||
expect(result.current?.thresholds).toHaveLength(4);
|
||||
expect(result.current?.thresholds[0].label).toBe("Easy");
|
||||
// CR 1/4 = 50 XP, 1 PC (<3) shifts x1 → x1.5, adjusted = 75
|
||||
expect(result.current?.encounterMultiplier).toBe(1.5);
|
||||
expect(result.current?.adjustedXp).toBe(75);
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("custom combatant with CR on party side subtracts XP", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyThreshold,
|
||||
DifficultyTier,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
@@ -9,6 +10,7 @@ import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { resolveSide } from "./use-difficulty.js";
|
||||
|
||||
export interface BreakdownCombatant {
|
||||
@@ -24,11 +26,10 @@ export interface BreakdownCombatant {
|
||||
interface DifficultyBreakdown {
|
||||
readonly tier: DifficultyTier;
|
||||
readonly totalMonsterXp: number;
|
||||
readonly partyBudget: {
|
||||
readonly low: number;
|
||||
readonly moderate: number;
|
||||
readonly high: number;
|
||||
};
|
||||
readonly thresholds: readonly DifficultyThreshold[];
|
||||
readonly encounterMultiplier: number | undefined;
|
||||
readonly adjustedXp: number | undefined;
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
readonly pcCount: number;
|
||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||
@@ -38,6 +39,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const { partyCombatants, enemyCombatants, descriptors, pcCount } =
|
||||
@@ -50,7 +52,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
|
||||
const result = calculateEncounterDifficulty(descriptors);
|
||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||
|
||||
return {
|
||||
...result,
|
||||
@@ -58,7 +60,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
partyCombatants,
|
||||
enemyCombatants,
|
||||
};
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
}, [encounter.combatants, characters, getCreature, edition]);
|
||||
}
|
||||
|
||||
type CreatureInfo = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
|
||||
export function resolveSide(c: Combatant): "party" | "enemy" {
|
||||
if (c.side) return c.side;
|
||||
@@ -42,6 +43,7 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const descriptors = buildDescriptors(
|
||||
@@ -57,6 +59,6 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
|
||||
return calculateEncounterDifficulty(descriptors);
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
return calculateEncounterDifficulty(descriptors, edition);
|
||||
}, [encounter.combatants, characters, getCreature, edition]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user