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

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