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();
|
||||
|
||||
Reference in New Issue
Block a user