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