Add combatant side assignment for encounter difficulty
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s

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:
Lukas
2026-04-03 14:15:12 +02:00
parent 30e7ed4121
commit 94e1806112
23 changed files with 1359 additions and 455 deletions

View File

@@ -234,6 +234,57 @@ describe("round-trip: export then import", () => {
expect(imported.encounter.combatants[0].cr).toBe("2");
});
it("round-trips a combatant with side field", () => {
const encounterWithSide: Encounter = {
combatants: [
{
id: combatantId("c-1"),
name: "Allied Guard",
cr: "2",
side: "party",
},
{
id: combatantId("c-2"),
name: "Goblin",
side: "enemy",
},
],
activeIndex: 0,
roundNumber: 1,
};
const emptyUndoRedo: UndoRedoState = {
undoStack: [],
redoStack: [],
};
const bundle = assembleExportBundle(encounterWithSide, 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].side).toBe("party");
expect(imported.encounter.combatants[1].side).toBe("enemy");
});
it("round-trips a combatant without side field as undefined", () => {
const encounterNoSide: Encounter = {
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
activeIndex: 0,
roundNumber: 1,
};
const emptyUndoRedo: UndoRedoState = {
undoStack: [],
redoStack: [],
};
const bundle = assembleExportBundle(encounterNoSide, 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].side).toBeUndefined();
});
it("round-trips an empty encounter", () => {
const emptyEncounter: Encounter = {
combatants: [],

View File

@@ -121,7 +121,7 @@ describe("DifficultyBreakdownPanel", () => {
});
});
it("renders bestiary combatant as read-only with source name", async () => {
it("shows PC in party column with level", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
@@ -129,12 +129,53 @@ describe("DifficultyBreakdownPanel", () => {
});
await waitFor(() => {
expect(screen.getByText("Goblin (SRD)")).toBeInTheDocument();
expect(screen.getByText("Hero")).toBeInTheDocument();
expect(screen.getByText("Lv 5")).toBeInTheDocument();
});
});
it("shows monsters in enemy column", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(screen.getByText("Goblin")).toBeInTheDocument();
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
});
});
it("renders custom combatant with CR picker", async () => {
it("renders explanation text", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(
screen.getByText(
"Allied NPC XP is subtracted from encounter difficulty",
),
).toBeInTheDocument();
});
});
it("renders Net Monster XP footer", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
});
});
it("renders custom combatant with CR picker in enemy column", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
@@ -144,27 +185,10 @@ describe("DifficultyBreakdownPanel", () => {
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({
@@ -173,24 +197,19 @@ describe("DifficultyBreakdownPanel", () => {
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
// Wait for the panel to render with bestiary data
await waitFor(() => {
expect(screen.getByText("—")).toBeInTheDocument();
expect(screen.getAllByLabelText("Challenge rating")).toHaveLength(2);
});
// 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 () => {
it("non-PC combatants show toggle button", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
@@ -198,12 +217,57 @@ describe("DifficultyBreakdownPanel", () => {
});
await waitFor(() => {
expect(screen.getByText("Total Monster XP")).toBeInTheDocument();
// Each non-PC enemy combatant has a toggle button
expect(
screen.getByLabelText("Move Goblin to party side"),
).toBeInTheDocument();
expect(
screen.getByLabelText("Move Custom Thug to party side"),
).toBeInTheDocument();
});
});
it("PC combatants do not show side toggle", async () => {
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(screen.getByText("Hero")).toBeInTheDocument();
});
expect(
screen.queryByLabelText("Move Hero to enemy side"),
).not.toBeInTheDocument();
});
it("side toggle moves combatant between sections", async () => {
const user = userEvent.setup();
renderPanel({
encounter: defaultEncounter(),
playerCharacters: defaultPCs,
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
await waitFor(() => {
expect(screen.getByText("Goblin")).toBeInTheDocument();
});
// Toggle goblin to party side
const toggleBtn = screen.getByLabelText("Move Goblin to party side");
await user.click(toggleBtn);
// After toggle, the aria-label should change to "Move Goblin to enemy side"
await waitFor(() => {
expect(
screen.getByLabelText("Move Goblin to enemy side"),
).toBeInTheDocument();
});
});
it("renders nothing when breakdown data is insufficient", () => {
// No PCs with level → breakdown returns null
const { container } = renderPanel({
encounter: buildEncounter({
combatants: [

View File

@@ -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>
);

View File

@@ -106,7 +106,7 @@ describe("useDifficultyBreakdown", () => {
expect(result.current).toBeNull();
});
it("returns per-combatant entries with correct data", async () => {
it("returns per-combatant entries split by side", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
@@ -145,29 +145,34 @@ describe("useDifficultyBreakdown", () => {
const breakdown = result.current;
expect(breakdown).not.toBeNull();
expect(breakdown?.pcCount).toBe(1);
// CR 1/4 = 50 + CR 2 = 450 total 500
// 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];
// PC in party column
expect(breakdown?.partyCombatants).toHaveLength(1);
expect(breakdown?.partyCombatants[0].combatant.name).toBe("Hero");
expect(breakdown?.partyCombatants[0].side).toBe("party");
expect(breakdown?.partyCombatants[0].level).toBe(5);
// Enemies: goblin, thug, bandit
expect(breakdown?.enemyCombatants).toHaveLength(3);
const goblin = breakdown?.enemyCombatants[0];
expect(goblin?.cr).toBe("1/4");
expect(goblin?.xp).toBe(50);
expect(goblin?.source).toBe("SRD");
expect(goblin?.editable).toBe(false);
expect(goblin?.side).toBe("enemy");
// Custom with CR
const thug = breakdown?.combatants[1];
const thug = breakdown?.enemyCombatants[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];
const bandit = breakdown?.enemyCombatants[2];
expect(bandit?.cr).toBeNull();
expect(bandit?.xp).toBeNull();
expect(bandit?.source).toBeNull();
expect(bandit?.editable).toBe(true);
});
});
@@ -203,16 +208,15 @@ describe("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];
const ghost = breakdown?.enemyCombatants[0];
expect(ghost?.cr).toBeNull();
expect(ghost?.xp).toBeNull();
expect(ghost?.editable).toBe(false);
});
it("excludes PC combatants from breakdown entries", async () => {
it("PC combatants appear in partyCombatants with level", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
@@ -239,8 +243,53 @@ describe("useDifficultyBreakdown", () => {
});
await waitFor(() => {
expect(result.current?.combatants).toHaveLength(1);
expect(result.current?.combatants[0].combatant.name).toBe("Goblin");
expect(result.current?.partyCombatants).toHaveLength(1);
expect(result.current?.partyCombatants[0].combatant.name).toBe("Hero");
expect(result.current?.partyCombatants[0].level).toBe(1);
expect(result.current?.partyCombatants[0].side).toBe("party");
});
});
it("combatant with explicit side override is placed correctly", () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c-1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c-2"),
name: "Allied Guard",
creatureId: goblinCreature.id,
side: "party",
}),
buildCombatant({
id: combatantId("c-3"),
name: "Thug",
cr: "1",
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[goblinCreature.id, goblinCreature]]),
});
const { result } = renderHook(() => useDifficultyBreakdown(), {
wrapper,
});
const breakdown = result.current;
expect(breakdown).not.toBeNull();
// Allied Guard should be in party column
expect(breakdown?.partyCombatants).toHaveLength(2);
expect(breakdown?.partyCombatants[1].combatant.name).toBe("Allied Guard");
expect(breakdown?.partyCombatants[1].side).toBe("party");
// Thug in enemy column
expect(breakdown?.enemyCombatants).toHaveLength(1);
expect(breakdown?.enemyCombatants[0].combatant.name).toBe("Thug");
});
});

View File

@@ -1,220 +0,0 @@
// @vitest-environment jsdom
import type {
Combatant,
CreatureId,
Encounter,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
vi.mock("../../contexts/player-characters-context.js", () => ({
usePlayerCharactersContext: vi.fn(),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
import { useEncounterContext } from "../../contexts/encounter-context.js";
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
import { useDifficulty } from "../use-difficulty.js";
const mockEncounterContext = vi.mocked(useEncounterContext);
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
const mockBestiaryContext = vi.mocked(useBestiaryContext);
const pcId1 = playerCharacterId("pc-1");
const pcId2 = playerCharacterId("pc-2");
const crId1 = creatureId("creature-1");
const _crId2 = creatureId("creature-2");
function setup(options: {
combatants: Combatant[];
characters: PlayerCharacter[];
creatures: Map<CreatureId, { cr: string }>;
}) {
const encounter = {
combatants: options.combatants,
activeIndex: 0,
roundNumber: 1,
} as Encounter;
mockEncounterContext.mockReturnValue({
encounter,
} as ReturnType<typeof useEncounterContext>);
mockPlayerCharactersContext.mockReturnValue({
characters: options.characters,
} as ReturnType<typeof usePlayerCharactersContext>);
mockBestiaryContext.mockReturnValue({
getCreature: (id: CreatureId) => options.creatures.get(id),
} as ReturnType<typeof useBestiaryContext>);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("useDifficulty", () => {
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
setup({
combatants: [
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
creatures: new Map([[crId1, { cr: "1/4" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).not.toBeNull();
expect(result.current?.tier).toBe("low");
expect(result.current?.totalMonsterXp).toBe(50);
});
describe("returns null when data is insufficient (ED-2)", () => {
it("returns null when encounter has no combatants", () => {
setup({ combatants: [], characters: [], creatures: new Map() });
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when only custom combatants (no creatureId)", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Custom",
playerCharacterId: pcId1,
},
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
creatures: new Map(),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when bestiary monsters present but no PC combatants", () => {
setup({
combatants: [
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
],
characters: [],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when PC combatants have no level", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
},
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when PC combatant references unknown player character", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId2,
},
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
});
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
// Party: one leveled PC, one without level (excluded)
// Monsters: one bestiary creature, one custom (excluded)
setup({
combatants: [
{
id: combatantId("c1"),
name: "Leveled",
playerCharacterId: pcId1,
},
{
id: combatantId("c2"),
name: "No Level",
playerCharacterId: pcId2,
},
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
{ id: combatantId("c4"), name: "Custom Monster" },
],
characters: [
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).not.toBeNull();
// 1 level-1 PC: budget low=50, mod=75, high=100
// 1 CR 1 monster: 200 XP → high (200 >= 100)
expect(result.current?.tier).toBe("high");
expect(result.current?.totalMonsterXp).toBe(200);
expect(result.current?.partyBudget.low).toBe(50);
});
it("includes duplicate PC combatants in budget", () => {
// Same PC added twice → counts twice
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero 1",
playerCharacterId: pcId1,
},
{
id: combatantId("c2"),
name: "Hero 2",
playerCharacterId: pcId1,
},
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
creatures: new Map([[crId1, { cr: "1/4" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).not.toBeNull();
// 2x level 1: budget low=100
expect(result.current?.partyBudget.low).toBe(100);
});
});

View File

@@ -0,0 +1,381 @@
// @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 pcId2 = playerCharacterId("pc-2");
const crId1 = creatureId("srd:goblin");
const goblinCreature = buildCreature({
id: crId1,
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", () => {
it("returns difficulty result for leveled PCs and bestiary monsters", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Goblin",
creatureId: crId1,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
],
creatures: new Map([[crId1, goblinCreature]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
await waitFor(() => {
expect(result.current).not.toBeNull();
expect(result.current?.tier).toBe("low");
expect(result.current?.totalMonsterXp).toBe(50);
});
});
describe("returns null when data is insufficient (ED-2)", () => {
it("returns null when encounter has no combatants", () => {
const wrapper = makeWrapper({
encounter: buildEncounter({ combatants: [] }),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
});
it("returns null when only custom combatants (no creatureId)", () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Custom",
playerCharacterId: pcId1,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
});
it("returns null when bestiary monsters present but no PC combatants", () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Goblin",
creatureId: crId1,
}),
],
}),
creatures: new Map([[crId1, goblinCreature]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
});
it("returns null when PC combatants have no level", () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Goblin",
creatureId: crId1,
}),
],
}),
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
creatures: new Map([[crId1, goblinCreature]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
});
it("returns null when PC combatant references unknown player character", () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId2,
}),
buildCombatant({
id: combatantId("c2"),
name: "Goblin",
creatureId: crId1,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[crId1, goblinCreature]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
});
});
it("handles mixed combatants: only leveled PCs and CR-bearing monsters contribute", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Leveled",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "No Level",
playerCharacterId: pcId2,
}),
buildCombatant({
id: combatantId("c3"),
name: "Goblin",
creatureId: crId1,
}),
buildCombatant({
id: combatantId("c4"),
name: "Custom Monster",
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
],
creatures: new Map([[crId1, goblinCreature]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
await waitFor(() => {
expect(result.current).not.toBeNull();
// 1 level-1 PC: budget low=50, mod=75, high=100
// CR 1/4 = 50 XP -> low (50 >= 50)
expect(result.current?.tier).toBe("low");
expect(result.current?.totalMonsterXp).toBe(50);
expect(result.current?.partyBudget.low).toBe(50);
});
});
it("includes duplicate PC combatants in budget", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero 1",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Hero 2",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c3"),
name: "Goblin",
creatureId: crId1,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
],
creatures: new Map([[crId1, goblinCreature]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
await waitFor(() => {
expect(result.current).not.toBeNull();
// 2x level 1: budget low=100
expect(result.current?.partyBudget.low).toBe(100);
});
});
it("combatant toggled to party side subtracts XP", async () => {
const bugbear = buildCreature({
id: creatureId("srd:bugbear"),
name: "Bugbear",
cr: "1",
});
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Allied Guard",
creatureId: bugbear.id,
side: "party",
}),
buildCombatant({
id: combatantId("c3"),
name: "Thug",
cr: "1",
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
],
creatures: new Map([[bugbear.id, bugbear]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
await waitFor(() => {
expect(result.current).not.toBeNull();
// Thug CR 1 = 200 XP, Allied Guard CR 1 = 200 XP subtracted, net = 0
expect(result.current?.totalMonsterXp).toBe(0);
expect(result.current?.tier).toBe("trivial");
});
});
it("default side resolution: PC -> party, non-PC -> enemy", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Goblin",
creatureId: crId1,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 3 },
],
creatures: new Map([[crId1, goblinCreature]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
await waitFor(() => {
expect(result.current).not.toBeNull();
// Level 3 budget: low=150, mod=225, high=400
// CR 1/4 = 50 XP -> trivial
expect(result.current?.partyBudget.low).toBe(150);
expect(result.current?.totalMonsterXp).toBe(50);
expect(result.current?.tier).toBe("trivial");
});
});
it("custom combatant with CR on party side subtracts XP", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Ally",
cr: "2",
side: "party",
}),
buildCombatant({
id: combatantId("c3"),
name: "Goblin",
creatureId: crId1,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[crId1, goblinCreature]]),
});
const { result } = renderHook(() => useDifficulty(), { wrapper });
await waitFor(() => {
expect(result.current).not.toBeNull();
// CR 1/4 = 50 XP enemy, CR 2 = 450 XP ally subtracted, net = 0 (floored)
expect(result.current?.totalMonsterXp).toBe(0);
});
});
});

View File

@@ -9,6 +9,7 @@ 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";
import { resolveSide } from "./use-difficulty.js";
export interface BreakdownCombatant {
readonly combatant: Combatant;
@@ -16,6 +17,8 @@ export interface BreakdownCombatant {
readonly xp: number | null;
readonly source: string | null;
readonly editable: boolean;
readonly side: "party" | "enemy";
readonly level: number | undefined;
}
interface DifficultyBreakdown {
@@ -27,7 +30,8 @@ interface DifficultyBreakdown {
readonly high: number;
};
readonly pcCount: number;
readonly combatants: readonly BreakdownCombatant[];
readonly partyCombatants: readonly BreakdownCombatant[];
readonly enemyCombatants: readonly BreakdownCombatant[];
}
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
@@ -36,105 +40,129 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
const { getCreature } = useBestiaryContext();
return useMemo(() => {
const partyLevels = derivePartyLevels(encounter.combatants, characters);
const { entries, crs } = classifyCombatants(
encounter.combatants,
getCreature,
const { partyCombatants, enemyCombatants, descriptors, pcCount } =
classifyCombatants(encounter.combatants, characters, getCreature);
const hasPartyLevel = descriptors.some(
(d) => d.side === "party" && d.level !== undefined,
);
const hasCr = descriptors.some((d) => d.cr !== undefined);
if (partyLevels.length === 0 || crs.length === 0) {
return null;
}
if (!hasPartyLevel || !hasCr) return null;
const result = calculateEncounterDifficulty(partyLevels, crs);
const result = calculateEncounterDifficulty(descriptors);
return {
...result,
pcCount: partyLevels.length,
combatants: entries,
pcCount,
partyCombatants,
enemyCombatants,
};
}, [encounter.combatants, characters, getCreature]);
}
function classifyBestiaryCombatant(
type CreatureInfo = {
cr: string;
source: string;
sourceDisplayName: string;
};
function buildBreakdownEntry(
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) {
side: "party" | "enemy",
level: number | undefined,
creature: CreatureInfo | undefined,
): BreakdownCombatant {
if (c.playerCharacterId) {
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,
},
side,
level,
};
}
if (creature) {
return {
combatant: c,
cr: creature.cr,
xp: crToXp(creature.cr),
source: creature.sourceDisplayName ?? creature.source,
editable: false,
side,
level: undefined,
};
}
if (c.cr) {
return {
combatant: c,
cr: c.cr,
xp: crToXp(c.cr),
source: null,
editable: true,
side,
level: undefined,
};
}
return {
combatant: c,
cr: null,
xp: null,
source: null,
editable: !c.creatureId,
side,
level: undefined,
};
}
function resolveLevel(
c: Combatant,
characters: readonly PlayerCharacter[],
): number | undefined {
if (!c.playerCharacterId) return undefined;
return characters.find((p) => p.id === c.playerCharacterId)?.level;
}
function resolveCr(
c: Combatant,
getCreature: (id: CreatureId) => CreatureInfo | undefined,
): { cr: string | null; creature: CreatureInfo | undefined } {
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
const cr = creature ? creature.cr : (c.cr ?? null);
return { cr, creature };
}
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[] = [];
getCreature: (id: CreatureId) => CreatureInfo | undefined,
) {
const partyCombatants: BreakdownCombatant[] = [];
const enemyCombatants: BreakdownCombatant[] = [];
const descriptors: {
level?: number;
cr?: string;
side: "party" | "enemy";
}[] = [];
let pcCount = 0;
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);
const side = resolveSide(c);
const level = resolveLevel(c, characters);
if (level !== undefined) pcCount++;
const { cr, creature } = resolveCr(c, getCreature);
if (level !== undefined || cr != null) {
descriptors.push({ level, cr: cr ?? undefined, side });
}
const entry = buildBreakdownEntry(c, side, level, creature);
const target = side === "party" ? partyCombatants : enemyCombatants;
target.push(entry);
}
return levels;
return { partyCombatants, enemyCombatants, descriptors, pcCount };
}

View File

@@ -1,5 +1,6 @@
import type {
Combatant,
CombatantDescriptor,
CreatureId,
DifficultyResult,
PlayerCharacter,
@@ -10,33 +11,31 @@ import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
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;
export function resolveSide(c: Combatant): "party" | "enemy" {
if (c.side) return c.side;
return c.playerCharacterId ? "party" : "enemy";
}
function deriveMonsterCrs(
function buildDescriptors(
combatants: readonly Combatant[],
characters: readonly PlayerCharacter[],
getCreature: (id: CreatureId) => { cr: string } | undefined,
): string[] {
const crs: string[] = [];
): CombatantDescriptor[] {
const descriptors: CombatantDescriptor[] = [];
for (const c of combatants) {
if (c.creatureId) {
const creature = getCreature(c.creatureId);
if (creature) crs.push(creature.cr);
} else if (c.cr) {
crs.push(c.cr);
const side = resolveSide(c);
const level = c.playerCharacterId
? characters.find((p) => p.id === c.playerCharacterId)?.level
: undefined;
const cr = c.creatureId
? getCreature(c.creatureId)?.cr
: (c.cr ?? undefined);
if (level !== undefined || cr !== undefined) {
descriptors.push({ level, cr, side });
}
}
return crs;
return descriptors;
}
export function useDifficulty(): DifficultyResult | null {
@@ -45,13 +44,19 @@ export function useDifficulty(): DifficultyResult | null {
const { getCreature } = useBestiaryContext();
return useMemo(() => {
const partyLevels = derivePartyLevels(encounter.combatants, characters);
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
const descriptors = buildDescriptors(
encounter.combatants,
characters,
getCreature,
);
if (partyLevels.length === 0 || monsterCrs.length === 0) {
return null;
}
const hasPartyLevel = descriptors.some(
(d) => d.side === "party" && d.level !== undefined,
);
const hasCr = descriptors.some((d) => d.cr !== undefined);
return calculateEncounterDifficulty(partyLevels, monsterCrs);
if (!hasPartyLevel || !hasCr) return null;
return calculateEncounterDifficulty(descriptors);
}, [encounter.combatants, characters, getCreature]);
}

View File

@@ -12,6 +12,7 @@ import {
setCrUseCase,
setHpUseCase,
setInitiativeUseCase,
setSideUseCase,
setTempHpUseCase,
toggleConcentrationUseCase,
toggleConditionUseCase,
@@ -54,6 +55,7 @@ type EncounterAction =
| { 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: "set-side"; id: CombatantId; value: "party" | "enemy" }
| {
type: "toggle-condition";
id: CombatantId;
@@ -321,6 +323,7 @@ function dispatchEncounterAction(
| { type: "set-temp-hp" }
| { type: "set-ac" }
| { type: "set-cr" }
| { type: "set-side" }
| { type: "toggle-condition" }
| { type: "toggle-concentration" }
>,
@@ -364,6 +367,9 @@ function dispatchEncounterAction(
case "set-cr":
result = setCrUseCase(store, action.id, action.value);
break;
case "set-side":
result = setSideUseCase(store, action.id, action.value);
break;
case "toggle-condition":
result = toggleConditionUseCase(store, action.id, action.conditionId);
break;
@@ -506,6 +512,11 @@ export function useEncounter() {
dispatch({ type: "set-cr", id, value }),
[],
),
setSide: useCallback(
(id: CombatantId, value: "party" | "enemy") =>
dispatch({ type: "set-side", id, value }),
[],
),
toggleCondition: useCallback(
(id: CombatantId, conditionId: ConditionId) =>
dispatch({ type: "toggle-condition", id, conditionId }),

View File

@@ -154,6 +154,47 @@ describe("loadEncounter", () => {
expect(loaded?.combatants[0].cr).toBe("2");
});
it("round-trip preserves combatant side field", () => {
const result = createEncounter(
[
{
id: combatantId("c-1"),
name: "Allied Guard",
cr: "2",
side: "party",
},
{
id: combatantId("c-2"),
name: "Goblin",
side: "enemy",
},
],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants[0].side).toBe("party");
expect(loaded?.combatants[1].side).toBe("enemy");
});
it("round-trip preserves combatant without side field as undefined", () => {
const result = createEncounter(
[{ id: combatantId("c-1"), name: "Custom" }],
0,
1,
);
if (isDomainError(result)) throw new Error("unreachable");
saveEncounter(result);
const loaded = loadEncounter();
expect(loaded).not.toBeNull();
expect(loaded?.combatants[0].side).toBeUndefined();
});
it("saving after modifications persists the latest state", () => {
const encounter = makeEncounter();
saveEncounter(encounter);