Add 2014 DMG encounter difficulty calculation
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s

Support the 2014 DMG encounter difficulty as an alternative to the 5.5e
system behind the existing Rules Edition toggle. The 2014 system uses
Easy/Medium/Hard/Deadly thresholds, an encounter multiplier based on
monster count, and party size adjustment (×0.5–×5 range).

- Extract RulesEdition to its own domain module
- Refactor DifficultyTier to abstract numeric values (0–3)
- Restructure DifficultyResult with thresholds array
- Add 2014 XP thresholds table and encounter multiplier logic
- Wire edition from context into difficulty hooks
- Edition-aware labels in indicator and breakdown panel
- Show multiplier, adjusted XP, and party size note for 2014
- Rename settings label from "Conditions" to "Rules Edition"
- Update spec 008 with issue #23 requirements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-04 14:52:23 +02:00
parent 94e1806112
commit 817cfddabc
17 changed files with 892 additions and 257 deletions

View File

@@ -1,13 +1,23 @@
export type DifficultyTier = "trivial" | "low" | "moderate" | "high";
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 partyBudget: {
readonly low: number;
readonly moderate: number;
readonly high: 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). */
@@ -74,6 +84,82 @@ const XP_BUDGET_PER_CHARACTER: Readonly<
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,
};
}
/** All standard 5e challenge rating strings, in ascending order. */
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
@@ -90,14 +176,66 @@ export interface CombatantDescriptor {
function determineTier(
xp: number,
low: number,
moderate: number,
high: number,
tierThresholds: readonly number[],
): DifficultyTier {
if (xp >= high) return "high";
if (xp >= moderate) return "moderate";
if (xp >= low) return "low";
return "trivial";
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,
};
}
/**
@@ -107,41 +245,54 @@ function determineTier(
*/
export function calculateEncounterDifficulty(
combatants: readonly CombatantDescriptor[],
edition: RulesEdition,
): DifficultyResult {
let budgetLow = 0;
let budgetModerate = 0;
let budgetHigh = 0;
let totalMonsterXp = 0;
const { totalMonsterXp, monsterCount, partyLevels } =
scanCombatants(combatants);
for (const c of combatants) {
if (c.level !== undefined && c.side === "party") {
const budget = XP_BUDGET_PER_CHARACTER[c.level];
if (budget) {
budgetLow += budget.low;
budgetModerate += budget.moderate;
budgetHigh += budget.high;
}
}
if (c.cr !== undefined) {
const xp = crToXp(c.cr);
if (c.side === "enemy") {
totalMonsterXp += xp;
} else {
totalMonsterXp -= xp;
}
}
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,
};
}
totalMonsterXp = Math.max(0, totalMonsterXp);
// 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(totalMonsterXp, budgetLow, budgetModerate, budgetHigh),
tier: determineTier(adjustedXp, [
budget.medium,
budget.hard,
budget.deadly,
]),
totalMonsterXp,
partyBudget: {
low: budgetLow,
moderate: budgetModerate,
high: budgetHigh,
},
thresholds,
encounterMultiplier,
adjustedXp,
partySizeAdjusted,
};
}