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

@@ -36,11 +36,27 @@ describe("crToXp", () => {
});
});
/** Helper to build party-side descriptors with level. */
function party(level: number) {
return { level, side: "party" as const };
}
/** Helper to build enemy-side descriptors with CR. */
function enemy(cr: string) {
return { cr, side: "enemy" as const };
}
describe("calculateEncounterDifficulty", () => {
it("returns trivial when monster XP is below Low threshold", () => {
// 4x level 1: Low = 200, Moderate = 300, High = 400
// 1x CR 0 = 0 XP trivial
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["0"]);
// 1x CR 0 = 0 XP -> trivial
const result = calculateEncounterDifficulty([
party(1),
party(1),
party(1),
party(1),
enemy("0"),
]);
expect(result.tier).toBe("trivial");
expect(result.totalMonsterXp).toBe(0);
expect(result.partyBudget).toEqual({
@@ -51,20 +67,29 @@ describe("calculateEncounterDifficulty", () => {
});
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
// DMG example: 4x level 1 PCs vs 1 Bugbear (CR 1, 200 XP)
// Low = 200, Moderate = 300 → 200 >= 200 but < 300 → Low
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1"]);
const result = calculateEncounterDifficulty([
party(1),
party(1),
party(1),
party(1),
enemy("1"),
]);
expect(result.tier).toBe("low");
expect(result.totalMonsterXp).toBe(200);
});
it("returns moderate for 5x level 3 vs 1125 XP", () => {
it("returns moderate for 5x level 3 vs 1150 XP", () => {
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
// 1125 XP >= 1125 Moderate but < 2000 High → Moderate
// Using CR 3 (700) + CR 2 (450) = 1150 XP ≈ 1125 threshold
// Let's use exact: 5 * 225 = 1125 moderate budget
// Need monsters that sum exactly to 1125: CR 3 (700) + CR 2 (450) = 1150
const result = calculateEncounterDifficulty([3, 3, 3, 3, 3], ["3", "2"]);
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
const result = calculateEncounterDifficulty([
party(3),
party(3),
party(3),
party(3),
party(3),
enemy("3"),
enemy("2"),
]);
expect(result.tier).toBe("moderate");
expect(result.totalMonsterXp).toBe(1150);
expect(result.partyBudget.moderate).toBe(1125);
@@ -72,26 +97,41 @@ describe("calculateEncounterDifficulty", () => {
it("returns high when XP meets High threshold", () => {
// 4x level 1: High = 400
// 2x CR 1 = 400 XP High
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1", "1"]);
// 2x CR 1 = 400 XP -> High
const result = calculateEncounterDifficulty([
party(1),
party(1),
party(1),
party(1),
enemy("1"),
enemy("1"),
]);
expect(result.tier).toBe("high");
expect(result.totalMonsterXp).toBe(400);
});
it("caps at high when XP far exceeds threshold", () => {
// 4x level 1: High = 400
// CR 30 = 155000 XP → still High (no tier above)
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["30"]);
const result = calculateEncounterDifficulty([
party(1),
party(1),
party(1),
party(1),
enemy("30"),
]);
expect(result.tier).toBe("high");
expect(result.totalMonsterXp).toBe(155000);
});
it("handles mixed party levels", () => {
// 3x level 3 + 1x level 2
// level 3: low=150, mod=225, high=400 (x3 = 450, 675, 1200)
// level 2: low=100, mod=150, high=200 (x1 = 100, 150, 200)
// Total: low=550, mod=825, high=1400
const result = calculateEncounterDifficulty([3, 3, 3, 2], ["3"]);
const result = calculateEncounterDifficulty([
party(3),
party(3),
party(3),
party(2),
enemy("3"),
]);
expect(result.partyBudget).toEqual({
low: 550,
moderate: 825,
@@ -101,33 +141,110 @@ describe("calculateEncounterDifficulty", () => {
expect(result.tier).toBe("low");
});
it("returns trivial with empty monster array", () => {
const result = calculateEncounterDifficulty([5, 5], []);
it("returns trivial with no enemies", () => {
const result = calculateEncounterDifficulty([party(5), party(5)]);
expect(result.tier).toBe("trivial");
expect(result.totalMonsterXp).toBe(0);
});
it("returns high with empty party array (zero budget thresholds)", () => {
// Domain function treats empty party as zero budgets — any XP exceeds all thresholds.
// The useDifficulty hook guards this path by returning null when no leveled PCs exist.
const result = calculateEncounterDifficulty([], ["1"]);
it("returns high with no party levels (zero budget thresholds)", () => {
const result = calculateEncounterDifficulty([enemy("1")]);
expect(result.tier).toBe("high");
expect(result.totalMonsterXp).toBe(200);
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
});
it("handles fractional CRs", () => {
const result = calculateEncounterDifficulty(
[1, 1, 1, 1],
["1/8", "1/4", "1/2"],
);
const result = calculateEncounterDifficulty([
party(1),
party(1),
party(1),
party(1),
enemy("1/8"),
enemy("1/4"),
enemy("1/2"),
]);
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
expect(result.tier).toBe("trivial"); // 175 < 200 Low
});
it("ignores unknown CRs (0 XP)", () => {
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["unknown"]);
const result = calculateEncounterDifficulty([
party(1),
party(1),
party(1),
party(1),
enemy("unknown"),
]);
expect(result.totalMonsterXp).toBe(0);
expect(result.tier).toBe("trivial");
});
it("subtracts XP for party-side combatant with CR", () => {
// 4x level 1 party, 1 enemy CR 2 (450 XP), 1 party CR 1 (200 XP)
// Net = 450 - 200 = 250
const result = calculateEncounterDifficulty([
party(1),
party(1),
party(1),
party(1),
enemy("2"),
{ cr: "1", side: "party" },
]);
expect(result.totalMonsterXp).toBe(250);
expect(result.tier).toBe("low"); // 250 >= 200 Low, < 300 Moderate
});
it("floors net monster XP at 0", () => {
// Party ally has more XP than enemy
const result = calculateEncounterDifficulty([
party(1),
{ cr: "5", side: "party" }, // 1800 XP subtracted
enemy("1"), // 200 XP added
]);
expect(result.totalMonsterXp).toBe(0);
expect(result.tier).toBe("trivial");
});
it("dual contribution: combatant with both level and CR on party side", () => {
// Party combatant with level 1 AND CR 1 on party side
// Level contributes to budget, CR subtracts from monster XP
const result = calculateEncounterDifficulty([
{ level: 1, cr: "1", side: "party" }, // budget += lv1, monsterXp -= 200
enemy("2"), // monsterXp += 450
]);
expect(result.partyBudget).toEqual({ low: 50, moderate: 75, high: 100 });
expect(result.totalMonsterXp).toBe(250); // 450 - 200
});
it("enemy-side combatant with level does NOT contribute to budget", () => {
const result = calculateEncounterDifficulty([
party(1),
{ level: 5, side: "enemy" }, // should not add to budget
enemy("1"),
]);
// Only level 1 party contributes to budget
expect(result.partyBudget).toEqual({ low: 50, moderate: 75, high: 100 });
expect(result.totalMonsterXp).toBe(200);
});
it("mixed sides calculate correctly", () => {
// 2 party PCs (level 3), 1 party ally (CR 1, 200 XP), 2 enemies (CR 2, 450 each)
// Budget: 2x level 3 = low 300, mod 450, high 800
// Monster XP: 900 - 200 = 700
const result = calculateEncounterDifficulty([
party(3),
party(3),
{ cr: "1", side: "party" },
enemy("2"),
enemy("2"),
]);
expect(result.partyBudget).toEqual({
low: 300,
moderate: 450,
high: 800,
});
expect(result.totalMonsterXp).toBe(700);
expect(result.tier).toBe("moderate"); // 700 >= 450 Moderate, < 800 High
});
});