namespace Aeshnidae.AwakenedRewards; /// /// Attuned quest items scale, once, to the awakenings of the character they first /// belong to. /// /// "First belongs to" has no single seam in ACE. The one method every arrival /// passes through - Container.TryAddToInventory - is also what a move between two /// of the player's own packs goes through, and by then TryRemoveFromInventory has /// wiped the item's OwnerId and ContainerId, so it cannot tell a pickup from a /// shuffle. The callers can, so the callers are hooked, three of them: /// /// Player.TryCreateInInventoryWithNetworking(WorldObject, out Container) - an item /// made for this player: NPC gives (TryCreateForGive), quest rewards from emotes, /// crafting, vendors. A PREFIX, so the changed numbers ride in the create message /// ACE sends right after; nothing else needs telling. /// /// Player.DoHandleActionPutItemInContainer(item, itemRootOwner, ...) - a pickup into /// the pack from the ground, a corpse or a chest. The item's root owner was not the /// player and the destination's is: that pair is the definition of a first /// ownership, and it excludes pack-to-pack moves, which have the player as both. /// /// Player.DoHandleActionGetAndWieldItem(item, fromContainer, itemRootOwner, ...) - /// the same pickup straight into the hand. /// /// The last two are POSTFIXES on success, because the client already knows an item /// it can see on the ground or in a container, so the changed properties are sent /// after the fact with GameMessageUpdateObject - what Tailoring does after renaming /// and re-statting a piece a player holds (Entity/Tailoring.cs). /// /// Never on login, never on a pack shuffle, never on equip from the pack: none of /// those is a hooked path, and the 9042 stamp makes a second pass a no-op besides. /// Stacks are never touched - a stackable attuned item (quest tokens, keys) has no /// combat numbers, and a stamp on half a stack would fight the merge. /// [HarmonyPatch] public static class Attuned { // ------------------------------------------------------------ the hooks [HarmonyPrefix] [HarmonyPatch(typeof(Player), nameof(Player.TryCreateInInventoryWithNetworking), new[] { typeof(WorldObject), typeof(Container) }, new[] { ArgumentType.Normal, ArgumentType.Out })] public static void PreTryCreateInInventoryWithNetworking(Player __instance, WorldObject item) { try { ScaleOnce(__instance, item, "created"); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] scaling {item?.Name ?? "?"} for {__instance?.Name} failed: {ex.Message}", ModManager.LogLevel.Warn); } } [HarmonyPostfix] [HarmonyPatch(typeof(Player), "DoHandleActionPutItemInContainer")] public static void PostDoHandleActionPutItemInContainer(Player __instance, WorldObject item, Container itemRootOwner, Container containerRootOwner, bool __result) { if (!__result) return; try { if (!ReferenceEquals(containerRootOwner, __instance) || ReferenceEquals(itemRootOwner, __instance)) return; if (FromPlayerCorpse(itemRootOwner)) return; if (ScaleOnce(__instance, item, itemRootOwner is null ? "picked up" : $"taken from {itemRootOwner.Name}")) Tell(__instance, item); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] scaling {item?.Name ?? "?"} for {__instance?.Name} failed: {ex.Message}", ModManager.LogLevel.Warn); } } [HarmonyPostfix] [HarmonyPatch(typeof(Player), "DoHandleActionGetAndWieldItem")] public static void PostDoHandleActionGetAndWieldItem(Player __instance, WorldObject item, Container itemRootOwner, bool wasEquipped, bool __result) { if (!__result) return; try { // wasEquipped is a move between the player's own wield slots. A null root // owner is an item on the ground (FindObject answers null for the // landblock); a corpse or chest is its own root owner. if (wasEquipped || ReferenceEquals(itemRootOwner, __instance)) return; if (FromPlayerCorpse(itemRootOwner)) return; if (ScaleOnce(__instance, item, itemRootOwner is null ? "wielded from the ground" : $"wielded from {itemRootOwner.Name}")) Tell(__instance, item); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] scaling {item?.Name ?? "?"} for {__instance?.Name} failed: {ex.Message}", ModManager.LogLevel.Warn); } } /// /// The changed properties to the client. An item in the hand is visible to /// everyone around, so that goes out as a broadcast; a pack item only the owner /// can see goes to the owner. The biota is saved at once, the way Tailoring does, /// so a crash before the next periodic save cannot leave a stamped name with /// retail numbers under it. /// /// /// A player's corpse is not a first ownership. An attuned item taken back off /// your own corpse - or off a friend's, with permission, for an attuned-but-not- /// bonded piece - was somebody's already; scaling it here would stamp a legacy /// item at today's count, or hand a scaled one on (exploit review, 2026-09-19). /// private static bool FromPlayerCorpse(Container? root) => root is Corpse corpse && !corpse.IsMonster; private static void Tell(Player player, WorldObject item) { if (item.CurrentWieldedLocation is not null && item.CurrentWieldedLocation != EquipMask.None) player.EnqueueBroadcast(new GameMessageUpdateObject(item)); else player.Session?.Network?.EnqueueSend(new GameMessageUpdateObject(item)); item.SaveBiotaToDatabase(); } // ------------------------------------------------------------ the scaling /// /// Scales an attuned item to the player's awakenings and stamps it, once. /// Returns true when anything on the item changed and the client needs telling. /// public static bool ScaleOnce(Player player, WorldObject? item, string via) { var s = Mod.Settings; if (!s.Enabled || !s.ScaleAttunedItems || player is null || item is null) return false; if (item.Attuned != AttunedStatus.Attuned) return false; if (item.GetProperty(Stamps.QuestAwakeningTier) is not null) return false; if ((item.MaxStackSize ?? 1) > 1) return false; var count = Stamps.AwakeningsOf(player); // Zero is stamped too: the item was this character's first, at no awakenings, // and a later owner (a mule, a re-roll, an admin) must not scale it. item.SetProperty(Stamps.QuestAwakeningTier, count); if (count <= 0) return true; var percent = count * s.PercentPerAwakening; if (s.MaxPercent > 0) percent = Math.Min(percent, s.MaxPercent); var factor = 1.0 + percent / 100.0; var changed = Apply(item, factor); Stamps.StampName(item, count); ModManager.Log($"[{Mod.Name}] {item.Name} ({item.WeenieClassId}) {via} by {player.Name}: awakenings {count}, +{percent:0.##}% on {changed}"); return true; } /// The integer numbers that grow by the factor, when present and above zero. private static readonly PropertyInt[] Ints = { PropertyInt.Damage, PropertyInt.ElementalDamageBonus, PropertyInt.ArmorLevel, PropertyInt.DamageRating, PropertyInt.CritRating, PropertyInt.CritDamageRating, PropertyInt.HealingBoostRating, PropertyInt.DamageResistRating, PropertyInt.CritResistRating, PropertyInt.CritDamageResistRating, }; /// /// The multipliers, where 1.0 is "no bonus": only the part above 1.0 grows, so /// a 1.20 DamageMod at +10% is 1.22, not 1.32, and a 1.0 stays 1.0. /// private static readonly PropertyFloat[] Mods = { PropertyFloat.DamageMod, PropertyFloat.ElementalDamageMod, PropertyFloat.WeaponOffense, PropertyFloat.WeaponDefense, PropertyFloat.WeaponMagicDefense, PropertyFloat.ManaConversionMod, }; /// Applies the factor; returns the names of what changed, for the log. public static string Apply(WorldObject item, double factor) { var changed = new List(); foreach (var p in Ints) { var before = item.GetProperty(p); if (before is not int value || value <= 0) continue; var after = (int)Math.Round(value * factor); if (after == value) continue; item.SetProperty(p, after); changed.Add($"{p} {value}->{after}"); } foreach (var p in Mods) { var before = item.GetProperty(p); if (before is not double mod || mod <= 1.0) continue; var after = 1.0 + (mod - 1.0) * factor; item.SetProperty(p, after); changed.Add($"{p} {mod:0.###}->{after:0.###}"); } return changed.Count == 0 ? "nothing (no scalable numbers)" : string.Join(", ", changed); } }