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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RulesEdition } from "./rules-edition.js";
|
||||
|
||||
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3;
|
||||
/** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
export interface DifficultyThreshold {
|
||||
readonly label: string;
|
||||
@@ -18,6 +18,8 @@ export interface DifficultyResult {
|
||||
readonly adjustedXp: number | undefined;
|
||||
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
/** PF2e only: the derived party level used for XP calculation. */
|
||||
readonly partyLevel: number | undefined;
|
||||
}
|
||||
|
||||
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||
@@ -160,6 +162,133 @@ function getEncounterMultiplier(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PF2e: XP granted by a creature based on its level relative to party level.
|
||||
* Key is (creature level − party level), clamped to [−4, +4].
|
||||
*/
|
||||
const PF2E_LEVEL_DIFF_XP: Readonly<Record<number, number>> = {
|
||||
[-4]: 10,
|
||||
[-3]: 15,
|
||||
[-2]: 20,
|
||||
[-1]: 30,
|
||||
0: 40,
|
||||
1: 60,
|
||||
2: 80,
|
||||
3: 120,
|
||||
4: 160,
|
||||
};
|
||||
|
||||
/** PF2e base encounter budget thresholds for a party of 4. */
|
||||
const PF2E_THRESHOLDS_BASE = {
|
||||
trivial: 40,
|
||||
low: 60,
|
||||
moderate: 80,
|
||||
severe: 120,
|
||||
extreme: 160,
|
||||
} as const;
|
||||
|
||||
/** PF2e per-PC adjustment to each threshold (added per PC beyond 4, subtracted per PC fewer). */
|
||||
const PF2E_THRESHOLD_ADJUSTMENTS = {
|
||||
trivial: 10,
|
||||
low: 15,
|
||||
moderate: 20,
|
||||
severe: 30,
|
||||
extreme: 40,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Derives PF2e party level from PC levels.
|
||||
* Returns the mode (most common level). If no unique mode, returns
|
||||
* the average rounded to the nearest integer.
|
||||
*/
|
||||
export function derivePartyLevel(levels: readonly number[]): number {
|
||||
if (levels.length === 0) return 0;
|
||||
if (levels.length === 1) return levels[0];
|
||||
|
||||
const counts = new Map<number, number>();
|
||||
for (const l of levels) {
|
||||
counts.set(l, (counts.get(l) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let maxCount = 0;
|
||||
let mode: number | undefined;
|
||||
let isTied = false;
|
||||
|
||||
for (const [level, count] of counts) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
mode = level;
|
||||
isTied = false;
|
||||
} else if (count === maxCount) {
|
||||
isTied = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTied && mode !== undefined) return mode;
|
||||
|
||||
const sum = levels.reduce((a, b) => a + b, 0);
|
||||
return Math.round(sum / levels.length);
|
||||
}
|
||||
|
||||
/** Returns PF2e XP for a creature given its level and the party level. */
|
||||
export function pf2eCreatureXp(
|
||||
creatureLevel: number,
|
||||
partyLevel: number,
|
||||
): number {
|
||||
const diff = Math.max(-4, Math.min(4, creatureLevel - partyLevel));
|
||||
return PF2E_LEVEL_DIFF_XP[diff] ?? 0;
|
||||
}
|
||||
|
||||
function calculatePf2eBudget(partySize: number) {
|
||||
const adjustment = partySize - 4;
|
||||
return {
|
||||
trivial: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.trivial +
|
||||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.trivial,
|
||||
),
|
||||
low: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.low + adjustment * PF2E_THRESHOLD_ADJUSTMENTS.low,
|
||||
),
|
||||
moderate: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.moderate +
|
||||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.moderate,
|
||||
),
|
||||
severe: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.severe +
|
||||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.severe,
|
||||
),
|
||||
extreme: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.extreme +
|
||||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.extreme,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function scanCombatantsPf2e(
|
||||
combatants: readonly CombatantDescriptor[],
|
||||
partyLevel: number,
|
||||
) {
|
||||
let totalCreatureXp = 0;
|
||||
|
||||
for (const c of combatants) {
|
||||
if (c.creatureLevel !== undefined) {
|
||||
const xp = pf2eCreatureXp(c.creatureLevel, partyLevel);
|
||||
if (c.side === "enemy") {
|
||||
totalCreatureXp += xp;
|
||||
} else {
|
||||
totalCreatureXp -= xp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { totalCreatureXp: Math.max(0, totalCreatureXp) };
|
||||
}
|
||||
|
||||
/** All standard 5e challenge rating strings, in ascending order. */
|
||||
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
||||
|
||||
@@ -171,6 +300,7 @@ export function crToXp(cr: string): number {
|
||||
export interface CombatantDescriptor {
|
||||
readonly level?: number;
|
||||
readonly cr?: string;
|
||||
readonly creatureLevel?: number;
|
||||
readonly side: "party" | "enemy";
|
||||
}
|
||||
|
||||
@@ -247,6 +377,41 @@ export function calculateEncounterDifficulty(
|
||||
combatants: readonly CombatantDescriptor[],
|
||||
edition: RulesEdition,
|
||||
): DifficultyResult {
|
||||
if (edition === "pf2e") {
|
||||
const partyLevels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (c.level !== undefined && c.side === "party") {
|
||||
partyLevels.push(c.level);
|
||||
}
|
||||
}
|
||||
|
||||
const partyLevel = derivePartyLevel(partyLevels);
|
||||
const { totalCreatureXp } = scanCombatantsPf2e(combatants, partyLevel);
|
||||
const budget = calculatePf2eBudget(partyLevels.length);
|
||||
const thresholds: DifficultyThreshold[] = [
|
||||
{ label: "Trivial", value: budget.trivial },
|
||||
{ label: "Low", value: budget.low },
|
||||
{ label: "Moderate", value: budget.moderate },
|
||||
{ label: "Severe", value: budget.severe },
|
||||
{ label: "Extreme", value: budget.extreme },
|
||||
];
|
||||
|
||||
return {
|
||||
tier: determineTier(totalCreatureXp, [
|
||||
budget.low,
|
||||
budget.moderate,
|
||||
budget.severe,
|
||||
budget.extreme,
|
||||
]),
|
||||
totalMonsterXp: totalCreatureXp,
|
||||
thresholds,
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel,
|
||||
};
|
||||
}
|
||||
|
||||
const { totalMonsterXp, monsterCount, partyLevels } =
|
||||
scanCombatants(combatants);
|
||||
|
||||
@@ -268,6 +433,7 @@ export function calculateEncounterDifficulty(
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,5 +460,6 @@ export function calculateEncounterDifficulty(
|
||||
encounterMultiplier,
|
||||
adjustedXp,
|
||||
partySizeAdjusted,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export {
|
||||
type DifficultyResult,
|
||||
type DifficultyThreshold,
|
||||
type DifficultyTier,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
VALID_CR_VALUES,
|
||||
} from "./encounter-difficulty.js";
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user