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> = { "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 > = { 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 > = { 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> = { [-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(); 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, }; }