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