using ACE.DatLoader;
using ACE.DatLoader.FileTypes;
namespace Aeshnidae.Towns;
///
/// Allegiance towns (Tom, 2026-09-29): an allegiance pools pyreals in a treasury, its monarch buys a
/// charter for a town on the menu, and from then on that allegiance has its own private copy of
/// that town.
///
/// **A copy is somewhere you go on purpose (Tom, 2026-10-09).** Until then a bought town resolved
/// to the owning allegiance's copy for its members however they arrived - portal, recall, walking
/// in, logging in - so a member could never visit the real town, and Holtburg had to be left off
/// the menu for that reason alone. Now:
///
/// * portals, recalls, walking in and logging in outside give the REAL town, to everyone;
/// * `/town go` is the only way into the copy, and it checks the allegiance, so the access
/// guarantee is unchanged: no outsider can reach a copy by any route;
/// * inside, movement is seamless - the copy's landblocks are one region, so walking between
/// them keeps you in the copy. Walking off the set puts you in the real world, and walking
/// back on gives you the real town until you `/town go` again;
/// * log out inside the copy and you log back in inside it: the copy is written on the
/// character (PropertyInt ) while they stand in it, and the login path
/// reads it back (see LoginCopyFor and Patches.PreAddObject).
///
/// How, in one line: the enclave is registered with no owner key at all, so InstancesNoDat routes
/// nobody into it (Instances.NoAutomaticOwner), and this mod puts a member in by claiming the copy
/// for the one teleport `/town go` makes - and at login, for a character who was inside.
///
/// A town is copy CopyBase + its id of each of its landblocks. It spawns no monsters
/// (Settings.NoMonsters), keeps what is left in it (InstancesNoDat keeps enclave copies like
/// shared ones), and has the town's own NPCs and vendors. Building on it - a steward, a catalogue,
/// placing things, the allegiance vault, hooks - comes next; this is the charter and the town itself.
///
/// The registry is the database; this class holds it in memory and registers every town with
/// InstancesNoDat whenever the binding is (re)made.
///
public static class Towns
{
private static readonly object _lock = new();
private static Dictionary _byMonarch = new();
private static readonly ConcurrentDictionary _lastGo = new();
///
/// PropertyInt 9052 on the character: the town copy they are standing in, 0/absent for the
/// real world. Written while they are inside (the arrival line, the sweep, and once more as
/// they log out), read by the login path - the one thing the old automatic routing was buying
/// that an opt-in copy must not lose. Checked against their allegiance's town on login, so a
/// stale number can never let anyone in.
///
public const PropertyInt Inside = (PropertyInt)9052;
/// A `/town go` in flight: the claim it holds, dropped the moment its teleport is done.
private static readonly ConcurrentDictionary _entering = new();
/// How long an unfulfilled `/town go` claim is left alone before the sweep drops it.
private static readonly TimeSpan EnteringLifetime = TimeSpan.FromSeconds(15);
public static string LoadStatus { get; private set; } = "not loaded";
/// Every landblock of every town on the menu, for the spawn hook's fast path.
public static HashSet MenuLandblocks { get; private set; } = new();
public static int CopyOf(TownRow town) => Mod.Settings.CopyBase + (int)town.Id;
public static bool IsTownCopy(int copy) => copy > Mod.Settings.CopyBase;
public static void Load()
{
try
{
TownDb.Initialize();
var rows = TownDb.LoadTowns();
lock (_lock)
_byMonarch = rows.ToDictionary(r => r.MonarchId);
LoadStatus = $"{rows.Count} town(s)";
}
catch (Exception ex)
{
LoadStatus = $"database failed: {ex.Message}";
ModManager.Log($"[{Mod.Name}] {LoadStatus}", ModManager.LogLevel.Error);
}
MenuLandblocks = (Mod.Settings.Towns ?? new()).SelectMany(t => t.LandblockIds()).ToHashSet();
}
public static TownRow? TownOf(uint monarch)
{
lock (_lock)
return _byMonarch.TryGetValue(monarch, out var t) ? t : null;
}
public static List All()
{
lock (_lock)
return _byMonarch.Values.OrderBy(t => t.Id).ToList();
}
/// The town a copy number belongs to, or null.
public static TownRow? TownOfCopy(int copy)
{
if (!IsTownCopy(copy))
return null;
lock (_lock)
return _byMonarch.Values.FirstOrDefault(t => CopyOf(t) == copy);
}
/// Registers every town with InstancesNoDat (called on each new binding, and after a purchase).
public static void RegisterAll()
{
if (!Mod.Settings.Enabled)
return;
foreach (var town in All())
Register(town);
}
private static void Register(TownRow town)
{
if (Mod.Settings.Town(town.TownKey) is not { } setting)
{
ModManager.Log($"[{Mod.Name}] town {town.Id} of {town.MonarchName} is '{town.TownKey}', which is not on the menu - not registered", ModManager.LogLevel.Warn);
return;
}
var name = $"{setting.Name} of {town.MonarchName}";
// Unregister first: Enclaves.Register REFUSES a copy number another owner key holds, and
// a town registered by an older build of this mod carries the monarch's key. Without this
// a hot reload that did not get to run Dispose would leave the town unregistered - and an
// unregistered town cannot be opened at all.
Instances.Unregister(CopyOf(town));
// Owner key 0 on purpose: the enclave must name the landblocks and open them as one
// region, but it must route NOBODY (see Instances.NoAutomaticOwner). /town go and the
// login path are the only ways in, and both check the allegiance here.
if (!Instances.Register(name, CopyOf(town), Instances.NoAutomaticOwner, setting.LandblockIds()))
ModManager.Log($"[{Mod.Name}] could not register {name} (copy {CopyOf(town)})", ModManager.LogLevel.Warn);
}
/// Takes every town down in InstancesNoDat (the switch off, or this mod unloading).
public static void UnregisterAll()
{
foreach (var town in All())
Instances.Unregister(CopyOf(town));
}
// ------------------------------------------------------------ the allegiance
/// The allegiance a player belongs to, by its monarch; 0 for none (a monarch with no vassals has none either).
public static uint MonarchOf(Player player) => player.Allegiance?.MonarchId ?? 0;
///
/// The same question at login, where may not be loaded yet:
/// the character's own Monarch property, which is on the biota and so is readable from the
/// first moment. Exactly the key InstancesNoDat's enclaves use (Enclaves.OwnerKeyOf), so
/// membership means the same thing on both sides of the binding.
///
public static uint MonarchKeyOf(Player player) => player.Allegiance?.MonarchId ?? player.MonarchId ?? 0;
public static bool IsMonarch(Player player) => MonarchOf(player) == player.Guid.Full;
public static string MonarchName(uint monarch) =>
PlayerManager.FindByGuid(new ObjectGuid(monarch))?.Name ?? $"0x{monarch:X8}";
// ------------------------------------------------------------ inside, and the way back in
/// The town copy this character recorded standing in (PropertyInt Inside), 0 for the real world.
public static int Recorded(Player player) => player.GetProperty(Inside) ?? 0;
/// The town copy a player is standing in right now, 0 for the real world.
public static int StandingIn(Player player) =>
Instances.CopyOf(player.CurrentLandblock) is var copy && IsTownCopy(copy) ? copy : 0;
private static void Record(Player player, int copy)
{
if (Recorded(player) == copy)
return;
if (copy > 0)
player.SetProperty(Inside, copy);
else
player.RemoveProperty(Inside);
}
///
/// The access decision, whole: may a character whose allegiance key is `monarchKey`, being
/// placed at `landblock`, and recording copy `recorded` when they were last in the world, be
/// put back into that copy? Every answer but one is 0 - the copy must be a town that still
/// exists, is registered, belongs to THEIR allegiance, and covers the landblock they are
/// standing in. A function of its arguments only, so `town-admin route` can exercise it.
///
public static (int Copy, string Why) ArrivalCopyFor(uint monarchKey, int recorded, ushort landblock)
{
if (!Mod.Settings.Enabled)
return (0, "towns are off");
if (recorded <= 0)
return (0, "nothing recorded - they were not inside a town");
if (!IsTownCopy(recorded))
return (0, $"copy {recorded} is not a town copy");
if (TownOfCopy(recorded) is not { } town)
return (0, $"no allegiance holds copy {recorded} any more");
if (monarchKey == 0)
return (0, "not sworn to an allegiance");
if (monarchKey != town.MonarchId)
return (0, $"copy {recorded} belongs to {town.MonarchName}'s allegiance, not to 0x{monarchKey:X8}'s");
if (Mod.Settings.Town(town.TownKey) is not { } setting)
return (0, $"'{town.TownKey}' is not on the menu");
if (!setting.LandblockIds().Contains(landblock))
return (0, $"{landblock:X4} is not part of {setting.Name}");
if (!Instances.IsRegistered(recorded))
return (0, $"copy {recorded} is not registered just now");
return (recorded, $"{setting.Name} of {town.MonarchName}");
}
///
/// A player being placed in the world - a login (Patches.PreAddObject, which is where
/// InstancesNoDat does the same job for its own dungeons). A character who was standing in
/// their allegiance's town copy goes back into it; every other case is the real world, and the
/// record is cleared so nothing stale is carried about.
///
public static void PrepareLogin(Player player, ushort landblock)
{
var recorded = Recorded(player);
var (copy, why) = ArrivalCopyFor(MonarchKeyOf(player), recorded, landblock);
if (copy <= 0)
{
if (recorded != 0)
{
Record(player, 0);
ModManager.Log($"[{Mod.Name}] {player.Name} recorded copy {recorded} but is placed in the real {landblock:X4}: {why}");
}
// And nothing of a `/town go` that never finished - a disconnection between the
// command and its teleport - is left to route this login anywhere.
ReleaseClaims(player);
return;
}
// The aim is all the login needs: InstancesNoDat's own AddObject hook asks Context.Resolve
// which copy this player is for, and Resolve answers an aim before anything else.
Instances.Force(player, landblock, copy);
ModManager.Log($"[{Mod.Name}] {player.Name} logs in inside copy {copy} of {landblock:X4} - {why}");
}
///
/// A character is leaving the world (Patches.PreLogOut): the record is made true one last time
/// while they are still standing somewhere, so logging out a step after walking out of a copy
/// does not log back in inside it.
///
public static void NoteLogOut(Player player)
{
if (player.CurrentLandblock is null)
return;
Record(player, StandingIn(player));
}
///
/// Every couple of seconds (Patches.PostTick): the record on each online character is made
/// equal to where they actually are, and any claim on a town's landblocks that no `/town go`
/// is using is dropped. Both are backstops - the record is written on arrival and at logout,
/// and a claim is dropped by the teleport that used it - and the backstops are what make the
/// login path trustworthy after a crash, a mod reload, or a copy that closed while empty.
///
public static void Sweep()
{
if (!Mod.Settings.Enabled || All().Count == 0)
return;
foreach (var player in PlayerManager.GetAllOnline())
{
try
{
// No landblock means in transit or logging out: nothing is known about where they
// are, and writing the record from a guess is exactly how a player loses their
// town on the way out of it.
if (player?.CurrentLandblock is null)
continue;
Record(player, StandingIn(player));
if (_entering.TryGetValue(player.Guid.Full, out var entering)
&& DateTime.UtcNow - entering.When <= EnteringLifetime)
continue; // a /town go is in flight; its claim is doing its work
_entering.TryRemove(player.Guid.Full, out _);
ReleaseClaims(player);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] town sweep of {player?.Name} failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
}
/// Drops any claim this player holds on their own town's landblocks - the only landblocks this mod ever claims.
private static void ReleaseClaims(Player player)
{
if (TownOf(MonarchKeyOf(player)) is not { } town || Mod.Settings.Town(town.TownKey) is not { } setting)
return;
var copy = CopyOf(town);
foreach (var landblock in setting.LandblockIds())
{
if (Instances.RouteFor(player, landblock) != copy)
continue;
Instances.Claim(player, landblock, 0);
ModManager.Log($"[{Mod.Name}] dropped {player.Name}'s stale claim on copy {copy} of {landblock:X4}", ModManager.LogLevel.Warn);
}
}
// ------------------------------------------------------------ buying
public static string Donate(Player player, long amount)
{
if (!TownDb.Ready)
return "The treasuries are not available just now.";
var monarch = MonarchOf(player);
if (monarch == 0)
return "You are not sworn to an allegiance.";
if (player.Account is null)
return "No account.";
var banked = TownDb.BankPyreals(player.Account.AccountId);
if (amount == long.MaxValue)
amount = banked;
if (amount <= 0)
return "You have no pyreals banked to give. /b d p deposits the pyreals you carry.";
var treasury = TownDb.Donate(player.Account.AccountId, monarch, player.Guid.Full, player.Name, amount);
if (treasury is null)
return $"Your bank holds {banked:N0} pyreals - not {amount:N0}.";
ModManager.Log($"[{Mod.Name}] {player.Name} gave {amount:N0} pyreals to the treasury of {MonarchName(monarch)}'s allegiance (now {treasury:N0})");
return $"You give {amount:N0} pyreals from your bank to your allegiance's treasury. It holds {treasury:N0}.";
}
public static string Buy(Player player, string key)
{
if (!Mod.Settings.Enabled || !TownDb.Ready)
return "Towns are not open.";
var monarch = MonarchOf(player);
if (monarch == 0 || !IsMonarch(player))
return "Only your allegiance's monarch may buy a town.";
if (TownOf(monarch) is { } owned)
return $"Your allegiance already has {Mod.Settings.Town(owned.TownKey)?.Name ?? owned.TownKey}.";
if (Mod.Settings.Town(key) is not { } setting)
return $"There is no town '{key}' on the menu - /town menu lists them.";
var accounts = TownDb.AccountsInAllegiance(monarch);
if (accounts < Mod.Settings.MinAccounts)
return $"An allegiance needs at least {Mod.Settings.MinAccounts} accounts among its members to hold a town; yours has {accounts}.";
return Found(player.Guid.Full, player.Name, monarch, player.Name, setting, Mod.Settings.CharterPrice);
}
/// Staff: a town for an allegiance - by its monarch's guid, online or not - free (testing, or a gift).
public static string Grant(uint monarch, string key)
{
if (monarch == 0)
return "No allegiance.";
if (Mod.Settings.Town(key) is not { } setting)
return $"No town '{key}' on the menu.";
return Found(0, "staff", monarch, MonarchName(monarch), setting, 0);
}
private static string Found(uint byId, string byName, uint monarch, string monarchName, TownSetting setting, long price)
{
var (id, error) = TownDb.Charter(monarch, monarchName, setting.Key, price, byId, byName);
if (error is not null)
return error;
var town = new TownRow { Id = id, MonarchId = monarch, MonarchName = monarchName, TownKey = setting.Key, CreatedAt = DateTime.UtcNow };
lock (_lock)
_byMonarch[monarch] = town;
if (Instances.Refresh())
Register(town);
ModManager.Log($"[{Mod.Name}] {monarchName}'s allegiance holds {setting.Name} (town {id}, copy {CopyOf(town)}){(price > 0 ? $" for {price:N0} pyreals" : " - granted")}");
PlayerManager.BroadcastToAll(new GameMessageSystemChat($"{monarchName}'s allegiance has bought a charter for {setting.Name}. It is theirs now.", ChatMessageType.Broadcast));
return $"{setting.Name} is your allegiance's{(price > 0 ? $" for {price:N0} pyreals from the treasury" : "")}. Any member travels to it with /town go; the public {setting.Name} is unchanged for everyone, you included.";
}
public static string Abandon(uint monarch)
{
if (TownOf(monarch) is not { } town)
return "That allegiance has no town.";
if (!TownDb.Abandon(town.Id))
return "Could not record that.";
Instances.Unregister(CopyOf(town));
lock (_lock)
_byMonarch.Remove(monarch);
ModManager.Log($"[{Mod.Name}] town {town.Id} ({town.TownKey}) of {town.MonarchName} abandoned");
return $"{town.MonarchName}'s allegiance no longer holds {town.TownKey}.";
}
// ------------------------------------------------------------ going there
///
/// A member to their town's landing - the ONLY way into an allegiance's copy. World thread.
///
/// The allegiance is checked here and nowhere else is needed: nothing routes anyone into a
/// town copy on its own any more (Instances.NoAutomaticOwner), so a player who does not get
/// past these checks has no way in at all.
///
public static string Go(Player player)
{
if (!Mod.Settings.Enabled)
return "Towns are not open.";
var monarch = MonarchOf(player);
if (monarch == 0 || TownOf(monarch) is not { } town || Mod.Settings.Town(town.TownKey) is not { } setting)
return "Your allegiance has no town.";
if (player.Teleporting)
return "Wait until you have arrived.";
if (player.PKTimerActive)
return "You have been in a fight too recently.";
var cooldown = TimeSpan.FromMinutes(Math.Max(0, Mod.Settings.TownCooldownMinutes));
if (_lastGo.TryGetValue(player.Guid.Full, out var last) && DateTime.UtcNow - last < cooldown)
return $"You can go to your town again in {Wait(cooldown - (DateTime.UtcNow - last))}.";
if (setting.Landing.ToPosition() is not { } landing)
return "Your town has no landing set - tell an admin.";
if (!Instances.Refresh())
return "Towns are not available just now.";
var copy = CopyOf(town);
if (!Instances.IsRegistered(copy))
return "Your town is not open just now - tell an admin.";
var landblock = (ushort)(landing.LandblockId.Raw >> 16);
// The claim is what InstancesNoDat's teleport hook reads to open the copy and point the
// physics into its cells rather than the real town's (the ghost lesson of 2026-09-20),
// and the aim is what carries the arrival itself. Both are let go of the moment the
// teleport is over (FinishEntry): a town copy is somewhere you ARE, never somewhere you
// are routed to, or the next portal would put you back in it.
_entering[player.Guid.Full] = (landblock, copy, DateTime.UtcNow);
Instances.Claim(player, landblock, copy);
Instances.Force(player, landblock, copy);
_lastGo[player.Guid.Full] = DateTime.UtcNow;
WorldManager.ThreadSafeTeleport(player, landing);
return $"You travel to {setting.Name}, your allegiance's own.";
}
/// "in 10 seconds", "in 2 minutes" - the cooldown as a player would say it.
public static string Wait(TimeSpan left)
{
if (left.TotalSeconds < 1)
return "a moment";
if (left.TotalSeconds < 60)
{
var seconds = (int)Math.Ceiling(left.TotalSeconds);
return $"{seconds} second{(seconds == 1 ? "" : "s")}";
}
var minutes = (int)Math.Ceiling(left.TotalMinutes);
return $"{minutes} minute{(minutes == 1 ? "" : "s")}";
}
/// A teleport has finished (Patches.PostTeleport): a `/town go` claim has done its work.
public static void FinishEntry(Player player)
{
if (_entering.IsEmpty || !_entering.TryRemove(player.Guid.Full, out var entering))
return;
Instances.Claim(player, entering.Landblock, 0);
if (StandingIn(player) == entering.Copy)
Record(player, entering.Copy);
}
public static string Leave(Player player)
{
if (player.PKTimerActive)
return "You have been in a fight too recently.";
if (Mod.Settings.LeaveTo.ToPosition() is not { } there)
return "Nowhere to go.";
// Leaving must leave even when the town IS where this command goes: an allegiance that
// owns Holtburg stands in a copy of the very landblock below, and the rule that keeps a
// player in the copy they are in would otherwise keep them there. Aim at the real one.
if (StandingIn(player) > 0)
Instances.Force(player, (ushort)(there.LandblockId.Raw >> 16), 0);
WorldManager.ThreadSafeTeleport(player, there);
return "You travel to Holtburg.";
}
/// InstancesNoDat's arrival moment: a member stepping into their town is welcomed, and it is recorded.
public static void OnArrival(Player player, ushort landblock, int copy, int tier)
{
try
{
if (!IsTownCopy(copy) || TownOf(MonarchOf(player)) is not { } town || CopyOf(town) != copy)
return;
// Recorded here because this is the moment they are really inside: log out now and
// the login path puts them back here (PrepareLogin).
Record(player, copy);
var name = Mod.Settings.Town(town.TownKey)?.Name ?? town.TownKey;
player.SendMessage($"{name}, the town of {town.MonarchName}'s allegiance - yours while you are in it. /town for its treasury; /town leave to go.", ChatMessageType.Broadcast);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] welcome failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
// ------------------------------------------------------------ no monsters
private static readonly ConcurrentDictionary _isMonster = new();
public static long HeldSpawns;
/// Patches: a monster profile of a generator in a town copy spawns nothing.
public static void HoldMonsters(GeneratorProfile profile)
{
if (!Mod.Settings.NoMonsters || profile.SpawnQueue.Count == 0)
return;
if (profile.Generator?.CurrentLandblock is not { } block || !MenuLandblocks.Contains((ushort)block.Id.Landblock))
return;
if (!IsTownCopy(Instances.CopyOf(block)) || profile.Biota is null)
return;
var monster = _isMonster.GetOrAdd(profile.Biota.WeenieClassId, id =>
DatabaseManager.World.GetCachedWeenie(id) is { } w && w.WeenieType == WeenieType.Creature
&& (w.PropertiesBool?.TryGetValue(PropertyBool.Attackable, out var a) != true || a));
if (!monster)
return;
HeldSpawns += profile.SpawnQueue.Count;
profile.SpawnQueue.Clear();
}
// ------------------------------------------------------------ scanning the menu
///
/// The land around a landblock: flood-fills landblocks that have any ground above sea level
/// (a height above 0 in the cell dat), up to `max`. What an island town's Landblocks should be.
///
public static List Scan(ushort start, int max)
{
static bool Land(ushort lb)
{
try
{
var cell = DatManager.CellDat.ReadFromDat((uint)lb << 16 | 0xFFFF);
return cell?.Height?.Any(h => h > 0) == true;
}
catch { return false; }
}
var found = new List();
var seen = new HashSet { start };
var queue = new Queue();
queue.Enqueue(start);
while (queue.Count > 0 && found.Count < max)
{
var lb = queue.Dequeue();
if (!Land(lb))
continue;
found.Add(lb);
var x = lb >> 8;
var y = lb & 0xFF;
foreach (var (dx, dy) in new[] { (1, 0), (-1, 0), (0, 1), (0, -1) })
{
var nx = x + dx;
var ny = y + dy;
if (nx < 0 || nx > 0xFE || ny < 0 || ny > 0xFE)
continue;
var n = (ushort)(nx << 8 | ny);
if (seen.Add(n))
queue.Enqueue(n);
}
}
return found.OrderBy(l => l).ToList();
}
}