namespace Aeshnidae.BuffBot;
///
/// One patch. WorldObject.TryCastSpell_WithRedirects is where a spell cast at a
/// creature finds the items it is really for. For Impenetrability and the Banes, ACE
/// (as retail) puts the spell on every enchantable piece of clothing and the shield
/// when the caster is the wearer, and on selectable equipment alone - the shield - when
/// it is anyone else, so a bot's seven Banes reached nothing but a shield. When the
/// caster is one of the bots in Settings, the wearer's own rule is used. Everything
/// else (weapon spells, negative item spells, resist checks) is ACE's.
///
/// Since 2026-10-05 the same patch is where a Town Warden's town tier is applied: the
/// warden's emote names all 68 spells at the top level and this decides, per spell, whether
/// the town has paid for it to land and at what level ().
///
[HarmonyPatch]
public static class Patches
{
///
/// The NPC emote path is TryCastSpell_WithRedirects(spell, player, itemCaster: the
/// bot itself). For an Impen/Bane on another creature ACE picks the targets with
/// GetNonComponentTargetTypes, which keeps only SELECTABLE equipment - the shield -
/// so armour never saw the spell. (The earlier patch sat one layer down, on
/// TryCastItemEnchantment_WithRedirects, and bowed out whenever itemCaster was set -
/// which the emote path always does. It never fired. 2026-09-21.)
///
/// For a listed bot the targets are every enchantable piece of clothing and armour
/// the player wears plus the shield, each cast exactly as ACE would cast at the one.
///
[HarmonyPrefix]
[HarmonyPatch(typeof(WorldObject), nameof(WorldObject.TryCastSpell_WithRedirects))]
public static bool PreTryCastSpellWithRedirects(WorldObject __instance, Spell spell, WorldObject target, WorldObject? itemCaster,
WorldObject? weapon, bool isWeaponSpell, bool fromProc, bool tryResist, ref bool __result)
{
try
{
if (spell is null || spell.IsHarmful || target is not Player wearer
|| !Mod.Settings.Bots.Contains(__instance.WeenieClassId))
return true;
var verdict = Wardens.Decide(__instance, wearer, spell);
if (!verdict.Allowed)
{
// Either the town has paid for nothing (say so, once per Use), or this tier
// simply does not reach this spell (say nothing - 68 refusals is noise).
if (verdict.Refusal is not null && Wardens.FirstInBurst(wearer, 0, message: true))
wearer.SendMessage(verdict.Refusal);
__result = false;
return false;
}
// The level the player is entitled to, whatever the emote names.
var landed = Cast(__instance, wearer, Bands.At(spell, verdict.Level), itemCaster, weapon, isWeaponSpell, fromProc, tryResist);
// Tier 10 adds the cantrip of each family the warden already casts, once per Use.
if (verdict.Cantrip != 0 && Wardens.FirstInBurst(wearer, verdict.Cantrip, message: false))
{
var cantrip = Bands.Strongest(verdict.Cantrip);
if (cantrip is not null)
Cast(__instance, wearer, cantrip, itemCaster, weapon, isWeaponSpell, fromProc, tryResist);
}
__result = landed;
return false;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] buff redirect failed, ACE's rule applies: {ex.Message}", ModManager.LogLevel.Warn);
return true;
}
}
///
/// One spell at the level decided. An Impen/Bane type goes on every enchantable piece
/// the player wears; anything else is ACE's own cast at the player - not back through
/// WithRedirects, which is the patched method.
///
private static bool Cast(WorldObject caster, Player wearer, Spell spell, WorldObject? itemCaster,
WorldObject? weapon, bool isWeaponSpell, bool fromProc, bool tryResist)
{
if (!spell.IsImpenBaneType)
{
caster.TryCastSpell(spell, wearer, itemCaster, weapon, isWeaponSpell, fromProc, tryResist);
return true;
}
var items = wearer.EquippedObjects.Values
.Where(i => (i.WeenieType == WeenieType.Clothing || i.IsShield) && i.IsEnchantable)
.ToList();
foreach (var item in items)
caster.TryCastSpell(spell, item, itemCaster, weapon, isWeaponSpell, fromProc, tryResist);
if (items.Count == 0)
wearer.SendMessage($"{caster.Name} finds nothing on you for {spell.Name}.");
return items.Count > 0;
}
}