namespace Aeshnidae.OverworldCopies; /// One bubble: a copy of an outdoor landblock, opened for someone. public sealed class Bubble { public required ushort Landblock { get; init; } public required int Copy { get; init; } public required uint OwnerGuid { get; init; } public required string OwnerName { get; init; } public DateTime Opened { get; } = DateTime.UtcNow; /// The copy's Landblock, once InstancesNoDat has made it. public Landblock? Block { get; set; } /// The region this landblock's copy is part of (InstancesNoDat Regions), or null for a one-landblock bubble. public string? Region { get; init; } /// Every landblock of the region - the landblocks a step, a sight line or a teleport may reach without leaving. public HashSet? RegionLandblocks { get; init; } /// Whether this landblock is inside the bubble: its own, or another landblock of its region. public bool Covers(ushort landblock) => landblock == Landblock || (RegionLandblocks?.Contains(landblock) ?? false); public override string ToString() => Region is null ? $"{Landblock:X4} copy {Copy} ({OwnerName})" : $"{Landblock:X4} copy {Copy} of region '{Region}' ({OwnerName})"; } /// /// The bubbles this mod opened, and every way in and out of them. /// /// A bubble is an InstancesNoDat copy of an OUTDOOR landblock. That mod makes it, ticks /// it, moves players in and out (InstanceWorld.PlaceIn), and closes it when it has been /// empty long enough - exactly as for a dungeon copy. What is different outdoors is the /// edge: a dungeon has no neighbours, a landblock outside has eight. So a bubble is a /// single 192 m square with nothing beyond it (Patches.PreGetPhysicsLandblock), a player /// who walks off it is put back in the real world where they stand /// (Patches, the movement hook -> WalkedOff), and housing is left out of it /// (Patches.PostGetCachedInstances). /// /// Only the copies listed here are bubbles; every other copy - every dungeon - is none /// of this mod's business, and every hook checks that first. /// public static class Bubbles { /// The copy a Shared region always is (InstancesNoDat's InstanceWorld.SharedCopy); its layers have their own numbers. private const int SharedCopy = 1; private static readonly ConcurrentDictionary<(ushort Landblock, int Copy), Bubble> _byKey = new(); private static readonly ConcurrentDictionary _byBlock = new(ReferenceEqualityComparer.Instance); /// Set while a bubble is being opened and its Landblock is not known yet; see IsBubble. private static int _opening; /// /// Whether any bubble exists. Read by the physics hook on every query on the server, /// so it is one volatile field rather than the dictionary's Count (which takes locks). /// private static volatile bool _anyOpen; public static bool AnyOpen => _anyOpen; public static bool Opening => Volatile.Read(ref _opening) > 0; /// A region bubble whose landblock is not open yet (regions open as players move): IsBubble looks harder while there are any. private static volatile bool _anyUnbound; private static void Recount() { _anyOpen = !_byKey.IsEmpty; _anyUnbound = _byKey.Values.Any(b => b.Block is null); } public static ICollection All => _byKey.Values; public static int Count => _byKey.Count; /// /// Whether a landblock is one of our bubbles. The hot path is one dictionary lookup /// by reference. While a bubble is being opened its Landblock object does not exist /// until InstancesNoDat makes it, and its spawning starts at once on a background /// task - so for that moment the slow way (which copy is it?) is asked too. /// public static bool IsBubble(Landblock? block, out Bubble? bubble) { bubble = null; if (block is null) return false; if (_byBlock.TryGetValue(block, out bubble)) return true; if (Volatile.Read(ref _opening) == 0 && !_anyUnbound) return false; var copy = Bridge.CopyOf(block); if (copy > 0 && _byKey.TryGetValue(((ushort)block.Id.Landblock, copy), out bubble)) { bubble.Block = block; _byBlock[block] = bubble; return true; } return false; } public static bool IsBubble(Landblock? block) => IsBubble(block, out _); /// A bubble by landblock and copy, known to us. public static Bubble? Get(ushort landblock, int copy) => _byKey.GetValueOrDefault((landblock, copy)); // ---- in --------------------------------------------------------------------------------- /// /overworld enter: the player's own bubble of the landblock they stand on. public static (bool Ok, string Message) Enter(Player player) { if (Refuse(player) is { } no) return (false, no); var here = (ushort)(player.Location.LandblockId.Raw >> 16); // A landblock of a region: the whole region, one copy. if (Bridge.RegionOf(here) is { } region) return EnterRegion(player, here, region); // A group area: a fellow's copy here is the fellowship's copy, so /overworld enter // goes into it (what /overworld join does), and only opens a new one when none is. if (Bridge.IsGroup(here) && FellowsCopy(player, here) is { } ours) return Open(player, here, ours.Copy, "/overworld enter (the fellowship's copy)", $"You are in your fellowship's copy of this area ({ours.OwnerName} opened it). Walk off its edge, or /overworld leave, to return."); // Reopen your own if it is still there - leaving and coming back is not a reset. var mine = _byKey.Values.FirstOrDefault(b => b.Landblock == here && b.OwnerGuid == player.Guid.Full && Bridge.Find(here, b.Copy) is not null); var copy = mine?.Copy ?? Bridge.AllocateCopy(here, Bridge.MaxCopies()); if (copy <= 0) return (false, $"Every copy of this area is in use ({Bridge.MaxCopies()}). Try again when one closes."); return Open(player, here, copy, mine is null ? "/overworld enter" : "/overworld enter (back into your own)", mine is null ? "You are in your own copy of this area: the 192 m landblock you were standing on, with its own creatures. " + "Walk off its edge, or type /overworld leave, to step back into the real world where you stand." : "Back in your own copy of this area. Walk off its edge, or /overworld leave, to return."); } /// /overworld join: into a fellowship member's bubble of this landblock. public static (bool Ok, string Message) Join(Player player) { if (Refuse(player) is { } no) return (false, no); var here = (ushort)(player.Location.LandblockId.Raw >> 16); // A solo area's copies are one person's each (dungeon rules: entering one leaves // your fellowship), so there is nothing to join. if (!Bridge.IsGroup(here)) return (false, "Copies of this area are solo - each is one person's. /overworld enter opens your own."); if (player.Fellowship?.GetFellowshipMembers() is not { Count: >= 2 }) return (false, "You are not in a fellowship. /overworld join puts you in a fellow's copy of the area you are standing in."); var theirs = FellowsCopy(player, here); if (theirs is null) return (false, "Nobody in your fellowship has a copy of this area open. Stand on the same landblock as them and try again."); return Open(player, here, theirs.Copy, "/overworld join", $"You are in {theirs.OwnerName}'s copy of this area. Walk off its edge, or /overworld leave, to return."); } /// /// /overworld enter on a landblock of a region: the player's region copy (or, in a group /// region, the fellowship's), opening it when there is none - every landblock of the /// region at once, joined at the seams - and the player placed in it where they stand. /// Every landblock of the region is claimed for them, so a step across a seam, a login /// and a teleport within the region all land in it. /// private static (bool Ok, string Message) EnterRegion(Player player, ushort here, string region) { var landblocks = Bridge.RegionLandblocks(region); if (landblocks.Count == 0) return (false, $"The region '{region}' has no landblocks."); var copy = OpenOrJoinRegion(player, here, region, landblocks, out var open, out var opened, out var failure); if (copy <= 0) return (false, failure); if (!Bridge.PlaceIn(player, here, copy, $"{Mod.Name}: /overworld enter (region {region})")) return (false, "Could not move you into the region - see the server log."); Mod.Trace($"{player.Name} into region '{region}' copy {copy} ({landblocks.Count} landblocks){(opened ? ", opened" : "")}"); var whose = open is null || open.OwnerGuid == player.Guid.Full ? "your" : open.OwnerName + "'s"; return (true, opened ? $"You are in your own copy of {region}: all {landblocks.Count} of its landblocks, joined, with their own creatures. " + "Walk off its outer edge, or type /overworld leave, to step back into the real world where you stand." : $"Back in {whose} copy of {region}. Walk off its outer edge, or /overworld leave, to return."); } /// /// The region copy this player belongs in - their own open one, or in a group region a /// fellow's - opened when there is none; every landblock of it claimed for them. /// Returns the copy number, or 0 with the reason in failure. World thread. /// /// `wanted` names the copy (InstancesNoDat layers, 2026-09-28): a Shared region has a /// fixed copy for each way in - 1 for its own portals, a layer's number for the layer's - /// so the Aerlinthe instance and the PK Aerlinthe are always the same two copies. 0 means /// the region's usual copy: 1 for a Shared region, found per player otherwise. /// private static int OpenOrJoinRegion(Player player, ushort here, string region, List landblocks, out Bubble? open, out bool opened, out string failure, int wanted = 0) { failure = ""; opened = false; if (wanted <= 0 && Bridge.IsShared(here)) wanted = SharedCopy; var fellows = Bridge.IsGroup(here) ? player.Fellowship?.GetFellowshipMembers()?.Values.Where(p => !ReferenceEquals(p, player)).Select(p => p.Guid.Full).ToHashSet() ?? new HashSet() : new HashSet(); // An open copy of this region to join - open meaning any landblock of it is (the one // they arrive in may have unloaded, idle, and is reopened in it below): // Shared - the one copy of it, whoever opened it (everyone goes to the same island); // group - a fellow's first (in a group area the fellowship's copy wins), then their own; // solo - their own. bool StillOpen(Bubble b) => b.RegionLandblocks!.Any(lb => Bridge.Find(lb, b.Copy) is not null); bool FellowsIn(Bubble b) => fellows.Count > 0 && (fellows.Contains(b.OwnerGuid) || SameCopy(b).Any(s => s.Block is { } blk && Bridge.PlayersIn(blk).Any(p => fellows.Contains(p.Guid.Full)))); var candidates = _byKey.Values.Where(b => b.Region == region && b.Landblock == here && StillOpen(b) && (wanted <= 0 || b.Copy == wanted)).ToList(); open = Bridge.IsShared(here) ? candidates.OrderBy(b => b.Copy).FirstOrDefault() : candidates.FirstOrDefault(FellowsIn) ?? candidates.FirstOrDefault(b => b.OwnerGuid == player.Guid.Full); var copy = open?.Copy ?? 0; if (copy > 0) { // The ring around where they arrive, in that copy (Regions.Open joins an open copy). Interlocked.Increment(ref _opening); try { Bridge.OpenRegion(region, player, copy, here); foreach (var bubble in SameCopy(open!)) { if (bubble.Block is null && Bridge.Find(bubble.Landblock, copy) is { } block) { bubble.Block = block; _byBlock[block] = bubble; } } Recount(); } finally { Interlocked.Decrement(ref _opening); } } if (copy <= 0) { copy = wanted > 0 ? wanted : Bridge.FreeRegionCopy(landblocks); if (copy <= 0) { failure = $"Every copy of {region} is in use. Try again when one closes."; return 0; } var set = landblocks.ToHashSet(); var bubbles = landblocks.Select(lb => _byKey.GetOrAdd((lb, copy), _ => new Bubble { Landblock = lb, Copy = copy, OwnerGuid = player.Guid.Full, OwnerName = player.Name, Region = region, RegionLandblocks = set, })).ToList(); Recount(); Interlocked.Increment(ref _opening); try { if (Bridge.OpenRegion(region, player, copy, here) <= 0) { bubbles.ForEach(Forget); failure = $"Could not open {region} - see the server log."; return 0; } foreach (var bubble in bubbles) { if (Bridge.Find(bubble.Landblock, copy) is { } block) { bubble.Block = block; _byBlock[block] = bubble; } } Recount(); } finally { Interlocked.Decrement(ref _opening); } opened = true; } foreach (var lb in landblocks) Bridge.Assign(player, lb, copy); return copy; } /// /// /overworld send (Admin, console): players into a region copy at a landing spot, as a /// portal would - for testing a region without walking there. In a group region the /// players are first put in one fellowship (the first named leads; no invite popup), /// so they share the copy. Queued to the world thread; the outcome is logged and each /// player is told. /// public static string SendToRegion(string region, List players, Position landing) { // region:layer names a layer's copy (aerlinthe:pk). var wanted = 0; if (region.Split(':') is { Length: 2 } parts) { region = parts[0]; wanted = Bridge.RegionLayerCopy(region, parts[1]); if (wanted <= 0) return $"No layer '{parts[1]}' in region '{region}'."; } var landblocks = Bridge.RegionLandblocks(region); if (landblocks.Count == 0) return $"No region '{region}' (InstancesNoDat Outdoor, an entry with Landblocks)."; var target = (ushort)(landing.LandblockId.Raw >> 16); if (!landblocks.Contains(target)) return $"The landing {landing.ToLOCString()} is not in {region}."; WorldManager.EnqueueAction(new ActionEventDelegate(() => { try { var leader = players[0]; if (Bridge.IsGroup(target) && players.Count > 1) { foreach (var p in players) if (p.Fellowship is not null && !ReferenceEquals(p.Fellowship, leader.Fellowship)) p.FellowshipQuit(false); if (leader.Fellowship is null) leader.FellowshipCreate($"{region} test", true); foreach (var p in players.Skip(1)) if (p.Fellowship is null && leader.Fellowship is not null) leader.Fellowship.AddConfirmedMember(leader, p, true); } foreach (var p in players) { var copy = OpenOrJoinRegion(p, target, region, landblocks, out _, out var opened, out var failure, wanted); if (copy <= 0) { ModManager.Log($"[{Mod.Name}] send {p.Name} to {region}: {failure}", ModManager.LogLevel.Warn); p.SendMessage(failure); continue; } WorldManager.ThreadSafeTeleport(p, new Position(landing)); p.SendMessage($"You are being sent into {(opened ? "a new" : "your fellowship's")} copy of {region} (copy {copy}, {landblocks.Count} landblocks). " + "Walk off its outer edge, or /overworld leave, to return to the real world."); ModManager.Log($"[{Mod.Name}] sent {p.Name} into region '{region}' copy {copy} at {landing.ToLOCString()}{(opened ? " (opened)" : "")}" + $"{(p.Fellowship is not null ? $", fellowship '{p.Fellowship.FellowshipName}'" : "")}"); } } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] send to {region} failed: {ex}", ModManager.LogLevel.Error); } })); return $"Sending {string.Join(", ", players.Select(p => p.Name))} into {region} at {landing.ToLOCString()} (see the log for the outcome)."; } /// /// A portal into a region (its Outdoor entry's OnlyVia): the player is given the region /// copy - theirs, or in a group region the fellowship's, opened around the landing when /// there is none - before the teleport lands, which is then aimed at it /// (Patches.PreTeleport). Called from inside Player.Teleport, where InstancesNoDat /// opens a dungeon copy too. /// public static bool EnterByPortal(Player player, ushort destination) { if (!Mod.Settings.Enabled || Bridge.RegionOf(destination) is not { } region) return false; var via = Bridge.LastPortalOf(player); // The copy the portal names: a layer's for one of its portals (the PK island), else // the region's usual one for its own portals. var wanted = Bridge.RegionCopyVia(region, via); if (via == 0 || (wanted <= 0 && !Bridge.RegionPortals(region).Contains(via))) return false; var landblocks = Bridge.RegionLandblocks(region); var copy = OpenOrJoinRegion(player, destination, region, landblocks, out _, out var opened, out var failure, wanted); if (copy <= 0) { player.SendMessage(failure); ModManager.Log($"[{Mod.Name}] portal {via} into {region} for {player.Name}: {failure}", ModManager.LogLevel.Warn); return false; } var layer = Bridge.RegionLayerName(region, copy); ModManager.Log($"[{Mod.Name}] {player.Name} through portal {via} into region '{region}' copy {copy}{(layer is null ? "" : $" (layer '{layer}')")}{(opened ? " (opened)" : "")}"); player.SendMessage(layer is not null ? $"You step onto the {layer} copy of {region}." : Bridge.IsShared(destination) ? $"You step onto {region}, one island for everyone on the server." : $"You step into {(opened ? "your own" : "your fellowship's")} copy of {region}. Walk off its outer edge, or /overworld leave, to return to the real world."); return true; } /// /// An admin teleport between two people where one of them stands in a bubble or a /// region copy - /teleto (the admin goes to them) or /teletome (they come to the /// admin). `traveller` is the one being moved; `into` the one standing still. /// /// ACE moves the traveller to a Position, and a Position cannot say which copy it /// means. The island's landblocks are not dungeons InstancesNoDat routes by itself /// (only a portal in OnlyVia hands out the copy), so the traveller used to land on /// the REAL island at the same spot: an admin following a player onto Aerlinthe /// stood alone among the retail island's monsters, invisible to the player and not /// counted in the island copy at all (2026-10-01). /// /// So the traveller is given the copy `into` stands in before the teleport runs - /// every landblock of it for a region, so a step across a seam stays inside, as a /// portal entry does - and pointed at it (Force), which also beats the stay-put rule /// for an admin already standing in some other copy. The teleport hook then aims the /// arrival at the copy's cells (Patches.PreTeleport) and they arrive inside it, /// listed in it like anyone who came through the portal. World thread (a command). /// public static bool FollowInto(Player traveller, Player into, string why) { if (!AnyOpen || traveller is null || into?.Location is null || ReferenceEquals(traveller, into)) return false; if (!IsBubble(into.CurrentLandblock, out var bubble) || bubble is null) return false; var landblock = (ushort)(into.Location.LandblockId.Raw >> 16); if (!bubble.Covers(landblock)) return false; foreach (var lb in bubble.RegionLandblocks ?? new HashSet { bubble.Landblock }) Bridge.Assign(traveller, lb, bubble.Copy); Bridge.Force(traveller, landblock, bubble.Copy); ModManager.Log($"[{Mod.Name}] {traveller.Name} ({why}) goes into {bubble}, where {into.Name} stands, not the real world"); return true; } /// Every bubble of the same copy: the whole region for a region bubble, else just this one. public static IEnumerable SameCopy(Bubble bubble) => bubble.Region is null ? new[] { bubble } : _byKey.Values.Where(b => b.Region == bubble.Region && b.Copy == bubble.Copy); /// /// A player leaving a region copy lets go of the rest of it: the landblock they leave /// from is released by PlaceIn; the region's other landblocks are released here, so a /// later walk across the real island is the real island. Not while a fellow is still /// inside a group region - the claim is the fellowship's. /// public static void ReleaseRegion(Player player, Bubble bubble) { if (bubble.Region is null || bubble.RegionLandblocks is null) return; if (Bridge.IsGroup(bubble.Landblock) && SameCopy(bubble).Any(b => b.Block is { } blk && Bridge.PlayersIn(blk).Any(p => !ReferenceEquals(p, player)))) return; foreach (var lb in bubble.RegionLandblocks) Bridge.Unassign(player, lb); } /// /// A fellow's open bubble of this landblock: where a fellow is standing right now, /// first; then one a fellow opened. Null when the player has no fellowship or none is open. /// private static Bubble? FellowsCopy(Player player, ushort here) { var fellows = player.Fellowship?.GetFellowshipMembers()?.Values .Where(p => !ReferenceEquals(p, player)).Select(p => p.Guid.Full).ToHashSet(); if (fellows is null || fellows.Count == 0) return null; return _byKey.Values.FirstOrDefault(b => b.Landblock == here && Bridge.Find(here, b.Copy) is { } block && Bridge.PlayersIn(block).Any(p => fellows.Contains(p.Guid.Full))) ?? _byKey.Values.FirstOrDefault(b => b.Landblock == here && fellows.Contains(b.OwnerGuid) && Bridge.Find(here, b.Copy) is not null); } private static (bool Ok, string Message) Open(Player player, ushort here, int copy, string why, string welcome) { var bubble = _byKey.GetOrAdd((here, copy), _ => new Bubble { Landblock = here, Copy = copy, OwnerGuid = player.Guid.Full, OwnerName = player.Name, }); Recount(); Interlocked.Increment(ref _opening); try { if (!Bridge.PlaceIn(player, here, copy, $"{Mod.Name}: {why}")) { if (Bridge.Find(here, copy) is null) Forget(bubble); return (false, "Could not open a copy of this area - see the server log."); } if (Bridge.Find(here, copy) is { } block) { bubble.Block = block; _byBlock[block] = bubble; } } finally { Interlocked.Decrement(ref _opening); } Mod.Trace($"{player.Name} into {bubble} ({why})"); return (true, welcome); } /// Why this player cannot go into a bubble here, or null if they can. private static string? Refuse(Player player) { if (!Mod.Settings.Enabled) return "Overworld copies are switched off."; if (!Bridge.Refresh()) return $"Overworld copies need Aeshnidae.InstancesNoDat, which is not available: {Bridge.Status}."; if (!Bridge.InstancesEnabled()) return "Overworld copies ride on the dungeon copies, which are switched off."; var block = player.CurrentLandblock; if (block is null || player.Location is null) return "You are not standing anywhere yet."; if (IsBubble(block)) return "You are already in a copy of this area. /overworld leave first."; if (Bridge.CopyOf(block) > 0) return "You are in a dungeon copy. Overworld copies are for the world outside."; if (block.IsDungeon) return "This is a dungeon, not the world outside. Overworld copies are for outdoor landblocks."; var here = (ushort)(player.Location.LandblockId.Raw >> 16); if (Bridge.IsInstanced(here)) return "This landblock already hands out copies of its own."; if (player.IsBusy || player.Teleporting) return "Finish what you are doing first."; return null; } /// /// /overworld testopen: a bubble of a landblock with nobody in it, for checking the /// edge, the housing filter and the spawn from the console (Commands.SelfTest). No /// owner, so its creatures are not scaled; closes like any other (or /overworld close). /// public static (Bubble? Bubble, string Message) OpenEmpty(ushort landblock) { if (!Mod.Settings.Enabled) return (null, "Overworld copies are switched off."); if (!Bridge.Refresh()) return (null, $"InstancesNoDat is not available: {Bridge.Status}."); if (Bridge.IsInstanced(landblock)) return (null, $"{landblock:X4} is a configured dungeon."); var master = Bridge.Master(landblock); if (master is null || master.IsDungeon) return (null, $"{landblock:X4} is not an outdoor landblock."); var copy = Bridge.AllocateCopy(landblock, Bridge.MaxCopies()); if (copy <= 0) return (null, "Every copy slot of that landblock is in use."); var bubble = _byKey.GetOrAdd((landblock, copy), _ => new Bubble { Landblock = landblock, Copy = copy, OwnerGuid = 0, OwnerName = "test" }); Recount(); Interlocked.Increment(ref _opening); try { var block = Bridge.GetOrCreate(landblock, copy); if (block is null) { Forget(bubble); return (null, "InstancesNoDat could not make the copy - see the server log."); } bubble.Block = block; _byBlock[block] = bubble; } finally { Interlocked.Decrement(ref _opening); } Mod.Trace($"opened {bubble} with nobody in it (test)"); return (bubble, $"Opened {bubble}. Its creatures spawn over the next few seconds; then /overworld testcheck {landblock:X4} {copy}."); } // ---- out -------------------------------------------------------------------------------- /// /overworld leave: back into the real world, where they stand. public static (bool Ok, string Message) Leave(Player player) { if (!IsBubble(player.CurrentLandblock, out var bubble) || bubble is null) return (false, "You are not in an overworld copy."); if (!Bridge.Refresh()) return (false, $"InstancesNoDat is not available ({Bridge.Status}) - cannot move you."); if (!Bridge.PlaceIn(player, bubble.Landblock, 0, $"{Mod.Name}: /overworld leave")) return (false, "Could not move you out - see the server log."); ReleaseRegion(player, bubble); return (true, "You are back in the real world. Your copy closes once it has been empty for a while; /overworld enter here takes you back into it until then."); } /// Players whose walk-off is queued, so a client still sending steps past the edge queues it once. private static readonly ConcurrentDictionary _leaving = new(); /// /// Called from inside the bubble's physics tick, where moving a player between /// landblocks is not safe: queue the walk-off for the world thread, between ticks. /// public static void QueueWalkOff(Player player, Bubble bubble) { var now = DateTime.UtcNow; if (_leaving.TryGetValue(player.Guid.Full, out var since) && now - since < TimeSpan.FromSeconds(5)) return; _leaving[player.Guid.Full] = now; WorldManager.EnqueueAction(new ActionEventDelegate(() => { try { if (IsBubble(player.CurrentLandblock, out var still) && ReferenceEquals(still, bubble)) WalkedOff(player, bubble); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] walking {player.Name} off {bubble} failed: {ex}", ModManager.LogLevel.Error); } finally { _leaving.TryRemove(player.Guid.Full, out _); } })); } /// /// A player in a bubble asked to move off its edge. They go back into the real world /// where they stand - just inside the edge - and their next step carries on over it /// normally, on the real neighbour. The step that asked is dropped: its position is /// in a landblock the bubble does not have. /// public static void WalkedOff(Player player, Bubble bubble) { if (!Bridge.PlaceIn(player, bubble.Landblock, 0, $"{Mod.Name}: walked off the edge")) { ModManager.Log($"[{Mod.Name}] {player.Name} walked off the edge of {bubble} and could not be moved out", ModManager.LogLevel.Warn); return; } ReleaseRegion(player, bubble); player.SendMessage("You step out of your copy of this area, back into the real world."); Mod.Trace($"{player.Name} walked off the edge of {bubble}"); } // ---- closing ------------------------------------------------------------------------------ /// /// Closes every bubble, moving anyone inside back into the real world where they stand /// first - InstancesNoDat's own close would recall them to their lifestone, which is /// right for a dungeon that is going away and wrong for a field that is still there. /// public static int CloseAll(string why) { var closed = 0; foreach (var bubble in _byKey.Values.ToList()) if (Close(bubble, why)) closed++; return closed; } public static bool Close(Bubble bubble, string why) { var block = Bridge.Find(bubble.Landblock, bubble.Copy); if (block is not null) { // Everyone in the copy - the whole region for a region bubble, which closes // with any of its landblocks - back into the real world where they stand. foreach (var part in SameCopy(bubble).ToList()) { if (Bridge.Find(part.Landblock, part.Copy) is not { } partBlock) continue; foreach (var player in Bridge.PlayersIn(partBlock)) { try { if (Bridge.PlaceIn(player, part.Landblock, 0, $"{Mod.Name}: {why}")) { ReleaseRegion(player, part); player.SendMessage($"Your copy of this area has been closed ({why}). You are back in the real world where you stood."); } } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not move {player.Name} out of {part}: {ex.Message}", ModManager.LogLevel.Warn); } } } // ejectPlayers true as a backstop: anyone PlaceIn could not move goes to their // lifestone rather than being left in a landblock nobody ticks. Bridge.Close(bubble.Landblock, bubble.Copy, ejectPlayers: true); } Forget(bubble); Mod.Trace($"closed {bubble} ({why})"); return block is not null; } private static void Forget(Bubble bubble) { _byKey.TryRemove((bubble.Landblock, bubble.Copy), out _); if (bubble.Block is not null) _byBlock.TryRemove(bubble.Block, out _); Recount(); } /// /// Once a second: drop bubbles InstancesNoDat has closed (it closes an empty copy /// after its EmptyGraceSeconds, and on its own reload), and pick up bubbles this mod /// lost track of across its own reload - an open copy of an outdoor landblock that is /// not a configured dungeon can only have come from here. /// public static void Housekeeping() { foreach (var bubble in _byKey.Values.ToList()) { var block = Bridge.Find(bubble.Landblock, bubble.Copy); if (block is null && bubble.Region is not null && bubble.RegionLandblocks!.Any(lb => Bridge.Find(lb, bubble.Copy) is not null)) { // A region landblock not open (yet, or again - ACE unloaded it idle); the // region is still open, so the bubble waits for it to open. if (bubble.Block is not null) { _byBlock.TryRemove(bubble.Block, out _); bubble.Block = null; } continue; } if (block is null) { Forget(bubble); if (bubble.Region is null || bubble.Block is not null) Mod.Trace($"{bubble} closed by InstancesNoDat (empty, or reloaded)"); } else if (!ReferenceEquals(block, bubble.Block)) { if (bubble.Block is not null) _byBlock.TryRemove(bubble.Block, out _); bubble.Block = block; _byBlock[block] = bubble; } } Recount(); foreach (var (landblock, copy, block) in Bridge.AllCopies()) { // An awakened world's copies are InstancesNoDat's own (Worlds), not a lost bubble. if (_byKey.ContainsKey((landblock, copy)) || block.IsDungeon || Bridge.IsInstanced(landblock) || Bridge.IsWorldCopy(copy)) continue; var owner = Bridge.PlayersIn(block).FirstOrDefault(); var region = Bridge.RegionOf(landblock); var bubble = new Bubble { Landblock = landblock, Copy = copy, OwnerGuid = owner?.Guid.Full ?? 0, OwnerName = owner?.Name ?? "unknown", Block = block, Region = region, RegionLandblocks = region is null ? null : Bridge.RegionLandblocks(region).ToHashSet(), }; if (_byKey.TryAdd((landblock, copy), bubble)) { _byBlock[block] = bubble; Recount(); ModManager.Log($"[{Mod.Name}] picked up {bubble} - an open outdoor copy this mod had lost track of"); } } } }