namespace Aeshnidae.BuffBot; /// What a warden is allowed to do with one spell for one player. public readonly record struct Verdict(bool Allowed, uint Level, SpellCategory Cantrip, string? Refusal); /// /// The Town Warden: one weenie standing in every main town, whose buff power the town buys /// and keeps buying (design settled 2026-10-05 - a town that invests in its warden is a town /// worth living in). /// /// The tier is the TOWN's, found from the landblock the WARDEN is standing in - never the /// player's - so a warden in Holtburg gives Holtburg's tier to a visitor from anywhere. The /// tiers live in on the shard and are cached here; the cache is /// re-read on every enable, on warden-reload, and whenever a tier is bought or set. /// /// The cast level is the LOWER of the player's level band (Bands, unchanged since /// 2026-09-21) and the town's tier, so a tier-7 town still gives a level-12 character /// level II buffs, and a tier-2 town gives a level-275 character level II buffs. /// public static class Wardens { private static Dictionary _rows = new(); /// Per-player burst state: one Use is 68 casts through the same patch. private static readonly Dictionary Cantrips, bool Told)> _bursts = new(); public static void Reload() { _rows = WardenDb.LoadAll(); lock (_bursts) _bursts.Clear(); Tiers.Forget(); } public static IReadOnlyDictionary Rows => _rows; /// /// Put one town's row back into the cache. The dictionary is REPLACED rather than /// written in place: Decide reads it on the world thread for every one of a warden's /// 68 casts, and a console warden-set runs on the remote console's own thread. /// private static void Remember(WardenRow row) { var copy = new Dictionary(_rows) { [row.Landblock] = row }; _rows = copy; } public static bool IsWarden(uint wcid) => Mod.Settings.Wardens?.Contains(wcid) == true; /// The name a player would call this town, from Settings.Towns. public static string TownName(uint landblock) { foreach (var town in Mod.Settings.Towns ?? new List()) if (uint.TryParse(town.Landblock?.Replace("0x", ""), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var lb) && lb == landblock) return town.Name; return "this town"; } public static WardenRow RowFor(uint landblock) => _rows.TryGetValue(landblock, out var row) ? row : new WardenRow { Landblock = landblock }; /// /// The tier a town is behaving at. ForcedTier, when it is 0 or more, overrides every /// town - that is the testing switch, and nothing can be paid while it is on. /// public static int TierOf(uint landblock) => Mod.Settings.ForcedTier >= 0 ? Mod.Settings.ForcedTier : RowFor(landblock).Tier; public static bool Forced => Mod.Settings.ForcedTier >= 0; // ---------------------------------------------------------------- the cast decision /// /// Whether this spell from this caster lands on this player, at what level, and which /// cantrip family (0 for none) should follow it. /// /// A bot that is not a warden is unchanged: the level band and nothing else. /// public static Verdict Decide(WorldObject caster, Player wearer, Spell spell) { // A bot that is not a warden keeps the hub ladder; a warden has its own. if (!IsWarden(caster.WeenieClassId)) { var hubBand = Bands.LevelFor(wearer); return new Verdict(true, hubBand, 0, null); } var band = Bands.LevelForWarden(wearer); var bandLevel = band == 0 ? Tiers.TopSpellLevel : band; var landblock = caster.Location?.Landblock ?? 0; var tier = TierOf(landblock); var step = Tiers.Step(tier); if (step.SpellLevel == 0 || step.Groups is null || step.Groups.Count == 0) { var (needR, needP) = Tiers.Price(1); var refusal = Forced ? $"{caster.Name} is not in service." : $"{TownName(landblock)} has not paid its warden. It will cast nothing until the town buys tier 1: " + $"{needR:N0} Radiance and {needP:N0} pyreals. /warden tells you how."; return new Verdict(false, 0, 0, refusal); } var group = Tiers.GroupOf(spell); if (!step.Groups.Contains(group)) return new Verdict(false, 0, 0, null); // this tier simply does not reach that spell var level = Math.Min(bandLevel, step.SpellLevel); var cantrip = step.Groups.Contains(Tiers.Cantrip) ? Tiers.CantripFor(spell) : 0; return new Verdict(true, level, cantrip, null); } /// /// True the first time in a burst. Everything a warden says or adds once per Use goes /// through here, because the patch is entered once per spell and a Use is 68 spells. /// public static bool FirstInBurst(Player player, SpellCategory cantrip, bool message) { var window = TimeSpan.FromSeconds(Math.Max(0.5, Mod.Settings.BurstSeconds)); var now = DateTime.UtcNow; lock (_bursts) { if (!_bursts.TryGetValue(player.Guid.Full, out var state) || now - state.At > window) state = (now, new HashSet(), false); var first = message ? !state.Told : state.Cantrips.Add(cantrip); if (message) state.Told = true; _bursts[player.Guid.Full] = (state.At, state.Cantrips, state.Told); // Keep the table from growing for ever: anyone whose burst is long over goes. if (_bursts.Count > 256) foreach (var stale in _bursts.Where(b => now - b.Value.At > window).Select(b => b.Key).ToList()) _bursts.Remove(stale); return first; } } // ---------------------------------------------------------------- finding one /// /// The Town Warden nearest the player, within Settings.WardenRange. The tier belongs to /// the warden's landblock, so the object itself is what we need, not the player's position. /// public static WorldObject? Nearest(Player player) { var block = player.CurrentLandblock; if (block is null || player.Location is null) return null; WorldObject? best = null; var bestDistance = Mod.Settings.WardenRange; foreach (var wo in block.GetAllWorldObjectsForDiagnostics()) { if (!IsWarden(wo.WeenieClassId) || wo.IsDestroyed || wo.Location is null) continue; var distance = Math.Sqrt(Math.Pow(wo.Location.PositionX - player.Location.PositionX, 2) + Math.Pow(wo.Location.PositionY - player.Location.PositionY, 2) + Math.Pow(wo.Location.PositionZ - player.Location.PositionZ, 2)); if (distance <= bestDistance) { bestDistance = distance; best = wo; } } return best; } // ---------------------------------------------------------------- what /warden prints public static List Report(Player player, uint landblock) { var row = RowFor(landblock); var tier = TierOf(landblock); var town = TownName(landblock); var lines = new List(); lines.Add($"The Town Warden of {town} - tier {tier} of {Mod.Settings.MaxTier}{(Forced ? " (testing: every town is held at this tier)" : "")}."); lines.Add($"At tier {tier} it casts {Tiers.Describe(tier)}."); if (tier < Mod.Settings.MaxTier) lines.Add($"Tier {tier + 1} would add {Tiers.Describe(tier + 1)}."); var band = Bands.LevelForWarden(player); var bandLevel = band == 0 ? Tiers.TopSpellLevel : band; var townLevel = Tiers.LevelAt(tier); if (townLevel > 0) lines.Add($"Your level {player.Level ?? 1} is entitled to level {Tiers.Roman(bandLevel)}; the town allows level {Tiers.Roman(townLevel)}. " + $"You get level {Tiers.Roman(Math.Min(bandLevel, townLevel))} - whichever is lower always wins."); if (Forced) { lines.Add("The wardens are in testing. Nothing can be paid, and no town's own tier is being used."); return lines; } if (tier >= Mod.Settings.MaxTier) lines.Add("This warden is as strong as a warden gets. There is nothing left to pay for."); else { var (needR, needP) = Tiers.Price(tier + 1); lines.Add($"Toward tier {tier + 1}: {row.RadiancePaid:N0} of {needR:N0} Radiance, {row.PyrealsPaid:N0} of {needP:N0} pyreals. " + "Both have to be full before the tier turns over."); lines.Add("/warden pay radiance takes it from your bank; /warden pay pyreals takes it from your pack. Anyone may pay."); } lines.Add(string.IsNullOrEmpty(row.OwnerAllegianceName) ? "Nobody maintains this warden yet - the allegiance that finishes a tier is named here." : $"Maintained by {row.OwnerAllegianceName}."); if (!string.IsNullOrEmpty(row.LastPatronName)) lines.Add($"Last paid by {row.LastPatronName}."); return lines; } // ---------------------------------------------------------------- paying /// /// /warden pay. Runs on the world thread (the pyreals come out of the pack), takes /// nothing until every refusal has been made, and puts the pyreals back if the ledger /// will not have them. /// public static List Pay(Player player, string currency, string rawAmount) { var lines = new List(); var warden = Nearest(player); if (warden?.Location is null) { lines.Add("There is no Town Warden near you to pay."); return lines; } var landblock = warden.Location.Landblock; var town = TownName(landblock); if (Forced) { lines.Add($"The wardens are in testing - every town is held at tier {Mod.Settings.ForcedTier} and nothing can be paid yet."); return lines; } if (!WardenDb.Ready) { lines.Add("The wardens' ledger is not available right now. Nothing was taken."); return lines; } var radiance = currency.StartsWith("r", StringComparison.OrdinalIgnoreCase); var pyreals = currency.StartsWith("p", StringComparison.OrdinalIgnoreCase); if (!radiance && !pyreals) { lines.Add("Pay in 'radiance' or 'pyreals'."); return lines; } var tier = RowFor(landblock).Tier; if (tier >= Mod.Settings.MaxTier) { lines.Add($"{town}'s warden is already at tier {tier}, the highest there is."); return lines; } var (needRadiance, needPyreals) = Tiers.Price(tier + 1); var row = RowFor(landblock); var outstanding = radiance ? needRadiance - row.RadiancePaid : needPyreals - row.PyrealsPaid; var balance = radiance ? WardenDb.RadianceBalance(player.Account.AccountId) : Pyreals.Carried(player); var what = radiance ? "Radiance in your bank" : "pyreals in your pack"; long amount; if (rawAmount.Equals("all", StringComparison.OrdinalIgnoreCase)) amount = Math.Min(balance, Math.Max(0, outstanding)); else if (!long.TryParse(rawAmount, NumberStyles.Any, CultureInfo.InvariantCulture, out amount)) { lines.Add("Give an amount, or 'all'."); return lines; } if (amount <= 0) { lines.Add(outstanding <= 0 ? $"{town} has already paid all the {(radiance ? "Radiance" : "pyreals")} tier {tier + 1} needs." : "Give an amount above zero."); return lines; } if (amount > balance) { lines.Add($"You have {balance:N0} {what} - not {amount:N0}."); return lines; } if (outstanding > 0 && amount > outstanding) { lines.Add($"Tier {tier + 1} needs only {outstanding:N0} more {(radiance ? "Radiance" : "pyreals")}. Pay that or less."); return lines; } var allegiance = player.Allegiance; var allegianceId = allegiance?.MonarchId ?? 0; var allegianceName = allegiance is null ? $"{player.Name} alone" : allegiance.AllegianceName ?? $"{PlayerManager.FindByGuid(new ObjectGuid(allegianceId))?.Name ?? "an"}'s allegiance"; long taken = 0; if (pyreals) { taken = Pyreals.Take(player, amount); if (taken == 0) { lines.Add("Your pack would not give up that many pyreals. Nothing was taken."); return lines; } } var result = WardenDb.Contribute(landblock, radiance, amount, player.Account.AccountId, allegianceId, allegianceName, player.Name, Tiers.Price, Mod.Settings.MaxTier, out var updated); if (!result.Ok) { if (taken > 0) Pyreals.Give(player, taken); lines.Add(result.Message); return lines; } // Change from the pack, now that the ledger has the amount it was promised. var change = taken - amount; if (change > 0) { var returned = Pyreals.Give(player, change); if (returned < change) { var stranded = change - returned; WardenDb.Contribute(landblock, false, stranded, player.Account.AccountId, allegianceId, allegianceName, player.Name, Tiers.Price, Mod.Settings.MaxTier, out var afterChange); if (afterChange is not null) updated = afterChange; lines.Add($"Your pack would not take {stranded:N0} pyreals of change, so it went into the pot as well."); } } if (updated is not null) Remember(updated); var unit = radiance ? "Radiance" : "pyreals"; lines.Add($"You put {amount:N0} {unit} toward {town}'s warden."); if (result.Promoted) { Announce(landblock, $"{town}'s Town Warden rises to tier {result.Tier}: it now casts {Tiers.Describe(result.Tier)}. " + $"Maintained by {allegianceName}."); ModManager.Log($"[{Mod.Name}] {TownName(landblock)} (0x{landblock:X4}) reached tier {result.Tier}, paid last by {player.Name} of {allegianceName}"); } else if (updated is not null && updated.Tier < Mod.Settings.MaxTier) { var (r, p) = Tiers.Price(updated.Tier + 1); lines.Add($"Tier {updated.Tier + 1} still needs {Math.Max(0, r - updated.RadiancePaid):N0} Radiance and {Math.Max(0, p - updated.PyrealsPaid):N0} pyreals."); } return lines; } /// Says something to everyone standing in the town. public static void Announce(uint landblock, string message) { foreach (var player in PlayerManager.GetAllOnline()) if (player.Location?.Landblock == landblock) player.SendMessage(message, ChatMessageType.Broadcast); } }