import { type ClassId, type RaceId, } from "../app/characterCatalog"; import azerothCoreReference from "./generated/azerothCoreReference.json"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { deriveCharacterStats } from "./wotlkStats"; export const MIN_PLAYER_LEVEL = 1; export const MAX_PLAYER_LEVEL = 80; export interface ProgressionSnapshot { readonly level: number; readonly xp: number; } export interface ExperienceAwardResult extends ProgressionSnapshot { readonly levelsGained: number; readonly xpToNext: number; readonly reachedLevelCap: boolean; } interface AzerothCoreProgressionReference { readonly player: { readonly xpForLevel: readonly (number | null)[]; }; } const AZEROTHCORE_XP = ( azerothCoreReference as AzerothCoreProgressionReference ).player.xpForLevel; export function clampPlayerLevel(level: number): number { if (!Number.isFinite(level)) return MIN_PLAYER_LEVEL; return Math.max(MIN_PLAYER_LEVEL, Math.min(MAX_PLAYER_LEVEL, Math.trunc(level))); } export function legacyHealerManXpRequiredForNextLevel(level: number): number { const safeLevel = clampPlayerLevel(level); if (safeLevel >= MAX_PLAYER_LEVEL) return 0; return 100 + (safeLevel - 1) * 50; } /** Exact AzerothCore `player_xp_for_level` threshold for the pinned snapshot. */ export function xpRequiredForNextLevel(level: number): number { const safeLevel = clampPlayerLevel(level); if (safeLevel >= MAX_PLAYER_LEVEL) return 0; const threshold = AZEROTHCORE_XP[safeLevel - 1]; return Number.isFinite(threshold) && Number(threshold) > 0 ? Number(threshold) : legacyHealerManXpRequiredForNextLevel(safeLevel); } /** * Converts the established encounter reward into the authoritative XP scale * without changing the fraction of a level earned by that reward. */ export function healerManExperienceAward(amount: number, level: number): number { const safeAmount = Number.isFinite(amount) ? Math.max(0, amount) : 0; const legacyThreshold = legacyHealerManXpRequiredForNextLevel(level); const serverThreshold = xpRequiredForNextLevel(level); if (legacyThreshold <= 0 || serverThreshold <= 0) return 0; return Math.max(0, Math.round(safeAmount / legacyThreshold * serverThreshold)); } export function normalizeProgression(level: number, xp: number): ProgressionSnapshot { let nextLevel = clampPlayerLevel(level); let nextXp = Number.isFinite(xp) ? Math.max(0, Math.trunc(xp)) : 0; while (nextLevel < MAX_PLAYER_LEVEL) { const threshold = xpRequiredForNextLevel(nextLevel); if (nextXp < threshold) break; nextXp -= threshold; nextLevel += 1; } if (nextLevel >= MAX_PLAYER_LEVEL) nextXp = 0; return { level: nextLevel, xp: nextXp }; } export function awardExperience( progression: ProgressionSnapshot, amount: number, ): ExperienceAwardResult { const starting = normalizeProgression(progression.level, progression.xp); const award = Number.isFinite(amount) ? Math.max(0, Math.trunc(amount)) : 0; const normalized = normalizeProgression(starting.level, starting.xp + award); return { ...normalized, levelsGained: normalized.level - starting.level, xpToNext: xpRequiredForNextLevel(normalized.level), reachedLevelCap: normalized.level >= MAX_PLAYER_LEVEL, }; } export function totalExperienceForLevel(level: number): number { const target = clampPlayerLevel(level); let total = 0; for (let current = MIN_PLAYER_LEVEL; current < target; current += 1) { total += xpRequiredForNextLevel(current); } return total; } export function maxHealthForLevel( classId: ClassId, level: number, raceId: RaceId = "human", ): number { return deriveCharacterStats( classId, clampPlayerLevel(level), EMPTY_ITEM_STATS, raceId, ).health; } export function playerCombatPower(level: number): number { return 12 + (clampPlayerLevel(level) - 1) * 3; } export function scaledCombatAmount(baseCoefficient: number, level: number): number { return Math.max(0, Math.round(baseCoefficient * playerCombatPower(level))); } export function levelProgressPercent(level: number, xp: number): number { const threshold = xpRequiredForNextLevel(level); if (threshold <= 0) return 100; return Math.max(0, Math.min(100, (Math.max(0, xp) / threshold) * 100)); }