107 lines
4.5 KiB
TypeScript
107 lines
4.5 KiB
TypeScript
// 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 ✓");
|
||
}
|