namespace Aeshnidae.InstancesNoDat; /// /// Admin-only, and deliberately raw. This exists to prove the routing works before /// anything is built on top of it - portals, pools and access control belong in a /// later slice, once two players in two copies of one dungeon has actually held up. /// public static class Commands { [CommandHandler("inst", AccessLevel.Admin, CommandHandlerFlag.RequiresWorld, -1, "Dungeon instances that need no client dat changes. Experimental.", "/inst what is open, and where you are\n" + "/inst enter move yourself into a copy of the dungeon you are standing in\n" + "/inst leave back to the original landblock\n" + "/inst master stand in the master copy to author it, and stay there\n" + "/inst reload push the master's content into every open copy\n" + "/inst goto join whichever copy a player is actually in\n" + "/inst close drop every copy")] public static void HandleNoInst(Session session, params string[] parameters) { var player = session.Player; if (player is null) return; if (!Mod.Settings.Enabled) { Reply(session, "InstancesNoDat is disabled in Settings.json. It is experimental - " + "turn it on deliberately, on a server you are willing to restart."); return; } var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : ""; switch (verb) { case "enter": Enter(session, parameters); break; case "leave": Leave(session); break; case "resync": Resync(session); break; case "count": Count(session, parameters); break; case "help": Help(session); break; case "master": Master(session); break; case "reload": Reload(session); break; case "goto": Goto(session, parameters); break; case "close": Close(session); break; case "instance": Instance(session, parameters); break; default: Status(session); break; } } /// /// Every command this mod adds, admin and player, in one place. Worth a command /// rather than a document because the question is always asked in game. /// private static void Help(Session session) { var sb = new StringBuilder(); sb.AppendLine("Instanced dungeons - private copies that need no client dat changes."); sb.AppendLine(""); sb.AppendLine("PLAYERS"); sb.AppendLine(" /instance which copy of a dungeon you are in,"); sb.AppendLine(" and its enlightenment difficulty tier"); sb.AppendLine(""); sb.AppendLine("ADMIN - flagging a dungeon"); sb.AppendLine(" /inst instance list what is flagged, and how many copies are open"); sb.AppendLine(" /inst instance 002B personal a copy each"); sb.AppendLine(" /inst instance 002B fellowship a copy per party"); sb.AppendLine(" /inst instance 002B allegiance a copy per allegiance"); sb.AppendLine(" /inst instance 002B unflag it"); sb.AppendLine(""); sb.AppendLine("ADMIN - authoring, in this order"); sb.AppendLine(" /inst master go to the master FIRST - the only version that is authored"); sb.AppendLine(" /createinst place a permanent object at your feet"); sb.AppendLine(" /removeinst remove the object you last appraised"); sb.AppendLine(" /inst reload push the master into every open copy"); sb.AppendLine(""); sb.AppendLine(" Author in the master, never in a copy. /createinst spawns into whichever"); sb.AppendLine(" landblock you are standing in, so doing it inside a copy puts the object"); sb.AppendLine(" somewhere only that copy can see until the copy is closed."); sb.AppendLine(""); sb.AppendLine("ADMIN - moving around and looking"); sb.AppendLine(" /inst where you are, and every open copy"); sb.AppendLine(" /inst count how many of a thing each copy really holds"); sb.AppendLine(" /inst resync clear a phantom player somebody can still see"); sb.AppendLine(" /inst goto join whichever copy a player is really in"); sb.AppendLine(" /teleto the same - patched to follow into copies"); sb.AppendLine(" /inst enter put yourself in copy n of where you stand"); sb.AppendLine(" /inst leave back to the master"); sb.AppendLine(" /inst close drop every copy; anyone inside is recalled"); sb.AppendLine(" /inst help this"); Reply(session, sb.ToString().TrimEnd()); } private static void Status(Session session) { var player = session.Player!; var here = (ushort)(player.Location.LandblockId.Raw >> 16); var sb = new StringBuilder(); var orphaned = InstanceWorld.IsOrphaned(player.CurrentLandblock) ? " <- CLOSED COPY, nothing ticks this" : ""; sb.AppendLine($"Standing in {here:X4}, copy {InstanceWorld.CopyOf(player.CurrentLandblock)} " + $"(0 is the server's own landblock).{orphaned}"); sb.AppendLine($"Open copies: {InstanceWorld.Count}. Players assigned: {Context.AssignedCount}."); if (Mod.Settings.ScaleWithEnlightenment) { var cap = Mod.Settings.MaxEnlightenmentsCounted; sb.AppendLine($"Enlightenment scaling (personal copies only): " + $"+{Mod.Settings.DifficultyPercentPerEnlightenment:0.##}% difficulty, " + $"+{Mod.Settings.RewardPercentPerEnlightenment:0.##}% reward, per enlightenment" + $"{(cap > 0 ? $", counted to {cap}" : "")}. You are EL {player.Enlightenment}."); } else sb.AppendLine("Enlightenment scaling: off. Copies are stock difficulty."); foreach (var (key, block) in InstanceWorld.All.OrderBy(a => a.Key.Landblock).ThenBy(a => a.Key.Copy)) { var people = InstanceWorld.PlayersIn(block); var names = people.Count > 0 ? " - " + string.Join(", ", people.Select(pl => pl.Name)) : ""; // The enlightenment the copy was OPENED at, which is what its monsters were // built from - not the owner's enlightenment now. Those differ the moment // somebody enlightens without closing their dungeon, and when a player says // "the scaling did not apply" this is the line that answers it. var counted = Scaling.Counted(key.Landblock, key.Copy); var scaled = Mod.Settings.ScaleWithEnlightenment && counted > 0 ? $", tier {counted} " + $"(+{counted * Mod.Settings.DifficultyPercentPerEnlightenment:0.##}% harder, " + $"+{counted * Mod.Settings.RewardPercentPerEnlightenment:0.##}% richer)" : ""; sb.AppendLine($" {key.Landblock:X4} copy {key.Copy}: {people.Count} player(s), " + $"{InstanceWorld.ObjectCount(block)} object(s){names}{scaled}"); } Reply(session, sb.ToString().TrimEnd()); } /// /// How many objects matching a name each copy holds, and the master alongside them. /// /// The question that keeps coming up is "is this thing really in that copy, or is the /// client just showing it to me", and until now the only way to guess was to read /// teardown counts out of the log. This answers it directly. /// /// /// Re-tells every client in this dungeon who is and is not actually beside them. /// /// For the one failure that leaves no trace on the server: a client still drawing /// somebody who has moved to another copy. Server-side tracking is correct, so no /// audit can find it - the missing message just has to be sent again. /// private static void Resync(Session session) { var here = (ushort)(session.Player!.Location.LandblockId.Raw >> 16); InstanceWorld.ResyncVisibility(here); Reply(session, $"Told every client in {here:X4} who is really beside them. " + "A phantom player should disappear within a moment."); } private static void Count(Session session, string[] parameters) { var player = session.Player!; if (parameters.Length < 2) { Reply(session, "usage: /inst count , e.g. /inst count drudge"); return; } var text = string.Join(" ", parameters.Skip(1)); var here = (ushort)(player.Location.LandblockId.Raw >> 16); var sb = new StringBuilder(); sb.AppendLine($"Objects matching \"{text}\" in {here:X4}:"); var master = LandblockManager.GetLandblock(new LandblockId((uint)here << 16 | 0xFFFF), false); sb.AppendLine($" you: {InstanceWorld.Describe(player)}"); sb.AppendLine($" master: {InstanceWorld.CountMatching(master, text)} " + $"(of {InstanceWorld.ObjectCount(master)} objects)"); foreach (var line in InstanceWorld.DescribeMatching(master, text)) sb.AppendLine($" {line}"); foreach (var (key, block) in InstanceWorld.All.Where(a => a.Key.Landblock == here) .OrderBy(a => a.Key.Copy)) { sb.AppendLine($" copy {key.Copy}: {InstanceWorld.CountMatching(block, text)} " + $"(of {InstanceWorld.ObjectCount(block)} objects)"); foreach (var line in InstanceWorld.DescribeMatching(block, text)) sb.AppendLine($" {line}"); } Reply(session, sb.ToString().TrimEnd()); } private static void Enter(Session session, string[] parameters) { var player = session.Player!; if (parameters.Length < 2 || !int.TryParse(parameters[1], out var copy) || copy < 1) { Reply(session, "usage: /inst enter , where copy is 1 or higher."); return; } if (copy > Mod.Settings.MaxCopiesPerLandblock) { Reply(session, $"Copy {copy} is above the configured maximum of {Mod.Settings.MaxCopiesPerLandblock}."); return; } var here = (ushort)(player.Location.LandblockId.Raw >> 16); if (Mod.Settings.InteriorsOnly && !HasInterior(here)) { Reply(session, $"{here:X4} has no dungeon interior. Copies are restricted to interiors - " + "a surface landblock would need adjacency and terrain seams reasoned through, " + "and they have not been."); return; } var block = InstanceWorld.GetOrCreate(here, copy); if (block is null) { Reply(session, "Could not open that copy - see the server log."); return; } if (!InstanceWorld.PlaceIn(player, here, copy, "/inst enter")) { Reply(session, "Could not move you into that copy - see the server log."); return; } Reply(session, $"You are now in copy {copy} of {here:X4}. " + "Anything you meet in here is yours alone."); } private static void Leave(Session session) { var player = session.Player!; var here = (ushort)(player.Location.LandblockId.Raw >> 16); var copy = Context.CopyFor(player, here); if (copy == 0) { Reply(session, "You are not in a copy of this landblock."); return; } InstanceWorld.PlaceIn(player, here, 0, "/inst leave"); Reply(session, $"Back on the server's own {here:X4}. The copy closes once it is empty."); } /// /// Puts a dev in the master copy - ACE's own landblock - and keeps them there. /// /// This is the one place a dungeon can be authored. /createinst writes world /// database rows against a landblock id, and every copy spawns from those rows, so /// there is exactly one authored version and this is it. Without this command an /// admin walking into an instanced dungeon is handed a private copy like anyone /// else, and edits made there would look right and reach nobody. /// private static void Master(Session session) { var player = session.Player!; var here = (ushort)(player.Location.LandblockId.Raw >> 16); if (!Mod.IsInstanced(here) && InstanceWorld.CopyOf(player.CurrentLandblock) == 0) { Reply(session, $"{here:X4} is not instanced, so where you are standing IS the master. " + "Edit it with /createinst and /removeinst as normal."); return; } // Clearing the assignment is the part that makes it stick: with one in place the // next landblock crossing would route you straight back into your own copy. InstanceWorld.PlaceIn(player, here, 0, "/inst master"); Reply(session, $"You are in the master copy of {here:X4}. Edits here with /createinst " + "and /removeinst are what every copy spawns from - push them out with " + "/inst reload. /inst enter 1 gets you a private copy again."); } /// /// Re-reads the master into every open copy, so a dev's edits land everywhere at /// once rather than only in the landblock they happen to be standing in. /// private static void Reload(Session session) { var player = session.Player!; var here = (ushort)(player.Location.LandblockId.Raw >> 16); var copies = InstanceWorld.ReloadAllCopies(here); // The master is asked for by id, never taken from where the caller happens to be // standing. CopyOf answers 0 for the master AND for a closed copy, so reading it // off the player reloaded whichever block they were holding - which, for someone // stranded in a copy that had been dropped, meant emptying and respawning a dead // landblock while the real master was left exactly as it was. var master = LandblockManager.GetLandblock(new LandblockId((uint)here << 16 | 0xFFFF), false); if (master is not null) { // Delayed a tick and done ACE's own way - this is what /reload-landblock // does, and on the real master SaveDB is correct rather than dangerous. var chain = new ActionChain(); chain.AddDelayForOneTick(); chain.AddAction(player, () => { master.DestroyAllNonPlayerObjects(); master.Init(true); }); chain.EnqueueChain(); } Reply(session, $"Reloaded the master of {here:X4} and {copies} open copy(ies). " + "Everything respawns from the master's world database rows - give it a " + "moment, then /inst count to check."); } /// /// Joins whichever copy a player is actually in. /// /// ACE's /teleto teleports to a Position, and a Position cannot tell two copies of a /// dungeon apart - so it lands a dev in their own private copy of the same geometry, /// alone, which is useless for watching someone. This assigns the dev to the /// target's copy first, so the teleport arrives where the player really is. /// private static void Goto(Session session, string[] parameters) { var player = session.Player!; if (parameters.Length < 2) { Reply(session, "usage: /inst goto "); return; } var name = string.Join(" ", parameters.Skip(1)); var target = PlayerManager.GetOnlinePlayer(name); if (target is null) { Reply(session, $"{name} is not online."); return; } if (target.Location is null) { Reply(session, $"{target.Name} has no location yet - still logging in."); return; } var landblock = (ushort)(target.Location.LandblockId.Raw >> 16); var copy = InstanceWorld.CopyOf(target.CurrentLandblock); // The position first, so we land beside them rather than wherever we were // standing in our own copy of the same rooms. player.Location = new Position(target.Location); InstanceWorld.PlaceIn(player, landblock, copy, "/inst goto"); Reply(session, copy > 0 ? $"You are in copy {copy} of {landblock:X4} with {target.Name}." : $"{target.Name} is not in a copy - this is the master {landblock:X4}."); } private static void Close(Session session) { var count = InstanceWorld.Count; InstanceWorld.Clear(); Reply(session, $"Closed {count} copy(ies). Anyone who was inside has been recalled to " + "their lifestone."); } /// /// Flags a dungeon and says who shares a copy of it. Once flagged, every existing /// route in works - a portal, a recall, an admin teleport, a login - because the /// hook is on entry rather than on any particular portal. Nothing needs a new weenie /// and nothing needs a world database change. /// private static void Instance(Session session, string[] parameters) { if (parameters.Length < 2) { if (Mod.Instanced.Count == 0) { Reply(session, "No instanced dungeons yet. /inst instance " + "[personal|fellowship|allegiance]"); return; } var sb = new StringBuilder(); sb.AppendLine("Instanced dungeons:"); foreach (var (lb, entry) in Mod.Instanced.OrderBy(e => e.Key)) { var open = InstanceWorld.All.Count(a => a.Key.Landblock == lb); sb.AppendLine($" {lb:X4} {entry.Scope,-10} {open} copy(ies) open" + (string.IsNullOrWhiteSpace(entry.Name) ? "" : $" \"{entry.Name}\"")); } Reply(session, sb.ToString().TrimEnd()); return; } var text = parameters[1].Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase); if (!ushort.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out var landblock)) { Reply(session, $"'{parameters[1]}' is not a hex landblock id. Try 8602."); return; } var list = Mod.Settings.Instanced ?? new List(); var existing = list.FirstOrDefault(e => e.TryParse(out var p) && p == landblock); // No scope given and already flagged: unflag it. if (parameters.Length < 3 && existing is not null) { list.Remove(existing); Mod.Settings.Instanced = list; Mod.Settings.Save(Mod.ModPath); Mod.ReloadInstanced(); Reply(session, $"{landblock:X4} is no longer instanced. Copies already open stay until empty."); return; } var scopeText = parameters.Length > 2 ? parameters[2] : "personal"; if (!Enum.TryParse(scopeText, ignoreCase: true, out var scope)) { Reply(session, $"'{scopeText}' is not a scope. Use personal, fellowship or allegiance."); return; } if (Mod.Settings.InteriorsOnly && !HasInterior(landblock)) { Reply(session, $"{landblock:X4} has no dungeon interior, so it cannot be instanced."); return; } if (existing is not null) { existing.Scope = scope; Reply(session, $"{landblock:X4} is now shared per {scope.ToString().ToLowerInvariant()}. " + "Copies already open keep their current owners until they empty."); } else { list.Add(new InstancedLandblock { Landblock = landblock.ToString("X4"), Scope = scope }); Reply(session, $"{landblock:X4} now hands out a copy per {scope.ToString().ToLowerInvariant()} " + "on entry. Every portal, recall, teleport and login into it works - " + "nothing else to set up."); } Mod.Settings.Instanced = list; Mod.Settings.Save(Mod.ModPath); Mod.ReloadInstanced(); } /// /// The player-facing question: am I in my own copy of this place, or the shared one? /// /// Worth its own command at player access because "we can see each other" and "we /// cannot see each other" are both reported as bugs, and neither reporter can /// currently tell which landblock they are standing in. /// [CommandHandler("instance", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, 0, "Which copy of a dungeon you are in.")] public static void HandleInstance(Session session, params string[] parameters) { var player = session.Player; if (player is null) return; if (!Mod.Settings.Enabled) { Reply(session, "Instanced dungeons are switched off on this server."); return; } var here = (ushort)(player.Location.LandblockId.Raw >> 16); var copy = InstanceWorld.CopyOf(player.CurrentLandblock); var name = Mod.NameFor(here); var where = string.IsNullOrWhiteSpace(name) ? $"{here:X4}" : $"{name} ({here:X4})"; if (copy > 0) { var scope = Mod.ScopeFor(here); var others = InstanceWorld.PlayersIn(player.CurrentLandblock).Count - 1; Reply(session, $"You are in copy {copy} of {where}, shared with " + $"{Context.GroupDescription(player, scope)}."); Reply(session, others > 0 ? $"{others} other player(s) are in here with you." : "Nobody else is in here with you."); foreach (var line in Scaling.Describe(here, copy)) Reply(session, line); return; } if (InstanceWorld.IsOrphaned(player.CurrentLandblock)) { Reply(session, $"You are in a copy of {where} that has been closed - an empty room " + "nothing is updating. Recall or relog to get out of it."); return; } if (Mod.IsInstanced(here)) { Reply(session, $"You are in the shared version of {where}, not a copy of it. " + "Leaving and coming back should hand you your own."); // Said explicitly rather than left out. Somebody who has enlightened and is // wondering why the dungeon feels unchanged is most likely standing in the // master, and "nothing about difficulty was mentioned" is a worse answer // than "tier 0, because this is the shared version". Reply(session, "Dungeon Difficulty: Enlightenment Tier 0 - the shared version is never scaled."); return; } Reply(session, $"{where} is not an instanced dungeon - everyone here is in the same place."); } /// /// A landblock has an interior if the cell dat holds a LandblockInfo for it. That /// is the same record the client uses to decide whether to draw a dungeon, so this /// agrees with what the player can actually see. /// private static bool HasInterior(ushort landblock) { try { var id = (uint)landblock << 16 | 0xFFFE; return DatManager.CellDat.AllFiles.ContainsKey(id); } catch { return false; } } private static void Reply(Session? session, string message) { if (session?.Player is null) { ModManager.Log(message); return; } foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (!string.IsNullOrWhiteSpace(text)) session.Player.SendMessage(text); } } }