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 { }
}
}