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