Add encounter difficulty indicator (5.5e XP budget)
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:
126
packages/domain/src/encounter-difficulty.ts
Normal file
126
packages/domain/src/encounter-difficulty.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user