Files
dm-pal/src/lib/encounter-budget.ts
T
2026-08-09 13:49:54 +01:00

107 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ponytail: 5e DMG p.82 XP thresholds per character. Single source of truth
// for the encounter builder's budget math. The "Easy/Medium/Hard/Deadly"
// thresholds are *per character*; the table is indexed by character level.
export type Difficulty = "Easy" | "Medium" | "Hard" | "Deadly";
const THRESHOLDS: Record<number, Record<Difficulty, number>> = {
1: { Easy: 25, Medium: 50, Hard: 75, Deadly: 100 },
2: { Easy: 50, Medium: 100, Hard: 150, Deadly: 200 },
3: { Easy: 75, Medium: 150, Hard: 225, Deadly: 400 },
4: { Easy: 125, Medium: 250, Hard: 375, Deadly: 500 },
5: { Easy: 250, Medium: 500, Hard: 750, Deadly: 1100 },
6: { Easy: 300, Medium: 600, Hard: 900, Deadly: 1400 },
7: { Easy: 350, Medium: 750, Hard: 1100, Deadly: 1700 },
8: { Easy: 450, Medium: 900, Hard: 1400, Deadly: 2100 },
9: { Easy: 550, Medium: 1100, Hard: 1600, Deadly: 2400 },
10: { Easy: 600, Medium: 1200, Hard: 1900, Deadly: 2800 },
11: { Easy: 800, Medium: 1600, Hard: 2400, Deadly: 3600 },
12: { Easy: 1000, Medium: 2000, Hard: 3000, Deadly: 4500 },
13: { Easy: 1100, Medium: 2200, Hard: 3400, Deadly: 5100 },
14: { Easy: 1250, Medium: 2500, Hard: 3800, Deadly: 5700 },
15: { Easy: 1400, Medium: 2800, Hard: 4300, Deadly: 6400 },
16: { Easy: 1600, Medium: 3200, Hard: 4800, Deadly: 7200 },
17: { Easy: 2000, Medium: 3900, Hard: 5900, Deadly: 8800 },
18: { Easy: 2100, Medium: 4200, Hard: 6300, Deadly: 9500 },
19: { Easy: 2400, Medium: 4900, Hard: 7300, Deadly: 10900 },
20: { Easy: 2800, Medium: 5700, Hard: 8500, Deadly: 12700 },
};
export interface Budget {
easy: number;
medium: number;
hard: number;
deadly: number;
}
/** Compute per-difficulty XP budgets for a party. Clamps level to 1..20. */
export function encounterBudget(partyLevel: number, partySize: number): Budget {
const lvl = Math.max(1, Math.min(20, Math.round(partyLevel)));
const size = Math.max(1, Math.round(partySize));
const t = THRESHOLDS[lvl];
return {
easy: t.Easy * size,
medium: t.Medium * size,
hard: t.Hard * size,
deadly: t.Deadly * size,
};
}
/** Map an XP total to a difficulty bucket. */
export function difficultyForXp(xp: number, budget: Budget): Difficulty {
if (xp >= budget.deadly) return "Deadly";
if (xp >= budget.hard) return "Hard";
if (xp >= budget.medium) return "Medium";
if (xp >= budget.easy) return "Easy";
return "Easy";
}
export const DIFFICULTY_COLOR: Record<Difficulty, string> = {
Easy: "var(--color-success)",
Medium: "var(--color-info)",
Hard: "var(--color-gold-bright)",
Deadly: "var(--color-danger)",
};
// ─── Self-check (run: node scripts/check-encounter-budget.ts) ────
// ponytail: the smallest thing that fails if the budget math breaks. No
// framework — plain asserts. Verifies the per-level table scales, the
// party-size multiplier, level clamping, and the XP→difficulty bucketing
// (the boundary the DMG relies on: a budget-equal XP is the *next* tier).
function assert(cond: boolean, msg: string): void {
if (!cond) throw new Error(`encounter-budget self-check failed: ${msg}`);
}
export function demo(): void {
// Level 5, 4 PCs — DMG p.82 per-character: Easy 250 / Med 500 / Hard 750 / Deadly 1100.
const b = encounterBudget(5, 4);
assert(b.easy === 1000, "L5×4 easy = 250×4");
assert(b.medium === 2000, "L5×4 medium = 500×4");
assert(b.hard === 3000, "L5×4 hard = 750×4");
assert(b.deadly === 4400, "L5×4 deadly = 1100×4");
// Solo L1 character.
const solo = encounterBudget(1, 1);
assert(solo.easy === 25 && solo.deadly === 100, "L1×1 matches table");
// Level clamps to 1..20; size clamps to >=1.
const low = encounterBudget(0, 1);
assert(low.easy === 25, "level clamps up to 1");
const high = encounterBudget(99, 1);
assert(high.deadly === 12700, "level clamps down to 20");
const zero = encounterBudget(5, 0);
assert(zero.medium === 500, "size clamps up to 1");
// difficultyForXp: budget-equal XP lands in the *next* tier up (>=, not >),
// and anything below the easy floor is still Easy (never undefined).
const d = encounterBudget(5, 4);
assert(difficultyForXp(999, d) === "Easy", "below easy floor stays Easy");
assert(difficultyForXp(1000, d) === "Easy", "== easy budget is Easy");
assert(difficultyForXp(2000, d) === "Medium", "== medium budget is Medium");
assert(difficultyForXp(3000, d) === "Hard", "== hard budget is Hard");
assert(difficultyForXp(4400, d) === "Deadly", "== deadly budget is Deadly");
assert(difficultyForXp(9999, d) === "Deadly", "over deadly stays Deadly");
console.log("encounter-budget self-check passed ✓");
}