import type { DifficultyTier, RulesEdition } from "@initiative/domain"; import { ArrowLeftRight } from "lucide-react"; import { useRef } from "react"; import { useEncounterContext } from "../contexts/encounter-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useClickOutside } from "../hooks/use-click-outside.js"; import { type BreakdownCombatant, useDifficultyBreakdown, } from "../hooks/use-difficulty-breakdown.js"; import { CrPicker } from "./cr-picker.js"; import { Button } from "./ui/button.js"; const TIER_LABEL_MAP: Partial< Record> > = { "5.5e": { 0: { label: "Trivial", color: "text-muted-foreground" }, 1: { label: "Low", color: "text-green-500" }, 2: { label: "Moderate", color: "text-yellow-500" }, 3: { 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" }, }, }; /** Short labels for threshold display where horizontal space is limited. */ const SHORT_LABELS: Readonly> = { Moderate: "Mod", Medium: "Med", }; function shortLabel(label: string): string { return SHORT_LABELS[label] ?? label; } function formatXp(xp: number): string { return xp.toLocaleString(); } function PcRow({ entry }: { entry: BreakdownCombatant }) { return (
{entry.combatant.name} {entry.level === undefined ? "\u2014" : `Lv ${entry.level}`} {"\u2014"}
); } function NpcRow({ entry, onToggleSide, }: { entry: BreakdownCombatant; onToggleSide: () => void; }) { const { setCr } = useEncounterContext(); const isParty = entry.side === "party"; const targetSide = isParty ? "enemy" : "party"; let xpDisplay: string; if (entry.xp == null) { xpDisplay = "\u2014"; } else if (isParty && entry.cr) { xpDisplay = `\u2212${formatXp(entry.xp)}`; } else { xpDisplay = formatXp(entry.xp); } return (
{entry.combatant.name} {entry.editable ? ( setCr(entry.combatant.id, cr)} /> ) : ( {entry.cr ? `CR ${entry.cr}` : "\u2014"} )} {xpDisplay}
); } export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) { const ref = useRef(null); useClickOutside(ref, onClose); const { setSide } = useEncounterContext(); const { edition } = useRulesEditionContext(); const breakdown = useDifficultyBreakdown(); if (!breakdown) return null; const tierLabels = TIER_LABEL_MAP[edition]; if (!tierLabels) return null; const tierConfig = tierLabels[breakdown.tier]; const handleToggle = (entry: BreakdownCombatant) => { const newSide = entry.side === "party" ? "enemy" : "party"; setSide(entry.combatant.id, newSide); }; const isPC = (entry: BreakdownCombatant) => entry.combatant.playerCharacterId != null; return (
Encounter Difficulty:{" "} {tierConfig.label}
Party Budget ({breakdown.pcCount}{" "} {breakdown.pcCount === 1 ? "PC" : "PCs"})
{breakdown.thresholds.map((t) => ( {shortLabel(t.label)}: {formatXp(t.value)} ))}
Allied NPC XP is subtracted from encounter difficulty
Party XP
{breakdown.partyCombatants.map((entry) => isPC(entry) ? ( ) : ( handleToggle(entry)} /> ), )}
Enemy XP
{breakdown.enemyCombatants.map((entry) => isPC(entry) ? ( ) : ( handleToggle(entry)} /> ), )}
{breakdown.encounterMultiplier !== undefined && breakdown.adjustedXp !== undefined ? (
Monster XP {formatXp(breakdown.totalMonsterXp)}{" "} ×{breakdown.encounterMultiplier} {" "} = {formatXp(breakdown.adjustedXp)}
{breakdown.partySizeAdjusted === true ? (
Adjusted for {breakdown.pcCount}{" "} {breakdown.pcCount === 1 ? "PC" : "PCs"}
) : null}
) : (
Net Monster XP {formatXp(breakdown.totalMonsterXp)}
)}
); }