Combatants can now be assigned to party or enemy side via a toggle in the difficulty breakdown panel. Party-side NPCs subtract their XP from the encounter total, letting allied NPCs reduce difficulty. PCs default to party, non-PCs to enemy — users who don't use sides see no change. Side persists across reload and export/import. Closes #22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
188 lines
5.4 KiB
TypeScript
188 lines
5.4 KiB
TypeScript
import type { DifficultyTier } from "@initiative/domain";
|
|
import { ArrowLeftRight } from "lucide-react";
|
|
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";
|
|
import { Button } from "./ui/button.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 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 breakdown = useDifficultyBreakdown();
|
|
if (!breakdown) return null;
|
|
|
|
const tierConfig = TIER_LABELS[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">
|
|
<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 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>
|
|
|
|
<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>
|
|
);
|
|
}
|