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