Fix encounter difficulty tier classification for 5.5e and PF2e
CI / build-image (push) Successful in 37s
CI / check (push) Successful in 2m43s

The 2024 DMG and PF2e GM Core define XP budgets as ceilings ("spend as
much of your XP budget as you can without going over"), not floors like
the 2014 DMG thresholds. The tier is now the lowest budget the XP does
not exceed: an encounter over the Moderate budget is High even if below
the High budget. PF2e now also uses the Trivial budget (40 or less),
GM Core remaster party-size adjustments (Low +20), and counts creatures
more than 4 levels below the party as 0 XP. 2014 rules are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-07-04 13:32:07 +02:00
co-authored by Claude Fable 5
parent a045e3a0f9
commit aa5555ef86
4 changed files with 242 additions and 52 deletions
@@ -49,9 +49,9 @@ function enemy(cr: string) {
}
describe("calculateEncounterDifficulty — 5.5e edition", () => {
it("returns tier 0 when monster XP is below Low threshold", () => {
it("returns tier 0 when monster XP is 0", () => {
// 4x level 1: Low = 200, Moderate = 300, High = 400
// 1x CR 0 = 0 XP -> tier 0
// 1x CR 0 = 0 XP -> tier 0 (5.5e has no Trivial tier; 0 bars only at 0 XP)
const result = calculateEncounterDifficulty(
[party(1), party(1), party(1), party(1), enemy("0")],
"5.5e",
@@ -77,9 +77,29 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
expect(result.totalMonsterXp).toBe(200);
});
it("returns tier 2 for 5x level 3 vs 1150 XP", () => {
it("returns tier 2 when XP spends exactly the Moderate budget", () => {
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
// CR 4 (1100) + CR 1/8 (25) = 1125 XP = Moderate budget
const result = calculateEncounterDifficulty(
[
party(3),
party(3),
party(3),
party(3),
party(3),
enemy("4"),
enemy("1/8"),
],
"5.5e",
);
expect(result.tier).toBe(2);
expect(result.totalMonsterXp).toBe(1125);
expect(result.thresholds[1].value).toBe(1125);
});
it("returns tier 3 when XP exceeds the Moderate budget", () => {
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
// CR 3 (700) + CR 2 (450) = 1150 XP > 1125 Moderate → High
const result = calculateEncounterDifficulty(
[
party(3),
@@ -92,14 +112,24 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
],
"5.5e",
);
expect(result.tier).toBe(2);
expect(result.tier).toBe(3);
expect(result.totalMonsterXp).toBe(1150);
expect(result.thresholds[1].value).toBe(1125);
});
it("returns tier 3 when XP meets High threshold", () => {
// 4x level 1: High = 400
// 2x CR 1 = 400 XP -> tier 3
it("returns tier 3 for 5x level 5 vs 5000 XP (over Moderate budget)", () => {
// 5x level 5: Low = 2500, Moderate = 3750, High = 5500
// CR 9 = 5000 XP > 3750 Moderate → High, even though below the High budget
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), party(5), enemy("9")],
"5.5e",
);
expect(result.tier).toBe(3);
expect(result.totalMonsterXp).toBe(5000);
});
it("returns tier 3 when XP exceeds the Moderate budget", () => {
// 4x level 1: Moderate = 300
// 2x CR 1 = 400 XP > 300 -> tier 3
const result = calculateEncounterDifficulty(
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
"5.5e",
@@ -120,6 +150,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
it("handles mixed party levels", () => {
// 3x level 3 + 1x level 2
// Total: low=550, mod=825, high=1400
// 700 XP > 550 Low, ≤ 825 Moderate → Moderate
const result = calculateEncounterDifficulty(
[party(3), party(3), party(3), party(2), enemy("3")],
"5.5e",
@@ -130,7 +161,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
{ label: "High", value: 1400 },
]);
expect(result.totalMonsterXp).toBe(700);
expect(result.tier).toBe(1);
expect(result.tier).toBe(2);
});
it("returns tier 0 with no enemies", () => {
@@ -164,7 +195,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
"5.5e",
);
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
expect(result.tier).toBe(0); // 175 < 200 Low
expect(result.tier).toBe(1); // 175 200 Low budget → Low
});
it("ignores unknown CRs (0 XP)", () => {
@@ -191,7 +222,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
"5.5e",
);
expect(result.totalMonsterXp).toBe(250);
expect(result.tier).toBe(1); // 250 >= 200 Low, < 300 Moderate
expect(result.tier).toBe(2); // 250 > 200 Low budget, 300 Moderate → Moderate
});
it("floors net monster XP at 0", () => {
@@ -254,7 +285,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
{ label: "High", value: 800 },
]);
expect(result.totalMonsterXp).toBe(700);
expect(result.tier).toBe(2); // 700 >= 450 Moderate, < 800 High
expect(result.tier).toBe(3); // 700 > 450 Moderate budget → High
});
});
@@ -448,8 +479,8 @@ describe("pf2eCreatureXp", () => {
expect(pf2eCreatureXp(5 + diff, 5)).toBe(expectedXp);
});
it("clamps level diff below 4 to 4 (10 XP)", () => {
expect(pf2eCreatureXp(0, 10)).toBe(10);
it("returns 0 XP for creatures more than 4 levels below the party", () => {
expect(pf2eCreatureXp(0, 10)).toBe(0);
});
it("clamps level diff above +4 to +4 (160 XP)", () => {
@@ -459,7 +490,7 @@ describe("pf2eCreatureXp", () => {
describe("calculateEncounterDifficulty — pf2e edition", () => {
it("returns Trivial (tier 0) for 40 XP with party of 4", () => {
// 1 creature at party level = 40 XP, below Low (60)
// 1 creature at party level = 40 XP, within the Trivial band (40 or less)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
@@ -507,7 +538,7 @@ describe("calculateEncounterDifficulty — pf2e edition", () => {
});
it("returns Extreme (tier 4) for 160 XP", () => {
// 1 creature at +4 = 160 XP
// 1 creature at +4 = 160 XP > Severe budget (120)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(9)],
"pf2e",
@@ -516,8 +547,28 @@ describe("calculateEncounterDifficulty — pf2e edition", () => {
expect(result.totalMonsterXp).toBe(160);
});
it("returns tier 0 when XP is below Low threshold", () => {
// 1 creature at 4 = 10 XP, Low = 60
it("returns Moderate (tier 2) for XP between the Low and Moderate budgets", () => {
// +1 (60) + 4 (10) = 70 XP > Low (60), ≤ Moderate (80)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(6), pf2eEnemy(1)],
"pf2e",
);
expect(result.tier).toBe(2);
expect(result.totalMonsterXp).toBe(70);
});
it("returns Extreme (tier 4) for XP just over the Severe budget", () => {
// +3 (120) + 4 (10) = 130 XP > Severe (120)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(8), pf2eEnemy(1)],
"pf2e",
);
expect(result.tier).toBe(4);
expect(result.totalMonsterXp).toBe(130);
});
it("returns tier 0 when XP is within the Trivial band", () => {
// 1 creature at 4 = 10 XP ≤ Trivial (40)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(1)],
"pf2e",
@@ -526,6 +577,15 @@ describe("calculateEncounterDifficulty — pf2e edition", () => {
expect(result.totalMonsterXp).toBe(10);
});
it("counts creatures more than 4 levels below the party as 0 XP", () => {
// Party level 6: a level-1 creature is 5 below → 0 XP
const result = calculateEncounterDifficulty(
[party(6), party(6), party(6), party(6), pf2eEnemy(1), pf2eEnemy(6)],
"pf2e",
);
expect(result.totalMonsterXp).toBe(40); // only the at-level creature counts
});
it("adjusts thresholds for 5 PCs (increases by adjustment)", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), party(5), pf2eEnemy(5)],
@@ -533,7 +593,7 @@ describe("calculateEncounterDifficulty — pf2e edition", () => {
);
expect(result.thresholds).toEqual([
{ label: "Trivial", value: 50 },
{ label: "Low", value: 75 },
{ label: "Low", value: 80 },
{ label: "Moderate", value: 100 },
{ label: "Severe", value: 150 },
{ label: "Extreme", value: 200 },
@@ -547,7 +607,7 @@ describe("calculateEncounterDifficulty — pf2e edition", () => {
);
expect(result.thresholds).toEqual([
{ label: "Trivial", value: 30 },
{ label: "Low", value: 45 },
{ label: "Low", value: 40 },
{ label: "Moderate", value: 60 },
{ label: "Severe", value: 90 },
{ label: "Extreme", value: 120 },
@@ -561,9 +621,9 @@ describe("calculateEncounterDifficulty — pf2e edition", () => {
);
// 1 PC: adjustment = 3
// Trivial: 40 + (3 * 10) = 10
// Low: 60 + (3 * 15) = 15
// Low: 60 + (3 * 20) = 0
expect(result.thresholds[0].value).toBe(10);
expect(result.thresholds[1].value).toBe(15);
expect(result.thresholds[1].value).toBe(0);
expect(result.thresholds[2].value).toBe(20); // 80 60
expect(result.thresholds[3].value).toBe(30); // 120 90
expect(result.thresholds[4].value).toBe(40); // 160 120