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,11 +1,17 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyThreshold,
|
||||
DifficultyTier,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -21,6 +27,10 @@ export interface BreakdownCombatant {
|
||||
readonly editable: boolean;
|
||||
readonly side: "party" | "enemy";
|
||||
readonly level: number | undefined;
|
||||
/** PF2e only: the creature's level from bestiary data. */
|
||||
readonly creatureLevel: number | undefined;
|
||||
/** PF2e only: creature level minus party level. */
|
||||
readonly levelDifference: number | undefined;
|
||||
}
|
||||
|
||||
interface DifficultyBreakdown {
|
||||
@@ -30,6 +40,7 @@ interface DifficultyBreakdown {
|
||||
readonly encounterMultiplier: number | undefined;
|
||||
readonly adjustedXp: number | undefined;
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
readonly partyLevel: number | undefined;
|
||||
readonly pcCount: number;
|
||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||
@@ -48,9 +59,16 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
if (edition === "pf2e") {
|
||||
const hasCreatureLevel = descriptors.some(
|
||||
(d) => d.creatureLevel !== undefined,
|
||||
);
|
||||
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||
} else {
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
}
|
||||
|
||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||
|
||||
@@ -65,6 +83,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
|
||||
type CreatureInfo = {
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
source: string;
|
||||
sourceDisplayName: string;
|
||||
};
|
||||
@@ -74,6 +93,7 @@ function buildBreakdownEntry(
|
||||
side: "party" | "enemy",
|
||||
level: number | undefined,
|
||||
creature: CreatureInfo | undefined,
|
||||
partyLevel: number | undefined,
|
||||
): BreakdownCombatant {
|
||||
if (c.playerCharacterId) {
|
||||
return {
|
||||
@@ -84,6 +104,29 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
level,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
if (creature && creature.creatureLevel !== undefined) {
|
||||
const levelDiff =
|
||||
partyLevel === undefined
|
||||
? undefined
|
||||
: creature.creatureLevel - partyLevel;
|
||||
const xp =
|
||||
partyLevel === undefined
|
||||
? null
|
||||
: pf2eCreatureXp(creature.creatureLevel, partyLevel);
|
||||
return {
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp,
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: creature.creatureLevel,
|
||||
levelDifference: levelDiff,
|
||||
};
|
||||
}
|
||||
if (creature) {
|
||||
@@ -96,6 +139,8 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
if (c.cr) {
|
||||
@@ -107,6 +152,8 @@ function buildBreakdownEntry(
|
||||
editable: true,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -117,6 +164,8 @@ function buildBreakdownEntry(
|
||||
editable: !c.creatureId,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,41 +177,91 @@ function resolveLevel(
|
||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||
}
|
||||
|
||||
function resolveCr(
|
||||
function resolveCreatureInfo(
|
||||
c: Combatant,
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const cr = creature?.cr ?? c.cr ?? null;
|
||||
return { cr, creature };
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
): {
|
||||
cr: string | null;
|
||||
creatureLevel: number | undefined;
|
||||
creature: CreatureInfo | undefined;
|
||||
} {
|
||||
const rawCreature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
if (!rawCreature) {
|
||||
return {
|
||||
cr: c.cr ?? null,
|
||||
creatureLevel: undefined,
|
||||
creature: undefined,
|
||||
};
|
||||
}
|
||||
if ("system" in rawCreature && rawCreature.system === "pf2e") {
|
||||
return {
|
||||
cr: null,
|
||||
creatureLevel: rawCreature.level,
|
||||
creature: {
|
||||
creatureLevel: rawCreature.level,
|
||||
source: rawCreature.source,
|
||||
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
const cr = "cr" in rawCreature ? rawCreature.cr : undefined;
|
||||
return {
|
||||
cr: cr ?? c.cr ?? null,
|
||||
creatureLevel: undefined,
|
||||
creature: {
|
||||
cr,
|
||||
source: rawCreature.source,
|
||||
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectPartyLevel(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number | undefined {
|
||||
const partyLevels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (resolveSide(c) !== "party") continue;
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) partyLevels.push(level);
|
||||
}
|
||||
return partyLevels.length > 0 ? derivePartyLevel(partyLevels) : undefined;
|
||||
}
|
||||
|
||||
function classifyCombatants(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
) {
|
||||
const partyCombatants: BreakdownCombatant[] = [];
|
||||
const enemyCombatants: BreakdownCombatant[] = [];
|
||||
const descriptors: {
|
||||
level?: number;
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
side: "party" | "enemy";
|
||||
}[] = [];
|
||||
let pcCount = 0;
|
||||
const partyLevel = collectPartyLevel(combatants, characters);
|
||||
|
||||
for (const c of combatants) {
|
||||
const side = resolveSide(c);
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) pcCount++;
|
||||
|
||||
const { cr, creature } = resolveCr(c, getCreature);
|
||||
const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature);
|
||||
|
||||
if (level !== undefined || cr != null) {
|
||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||
if (level !== undefined || cr != null || creatureLevel !== undefined) {
|
||||
descriptors.push({
|
||||
level,
|
||||
cr: cr ?? undefined,
|
||||
creatureLevel,
|
||||
side,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = buildBreakdownEntry(c, side, level, creature);
|
||||
const entry = buildBreakdownEntry(c, side, level, creature, partyLevel);
|
||||
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||
target.push(entry);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user