namespace Aeshnidae.MaxLevel;
///
/// Extends the client XP table in memory so the server accepts levels past retail.
///
/// Player.GetMaxLevel() is literally
/// DatManager.PortalDat.XpTable.CharacterLevelXPList.Count - 1
/// and CheckForLevelup indexes CharacterLevelSkillCreditList by level, so both
/// lists have to grow together or levelling past 275 throws IndexOutOfRange.
/// Both are `{ get; }` List properties - the objects are mutable even though the
/// properties are read-only, so no reflection is needed.
///
public static class LevelTable
{
/// Retail cap, captured the first time we touch the table.
public static int RetailMaxLevel { get; private set; }
public static int CurrentMaxLevel { get; private set; }
public static bool Extended => CurrentMaxLevel > RetailMaxLevel && RetailMaxLevel > 0;
/// Credits added to DeveloperFixCommands.AdditionalCredits, so we can remove exactly those.
private static readonly List _addedCreditLevels = new();
///
/// The retail curve's growth law.
///
/// Successive level costs satisfy delta[L] / delta[L-1] == (L + 6) / (L + 2),
/// which holds across the whole retail table and is essentially exact at the
/// top of it - at L=275 it predicts 1.01444043 against 1.01444015 measured,
/// and the small drift at low levels is just integer rounding in the dat.
/// Continuing that recurrence is what "keep retail scaling" means here.
///
private static double GrowthRatio(int level) => (level + 6.0) / (level + 2.0);
public static bool TryExtend(XpTable table, Settings settings, out string message)
{
message = "";
if (table is null)
{
message = "XpTable not loaded yet";
return false;
}
var xp = table.CharacterLevelXPList;
var credits = table.CharacterLevelSkillCreditList;
if (xp.Count == 0 || credits.Count == 0)
{
message = "XpTable is empty";
return false;
}
if (xp.Count != credits.Count)
{
message = $"XpTable is inconsistent: {xp.Count} xp entries vs {credits.Count} credit entries";
return false;
}
// First contact: whatever the dat shipped is "retail".
if (RetailMaxLevel == 0)
RetailMaxLevel = xp.Count - 1;
var target = settings.MaxLevel;
if (target <= RetailMaxLevel)
{
message = $"MaxLevel {target} is not above retail {RetailMaxLevel}; leaving the table alone";
CurrentMaxLevel = RetailMaxLevel;
return false;
}
// Idempotent: a second call (dat reload, /mod restart) must not stack.
if (xp.Count - 1 == target)
{
CurrentMaxLevel = target;
message = $"already extended to {target}";
return true;
}
if (xp.Count - 1 > RetailMaxLevel)
Revert(table);
// Seed from the last retail entries.
var lastTotal = xp[RetailMaxLevel];
var lastDelta = xp[RetailMaxLevel] - xp[RetailMaxLevel - 1];
for (var level = RetailMaxLevel + 1; level <= target; level++)
{
// double is exact for integers up to 2^53; deltas top out around 3.6e10.
lastDelta = (ulong)Math.Round(lastDelta * GrowthRatio(level));
lastTotal += lastDelta;
xp.Add(lastTotal);
var milestone = settings.SkillCreditInterval > 0
&& (level - RetailMaxLevel) % settings.SkillCreditInterval == 0;
credits.Add(milestone ? settings.SkillCreditsPerInterval : 0u);
}
CurrentMaxLevel = target;
ExtendCreditAudit(settings);
message = $"level cap {RetailMaxLevel} -> {target}, "
+ $"{xp[target]:N0} total XP at {target}, "
+ $"+{CreditsAdded(settings)} skill credits";
return true;
}
///
/// DeveloperFixCommands.GetAdditionalCredits walks AdditionalCredits in reverse
/// and returns the first entry with level >= key, so without this every
/// character above 275 looks like it has too many credits and
/// "verify-skill-credits fix" would strip the ones we just granted.
///
private static void ExtendCreditAudit(Settings settings)
{
var table = DeveloperFixCommands.AdditionalCredits;
if (table is null || table.Count == 0)
return;
var running = table[table.Keys.Max()];
for (var level = RetailMaxLevel + settings.SkillCreditInterval;
level <= CurrentMaxLevel;
level += settings.SkillCreditInterval)
{
if (settings.SkillCreditInterval <= 0)
break;
running += (int)settings.SkillCreditsPerInterval;
if (table.ContainsKey(level))
continue;
table[level] = running;
_addedCreditLevels.Add(level);
}
}
private static int CreditsAdded(Settings settings) =>
settings.SkillCreditInterval <= 0
? 0
: ((CurrentMaxLevel - RetailMaxLevel) / settings.SkillCreditInterval) * (int)settings.SkillCreditsPerInterval;
///
/// Puts the table back to exactly what the dat shipped, so unloading the mod
/// leaves no trace and a reload cannot stack entries.
///
public static void Revert(XpTable? table)
{
if (table is null || RetailMaxLevel == 0)
return;
var wanted = RetailMaxLevel + 1;
if (table.CharacterLevelXPList.Count > wanted)
table.CharacterLevelXPList.RemoveRange(wanted, table.CharacterLevelXPList.Count - wanted);
if (table.CharacterLevelSkillCreditList.Count > wanted)
table.CharacterLevelSkillCreditList.RemoveRange(wanted, table.CharacterLevelSkillCreditList.Count - wanted);
foreach (var level in _addedCreditLevels)
DeveloperFixCommands.AdditionalCredits.Remove(level);
_addedCreditLevels.Clear();
CurrentMaxLevel = RetailMaxLevel;
}
}