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