namespace Aeshnidae.Towns;
///
/// What this mod asks Aeshnidae.InstancesNoDat, bound by name at runtime (the sibling pattern of
/// Trials' and Expedition's bindings: each mod has its own load context, so a project reference
/// would never cast). The assembly is checked on every use and re-bound after a reload of
/// InstancesNoDat - and every town is registered again then, since the enclave registry lives in
/// InstancesNoDat's memory.
///
/// Enclaves.Register / Unregister a town's landblocks as a copy, and as one region
/// Enclaves.Of the registration, for the diagnostics (its ToString)
/// InstanceWorld.CopyOf / IsOurs whether a Landblock is a town's copy
/// Context.CopyFor the copy a player is routed to for a landblock, 0 for none
/// Context.Assign / Mod.ScopeFor a claim on a copy - held only while /town go teleports
/// Context.Force "this teleport goes to this copy" (0 = the real landblock)
/// Arrival.OnArrival the moment a player steps into a copy: the welcome line
///
/// **A town copy is opt-in (2026-10-09).** Enclaves route a landblock to its owner's copy by
/// the OWNER KEY the enclave is registered under (a monarch's guid): every member arriving any
/// way at all was put in the copy. Tom asked for the opposite - the real town for everyone,
/// however they arrive, and the copy only for a member who asks with `/town go`. So a town is
/// registered with **owner key 0**, which no player can ever match (Enclaves.CopyFor needs a
/// non-zero key, and a player's key is their monarch's guid): the registration still names the
/// landblocks, opens them as one region and marks the copy shared and kept, but it routes
/// nobody. This mod decides who is inside, and it is the only thing that does - see Towns.cs
/// (Go, LoginCopyFor, Sweep). Nothing in InstancesNoDat was changed for it.
///
public static class Instances
{
private const string ModName = "Aeshnidae.InstancesNoDat";
///
/// The owner key every town is registered under: 0, so no player is ever routed into a town
/// copy automatically. See the note above - this one number is what makes a town opt-in.
///
public const uint NoAutomaticOwner = 0;
private static readonly object _lock = new();
private static Assembly? _boundTo;
private static string _status = "not looked up yet";
private static Func, bool>? _register;
private static Func? _unregister;
private static Func? _copyOf;
private static Func? _isOurs;
private static Func? _getOrCreate; // InstanceWorld.GetOrCreate - for town-admin open
private static Func? _copyForPlayer; // Context.CopyFor
private static Action? _force; // Context.Force
private static MethodInfo? _assign; // Context.Assign(Player, ushort, int, InstanceScope)
private static MethodInfo? _scopeFor; // Mod.ScopeFor(ushort) -> InstanceScope
private static MethodInfo? _enclaveOf; // Enclaves.Of(int) -> Enclave?
private static FieldInfo? _onArrival;
private static readonly Action ArrivalHandler = Towns.OnArrival;
public static string Status { get { lock (_lock) return _status; } }
/// Makes sure the binding matches the InstancesNoDat loaded now; on a new binding, every town is registered again.
public static bool Refresh()
{
try
{
var container = ModManager.GetModContainerByName(ModName, allowPartial: false);
if (container is null || container.Status != ModStatus.Active || container.ModAssembly is null)
return Drop(container is null ? "InstancesNoDat is not installed" : $"InstancesNoDat is {container.Status}");
lock (_lock)
{
if (ReferenceEquals(_boundTo, container.ModAssembly))
return true;
}
if (!Bind(container.ModAssembly))
return false;
Towns.RegisterAll();
return true;
}
catch (Exception ex)
{
return Drop($"{ex.GetType().Name}: {ex.Message}");
}
}
private static bool Bind(Assembly assembly)
{
var enclaves = assembly.GetType($"{ModName}.Enclaves", throwOnError: false);
var world = assembly.GetType($"{ModName}.InstanceWorld", throwOnError: false);
var arrival = assembly.GetType($"{ModName}.Arrival", throwOnError: false);
var context = assembly.GetType($"{ModName}.Context", throwOnError: false);
var instancesMod = assembly.GetType($"{ModName}.Mod", throwOnError: false);
if (enclaves is null || world is null || context is null || instancesMod is null)
return Drop("InstancesNoDat has no enclaves (older than 2026-09-29?)");
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 register = D, bool>>(enclaves, "Register", [typeof(string), typeof(int), typeof(uint), typeof(List)]);
var unregister = D>(enclaves, "Unregister", [typeof(int)]);
var copyOf = D>(world, "CopyOf", [typeof(Landblock)]);
var isOurs = D>(world, "IsOurs", [typeof(Landblock)]);
var getOrCreate = D>(world, "GetOrCreate", [typeof(ushort), typeof(int)]);
var copyForPlayer = D>(context, "CopyFor", [typeof(Player), typeof(ushort)]);
var force = D>(context, "Force", [typeof(Player), typeof(ushort), typeof(int)]);
// Assign takes InstancesNoDat's own InstanceScope enum and ScopeFor returns it, so
// these two cross the mod boundary as MethodInfo and the value is passed straight
// from one to the other without this assembly ever naming the type.
var assign = AccessTools.Method(context, "Assign") ?? throw new MissingMethodException("Context", "Assign");
var scopeFor = AccessTools.Method(instancesMod, "ScopeFor", [typeof(ushort)]) ?? throw new MissingMethodException("Mod", "ScopeFor");
var enclaveOf = AccessTools.Method(enclaves, "Of", [typeof(int)]) ?? throw new MissingMethodException("Enclaves", "Of");
lock (_lock)
{
Unhook();
_register = register;
_unregister = unregister;
_copyOf = copyOf;
_isOurs = isOurs;
_getOrCreate = getOrCreate;
_copyForPlayer = copyForPlayer;
_force = force;
_assign = assign;
_scopeFor = scopeFor;
_enclaveOf = enclaveOf;
_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));
}
_boundTo = assembly;
_status = "bound";
}
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})");
}
}
private static void Unhook()
{
try
{
if (_onArrival?.GetValue(null) is Delegate current)
_onArrival.SetValue(null, Delegate.Remove(current, ArrivalHandler));
}
catch { /* the assembly may be gone */ }
_onArrival = null;
}
private static bool Drop(string why)
{
lock (_lock)
{
var was = _boundTo is not null;
Unhook();
_boundTo = null;
_register = null;
_unregister = null;
_copyOf = null;
_isOurs = null;
_getOrCreate = null;
_copyForPlayer = null;
_force = null;
_assign = null;
_scopeFor = null;
_enclaveOf = null;
_status = why;
if (was)
ModManager.Log($"[{Mod.Name}] lost {ModName}: {why}", ModManager.LogLevel.Warn);
}
return false;
}
/// Takes the arrival handler off InstancesNoDat (this mod unloading).
public static void Unbind() => Drop("unbound");
public static bool Register(string name, int copy, uint owner, List landblocks)
{
Func, bool>? f;
lock (_lock) f = _register;
return f?.Invoke(name, copy, owner, landblocks) ?? false;
}
public static bool Unregister(int copy)
{
if (!Refresh()) return false;
Func? f;
lock (_lock) f = _unregister;
return f?.Invoke(copy) ?? false;
}
/// Opens a copy (staff test: town-admin open). World thread.
public static Landblock? GetOrCreate(ushort landblock, int copy)
{
if (!Refresh()) return null;
Func? f;
lock (_lock) f = _getOrCreate;
return f?.Invoke(landblock, copy);
}
public static int CopyOf(Landblock? block)
{
if (block is null || !Refresh()) return 0;
Func? ours; Func? copyOf;
lock (_lock) { ours = _isOurs; copyOf = _copyOf; }
return ours?.Invoke(block) == true ? copyOf?.Invoke(block) ?? 0 : 0;
}
/// Whether a town's enclave is registered with InstancesNoDat right now.
public static bool IsRegistered(int copy)
{
MethodInfo? of;
lock (_lock) of = _enclaveOf;
try { return of?.Invoke(null, [copy]) is not null; }
catch { return false; }
}
/// The enclave's own description (InstancesNoDat's ToString), for town-admin. Null if it is not registered.
public static string? Describe(int copy)
{
MethodInfo? of;
lock (_lock) of = _enclaveOf;
try { return of?.Invoke(null, [copy])?.ToString(); }
catch { return null; }
}
/// The copy a player is routed to for a landblock (Context.CopyFor), 0 for the real one.
public static int RouteFor(Player player, ushort landblock)
{
Func? f;
lock (_lock) f = _copyForPlayer;
try { return f?.Invoke(player, landblock) ?? 0; }
catch { return 0; }
}
/// "This player's next teleport to this landblock goes to this copy" - 0 for the real landblock. Self-expiring.
public static void Force(Player player, ushort landblock, int copy)
{
Action? f;
lock (_lock) f = _force;
try { f?.Invoke(player, landblock, copy); }
catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not aim {player.Name} at copy {copy} of {landblock:X4}: {ex.Message}", ModManager.LogLevel.Warn); }
}
///
/// A claim on a copy of a landblock (Context.Assign; 0 removes it). Towns holds one only
/// while a `/town go` teleport is in flight - InstancesNoDat's teleport hook reads it to open
/// the copy and point the physics at it - and drops it the moment the teleport is done
/// (Towns.FinishEntry). A claim that outlived that would route the player's next portal or
/// recall into the copy, which is the behaviour this change exists to remove.
///
public static bool Claim(Player player, ushort landblock, int copy)
{
MethodInfo? assign; MethodInfo? scopeFor;
lock (_lock) { assign = _assign; scopeFor = _scopeFor; }
if (assign is null || scopeFor is null)
return false;
try
{
var scope = scopeFor.Invoke(null, [landblock]);
assign.Invoke(null, [player, landblock, copy, scope]);
return true;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not {(copy > 0 ? "claim" : "release")} copy {copy} of {landblock:X4} for {player.Name}: {ex.Message}", ModManager.LogLevel.Warn);
return false;
}
}
}