Add 2014 DMG encounter difficulty calculation
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s

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:
Lukas
2026-04-04 14:52:23 +02:00
parent 94e1806112
commit 817cfddabc
17 changed files with 892 additions and 257 deletions

View File

@@ -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();

View File

@@ -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={() => {}}
/>,
);

View File

@@ -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();

View File

@@ -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">
&times;{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>
);
}

View File

@@ -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";

View File

@@ -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) => (

View File

@@ -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 ? (