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>
495 lines
14 KiB
TypeScript
495 lines
14 KiB
TypeScript
import type { RulesEdition } from "./rules-edition.js";
|
||
|
||
/** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */
|
||
export type DifficultyTier = 0 | 1 | 2 | 3 | 4;
|
||
|
||
export interface DifficultyThreshold {
|
||
readonly label: string;
|
||
readonly value: number;
|
||
}
|
||
|
||
export interface DifficultyResult {
|
||
readonly tier: DifficultyTier;
|
||
readonly totalMonsterXp: number;
|
||
readonly thresholds: readonly DifficultyThreshold[];
|
||
/** 2014 only: the encounter multiplier applied to base monster XP. */
|
||
readonly encounterMultiplier: number | undefined;
|
||
/** 2014 only: monster XP after applying the encounter multiplier. */
|
||
readonly adjustedXp: number | undefined;
|
||
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||
readonly partySizeAdjusted: boolean | undefined;
|
||
/** PF2e only: the derived party level used for XP calculation. */
|
||
readonly partyLevel: number | undefined;
|
||
}
|
||
|
||
/** Maps challenge rating strings to XP values (standard 5e). */
|
||
const CR_TO_XP: Readonly<Record<string, number>> = {
|
||
"0": 0,
|
||
"1/8": 25,
|
||
"1/4": 50,
|
||
"1/2": 100,
|
||
"1": 200,
|
||
"2": 450,
|
||
"3": 700,
|
||
"4": 1100,
|
||
"5": 1800,
|
||
"6": 2300,
|
||
"7": 2900,
|
||
"8": 3900,
|
||
"9": 5000,
|
||
"10": 5900,
|
||
"11": 7200,
|
||
"12": 8400,
|
||
"13": 10000,
|
||
"14": 11500,
|
||
"15": 13000,
|
||
"16": 15000,
|
||
"17": 18000,
|
||
"18": 20000,
|
||
"19": 22000,
|
||
"20": 25000,
|
||
"21": 33000,
|
||
"22": 41000,
|
||
"23": 50000,
|
||
"24": 62000,
|
||
"25": 75000,
|
||
"26": 90000,
|
||
"27": 105000,
|
||
"28": 120000,
|
||
"29": 135000,
|
||
"30": 155000,
|
||
};
|
||
|
||
/** Maps character level (1-20) to XP budget thresholds (2024 5.5e DMG). */
|
||
const XP_BUDGET_PER_CHARACTER: Readonly<
|
||
Record<number, { low: number; moderate: number; high: number }>
|
||
> = {
|
||
1: { low: 50, moderate: 75, high: 100 },
|
||
2: { low: 100, moderate: 150, high: 200 },
|
||
3: { low: 150, moderate: 225, high: 400 },
|
||
4: { low: 250, moderate: 375, high: 500 },
|
||
5: { low: 500, moderate: 750, high: 1100 },
|
||
6: { low: 600, moderate: 1000, high: 1400 },
|
||
7: { low: 750, moderate: 1300, high: 1700 },
|
||
8: { low: 1000, moderate: 1700, high: 2100 },
|
||
9: { low: 1300, moderate: 2000, high: 2600 },
|
||
10: { low: 1600, moderate: 2300, high: 3100 },
|
||
11: { low: 1900, moderate: 2900, high: 4100 },
|
||
12: { low: 2200, moderate: 3700, high: 4700 },
|
||
13: { low: 2600, moderate: 4200, high: 5400 },
|
||
14: { low: 2900, moderate: 4900, high: 6200 },
|
||
15: { low: 3300, moderate: 5400, high: 7800 },
|
||
16: { low: 3800, moderate: 6100, high: 9800 },
|
||
17: { low: 4500, moderate: 7200, high: 11700 },
|
||
18: { low: 5000, moderate: 8700, high: 14200 },
|
||
19: { low: 5500, moderate: 10700, high: 17200 },
|
||
20: { low: 6400, moderate: 13200, high: 22000 },
|
||
};
|
||
|
||
/** Maps character level (1-20) to XP thresholds (2014 DMG). */
|
||
const XP_THRESHOLDS_2014: Readonly<
|
||
Record<number, { easy: number; medium: number; hard: number; deadly: number }>
|
||
> = {
|
||
1: { easy: 25, medium: 50, hard: 75, deadly: 100 },
|
||
2: { easy: 50, medium: 100, hard: 150, deadly: 200 },
|
||
3: { easy: 75, medium: 150, hard: 225, deadly: 400 },
|
||
4: { easy: 125, medium: 250, hard: 375, deadly: 500 },
|
||
5: { easy: 250, medium: 500, hard: 750, deadly: 1100 },
|
||
6: { easy: 300, medium: 600, hard: 900, deadly: 1400 },
|
||
7: { easy: 350, medium: 750, hard: 1100, deadly: 1700 },
|
||
8: { easy: 450, medium: 900, hard: 1400, deadly: 2100 },
|
||
9: { easy: 550, medium: 1100, hard: 1600, deadly: 2400 },
|
||
10: { easy: 600, medium: 1200, hard: 1900, deadly: 2800 },
|
||
11: { easy: 800, medium: 1600, hard: 2400, deadly: 3600 },
|
||
12: { easy: 1000, medium: 2000, hard: 3000, deadly: 4500 },
|
||
13: { easy: 1100, medium: 2200, hard: 3400, deadly: 5100 },
|
||
14: { easy: 1250, medium: 2500, hard: 3800, deadly: 5700 },
|
||
15: { easy: 1400, medium: 2800, hard: 4300, deadly: 6400 },
|
||
16: { easy: 1600, medium: 3200, hard: 4800, deadly: 7200 },
|
||
17: { easy: 2000, medium: 3900, hard: 5900, deadly: 8800 },
|
||
18: { easy: 2100, medium: 4200, hard: 6300, deadly: 9500 },
|
||
19: { easy: 2400, medium: 4900, hard: 7300, deadly: 10900 },
|
||
20: { easy: 2800, medium: 5700, hard: 8500, deadly: 12700 },
|
||
};
|
||
|
||
/** 2014 encounter multiplier by number of enemy-side monsters. */
|
||
const ENCOUNTER_MULTIPLIER_TABLE: readonly {
|
||
max: number;
|
||
multiplier: number;
|
||
}[] = [
|
||
{ max: 1, multiplier: 1 },
|
||
{ max: 2, multiplier: 1.5 },
|
||
{ max: 6, multiplier: 2 },
|
||
{ max: 10, multiplier: 2.5 },
|
||
{ max: 14, multiplier: 3 },
|
||
{ max: Number.POSITIVE_INFINITY, multiplier: 4 },
|
||
];
|
||
|
||
/**
|
||
* Multiplier values in ascending order for party size shifting.
|
||
* Extends beyond the base table: x0.5 (6+ PCs, 1 monster) and x5 (<3 PCs, 15+ monsters)
|
||
* per 2014 DMG party size adjustment rules.
|
||
*/
|
||
const MULTIPLIER_STEPS = [0.5, 1, 1.5, 2, 2.5, 3, 4, 5] as const;
|
||
|
||
/** Index into MULTIPLIER_STEPS for each base table entry (before party size adjustment). */
|
||
const BASE_STEP_INDEX = [1, 2, 3, 4, 5, 6] as const;
|
||
|
||
function getEncounterMultiplier(
|
||
monsterCount: number,
|
||
partySize: number,
|
||
): { multiplier: number; partySizeAdjusted: boolean } {
|
||
const tableIndex = ENCOUNTER_MULTIPLIER_TABLE.findIndex(
|
||
(entry) => monsterCount <= entry.max,
|
||
);
|
||
let stepIndex: number =
|
||
BASE_STEP_INDEX[
|
||
tableIndex === -1 ? BASE_STEP_INDEX.length - 1 : tableIndex
|
||
];
|
||
let partySizeAdjusted = false;
|
||
|
||
if (partySize < 3) {
|
||
stepIndex = Math.min(stepIndex + 1, MULTIPLIER_STEPS.length - 1);
|
||
partySizeAdjusted = true;
|
||
} else if (partySize >= 6) {
|
||
stepIndex = Math.max(stepIndex - 1, 0);
|
||
partySizeAdjusted = true;
|
||
}
|
||
|
||
return {
|
||
multiplier: MULTIPLIER_STEPS[stepIndex] as number,
|
||
partySizeAdjusted,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* PF2e: XP granted by a creature based on its level relative to party level.
|
||
* Key is (creature level − party level), clamped to [−4, +4].
|
||
*/
|
||
const PF2E_LEVEL_DIFF_XP: Readonly<Record<number, number>> = {
|
||
[-4]: 10,
|
||
[-3]: 15,
|
||
[-2]: 20,
|
||
[-1]: 30,
|
||
0: 40,
|
||
1: 60,
|
||
2: 80,
|
||
3: 120,
|
||
4: 160,
|
||
};
|
||
|
||
/** PF2e base encounter budget thresholds for a party of 4. */
|
||
const PF2E_THRESHOLDS_BASE = {
|
||
trivial: 40,
|
||
low: 60,
|
||
moderate: 80,
|
||
severe: 120,
|
||
extreme: 160,
|
||
} as const;
|
||
|
||
/**
|
||
* 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: 20,
|
||
moderate: 20,
|
||
severe: 30,
|
||
extreme: 40,
|
||
} as const;
|
||
|
||
/**
|
||
* Derives PF2e party level from PC levels.
|
||
* Returns the mode (most common level). If no unique mode, returns
|
||
* the average rounded to the nearest integer.
|
||
*/
|
||
export function derivePartyLevel(levels: readonly number[]): number {
|
||
if (levels.length === 0) return 0;
|
||
if (levels.length === 1) return levels[0];
|
||
|
||
const counts = new Map<number, number>();
|
||
for (const l of levels) {
|
||
counts.set(l, (counts.get(l) ?? 0) + 1);
|
||
}
|
||
|
||
let maxCount = 0;
|
||
let mode: number | undefined;
|
||
let isTied = false;
|
||
|
||
for (const [level, count] of counts) {
|
||
if (count > maxCount) {
|
||
maxCount = count;
|
||
mode = level;
|
||
isTied = false;
|
||
} else if (count === maxCount) {
|
||
isTied = true;
|
||
}
|
||
}
|
||
|
||
if (!isTied && mode !== undefined) return mode;
|
||
|
||
const sum = levels.reduce((a, b) => a + b, 0);
|
||
return Math.round(sum / levels.length);
|
||
}
|
||
|
||
/**
|
||
* 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 = creatureLevel - partyLevel;
|
||
if (diff < -4) return 0;
|
||
return PF2E_LEVEL_DIFF_XP[Math.min(4, diff)] ?? 0;
|
||
}
|
||
|
||
function calculatePf2eBudget(partySize: number) {
|
||
const adjustment = partySize - 4;
|
||
return {
|
||
trivial: Math.max(
|
||
0,
|
||
PF2E_THRESHOLDS_BASE.trivial +
|
||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.trivial,
|
||
),
|
||
low: Math.max(
|
||
0,
|
||
PF2E_THRESHOLDS_BASE.low + adjustment * PF2E_THRESHOLD_ADJUSTMENTS.low,
|
||
),
|
||
moderate: Math.max(
|
||
0,
|
||
PF2E_THRESHOLDS_BASE.moderate +
|
||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.moderate,
|
||
),
|
||
severe: Math.max(
|
||
0,
|
||
PF2E_THRESHOLDS_BASE.severe +
|
||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.severe,
|
||
),
|
||
extreme: Math.max(
|
||
0,
|
||
PF2E_THRESHOLDS_BASE.extreme +
|
||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.extreme,
|
||
),
|
||
};
|
||
}
|
||
|
||
function scanCombatantsPf2e(
|
||
combatants: readonly CombatantDescriptor[],
|
||
partyLevel: number,
|
||
) {
|
||
let totalCreatureXp = 0;
|
||
|
||
for (const c of combatants) {
|
||
if (c.creatureLevel !== undefined) {
|
||
const xp = pf2eCreatureXp(c.creatureLevel, partyLevel);
|
||
if (c.side === "enemy") {
|
||
totalCreatureXp += xp;
|
||
} else {
|
||
totalCreatureXp -= xp;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { totalCreatureXp: Math.max(0, totalCreatureXp) };
|
||
}
|
||
|
||
/** All standard 5e challenge rating strings, in ascending order. */
|
||
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
||
|
||
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
|
||
export function crToXp(cr: string): number {
|
||
return CR_TO_XP[cr] ?? 0;
|
||
}
|
||
|
||
export interface CombatantDescriptor {
|
||
readonly level?: number;
|
||
readonly cr?: string;
|
||
readonly creatureLevel?: number;
|
||
readonly side: "party" | "enemy";
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
for (let i = tierThresholds.length - 1; i >= 0; i--) {
|
||
if (xp >= tierThresholds[i]) return (i + 1) as DifficultyTier;
|
||
}
|
||
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) {
|
||
const b = XP_BUDGET_PER_CHARACTER[level];
|
||
if (b) {
|
||
budget.low += b.low;
|
||
budget.moderate += b.moderate;
|
||
budget.high += b.high;
|
||
}
|
||
}
|
||
return budget;
|
||
}
|
||
|
||
function accumulateBudget2014(levels: readonly number[]) {
|
||
const budget = { easy: 0, medium: 0, hard: 0, deadly: 0 };
|
||
for (const level of levels) {
|
||
const b = XP_THRESHOLDS_2014[level];
|
||
if (b) {
|
||
budget.easy += b.easy;
|
||
budget.medium += b.medium;
|
||
budget.hard += b.hard;
|
||
budget.deadly += b.deadly;
|
||
}
|
||
}
|
||
return budget;
|
||
}
|
||
|
||
function scanCombatants(combatants: readonly CombatantDescriptor[]) {
|
||
let totalMonsterXp = 0;
|
||
let monsterCount = 0;
|
||
const partyLevels: number[] = [];
|
||
|
||
for (const c of combatants) {
|
||
if (c.level !== undefined && c.side === "party") {
|
||
partyLevels.push(c.level);
|
||
}
|
||
if (c.cr !== undefined) {
|
||
const xp = crToXp(c.cr);
|
||
if (c.side === "enemy") {
|
||
totalMonsterXp += xp;
|
||
monsterCount++;
|
||
} else {
|
||
totalMonsterXp -= xp;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
totalMonsterXp: Math.max(0, totalMonsterXp),
|
||
monsterCount,
|
||
partyLevels,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Calculates encounter difficulty from combatant descriptors.
|
||
* Party-side combatants with level contribute to the budget.
|
||
* Enemy-side combatants with CR add XP; party-side with CR subtract XP (floored at 0).
|
||
*/
|
||
export function calculateEncounterDifficulty(
|
||
combatants: readonly CombatantDescriptor[],
|
||
edition: RulesEdition,
|
||
): DifficultyResult {
|
||
if (edition === "pf2e") {
|
||
const partyLevels: number[] = [];
|
||
for (const c of combatants) {
|
||
if (c.level !== undefined && c.side === "party") {
|
||
partyLevels.push(c.level);
|
||
}
|
||
}
|
||
|
||
const partyLevel = derivePartyLevel(partyLevels);
|
||
const { totalCreatureXp } = scanCombatantsPf2e(combatants, partyLevel);
|
||
const budget = calculatePf2eBudget(partyLevels.length);
|
||
const thresholds: DifficultyThreshold[] = [
|
||
{ label: "Trivial", value: budget.trivial },
|
||
{ label: "Low", value: budget.low },
|
||
{ label: "Moderate", value: budget.moderate },
|
||
{ label: "Severe", value: budget.severe },
|
||
{ label: "Extreme", value: budget.extreme },
|
||
];
|
||
|
||
return {
|
||
tier: tierFromBudgets(totalCreatureXp, [
|
||
budget.trivial,
|
||
budget.low,
|
||
budget.moderate,
|
||
budget.severe,
|
||
]),
|
||
totalMonsterXp: totalCreatureXp,
|
||
thresholds,
|
||
encounterMultiplier: undefined,
|
||
adjustedXp: undefined,
|
||
partySizeAdjusted: undefined,
|
||
partyLevel,
|
||
};
|
||
}
|
||
|
||
const { totalMonsterXp, monsterCount, partyLevels } =
|
||
scanCombatants(combatants);
|
||
|
||
if (edition === "5.5e") {
|
||
const budget = accumulateBudget5_5e(partyLevels);
|
||
const thresholds: DifficultyThreshold[] = [
|
||
{ label: "Low", value: budget.low },
|
||
{ label: "Moderate", value: budget.moderate },
|
||
{ label: "High", value: budget.high },
|
||
];
|
||
return {
|
||
// 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,
|
||
adjustedXp: undefined,
|
||
partySizeAdjusted: undefined,
|
||
partyLevel: undefined,
|
||
};
|
||
}
|
||
|
||
// 2014 edition
|
||
const budget = accumulateBudget2014(partyLevels);
|
||
const { multiplier: encounterMultiplier, partySizeAdjusted } =
|
||
getEncounterMultiplier(monsterCount, partyLevels.length);
|
||
const adjustedXp = Math.round(totalMonsterXp * encounterMultiplier);
|
||
const thresholds: DifficultyThreshold[] = [
|
||
{ label: "Easy", value: budget.easy },
|
||
{ label: "Medium", value: budget.medium },
|
||
{ label: "Hard", value: budget.hard },
|
||
{ label: "Deadly", value: budget.deadly },
|
||
];
|
||
|
||
return {
|
||
tier: tierFromThresholds(adjustedXp, [
|
||
budget.medium,
|
||
budget.hard,
|
||
budget.deadly,
|
||
]),
|
||
totalMonsterXp,
|
||
thresholds,
|
||
encounterMultiplier,
|
||
adjustedXp,
|
||
partySizeAdjusted,
|
||
partyLevel: undefined,
|
||
};
|
||
}
|