namespace Aeshnidae.Leaderboard;
///
/// The lifetime counters that nothing else keeps: what each character has earned
/// and spent of Radiance, Resonance and luminance. Level, experience, kills, deaths
/// and enlightenments are ACE's own properties and are read from the character; the
/// bank balances, mastery ranks and aura ranks are the sibling mods' tables. Only
/// the flows need counting, and they are counted here as they happen.
///
/// Luminance is hooked in ACE. The three currencies are hooked in the sibling mods
/// through Harmony's imperative API, resolved by name at runtime, because each mod
/// lives in its own collectible load context and this assembly cannot reference
/// their types - the pattern Aeshnidae.AdminAudit established, with the same
/// caveats: a rename upstream silently drops a hook, and /mod find (which reloads
/// every mod) unbinds them. Hence Status, /top hooks and the retry on every refresh.
///
/// Balances are per account; the boards are per character. A flow is credited to
/// the character of that account who is in the world at the time - there is only
/// ever one.
///
[HarmonyPatch]
public static class Hooks
{
// ------------------------------------------------------------ luminance (ACE)
///
/// Luminance earned: what AddLuminance really changed the balance by, which is
/// the award clamped to the maximum. When Aeshnidae.Bank's auto-banking has
/// diverted the award (its prefix skips the original), the whole award went to
/// the bank and counts in full. Admin grants and bank withdrawals are XpType.Admin
/// and are not income.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Player), "AddLuminance")]
public static void PreAddLuminance(Player __instance, out long __state) =>
__state = __instance.AvailableLuminance ?? 0;
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), "AddLuminance")]
public static void PostAddLuminance(Player __instance, long amount, XpType xpType, long __state, bool __runOriginal)
{
try
{
if (xpType == XpType.Admin || amount <= 0)
return;
var gained = __runOriginal ? (__instance.AvailableLuminance ?? 0) - __state : amount;
if (gained > 0)
Db.Add(__instance.Guid.Full, Boards.LuminanceEarned, gained);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] luminance count failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
///
/// Luminance spent - except into the bank. A deposit goes through SpendLuminance
/// too, and moving your own luminance is not spending it; BankService.Deposit is
/// bracketed below so the spend inside it is skipped.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.SpendLuminance))]
public static void PostSpendLuminance(Player __instance, long amount, bool __result)
{
try
{
if (__result && amount > 0 && _insideDeposit == 0)
Db.Add(__instance.Guid.Full, Boards.LuminanceSpent, amount);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] luminance spend count failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
[ThreadStatic] private static int _insideDeposit;
// ------------------------------------------------------------ the siblings
public sealed record HookStatus(string Target, bool Bound, string Note);
private static readonly List _status = new();
private static readonly Dictionary _bound = new();
public static IReadOnlyList Status
{
get { lock (_status) return _status.ToList(); }
}
public static bool AllBound
{
get { lock (_status) return _status.Count > 0 && _status.All(s => s.Bound); }
}
public static void BindSiblings(Harmony harmony)
{
lock (_status)
_status.Clear();
Bind(harmony, "Aeshnidae.Bank", "Aeshnidae.Bank.Earning", "Award", postfix: nameof(AfterAward));
Bind(harmony, "Aeshnidae.Bank", "Aeshnidae.Bank.BankService", "Deposit", prefix: nameof(BeforeDeposit), postfix: nameof(AfterDeposit));
Bind(harmony, "Aeshnidae.SkillMastery", "Aeshnidae.SkillMastery.MasteryDb", "TryDebitRadiance", postfix: nameof(AfterRadianceDebit));
Bind(harmony, "Aeshnidae.ResonanceAuras", "Aeshnidae.ResonanceAuras.AuraDb", "TryDebitResonance", postfix: nameof(AfterResonanceDebit));
}
private static void Bind(Harmony harmony, string modName, string typeName, string methodName, string? prefix = null, string? postfix = null)
{
var target = $"{typeName}.{methodName}";
try
{
// Detach from the copy we patched last time, if any; a patch left on a dead
// assembly pins its load context.
if (_bound.Remove(target, out var previous))
{
try { harmony.Unpatch(previous, HarmonyPatchType.All, Mod.HarmonyId); }
catch { /* already gone */ }
}
var container = ModManager.GetModContainerByName(modName, allowPartial: false);
if (container is null) { Note(target, false, "mod not installed"); return; }
if (container.Status != ModStatus.Active) { Note(target, false, $"mod is {container.Status}"); return; }
var type = container.ModAssembly?.GetType(typeName, throwOnError: false);
if (type is null) { Note(target, false, "type not found in that mod's assembly"); return; }
var method = AccessTools.Method(type, methodName);
if (method is null) { Note(target, false, "method not found - renamed upstream?"); return; }
harmony.Patch(method,
prefix: prefix is null ? null : new HarmonyMethod(typeof(Hooks), prefix),
postfix: postfix is null ? null : new HarmonyMethod(typeof(Hooks), postfix));
_bound[target] = method;
Note(target, true, "bound");
}
catch (Exception ex)
{
Note(target, false, $"{ex.GetType().Name}: {ex.Message}");
ModManager.Log($"[{Mod.Name}] could not hook {target}: {ex.Message}", ModManager.LogLevel.Warn);
}
}
private static void Note(string target, bool bound, string note)
{
lock (_status)
_status.Add(new HookStatus(target, bound, note));
}
private static uint CharacterOf(uint accountId) =>
PlayerManager.GetAllOnline().FirstOrDefault(p => p.Account?.AccountId == accountId)?.Guid.Full ?? 0;
/// Bank credited an earned currency. kind is Bank's own enum, read boxed; it names itself.
public static void AfterAward(uint accountId, long amount, object[] __args)
{
try
{
if (amount <= 0)
return;
var stat = __args[1]?.ToString() switch
{
"Radiance" => Boards.RadianceEarned,
"Resonance" => Boards.ResonanceEarned,
_ => null, // luminance is counted at the ACE hook, whether or not it is banked
};
if (stat is not null)
Db.Add(CharacterOf(accountId), stat, amount);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] award count failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
public static void BeforeDeposit() => _insideDeposit++;
public static void AfterDeposit() => _insideDeposit = Math.Max(0, _insideDeposit - 1);
public static void AfterRadianceDebit(uint accountId, long amount, bool __result)
{
try
{
if (__result && amount > 0)
Db.Add(CharacterOf(accountId), Boards.RadianceSpent, amount);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] radiance spend count failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
public static void AfterResonanceDebit(uint accountId, long amount, bool __result)
{
try
{
if (__result && amount > 0)
Db.Add(CharacterOf(accountId), Boards.ResonanceSpent, amount);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] resonance spend count failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
}