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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user