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