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>
This commit is contained in:
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "../encounter-difficulty.js";
|
||||
|
||||
describe("crToXp", () => {
|
||||
@@ -386,3 +388,234 @@ describe("calculateEncounterDifficulty — 2014 edition", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user