The 2024 DMG and PF2e GM Core define XP budgets as ceilings ("spend as
much of your XP budget as you can without going over"), not floors like
the 2014 DMG thresholds. The tier is now the lowest budget the XP does
not exceed: an encounter over the Moderate budget is High even if below
the High budget. PF2e now also uses the Trivial budget (40 or less),
GM Core remaster party-size adjustments (Low +20), and counts creatures
more than 4 levels below the party as 0 XP. 2014 rules are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
682 lines
19 KiB
TypeScript
682 lines
19 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
calculateEncounterDifficulty,
|
||
crToXp,
|
||
derivePartyLevel,
|
||
pf2eCreatureXp,
|
||
} 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 0", () => {
|
||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||
// 1x CR 0 = 0 XP -> tier 0 (5.5e has no Trivial tier; 0 bars only at 0 XP)
|
||
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 when XP spends exactly the Moderate budget", () => {
|
||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||
// CR 4 (1100) + CR 1/8 (25) = 1125 XP = Moderate budget
|
||
const result = calculateEncounterDifficulty(
|
||
[
|
||
party(3),
|
||
party(3),
|
||
party(3),
|
||
party(3),
|
||
party(3),
|
||
enemy("4"),
|
||
enemy("1/8"),
|
||
],
|
||
"5.5e",
|
||
);
|
||
expect(result.tier).toBe(2);
|
||
expect(result.totalMonsterXp).toBe(1125);
|
||
expect(result.thresholds[1].value).toBe(1125);
|
||
});
|
||
|
||
it("returns tier 3 when XP exceeds the Moderate budget", () => {
|
||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||
// CR 3 (700) + CR 2 (450) = 1150 XP > 1125 Moderate → High
|
||
const result = calculateEncounterDifficulty(
|
||
[
|
||
party(3),
|
||
party(3),
|
||
party(3),
|
||
party(3),
|
||
party(3),
|
||
enemy("3"),
|
||
enemy("2"),
|
||
],
|
||
"5.5e",
|
||
);
|
||
expect(result.tier).toBe(3);
|
||
expect(result.totalMonsterXp).toBe(1150);
|
||
});
|
||
|
||
it("returns tier 3 for 5x level 5 vs 5000 XP (over Moderate budget)", () => {
|
||
// 5x level 5: Low = 2500, Moderate = 3750, High = 5500
|
||
// CR 9 = 5000 XP > 3750 Moderate → High, even though below the High budget
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), party(5), enemy("9")],
|
||
"5.5e",
|
||
);
|
||
expect(result.tier).toBe(3);
|
||
expect(result.totalMonsterXp).toBe(5000);
|
||
});
|
||
|
||
it("returns tier 3 when XP exceeds the Moderate budget", () => {
|
||
// 4x level 1: Moderate = 300
|
||
// 2x CR 1 = 400 XP > 300 -> 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
|
||
// 700 XP > 550 Low, ≤ 825 Moderate → Moderate
|
||
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(2);
|
||
});
|
||
|
||
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(1); // 175 ≤ 200 Low budget → 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(2); // 250 > 200 Low budget, ≤ 300 Moderate → 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(3); // 700 > 450 Moderate budget → 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();
|
||
});
|
||
});
|
||
|
||
/** Helper to build a PF2e enemy-side descriptor with creature level. */
|
||
function pf2eEnemy(creatureLevel: number) {
|
||
return { creatureLevel, side: "enemy" as const };
|
||
}
|
||
|
||
/** Helper to build a PF2e party-side creature descriptor. */
|
||
function pf2eAlly(creatureLevel: number) {
|
||
return { creatureLevel, side: "party" as const };
|
||
}
|
||
|
||
describe("derivePartyLevel", () => {
|
||
it("returns 0 for empty array", () => {
|
||
expect(derivePartyLevel([])).toBe(0);
|
||
});
|
||
|
||
it("returns the level for a single PC", () => {
|
||
expect(derivePartyLevel([7])).toBe(7);
|
||
});
|
||
|
||
it("returns the unanimous level", () => {
|
||
expect(derivePartyLevel([5, 5, 5, 5])).toBe(5);
|
||
});
|
||
|
||
it("returns the mode when one level is most common", () => {
|
||
expect(derivePartyLevel([3, 3, 3, 5])).toBe(3);
|
||
});
|
||
|
||
it("returns rounded average when mode is tied", () => {
|
||
// 3,3,5,5 → average 4
|
||
expect(derivePartyLevel([3, 3, 5, 5])).toBe(4);
|
||
});
|
||
|
||
it("returns rounded average when all levels are different", () => {
|
||
// 2,4,6,8 → average 5
|
||
expect(derivePartyLevel([2, 4, 6, 8])).toBe(5);
|
||
});
|
||
|
||
it("rounds average to nearest integer", () => {
|
||
// 1,2 → average 1.5 → rounds to 2
|
||
expect(derivePartyLevel([1, 2])).toBe(2);
|
||
});
|
||
});
|
||
|
||
describe("pf2eCreatureXp", () => {
|
||
it.each([
|
||
[-4, 10],
|
||
[-3, 15],
|
||
[-2, 20],
|
||
[-1, 30],
|
||
[0, 40],
|
||
[1, 60],
|
||
[2, 80],
|
||
[3, 120],
|
||
[4, 160],
|
||
])("level diff %i returns %i XP", (diff, expectedXp) => {
|
||
// partyLevel 5, creatureLevel = 5 + diff
|
||
expect(pf2eCreatureXp(5 + diff, 5)).toBe(expectedXp);
|
||
});
|
||
|
||
it("returns 0 XP for creatures more than 4 levels below the party", () => {
|
||
expect(pf2eCreatureXp(0, 10)).toBe(0);
|
||
});
|
||
|
||
it("clamps level diff above +4 to +4 (160 XP)", () => {
|
||
expect(pf2eCreatureXp(15, 5)).toBe(160);
|
||
});
|
||
});
|
||
|
||
describe("calculateEncounterDifficulty — pf2e edition", () => {
|
||
it("returns Trivial (tier 0) for 40 XP with party of 4", () => {
|
||
// 1 creature at party level = 40 XP, within the Trivial band (40 or less)
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||
"pf2e",
|
||
);
|
||
expect(result.tier).toBe(0);
|
||
expect(result.totalMonsterXp).toBe(40);
|
||
expect(result.partyLevel).toBe(5);
|
||
expect(result.thresholds).toEqual([
|
||
{ label: "Trivial", value: 40 },
|
||
{ label: "Low", value: 60 },
|
||
{ label: "Moderate", value: 80 },
|
||
{ label: "Severe", value: 120 },
|
||
{ label: "Extreme", value: 160 },
|
||
]);
|
||
});
|
||
|
||
it("returns Low (tier 1) for 60 XP", () => {
|
||
// 1 creature at party level +1 = 60 XP
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(6)],
|
||
"pf2e",
|
||
);
|
||
expect(result.tier).toBe(1);
|
||
expect(result.totalMonsterXp).toBe(60);
|
||
});
|
||
|
||
it("returns Moderate (tier 2) for 80 XP", () => {
|
||
// 1 creature at +2 = 80 XP
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(7)],
|
||
"pf2e",
|
||
);
|
||
expect(result.tier).toBe(2);
|
||
expect(result.totalMonsterXp).toBe(80);
|
||
});
|
||
|
||
it("returns Severe (tier 3) for 120 XP", () => {
|
||
// 1 creature at +3 = 120 XP
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(8)],
|
||
"pf2e",
|
||
);
|
||
expect(result.tier).toBe(3);
|
||
expect(result.totalMonsterXp).toBe(120);
|
||
});
|
||
|
||
it("returns Extreme (tier 4) for 160 XP", () => {
|
||
// 1 creature at +4 = 160 XP > Severe budget (120)
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(9)],
|
||
"pf2e",
|
||
);
|
||
expect(result.tier).toBe(4);
|
||
expect(result.totalMonsterXp).toBe(160);
|
||
});
|
||
|
||
it("returns Moderate (tier 2) for XP between the Low and Moderate budgets", () => {
|
||
// +1 (60) + −4 (10) = 70 XP > Low (60), ≤ Moderate (80)
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(6), pf2eEnemy(1)],
|
||
"pf2e",
|
||
);
|
||
expect(result.tier).toBe(2);
|
||
expect(result.totalMonsterXp).toBe(70);
|
||
});
|
||
|
||
it("returns Extreme (tier 4) for XP just over the Severe budget", () => {
|
||
// +3 (120) + −4 (10) = 130 XP > Severe (120)
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(8), pf2eEnemy(1)],
|
||
"pf2e",
|
||
);
|
||
expect(result.tier).toBe(4);
|
||
expect(result.totalMonsterXp).toBe(130);
|
||
});
|
||
|
||
it("returns tier 0 when XP is within the Trivial band", () => {
|
||
// 1 creature at −4 = 10 XP ≤ Trivial (40)
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(1)],
|
||
"pf2e",
|
||
);
|
||
expect(result.tier).toBe(0);
|
||
expect(result.totalMonsterXp).toBe(10);
|
||
});
|
||
|
||
it("counts creatures more than 4 levels below the party as 0 XP", () => {
|
||
// Party level 6: a level-1 creature is 5 below → 0 XP
|
||
const result = calculateEncounterDifficulty(
|
||
[party(6), party(6), party(6), party(6), pf2eEnemy(1), pf2eEnemy(6)],
|
||
"pf2e",
|
||
);
|
||
expect(result.totalMonsterXp).toBe(40); // only the at-level creature counts
|
||
});
|
||
|
||
it("adjusts thresholds for 5 PCs (increases by adjustment)", () => {
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||
"pf2e",
|
||
);
|
||
expect(result.thresholds).toEqual([
|
||
{ label: "Trivial", value: 50 },
|
||
{ label: "Low", value: 80 },
|
||
{ label: "Moderate", value: 100 },
|
||
{ label: "Severe", value: 150 },
|
||
{ label: "Extreme", value: 200 },
|
||
]);
|
||
});
|
||
|
||
it("adjusts thresholds for 3 PCs (decreases by adjustment)", () => {
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), pf2eEnemy(5)],
|
||
"pf2e",
|
||
);
|
||
expect(result.thresholds).toEqual([
|
||
{ label: "Trivial", value: 30 },
|
||
{ label: "Low", value: 40 },
|
||
{ label: "Moderate", value: 60 },
|
||
{ label: "Severe", value: 90 },
|
||
{ label: "Extreme", value: 120 },
|
||
]);
|
||
});
|
||
|
||
it("floors thresholds at 0 for very small parties", () => {
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), pf2eEnemy(5)],
|
||
"pf2e",
|
||
);
|
||
// 1 PC: adjustment = −3
|
||
// Trivial: 40 + (−3 * 10) = 10
|
||
// Low: 60 + (−3 * 20) = 0
|
||
expect(result.thresholds[0].value).toBe(10);
|
||
expect(result.thresholds[1].value).toBe(0);
|
||
expect(result.thresholds[2].value).toBe(20); // 80 − 60
|
||
expect(result.thresholds[3].value).toBe(30); // 120 − 90
|
||
expect(result.thresholds[4].value).toBe(40); // 160 − 120
|
||
});
|
||
|
||
it("subtracts XP for party-side creatures", () => {
|
||
// 2 enemies at party level = 80 XP, 1 ally at party level = 40 XP
|
||
// Net = 80 − 40 = 40 XP
|
||
const result = calculateEncounterDifficulty(
|
||
[
|
||
party(5),
|
||
party(5),
|
||
party(5),
|
||
party(5),
|
||
pf2eEnemy(5),
|
||
pf2eEnemy(5),
|
||
pf2eAlly(5),
|
||
],
|
||
"pf2e",
|
||
);
|
||
expect(result.totalMonsterXp).toBe(40);
|
||
});
|
||
|
||
it("floors net creature XP at 0", () => {
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(1), pf2eAlly(9)],
|
||
"pf2e",
|
||
);
|
||
expect(result.totalMonsterXp).toBe(0);
|
||
});
|
||
|
||
it("derives party level using mode", () => {
|
||
// 3x level 3, 1x level 5 → mode is 3
|
||
const result = calculateEncounterDifficulty(
|
||
[party(3), party(3), party(3), party(5), pf2eEnemy(3)],
|
||
"pf2e",
|
||
);
|
||
expect(result.partyLevel).toBe(3);
|
||
});
|
||
|
||
it("has no encounterMultiplier, adjustedXp, or partySizeAdjusted", () => {
|
||
const result = calculateEncounterDifficulty(
|
||
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||
"pf2e",
|
||
);
|
||
expect(result.encounterMultiplier).toBeUndefined();
|
||
expect(result.adjustedXp).toBeUndefined();
|
||
expect(result.partySizeAdjusted).toBeUndefined();
|
||
});
|
||
|
||
it("returns partyLevel undefined for D&D editions", () => {
|
||
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
|
||
expect(result.partyLevel).toBeUndefined();
|
||
});
|
||
});
|