namespace Aeshnidae.Bank; /// /// The two faucets: monster kills pay radiance, quest completions pay resonance. /// /// Both are postfixes that observe rather than replace. Nothing here changes what ACE /// awards, so a mod failure costs the currency and nothing else - experience, luminance /// and quest state are untouched either way. /// [HarmonyPatch] public static class Patches { // ------------------------------------------------------------------ radiance /// /// Radiance is experience's mirror: one Radiance per point of experience received, /// from everything experience comes from. /// /// Hooked on Player.GrantXP because it is the one funnel every award passes through /// AFTER the modifiers have been applied - a kill (EarnXP applies xp_modifier and the /// enchantment), a quest emote (quest_xp_modifier too), a level-proportional award, /// and each member's slice of a fellowship split. What lands on the experience bar is /// what lands in Radiance, and nothing has to re-derive damage shares or quest rules. /// /// The one deliberate divergence: UpdateXpAndLevel stops adding experience at the /// level cap, and this runs before that check. A capped character keeps earning /// Radiance. That is the whole point of it existing. /// /// What is NOT counted, and why: /// - The outer call of a fellowship split. GrantXP with ShareType.Fellowship set /// hands the whole amount to Fellowship.SplitXp, which re-enters GrantXP once per /// member without the flag. Counting the outer call too would pay the sharer for /// everyone's slice. The early-return condition is mirrored exactly. /// - Allegiance passup. A vassal's kill already paid them; the patron's share of it /// arrives as XpType.Allegiance and would be the same kill counted twice. /// - Proficiency, Emote, Admin. Skill-use trickle, the AwardSkillXP emote's pass /// through the pool, and /grantxp are not "earned" in the sense a player means. /// - Olthoi. GrantXP itself gives them no experience; they get no Radiance either. /// [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.GrantXP), new[] { typeof(long), typeof(XpType), typeof(ShareType) })] public static void PostGrantXP(Player __instance, long amount, XpType xpType, ShareType shareType) { if (!Mod.Settings.RadianceEnabled || !BankDb.Ready || amount <= 0) return; // Kill and Quest are the earner's own award. Fellowship is every other member's // slice of a split - SplitXp relabels it on the way in - which is the "share of // a fellowship's" the front page promises. It was missing here until 2026-09-14, // so a fellow earned nothing from a shared kill and the killer only their slice. if (xpType is not (XpType.Kill or XpType.Quest or XpType.Fellowship)) return; if (__instance.IsOlthoiPlayer || __instance.Account is null) return; // The outer call of a split does no granting itself - skip it, as the original did. if (__instance.Fellowship != null && __instance.Fellowship.ShareXP && shareType.HasFlag(ShareType.Fellowship)) return; try { var radiance = (long)Math.Round(amount * Mod.Settings.RadiancePerExperience); if (radiance <= 0) return; Earning.Award(__instance.Account.AccountId, CurrencyKind.Radiance, radiance); History.Record(__instance, CurrencyKind.Radiance, radiance); Earning.AnnounceRadiance(__instance, radiance); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not award radiance to {__instance?.Name ?? "?"}: {ex}", ModManager.LogLevel.Error); } } // ----------------------------------------------------------------- luminance /// /// Where luminance goes, and how much of it /earned counts. /// /// Hooked on the private AddLuminance rather than the public EarnLuminance because /// that is the single point where a balance actually moves: EarnLuminance applies /// the server modifiers and GrantLuminance may hand the whole thing to /// Fellowship.SplitLuminance, which re-enters per member. Watching the outer call /// would credit the sharer with everybody's luminance and the sharees with none. /// /// For a FLAGGED character the award is diverted to the bank and the original is /// skipped entirely, so nothing reaches AvailableLuminance and nothing can be lost /// to MaximumLuminance on the way. That is the whole point of the flag: a capped /// character stops earning luminance, and a flagged one never caps. /// /// XpType.Admin runs normally either way. The bank pays withdrawals out through that /// path, so counting them would make taking your own luminance out of the bank look /// like income - and diverting them would make it impossible to take out at all. /// [HarmonyPrefix] [HarmonyPatch(typeof(Player), "AddLuminance")] public static bool PreAddLuminance(Player __instance, long amount, XpType xpType, out long __state) { __state = __instance.AvailableLuminance ?? 0; if (xpType is XpType.Admin || amount <= 0) return true; if (!AutoBank.IsOn(__instance) || __instance.Account is null || !BankDb.Ready) return true; try { Earning.Award(__instance.Account.AccountId, CurrencyKind.Luminance, amount); History.Record(__instance, CurrencyKind.Luminance, amount); if (Mod.Settings.AnnounceLuminanceBanked) __instance.SendMessage($"{amount:N0} Luminance banked."); return false; // skip the original; it never touches the character } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not auto-bank luminance for {__instance.Name}, " + $"letting it land normally: {ex}", ModManager.LogLevel.Error); return true; } } /// /// Records what an UNFLAGGED character actually gained - the real change in the /// balance, not the amount awarded. AddLuminance clamps to MaximumLuminance and /// silently discards the remainder, so a capped player is genuinely earning nothing /// however much is being thrown at them, and /earned showing a row of zeroes is the /// correct answer rather than a bug. A flagged character never reaches here with a /// change to measure, because the prefix already banked it and skipped the original. /// [HarmonyPostfix] [HarmonyPatch(typeof(Player), "AddLuminance")] public static void PostAddLuminance(Player __instance, XpType xpType, long __state) { if (xpType is XpType.Admin) return; try { var gained = (__instance.AvailableLuminance ?? 0) - __state; if (gained > 0) History.Record(__instance, CurrencyKind.Luminance, gained); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not record luminance for {__instance?.Name ?? "?"}: {ex.Message}", ModManager.LogLevel.Warn); } } // ------------------------------------------------------- spending from the bank /// /// Lets a flagged character spend banked luminance, by drawing the shortfall onto /// them first and then letting ACE's own SpendLuminance do exactly what it always /// does. /// [HarmonyPrefix] [HarmonyPatch(typeof(Player), nameof(Player.SpendLuminance))] public static void PreSpendLuminance(Player __instance, long amount) { if (amount <= 0 || !AutoBank.IsOn(__instance)) return; AutoBank.TopUp(__instance, amount); } /// /// The half of spending that is easy to miss. /// /// Aura NPCs do not simply call SpendLuminance and see whether it worked - the emote /// ignores its return value entirely. They gate the sale first with InqInt64Stat on /// AvailableLuminance, which reads the property straight off the character. So for a /// flagged character with everything banked, the NPC would decide they could not /// afford it and never reach the spend at all: auto-banking would quietly lock them /// out of buying auras, which is the one thing luminance is for. /// /// Topping up before the check means the property the NPC reads is true by the time /// it reads it, and the purchase then proceeds through entirely stock code. /// [HarmonyPrefix] [HarmonyPatch(typeof(EmoteManager), nameof(EmoteManager.ExecuteEmote))] public static void PreExecuteEmote(PropertiesEmoteAction emote, WorldObject targetObject) { if (emote is null || emote.Type != (uint)EmoteType.InqInt64Stat) return; if (emote.Stat != (int)PropertyInt64.AvailableLuminance) return; if (targetObject is not Player player || !AutoBank.IsOn(player)) return; try { // Min64 is the floor the emote is testing for - the price. Nothing to do for // a test with no floor, which is not asking whether they can afford anything. if (emote.Min64 is { } needed && needed > 0) AutoBank.TopUp(player, needed); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not prepare banked luminance for {player.Name}: {ex.Message}", ModManager.LogLevel.Warn); } } // ------------------------------------------------------------------- session /// /// Starts and ends the /earned session clock. /// /// Tied to login rather than to the first kill so that the hourly rate counts the /// whole session - the travel, the buffing, the corpse runs. Starting the clock on /// the first award instead would quietly measure only the productive part and /// report a rate nobody actually sustains. /// [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.PlayerEnterWorld))] public static void PostEnterWorld(Player __instance) { History.StartSession(__instance); // Read once here so the luminance hot path only ever reads a dictionary. Login // already touches the database heavily; a landblock tick must not. AutoBank.Load(__instance); } [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.LogOut_Inner))] public static void PostLogOut(Player __instance) { History.EndSession(__instance); AutoBank.Forget(__instance); } // ----------------------------------------------------------------- resonance /// /// Resonance, on any increase in a quest's completion count. /// /// Solves are counted before and after rather than the postfix simply reading the /// new value, because the registry has no "this was just completed" signal - Update /// both creates and increments, and the only way to know a completion happened is /// that the number went up. This is the same before/after shape Aeshnidae.QuestBonus /// uses on the same method, and for the same reason. /// /// Unlike QuestBonus, which pays only on the first solve so dailies cannot inflate a /// permanent bonus, this pays on EVERY increase by default: a currency should pay /// for repeatable content, that is what makes repeatable content worth doing. Set /// ResonanceFirstSolveOnly if you would rather it did not. /// /// /// A counter ticking is not a completion. A kill task counts kills through /// QuestManager.HandleKillTask, which Stamps the counter (one Update) per kill, and /// the IncrementQuest emote counts through Increment (one Update per unit) - so /// before this a "kill a hundred" task paid a hundred times on the way. Inside /// either, AwardResonance pays only the tick that reaches the task's maximum: "Your /// task is complete!" - once per task, which is what a completion is worth. /// /// The first cut of this (09:56 today) guarded Increment alone, on the belief that /// kill tasks went through it. They go through HandleKillTask; kills kept paying /// for an hour. Both are guarded now. /// /// Thread-static because the counting runs on a landblock thread and the Update /// postfix reads the flag on that same thread, inside that same call. Finalizers /// rather than postfixes clear it, so an exception inside cannot leave it set. /// [ThreadStatic] private static bool _insideCounting; [HarmonyPrefix] [HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Increment), new[] { typeof(string), typeof(int) })] public static void PreIncrement() => _insideCounting = true; [HarmonyFinalizer] [HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Increment), new[] { typeof(string), typeof(int) })] public static void PostIncrement() => _insideCounting = false; [HarmonyPrefix] [HarmonyPatch(typeof(QuestManager), nameof(QuestManager.HandleKillTask), new[] { typeof(string), typeof(WorldObject) })] public static void PreHandleKillTask() => _insideCounting = true; [HarmonyFinalizer] [HarmonyPatch(typeof(QuestManager), nameof(QuestManager.HandleKillTask), new[] { typeof(string), typeof(WorldObject) })] public static void PostHandleKillTask() => _insideCounting = false; [HarmonyPrefix] [HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Update), new[] { typeof(string) })] public static void PreQuestUpdate(string questFormat, QuestManager __instance, ref int __state) => __state = SolvesOf(__instance, questFormat); [HarmonyPostfix] [HarmonyPatch(typeof(QuestManager), nameof(QuestManager.Update), new[] { typeof(string) })] public static void PostQuestUpdate(string questFormat, QuestManager __instance, int __state) => AwardResonance(__instance, questFormat, __state); private static int SolvesOf(QuestManager manager, string questFormat) { // NPCs and generators keep quest registries too, and only players have accounts. if (manager?.Creature is not Player) return 0; try { return manager.GetCurrentSolves(questFormat); } catch { return 0; } } private static void AwardResonance(QuestManager manager, string questFormat, int solvesBefore) { if (!Mod.Settings.ResonanceEnabled || !BankDb.Ready) return; if (manager?.Creature is not Player player || player.Account is null) return; try { var solvesAfter = manager.GetCurrentSolves(questFormat); if (solvesAfter <= solvesBefore) return; if (Mod.Settings.ResonanceFirstSolveOnly && solvesBefore != 0) return; // A counter going up is not a completion - except the tick that reaches the // task's maximum, which is the moment the task is done. Update refuses to go // past MaxSolves, so that tick happens exactly once per task. if (_insideCounting) { var max = manager.GetMaxSolves(questFormat); if (max <= 0 || solvesAfter != max) return; } var name = QuestManager.GetQuestName(questFormat); // Timers and other bookkeeping stamps are quests to the registry, not to // anyone else. if (Mod.Settings.IsResonanceSkipped(name)) return; var resonance = Earning.ResonanceFor(name); if (resonance <= 0) return; Earning.Award(player.Account.AccountId, CurrencyKind.Resonance, resonance); History.Record(player, CurrencyKind.Resonance, resonance); if (Mod.Settings.AnnounceResonance) player.SendMessage($"You gain {resonance:N0} Resonance."); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not award resonance for '{questFormat}': {ex}", ModManager.LogLevel.Error); } } }