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>
389 lines
11 KiB
TypeScript
389 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
calculateEncounterDifficulty,
|
|
crToXp,
|
|
} from "../encounter-difficulty.js";
|
|
|
|
describe("crToXp", () => {
|
|
it("returns 0 for CR 0", () => {
|
|
expect(crToXp("0")).toBe(0);
|
|
});
|
|
|
|
it("returns 25 for CR 1/8", () => {
|
|
expect(crToXp("1/8")).toBe(25);
|
|
});
|
|
|
|
it("returns 50 for CR 1/4", () => {
|
|
expect(crToXp("1/4")).toBe(50);
|
|
});
|
|
|
|
it("returns 100 for CR 1/2", () => {
|
|
expect(crToXp("1/2")).toBe(100);
|
|
});
|
|
|
|
it("returns 200 for CR 1", () => {
|
|
expect(crToXp("1")).toBe(200);
|
|
});
|
|
|
|
it("returns 155000 for CR 30", () => {
|
|
expect(crToXp("30")).toBe(155000);
|
|
});
|
|
|
|
it("returns 0 for unknown CR", () => {
|
|
expect(crToXp("99")).toBe(0);
|
|
expect(crToXp("")).toBe(0);
|
|
expect(crToXp("abc")).toBe(0);
|
|
});
|
|
});
|
|
|
|
/** Helper to build party-side descriptors with level. */
|
|
function party(level: number) {
|
|
return { level, side: "party" as const };
|
|
}
|
|
|
|
/** Helper to build enemy-side descriptors with CR. */
|
|
function enemy(cr: string) {
|
|
return { cr, side: "enemy" as const };
|
|
}
|
|
|
|
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 -> 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.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 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 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"),
|
|
],
|
|
"5.5e",
|
|
);
|
|
expect(result.tier).toBe(2);
|
|
expect(result.totalMonsterXp).toBe(1150);
|
|
expect(result.thresholds[1].value).toBe(1125);
|
|
});
|
|
|
|
it("returns tier 3 when XP meets High threshold", () => {
|
|
// 4x level 1: High = 400
|
|
// 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 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")],
|
|
"5.5e",
|
|
);
|
|
expect(result.thresholds).toEqual([
|
|
{ label: "Low", value: 550 },
|
|
{ label: "Moderate", value: 825 },
|
|
{ label: "High", value: 1400 },
|
|
]);
|
|
expect(result.totalMonsterXp).toBe(700);
|
|
expect(result.tier).toBe(1);
|
|
});
|
|
|
|
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 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.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"),
|
|
],
|
|
"5.5e",
|
|
);
|
|
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
|
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")],
|
|
"5.5e",
|
|
);
|
|
expect(result.totalMonsterXp).toBe(0);
|
|
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" },
|
|
],
|
|
"5.5e",
|
|
);
|
|
expect(result.totalMonsterXp).toBe(250);
|
|
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
|
|
],
|
|
"5.5e",
|
|
);
|
|
expect(result.totalMonsterXp).toBe(0);
|
|
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
|
|
],
|
|
"5.5e",
|
|
);
|
|
expect(result.thresholds).toEqual([
|
|
{ label: "Low", value: 50 },
|
|
{ label: "Moderate", value: 75 },
|
|
{ label: "High", value: 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" }, enemy("1")],
|
|
"5.5e",
|
|
);
|
|
// Only level 1 party contributes to budget
|
|
expect(result.thresholds).toEqual([
|
|
{ label: "Low", value: 50 },
|
|
{ label: "Moderate", value: 75 },
|
|
{ label: "High", value: 100 },
|
|
]);
|
|
expect(result.totalMonsterXp).toBe(200);
|
|
});
|
|
|
|
it("mixed sides calculate correctly", () => {
|
|
// 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")],
|
|
"5.5e",
|
|
);
|
|
expect(result.thresholds).toEqual([
|
|
{ label: "Low", value: 300 },
|
|
{ label: "Moderate", value: 450 },
|
|
{ label: "High", value: 800 },
|
|
]);
|
|
expect(result.totalMonsterXp).toBe(700);
|
|
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();
|
|
});
|
|
});
|