Add manual CR assignment and difficulty breakdown panel
Implement issue #21: custom combatants can now have a challenge rating assigned via a new breakdown panel, opened by tapping the difficulty indicator. Bestiary-linked combatants show read-only CR with source name; custom combatants get a CR picker with all standard 5e values. CR persists across reloads and round-trips through JSON export/import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
109
apps/web/src/components/difficulty-breakdown-panel.tsx
Normal file
109
apps/web/src/components/difficulty-breakdown-panel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { DifficultyTier } from "@initiative/domain";
|
||||
import { useRef } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-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";
|
||||
|
||||
const TIER_LABELS: Record<DifficultyTier, { label: string; color: string }> = {
|
||||
trivial: { label: "Trivial", color: "text-muted-foreground" },
|
||||
low: { label: "Low", color: "text-green-500" },
|
||||
moderate: { label: "Moderate", color: "text-yellow-500" },
|
||||
high: { label: "High", color: "text-red-500" },
|
||||
};
|
||||
|
||||
function formatXp(xp: number): string {
|
||||
return xp.toLocaleString();
|
||||
}
|
||||
|
||||
function CombatantRow({ entry }: { entry: BreakdownCombatant }) {
|
||||
const { setCr } = useEncounterContext();
|
||||
|
||||
const nameLabel = entry.source
|
||||
? `${entry.combatant.name} (${entry.source})`
|
||||
: entry.combatant.name;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="min-w-0 truncate" title={nameLabel}>
|
||||
{nameLabel}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{entry.editable ? (
|
||||
<CrPicker
|
||||
value={entry.cr}
|
||||
onChange={(cr) => setCr(entry.combatant.id, cr)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{entry.cr ? `CR ${entry.cr}` : "—"}
|
||||
</span>
|
||||
)}
|
||||
<span className="w-12 text-right tabular-nums">
|
||||
{entry.xp == null ? "—" : formatXp(entry.xp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const breakdown = useDifficultyBreakdown();
|
||||
if (!breakdown) return null;
|
||||
|
||||
const tierConfig = TIER_LABELS[breakdown.tier];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full right-0 z-50 mt-1 w-72 rounded-lg border border-border bg-card p-3 shadow-lg"
|
||||
>
|
||||
<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">
|
||||
<span>
|
||||
Low: <strong>{formatXp(breakdown.partyBudget.low)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Mod: <strong>{formatXp(breakdown.partyBudget.moderate)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
High: <strong>{formatXp(breakdown.partyBudget.high)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border border-t pt-2">
|
||||
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||
<span>Monsters</span>
|
||||
<span>XP</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{breakdown.combatants.map((entry) => (
|
||||
<CombatantRow key={entry.combatant.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Total Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user