Files
initiative/packages/domain/src/encounter-difficulty.ts
Lukas 817cfddabc
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s
Add 2014 DMG encounter difficulty calculation
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>
2026-04-04 14:52:23 +02:00

299 lines
8.6 KiB
TypeScript

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<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,
};
}
/** 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,
};
}