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:
@@ -46,185 +46,195 @@ function enemy(cr: string) {
|
||||
return { cr, side: "enemy" as const };
|
||||
}
|
||||
|
||||
describe("calculateEncounterDifficulty", () => {
|
||||
it("returns trivial when monster XP is below Low threshold", () => {
|
||||
describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
it("returns tier 0 when monster XP is below Low threshold", () => {
|
||||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||
// 1x CR 0 = 0 XP -> trivial
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("0"),
|
||||
]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
// 1x CR 0 = 0 XP -> tier 0
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("0")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 200,
|
||||
moderate: 300,
|
||||
high: 400,
|
||||
});
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 200 },
|
||||
{ label: "Moderate", value: 300 },
|
||||
{ label: "High", value: 400 },
|
||||
]);
|
||||
expect(result.encounterMultiplier).toBeUndefined();
|
||||
expect(result.adjustedXp).toBeUndefined();
|
||||
expect(result.partySizeAdjusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
]);
|
||||
expect(result.tier).toBe("low");
|
||||
it("returns tier 1 for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(1);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("returns moderate for 5x level 3 vs 1150 XP", () => {
|
||||
it("returns tier 2 for 5x level 3 vs 1150 XP", () => {
|
||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
enemy("3"),
|
||||
enemy("2"),
|
||||
]);
|
||||
expect(result.tier).toBe("moderate");
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
enemy("3"),
|
||||
enemy("2"),
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(2);
|
||||
expect(result.totalMonsterXp).toBe(1150);
|
||||
expect(result.partyBudget.moderate).toBe(1125);
|
||||
expect(result.thresholds[1].value).toBe(1125);
|
||||
});
|
||||
|
||||
it("returns high when XP meets High threshold", () => {
|
||||
it("returns tier 3 when XP meets High threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// 2x CR 1 = 400 XP -> High
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
]);
|
||||
expect(result.tier).toBe("high");
|
||||
// 2x CR 1 = 400 XP -> tier 3
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(400);
|
||||
});
|
||||
|
||||
it("caps at high when XP far exceeds threshold", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("30"),
|
||||
]);
|
||||
expect(result.tier).toBe("high");
|
||||
it("caps at tier 3 when XP far exceeds threshold", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("30")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(155000);
|
||||
});
|
||||
|
||||
it("handles mixed party levels", () => {
|
||||
// 3x level 3 + 1x level 2
|
||||
// Total: low=550, mod=825, high=1400
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(2),
|
||||
enemy("3"),
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(3), party(3), party(3), party(2), enemy("3")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 550 },
|
||||
{ label: "Moderate", value: 825 },
|
||||
{ label: "High", value: 1400 },
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 550,
|
||||
moderate: 825,
|
||||
high: 1400,
|
||||
});
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe("low");
|
||||
expect(result.tier).toBe(1);
|
||||
});
|
||||
|
||||
it("returns trivial with no enemies", () => {
|
||||
const result = calculateEncounterDifficulty([party(5), party(5)]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
it("returns tier 0 with no enemies", () => {
|
||||
const result = calculateEncounterDifficulty([party(5), party(5)], "5.5e");
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
});
|
||||
|
||||
it("returns high with no party levels (zero budget thresholds)", () => {
|
||||
const result = calculateEncounterDifficulty([enemy("1")]);
|
||||
expect(result.tier).toBe("high");
|
||||
it("returns tier 3 with no party levels (zero budget thresholds)", () => {
|
||||
const result = calculateEncounterDifficulty([enemy("1")], "5.5e");
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 0 },
|
||||
{ label: "Moderate", value: 0 },
|
||||
{ label: "High", value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles fractional CRs", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/4"),
|
||||
enemy("1/2"),
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/4"),
|
||||
enemy("1/2"),
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
||||
expect(result.tier).toBe(0); // 175 < 200 Low
|
||||
});
|
||||
|
||||
it("ignores unknown CRs (0 XP)", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("unknown"),
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("unknown")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.tier).toBe(0);
|
||||
});
|
||||
|
||||
it("subtracts XP for party-side combatant with CR", () => {
|
||||
// 4x level 1 party, 1 enemy CR 2 (450 XP), 1 party CR 1 (200 XP)
|
||||
// Net = 450 - 200 = 250
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("2"),
|
||||
{ cr: "1", side: "party" },
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("2"),
|
||||
{ cr: "1", side: "party" },
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(250);
|
||||
expect(result.tier).toBe("low"); // 250 >= 200 Low, < 300 Moderate
|
||||
expect(result.tier).toBe(1); // 250 >= 200 Low, < 300 Moderate
|
||||
});
|
||||
|
||||
it("floors net monster XP at 0", () => {
|
||||
// Party ally has more XP than enemy
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
{ cr: "5", side: "party" }, // 1800 XP subtracted
|
||||
enemy("1"), // 200 XP added
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
{ cr: "5", side: "party" }, // 1800 XP subtracted
|
||||
enemy("1"), // 200 XP added
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.tier).toBe(0);
|
||||
});
|
||||
|
||||
it("dual contribution: combatant with both level and CR on party side", () => {
|
||||
// Party combatant with level 1 AND CR 1 on party side
|
||||
// Level contributes to budget, CR subtracts from monster XP
|
||||
const result = calculateEncounterDifficulty([
|
||||
{ level: 1, cr: "1", side: "party" }, // budget += lv1, monsterXp -= 200
|
||||
enemy("2"), // monsterXp += 450
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
{ level: 1, cr: "1", side: "party" }, // budget += lv1, monsterXp -= 200
|
||||
enemy("2"), // monsterXp += 450
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 75 },
|
||||
{ label: "High", value: 100 },
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({ low: 50, moderate: 75, high: 100 });
|
||||
expect(result.totalMonsterXp).toBe(250); // 450 - 200
|
||||
});
|
||||
|
||||
it("enemy-side combatant with level does NOT contribute to budget", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
{ level: 5, side: "enemy" }, // should not add to budget
|
||||
enemy("1"),
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), { level: 5, side: "enemy" }, enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
// Only level 1 party contributes to budget
|
||||
expect(result.partyBudget).toEqual({ low: 50, moderate: 75, high: 100 });
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 75 },
|
||||
{ label: "High", value: 100 },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
@@ -232,19 +242,147 @@ describe("calculateEncounterDifficulty", () => {
|
||||
// 2 party PCs (level 3), 1 party ally (CR 1, 200 XP), 2 enemies (CR 2, 450 each)
|
||||
// Budget: 2x level 3 = low 300, mod 450, high 800
|
||||
// Monster XP: 900 - 200 = 700
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
{ cr: "1", side: "party" },
|
||||
enemy("2"),
|
||||
enemy("2"),
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(3), party(3), { cr: "1", side: "party" }, enemy("2"), enemy("2")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 300 },
|
||||
{ label: "Moderate", value: 450 },
|
||||
{ label: "High", value: 800 },
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 300,
|
||||
moderate: 450,
|
||||
high: 800,
|
||||
});
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe("moderate"); // 700 >= 450 Moderate, < 800 High
|
||||
expect(result.tier).toBe(2); // 700 >= 450 Moderate, < 800 High
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEncounterDifficulty — 2014 edition", () => {
|
||||
it("uses 2014 XP thresholds table", () => {
|
||||
// 4x level 1: Easy=100, Medium=200, Hard=300, Deadly=400
|
||||
// 1 enemy CR 1 = 200 XP, x1 multiplier = 200 adjusted
|
||||
// 200 >= 200 Medium → tier 1
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.tier).toBe(1);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Easy", value: 100 },
|
||||
{ label: "Medium", value: 200 },
|
||||
{ label: "Hard", value: 300 },
|
||||
{ label: "Deadly", value: 400 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies encounter multiplier for 3 monsters (x2)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/8"),
|
||||
enemy("1/8"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// Base: 75 XP, 3 monsters → x2 = 150 adjusted
|
||||
expect(result.totalMonsterXp).toBe(75);
|
||||
expect(result.encounterMultiplier).toBe(2);
|
||||
expect(result.adjustedXp).toBe(150);
|
||||
});
|
||||
|
||||
it("shifts multiplier up for fewer than 3 PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
// 1 monster, 2 PCs → base x1 shifts up to x1.5
|
||||
expect(result.encounterMultiplier).toBe(1.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier down for 6+ PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// 3 monsters, 6 PCs → base x2 shifts down to x1.5
|
||||
expect(result.encounterMultiplier).toBe(1.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier to x5 for 15+ monsters with <3 PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), ...Array.from({ length: 15 }, () => enemy("0"))],
|
||||
"5e",
|
||||
);
|
||||
// 15+ monsters = x4 base, shift up → x5
|
||||
expect(result.encounterMultiplier).toBe(5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier to x0.5 for 1 monster with 6+ PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.encounterMultiplier).toBe(0.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("only counts enemy-side combatants for monster count", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
{ cr: "1", side: "party" },
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// 3 enemy monsters → x2, NOT 4
|
||||
expect(result.encounterMultiplier).toBe(2);
|
||||
});
|
||||
|
||||
it("returns tier 0 when adjusted XP meets Easy but not Medium", () => {
|
||||
// 4x level 1: Easy=100, Medium=200
|
||||
// 1 enemy CR 1/2 = 100 XP, x1 multiplier = 100 adjusted
|
||||
// 100 >= Easy(100) but < Medium(200) → tier 0
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1/2")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.adjustedXp).toBe(100);
|
||||
});
|
||||
|
||||
it("returns no party size adjustment for standard party (3-5)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.partySizeAdjusted).toBe(false);
|
||||
});
|
||||
|
||||
it("returns undefined multiplier/adjustedXp for 5.5e", () => {
|
||||
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
|
||||
expect(result.encounterMultiplier).toBeUndefined();
|
||||
expect(result.adjustedXp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user