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