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(); }); });