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:
@@ -209,6 +209,31 @@ describe("round-trip: export then import", () => {
|
||||
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||
});
|
||||
|
||||
it("round-trips a combatant with cr field", () => {
|
||||
const encounterWithCr: Encounter = {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(encounterWithCr, emptyUndoRedo, []);
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.encounter.combatants[0].cr).toBe("2");
|
||||
});
|
||||
|
||||
it("round-trips an empty encounter", () => {
|
||||
const emptyEncounter: Encounter = {
|
||||
combatants: [],
|
||||
|
||||
26
apps/web/src/__tests__/factories/build-creature.ts
Normal file
26
apps/web/src/__tests__/factories/build-creature.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function buildCreature(overrides?: Partial<Creature>): Creature {
|
||||
const id = ++counter;
|
||||
return {
|
||||
id: creatureId(`creature-${id}`),
|
||||
name: `Creature ${id}`,
|
||||
source: "srd",
|
||||
sourceDisplayName: "SRD",
|
||||
size: "Medium",
|
||||
type: "humanoid",
|
||||
alignment: "neutral",
|
||||
ac: 13,
|
||||
hp: { average: 7, formula: "2d6" },
|
||||
speed: "30 ft.",
|
||||
abilities: { str: 10, dex: 14, con: 10, int: 10, wis: 10, cha: 10 },
|
||||
cr: "1/4",
|
||||
initiativeProficiency: 0,
|
||||
proficiencyBonus: 2,
|
||||
passive: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { buildCombatant } from "./build-combatant.js";
|
||||
export { buildCreature } from "./build-creature.js";
|
||||
export { buildEncounter } from "./build-encounter.js";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
36
apps/web/src/components/cr-picker.tsx
Normal file
36
apps/web/src/components/cr-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
246
apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx
Normal file
246
apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { 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 { useDifficultyBreakdown } from "../use-difficulty-breakdown.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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const goblinCreature = buildCreature({
|
||||
id: creatureId("srd:goblin"),
|
||||
name: "Goblin",
|
||||
cr: "1/4",
|
||||
source: "srd",
|
||||
sourceDisplayName: "SRD",
|
||||
});
|
||||
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
playerCharacters: options.playerCharacters ?? [],
|
||||
creatures: options.creatures,
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDifficultyBreakdown", () => {
|
||||
it("returns null when no leveled PCs", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no monsters with CR", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Custom",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("returns per-combatant entries with correct data", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: 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",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
expect(breakdown?.pcCount).toBe(1);
|
||||
// CR 1/4 = 50 + CR 2 = 450 → total 500
|
||||
expect(breakdown?.totalMonsterXp).toBe(500);
|
||||
expect(breakdown?.combatants).toHaveLength(3);
|
||||
|
||||
// Bestiary combatant
|
||||
const goblin = breakdown?.combatants[0];
|
||||
expect(goblin?.cr).toBe("1/4");
|
||||
expect(goblin?.xp).toBe(50);
|
||||
expect(goblin?.source).toBe("SRD");
|
||||
expect(goblin?.editable).toBe(false);
|
||||
|
||||
// Custom with CR
|
||||
const thug = breakdown?.combatants[1];
|
||||
expect(thug?.cr).toBe("2");
|
||||
expect(thug?.xp).toBe(450);
|
||||
expect(thug?.source).toBeNull();
|
||||
expect(thug?.editable).toBe(true);
|
||||
|
||||
// Custom without CR
|
||||
const bandit = breakdown?.combatants[2];
|
||||
expect(bandit?.cr).toBeNull();
|
||||
expect(bandit?.xp).toBeNull();
|
||||
expect(bandit?.source).toBeNull();
|
||||
expect(bandit?.editable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("bestiary combatant with missing creature is non-editable with null CR", () => {
|
||||
const missingCreatureId = creatureId("creature-missing");
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Ghost",
|
||||
creatureId: missingCreatureId,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-3"),
|
||||
name: "Thug",
|
||||
cr: "1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// With no bestiary creatures loaded, the Ghost has null CR
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
const ghost = breakdown?.combatants[0];
|
||||
expect(ghost?.cr).toBeNull();
|
||||
expect(ghost?.xp).toBeNull();
|
||||
expect(ghost?.editable).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes PC combatants from breakdown entries", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current?.combatants).toHaveLength(1);
|
||||
expect(result.current?.combatants[0].combatant.name).toBe("Goblin");
|
||||
});
|
||||
});
|
||||
});
|
||||
173
apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx
Normal file
173
apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { 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 { useDifficulty } from "../use-difficulty.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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const pcId1 = playerCharacterId("pc-1");
|
||||
const goblinCreature = buildCreature({
|
||||
id: creatureId("srd:goblin"),
|
||||
name: "Goblin",
|
||||
cr: "1/4",
|
||||
});
|
||||
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
playerCharacters: options.playerCharacters ?? [],
|
||||
creatures: options.creatures,
|
||||
});
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDifficulty with custom combatant CRs", () => {
|
||||
it("includes custom combatant with cr field in monster XP", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.totalMonsterXp).toBe(450);
|
||||
});
|
||||
|
||||
it("uses bestiary CR when combatant has both creatureId and cr", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
creatureId: goblinCreature.id,
|
||||
cr: "5",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// Should use bestiary CR 1/4 (50 XP), not the manual cr "5" (1800 XP)
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
it("mixes bestiary and custom-with-CR combatants correctly", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: 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",
|
||||
cr: "1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// CR 1/4 = 50 XP, CR 1 = 200 XP → total 250
|
||||
expect(result.current?.totalMonsterXp).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
it("custom combatant without CR is still excluded", () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Custom Monster",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
140
apps/web/src/hooks/use-difficulty-breakdown.ts
Normal file
140
apps/web/src/hooks/use-difficulty-breakdown.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyTier,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
|
||||
export interface BreakdownCombatant {
|
||||
readonly combatant: Combatant;
|
||||
readonly cr: string | null;
|
||||
readonly xp: number | null;
|
||||
readonly source: string | null;
|
||||
readonly editable: boolean;
|
||||
}
|
||||
|
||||
interface DifficultyBreakdown {
|
||||
readonly tier: DifficultyTier;
|
||||
readonly totalMonsterXp: number;
|
||||
readonly partyBudget: {
|
||||
readonly low: number;
|
||||
readonly moderate: number;
|
||||
readonly high: number;
|
||||
};
|
||||
readonly pcCount: number;
|
||||
readonly combatants: readonly BreakdownCombatant[];
|
||||
}
|
||||
|
||||
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { characters } = usePlayerCharactersContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
||||
const { entries, crs } = classifyCombatants(
|
||||
encounter.combatants,
|
||||
getCreature,
|
||||
);
|
||||
|
||||
if (partyLevels.length === 0 || crs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = calculateEncounterDifficulty(partyLevels, crs);
|
||||
|
||||
return {
|
||||
...result,
|
||||
pcCount: partyLevels.length,
|
||||
combatants: entries,
|
||||
};
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
}
|
||||
|
||||
function classifyBestiaryCombatant(
|
||||
c: Combatant,
|
||||
getCreature: (
|
||||
id: CreatureId,
|
||||
) => { cr: string; source: string; sourceDisplayName: string } | undefined,
|
||||
): { entry: BreakdownCombatant; cr: string | null } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
if (creature) {
|
||||
return {
|
||||
entry: {
|
||||
combatant: c,
|
||||
cr: creature.cr,
|
||||
xp: crToXp(creature.cr),
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
},
|
||||
cr: creature.cr,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entry: {
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: false,
|
||||
},
|
||||
cr: null,
|
||||
};
|
||||
}
|
||||
|
||||
function classifyCombatants(
|
||||
combatants: readonly Combatant[],
|
||||
getCreature: (
|
||||
id: CreatureId,
|
||||
) => { cr: string; source: string; sourceDisplayName: string } | undefined,
|
||||
): { entries: BreakdownCombatant[]; crs: string[] } {
|
||||
const entries: BreakdownCombatant[] = [];
|
||||
const crs: string[] = [];
|
||||
|
||||
for (const c of combatants) {
|
||||
if (c.playerCharacterId) continue;
|
||||
|
||||
if (c.creatureId) {
|
||||
const { entry, cr } = classifyBestiaryCombatant(c, getCreature);
|
||||
entries.push(entry);
|
||||
if (cr) crs.push(cr);
|
||||
} else if (c.cr) {
|
||||
crs.push(c.cr);
|
||||
entries.push({
|
||||
combatant: c,
|
||||
cr: c.cr,
|
||||
xp: crToXp(c.cr),
|
||||
source: null,
|
||||
editable: true,
|
||||
});
|
||||
} else {
|
||||
entries.push({
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, crs };
|
||||
}
|
||||
|
||||
function derivePartyLevels(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number[] {
|
||||
const levels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.playerCharacterId) continue;
|
||||
const pc = characters.find((p) => p.id === c.playerCharacterId);
|
||||
if (pc?.level !== undefined) levels.push(pc.level);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
@@ -29,9 +29,12 @@ function deriveMonsterCrs(
|
||||
): string[] {
|
||||
const crs: string[] = [];
|
||||
for (const c of combatants) {
|
||||
if (!c.creatureId) continue;
|
||||
const creature = getCreature(c.creatureId);
|
||||
if (creature) crs.push(creature.cr);
|
||||
if (c.creatureId) {
|
||||
const creature = getCreature(c.creatureId);
|
||||
if (creature) crs.push(creature.cr);
|
||||
} else if (c.cr) {
|
||||
crs.push(c.cr);
|
||||
}
|
||||
}
|
||||
return crs;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
setCrUseCase,
|
||||
setHpUseCase,
|
||||
setInitiativeUseCase,
|
||||
setTempHpUseCase,
|
||||
@@ -52,6 +53,7 @@ type EncounterAction =
|
||||
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
||||
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||
| { type: "set-cr"; id: CombatantId; value: string | undefined }
|
||||
| {
|
||||
type: "toggle-condition";
|
||||
id: CombatantId;
|
||||
@@ -318,6 +320,7 @@ function dispatchEncounterAction(
|
||||
| { type: "adjust-hp" }
|
||||
| { type: "set-temp-hp" }
|
||||
| { type: "set-ac" }
|
||||
| { type: "set-cr" }
|
||||
| { type: "toggle-condition" }
|
||||
| { type: "toggle-concentration" }
|
||||
>,
|
||||
@@ -358,6 +361,9 @@ function dispatchEncounterAction(
|
||||
case "set-ac":
|
||||
result = setAcUseCase(store, action.id, action.value);
|
||||
break;
|
||||
case "set-cr":
|
||||
result = setCrUseCase(store, action.id, action.value);
|
||||
break;
|
||||
case "toggle-condition":
|
||||
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||
break;
|
||||
@@ -495,6 +501,11 @@ export function useEncounter() {
|
||||
dispatch({ type: "set-ac", id, value }),
|
||||
[],
|
||||
),
|
||||
setCr: useCallback(
|
||||
(id: CombatantId, value: string | undefined) =>
|
||||
dispatch({ type: "set-cr", id, value }),
|
||||
[],
|
||||
),
|
||||
toggleCondition: useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||
|
||||
@@ -134,6 +134,26 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant cr field", () => {
|
||||
const result = createEncounter(
|
||||
[
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Custom Thug",
|
||||
cr: "2",
|
||||
},
|
||||
],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.combatants[0].cr).toBe("2");
|
||||
});
|
||||
|
||||
it("saving after modifications persists the latest state", () => {
|
||||
const encounter = makeEncounter();
|
||||
saveEncounter(encounter);
|
||||
|
||||
Reference in New Issue
Block a user