namespace Aeshnidae.Awakens; /// /// Every awakening raises the character's level cap by one (Tom, 2026-09-19: /// "Every awaken increases your max level capacity by 1, and you still need to /// be max level to awaken"). Retail 275 for a character that has never awakened, /// 276 after the first, 277 after the second. /// /// ACE has one cap for everyone: Player.GetMaxLevel() is the length of the /// client's XP table, and Player.UpdateXpAndLevel / CheckForLevelup stop at it. /// Two things make a per-character cap out of that: /// /// 1. the table is extended in memory past retail (Aeshnidae.MaxLevel's curve: /// each level's cost grows by (L + 6) / (L + 2), the law the retail table /// follows), with no skill credits on the new levels - so the server has /// an XP total for level 276 and up to check against; /// 2. the two methods that climb the table are taken over (Patches.cs) and /// stop at the character's own cap instead of the table's end. /// /// GetMaxLevel() itself is left alone - it is two instructions and the JIT /// inlines it into its callers, so a patch on it would not be seen. The places /// that still call it see the table's end, and each of them is fine with that: /// they clamp a level argument, or compute an XP remainder that our take-over /// then trims. /// public static class LevelCap { /// The cap the dat shipped, captured the first time the table is touched. 275. public static int RetailMaxLevel { get; private set; } /// How far the in-memory table currently reaches. public static int TableMaxLevel => (DatManager.PortalDat?.XpTable?.CharacterLevelXPList.Count ?? 1) - 1; /// Levels this mod added to the table, so Revert removes exactly those. private static int _added; /// The level this character may climb to: retail plus one per awakening. public static int Of(Player player) { var retail = RetailMaxLevel > 0 ? RetailMaxLevel : TableMaxLevel; var cap = retail + Awakening.CountOf(player) * Mod.Settings.LevelsPerAwakening; // never past the table; the headroom setting says how many awakenings we planned for return Math.Min(cap, TableMaxLevel); } /// Total XP at the top of this character's climb. public static ulong XpAt(int level) { var xp = DatManager.PortalDat.XpTable.CharacterLevelXPList; return xp[Math.Clamp(level, 0, xp.Count - 1)]; } private static double GrowthRatio(int level) => (level + 6.0) / (level + 2.0); /// /// Grow the table to retail + LevelCapHeadroom. Idempotent; leaves a table /// someone else (Aeshnidae.MaxLevel) already grew further alone. /// public static void Extend(string reason) { try { var table = DatManager.PortalDat?.XpTable; if (table is null) return; // cold start; the DatManager postfix calls again once the dats are in var xp = table.CharacterLevelXPList; var credits = table.CharacterLevelSkillCreditList; if (xp.Count == 0 || xp.Count != credits.Count) { ModManager.Log($"[{Mod.Name}] XP table is empty or inconsistent ({xp.Count} xp / {credits.Count} credits); level caps stay at the table's end", ModManager.LogLevel.Warn); return; } if (RetailMaxLevel == 0) RetailMaxLevel = xp.Count - 1; var target = RetailMaxLevel + Math.Max(0, Mod.Settings.LevelCapHeadroom); if (xp.Count - 1 >= target) { ModManager.Log($"[{Mod.Name}] XP table already reaches {xp.Count - 1} (retail {RetailMaxLevel}); nothing to add ({reason})"); return; } var lastTotal = xp[^1]; var lastDelta = xp[^1] - xp[^2]; for (var level = xp.Count; level <= target; level++) { lastDelta = (ulong)Math.Round(lastDelta * GrowthRatio(level)); lastTotal += lastDelta; xp.Add(lastTotal); credits.Add(0); _added++; } ModManager.Log($"[{Mod.Name}] XP table {RetailMaxLevel} -> {target}: room for {target - RetailMaxLevel} awakenings, {xp[target]:N0} total XP at {target} ({reason})"); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not extend the XP table: {ex.Message}", ModManager.LogLevel.Error); } } /// Take back exactly the levels this mod added, so unloading leaves the dat as shipped. public static void Revert() { try { var table = DatManager.PortalDat?.XpTable; if (table is null || _added == 0) return; var xp = table.CharacterLevelXPList; var credits = table.CharacterLevelSkillCreditList; var remove = Math.Min(_added, xp.Count - (RetailMaxLevel + 1)); if (remove > 0) { xp.RemoveRange(xp.Count - remove, remove); credits.RemoveRange(credits.Count - remove, remove); } _added = 0; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not revert the XP table: {ex.Message}", ModManager.LogLevel.Warn); } } }