diff --git a/apps/web/src/__tests__/factories/build-pf2e-creature.ts b/apps/web/src/__tests__/factories/build-pf2e-creature.ts new file mode 100644 index 0000000..5255241 --- /dev/null +++ b/apps/web/src/__tests__/factories/build-pf2e-creature.ts @@ -0,0 +1,28 @@ +import type { Pf2eCreature } from "@initiative/domain"; +import { creatureId } from "@initiative/domain"; + +let counter = 0; + +export function buildPf2eCreature( + overrides?: Partial, +): Pf2eCreature { + const id = ++counter; + return { + system: "pf2e", + id: creatureId(`pf2e-creature-${id}`), + name: `PF2e Creature ${id}`, + source: "crb", + sourceDisplayName: "Core Rulebook", + level: 1, + traits: ["humanoid"], + perception: 5, + abilityMods: { str: 2, dex: 1, con: 2, int: 0, wis: 1, cha: -1 }, + ac: 15, + saveFort: 7, + saveRef: 4, + saveWill: 5, + hp: 20, + speed: "25 ft.", + ...overrides, + }; +} diff --git a/apps/web/src/__tests__/factories/index.ts b/apps/web/src/__tests__/factories/index.ts index edc9be0..2b5d189 100644 --- a/apps/web/src/__tests__/factories/index.ts +++ b/apps/web/src/__tests__/factories/index.ts @@ -1,3 +1,4 @@ export { buildCombatant } from "./build-combatant.js"; export { buildCreature } from "./build-creature.js"; export { buildEncounter } from "./build-encounter.js"; +export { buildPf2eCreature } from "./build-pf2e-creature.js"; diff --git a/apps/web/src/components/__tests__/difficulty-breakdown-panel.test.tsx b/apps/web/src/components/__tests__/difficulty-breakdown-panel.test.tsx index ac3c1b0..21f10f4 100644 --- a/apps/web/src/components/__tests__/difficulty-breakdown-panel.test.tsx +++ b/apps/web/src/components/__tests__/difficulty-breakdown-panel.test.tsx @@ -1,7 +1,11 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; +import type { + AnyCreature, + CreatureId, + PlayerCharacter, +} from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { cleanup, @@ -17,6 +21,7 @@ import { buildCombatant, buildCreature, buildEncounter, + buildPf2eCreature, } from "../../__tests__/factories/index.js"; import { AllProviders } from "../../__tests__/test-providers.js"; import { useRulesEdition } from "../../hooks/use-rules-edition.js"; @@ -52,7 +57,7 @@ const goblinCreature = buildCreature({ function renderPanel(options: { encounter: ReturnType; playerCharacters?: PlayerCharacter[]; - creatures?: Map; + creatures?: Map; onClose?: () => void; }) { const adapters = createTestAdapters({ @@ -357,4 +362,157 @@ describe("DifficultyBreakdownPanel", () => { expect(onClose).toHaveBeenCalledOnce(); }); + + describe("PF2e edition", () => { + const orcWarrior = buildPf2eCreature({ + id: creatureId("pf2e:orc-warrior"), + name: "Orc Warrior", + level: 3, + source: "crb", + sourceDisplayName: "Core Rulebook", + }); + + function pf2eEncounter() { + return buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Orc Warrior", + creatureId: orcWarrior.id, + }), + ], + }); + } + + it("shows PF2e tier label", async () => { + const { result: editionResult } = renderHook(() => useRulesEdition()); + editionResult.current.setEdition("pf2e"); + + try { + renderPanel({ + encounter: pf2eEncounter(), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[orcWarrior.id, orcWarrior]]), + }); + + await waitFor(() => { + expect( + screen.getByText("Encounter Difficulty:", { exact: false }), + ).toBeInTheDocument(); + }); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + + it("shows party level", async () => { + const { result: editionResult } = renderHook(() => useRulesEdition()); + editionResult.current.setEdition("pf2e"); + + try { + renderPanel({ + encounter: pf2eEncounter(), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[orcWarrior.id, orcWarrior]]), + }); + + await waitFor(() => { + expect( + screen.getByText("Party Level: 5", { exact: false }), + ).toBeInTheDocument(); + }); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + + it("shows creature level and level difference", async () => { + const { result: editionResult } = renderHook(() => useRulesEdition()); + editionResult.current.setEdition("pf2e"); + + try { + renderPanel({ + encounter: pf2eEncounter(), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[orcWarrior.id, orcWarrior]]), + }); + + await waitFor(() => { + // Orc Warrior level 3, party level 5 → diff −2 + expect( + screen.getByText("Lv 3 (-2)", { exact: false }), + ).toBeInTheDocument(); + }); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + + it("shows 5 thresholds with short labels", async () => { + const { result: editionResult } = renderHook(() => useRulesEdition()); + editionResult.current.setEdition("pf2e"); + + try { + renderPanel({ + encounter: pf2eEncounter(), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[orcWarrior.id, orcWarrior]]), + }); + + await waitFor(() => { + expect( + screen.getByText("Triv:", { exact: false }), + ).toBeInTheDocument(); + expect( + screen.getByText("Low:", { exact: false }), + ).toBeInTheDocument(); + expect( + screen.getByText("Mod:", { exact: false }), + ).toBeInTheDocument(); + expect( + screen.getByText("Sev:", { exact: false }), + ).toBeInTheDocument(); + expect( + screen.getByText("Ext:", { exact: false }), + ).toBeInTheDocument(); + }); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + + it("shows Net Creature XP label in PF2e mode", async () => { + const { result: editionResult } = renderHook(() => useRulesEdition()); + editionResult.current.setEdition("pf2e"); + + try { + renderPanel({ + encounter: pf2eEncounter(), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[orcWarrior.id, orcWarrior]]), + }); + + await waitFor(() => { + expect(screen.getByText("Net Creature XP")).toBeInTheDocument(); + }); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + }); }); diff --git a/apps/web/src/components/__tests__/difficulty-indicator.test.tsx b/apps/web/src/components/__tests__/difficulty-indicator.test.tsx index b55da83..9d55415 100644 --- a/apps/web/src/components/__tests__/difficulty-indicator.test.tsx +++ b/apps/web/src/components/__tests__/difficulty-indicator.test.tsx @@ -7,6 +7,7 @@ import { DifficultyIndicator, TIER_LABELS_5_5E, TIER_LABELS_2014, + TIER_LABELS_PF2E, } from "../difficulty-indicator.js"; afterEach(cleanup); @@ -23,6 +24,7 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult { encounterMultiplier: undefined, adjustedXp: undefined, partySizeAdjusted: undefined, + partyLevel: undefined, }; } @@ -125,4 +127,64 @@ describe("DifficultyIndicator", () => { const element = container.querySelector("[role='img']"); expect(element?.tagName).toBe("BUTTON"); }); + + it("renders 4 bars when barCount is 4", () => { + const { container } = render( + , + ); + const bars = container.querySelectorAll("[class*='rounded-sm']"); + expect(bars).toHaveLength(4); + }); + + it("shows 0 filled bars for tier 0 with 4 bars", () => { + const { container } = render( + , + ); + const bars = container.querySelectorAll("[class*='rounded-sm']"); + for (const bar of bars) { + expect(bar.className).toContain("bg-muted"); + } + }); + + it("shows correct PF2e tooltip for Severe tier", () => { + render( + , + ); + expect( + screen.getByRole("img", { name: "Severe encounter difficulty" }), + ).toBeDefined(); + }); + + it("shows correct PF2e tooltip for Extreme tier", () => { + render( + , + ); + expect( + screen.getByRole("img", { name: "Extreme encounter difficulty" }), + ).toBeDefined(); + }); + + it("D&D indicator still renders 3 bars (no regression)", () => { + const { container } = render( + , + ); + const bars = container.querySelectorAll("[class*='rounded-sm']"); + expect(bars).toHaveLength(3); + }); }); diff --git a/apps/web/src/components/difficulty-breakdown-panel.tsx b/apps/web/src/components/difficulty-breakdown-panel.tsx index 494cca4..16c27bc 100644 --- a/apps/web/src/components/difficulty-breakdown-panel.tsx +++ b/apps/web/src/components/difficulty-breakdown-panel.tsx @@ -19,12 +19,21 @@ const TIER_LABEL_MAP: Partial< 1: { label: "Low", color: "text-green-500" }, 2: { label: "Moderate", color: "text-yellow-500" }, 3: { label: "High", color: "text-red-500" }, + 4: { label: "High", color: "text-red-500" }, }, "5e": { 0: { label: "Easy", color: "text-muted-foreground" }, 1: { label: "Medium", color: "text-green-500" }, 2: { label: "Hard", color: "text-yellow-500" }, 3: { label: "Deadly", color: "text-red-500" }, + 4: { label: "Deadly", color: "text-red-500" }, + }, + pf2e: { + 0: { label: "Trivial", color: "text-muted-foreground" }, + 1: { label: "Low", color: "text-green-500" }, + 2: { label: "Moderate", color: "text-yellow-500" }, + 3: { label: "Severe", color: "text-orange-500" }, + 4: { label: "Extreme", color: "text-red-500" }, }, }; @@ -32,6 +41,9 @@ const TIER_LABEL_MAP: Partial< const SHORT_LABELS: Readonly> = { Moderate: "Mod", Medium: "Med", + Trivial: "Triv", + Severe: "Sev", + Extreme: "Ext", }; function shortLabel(label: string): string { @@ -107,6 +119,54 @@ function NpcRow({ ); } +function Pf2eNpcRow({ + entry, + onToggleSide, +}: { + entry: BreakdownCombatant; + onToggleSide: () => void; +}) { + const isParty = entry.side === "party"; + const targetSide = isParty ? "enemy" : "party"; + + let xpDisplay: string; + if (entry.xp == null) { + xpDisplay = "\u2014"; + } else if (isParty) { + xpDisplay = `\u2212${formatXp(entry.xp)}`; + } else { + xpDisplay = formatXp(entry.xp); + } + + let levelDisplay: string; + if (entry.creatureLevel === undefined) { + levelDisplay = "\u2014"; + } else if (entry.levelDifference === undefined) { + levelDisplay = `Lv ${entry.creatureLevel}`; + } else { + const sign = entry.levelDifference >= 0 ? "+" : ""; + levelDisplay = `Lv ${entry.creatureLevel} (${sign}${entry.levelDifference})`; + } + + return ( +
+ + {entry.combatant.name} + + + {levelDisplay} + {xpDisplay} +
+ ); +} + export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) { const ref = useRef(null); useClickOutside(ref, onClose); @@ -128,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) { const isPC = (entry: BreakdownCombatant) => entry.combatant.playerCharacterId != null; + const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow; + return (
void }) {
Party Budget ({breakdown.pcCount}{" "} {breakdown.pcCount === 1 ? "PC" : "PCs"}) + {breakdown.partyLevel !== undefined && ( + <> · Party Level: {breakdown.partyLevel} + )}
{breakdown.thresholds.map((t) => ( @@ -166,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) { isPC(entry) ? ( ) : ( - handleToggle(entry)} @@ -186,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) { isPC(entry) ? ( ) : ( - handleToggle(entry)} @@ -218,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
) : (
- Net Monster XP + + {edition === "pf2e" ? "Net Creature XP" : "Net Monster XP"} + {formatXp(breakdown.totalMonsterXp)} diff --git a/apps/web/src/components/difficulty-indicator.tsx b/apps/web/src/components/difficulty-indicator.tsx index c19c58c..47b87d9 100644 --- a/apps/web/src/components/difficulty-indicator.tsx +++ b/apps/web/src/components/difficulty-indicator.tsx @@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record = { 1: "Low", 2: "Moderate", 3: "High", + 4: "High", }; export const TIER_LABELS_2014: Record = { @@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record = { 1: "Medium", 2: "Hard", 3: "Deadly", + 4: "Deadly", }; -const TIER_COLORS: Record< - DifficultyTier, - { filledBars: number; color: string } -> = { - 0: { filledBars: 0, color: "" }, - 1: { filledBars: 1, color: "bg-green-500" }, - 2: { filledBars: 2, color: "bg-yellow-500" }, - 3: { filledBars: 3, color: "bg-red-500" }, +export const TIER_LABELS_PF2E: Record = { + 0: "Trivial", + 1: "Low", + 2: "Moderate", + 3: "Severe", + 4: "Extreme", }; -const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const; +const BAR_HEIGHTS_3 = ["h-2", "h-3", "h-4"] as const; +const BAR_HEIGHTS_4 = ["h-1.5", "h-2", "h-3", "h-4"] as const; + +/** Color for the Nth filled bar (1-indexed) in 4-bar mode. */ +const BAR_COLORS: Record = { + 1: "bg-green-500", + 2: "bg-yellow-500", + 3: "bg-orange-500", + 4: "bg-red-500", +}; + +/** For 3-bar mode, bar 3 uses red directly (skip orange). */ +const BAR_COLORS_3: Record = { + 1: "bg-green-500", + 2: "bg-yellow-500", + 3: "bg-red-500", +}; export function DifficultyIndicator({ result, labels, + barCount = 3, onClick, }: { result: DifficultyResult; labels: Record; + barCount?: 3 | 4; onClick?: () => void; }) { - const config = TIER_COLORS[result.tier]; + const barHeights = barCount === 4 ? BAR_HEIGHTS_4 : BAR_HEIGHTS_3; + const colorMap = barCount === 4 ? BAR_COLORS : BAR_COLORS_3; + const filledBars = result.tier; const label = labels[result.tier]; const tooltip = `${label} encounter difficulty`; @@ -54,13 +74,13 @@ export function DifficultyIndicator({ onClick={onClick} type={onClick ? "button" : undefined} > - {BAR_HEIGHTS.map((height, i) => ( + {barHeights.map((height, i) => (
))} diff --git a/apps/web/src/components/turn-navigation.tsx b/apps/web/src/components/turn-navigation.tsx index 30ad429..623416f 100644 --- a/apps/web/src/components/turn-navigation.tsx +++ b/apps/web/src/components/turn-navigation.tsx @@ -8,6 +8,7 @@ import { DifficultyIndicator, TIER_LABELS_5_5E, TIER_LABELS_2014, + TIER_LABELS_PF2E, } from "./difficulty-indicator.js"; import { Button } from "./ui/button.js"; import { ConfirmButton } from "./ui/confirm-button.js"; @@ -26,7 +27,13 @@ export function TurnNavigation() { const difficulty = useDifficulty(); const { edition } = useRulesEditionContext(); - const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E; + const TIER_LABELS_BY_EDITION = { + pf2e: TIER_LABELS_PF2E, + "5e": TIER_LABELS_2014, + "5.5e": TIER_LABELS_5_5E, + } as const; + const tierLabels = TIER_LABELS_BY_EDITION[edition]; + const barCount = edition === "pf2e" ? 4 : 3; const [showBreakdown, setShowBreakdown] = useState(false); const hasCombatants = encounter.combatants.length > 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; @@ -87,6 +94,7 @@ export function TurnNavigation() { setShowBreakdown((prev) => !prev)} /> {showBreakdown ? ( diff --git a/apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx b/apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx index 27ef38a..7fe27e5 100644 --- a/apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx +++ b/apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx @@ -1,5 +1,9 @@ // @vitest-environment jsdom -import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; +import type { + AnyCreature, + CreatureId, + PlayerCharacter, +} from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; @@ -9,6 +13,7 @@ import { buildCombatant, buildCreature, buildEncounter, + buildPf2eCreature, } from "../../__tests__/factories/index.js"; import { AllProviders } from "../../__tests__/test-providers.js"; import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js"; @@ -42,7 +47,7 @@ const goblinCreature = buildCreature({ function makeWrapper(options: { encounter: ReturnType; playerCharacters?: PlayerCharacter[]; - creatures?: Map; + creatures?: Map; }) { const adapters = createTestAdapters({ encounter: options.encounter, @@ -345,4 +350,115 @@ describe("useDifficultyBreakdown", () => { editionResult.current.setEdition("5.5e"); } }); + + describe("PF2e edition", () => { + const orcWarrior = buildPf2eCreature({ + id: creatureId("pf2e:orc-warrior"), + name: "Orc Warrior", + level: 3, + source: "crb", + sourceDisplayName: "Core Rulebook", + }); + + it("returns breakdown with creatureLevel, levelDifference, and XP for PF2e creatures", async () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Orc Warrior", + creatureId: orcWarrior.id, + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[orcWarrior.id, orcWarrior]]), + }); + + const { result: editionResult } = renderHook(() => useRulesEdition(), { + wrapper, + }); + editionResult.current.setEdition("pf2e"); + + try { + const { result } = renderHook(() => useDifficultyBreakdown(), { + wrapper, + }); + + await waitFor(() => { + const breakdown = result.current; + expect(breakdown).not.toBeNull(); + + // Party level should be 5 + expect(breakdown?.partyLevel).toBe(5); + + // Orc Warrior: level 3, party level 5 → diff −2 → 20 XP + const orc = breakdown?.enemyCombatants[0]; + expect(orc?.creatureLevel).toBe(3); + expect(orc?.levelDifference).toBe(-2); + expect(orc?.xp).toBe(20); + expect(orc?.cr).toBeNull(); + expect(orc?.source).toBe("Core Rulebook"); + + // PC should have no creature level + const pc = breakdown?.partyCombatants[0]; + expect(pc?.creatureLevel).toBeUndefined(); + expect(pc?.levelDifference).toBeUndefined(); + }); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + + it("returns partyLevel in result", async () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Orc Warrior", + creatureId: orcWarrior.id, + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[orcWarrior.id, orcWarrior]]), + }); + + const { result: editionResult } = renderHook(() => useRulesEdition(), { + wrapper, + }); + editionResult.current.setEdition("pf2e"); + + try { + const { result } = renderHook(() => useDifficultyBreakdown(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).not.toBeNull(); + expect(result.current?.partyLevel).toBe(5); + // 5 thresholds for PF2e + expect(result.current?.thresholds).toHaveLength(5); + expect(result.current?.thresholds[0].label).toBe("Trivial"); + }); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + }); }); diff --git a/apps/web/src/hooks/__tests__/use-difficulty.test.tsx b/apps/web/src/hooks/__tests__/use-difficulty.test.tsx index fe9493f..e4c973c 100644 --- a/apps/web/src/hooks/__tests__/use-difficulty.test.tsx +++ b/apps/web/src/hooks/__tests__/use-difficulty.test.tsx @@ -1,5 +1,9 @@ // @vitest-environment jsdom -import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; +import type { + AnyCreature, + CreatureId, + PlayerCharacter, +} from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; @@ -9,6 +13,7 @@ import { buildCombatant, buildCreature, buildEncounter, + buildPf2eCreature, } from "../../__tests__/factories/index.js"; import { AllProviders } from "../../__tests__/test-providers.js"; import { useDifficulty } from "../use-difficulty.js"; @@ -43,7 +48,7 @@ const goblinCreature = buildCreature({ function makeWrapper(options: { encounter: ReturnType; playerCharacters?: PlayerCharacter[]; - creatures?: Map; + creatures?: Map; }) { const adapters = createTestAdapters({ encounter: options.encounter, @@ -424,4 +429,134 @@ describe("useDifficulty", () => { expect(result.current?.totalMonsterXp).toBe(0); }); }); + + describe("PF2e edition", () => { + const pf2eCreature = buildPf2eCreature({ + id: creatureId("pf2e:orc-warrior"), + name: "Orc Warrior", + level: 5, + }); + + function makePf2eWrapper(options: { + encounter: ReturnType; + playerCharacters?: PlayerCharacter[]; + creatures?: Map; + }) { + const adapters = createTestAdapters({ + encounter: options.encounter, + playerCharacters: options.playerCharacters ?? [], + creatures: options.creatures, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); + } + + it("returns result for PF2e with leveled PCs and PF2e creatures", async () => { + const wrapper = makePf2eWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c2"), + name: "Orc Warrior", + creatureId: pf2eCreature.id, + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[pf2eCreature.id, pf2eCreature]]), + }); + + const { result: editionResult } = renderHook(() => useRulesEdition(), { + wrapper, + }); + editionResult.current.setEdition("pf2e"); + + try { + const { result } = renderHook(() => useDifficulty(), { wrapper }); + + await waitFor(() => { + expect(result.current).not.toBeNull(); + // Creature level 5, party level 5 → diff 0 → 40 XP + expect(result.current?.totalMonsterXp).toBe(40); + expect(result.current?.partyLevel).toBe(5); + }); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + + it("returns null for PF2e when no PF2e creatures with level", () => { + const wrapper = makePf2eWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c2"), + name: "Custom Monster", + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + }); + + const { result: editionResult } = renderHook(() => useRulesEdition(), { + wrapper, + }); + editionResult.current.setEdition("pf2e"); + + try { + const { result } = renderHook(() => useDifficulty(), { wrapper }); + expect(result.current).toBeNull(); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + + it("returns null for PF2e when no PCs with level", () => { + const wrapper = makePf2eWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c2"), + name: "Orc Warrior", + creatureId: pf2eCreature.id, + }), + ], + }), + playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }], + creatures: new Map([[pf2eCreature.id, pf2eCreature]]), + }); + + const { result: editionResult } = renderHook(() => useRulesEdition(), { + wrapper, + }); + editionResult.current.setEdition("pf2e"); + + try { + const { result } = renderHook(() => useDifficulty(), { wrapper }); + expect(result.current).toBeNull(); + } finally { + editionResult.current.setEdition("5.5e"); + } + }); + }); }); diff --git a/apps/web/src/hooks/use-difficulty-breakdown.ts b/apps/web/src/hooks/use-difficulty-breakdown.ts index cb0d18c..b5450ce 100644 --- a/apps/web/src/hooks/use-difficulty-breakdown.ts +++ b/apps/web/src/hooks/use-difficulty-breakdown.ts @@ -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); } diff --git a/apps/web/src/hooks/use-difficulty.ts b/apps/web/src/hooks/use-difficulty.ts index 678d43d..5db1f70 100644 --- a/apps/web/src/hooks/use-difficulty.ts +++ b/apps/web/src/hooks/use-difficulty.ts @@ -33,9 +33,17 @@ function buildDescriptors( const creatureCr = creature && !("system" in creature) ? creature.cr : undefined; const cr = creatureCr ?? c.cr ?? undefined; + const creatureLevel = + creature && "system" in creature && creature.system === "pf2e" + ? creature.level + : undefined; - if (level !== undefined || cr !== undefined) { - descriptors.push({ level, cr, side }); + if ( + level !== undefined || + cr !== undefined || + creatureLevel !== undefined + ) { + descriptors.push({ level, cr, creatureLevel, side }); } } return descriptors; @@ -48,8 +56,6 @@ export function useDifficulty(): DifficultyResult | null { const { edition } = useRulesEditionContext(); return useMemo(() => { - if (edition === "pf2e") return null; - const descriptors = buildDescriptors( encounter.combatants, characters, @@ -59,9 +65,16 @@ export function useDifficulty(): DifficultyResult | 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; + } return calculateEncounterDifficulty(descriptors, edition); }, [encounter.combatants, characters, getCreature, edition]); diff --git a/packages/domain/src/__tests__/encounter-difficulty.test.ts b/packages/domain/src/__tests__/encounter-difficulty.test.ts index 41239eb..76262d2 100644 --- a/packages/domain/src/__tests__/encounter-difficulty.test.ts +++ b/packages/domain/src/__tests__/encounter-difficulty.test.ts @@ -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(); + }); +}); diff --git a/packages/domain/src/encounter-difficulty.ts b/packages/domain/src/encounter-difficulty.ts index cea0178..ad5daae 100644 --- a/packages/domain/src/encounter-difficulty.ts +++ b/packages/domain/src/encounter-difficulty.ts @@ -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> = { + [-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(); + 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, }; } diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 4fa1314..ca76459 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -64,6 +64,8 @@ export { type DifficultyResult, type DifficultyThreshold, type DifficultyTier, + derivePartyLevel, + pf2eCreatureXp, VALID_CR_VALUES, } from "./encounter-difficulty.js"; export type {