Files
initiative/apps/web/src/components/difficulty-breakdown-panel.tsx
Lukas e62c49434c
All checks were successful
CI / check (push) Successful in 2m21s
CI / build-image (push) Successful in 24s
Add Pathfinder 2e game system mode
Implements PF2e as an alternative game system alongside D&D 5e/5.5e.
Settings modal "Game System" selector switches conditions, bestiary,
stat block layout, and initiative calculation between systems.

- Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3)
- 2,502 PF2e creatures from bundled search index (77 sources)
- PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods
- Perception-based initiative rolling
- System-scoped source cache (D&D and PF2e sources don't collide)
- Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[])
- Difficulty indicator hidden in PF2e mode (excluded from MVP)

Closes dostulata/initiative#19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:26:22 +02:00

230 lines
6.8 KiB
TypeScript

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<RulesEdition, Record<DifficultyTier, { label: string; color: string }>>
> = {
"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<Record<string, string>> = {
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 (
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
<span className="min-w-0 truncate" title={entry.combatant.name}>
{entry.combatant.name}
</span>
<span />
<span className="text-muted-foreground">
{entry.level === undefined ? "\u2014" : `Lv ${entry.level}`}
</span>
<span className="text-right tabular-nums">{"\u2014"}</span>
</div>
);
}
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 (
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
<span className="min-w-0 truncate" title={entry.combatant.name}>
{entry.combatant.name}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleSide}
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
>
<ArrowLeftRight className="h-3 w-3" />
</Button>
<span>
{entry.editable ? (
<CrPicker
value={entry.cr}
onChange={(cr) => setCr(entry.combatant.id, cr)}
/>
) : (
<span className="text-muted-foreground">
{entry.cr ? `CR ${entry.cr}` : "\u2014"}
</span>
)}
</span>
<span className="text-right tabular-nums">{xpDisplay}</span>
</div>
);
}
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
className="absolute top-full right-0 z-50 mt-1 w-80 rounded-lg border border-border bg-card p-3 shadow-lg max-sm:fixed max-sm:top-12 max-sm:right-3 max-sm:left-3 max-sm:w-auto"
>
<div className="mb-2 font-medium text-sm">
Encounter Difficulty:{" "}
<span className={tierConfig.color}>{tierConfig.label}</span>
</div>
<div className="mb-2 border-border border-t pt-2">
<div className="mb-1 text-muted-foreground text-xs">
Party Budget ({breakdown.pcCount}{" "}
{breakdown.pcCount === 1 ? "PC" : "PCs"})
</div>
<div className="flex gap-3 text-xs">
{breakdown.thresholds.map((t) => (
<span key={t.label}>
{shortLabel(t.label)}: <strong>{formatXp(t.value)}</strong>
</span>
))}
</div>
</div>
<div className="border-border border-t pt-2 pb-2 text-muted-foreground text-xs italic">
Allied NPC XP is subtracted from encounter difficulty
</div>
<div className="border-border border-t pt-2">
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
<span>Party</span>
<span>XP</span>
</div>
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
{breakdown.partyCombatants.map((entry) =>
isPC(entry) ? (
<PcRow key={entry.combatant.id} entry={entry} />
) : (
<NpcRow
key={entry.combatant.id}
entry={entry}
onToggleSide={() => handleToggle(entry)}
/>
),
)}
</div>
</div>
<div className="mt-2 border-border border-t pt-2">
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
<span>Enemy</span>
<span>XP</span>
</div>
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
{breakdown.enemyCombatants.map((entry) =>
isPC(entry) ? (
<PcRow key={entry.combatant.id} entry={entry} />
) : (
<NpcRow
key={entry.combatant.id}
entry={entry}
onToggleSide={() => handleToggle(entry)}
/>
),
)}
</div>
</div>
{breakdown.encounterMultiplier !== undefined &&
breakdown.adjustedXp !== undefined ? (
<div className="mt-2 border-border border-t pt-2">
<div className="flex justify-between font-medium text-xs">
<span>Monster XP</span>
<span className="tabular-nums">
{formatXp(breakdown.totalMonsterXp)}{" "}
<span className="text-muted-foreground">
&times;{breakdown.encounterMultiplier}
</span>{" "}
= {formatXp(breakdown.adjustedXp)}
</span>
</div>
{breakdown.partySizeAdjusted === true ? (
<div className="mt-0.5 text-muted-foreground text-xs italic">
Adjusted for {breakdown.pcCount}{" "}
{breakdown.pcCount === 1 ? "PC" : "PCs"}
</div>
) : null}
</div>
) : (
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
<span>Net Monster XP</span>
<span className="tabular-nums">
{formatXp(breakdown.totalMonsterXp)}
</span>
</div>
)}
</div>
);
}