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