using PhysicsLandblock = ACE.Server.Physics.Common.Landblock;
namespace Aeshnidae.OverworldCopies;
///
/// What an outdoor copy needs on top of InstancesNoDat's copy machinery. Every hook
/// first asks whether the thing in front of it is one of OUR bubbles, and passes
/// straight through if not - so dungeon copies, and the whole server while the switch
/// is off, see no difference at all.
///
[HarmonyPatch]
public static class Patches
{
// ---- the edge: nothing beyond it ----------------------------------------------------
///
/// Physics resolving a cell to a landblock while a bubble is ticking. For the
/// bubble's own landblock InstancesNoDat hands back the copy's physics; for any
/// OTHER landblock - a neighbour, reached by a step, a chase, a line of sight or an
/// arrow near the edge - stock ACE would hand back the REAL one, and the bubble would
/// see, touch and walk into the real world across its edge. So a bubble has nothing
/// beyond its edge: no cells, which physics treats as nowhere to go.
///
/// First in line, and it is the hottest method this mod touches (every physics query
/// on the server passes through it), so the common case is two field reads.
///
[HarmonyPrefix]
[HarmonyPriority(Priority.First)]
[HarmonyPatch(typeof(LScape), nameof(LScape.get_landblock))]
public static bool PreGetPhysicsLandblock(uint blockCellID, ref PhysicsLandblock __result)
{
if (!Bubbles.AnyOpen)
return true;
var ambient = Bridge.AmbientFast();
var wanted = (ushort)(blockCellID >> 16);
if (ambient is null || wanted == ambient.Id.Landblock || !Bubbles.IsBubble(ambient, out var bubble))
return true;
// Another landblock of the same region: InstancesNoDat's own hook hands back the
// region copy's cells there. Only past the region's outer edge is there nothing.
if (bubble is not null && bubble.Covers(wanted))
return true;
__result = null!;
return false;
}
// ---- a bubble being built -----------------------------------------------------------
///
/// A bubble's Landblock.Init, which InstancesNoDat calls the moment the copy is made.
/// Two things an outdoor copy needs that a dungeon copy never did:
///
/// Neighbours: outdoor visibility is this landblock's objects plus those of its
/// physics NEIGHBOURS (Physics.Common.Landblock.GetServerObjects(true)), and that
/// list is null until LandblockManager.SetAdjacents fills it - which it never does
/// for a copy. A dungeon cell never asks; an outdoor cell asks the moment the first
/// creature steps into it, and throws. A bubble's neighbours are: none.
///
/// Trees, rocks and buildings: PhysicsLandblock.PostInit files every static
/// object's and every piece of scenery's collision into the cells it covers,
/// looking cells up through LScape - which, with no copy marked, are the REAL
/// landblock's. Marked, they land in the bubble's own cells, so its creatures do
/// not walk through walls; the few that overhang the edge find no cells there.
///
/// InstancesNoDat does both for every copy since 2026-09-24 (GetOrCreate). Kept here
/// as well, so a bubble is built right against an InstancesNoDat without that - live's
/// until it is deployed there. Setting an empty neighbour list twice is harmless.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(Landblock), nameof(Landblock.Init), new[] { typeof(bool) })]
public static void PreInit(Landblock __instance, out (bool Marked, Landblock? Previous) __state)
{
__state = (false, null);
try
{
if (!Bubbles.AnyOpen || !Bubbles.IsBubble(__instance, out var bubble))
return;
__instance.PhysicsLandblock.SetAdjacents(new List());
__state = (true, Bridge.AmbientFast());
Bridge.SetAmbient(__instance);
Mod.Trace($"{bubble}: built with no neighbours; its scenery filed into its own cells");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] preparing a bubble failed: {ex}", ModManager.LogLevel.Error);
}
}
[HarmonyFinalizer]
[HarmonyPatch(typeof(Landblock), nameof(Landblock.Init), new[] { typeof(bool) })]
public static void FinalInit((bool Marked, Landblock? Previous) __state)
{
if (__state.Marked)
Bridge.SetAmbient(__state.Previous);
}
// ---- the edge: a player walking off it ----------------------------------------------
///
/// A player in a bubble whose client has walked them over its edge. ACE applies the
/// client's position here, inside the bubble's physics tick, and - with no cell to
/// put them in beyond the edge - would skip physics but ACCEPT the position, and the
/// relocation after the tick would put them in the real neighbour with the bubble's
/// objects still on their screen. So the step is refused here, and the player is
/// put back in the real world where they stand (just inside the edge) on the world
/// thread straight after the tick; the teleport that does it resets their client,
/// and their next step carries on across the real edge.
///
/// Not for teleports (Teleporting, forceUpdate): those are routed by InstancesNoDat.
///
[HarmonyPrefix]
[HarmonyPriority(Priority.First)]
[HarmonyPatch(typeof(Player), nameof(Player.UpdatePlayerPosition), new[] { typeof(Position), typeof(bool) })]
public static bool PreUpdatePlayerPosition(Player __instance, Position newPosition, bool forceUpdate, ref bool __result)
{
try
{
if (!Bubbles.AnyOpen || forceUpdate || newPosition is null || __instance.Teleporting)
return true;
if (!Bubbles.IsBubble(__instance.CurrentLandblock, out var bubble) || bubble is null)
return true;
if (bubble.Covers((ushort)(newPosition.LandblockId.Raw >> 16)))
return true; // inside the bubble, or across a seam of its region
Bubbles.QueueWalkOff(__instance, bubble);
__result = false;
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] edge check failed, passing through: {ex.Message}", ModManager.LogLevel.Error);
return true;
}
}
// ---- the edge: the backstop -------------------------------------------------------------
///
/// Anything in a bubble being moved into a different landblock. With nothing beyond
/// the edge physics should never get this far, so this is a backstop: a player is put
/// back in the real world where they stand (the same as walking off), and anything
/// else is kept out of the real world - InstancesNoDat's own rule, which is right for
/// a dungeon, would move a bubble's monster into the real neighbour.
///
/// First in line, ahead of InstancesNoDat's relocation hook, and only when the
/// destination is a different landblock: a move inside the bubble is theirs.
///
[HarmonyPrefix]
[HarmonyPriority(Priority.First)]
[HarmonyPatch(typeof(LandblockManager), nameof(LandblockManager.RelocateObjectForPhysics))]
public static bool PreRelocate(WorldObject worldObject, bool adjacencyMove)
{
try
{
if (!Bubbles.AnyOpen || worldObject?.Location is null
|| !Bubbles.IsBubble(worldObject.CurrentLandblock, out var bubble) || bubble is null)
return true;
var landblock = (ushort)(worldObject.Location.LandblockId.Raw >> 16);
// A teleport out - a recall, a portal away, a death - is a move to another
// landblock by definition, and InstancesNoDat's own relocation hook takes it
// out of the copy exactly as it does from a dungeon. Only a STEP across the
// edge (nothing teleporting) is this backstop's business.
if (bubble.Covers(landblock) || worldObject.Teleporting)
return true;
if (worldObject is Player player)
{
ModManager.Log($"[{Mod.Name}] {player.Name} reached the relocation backstop leaving {bubble} for {landblock:X4} - moving them out", ModManager.LogLevel.Warn);
Bubbles.WalkedOff(player, bubble);
return false;
}
ModManager.Log($"[{Mod.Name}] {worldObject.Name} (0x{worldObject.Guid.Full:X8}) tried to leave {bubble} for {landblock:X4}; " +
"kept out of the real world", ModManager.LogLevel.Warn);
worldObject.Destroy();
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] relocation guard failed, passing through: {ex}", ModManager.LogLevel.Error);
return true;
}
}
// ---- teleports -------------------------------------------------------------------------------
///
/// Every teleport of a player standing in a bubble. Two jobs:
///
/// Within the bubble's own landblock - InstancesNoDat's own redraw after moving a
/// player in (PlaceIn), a portal across the field - the teleport is aimed at the
/// bubble. Player.Teleport places the player's physics by looking the cell up
/// through LScape, and with no copy marked that is the REAL landblock's cell: the
/// player would stand among the real landblock's creatures until a repair noticed.
/// InstancesNoDat aims teleports into its dungeons the same way (its Aim); it does
/// not know a bubble's landblock is one of ours.
///
/// Out of it - a recall, a portal away, a death - the player's claim on the bubble
/// is dropped. InstancesNoDat keeps a claim until the copy closes, which for a
/// dungeon means "come back through the portal and you are in your own again"; for
/// a field it would mean every later visit to that landblock, on foot or by
/// portal, landed back in the bubble instead of the real world.
///
/// Last in line, so InstancesNoDat's own teleport hook has already noted what was
/// marked before - its finalizer and ours then restore the same thing.
///
[HarmonyPrefix]
[HarmonyPriority(Priority.Last)]
[HarmonyPatch(typeof(Player), nameof(Player.Teleport), new[] { typeof(Position), typeof(bool) })]
public static void PreTeleport(Player __instance, Position _newPosition, out (bool Aimed, Landblock? Previous) __state)
{
__state = (false, null);
try
{
if (_newPosition is null)
return;
var destination = (ushort)(_newPosition.LandblockId.Raw >> 16);
// A portal into a region: give the player its copy first; the aim below then
// lands them in it.
if (!Bubbles.IsBubble(__instance.CurrentLandblock) && Mod.Settings.Enabled)
Bubbles.EnterByPortal(__instance, destination);
if (!Bubbles.AnyOpen)
return;
if (!Bubbles.IsBubble(__instance.CurrentLandblock, out var bubble) || bubble?.Block is null)
{
// Arriving from outside into a bubble or region the player has a claim on
// (/overworld send): aimed at it, so the physics lands in its cells and the
// real landblock is never stood in on the way.
var claimed = Bridge.CopyFor(__instance, destination);
if (claimed > 0 && Bubbles.Get(destination, claimed) is { Block: { } into })
{
__state = (true, Bridge.AmbientFast());
Bridge.SetAmbient(into);
}
return;
}
// Sent on purpose into ANOTHER copy that has the destination - an admin's
// /teleto from one copy of the region into the other (Bubbles.FollowInto):
// aimed at that copy, not the one they are standing in.
var named = Bridge.CopyFor(__instance, destination);
if (named > 0 && named != bubble.Copy && Bubbles.Get(destination, named) is { Block: { } other })
{
__state = (true, Bridge.AmbientFast());
Bridge.SetAmbient(other);
return;
}
if (destination == bubble.Landblock)
{
__state = (true, Bridge.AmbientFast());
Bridge.SetAmbient(bubble.Block);
return;
}
// Within a region: aimed at the region copy's landblock the teleport lands on.
if (bubble.Covers(destination) && Bridge.Find(destination, bubble.Copy) is { } there)
{
__state = (true, Bridge.AmbientFast());
Bridge.SetAmbient(there);
return;
}
// A group copy's claim is the fellowship's (keyed on its leader): dropping it while
// a fellow is still inside would cut the others off from their own copy.
if (Bridge.IsGroup(bubble.Landblock)
&& Bridge.PlayersIn(bubble.Block).Any(p => !ReferenceEquals(p, __instance)))
{
Mod.Trace($"{__instance.Name} teleporting out of group {bubble} to {_newPosition.ToLOCString()} - claim kept, fellows still inside");
return;
}
Bridge.Unassign(__instance, bubble.Landblock);
Bubbles.ReleaseRegion(__instance, bubble);
Mod.Trace($"{__instance.Name} teleporting out of {bubble} to {_newPosition.ToLOCString()} - claim dropped");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] teleport check failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
[HarmonyFinalizer]
[HarmonyPriority(Priority.Last)]
[HarmonyPatch(typeof(Player), nameof(Player.Teleport), new[] { typeof(Position), typeof(bool) })]
public static void FinalTeleport((bool Aimed, Landblock? Previous) __state)
{
if (__state.Aimed)
Bridge.SetAmbient(__state.Previous);
}
// ---- admin teleports between people -----------------------------------------------------
///
/// /teleto: the admin goes to a player. When that player is in a bubble or a region
/// copy, the admin arrives in it beside them (Bubbles.FollowInto) rather than on the
/// real landblock at the same spot. InstancesNoDat does the same for its dungeons in
/// its own prefix on this command; the island's landblocks are not among them.
///
[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;
if (PlayerManager.GetOnlinePlayer(string.Join(" ", parameters)) is { } target)
Bubbles.FollowInto(session.Player, target, "/teleto");
}
catch (Exception ex)
{
// Never block a teleport an admin asked for; they simply land where ACE puts them.
ModManager.Log($"[{Mod.Name}] /teleto into a copy failed, passing through: {ex.Message}", ModManager.LogLevel.Warn);
}
}
/// /teletome: a player brought to an admin who is standing in a bubble or a region copy arrives in it.
[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 is null || parameters is null || parameters.Length == 0)
return;
if (PlayerManager.GetOnlinePlayer(string.Join(" ", parameters)) is { } target)
Bubbles.FollowInto(target, session.Player, "/teletome");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] /teletome into a copy failed, passing through: {ex.Message}", ModManager.LogLevel.Warn);
}
}
// ---- housing left out ---------------------------------------------------------------------
private static HashSet? _leftOut;
private static List? _leftOutFrom;
private static HashSet LeftOut()
{
var names = Mod.Settings.LeftOutTypes;
if (_leftOut is null || !ReferenceEquals(_leftOutFrom, names))
{
_leftOut = names.Select(n => Enum.TryParse(n, true, out var t) ? t : (WeenieType?)null)
.Where(t => t is not null).Select(t => t!.Value).ToHashSet();
_leftOutFrom = names;
}
return _leftOut;
}
///
/// The placed rows a bubble spawns from, without housing. Runs after InstancesNoDat's
/// own postfix (its variants) and, like it, returns a new list - the cache is shared
/// with the real landblock and is never touched.
///
[HarmonyPostfix]
[HarmonyPriority(Priority.Last)]
[HarmonyPatch(typeof(WorldDatabaseWithEntityCache), nameof(WorldDatabaseWithEntityCache.GetCachedInstancesByLandblock), new[] { typeof(ushort) })]
public static void PostGetCachedInstances(ushort landblock, ref List __result)
{
try
{
if (!Bubbles.AnyOpen && !Bubbles.Opening || __result is null || __result.Count == 0)
return;
var ambient = Bridge.AmbientFast();
if (ambient is null || ambient.Id.Landblock != landblock || !Bubbles.IsBubble(ambient, out var bubble))
return;
var leftOut = LeftOut();
var kept = __result.Where(row => DatabaseManager.World.GetCachedWeenie(row.WeenieClassId) is not { } w || !leftOut.Contains(w.WeenieType)).ToList();
if (kept.Count == __result.Count)
return;
Mod.Trace($"{bubble}: {__result.Count - kept.Count} housing object(s) left out of {__result.Count}");
__result = kept;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] housing filter failed: {ex.Message}", ModManager.LogLevel.Error);
}
}
// ---- housekeeping ----------------------------------------------------------------------------
private static DateTime _lastHousekeeping;
/// Once a second, after ACE's landblock tick: see Bubbles.Housekeeping.
[HarmonyPostfix]
[HarmonyPatch(typeof(LandblockManager), nameof(LandblockManager.Tick))]
public static void PostTick()
{
try
{
var now = DateTime.UtcNow;
if (now - _lastHousekeeping < TimeSpan.FromSeconds(1))
return;
_lastHousekeeping = now;
if (!Bridge.Refresh())
return;
if (Mod.Settings.Enabled)
Bubbles.Housekeeping();
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] housekeeping failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
}