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);
}
}
}