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