Add combatant side assignment for encounter difficulty
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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
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" },
|
||||
@@ -19,19 +21,55 @@ function formatXp(xp: number): string {
|
||||
return xp.toLocaleString();
|
||||
}
|
||||
|
||||
function CombatantRow({ entry }: { entry: BreakdownCombatant }) {
|
||||
const { setCr } = useEncounterContext();
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const nameLabel = entry.source
|
||||
? `${entry.combatant.name} (${entry.source})`
|
||||
: entry.combatant.name;
|
||||
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="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="min-w-0 truncate" title={nameLabel}>
|
||||
{nameLabel}
|
||||
<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>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<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}
|
||||
@@ -39,13 +77,11 @@ function CombatantRow({ entry }: { entry: BreakdownCombatant }) {
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{entry.cr ? `CR ${entry.cr}` : "—"}
|
||||
{entry.cr ? `CR ${entry.cr}` : "\u2014"}
|
||||
</span>
|
||||
)}
|
||||
<span className="w-12 text-right tabular-nums">
|
||||
{entry.xp == null ? "—" : formatXp(entry.xp)}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,16 +89,25 @@ function CombatantRow({ entry }: { entry: BreakdownCombatant }) {
|
||||
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-72 rounded-lg border border-border bg-card p-3 shadow-lg"
|
||||
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:{" "}
|
||||
@@ -87,22 +132,55 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
</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>Monsters</span>
|
||||
<span>Party</span>
|
||||
<span>XP</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{breakdown.combatants.map((entry) => (
|
||||
<CombatantRow key={entry.combatant.id} entry={entry} />
|
||||
))}
|
||||
<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 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 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user