namespace Aeshnidae.Challenge; /// /// What this mod asks - and gives - Aeshnidae.InstancesNoDat, bound by name at runtime. /// /// The sibling pattern of Aeshnidae.Expedition's Island.cs and AwakenedRewards' /// Instances.cs: each mod lives in its own collectible load context, so a project /// reference would pin a second copy of InstancesNoDat's types and cast nothing. The /// assembly is re-checked on every use, so a hot reload of either mod re-binds instead of /// going quietly stale. /// /// TWO hooks are installed here, and both are plain static delegate fields over host and /// runtime types only - no InstancesNoDat type crosses the boundary: /// /// Variants.Filling (landblock, copy, owner guid) -> a variant as JSON. This is the /// whole of the integration: InstancesNoDat asks who wants to fill /// this copy, we answer with a drawn roster, and every piece of /// machinery after that point (Removes, Adds, NoCorpse, Unscaled) is /// the machinery a hand-written variant already used. /// /// Arrival.OnArrival so a player stepping in is told what they drew. InstancesNoDat /// deliberately says nothing about an Unscaled copy, because "tier 0, /// stock monsters" would be a wrong line about a drawn roster. /// 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? _copyOf; // InstanceWorld.CopyOf(Landblock) private static Func? _find; // InstanceWorld.Find(ushort, int) private static Func? _isOurs; // InstanceWorld.IsOurs(Landblock) private static Func>? _playersIn; private static Func? _getAmbient; // Context.Ambient (get) private static Action? _setAmbient; // Context.Ambient (set) private static FieldInfo? _filling; // Variants.Filling private static FieldInfo? _onArrival; // Arrival.OnArrival private static Func? _supply; // Variants.Supply private static Func? _getOrCreate; // InstanceWorld.GetOrCreate private static Func? _isScaled; // Scaling.IsScaled private static Func? _counted; // Scaling.Counted private static Func? _tierFor; // Scaling.TierFor /// Ours, kept so the exact delegate can be taken off the field again. private static readonly Func FillingHandler = Runs.FillingFor; private static readonly Action ArrivalHandler = Runs.OnArrival; public static string Status { get { lock (_lock) return _status; } } public static bool Bound { get { lock (_lock) return _boundTo is not null; } } public static bool Refresh() { try { var container = ModManager.GetModContainerByName(ModName, allowPartial: false); if (container is null) return Drop("InstancesNoDat is not installed"); if (container.Status != ModStatus.Active || container.ModAssembly is null) return Drop($"InstancesNoDat is {container.Status}"); lock (_lock) { if (ReferenceEquals(_boundTo, container.ModAssembly)) return true; } return Bind(container.ModAssembly); } catch (Exception ex) { return Drop($"{ex.GetType().Name}: {ex.Message}"); } } private static bool Bind(Assembly assembly) { var world = assembly.GetType($"{ModName}.InstanceWorld", throwOnError: false); var context = assembly.GetType($"{ModName}.Context", throwOnError: false); var variants = assembly.GetType($"{ModName}.Variants", throwOnError: false); var arrival = assembly.GetType($"{ModName}.Arrival", throwOnError: false); var scaling = assembly.GetType($"{ModName}.Scaling", throwOnError: false); if (world is null || context is null || variants is null) return Drop("a type is missing from InstancesNoDat - renamed?"); try { T D(Type type, string name, Type[] args) where T : Delegate => (AccessTools.Method(type, name, args) ?? throw new MissingMethodException(type.Name, name)).CreateDelegate(); var copyOf = D>(world, "CopyOf", new[] { typeof(Landblock) }); var find = D>(world, "Find", new[] { typeof(ushort), typeof(int) }); var isOurs = D>(world, "IsOurs", new[] { typeof(Landblock) }); var playersIn = D>>(world, "PlayersIn", new[] { typeof(Landblock) }); var getAmbient = (AccessTools.PropertyGetter(context, "Ambient") ?? throw new MissingMethodException("Context", "Ambient")).CreateDelegate>(); var setAmbient = (AccessTools.PropertySetter(context, "Ambient") ?? throw new MissingMethodException("Context", "Ambient (set)")).CreateDelegate>(); var filling = AccessTools.Field(variants, "Filling") ?? throw new MissingFieldException("Variants", "Filling"); // Only challenge-admin uses these two, so a build of InstancesNoDat without // them loses the console test, not the feature. var supply = AccessTools.Method(variants, "Supply", new[] { typeof(ushort), typeof(int), typeof(string), typeof(string) }) ?.CreateDelegate>(); var getOrCreate = AccessTools.Method(world, "GetOrCreate", new[] { typeof(ushort), typeof(int) }) ?.CreateDelegate>(); lock (_lock) { _supply = supply; _getOrCreate = getOrCreate; _isScaled = scaling is null ? null : AccessTools.Method(scaling, "IsScaled", new[] { typeof(ushort), typeof(int) })?.CreateDelegate>(); _counted = scaling is null ? null : AccessTools.Method(scaling, "Counted", new[] { typeof(ushort), typeof(int) })?.CreateDelegate>(); _tierFor = scaling is null ? null : AccessTools.Method(scaling, "TierFor", new[] { typeof(ushort), typeof(int) })?.CreateDelegate>(); _copyOf = copyOf; _find = find; _isOurs = isOurs; _playersIn = playersIn; _getAmbient = getAmbient; _setAmbient = setAmbient; _filling = filling; _onArrival = arrival is null ? null : AccessTools.Field(arrival, "OnArrival"); _boundTo = assembly; _status = "bound"; } Hook(); ModManager.Log($"[{Mod.Name}] bound to {ModName} ({assembly.GetName().Version})"); return true; } catch (Exception ex) { return Drop($"InstancesNoDat does not have what this mod needs ({ex.Message})"); } } /// /// Puts our filling provider and arrival line on InstancesNoDat's fields. Removed /// first, so a re-bind cannot leave two of us on and draw two rosters. /// public static void Hook() { if (!Mod.Settings.Enabled) { Unhook(); return; } FieldInfo? filling, arrival; lock (_lock) { filling = _filling; arrival = _onArrival; } try { if (filling is not null) { var current = filling.GetValue(null) as Delegate; filling.SetValue(null, Delegate.Combine(Delegate.Remove(current, FillingHandler), FillingHandler)); } if (arrival is not null) { var current = arrival.GetValue(null) as Delegate; arrival.SetValue(null, Delegate.Combine(Delegate.Remove(current, ArrivalHandler), ArrivalHandler)); } } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not install the filling hook: {ex.Message}", ModManager.LogLevel.Warn); } } /// /// Takes both hooks off. Called from Dispose, and whenever the mod is switched off - /// leaving a delegate into an unloaded assembly on a static field of a mod that is /// still running is exactly how a collectible context stops being collectible. /// public static void Unhook() { FieldInfo? filling, arrival; lock (_lock) { filling = _filling; arrival = _onArrival; } try { if (filling is not null) filling.SetValue(null, Delegate.Remove(filling.GetValue(null) as Delegate, FillingHandler)); if (arrival is not null) arrival.SetValue(null, Delegate.Remove(arrival.GetValue(null) as Delegate, ArrivalHandler)); } catch { /* the assembly may already be gone */ } } private static bool Drop(string why) { bool was; lock (_lock) { was = _boundTo is not null; } if (was) Unhook(); lock (_lock) { _boundTo = null; _copyOf = null; _find = null; _isOurs = null; _playersIn = null; _getAmbient = null; _setAmbient = null; _filling = null; _onArrival = null; _supply = null; _getOrCreate = null; _isScaled = null; _counted = null; _tierFor = null; _status = why; } if (was) ModManager.Log($"[{Mod.Name}] lost {ModName}: {why}", ModManager.LogLevel.Warn); return false; } // ------------------------------------------------------------ lookups public static int CopyOf(Landblock? block) { if (block is null || !Refresh()) return 0; Func? f; lock (_lock) f = _copyOf; return f?.Invoke(block) ?? 0; } public static bool IsOurs(Landblock? block) { if (block is null || !Refresh()) return false; Func? f; lock (_lock) f = _isOurs; return f?.Invoke(block) == true; } public static Landblock? Find(ushort landblock, int copy) { if (!Refresh()) return null; Func? f; lock (_lock) f = _find; return f?.Invoke(landblock, copy); } public static IReadOnlyList PlayersIn(Landblock? block) { if (block is null || !Refresh()) return Array.Empty(); Func>? f; lock (_lock) f = _playersIn; return f?.Invoke(block) ?? Array.Empty(); } /// /// Records a filling for a copy that has not been created yet (Variants.Supply) - the /// console test's half of the player path, which normally does this from /// Scaling.Remember. /// public static bool Supply(ushort landblock, int copy, string json, string why) { if (!Refresh()) return false; Func? f; lock (_lock) f = _supply; if (f is null) { ModManager.Log($"[{Mod.Name}] this build of {ModName} has no Variants.Supply - the console open test is unavailable", ModManager.LogLevel.Warn); return false; } return f(landblock, copy, json, why); } /// /// InstancesNoDat's own three answers about a copy's scaling, for challenge-admin tier. /// /// IsScaled is the discriminator and is the one worth reading: it is independent of who /// opened the copy, so it says whether this copy WOULD be scaled by an awakened owner. /// Counted is that owner's tier and TierFor is what AwakenedRewards actually asks /// (IsScaled ? Counted : 0). /// public static (bool? Scaled, int? Counted, int? Tier) Scaling(ushort landblock, int copy) { if (!Refresh()) return (null, null, null); Func? scaled; Func? counted, tier; lock (_lock) { scaled = _isScaled; counted = _counted; tier = _tierFor; } return (scaled?.Invoke(landblock, copy), counted?.Invoke(landblock, copy), tier?.Invoke(landblock, copy)); } /// Opens (or finds) a copy - InstanceWorld.GetOrCreate. Only the console test calls this. public static Landblock? GetOrCreate(ushort landblock, int copy) { if (!Refresh()) return null; Func? f; lock (_lock) f = _getOrCreate; return f?.Invoke(landblock, copy); } /// /// EnterWorld with the copy as the ambient, so ACE's landblock lookup answers the copy /// and not the real dungeon with the same id. The chest is created on the copy's own /// tick, where the ambient is already right, but setting it explicitly costs nothing /// and is the difference between a chest in the run and a chest in the real Old Mine. /// public static bool EnterIntoCopy(WorldObject wo, Landblock block) { if (!Refresh()) return false; Func? get; Action? set; lock (_lock) { get = _getAmbient; set = _setAmbient; } if (get is null || set is null) return false; var previous = get(); set(block); try { return wo.EnterWorld(); } finally { set(previous); } } }