namespace Aeshnidae.AwakenedRewards; /// /// The one question this mod asks Aeshnidae.InstancesNoDat: what tier is the copy /// this loot is being rolled in? /// /// Asked by reflection rather than by reference. Each mod lives in its own /// collectible load context, so a project reference to InstancesNoDat would pin /// two copies of its types and cast nothing (the sibling-mod pattern /// Aeshnidae.Leaderboard and Aeshnidae.AdminAudit use, with the same caveats: a /// rename there silently drops the binding, and /mod find, which reloads every /// mod, changes the assembly under us). Hence the assembly is checked on every /// call and re-bound when it differs - cheap, and a corpse is a rare event next /// to a tick. /// /// The ACE types crossing the boundary - Landblock - are the host's, shared by /// every mod (PreferSharedTypes), so delegates can be built over them directly. /// InstancesNoDat's own types (InstanceScope, its Settings) are read boxed and by /// name. /// /// What "the tier" is here is exactly what InstancesNoDat's own readout says it is /// (Scaling.Describe): Scaling.Counted for a PERSONAL copy with scaling switched on; /// 0 for the master, for a shared or fellowship copy, and when scaling is off. Loot /// follows the monsters: where they are stock, so is the roll. /// public static class Instances { private const string ModName = "Aeshnidae.InstancesNoDat"; private static readonly object _lock = new(); private static Assembly? _boundTo; private static string _status = "not looked up yet"; private static Func? _ambient; // Context.Ambient (get) private static Func? _copyOf; // InstanceWorld.CopyOf(Landblock) private static Func? _counted; // Scaling.Counted(ushort, int) private static Func? _tierFor; // Scaling.TierFor(ushort, int) - optional (2026-09-28): the whole rule, awakened worlds included private static MethodInfo? _scopeFor; // Mod.ScopeFor(ushort) -> InstanceScope (boxed) private static PropertyInfo? _settings; // Mod.Settings (static) private static PropertyInfo? _scaleWithEnlightenment; // Settings.ScaleWithEnlightenment private static FieldInfo? _onArrival; // Arrival.OnArrival (static Action?) /// Our handler, kept so it can be taken off the same field on unbind. private static readonly Action ArrivalHandler = Loot.OnArrival; /// "bound", or the reason it is not - for the log line and /awakened-rewards. public static string Status { get { lock (_lock) return _status; } } public static bool Bound { get { lock (_lock) return _boundTo is not null; } } public static void Unbind() { lock (_lock) { _boundTo = null; _ambient = null; _copyOf = null; _counted = null; _tierFor = null; _scopeFor = null; _settings = null; _scaleWithEnlightenment = null; Unhook(); _status = "unbound"; } } /// Take our line off InstancesNoDat's arrival hook, if it is on it. private static void Unhook() { try { if (_onArrival is null) return; var current = _onArrival.GetValue(null) as Delegate; _onArrival.SetValue(null, Delegate.Remove(current, ArrivalHandler)); } catch { /* the assembly may already be gone */ } finally { _onArrival = null; } } /// /// Makes sure the binding matches the InstancesNoDat assembly that is loaded /// right now. Returns false when there is nothing to bind to. /// public static bool Refresh() { try { var container = ModManager.GetModContainerByName(ModName, allowPartial: false); if (container is null) { Drop("mod not installed"); return false; } if (container.Status != ModStatus.Active || container.ModAssembly is null) { Drop($"mod is {container.Status}"); return false; } lock (_lock) { if (ReferenceEquals(_boundTo, container.ModAssembly)) return true; } return Bind(container.ModAssembly); } catch (Exception ex) { Drop($"{ex.GetType().Name}: {ex.Message}"); return false; } } private static bool Bind(Assembly assembly) { var context = assembly.GetType($"{ModName}.Context", throwOnError: false); var world = assembly.GetType($"{ModName}.InstanceWorld", throwOnError: false); var scaling = assembly.GetType($"{ModName}.Scaling", throwOnError: false); var mod = assembly.GetType($"{ModName}.Mod", throwOnError: false); if (context is null || world is null || scaling is null || mod is null) { Drop("a type is missing from that mod's assembly - renamed upstream?"); return false; } var ambientGetter = AccessTools.PropertyGetter(context, "Ambient"); var copyOf = AccessTools.Method(world, "CopyOf", new[] { typeof(Landblock) }); var counted = AccessTools.Method(scaling, "Counted", new[] { typeof(ushort), typeof(int) }); var scopeFor = AccessTools.Method(mod, "ScopeFor", new[] { typeof(ushort) }); var settings = AccessTools.Property(mod, "Settings"); if (ambientGetter is null || copyOf is null || counted is null || scopeFor is null) { Drop("a member is missing from that mod - renamed upstream?"); return false; } try { lock (_lock) { _ambient = ambientGetter.CreateDelegate>(); _copyOf = copyOf.CreateDelegate>(); _counted = counted.CreateDelegate>(); _tierFor = AccessTools.Method(scaling, "TierFor", new[] { typeof(ushort), typeof(int) })?.CreateDelegate>(); _scopeFor = scopeFor; _settings = settings; _scaleWithEnlightenment = settings is null ? null : AccessTools.Property(settings.PropertyType, "ScaleWithEnlightenment"); _boundTo = assembly; _status = "bound"; // The arrival line: InstancesNoDat says the tier, we say the loot. Its // Arrival.OnArrival is a plain static delegate field over host types // (Player), so ours combines onto it directly. Older builds have no // such field, and then there is simply no second line. var arrival = assembly.GetType($"{ModName}.Arrival", throwOnError: false); _onArrival = arrival is null ? null : AccessTools.Field(arrival, "OnArrival"); if (_onArrival is not null) { var current = _onArrival.GetValue(null) as Delegate; _onArrival.SetValue(null, Delegate.Combine(Delegate.Remove(current, ArrivalHandler), ArrivalHandler)); } } ModManager.Log($"[{Mod.Name}] bound to {ModName} ({assembly.GetName().Version})"); return true; } catch (Exception ex) { Drop($"{ex.GetType().Name}: {ex.Message}"); return false; } } private static void Drop(string why) { bool was; lock (_lock) { was = _boundTo is not null; _status = why; } if (was) { Unbind(); lock (_lock) _status = why; ModManager.Log($"[{Mod.Name}] lost {ModName}: {why}", ModManager.LogLevel.Warn); } } /// /// Every open copy InstancesNoDat holds (InstanceWorld.All), with its tier by the same rule /// as - for the console readout of the tier feed. Empty when unbound. /// The tuple types are the runtime's and Landblock is the host's, so the cast crosses the /// load contexts. /// public static List<(ushort Landblock, int Copy, int Tier)> OpenCopies() { var list = new List<(ushort, int, int)>(); try { if (!Refresh()) return list; Assembly? assembly; lock (_lock) assembly = _boundTo; var world = assembly?.GetType($"{ModName}.InstanceWorld", throwOnError: false); var all = world is null ? null : AccessTools.Property(world, "All")?.GetValue(null); if (all is IEnumerable<((ushort Landblock, int Copy) Key, Landblock Block)> copies) foreach (var c in copies.ToList()) list.Add((c.Key.Landblock, c.Key.Copy, TierOf(c.Block))); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] listing open copies failed: {ex.Message}", ModManager.LogLevel.Warn); } return list; } /// The copy InstancesNoDat is ticking on this thread, or null. public static Landblock? Ambient() { if (!Refresh()) return null; Func? ambient; lock (_lock) ambient = _ambient; return ambient?.Invoke(); } /// /// The tier of a copy, by InstancesNoDat's own rule (see the class comment): 0 /// for anything that is not a personal, scaled copy. /// public static int TierOf(Landblock? block) { if (block is null || !Refresh()) return 0; Func? copyOf; Func? counted, tierFor; MethodInfo? scopeFor; PropertyInfo? settings, scaleWith; lock (_lock) { copyOf = _copyOf; counted = _counted; tierFor = _tierFor; scopeFor = _scopeFor; settings = _settings; scaleWith = _scaleWithEnlightenment; } if (copyOf is null || counted is null || scopeFor is null) return 0; var copy = copyOf(block); if (copy <= 0) return 0; var landblock = block.Id.Landblock; // InstancesNoDat's own answer when it has one (Scaling.TierFor): personal copies by their // owner, awakened worlds by the world's tier, nothing else - the rule stated once, there. if (tierFor is not null) return Math.Max(0, tierFor(landblock, copy)); // Personal copies only - the rule Scaling.Apply enforces for the monsters. A // fellowship or allegiance copy has no single owner to take a tier from, and a // shared copy (the hub) is never scaled at all. var scope = scopeFor.Invoke(null, new object[] { landblock })?.ToString(); if (!string.Equals(scope, "Personal", StringComparison.Ordinal)) return 0; // Scaling switched off there means tier 0 everywhere, whatever was recorded. if (settings is not null && scaleWith is not null) { var s = settings.GetValue(null); if (s is not null && scaleWith.GetValue(s) is bool on && !on) return 0; } return Math.Max(0, counted(landblock, copy)); } }