Files
initiative/packages/domain/src/__tests__/encounter-difficulty.test.ts
T
Lukas d9fb271607
CI / check (push) Successful in 2m39s
CI / build-image (push) Successful in 18s
Add PF2e encounter difficulty calculation with 5-tier budget system
Implements PF2e encounter difficulty alongside the existing D&D system.
PF2e uses creature level vs party level to derive XP, compares against
5-tier budgets (Trivial/Low/Moderate/Severe/Extreme), and adjusts
thresholds for party size. The indicator shows 4 bars in PF2e mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:24:18 +02:00

622 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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();
});
});
/** 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("clamps level diff below 4 to 4 (10 XP)", () => {
expect(pf2eCreatureXp(0, 10)).toBe(10);
});
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, below Low (60)
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
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 tier 0 when XP is below Low threshold", () => {
// 1 creature at 4 = 10 XP, Low = 60
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("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: 75 },
{ 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: 45 },
{ 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 * 15) = 15
expect(result.thresholds[0].value).toBe(10);
expect(result.thresholds[1].value).toBe(15);
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();
});
});