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

@@ -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,
},
};
}