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>
This commit is contained in:
@@ -46,185 +46,195 @@ function enemy(cr: string) {
|
||||
return { cr, side: "enemy" as const };
|
||||
}
|
||||
|
||||
describe("calculateEncounterDifficulty", () => {
|
||||
it("returns trivial when monster XP is below Low threshold", () => {
|
||||
describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
it("returns tier 0 when monster XP is below Low threshold", () => {
|
||||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||
// 1x CR 0 = 0 XP -> trivial
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("0"),
|
||||
]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
// 1x CR 0 = 0 XP -> tier 0
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("0")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 200,
|
||||
moderate: 300,
|
||||
high: 400,
|
||||
});
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 200 },
|
||||
{ label: "Moderate", value: 300 },
|
||||
{ label: "High", value: 400 },
|
||||
]);
|
||||
expect(result.encounterMultiplier).toBeUndefined();
|
||||
expect(result.adjustedXp).toBeUndefined();
|
||||
expect(result.partySizeAdjusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
]);
|
||||
expect(result.tier).toBe("low");
|
||||
it("returns tier 1 for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(1);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("returns moderate for 5x level 3 vs 1150 XP", () => {
|
||||
it("returns tier 2 for 5x level 3 vs 1150 XP", () => {
|
||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
enemy("3"),
|
||||
enemy("2"),
|
||||
]);
|
||||
expect(result.tier).toBe("moderate");
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
enemy("3"),
|
||||
enemy("2"),
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(2);
|
||||
expect(result.totalMonsterXp).toBe(1150);
|
||||
expect(result.partyBudget.moderate).toBe(1125);
|
||||
expect(result.thresholds[1].value).toBe(1125);
|
||||
});
|
||||
|
||||
it("returns high when XP meets High threshold", () => {
|
||||
it("returns tier 3 when XP meets High threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// 2x CR 1 = 400 XP -> High
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
]);
|
||||
expect(result.tier).toBe("high");
|
||||
// 2x CR 1 = 400 XP -> tier 3
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(400);
|
||||
});
|
||||
|
||||
it("caps at high when XP far exceeds threshold", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("30"),
|
||||
]);
|
||||
expect(result.tier).toBe("high");
|
||||
it("caps at tier 3 when XP far exceeds threshold", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("30")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(155000);
|
||||
});
|
||||
|
||||
it("handles mixed party levels", () => {
|
||||
// 3x level 3 + 1x level 2
|
||||
// Total: low=550, mod=825, high=1400
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(2),
|
||||
enemy("3"),
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(3), party(3), party(3), party(2), enemy("3")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 550 },
|
||||
{ label: "Moderate", value: 825 },
|
||||
{ label: "High", value: 1400 },
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 550,
|
||||
moderate: 825,
|
||||
high: 1400,
|
||||
});
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe("low");
|
||||
expect(result.tier).toBe(1);
|
||||
});
|
||||
|
||||
it("returns trivial with no enemies", () => {
|
||||
const result = calculateEncounterDifficulty([party(5), party(5)]);
|
||||
expect(result.tier).toBe("trivial");
|
||||
it("returns tier 0 with no enemies", () => {
|
||||
const result = calculateEncounterDifficulty([party(5), party(5)], "5.5e");
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
});
|
||||
|
||||
it("returns high with no party levels (zero budget thresholds)", () => {
|
||||
const result = calculateEncounterDifficulty([enemy("1")]);
|
||||
expect(result.tier).toBe("high");
|
||||
it("returns tier 3 with no party levels (zero budget thresholds)", () => {
|
||||
const result = calculateEncounterDifficulty([enemy("1")], "5.5e");
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 0 },
|
||||
{ label: "Moderate", value: 0 },
|
||||
{ label: "High", value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles fractional CRs", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/4"),
|
||||
enemy("1/2"),
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/4"),
|
||||
enemy("1/2"),
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
||||
expect(result.tier).toBe(0); // 175 < 200 Low
|
||||
});
|
||||
|
||||
it("ignores unknown CRs (0 XP)", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("unknown"),
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("unknown")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.tier).toBe(0);
|
||||
});
|
||||
|
||||
it("subtracts XP for party-side combatant with CR", () => {
|
||||
// 4x level 1 party, 1 enemy CR 2 (450 XP), 1 party CR 1 (200 XP)
|
||||
// Net = 450 - 200 = 250
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("2"),
|
||||
{ cr: "1", side: "party" },
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("2"),
|
||||
{ cr: "1", side: "party" },
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(250);
|
||||
expect(result.tier).toBe("low"); // 250 >= 200 Low, < 300 Moderate
|
||||
expect(result.tier).toBe(1); // 250 >= 200 Low, < 300 Moderate
|
||||
});
|
||||
|
||||
it("floors net monster XP at 0", () => {
|
||||
// Party ally has more XP than enemy
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
{ cr: "5", side: "party" }, // 1800 XP subtracted
|
||||
enemy("1"), // 200 XP added
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
{ cr: "5", side: "party" }, // 1800 XP subtracted
|
||||
enemy("1"), // 200 XP added
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
expect(result.tier).toBe("trivial");
|
||||
expect(result.tier).toBe(0);
|
||||
});
|
||||
|
||||
it("dual contribution: combatant with both level and CR on party side", () => {
|
||||
// Party combatant with level 1 AND CR 1 on party side
|
||||
// Level contributes to budget, CR subtracts from monster XP
|
||||
const result = calculateEncounterDifficulty([
|
||||
{ level: 1, cr: "1", side: "party" }, // budget += lv1, monsterXp -= 200
|
||||
enemy("2"), // monsterXp += 450
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
{ level: 1, cr: "1", side: "party" }, // budget += lv1, monsterXp -= 200
|
||||
enemy("2"), // monsterXp += 450
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 75 },
|
||||
{ label: "High", value: 100 },
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({ low: 50, moderate: 75, high: 100 });
|
||||
expect(result.totalMonsterXp).toBe(250); // 450 - 200
|
||||
});
|
||||
|
||||
it("enemy-side combatant with level does NOT contribute to budget", () => {
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(1),
|
||||
{ level: 5, side: "enemy" }, // should not add to budget
|
||||
enemy("1"),
|
||||
]);
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), { level: 5, side: "enemy" }, enemy("1")],
|
||||
"5.5e",
|
||||
);
|
||||
// Only level 1 party contributes to budget
|
||||
expect(result.partyBudget).toEqual({ low: 50, moderate: 75, high: 100 });
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 50 },
|
||||
{ label: "Moderate", value: 75 },
|
||||
{ label: "High", value: 100 },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
@@ -232,19 +242,147 @@ describe("calculateEncounterDifficulty", () => {
|
||||
// 2 party PCs (level 3), 1 party ally (CR 1, 200 XP), 2 enemies (CR 2, 450 each)
|
||||
// Budget: 2x level 3 = low 300, mod 450, high 800
|
||||
// Monster XP: 900 - 200 = 700
|
||||
const result = calculateEncounterDifficulty([
|
||||
party(3),
|
||||
party(3),
|
||||
{ cr: "1", side: "party" },
|
||||
enemy("2"),
|
||||
enemy("2"),
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(3), party(3), { cr: "1", side: "party" }, enemy("2"), enemy("2")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Low", value: 300 },
|
||||
{ label: "Moderate", value: 450 },
|
||||
{ label: "High", value: 800 },
|
||||
]);
|
||||
expect(result.partyBudget).toEqual({
|
||||
low: 300,
|
||||
moderate: 450,
|
||||
high: 800,
|
||||
});
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe("moderate"); // 700 >= 450 Moderate, < 800 High
|
||||
expect(result.tier).toBe(2); // 700 >= 450 Moderate, < 800 High
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEncounterDifficulty — 2014 edition", () => {
|
||||
it("uses 2014 XP thresholds table", () => {
|
||||
// 4x level 1: Easy=100, Medium=200, Hard=300, Deadly=400
|
||||
// 1 enemy CR 1 = 200 XP, x1 multiplier = 200 adjusted
|
||||
// 200 >= 200 Medium → tier 1
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.tier).toBe(1);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Easy", value: 100 },
|
||||
{ label: "Medium", value: 200 },
|
||||
{ label: "Hard", value: 300 },
|
||||
{ label: "Deadly", value: 400 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies encounter multiplier for 3 monsters (x2)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1/8"),
|
||||
enemy("1/8"),
|
||||
enemy("1/8"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// Base: 75 XP, 3 monsters → x2 = 150 adjusted
|
||||
expect(result.totalMonsterXp).toBe(75);
|
||||
expect(result.encounterMultiplier).toBe(2);
|
||||
expect(result.adjustedXp).toBe(150);
|
||||
});
|
||||
|
||||
it("shifts multiplier up for fewer than 3 PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
// 1 monster, 2 PCs → base x1 shifts up to x1.5
|
||||
expect(result.encounterMultiplier).toBe(1.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier down for 6+ PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// 3 monsters, 6 PCs → base x2 shifts down to x1.5
|
||||
expect(result.encounterMultiplier).toBe(1.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier to x5 for 15+ monsters with <3 PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), ...Array.from({ length: 15 }, () => enemy("0"))],
|
||||
"5e",
|
||||
);
|
||||
// 15+ monsters = x4 base, shift up → x5
|
||||
expect(result.encounterMultiplier).toBe(5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("shifts multiplier to x0.5 for 1 monster with 6+ PCs", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.encounterMultiplier).toBe(0.5);
|
||||
expect(result.partySizeAdjusted).toBe(true);
|
||||
});
|
||||
|
||||
it("only counts enemy-side combatants for monster count", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
party(1),
|
||||
{ cr: "1", side: "party" },
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
enemy("1"),
|
||||
],
|
||||
"5e",
|
||||
);
|
||||
// 3 enemy monsters → x2, NOT 4
|
||||
expect(result.encounterMultiplier).toBe(2);
|
||||
});
|
||||
|
||||
it("returns tier 0 when adjusted XP meets Easy but not Medium", () => {
|
||||
// 4x level 1: Easy=100, Medium=200
|
||||
// 1 enemy CR 1/2 = 100 XP, x1 multiplier = 100 adjusted
|
||||
// 100 >= Easy(100) but < Medium(200) → tier 0
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1/2")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.adjustedXp).toBe(100);
|
||||
});
|
||||
|
||||
it("returns no party size adjustment for standard party (3-5)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||
"5e",
|
||||
);
|
||||
expect(result.partySizeAdjusted).toBe(false);
|
||||
});
|
||||
|
||||
it("returns undefined multiplier/adjustedXp for 5.5e", () => {
|
||||
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
|
||||
expect(result.encounterMultiplier).toBeUndefined();
|
||||
expect(result.adjustedXp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ConditionId =
|
||||
| "stunned"
|
||||
| "unconscious";
|
||||
|
||||
export type RulesEdition = "5e" | "5.5e";
|
||||
import type { RulesEdition } from "./rules-edition.js";
|
||||
|
||||
export interface ConditionDefinition {
|
||||
readonly id: ConditionId;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export {
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
type RulesEdition,
|
||||
VALID_CONDITION_IDS,
|
||||
} from "./conditions.js";
|
||||
export {
|
||||
@@ -53,6 +52,7 @@ export {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
type DifficultyResult,
|
||||
type DifficultyThreshold,
|
||||
type DifficultyTier,
|
||||
VALID_CR_VALUES,
|
||||
} from "./encounter-difficulty.js";
|
||||
@@ -110,6 +110,7 @@ export {
|
||||
rollInitiative,
|
||||
selectRoll,
|
||||
} from "./roll-initiative.js";
|
||||
export type { RulesEdition } from "./rules-edition.js";
|
||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||
export { type SetCrSuccess, setCr } from "./set-cr.js";
|
||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||
|
||||
1
packages/domain/src/rules-edition.ts
Normal file
1
packages/domain/src/rules-edition.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type RulesEdition = "5e" | "5.5e";
|
||||
Reference in New Issue
Block a user