import type { AnyCreature, Combatant, CreatureId, DifficultyThreshold, DifficultyTier, PlayerCharacter, } 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"; import { usePlayerCharactersContext } from "../contexts/player-characters-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { resolveSide } from "./use-difficulty.js"; export interface BreakdownCombatant { readonly combatant: Combatant; readonly cr: string | null; readonly xp: number | null; readonly source: string | null; 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 { readonly tier: DifficultyTier; readonly totalMonsterXp: number; readonly thresholds: readonly DifficultyThreshold[]; 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[]; } export function useDifficultyBreakdown(): DifficultyBreakdown | null { const { encounter } = useEncounterContext(); const { characters } = usePlayerCharactersContext(); const { getCreature } = useBestiaryContext(); const { edition } = useRulesEditionContext(); return useMemo(() => { const { partyCombatants, enemyCombatants, descriptors, pcCount } = classifyCombatants(encounter.combatants, characters, getCreature); const hasPartyLevel = descriptors.some( (d) => d.side === "party" && d.level !== undefined, ); 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); return { ...result, pcCount, partyCombatants, enemyCombatants, }; }, [encounter.combatants, characters, getCreature, edition]); } type CreatureInfo = { cr?: string; creatureLevel?: number; source: string; sourceDisplayName: string; }; function buildBreakdownEntry( c: Combatant, side: "party" | "enemy", level: number | undefined, creature: CreatureInfo | undefined, partyLevel: number | undefined, ): BreakdownCombatant { if (c.playerCharacterId) { return { combatant: c, cr: null, xp: null, source: null, 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) { const cr = creature.cr ?? null; return { combatant: c, cr, xp: cr ? crToXp(cr) : null, source: creature.sourceDisplayName ?? creature.source, editable: false, side, level: undefined, creatureLevel: undefined, levelDifference: undefined, }; } if (c.cr) { return { combatant: c, cr: c.cr, xp: crToXp(c.cr), source: null, editable: true, side, level: undefined, creatureLevel: undefined, levelDifference: undefined, }; } return { combatant: c, cr: null, xp: null, source: null, editable: !c.creatureId, side, level: undefined, creatureLevel: undefined, levelDifference: undefined, }; } function resolveLevel( c: Combatant, characters: readonly PlayerCharacter[], ): number | undefined { if (!c.playerCharacterId) return undefined; return characters.find((p) => p.id === c.playerCharacterId)?.level; } function resolveCreatureInfo( c: Combatant, 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) => 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, creatureLevel, creature } = resolveCreatureInfo(c, getCreature); if (level !== undefined || cr != null || creatureLevel !== undefined) { descriptors.push({ level, cr: cr ?? undefined, creatureLevel, side, }); } const entry = buildBreakdownEntry(c, side, level, creature, partyLevel); const target = side === "party" ? partyCombatants : enemyCombatants; target.push(entry); } return { partyCombatants, enemyCombatants, descriptors, pcCount }; }