Add encounter difficulty indicator (5.5e XP budget)
All checks were successful
CI / check (push) Successful in 1m13s
CI / build-image (push) Successful in 16s

Live 3-bar difficulty indicator in the top bar showing encounter
difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP
budget system. Automatically derived from PC levels and bestiary
creature CRs.

- Add optional level field (1-20) to PlayerCharacter
- Add CR-to-XP and XP Budget per Character lookup tables in domain
- Add calculateEncounterDifficulty pure function
- Add DifficultyIndicator component with color-coded bars and tooltip
- Add useDifficulty hook composing encounter, PC, and bestiary contexts
- Indicator hidden when no PCs with levels or no bestiary-linked monsters
- Level field in PC create/edit forms, persisted in storage

Closes #18

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-27 22:55:48 +01:00
parent 36122b500b
commit ef76b9c90b
32 changed files with 1648 additions and 11 deletions

View File

@@ -23,6 +23,7 @@ function success(
maxHp,
color,
icon,
undefined,
);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
@@ -241,4 +242,76 @@ describe("createPlayerCharacter", () => {
expect(events).toHaveLength(1);
expect(events[0].type).toBe("PlayerCharacterCreated");
});
it("creates a player character with a valid level", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
"sword",
5,
);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].level).toBe(5);
});
it("creates a player character without a level", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
"sword",
undefined,
);
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].level).toBeUndefined();
});
it("rejects level below 1", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
"sword",
0,
);
expectDomainError(result, "invalid-level");
});
it("rejects level above 20", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
"sword",
21,
);
expectDomainError(result, "invalid-level");
});
it("rejects non-integer level", () => {
const result = createPlayerCharacter(
[],
id,
"Test",
10,
50,
"blue",
"sword",
3.5,
);
expectDomainError(result, "invalid-level");
});
});

View File

@@ -110,4 +110,33 @@ describe("editPlayerCharacter", () => {
expect(event.oldName).toBe("Aragorn");
expect(event.newName).toBe("Strider");
});
it("sets level on a player character", () => {
const result = editPlayerCharacter([makePC()], id, { level: 5 });
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].level).toBe(5);
});
it("clears level when set to null", () => {
const result = editPlayerCharacter([makePC({ level: 5 })], id, {
level: null,
});
if (isDomainError(result)) throw new Error(result.message);
expect(result.characters[0].level).toBeUndefined();
});
it("rejects invalid level", () => {
const result = editPlayerCharacter([makePC()], id, { level: 0 });
expectDomainError(result, "invalid-level");
});
it("rejects level above 20", () => {
const result = editPlayerCharacter([makePC()], id, { level: 21 });
expectDomainError(result, "invalid-level");
});
it("rejects non-integer level", () => {
const result = editPlayerCharacter([makePC()], id, { level: 3.5 });
expectDomainError(result, "invalid-level");
});
});

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import {
calculateEncounterDifficulty,
crToXp,
} from "../encounter-difficulty.js";
describe("crToXp", () => {
it("returns 0 for CR 0", () => {
expect(crToXp("0")).toBe(0);
});
it("returns 25 for CR 1/8", () => {
expect(crToXp("1/8")).toBe(25);
});
it("returns 50 for CR 1/4", () => {
expect(crToXp("1/4")).toBe(50);
});
it("returns 100 for CR 1/2", () => {
expect(crToXp("1/2")).toBe(100);
});
it("returns 200 for CR 1", () => {
expect(crToXp("1")).toBe(200);
});
it("returns 155000 for CR 30", () => {
expect(crToXp("30")).toBe(155000);
});
it("returns 0 for unknown CR", () => {
expect(crToXp("99")).toBe(0);
expect(crToXp("")).toBe(0);
expect(crToXp("abc")).toBe(0);
});
});
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"]);
expect(result.tier).toBe("trivial");
expect(result.totalMonsterXp).toBe(0);
expect(result.partyBudget).toEqual({
low: 200,
moderate: 300,
high: 400,
});
});
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"]);
expect(result.tier).toBe("low");
expect(result.totalMonsterXp).toBe(200);
});
it("returns moderate for 5x level 3 vs 1125 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"]);
expect(result.tier).toBe("moderate");
expect(result.totalMonsterXp).toBe(1150);
expect(result.partyBudget.moderate).toBe(1125);
});
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"]);
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"]);
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"]);
expect(result.partyBudget).toEqual({
low: 550,
moderate: 825,
high: 1400,
});
expect(result.totalMonsterXp).toBe(700);
expect(result.tier).toBe("low");
});
it("returns trivial with empty monster array", () => {
const result = calculateEncounterDifficulty([5, 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"]);
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"],
);
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"]);
expect(result.totalMonsterXp).toBe(0);
expect(result.tier).toBe("trivial");
});
});