namespace Aeshnidae.Towns; /// /town for everyone, /town-admin for staff and the console. One chat line per SendMessage. public static class Commands { [CommandHandler("town", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, 0, "Allegiance towns: your allegiance's town and treasury.", "/town your allegiance's town, treasury, what a charter costs, and which town you are in\n" + "/town donate give pyreals from your bank to your allegiance's treasury\n" + "/town buy (monarch) buy a charter for a town on the menu\n" + "/town go travel to your allegiance's own copy of its town - the only way in\n" + "/town leave travel to Holtburg\n" + "/town ledger the treasury's recent donations and charges\n" + "/town menu every town an allegiance can buy")] public static void HandleTown(Session session, params string[] parameters) { var player = session.Player; var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : ""; switch (verb) { case "": Reply(session, Status(player)); return; case "donate" or "give" or "d": if (parameters.Length < 2) { Reply(session, "/town donate , or /town donate all - from your bank."); return; } var text = parameters[1].Replace(",", "").ToLowerInvariant(); long amount = text == "all" ? long.MaxValue : long.TryParse(text, out var n) ? n : 0; if (amount <= 0) { Reply(session, "Give an amount of pyreals, or 'all'."); return; } Reply(session, Towns.Donate(player, amount)); return; case "buy": Reply(session, parameters.Length < 2 ? "/town buy - see /town for the menu." : Towns.Buy(player, string.Join(" ", parameters.Skip(1)))); return; case "go" or "home" or "travel": Reply(session, Towns.Go(player)); return; case "leave": Reply(session, Towns.Leave(player)); return; case "where": Reply(session, Where(player)); return; case "menu" or "towns" or "list": var names = (Mod.Settings.Towns ?? new()).OrderBy(t => t.Name).Select(t => t.Name).ToList(); Reply(session, $"Towns an allegiance can buy ({names.Count}), each as its own private copy its members reach with /town go - buy with /town buy :"); for (var i = 0; i < names.Count; i += 12) Reply(session, " " + string.Join(", ", names.Skip(i).Take(12))); return; case "ledger" or "log": var monarch = Towns.MonarchOf(player); if (monarch == 0) { Reply(session, "You are not sworn to an allegiance."); return; } var rows = TownDb.RecentLedger(monarch, 10); Reply(session, rows.Count == 0 ? "The treasury has no history yet." : "Treasury, latest first:\n" + string.Join("\n", rows.Select(r => $" {r.At:yyyy-MM-dd} {r.Name}: {r.Kind} {r.Amount:N0}"))); return; default: Reply(session, "/town, /town donate , /town buy , /town go, /town leave, /town ledger"); return; } } private static string Status(Player player) { if (!Mod.Settings.Enabled) return "Towns are not open."; var sb = new StringBuilder(); var monarch = Towns.MonarchOf(player); if (monarch == 0) { sb.AppendLine("You are not sworn to an allegiance. An allegiance can buy a town of its own - a private copy for its members."); } else { var treasury = TownDb.Ready ? TownDb.Treasury(monarch) : 0; var town = Towns.TownOf(monarch); sb.AppendLine(town is null ? $"{Towns.MonarchName(monarch)}'s allegiance has no town yet. Its treasury holds {treasury:N0} pyreals; a charter costs {Mod.Settings.CharterPrice:N0}." : $"{Towns.MonarchName(monarch)}'s allegiance holds {Mod.Settings.Town(town.TownKey)?.Name ?? town.TownKey} (since {town.CreatedAt:yyyy-MM-dd}). Treasury: {treasury:N0} pyreals. /town go takes you there; everywhere else, including the public town itself, is unchanged."); if (town is null) sb.AppendLine($"Members give with /town donate (from the bank); the monarch buys with /town buy . At least {Mod.Settings.MinAccounts} accounts must be sworn to the allegiance."); } sb.AppendLine(Where(player)); sb.AppendLine($"{Mod.Settings.Towns?.Count ?? 0} towns are on the menu - /town menu lists them."); return sb.ToString(); } /// /// Which town you are standing in - your allegiance's own copy of it, or the public one. /// Said every time, because the two look identical and a player must never have to guess /// (Tom, 2026-10-09). /// public static string Where(Player player) { var copy = Towns.StandingIn(player); if (copy > 0 && Towns.TownOfCopy(copy) is { } own) { var mine = Mod.Settings.Town(own.TownKey)?.Name ?? own.TownKey; return $"You are standing in {mine} - {own.MonarchName}'s allegiance's own copy of it, not the public town. /town leave to go."; } var landblock = player.Location is null ? (ushort)0 : (ushort)(player.Location.LandblockId.Raw >> 16); if (Mod.Settings.TownAt(landblock) is { } here) return $"You are standing in {here.Name} - the public one, the same {here.Name} everyone else is in."; return "You are not standing in a town on the menu."; } [CommandHandler("town-admin", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Allegiance towns, for staff. Console-safe.", "/town-admin every town, its allegiance, copy and treasury\n" + "/town-admin grant a town for that monarch's allegiance, free\n" + "/town-admin open test: open a town's copy and count what is in it\n" + "/town-admin route test: the access decision, as a login asks it\n" + "/town-admin where [player] which town a player is standing in, what is recorded, and their routing\n" + "/town-admin bindcheck [player] test: the claim, routing and aim this mod borrows from InstancesNoDat\n" + "/town-admin abandon that allegiance's town let go (no refund)\n" + "/town-admin scan [max] the land around a landblock - what an island town's Landblocks should be\n" + "/town-admin reload re-read Settings.json and register every town again")] public static void HandleAdmin(Session session, params string[] parameters) { var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : ""; switch (verb) { case "": var towns = Towns.All(); var sb = new StringBuilder($"Towns {(Mod.Settings.Enabled ? "ON" : "off")}: {towns.Count} town(s) (at load: {Towns.LoadStatus}); binding {Instances.Status}; {Towns.HeldSpawns} monster spawn(s) held\n"); sb.AppendLine(" a copy is OPT-IN: /town go and a login inside are the only ways in (owner key 0 = InstancesNoDat routes nobody)"); var cooldown = TimeSpan.FromMinutes(Math.Max(0, Mod.Settings.TownCooldownMinutes)); var refused = TimeSpan.FromSeconds(Math.Max(0, cooldown.TotalSeconds - 0.05)); // what is left the instant after using it sb.AppendLine($" /town go cooldown {cooldown.TotalSeconds:0.##} s ({Mod.Settings.TownCooldownMinutes} min) - a player refused right after using it is told \"{Towns.Wait(refused)}\""); foreach (var t in towns) { sb.AppendLine($" town {t.Id}: {t.TownKey} of {t.MonarchName} (0x{t.MonarchId:X8}), copy {Towns.CopyOf(t)}, since {t.CreatedAt:yyyy-MM-dd HH:mm}, treasury {TownDb.Treasury(t.MonarchId):N0}"); sb.AppendLine($" registered: {Instances.Describe(Towns.CopyOf(t)) ?? "NO - nobody can enter it"}"); } foreach (var p in PlayerManager.GetAllOnline().Where(p => p is not null && Towns.StandingIn(p) > 0)) sb.AppendLine($" inside: {p.Name} in copy {Towns.StandingIn(p)} (recorded {Towns.Recorded(p)})"); Reply(session, sb.ToString()); return; case "grant" when parameters.Length >= 3: { // A monarch by name (online: their allegiance is read) or by guid (0x5..., online or not). var who = string.Join(" ", parameters.Skip(1).Take(parameters.Length - 2)); uint monarch = 0; if (who.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && uint.TryParse(who[2..], System.Globalization.NumberStyles.HexNumber, null, out var guid)) monarch = guid; else if (PlayerManager.GetOnlinePlayer(who) is { } online) monarch = Towns.MonarchOf(online); if (monarch == 0) { Reply(session, "Give an online monarch's name, or a monarch's guid (0x...)."); return; } WorldManager.EnqueueAction(new ActionEventDelegate(() => Reply(session, Towns.Grant(monarch, parameters[^1])))); return; } case "open" when parameters.Length >= 2 && uint.TryParse(parameters[1], out var townId): { var town = Towns.All().FirstOrDefault(t => t.Id == townId); if (town is null || Mod.Settings.Town(town.TownKey)?.Landing.ToPosition() is not { } landing) { Reply(session, $"No town {townId}."); return; } WorldManager.EnqueueAction(new ActionEventDelegate(() => { var lb = (ushort)(landing.LandblockId.Raw >> 16); var block = Instances.GetOrCreate(lb, Towns.CopyOf(town)); Reply(session, block is null ? $"could not open town {townId}" : $"town {townId} open at {lb:X4} (copy {Towns.CopyOf(town)}) - counting in 4 s"); var chain = new ActionChain(); chain.AddDelaySeconds(4); chain.AddAction(WorldManager.ActionQueue, () => { if (block is null) return; var objects = block.GetAllWorldObjectsForDiagnostics(); var monsters = objects.OfType().Count(c => c is not Player && c.IsMonster); var npcs = objects.OfType().Count(c => c is not Player && !c.IsMonster); Reply(session, $"town {townId} {lb:X4}: {objects.Count} object(s), {npcs} NPC(s) and vendor(s), {monsters} monster(s); {Towns.HeldSpawns} monster spawn(s) held so far"); }); chain.EnqueueChain(); })); return; } // The access decision on its own, with the three things it is made of handed in: // whose allegiance (a monarch's name or guid, or - for nobody), what the character // recorded standing in, and where they are being placed. This is the question a login // asks, and the answer a non-member must never get. case "route" when parameters.Length >= 4: { uint key = 0; // The last two are the copy and the landblock; everything between is the name, // which may have spaces ("Lucy McJuicy"). var who = string.Join(" ", parameters.Skip(1).Take(parameters.Length - 3)); if (who.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && uint.TryParse(who[2..], System.Globalization.NumberStyles.HexNumber, null, out var guid)) key = guid; else if (PlayerManager.GetOnlinePlayer(who) is { } online) key = Towns.MonarchKeyOf(online); else if (Towns.All().FirstOrDefault(t => string.Equals(t.MonarchName, who, StringComparison.OrdinalIgnoreCase)) is { } named) key = named.MonarchId; if (!int.TryParse(parameters[^2], out var recorded)) { Reply(session, "/town-admin route "); return; } var lbText = parameters[^1].Replace("0x", "", StringComparison.OrdinalIgnoreCase); if (!ushort.TryParse(lbText[..Math.Min(4, lbText.Length)], System.Globalization.NumberStyles.HexNumber, null, out var lbTest)) { Reply(session, "the landblock is four hex digits, e.g. DC3C"); return; } var (answer, why) = Towns.ArrivalCopyFor(key, recorded, lbTest); Reply(session, $"allegiance 0x{key:X8}, recorded {recorded}, at {lbTest:X4} -> {(answer > 0 ? $"copy {answer}" : "the REAL landblock")} ({why})"); return; } // Proves the three things `/town go` and a login borrow from InstancesNoDat across // the reflection binding really answer: a claim, the routing question, and an aim. // Done on a landblock nothing is ever routed to, and the claim is put straight back, // so it moves nobody and changes nothing a player can see. case "bindcheck": { var who = parameters.Length >= 2 ? string.Join(" ", parameters.Skip(1)) : null; var subject = PlayerManager.GetAllOnline().FirstOrDefault(p => p is not null && (who is null || string.Equals(p.Name, who, StringComparison.OrdinalIgnoreCase))); if (subject is null) { Reply(session, who is null ? "nobody is online to test the binding with." : $"{who} is not online."); return; } const ushort nowhere = 0x0001; // not a town landblock and not one anybody travels to const int fake = 999_999; var before = Instances.RouteFor(subject, nowhere); var claimed = Instances.Claim(subject, nowhere, fake); var during = Instances.RouteFor(subject, nowhere); Instances.Force(subject, nowhere, 0); var released = Instances.Claim(subject, nowhere, 0); var after = Instances.RouteFor(subject, nowhere); var ok = claimed && released && during == fake && after == before; Reply(session, $"binding check with {subject.Name} on {nowhere:X4}: routing {before} -> claim {(claimed ? "ok" : "FAILED")} -> routing {during} " + $"-> release {(released ? "ok" : "FAILED")} -> routing {after}: {(ok ? "OK - Context.Assign, Context.CopyFor and Context.Force all answer across the binding" : "FAILED - /town go and the login path cannot work")}"); return; } case "where": { var who = parameters.Length >= 2 ? string.Join(" ", parameters.Skip(1)) : null; foreach (var p in PlayerManager.GetAllOnline().Where(p => p is not null && (who is null || string.Equals(p.Name, who, StringComparison.OrdinalIgnoreCase)))) { var lb = p.Location is null ? (ushort)0 : (ushort)(p.Location.LandblockId.Raw >> 16); Reply(session, $"{p.Name} at {lb:X4}: standing in copy {Towns.StandingIn(p)}, recorded {Towns.Recorded(p)}, " + $"allegiance 0x{Towns.MonarchKeyOf(p):X8}, routing says {Instances.RouteFor(p, lb)} - {Where(p)}"); } Reply(session, who is null ? "(every online player above)" : $"(if nothing is listed, {who} is not online)"); return; } case "abandon" when parameters.Length >= 2: { var name = string.Join(" ", parameters.Skip(1)); var town = Towns.All().FirstOrDefault(t => string.Equals(t.MonarchName, name, StringComparison.OrdinalIgnoreCase)); Reply(session, town is null ? $"No town held by {name}'s allegiance." : Towns.Abandon(town.MonarchId)); return; } case "scan" when parameters.Length >= 2: { var text = parameters[1].Replace("0x", "", StringComparison.OrdinalIgnoreCase); if (!ushort.TryParse(text[..Math.Min(4, text.Length)], System.Globalization.NumberStyles.HexNumber, null, out var start)) { Reply(session, "/town-admin scan [max], e.g. scan E922"); return; } var max = parameters.Length > 2 && int.TryParse(parameters[2], out var m) ? m : 40; var land = Towns.Scan(start, max); Reply(session, $"land around {start:X4}: {land.Count} landblock(s){(land.Count >= max ? " (stopped at the limit - not an island this small?)" : "")}: " + string.Join(" ", land.Select(l => l.ToString("X4")))); return; } case "reload": Mod.ReloadSettings(); Reply(session, $"settings re-read: {Mod.Settings.Towns?.Count ?? 0} town(s) on the menu; towns registered again"); return; default: Reply(session, "/town-admin [grant|open|route|where|bindcheck|abandon|scan|reload] - see /help town-admin"); return; } } private static void Reply(Session? session, string message) { foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (string.IsNullOrWhiteSpace(text)) continue; if (session?.Player is null) ModManager.Log(text); else session.Player.SendMessage(text); } } }