Add combatant side assignment for encounter difficulty
Combatants can now be assigned to party or enemy side via a toggle in the difficulty breakdown panel. Party-side NPCs subtract their XP from the encounter total, letting allied NPCs reduce difficulty. PCs default to party, non-PCs to enemy — users who don't use sides see no change. Side persists across reload and export/import. Closes #22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
381
apps/web/src/hooks/__tests__/use-difficulty.test.tsx
Normal file
381
apps/web/src/hooks/__tests__/use-difficulty.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user