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>
299 lines
8.6 KiB
TypeScript
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,
|
|
};
|
|
}
|