namespace Aeshnidae.AllegianceBounty; /// /// Two hooks: where a quest completion is written, and where kill experience lands. /// /// Both observe more than they change. The stamp hook changes nothing at all; the /// experience hook only ever adds, and only on a Kill grant with an active bounty, so a /// failure here costs the bonus and nothing else. /// [HarmonyPatch] public static class Patches { // ------------------------------------------------------------ completions /// /// Every completion write in ACE funnels through QuestManager.Update(string): /// the StampQuest emote calls QuestManager.Stamp, which is Update (QuestManager.cs:484); /// the IncrementQuest emote calls Increment, a loop of Update (:367); a kill task's /// counter goes through HandleKillTask -> Stamp -> Update (:583, :608); the UpdateQuest /// emote and an item pickup with a quest stamp call Update or Stamp directly /// (EmoteManager.cs:1475/1484, OnQuest). SetQuestCompletions is a separate method that /// initialises a counter (a kill task's "0 of 20") and is deliberately not hooked: no /// listed flag is written by it. /// /// Update both creates and increments and has no "this was a completion" signal, so /// the solve count is read before and after and a completion is the count going up - /// the same before/after shape Aeshnidae.Bank uses on the same method. A stamp on a /// flag at its MaxSolves returns early without incrementing, so a repeat of a /// once-only quest is not a completion; a stamp on a timer flag before the timer is /// up never reaches Update, the NPC refuses first. /// /// A kill task's counter ticks through the same method once per kill; the counter /// flag is not in the list, the completion timer the turn-in stamps is, so the kill /// ticks cost a dictionary miss each and nothing else. /// [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) { if (!Mod.Settings.Enabled) return; // NPCs, generators and fellowships keep quest registries too; only a player is sworn. if (__instance?.Creature is not Player player) return; try { if (__instance.GetCurrentSolves(questFormat) <= __state) return; var flag = QuestManager.GetQuestName(questFormat); // The allegiance layer: only a listed completion. The personal layer: any // flag written, the first time today (Personal decides). Bounty.OnStamp(player, flag); Personal.OnStamp(player, flag); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not handle the stamp of '{questFormat}' for {player.Name}: {ex}", ModManager.LogLevel.Error); } } private static int SolvesOf(QuestManager manager, string questFormat) { if (manager?.Creature is not Player) return 0; try { return manager.GetCurrentSolves(questFormat); } catch { return 0; } } // ------------------------------------------------------------ experience /// /// The type of the OUTER grant of a fellowship split, while the split's inner grants /// run. GrantXP with ShareType.Fellowship set hands the whole amount to /// Fellowship.SplitXp (Player_Xp.cs:63-68), which re-enters GrantXP synchronously on /// the same thread, once per member, with the flag removed: the earner's slice keeps /// its type, every other member's arrives as XpType.Fellowship. So a Fellowship-typed /// grant is a share of whatever the outer grant was - a kill or a quest turn-in - and /// this is how the inner call knows which. /// [ThreadStatic] private static XpType? _splitOuterType; private static bool IsSplitOuter(Player player, ShareType shareType) => player.Fellowship != null && player.Fellowship.ShareXP && shareType.HasFlag(ShareType.Fellowship); /// /// Adds the allegiance's bounty and the character's personal bounty to a kill before it lands. /// /// Hooked on Player.GrantXP because it is the one funnel every award passes through /// after xp_modifier and the enchantments (EarnXP applies those and calls it), and /// before UpdateXpAndLevel, allegiance passup and item experience read the amount - /// so what the bounty adds is what the bar, the patron's share, the items and /// Aeshnidae.Bank's Radiance all see. A prefix on the amount, as Aeshnidae.QuestBonus /// did, rather than a second grant: one number lands, one kill line is printed. /// /// Applied on the INNER grants of a fellowship split only. The outer call grants /// nothing itself; scaling it too would bonus the sharer's allegiance on everybody's /// slice and then each member's allegiance again. Each member's slice is scaled by /// their own allegiance's bounty instead, which is what "every member of the /// allegiance" means when the fellowship crosses allegiances. The share Instances /// pays other players in a dungeon copy is a plain Kill grant with ShareType.None /// and is bonused by the receiver's allegiance like any kill. /// /// Not applied to Quest (unless ApplyToQuestExperience), Allegiance passup (the /// vassal's kill was already bonused and the patron's share is computed from it), /// Proficiency, Emote or Admin. /// [HarmonyPrefix] [HarmonyPatch(typeof(Player), nameof(Player.GrantXP), new[] { typeof(long), typeof(XpType), typeof(ShareType) })] public static void PreGrantXP(Player __instance, ref long amount, XpType xpType, ShareType shareType) { try { if (__instance is null || amount <= 0 || __instance.IsOlthoiPlayer) return; if (IsSplitOuter(__instance, shareType)) { _splitOuterType = xpType; return; } if (!Mod.Settings.Enabled || !BountyDb.Ready) return; var s = Mod.Settings; var applies = xpType switch { XpType.Kill => true, XpType.Quest => s.ApplyToQuestExperience, XpType.Fellowship => s.ApplyToFellowshipShares && (_splitOuterType == XpType.Kill || (_splitOuterType == XpType.Quest && s.ApplyToQuestExperience)), _ => false, }; if (!applies) return; // Additive percents: the allegiance's bounty plus the character's own day. var percent = Bounty.EffectivePercent(__instance) + Personal.Percent(__instance); if (percent <= 0) return; var bonus = (long)Math.Round(amount * percent / 100.0); if (bonus > 0) amount += bonus; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not apply the bounty for {__instance?.Name ?? "?"}: {ex.Message}", ModManager.LogLevel.Warn); } } // ------------------------------------------------------------ the personal cache /// /// Read the character's day once at login so the kill path only ever reads a /// dictionary; login already touches the database heavily, a landblock tick must not. /// [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.PlayerEnterWorld))] public static void PostEnterWorld(Player __instance) { if (Mod.Settings.Enabled && Mod.Settings.PersonalEnabled) Personal.Load(__instance); } [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.LogOut_Inner))] public static void PostLogOut(Player __instance) => Personal.Forget(__instance); /// The outer split has returned; the inner grants are done. A finalizer so an exception inside cannot leave the type set. [HarmonyFinalizer] [HarmonyPatch(typeof(Player), nameof(Player.GrantXP), new[] { typeof(long), typeof(XpType), typeof(ShareType) })] public static void FinalGrantXP(Player __instance, ShareType shareType) { if (__instance is not null && IsSplitOuter(__instance, shareType)) _splitOuterType = null; } }