Fix encounter difficulty tier classification for 5.5e and PF2e
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:
@@ -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
|
||||
|
||||
@@ -187,10 +187,14 @@ const PF2E_THRESHOLDS_BASE = {
|
||||
extreme: 160,
|
||||
} as const;
|
||||
|
||||
/** PF2e per-PC adjustment to each threshold (added per PC beyond 4, subtracted per PC fewer). */
|
||||
/**
|
||||
* PF2e per-PC adjustment to each threshold (added per PC beyond 4, subtracted
|
||||
* per PC fewer). GM Core remaster values — Low is 20 there, not the
|
||||
* pre-remaster CRB's 15.
|
||||
*/
|
||||
const PF2E_THRESHOLD_ADJUSTMENTS = {
|
||||
trivial: 10,
|
||||
low: 15,
|
||||
low: 20,
|
||||
moderate: 20,
|
||||
severe: 30,
|
||||
extreme: 40,
|
||||
@@ -230,13 +234,19 @@ export function derivePartyLevel(levels: readonly number[]): number {
|
||||
return Math.round(sum / levels.length);
|
||||
}
|
||||
|
||||
/** Returns PF2e XP for a creature given its level and the party level. */
|
||||
/**
|
||||
* Returns PF2e XP for a creature given its level and the party level.
|
||||
* Creatures more than 4 levels below the party have no row in the GM Core
|
||||
* table and pose no meaningful threat — they are worth 0 XP. Creatures more
|
||||
* than 4 levels above are counted as the +4 row (160 XP).
|
||||
*/
|
||||
export function pf2eCreatureXp(
|
||||
creatureLevel: number,
|
||||
partyLevel: number,
|
||||
): number {
|
||||
const diff = Math.max(-4, Math.min(4, creatureLevel - partyLevel));
|
||||
return PF2E_LEVEL_DIFF_XP[diff] ?? 0;
|
||||
const diff = creatureLevel - partyLevel;
|
||||
if (diff < -4) return 0;
|
||||
return PF2E_LEVEL_DIFF_XP[Math.min(4, diff)] ?? 0;
|
||||
}
|
||||
|
||||
function calculatePf2eBudget(partySize: number) {
|
||||
@@ -304,7 +314,11 @@ export interface CombatantDescriptor {
|
||||
readonly side: "party" | "enemy";
|
||||
}
|
||||
|
||||
function determineTier(
|
||||
/**
|
||||
* 2014 DMG: thresholds are floors — "the closest threshold that is lower
|
||||
* than the adjusted XP value determines the encounter's difficulty".
|
||||
*/
|
||||
function tierFromThresholds(
|
||||
xp: number,
|
||||
tierThresholds: readonly number[],
|
||||
): DifficultyTier {
|
||||
@@ -314,6 +328,23 @@ function determineTier(
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2024 DMG / PF2e: budgets are ceilings — an encounter belongs to the lowest
|
||||
* tier whose budget it does not exceed ("spend as much of your XP budget as
|
||||
* you can without going over"; PF2e Trivial is "40 XP or less"). XP above the
|
||||
* last budget falls into the tier beyond it (5.5e High / PF2e Extreme have no
|
||||
* upper bound).
|
||||
*/
|
||||
function tierFromBudgets(
|
||||
xp: number,
|
||||
budgets: readonly number[],
|
||||
): DifficultyTier {
|
||||
for (let i = 0; i < budgets.length; i++) {
|
||||
if (xp <= budgets[i]) return i as DifficultyTier;
|
||||
}
|
||||
return budgets.length as DifficultyTier;
|
||||
}
|
||||
|
||||
function accumulateBudget5_5e(levels: readonly number[]) {
|
||||
const budget = { low: 0, moderate: 0, high: 0 };
|
||||
for (const level of levels) {
|
||||
@@ -397,11 +428,11 @@ export function calculateEncounterDifficulty(
|
||||
];
|
||||
|
||||
return {
|
||||
tier: determineTier(totalCreatureXp, [
|
||||
tier: tierFromBudgets(totalCreatureXp, [
|
||||
budget.trivial,
|
||||
budget.low,
|
||||
budget.moderate,
|
||||
budget.severe,
|
||||
budget.extreme,
|
||||
]),
|
||||
totalMonsterXp: totalCreatureXp,
|
||||
thresholds,
|
||||
@@ -423,11 +454,9 @@ export function calculateEncounterDifficulty(
|
||||
{ label: "High", value: budget.high },
|
||||
];
|
||||
return {
|
||||
tier: determineTier(totalMonsterXp, [
|
||||
budget.low,
|
||||
budget.moderate,
|
||||
budget.high,
|
||||
]),
|
||||
// 5.5e has no tier below Low, so tier 0 (empty bars) only at 0 XP.
|
||||
// The High budget is display-only: anything over Moderate is High.
|
||||
tier: tierFromBudgets(totalMonsterXp, [0, budget.low, budget.moderate]),
|
||||
totalMonsterXp,
|
||||
thresholds,
|
||||
encounterMultiplier: undefined,
|
||||
@@ -450,7 +479,7 @@ export function calculateEncounterDifficulty(
|
||||
];
|
||||
|
||||
return {
|
||||
tier: determineTier(adjustedXp, [
|
||||
tier: tierFromThresholds(adjustedXp, [
|
||||
budget.medium,
|
||||
budget.hard,
|
||||
budget.deadly,
|
||||
|
||||
Reference in New Issue
Block a user