Add manual CR assignment and difficulty breakdown panel
All checks were successful
CI / check (push) Successful in 2m20s
CI / build-image (push) Successful in 17s

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:
Lukas
2026-04-02 17:03:33 +02:00
parent 2c643cc98b
commit 1ae9e12cff
26 changed files with 1461 additions and 17 deletions

View File

@@ -0,0 +1,232 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
import {
buildCombatant,
buildCreature,
buildEncounter,
} from "../../__tests__/factories/index.js";
import { AllProviders } from "../../__tests__/test-providers.js";
import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js";
beforeAll(() => {
Object.defineProperty(globalThis, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(cleanup);
const pcId1 = playerCharacterId("pc-1");
const goblinCreature = buildCreature({
id: creatureId("srd:goblin"),
name: "Goblin",
cr: "1/4",
source: "srd",
sourceDisplayName: "SRD",
});
function renderPanel(options: {
encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, Creature>;
onClose?: () => void;
}) {
const adapters = createTestAdapters({
encounter: options.encounter,
playerCharacters: options.playerCharacters ?? [],
creatures: options.creatures,
});
return render(
<AllProviders adapters={adapters}>
<DifficultyBreakdownPanel onClose={options.onClose ?? (() => {})} />
</AllProviders>,
);
}
function defaultEncounter() {
return buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c-1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c-2"),
name: "Goblin",
creatureId: goblinCreature.id,
}),
buildCombatant({
id: combatantId("c-3"),
name: "Custom Thug",
cr: "2",
}),
buildCombatant({
id: combatantId("c-4"),
name: "Bandit",
}),
],
});
}
const defaultPCs: PlayerCharacter[] = [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
];
describe("DifficultyBreakdownPanel", () => {
it("renders party budget section", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(
screen.getByText("Party Budget", { exact: false }),
).toBeInTheDocument();
expect(screen.getByText("1 PC", { exact: false })).toBeInTheDocument();
expect(screen.getByText("Low:", { exact: false })).toBeInTheDocument();
});
});
it("renders tier label", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(
screen.getByText("Encounter Difficulty:", { exact: false }),
).toBeInTheDocument();
});
});
it("renders bestiary combatant as read-only with source name", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(screen.getByText("Goblin (SRD)")).toBeInTheDocument();
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
});
});
it("renders custom combatant with CR picker", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
const pickers = screen.getAllByLabelText("Challenge rating");
expect(pickers).toHaveLength(2);
// First picker is "Custom Thug" with CR 2
expect(pickers[0]).toHaveValue("2");
});
});
it("renders unassigned combatant with Assign picker and dash for XP", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
const pickers = screen.getAllByLabelText("Challenge rating");
// Second picker is "Bandit" with no CR
expect(pickers[1]).toHaveValue("");
// "—" appears for unassigned XP
expect(screen.getByText("—")).toBeInTheDocument();
});
});
it("selecting a CR updates the visible XP value", async () => {
const user = userEvent.setup();
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
// Wait for the panel to render with bestiary data
await waitFor(() => {
expect(screen.getByText("—")).toBeInTheDocument();
});
// The Bandit (second picker) has no CR — shows "—" for XP
const pickers = screen.getAllByLabelText("Challenge rating");
// Select CR 5 (1,800 XP) on Bandit
await user.selectOptions(pickers[1], "5");
// XP should update — the "—" should be replaced with an XP value
await waitFor(() => {
expect(screen.getByText("1,800")).toBeInTheDocument();
});
});
it("renders total monster XP", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(screen.getByText("Total Monster XP")).toBeInTheDocument();
});
});
it("renders nothing when breakdown data is insufficient", () => {
// No PCs with level → breakdown returns null
const { container } = renderPanel({
encounter: buildEncounter({
combatants: [
buildCombatant({ id: combatantId("c-1"), name: "Custom" }),
],
}),
});
expect(container.innerHTML).toBe("");
});
it("calls onClose when Escape is pressed", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
onClose,
});
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledOnce();
});
});

View File

@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import type { DifficultyResult } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DifficultyIndicator } from "../difficulty-indicator.js";
afterEach(cleanup);
@@ -56,4 +57,41 @@ describe("DifficultyIndicator", () => {
}),
).toBeDefined();
});
it("calls onClick when clicked", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(
<DifficultyIndicator
result={makeResult("moderate")}
onClick={handleClick}
/>,
);
await user.click(
screen.getByRole("img", {
name: "Moderate encounter difficulty",
}),
);
expect(handleClick).toHaveBeenCalledOnce();
});
it("renders as div when onClick not provided", () => {
const { container } = render(
<DifficultyIndicator result={makeResult("moderate")} />,
);
const element = container.querySelector("[role='img']");
expect(element?.tagName).toBe("DIV");
});
it("renders as button when onClick provided", () => {
const { container } = render(
<DifficultyIndicator
result={makeResult("moderate")}
onClick={() => {}}
/>,
);
const element = container.querySelector("[role='img']");
expect(element?.tagName).toBe("BUTTON");
});
});

View File

@@ -0,0 +1,36 @@
import { VALID_CR_VALUES } from "@initiative/domain";
const CR_LABELS: Record<string, string> = {
"0": "CR 0",
"1/8": "CR 1/8",
"1/4": "CR 1/4",
"1/2": "CR 1/2",
};
function formatCr(cr: string): string {
return CR_LABELS[cr] ?? `CR ${cr}`;
}
export function CrPicker({
value,
onChange,
}: {
value: string | null;
onChange: (cr: string | undefined) => void;
}) {
return (
<select
className="rounded border border-border bg-card px-1.5 py-0.5 text-xs"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || undefined)}
aria-label="Challenge rating"
>
<option value="">Assign</option>
{VALID_CR_VALUES.map((cr) => (
<option key={cr} value={cr}>
{formatCr(cr)}
</option>
))}
</select>
);
}

View 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>
);
}

View File

@@ -13,16 +13,29 @@ const TIER_CONFIG: Record<
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
export function DifficultyIndicator({
result,
onClick,
}: {
result: DifficultyResult;
onClick?: () => void;
}) {
const config = TIER_CONFIG[result.tier];
const tooltip = `${config.label} encounter difficulty`;
const Element = onClick ? "button" : "div";
return (
<div
className="flex items-end gap-0.5"
<Element
className={cn(
"flex items-end gap-0.5",
onClick && "cursor-pointer rounded p-1 hover:bg-muted/50",
)}
title={tooltip}
role="img"
aria-label={tooltip}
onClick={onClick}
type={onClick ? "button" : undefined}
>
{BAR_HEIGHTS.map((height, i) => (
<div
@@ -34,6 +47,6 @@ export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
)}
/>
))}
</div>
</Element>
);
}

View File

@@ -1,6 +1,8 @@
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
import { useState } from "react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useDifficulty } from "../hooks/use-difficulty.js";
import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.js";
import { DifficultyIndicator } from "./difficulty-indicator.js";
import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js";
@@ -18,6 +20,7 @@ export function TurnNavigation() {
} = useEncounterContext();
const difficulty = useDifficulty();
const [showBreakdown, setShowBreakdown] = useState(false);
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
@@ -69,7 +72,19 @@ export function TurnNavigation() {
) : (
<span className="text-muted-foreground">No combatants</span>
)}
{difficulty && <DifficultyIndicator result={difficulty} />}
{difficulty && (
<div className="relative">
<DifficultyIndicator
result={difficulty}
onClick={() => setShowBreakdown((prev) => !prev)}
/>
{showBreakdown ? (
<DifficultyBreakdownPanel
onClose={() => setShowBreakdown(false)}
/>
) : null}
</div>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-3">