namespace Aeshnidae.OverworldCopies; public static class Commands { [CommandHandler("overworld", AccessLevel.Player, CommandHandlerFlag.None, 0, "Private copies of the outdoor landblock you stand on (test server).", "/overworld status, and what you can do here\n" + "/overworld enter your own copy of the landblock you stand on\n" + "/overworld join a fellowship member's copy of it\n" + "/overworld leave back to the real world, where you stand\n" + "/overworld on | off the switch (Admin)\n" + "/overworld list every open copy (Admin)\n" + "/overworld close close one, everyone inside put back (Admin)\n" + "/overworld selftest check the edge from inside a bubble (Admin)\n" + "/overworld testopen open a bubble with nobody in it (Admin, console)\n" + "/overworld testcheck check one from the console (Admin)\n" + "/overworld testcopy open a PLAIN copy (not a bubble), report where its statics landed (Admin)\n" + "/overworld testclose close a plain test copy (Admin)\n" + "/overworld send [player...] put players (a fellowship, for a group region) into a region copy at its landing (Admin, console)")] public static void Handle(Session session, params string[] parameters) { var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : ""; var isAdmin = session?.Player is null || session.AccessLevel >= AccessLevel.Admin; var who = session?.Player?.Name ?? "console"; switch (verb) { case "on": case "off": if (!isAdmin) { Reply(session, "Only an Admin can switch overworld copies on or off."); return; } Reply(session, Mod.Switch(verb == "on", who)); return; case "list": if (!isAdmin) { Reply(session, "Only an Admin can list every copy."); return; } Reply(session, List()); return; case "close": if (!isAdmin) { Reply(session, "Only an Admin can close a copy."); return; } Reply(session, Close(parameters)); return; case "selftest": if (!isAdmin || session?.Player is null) { Reply(session, "An Admin standing in a bubble runs this."); return; } Reply(session, Bubbles.IsBubble(session.Player.CurrentLandblock, out var mine) && mine is not null ? SelfTest(mine, session.Player) : "Stand in a bubble first (/overworld enter)."); return; case "testopen": if (!isAdmin) { Reply(session, "Admin only."); return; } if (parameters.Length < 2 || !TryLandblock(parameters[1], out var testLb)) { Reply(session, "usage: /overworld testopen "); return; } Reply(session, Bubbles.OpenEmpty(testLb).Message); return; case "testcopy": if (!isAdmin) { Reply(session, "Admin only."); return; } if (parameters.Length < 2 || !TryLandblock(parameters[1], out var copyLb)) { Reply(session, "usage: /overworld testcopy "); return; } Reply(session, TestCopy(copyLb)); return; case "testclose": if (!isAdmin) { Reply(session, "Admin only."); return; } if (parameters.Length < 3 || !TryLandblock(parameters[1], out var closeLb) || !int.TryParse(parameters[2], out var closeCopy)) { Reply(session, "usage: /overworld testclose "); return; } if (Bubbles.Get(closeLb, closeCopy) is not null) { Reply(session, "That one is a bubble - /overworld close."); return; } Reply(session, Bridge.Refresh() && Bridge.Close(closeLb, closeCopy, ejectPlayers: true) ? $"Closed copy {closeCopy} of {closeLb:X4}." : $"No copy {closeCopy} of {closeLb:X4} is open."); return; case "testcheck": if (!isAdmin) { Reply(session, "Admin only."); return; } if (parameters.Length < 3 || !TryLandblock(parameters[1], out var checkLb) || !int.TryParse(parameters[2], out var checkCopy)) { Reply(session, "usage: /overworld testcheck "); return; } Reply(session, Bubbles.Get(checkLb, checkCopy) is { } tested ? SelfTest(tested, null) : $"No overworld copy {checkCopy} of {checkLb:X4}."); return; case "send": if (!isAdmin) { Reply(session, "Admin only."); return; } Reply(session, Send(parameters)); return; case "enter": case "join": case "leave": var player = session?.Player; if (player is null) { Reply(session, "That needs a character in the world."); return; } if (session!.AccessLevel < (AccessLevel)Mod.Settings.EnterAccessLevel) { Reply(session, "Overworld copies are not open to you yet."); return; } var (_, message) = verb switch { "enter" => Bubbles.Enter(player), "join" => Bubbles.Join(player), _ => Bubbles.Leave(player), }; Reply(session, message); return; default: Reply(session, Status(session?.Player)); return; } } /// /overworld send <region> <player> [player...]: see Bubbles.SendToRegion. private static string Send(string[] parameters) { if (parameters.Length < 3) return "usage: /overworld send [player...] e.g. /overworld send aerlinthe-south Buffy Drakkon"; if (!Mod.Settings.Enabled) return "Overworld copies are switched off."; if (!Bridge.Refresh()) return $"InstancesNoDat is not available: {Bridge.Status}."; var region = parameters[1]; if (!Mod.Settings.Landings.TryGetValue(region, out var landingText) || !TryPosition(landingText, out var landing)) return $"No landing for '{region}' in Settings.Landings (\"0xCELL x y z qw qz\")."; var players = new List(); foreach (var name in parameters.Skip(2)) { var p = PlayerManager.GetOnlinePlayer(name) ?? PlayerManager.GetOnlinePlayer("+" + name); if (p is null) return $"{name} is not online."; players.Add(p); } return Bubbles.SendToRegion(region, players, landing); } /// "0xBAE8001D 84 105 26 0 -1": cell, x, y, z, qw, qz. private static bool TryPosition(string text, out Position position) { position = null!; var f = (text ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries); if (f.Length < 4 || !uint.TryParse(f[0].Replace("0x", "", StringComparison.OrdinalIgnoreCase), System.Globalization.NumberStyles.HexNumber, null, out var cell)) return false; var n = f.Skip(1).Select(v => float.TryParse(v, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var x) ? x : float.NaN).ToArray(); if (n.Take(3).Any(float.IsNaN)) return false; var qw = n.Length > 3 && !float.IsNaN(n[3]) ? n[3] : 1f; var qz = n.Length > 4 && !float.IsNaN(n[4]) ? n[4] : 0f; position = new Position(cell, n[0], n[1], n[2], 0f, 0f, qz, qw); return true; } private static string Status(Player? player) { Bridge.Refresh(); var sb = new StringBuilder(); sb.Append($"Overworld copies are {(Mod.Settings.Enabled ? "ON" : "OFF")}; {Bubbles.Count} open. InstancesNoDat: {Bridge.Status}."); if (player?.CurrentLandblock is { } block) { var here = (ushort)(player.Location.LandblockId.Raw >> 16); if (Bubbles.IsBubble(block, out var bubble) && bubble is not null) sb.Append($"\nYou are in {bubble}: {Bridge.PlayersIn(block).Count} player(s) here. /overworld leave, or walk off the edge, to return."); else if (block.IsDungeon) sb.Append($"\nYou are in a dungeon ({here:X4}); overworld copies are for outdoor landblocks."); else sb.Append($"\nYou are on outdoor landblock {here:X4}.{(Mod.Settings.Enabled ? " /overworld enter opens your own copy of it." : "")}"); } return sb.ToString(); } private static string List() { Bridge.Refresh(); if (Bubbles.Count == 0) return $"No overworld copies are open (switch {(Mod.Settings.Enabled ? "on" : "off")})."; var sb = new StringBuilder($"{Bubbles.Count} overworld copy(ies):"); foreach (var b in Bubbles.All.OrderBy(b => b.Landblock).ThenBy(b => b.Copy)) { var block = Bridge.Find(b.Landblock, b.Copy); var players = Bridge.PlayersIn(block); sb.Append($"\n {b}: opened {(DateTime.UtcNow - b.Opened).TotalMinutes:0} min ago, " + (players.Count == 0 ? "empty" : string.Join(", ", players.Select(p => p.Name)))); } return sb.ToString(); } private static bool TryLandblock(string text, out ushort landblock) => ushort.TryParse(text.Replace("0x", "", StringComparison.OrdinalIgnoreCase), System.Globalization.NumberStyles.HexNumber, null, out landblock); private static string Close(string[] parameters) { if (parameters.Length < 3 || !TryLandblock(parameters[1], out var landblock) || !int.TryParse(parameters[2], out var copy)) return "usage: /overworld close , as /overworld list shows them"; var bubble = Bubbles.Get(landblock, copy); if (bubble is null) return $"No overworld copy {copy} of {landblock:X4}."; Bubbles.Close(bubble, "closed by an Admin"); return $"Closed {bubble}; anyone inside is back in the real world where they stood."; } private static readonly AccessTools.FieldRef> _objectsOf = AccessTools.FieldRefAccess>("worldObjects"); /// /// Checks, from inside a bubble, that the edge is what it should be - against the /// running server rather than against what the code says: the neighbour list is /// empty, the bubble's own landblock resolves to the bubble, a neighbour resolves to /// nothing (which is also the proof that this mod's physics hook and InstancesNoDat's /// combine the right way round), the player is physically in the bubble, and no /// housing came with it. /// private static string SelfTest(Bubble bubble, Player? player) { if (bubble.Block is null) return $"{bubble} has no landblock yet."; var block = bubble.Block; var physics = block.PhysicsLandblock; var lines = new List { $"Self-test in {bubble}:" }; void Check(string what, bool ok, string detail = "") => lines.Add($" {(ok ? "PASS" : "FAIL")} {what}{(detail.Length > 0 ? $" - {detail}" : "")}"); var adjacents = physics.get_adjacents(); Check("physics neighbour list is empty", adjacents is not null && adjacents.Count == 0, adjacents is null ? "it is null (visibility would throw)" : $"{adjacents.Count} neighbour(s)"); var own = (uint)bubble.Landblock << 16 | 0x0001; var x = (byte)(bubble.Landblock >> 8); var y = (byte)(bubble.Landblock & 0xFF); var neighbour = (uint)(((x < 0xFE ? x + 1 : x - 1) << 8) | y) << 16 | 0x0001; var previous = Bridge.AmbientFast(); ACE.Server.Physics.Common.Landblock? ownResolved, neighbourResolved; try { Bridge.SetAmbient(block); ownResolved = LScape.get_landblock(own); neighbourResolved = LScape.get_landblock(neighbour); } finally { Bridge.SetAmbient(previous); } Check("own landblock resolves to the bubble", ReferenceEquals(ownResolved, physics), ownResolved is null ? "nothing" : ReferenceEquals(ownResolved, physics) ? "" : "the REAL landblock"); Check($"neighbour {neighbour >> 16:X4} resolves to nothing", neighbourResolved is null, neighbourResolved is null ? "" : "a real landblock - the bubble would see across its edge"); if (player is not null) Check("you are physically in the bubble", ReferenceEquals(player.PhysicsObj?.CurLandblock, physics), player.PhysicsObj?.CurLandblock is null ? "no cell" : ReferenceEquals(player.PhysicsObj.CurLandblock, physics) ? "" : "your physics is in another landblock"); List objects; lock (block) objects = _objectsOf(block).Values.ToList(); var leftOut = Mod.Settings.LeftOutTypes.Select(n => Enum.TryParse(n, true, out var t) ? t : (WeenieType?)null).Where(t => t is not null).ToHashSet(); var housing = objects.Count(o => leftOut.Contains(o.WeenieType)); Check("no housing in the bubble", housing == 0, $"{housing} housing object(s) of {objects.Count}"); // Every creature and object the bubble holds must be filed in the bubble's own cells, // not the real landblock's - the thing that makes it invisible from outside. var misfiled = objects.Count(o => o.PhysicsObj?.CurLandblock is { } lb && !ReferenceEquals(lb, physics)); Check("every object is in the bubble's own cells", misfiled == 0, $"{misfiled} filed elsewhere"); var master = Bridge.Master(bubble.Landblock); var masterObjects = master is null ? new List() : _objectsOf(master).Values.ToList(); var masterHousing = masterObjects.Count(o => leftOut.Contains(o.WeenieType)); lines.Add($" {objects.Count} object(s) here ({objects.Count(o => o is Creature && o is not Player)} creature(s)); " + $"the real landblock has {masterObjects.Count}, {masterHousing} of them housing."); return string.Join("\n", lines); } /// /// Where a copy's static objects and scenery filed their collision: into the copy's /// own cells, or the real landblock's. InstancesNoDat's own job (its GetOrCreate marks /// the copy during Init); this just looks, on any landblock, dungeon or not, with a /// plain copy that is not a bubble - so none of this mod's hooks are involved. Left /// open, because its creatures are still spawning on Init's background task; close it /// with /overworld testclose once they have (or it closes itself once it has been /// empty for InstancesNoDat's EmptyGraceSeconds). /// private static string TestCopy(ushort landblock) { if (!Bridge.Refresh()) return $"InstancesNoDat is not available: {Bridge.Status}."; var copy = Bridge.AllocateCopy(landblock, Bridge.MaxCopies()); if (copy <= 0) return "No free copy slot."; var block = Bridge.GetOrCreate(landblock, copy); if (block is null) return "InstancesNoDat could not make the copy."; { var master = Bridge.Master(landblock); var own = block.PhysicsLandblock; var real = master?.PhysicsLandblock; int inCopy = 0, inMaster = 0, elsewhere = 0, none = 0; foreach (var obj in own.StaticObjects.Concat(own.Scenery ?? [])) { var cell = obj.CurCell; if (cell is null) { none++; continue; } var key = CellKey(cell.ID); if (own.LandCells.TryGetValue(key, out var c) && ReferenceEquals(c, cell)) inCopy++; else if (real is not null && real.LandCells.TryGetValue(key, out var m) && ReferenceEquals(m, cell)) inMaster++; else elsewhere++; } var adjacents = own.get_adjacents(); return $"Plain copy {copy} of {landblock:X4} ({(block.IsDungeon ? "dungeon" : "outdoor")}): " + $"{own.StaticObjects.Count} static object(s), {own.Scenery?.Count ?? 0} scenery - " + $"{inCopy} filed in the copy's own cells, {inMaster} in the REAL landblock's, {elsewhere} elsewhere, {none} with no cell. " + $"Neighbour list: {(adjacents is null ? "null" : $"{adjacents.Count}")}. " + $"Left open while it spawns: /overworld testclose {landblock:X4} {copy}."; } } /// A cell's key in LandCells (see InstancesNoDat's InstanceWorld.CellKey): outdoor cell n is n - 1. private static int CellKey(uint cellId) { var low = cellId & 0xFFFF; return low >= 0x100 ? (int)low : (int)low - 1; } private static void Reply(Session? session, string message) { if (session?.Player is null) { ModManager.Log(message); return; } foreach (var line in message.Split('\n')) session.Player.SendMessage(line); } }