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:
@@ -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