namespace Aeshnidae.AwakenedRewards; /// /// Loot from a scaled dungeon copy: a better roll, and a gate on wearing it. /// /// ACE rolls a corpse's random loot in one place - Creature.GenerateTreasure calls /// LootGenerationFactory.CreateRandomLootObjects(TreasureDeath) with the cached /// profile - and chests and a creature's spawn inventory come through the same /// factory method (GeneratorProfile.TreasureGenerator, Creature.GenerateInventoryTreasure). /// So that method is patched, twice: /// /// Prefix: swaps the profile for a CLONE with a richer quality mod and, every few /// tiers, a higher loot tier. The cached profile is never touched - it is shared by /// every creature of that treasure type on the server, and a mutation would leak /// into the retail landblocks. /// /// Postfix: every item the roll produced gets the copy's tier as PropertyInt 9043, /// the wield requirement "awakenings at least the tier", and its name stamped. /// /// Which copy: the dying creature says so itself. A bracket on Creature.GenerateTreasure /// records the creature's CurrentLandblock in a thread-static, and that is the /// answer whenever it is set. Failing that (chests, spawn inventory) the copy is the /// one InstancesNoDat is ticking on this thread - its Context.Ambient, set around every /// copy's tick. Loot in the master landblocks sees neither and is left alone; loot in /// a tier-0 copy sees tier 0 and is left alone. /// /// The tier travels prefix -> postfix in a thread-static as well: the factory runs on /// landblock threads, and there is no other channel between the two patches. /// [HarmonyPatch] public static class Loot { /// The landblock whose creature is dropping loot right now, on this thread. [ThreadStatic] private static Landblock? _source; /// The tier the prefix decided on, for the postfix. [ThreadStatic] private static int _tier; // ------------------------------------------------------------ which copy [HarmonyPrefix] [HarmonyPatch(typeof(Creature), "GenerateTreasure")] public static void PreGenerateTreasure(Creature __instance, out Landblock? __state) { __state = _source; _source = __instance?.CurrentLandblock; } // A finalizer, not a postfix: it runs when GenerateTreasure throws as well, so a // failed roll cannot leave this copy's landblock on the thread for the next // retail corpse to be scaled by (exploit review, 2026-09-19). [HarmonyFinalizer] [HarmonyPatch(typeof(Creature), "GenerateTreasure")] public static void PostGenerateTreasure(Landblock? __state) => _source = __state; /// The tier of the copy this roll belongs to, or 0. private static int TierNow() { var block = _source ?? Instances.Ambient(); return block is null ? 0 : Instances.TierOf(block); } // ------------------------------------------------------------ the roll [HarmonyPrefix] [HarmonyPatch(typeof(LootGenerationFactory), nameof(LootGenerationFactory.CreateRandomLootObjects), new[] { typeof(TreasureDeath) })] public static void PreCreateRandomLootObjects(ref TreasureDeath profile) { _tier = 0; try { var s = Mod.Settings; if (!s.Enabled || !s.ScaleCopyLoot || profile is null) return; var tier = TierNow(); if (tier <= 0) return; _tier = tier; profile = Richer(profile, tier, s); } catch (Exception ex) { _tier = 0; ModManager.Log($"[{Mod.Name}] loot profile scaling failed, rolling retail: {ex.Message}", ModManager.LogLevel.Warn); } } [HarmonyPostfix] [HarmonyPatch(typeof(LootGenerationFactory), nameof(LootGenerationFactory.CreateRandomLootObjects), new[] { typeof(TreasureDeath) })] public static void PostCreateRandomLootObjects(List __result) { var tier = _tier; _tier = 0; if (tier <= 0 || __result is null) return; foreach (var item in __result) { try { Mark(item, tier); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not mark {item?.Name ?? "?"} as tier {tier} loot: {ex.Message}", ModManager.LogLevel.Warn); } } } /// /// A copy of the profile with the tier's bonuses. Every public property is copied, /// so a column added upstream rides along rather than silently reading as zero. /// public static TreasureDeath Richer(TreasureDeath profile, int tier, Settings s) { var clone = new TreasureDeath(); foreach (var p in typeof(TreasureDeath).GetProperties(BindingFlags.Public | BindingFlags.Instance)) { if (p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0) p.SetValue(clone, p.GetValue(profile)); } clone.LootQualityMod = (float)Math.Min(s.MaxLootQuality, profile.LootQualityMod + tier * s.LootQualityPercentPerTier / 100.0); if (s.LootTierStepEveryTiers > 0) clone.Tier = Math.Min(8, profile.Tier + tier / s.LootTierStepEveryTiers); return clone; } /// /// The second arrival line: what this copy's loot does. Hung on InstancesNoDat's /// Arrival.OnArrival (Instances.cs). Nothing for a tier-0 copy - its loot is retail /// and the first line already said so. /// public static void OnArrival(Player player, ushort landblock, int copy, int tier) { // The client's copy of the tier first (TierFeed.cs): every arrival, tier 0 included. TierFeed.Arrived(player, tier); try { var s = Mod.Settings; if (tier <= 0 || !s.ScaleCopyLoot || player?.Session is null) return; var parts = new List(4); var quality = tier * s.LootQualityPercentPerTier; if (quality > 0) parts.Add($"quality +{Math.Min(100, quality):0.#}%"); var step = s.LootTierStepEveryTiers > 0 ? tier / s.LootTierStepEveryTiers : 0; if (step > 0) parts.Add($"{step} loot tier{(step == 1 ? "" : "s")} up (to 8)"); var percent = tier * s.PercentPerAwakening; if (s.MaxPercent > 0) percent = Math.Min(percent, s.MaxPercent); if (s.ScaleCopyLootStats && percent > 0) parts.Add($"damage, armour and modifiers +{percent:0.#}%"); var gate = s.WieldGateFor(tier); if (s.MinimumAwakeningsToWieldLoot && gate > 0) parts.Add($"wearing it needs {gate} awakening{(gate == 1 ? "" : "s")}"); if (parts.Count > 0) player.SendMessage($"Loot here: {string.Join(", ", parts)}."); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] loot arrival line failed: {ex.Message}", ModManager.LogLevel.Warn); } } /// /// The stamp, the gate and the name on one item. Idempotent on the stamp: an item /// that already carries 9043 is left exactly as it is. /// /// Stacks are skipped altogether - pyreals, arrows, spell components, trade notes. /// A stack merges into whatever the player already holds by weenie id, and a /// stamped, renamed half-stack would either be swallowed by a retail stack or /// refuse to join it, which is the one thing worse than a "Pyreal (Awakened 3)". /// The gate goes only on what can be worn (ValidLocations): a wield requirement on /// a gem or a key is dead weight in the biota and nothing else. /// public static void Mark(WorldObject? item, int tier) { if (item is null || tier <= 0) return; if (item.GetProperty(Stamps.LootAwakeningTier) is not null) return; if ((item.MaxStackSize ?? 1) > 1) return; item.SetProperty(Stamps.LootAwakeningTier, tier); if (Mod.Settings.MinimumAwakeningsToWieldLoot && (item.ValidLocations ?? EquipMask.None) != EquipMask.None) Gate(item, tier); if (Mod.Settings.ScaleCopyLootStats) { // The same numbers, on the same curve, as an attuned item earned at this // many awakenings - the roll is already richer; this makes the piece itself // stronger. Before the name stamp, before the client has seen it. var percent = tier * Mod.Settings.PercentPerAwakening; if (Mod.Settings.MaxPercent > 0) percent = Math.Min(percent, Mod.Settings.MaxPercent); if (percent > 0) Attuned.Apply(item, 1.0 + percent / 100.0); } Stamps.StampName(item, tier); } /// /// The gate is the 9043 stamp itself, enforced in WieldGate below - not a wield /// requirement written on the item. That was tried first (WieldRequirement.IntStat /// against 9040), and the server side of it was exact, but the client names the /// stat in its appraisal panel and has no name for 9040: "Wield requires unknown /// quality 10" (Tom, 2026-09-19). The name stamp says what is needed; the check /// below refuses the wield and says so in chat. /// public static bool Gate(WorldObject item, int tier) => true; } /// /// The wield gate for copy loot. Player.CheckWieldRequirements is where every retail /// wield rule is checked before an item goes on; a postfix adds ours: an item stamped /// with a loot tier needs that many awakenings. Refused as SkillTooLow, which the /// client shows as "not skilled enough", with a line in chat that says the real reason. /// [HarmonyPatch] public static class WieldGate { [HarmonyPostfix] [HarmonyPatch(typeof(Player), "CheckWieldRequirements", new[] { typeof(WorldObject) })] public static void PostCheckWieldRequirements(Player __instance, WorldObject item, ref WeenieError __result) { try { if (__result != WeenieError.None || !Mod.Settings.ScaleCopyLoot || !Mod.Settings.MinimumAwakeningsToWieldLoot) return; var stamped = item?.GetProperty(Stamps.LootAwakeningTier) ?? 0; var needed = Mod.Settings.WieldGateFor(stamped); if (needed <= 0) return; var have = Stamps.AwakeningsOf(__instance); if (have >= needed) return; __instance.SendMessage($"{item!.Name} was found in a tier {stamped} dungeon and needs {needed} awakening{(needed == 1 ? "" : "s")} to wear; you have {have}."); __result = WeenieError.SkillTooLow; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] wield gate failed: {ex.Message}", ModManager.LogLevel.Warn); } } } /// /// Keeps the arrival hook bound. The binding is refreshed when loot rolls, which is /// too late for the first arrival after InstancesNoDat has been reloaded (its /// Arrival.OnArrival field is new and empty until we combine onto it again). A /// teleport is the moment before any arrival, and Refresh is a reference compare /// when nothing changed. /// [HarmonyPatch] public static class ArrivalBinding { [HarmonyPrefix] [HarmonyPatch(typeof(Player), nameof(Player.Teleport), new[] { typeof(Position), typeof(bool) })] public static void PreTeleport() { try { Instances.Refresh(); } catch { } } }