// 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> = { 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 = { 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 ✓"); }