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

@@ -15,6 +15,7 @@ export function createPlayerCharacterUseCase(
maxHp: number,
color: string | undefined,
icon: string | undefined,
level?: number,
): DomainEvent[] | DomainError {
const characters = store.getAll();
const result = createPlayerCharacter(
@@ -25,6 +26,7 @@ export function createPlayerCharacterUseCase(
maxHp,
color,
icon,
level,
);
if (isDomainError(result)) {

View File

@@ -13,6 +13,7 @@ interface EditFields {
readonly maxHp?: number;
readonly color?: string | null;
readonly icon?: string | null;
readonly level?: number | null;
}
export function editPlayerCharacterUseCase(

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

View File

@@ -22,6 +22,7 @@ export function createPlayerCharacter(
maxHp: number,
color: string | undefined,
icon: string | undefined,
level?: number,
): CreatePlayerCharacterSuccess | DomainError {
const trimmed = name.trim();
@@ -65,6 +66,17 @@ export function createPlayerCharacter(
};
}
if (
level !== undefined &&
(!Number.isInteger(level) || level < 1 || level > 20)
) {
return {
kind: "domain-error",
code: "invalid-level",
message: "Level must be an integer between 1 and 20",
};
}
const newCharacter: PlayerCharacter = {
id,
name: trimmed,
@@ -72,6 +84,7 @@ export function createPlayerCharacter(
maxHp,
color: color as PlayerCharacter["color"],
icon: icon as PlayerCharacter["icon"],
level,
};
return {

View File

@@ -20,6 +20,7 @@ interface EditFields {
readonly maxHp?: number;
readonly color?: string | null;
readonly icon?: string | null;
readonly level?: number | null;
}
function validateFields(fields: EditFields): DomainError | null {
@@ -72,6 +73,17 @@ function validateFields(fields: EditFields): DomainError | null {
message: `Invalid icon: ${fields.icon}`,
};
}
if (
fields.level !== undefined &&
fields.level !== null &&
(!Number.isInteger(fields.level) || fields.level < 1 || fields.level > 20)
) {
return {
kind: "domain-error",
code: "invalid-level",
message: "Level must be an integer between 1 and 20",
};
}
return null;
}
@@ -92,6 +104,8 @@ function applyFields(
fields.icon === undefined
? existing.icon
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
level:
fields.level === undefined ? existing.level : (fields.level ?? undefined),
};
}
@@ -120,7 +134,8 @@ export function editPlayerCharacter(
updated.ac === existing.ac &&
updated.maxHp === existing.maxHp &&
updated.color === existing.color &&
updated.icon === existing.icon
updated.icon === existing.icon &&
updated.level === existing.level
) {
return {
kind: "domain-error",

View File

@@ -0,0 +1,126 @@
export type DifficultyTier = "trivial" | "low" | "moderate" | "high";
export interface DifficultyResult {
readonly tier: DifficultyTier;
readonly totalMonsterXp: number;
readonly partyBudget: {
readonly low: number;
readonly moderate: number;
readonly high: number;
};
}
/** Maps challenge rating strings to XP values (standard 5e). */
const CR_TO_XP: Readonly<Record<string, number>> = {
"0": 0,
"1/8": 25,
"1/4": 50,
"1/2": 100,
"1": 200,
"2": 450,
"3": 700,
"4": 1100,
"5": 1800,
"6": 2300,
"7": 2900,
"8": 3900,
"9": 5000,
"10": 5900,
"11": 7200,
"12": 8400,
"13": 10000,
"14": 11500,
"15": 13000,
"16": 15000,
"17": 18000,
"18": 20000,
"19": 22000,
"20": 25000,
"21": 33000,
"22": 41000,
"23": 50000,
"24": 62000,
"25": 75000,
"26": 90000,
"27": 105000,
"28": 120000,
"29": 135000,
"30": 155000,
};
/** Maps character level (1-20) to XP budget thresholds (2024 5.5e DMG). */
const XP_BUDGET_PER_CHARACTER: Readonly<
Record<number, { low: number; moderate: number; high: number }>
> = {
1: { low: 50, moderate: 75, high: 100 },
2: { low: 100, moderate: 150, high: 200 },
3: { low: 150, moderate: 225, high: 400 },
4: { low: 250, moderate: 375, high: 500 },
5: { low: 500, moderate: 750, high: 1100 },
6: { low: 600, moderate: 1000, high: 1400 },
7: { low: 750, moderate: 1300, high: 1700 },
8: { low: 1000, moderate: 1700, high: 2100 },
9: { low: 1300, moderate: 2000, high: 2600 },
10: { low: 1600, moderate: 2300, high: 3100 },
11: { low: 1900, moderate: 2900, high: 4100 },
12: { low: 2200, moderate: 3700, high: 4700 },
13: { low: 2600, moderate: 4200, high: 5400 },
14: { low: 2900, moderate: 4900, high: 6200 },
15: { low: 3300, moderate: 5400, high: 7800 },
16: { low: 3800, moderate: 6100, high: 9800 },
17: { low: 4500, moderate: 7200, high: 11700 },
18: { low: 5000, moderate: 8700, high: 14200 },
19: { low: 5500, moderate: 10700, high: 17200 },
20: { low: 6400, moderate: 13200, high: 22000 },
};
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
export function crToXp(cr: string): number {
return CR_TO_XP[cr] ?? 0;
}
/**
* Calculates encounter difficulty from party levels and monster CRs.
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
*/
export function calculateEncounterDifficulty(
partyLevels: readonly number[],
monsterCrs: readonly string[],
): DifficultyResult {
let budgetLow = 0;
let budgetModerate = 0;
let budgetHigh = 0;
for (const level of partyLevels) {
const budget = XP_BUDGET_PER_CHARACTER[level];
if (budget) {
budgetLow += budget.low;
budgetModerate += budget.moderate;
budgetHigh += budget.high;
}
}
let totalMonsterXp = 0;
for (const cr of monsterCrs) {
totalMonsterXp += crToXp(cr);
}
let tier: DifficultyTier = "trivial";
if (totalMonsterXp >= budgetHigh) {
tier = "high";
} else if (totalMonsterXp >= budgetModerate) {
tier = "moderate";
} else if (totalMonsterXp >= budgetLow) {
tier = "low";
}
return {
tier,
totalMonsterXp,
partyBudget: {
low: budgetLow,
moderate: budgetModerate,
high: budgetHigh,
},
};
}

View File

@@ -48,6 +48,12 @@ export {
type EditPlayerCharacterSuccess,
editPlayerCharacter,
} from "./edit-player-character.js";
export {
calculateEncounterDifficulty,
crToXp,
type DifficultyResult,
type DifficultyTier,
} from "./encounter-difficulty.js";
export type {
AcSet,
CombatantAdded,

View File

@@ -74,6 +74,7 @@ export interface PlayerCharacter {
readonly maxHp: number;
readonly color?: PlayerColor;
readonly icon?: PlayerIcon;
readonly level?: number;
}
export interface PlayerCharacterList {