namespace Aeshnidae.InstancesNoDat;
///
/// The routing. Five patches, and every one of them declines to act unless a copy is
/// actually in play - so with no instances open the server behaves exactly as stock.
///
[HarmonyPatch]
public static class Patches
{
///
/// Our copies are not in ACE's landblocks[,] array, so nothing would ever tick
/// them and an instance would sit frozen - no creature AI, no physics, no
/// generators. Driving them straight after ACE drives its own keeps the two in
/// step within a single server tick.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(LandblockManager), nameof(LandblockManager.Tick))]
public static void PostTick(double portalYearTicks)
{
try
{
InstanceWorld.Tick(portalYearTicks);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] instance tick failed: {ex}", ModManager.LogLevel.Error);
}
}
///
/// Allocates and opens a copy for a player, used by both entry paths. Failure is
/// never fatal: the player simply arrives on the server's own landblock, which is
/// the same geometry, just shared.
///
private static void EnsureCopy(Player player, ushort landblock, string why)
{
// Idempotent, and group-aware: if this player's fellowship or allegiance already
// holds a copy here, that is the answer. Every caller happens to check this
// first today, but the one that forgot to split a fellowship on every reload.
var existing = Context.CopyFor(player, landblock);
if (existing > 0)
{
Context.Assign(player, landblock, existing, Mod.ScopeFor(landblock));
return;
}
var copy = InstanceWorld.AllocateCopy(landblock, Mod.Settings.MaxCopiesPerLandblock);
if (copy == 0)
{
player.SendMessage($"Every copy of this place is occupied " +
$"({Mod.Settings.MaxCopiesPerLandblock} in use). Try again shortly.");
return;
}
// Before GetOrCreate, not after. Landblock.Init spawns on a background task, so
// the first monster can be built before the Context.Assign below has run - and
// scaling that asked "whose copy is this" at spawn time would sometimes get no
// answer and hand out a stock dungeon. Recording it here closes that window.
Scaling.Remember(landblock, copy, player);
if (InstanceWorld.GetOrCreate(landblock, copy) is null)
return;
var scope = Mod.ScopeFor(landblock);
Context.Assign(player, landblock, copy, scope);
ModManager.Log($"[{Mod.Name}] {player.Name} {why} copy {copy} of {landblock:X4} " +
$"({scope}, shared with {Context.GroupDescription(player, scope)})");
}
///
/// Entry. Every way into a landblock ends here - a portal, a recall, a summon, an
/// admin teleport - so hooking Teleport rather than the portal means nothing else
/// has to know instances exist. Flag a dungeon in PersonalLandblocks and every
/// existing portal into it starts handing out private copies, with no new weenie
/// and no world database change.
///
/// A player already assigned to this landblock keeps their copy, so stepping out
/// and back returns you to your own dungeon rather than a fresh one.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Player), nameof(Player.Teleport), new[] { typeof(Position), typeof(bool) })]
public static void PreTeleport(Player __instance, Position _newPosition)
{
try
{
if (!Mod.Settings.Enabled || _newPosition is null)
return;
var landblock = (ushort)(_newPosition.LandblockId.Raw >> 16);
if (!Mod.IsInstanced(landblock))
return;
if (Context.HasForced(__instance, landblock, out _))
return; // a destination was named on purpose - do not hand out a copy
if (Context.CopyFor(__instance, landblock) > 0)
return; // already have a copy here - go back to it
EnsureCopy(__instance, landblock, "entering");
}
catch (Exception ex)
{
// A failed assignment must never block the teleport - the player simply
// arrives on the server's own landblock instead.
ModManager.Log($"[{Mod.Name}] personal copy assignment failed: {ex}", ModManager.LogLevel.Error);
}
}
///
/// Placing an object into the world. This is the one moment a copy is chosen
/// rather than inherited, so a player's assignment decides it.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(LandblockManager), nameof(LandblockManager.AddObject))]
public static bool PreAddObject(WorldObject worldObject, bool loadAdjacents, ref bool __result)
{
try
{
if (worldObject?.Location is null)
return true;
var landblock = (ushort)(worldObject.Location.LandblockId.Raw >> 16);
// A player arriving with no assignment is logging in - their position was
// saved inside a copy that no longer exists. These dungeons have no public
// version to fall back to, so give them a fresh copy rather than dropping
// them into the shared landblock.
if (worldObject is Player arriving && Mod.Settings.Enabled
&& Mod.IsInstanced(landblock) && !Mod.IsStaff(arriving)
&& Context.CopyFor(arriving, landblock) == 0)
{
EnsureCopy(arriving, landblock, "logging in");
}
var copy = Context.Resolve(worldObject, landblock);
if (copy <= 0)
{
// The case that eats an afternoon: /createinst spawns through
// EnterWorld -> AddObject, and a brand new creature is not a player and
// has no landblock yet, so nothing about it says which copy it belongs
// in. It goes to the master, which is right - the master is the one
// authored version - but that is NOT where the dev is standing if they
// are in a copy, so the thing they just made appears to have vanished.
if (worldObject is not Player && worldObject.ProjectileSource is null
&& InstanceWorld.OpenCount(landblock) > 0)
{
Mod.Trace($"{worldObject.Name} spawned into the MASTER of {landblock:X4}; " +
$"{InstanceWorld.OpenCount(landblock)} copy(ies) are open and none of " +
"them has it. Authored objects reach copies on /inst reload.");
}
return true; // not instanced - let ACE do exactly what it always did
}
var block = InstanceWorld.GetOrCreate(landblock, copy);
if (block is null)
return true;
Mod.Trace($"{worldObject.Name} added to copy {copy} of {landblock:X4}");
__result = block.AddWorldObject(worldObject);
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] AddObject routing failed, falling back to stock: {ex}",
ModManager.LogLevel.Error);
return true;
}
}
///
/// Physics moved something across a landblock boundary. The rule that carries this
/// whole design: an object bound to a copy stays in that copy. Without it, the
/// first step a player took inside an instance would silently relocate them into
/// ACE's original landblock.
///
/// The question is asked of the DESTINATION, never of where the object came from.
/// An earlier version bailed out unless the object was already standing in one of
/// our copies, which guarded the way out of an instance and left the way in wide
/// open: walk out of a dungeon and back in on foot - not a portal, so no teleport
/// to hook - and ACE's own path put you on the shared landblock while your
/// assignment still said copy 2. The copy you thought you were in then sat empty,
/// the janitor closed it on schedule, and its creatures died on a client that was
/// never told, leaving a drudge standing there forever. Origin tells you nothing
/// about where something belongs; only the destination and the assignment do.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(LandblockManager), nameof(LandblockManager.RelocateObjectForPhysics))]
public static bool PreRelocate(WorldObject worldObject, bool adjacencyMove)
{
try
{
if (worldObject?.Location is null)
return true;
var oldBlock = worldObject.CurrentLandblock;
var landblock = (ushort)(worldObject.Location.LandblockId.Raw >> 16);
var copy = Context.Resolve(worldObject, landblock);
if (copy <= 0 && worldObject is Player entering && Mod.Settings.Enabled
&& Mod.IsInstanced(landblock) && !Mod.IsStaff(entering)
&& !Context.HasForced(entering, landblock, out _))
{
// Walking into an instanced dungeon with no assignment. The login path
// already hands out a copy in this situation; this is the same case
// arriving on foot, and without it the player simply stands in the
// shared landblock. These dungeons are not meant to have a public
// version, so "no assignment" must mean "give them one", not "let them
// into the original".
//
// An assignment lives only in memory, so a mod reload empties the table
// and every player already inside an instance falls into exactly this
// case on their next crossing.
EnsureCopy(entering, landblock, "walking into");
copy = Context.Resolve(worldObject, landblock);
}
if (copy <= 0)
{
// Leaving a copy needs the same full re-entry that entering one does,
// and ACE's stock path cannot give it: it passes the adjacencyMove flag
// it was handed, and when that is true the physics object is never
// destroyed, so CurCell still points into the copy's cells and
// AddWorldObjectInternal skips AddPhysicsObj entirely.
//
// The result is the exact mirror of the bug on the way in: the player is
// listed in the master, /instance says the master, the master genuinely
// holds the objects - and they see an empty room, because their physics
// is still in the cells of the copy they left. Half-fixing this was why
// a drudge that every check agreed was there could not be seen.
//
// Orphaned blocks count too. Those are the worst case: nothing ticks
// them, so a player whose physics is stranded in one sees a room frozen
// at the moment it was dropped.
if (InstanceWorld.IsOurs(oldBlock) || InstanceWorld.IsOrphaned(oldBlock))
{
var previous = Context.Ambient;
Context.Ambient = null; // we want ACE's own landblock, not a copy
Landblock destination;
try
{
destination = LandblockManager.GetLandblock(worldObject.Location.LandblockId, true);
}
finally
{
Context.Ambient = previous;
}
if (destination is null)
return true;
if (worldObject is Player)
Mod.Trace($"{worldObject.Name} left copy {InstanceWorld.CopyOf(oldBlock)} " +
$"of {landblock:X4} for the server's own landblock");
oldBlock!.RemoveWorldObjectForPhysics(worldObject.Guid, adjacencyMove: false);
destination.AddWorldObject(worldObject);
return false;
}
// Nothing to do with instances at all - ACE's path is correct.
return true;
}
var newBlock = InstanceWorld.Find(landblock, copy);
if (newBlock is null)
{
// A live assignment pointing at a copy that does not exist. Close()
// clears assignments, so this should be unreachable - but the failure
// mode is a player silently dropped into the shared dungeon, which is
// too quiet a way to lose an instance. Say so.
ModManager.Log($"[{Mod.Name}] {worldObject.Name} is assigned to copy {copy} of " +
$"{landblock:X4}, which is not open - falling back to the shared landblock",
ModManager.LogLevel.Warn);
return true;
}
if (ReferenceEquals(oldBlock, newBlock))
{
// Moving within the same copy. ACE would remove and re-add across
// landblocks here; within one landblock there is nothing to do.
return false;
}
if (worldObject is Player)
{
var from = InstanceWorld.CopyOf(oldBlock);
Mod.Trace($"{worldObject.Name} crossed into copy {copy} of {landblock:X4} from " +
(from > 0 ? $"copy {from}" : "the master or outside the dungeon"));
}
// adjacencyMove: false, deliberately, whatever ACE passed in.
//
// This is the difference between being in a copy and only being listed in
// one. Landblock.AddWorldObjectInternal re-files an object into its new
// landblock's physics cells ONLY when PhysicsObj.CurCell is null:
//
// if (wo.PhysicsObj.CurCell == null)
// var success = wo.AddPhysicsObj();
//
// and CurCell is only cleared by PhysicsObj.DestroyObject(), which the
// removal skips when adjacencyMove is true. So passing ACE's flag through
// put the player in the destination copy's player list while their physics
// object stayed in the cells of the copy they left: /instance reported the
// new copy, and they could not see anyone in it. Two players who both
// believed they were in copy 2 were rendering different dungeons.
//
// An adjacency move is a step between two neighbouring landblocks that share
// a cell space. Two copies never do, however identical their ids, so a copy
// change is by definition not adjacent - it is a full re-entry, and the
// clients of the copy being left need telling as much.
//
// Null oldBlock once the inbound path was opened up: an object arriving from
// outdoors has a landblock, one arriving from nowhere does not.
oldBlock?.RemoveWorldObjectForPhysics(worldObject.Guid, adjacencyMove: false);
newBlock.AddWorldObject(worldObject);
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] relocation routing failed, falling back to stock: {ex}",
ModManager.LogLevel.Error);
return true;
}
}
///
/// The other half of /teleto: bringing a player TO the admin.
///
/// /teletome teleports the target to the admin's position, and a position cannot tell
/// two copies apart - so the target arrived with no assignment, was treated as
/// somebody walking in fresh, and was handed a private copy of their own. An admin
/// summoning someone got a player who could not see them, standing in a different
/// dungeon; an admin standing OUTSIDE the dungeon got a player who appeared to have
/// been thrown out of it.
///
/// The target goes wherever the admin is, master included - being summoned means
/// being put next to the person who summoned you.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(ACE.Server.Command.Handlers.AdminCommands),
nameof(ACE.Server.Command.Handlers.AdminCommands.HandleTeleToMe))]
public static void PreTeleToMe(Session session, string[] parameters)
{
try
{
if (!Mod.Settings.Enabled || session?.Player?.Location is null
|| parameters is null || parameters.Length == 0)
return;
var landblock = (ushort)(session.Player.Location.LandblockId.Raw >> 16);
if (!Mod.IsInstanced(landblock))
return; // summoning them out of an instance needs nothing from us
var target = PlayerManager.GetOnlinePlayer(string.Join(" ", parameters));
if (target is null)
return;
var copy = InstanceWorld.CopyOf(session.Player.CurrentLandblock);
Context.Assign(target, landblock, copy, Mod.ScopeFor(landblock));
Context.Force(target, landblock, copy);
Mod.Trace($"{target.Name} summoned into " +
(copy > 0 ? $"copy {copy}" : "the master") + $" of {landblock:X4} by {session.Player.Name}");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not summon a player into this copy: {ex.Message}",
ModManager.LogLevel.Warn);
}
}
///
/// Makes /teleto work on someone inside an instance.
///
/// ACE teleports the admin to the target's Position, and a Position cannot tell two
/// copies of a dungeon apart - so the admin arrives in a private copy of the same
/// geometry, alone, which is the opposite of what /teleto is for. Someone checking
/// on a player would conclude they had vanished.
///
/// Patching /teleto rather than only offering /inst goto matters because /teleto is
/// what an admin will actually type under pressure.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(ACE.Server.Command.Handlers.AdminCommands),
nameof(ACE.Server.Command.Handlers.AdminCommands.HandleTeleto))]
public static void PreTeleto(Session session, string[] parameters)
{
try
{
if (!Mod.Settings.Enabled || session?.Player is null || parameters is null || parameters.Length == 0)
return;
var target = PlayerManager.GetOnlinePlayer(string.Join(" ", parameters));
if (target?.Location is null)
return;
var landblock = (ushort)(target.Location.LandblockId.Raw >> 16);
if (!Mod.IsInstanced(landblock))
return;
var copy = InstanceWorld.CopyOf(target.CurrentLandblock);
Context.Assign(session.Player, landblock, copy, Mod.ScopeFor(landblock));
// Beats the stay-put rule, which would otherwise keep an admin who is
// already in some other copy of this dungeon exactly where they are.
Context.Force(session.Player, landblock, copy);
}
catch (Exception ex)
{
// Never block a teleport an admin asked for; they simply arrive in their own
// copy, which is the behaviour they had before this patch existed.
ModManager.Log($"[{Mod.Name}] could not follow a player into their copy: {ex.Message}",
ModManager.LogLevel.Warn);
}
}
///
/// Keeps a copy's static objects out of ACE's global guid registry, and out of each
/// other's way. See Registry for why they collide at all.
///
/// Skipping the original rather than adding alongside it is the point: if a copy
/// wrote to the global slot it would evict whatever was there, which is precisely
/// how a drudge authored in the master ended up existing only in someone's copy.
/// The master keeps the global slot; copies keep their own.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(ServerObjectManager), nameof(ServerObjectManager.AddServerObject))]
public static bool PreAddServerObject(PhysicsObj obj)
{
try
{
return !Registry.Add(obj);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] registry add failed, using ACE's: {ex.Message}",
ModManager.LogLevel.Warn);
return true;
}
}
///
/// The mirror. A copy tearing down its own object must not remove the master's
/// registration, which is what a guid-keyed global remove does.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(ServerObjectManager), nameof(ServerObjectManager.RemoveServerObject))]
public static bool PreRemoveServerObject(PhysicsObj obj)
{
try
{
return !Registry.Remove(obj);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] registry remove failed, using ACE's: {ex.Message}",
ModManager.LogLevel.Warn);
return true;
}
}
///
/// Answers a guid lookup with the object from the copy being processed, when there
/// is one. With no ambient copy this declines to act, so every lookup outside an
/// instance returns exactly what it always did.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(ServerObjectManager), nameof(ServerObjectManager.GetObjectA))]
public static bool PreGetObjectA(uint objectID, ref PhysicsObj __result)
{
try
{
var mine = Registry.Get(objectID);
if (mine is null)
return true;
__result = mine;
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] registry lookup failed, using ACE's: {ex.Message}",
ModManager.LogLevel.Warn);
return true;
}
}
///
/// Sets the ambient copy for the duration of any add into one of our landblocks.
///
/// This is where the cross-copy visibility bug lived. Adding an object leads to
/// PhysicsObj.enter_world, which resolves the object's cell via
/// LScape.get_landcell -> get_landblock. With no ambient set that returns ACE's
/// ORIGINAL landblock, so the object is filed into the original's LandCells - and
/// two players in different copies end up standing in the same EnvCell, seeing
/// each other. The cells are per-landblock and genuinely private; only the lookup
/// leaked.
///
/// Patching the add itself rather than its callers matters for one specific reason:
/// Landblock.Init() spawns a copy's creatures on a background Task.Run, and ambient
/// is thread-local. Wrapping call sites would have left every monster in a copy
/// filed into the original's cells.
///
/// The previous value is saved and restored rather than cleared, since an add can
/// happen while a copy is already being ticked.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Landblock), nameof(Landblock.AddWorldObject))]
public static void PreAddWorldObject(Landblock __instance, out Landblock? __state)
{
__state = Context.Ambient;
if (InstanceWorld.IsOurs(__instance))
Context.Ambient = __instance;
}
///
/// Restores the ambient copy, and scales anything that just entered one.
///
/// This is the right seam for scaling because it is not only the initial spawn:
/// generator respawns during a run reach a landblock through
/// LandblockManager.AddObject, which calls straight into here, so a dungeon that
/// repops mid-clear repops at the same difficulty it opened with.
///
/// The ambient restore happens first and unconditionally. Scaling is downstream of
/// it and wrapped, because this is as hot a path as this mod touches and a throw
/// here would read as "the dungeon is broken" rather than as a scaling bug.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Landblock), nameof(Landblock.AddWorldObject))]
public static void PostAddWorldObject(Landblock __instance, WorldObject wo, bool __result, Landblock? __state)
{
Context.Ambient = __state;
if (!__result)
return;
try
{
Scaling.Apply(__instance, wo);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not scale {wo?.Name ?? "?"}: {ex}", ModManager.LogLevel.Error);
}
}
[HarmonyPrefix]
[HarmonyPatch(typeof(Landblock), nameof(Landblock.AddWorldObjectForPhysics))]
public static void PreAddForPhysics(Landblock __instance, out Landblock? __state)
{
__state = Context.Ambient;
if (InstanceWorld.IsOurs(__instance))
Context.Ambient = __instance;
}
[HarmonyPostfix]
[HarmonyPatch(typeof(Landblock), nameof(Landblock.AddWorldObjectForPhysics))]
public static void PostAddForPhysics(Landblock? __state) => Context.Ambient = __state;
///
/// True while this player is standing in one of our copies.
///
/// Checked two ways because death is a busy moment: the landblock reference is the
/// direct answer, and the assignment covers the case where the player has already
/// been detached from it by the time the corpse is being built.
///
private static bool InCopy(Player player)
{
if (InstanceWorld.IsOurs(player.CurrentLandblock))
return true;
if (player.Location is null)
return false;
var landblock = (ushort)(player.Location.LandblockId.Raw >> 16);
return Context.CopyFor(player, landblock) > 0;
}
///
/// No item loss inside a copy. See Settings.NoItemLossInCopies for why this is a
/// safety rail: the copy is destroyed when it empties, and a corpse full of your
/// gear would go with it.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.GetNumItemsDropped))]
public static void PostGetNumItemsDropped(Player __instance, ref int __result)
{
try
{
if (__result != 0 && Mod.Settings.Enabled && Mod.Settings.NoItemLossInCopies && InCopy(__instance))
{
__result = 0;
__instance.SendMessage("Nothing is lost from your corpse in here.");
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] item-loss check failed: {ex}", ModManager.LogLevel.Error);
}
}
/// Coins are counted separately from items, so they need the same rail.
[HarmonyPostfix]
[HarmonyPatch(typeof(Player), nameof(Player.GetNumCoinsDropped))]
public static void PostGetNumCoinsDropped(Player __instance, ref int __result)
{
try
{
if (__result != 0 && Mod.Settings.Enabled && Mod.Settings.NoItemLossInCopies && InCopy(__instance))
__result = 0;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] coin-loss check failed: {ex}", ModManager.LogLevel.Error);
}
}
// ------------------------------------------------------------ fellowship sharing
///
/// Fellowship experience does not cross copies.
///
/// Fellowship.GetDistanceScalar decides two fellows are together by comparing
/// landblock IDS and a 2D distance. Two copies of a dungeon share both, so a
/// fellowship spread across personal copies looked, to that rule, like one group
/// in one room: every kill in every copy was shared to all of them - N dungeons'
/// worth of monsters feeding one share bonus. The landblock OBJECTS differ, and
/// that is the test used here.
///
/// Quest experience is left alone: ACE shares it at any distance on purpose, and a
/// fellow in another copy is no further away than one in another town.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(ACE.Server.Entity.Fellowship), nameof(ACE.Server.Entity.Fellowship.GetDistanceScalar))]
public static void PostGetDistanceScalar(Player earner, Player fellow, XpType xpType, ref double __result)
{
try
{
if (__result > 0 && xpType != XpType.Quest && Mod.Settings.Enabled && InDifferentCopies(earner, fellow))
__result = 0;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] fellowship xp check failed: {ex}", ModManager.LogLevel.Error);
}
}
///
/// The same rule for luminance and kill-task sharing. WithinRange already compares
/// landblock objects, but when fellow_kt_landblock is on it ORs that with a plain
/// distance, which two copies pass. Off today; this keeps it correct either way.
///
[HarmonyPostfix]
[HarmonyPatch(typeof(ACE.Server.Entity.Fellowship), nameof(ACE.Server.Entity.Fellowship.WithinRange))]
public static void PostWithinRange(Player player, List __result)
{
try
{
if (__result is { Count: > 0 } && Mod.Settings.Enabled)
__result.RemoveAll(f => !ReferenceEquals(f, player) && InDifferentCopies(player, f));
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] fellowship range check failed: {ex}", ModManager.LogLevel.Error);
}
}
///
/// Two players standing in different copies of the SAME landblock. Different
/// landblock ids are not our business - ACE's own distance rules handle those.
///
private static bool InDifferentCopies(Player? a, Player? b)
{
var la = a?.CurrentLandblock;
var lb = b?.CurrentLandblock;
if (la is null || lb is null || ReferenceEquals(la, lb))
return false;
return la.Id.Landblock == lb.Id.Landblock;
}
///
/// Stops a copy loading the real dungeon's shard-persisted objects.
///
/// Landblock.Init calls SpawnDynamicShardObjects, which fetches by landblock id:
///
/// GetDynamicObjectsByLandblock(Id.Landblock)
///
/// Since a copy shares the source id, every copy would instantiate the SAME biota
/// rows as the real dungeon - duplicate objects sharing guids across landblocks.
/// Worse, WorldObject.Destroy calls RemoveBiotaFromDatabase unconditionally, so
/// closing a copy would delete the real dungeon's persisted objects outright.
///
/// A copy is meant to be a fresh instance of a dungeon's static content, never a
/// second instantiation of persisted state, so it simply does not load any.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Landblock), "SpawnDynamicShardObjects")]
public static bool PreSpawnDynamicShardObjects(Landblock __instance)
{
try
{
return !InstanceWorld.IsOurs(__instance);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] shard-object guard failed: {ex}", ModManager.LogLevel.Error);
return true;
}
}
///
/// Anything asking LandblockManager for a landblock while a copy is being ticked
/// should get that copy. Guarded on the ambient tick only - a lookup with no
/// instance context in play must never be diverted, or housing, allegiances and
/// lifestones would start resolving into instances.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(LandblockManager), nameof(LandblockManager.GetLandblock),
new[] { typeof(LandblockId), typeof(bool), typeof(bool) })]
public static bool PreGetLandblock(LandblockId landblockId, ref Landblock __result)
{
try
{
var ambient = Context.Ambient;
if (ambient is null)
return true;
if (ambient.Id.LandblockX != landblockId.LandblockX ||
ambient.Id.LandblockY != landblockId.LandblockY)
return true; // asking about a different landblock than the one ticking
__result = ambient;
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] GetLandblock routing failed, falling back to stock: {ex}",
ModManager.LogLevel.Error);
return true;
}
}
///
/// Physics resolving a cell id to a landblock. LScape.get_landblock is static and
/// takes only a cell id, so the ambient tick is the only context available - which
/// is precisely why Context keeps one.
///
/// Returning the copy's own PhysicsLandblock is what keeps collision inside the
/// instance: each Landblock built its own, so two copies never share cells.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(LScape), nameof(LScape.get_landblock))]
public static bool PreGetPhysicsLandblock(uint blockCellID, ref ACE.Server.Physics.Common.Landblock __result)
{
try
{
var ambient = Context.Ambient;
if (ambient is null)
return true;
var wanted = (ushort)(blockCellID >> 16);
if (ambient.Id.Landblock != wanted)
return true;
__result = ambient.PhysicsLandblock;
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] physics landblock routing failed, falling back to stock: {ex}",
ModManager.LogLevel.Error);
return true;
}
}
}