import type { RulesEdition } from "./rules-edition.js"; /** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */ export type DifficultyTier = 0 | 1 | 2 | 3; 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; } /** 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, }; } /** 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 side: "party" | "enemy"; } function determineTier( 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; } 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 { 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 { tier: determineTier(totalMonsterXp, [ budget.low, budget.moderate, budget.high, ]), totalMonsterXp, thresholds, encounterMultiplier: undefined, adjustedXp: undefined, partySizeAdjusted: 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: determineTier(adjustedXp, [ budget.medium, budget.hard, budget.deadly, ]), totalMonsterXp, thresholds, encounterMultiplier, adjustedXp, partySizeAdjusted, }; }