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

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