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