using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// BUG FIXED 2026-08-28 (Insect Swarm attacked the player instead of zombies, even after the /// entityclasses.xml AITask-2/AITarget-4 fix): wasted effort, because none of that XML /// mattered. Confirmed by decompiling the actual class chain - /// necroInsectSwarm -> animalInsectSwarm -> Class="EntitySwarm" -> EntitySwarm : EntityVulture. /// EntityVulture does NOT use the generic AITask/AITarget system for target selection at all - /// it has its own hardcoded C# targeting (updateTasks()'s State.Wander branch calls /// FindTarget(), which calls World.GetClosestPlayerSeen/GetClosestPlayer - literally typed to /// return EntityPlayer, there is no "closest zombie" variant to point it at). Every ground /// creature in this mod (the Dog, all vanilla animals) goes through the declarative AITask /// system just fine; flying "swarm" creatures (insect swarm, bee swarm, vultures) are a /// completely separate hardcoded-C# codepath. No XML property changes that. /// /// Fix: Prefix on EntityAlive.SetAttackTarget - the one non-EntityPlayer-typed choke point /// every one of EntityVulture's several call sites funnels through (FindTarget() results, /// revenge-target retaliation, sleeper wake-up - all of them end in a SetAttackTarget call, /// confirmed by decompiling EntityVulture). Whenever the entity is one of OUR /// EntityVulture-based pets AND the target it's about to be given is an EntityPlayer, swap in /// the nearest EntityZombie instead (or null if none are nearby - just idles, better than /// attacking the player). Only filters on our own entity classes, so vanilla's own /// animalInsectSwarm/animalBeeSwarm/real vultures are entirely unaffected and keep hunting /// players normally. /// /// GENERALIZED 2026-08-29, THEN UN-GENERALIZED SAME DAY: briefly also covered /// necroZombieGriffin (extending animalZombieVulture, the same Class="EntityVulture" root as /// the Swarm) via this same redirect. Confirmed live in-game that this didn't actually fix /// the Griffin - it just flew around doing EntityVulture's own default Wander behavior, /// never engaging zombies at all ("летает где-то в небе, и зомби его вообще не интересуют"). /// Rather than keep debugging the redirect blind (each guess needs a full test cycle the /// user has to run), the Griffin was converted to extend necroZombieDog directly instead (see /// entityclasses.xml) - same proven-reliable ground-AI trick as the Bear/Wolf, no longer /// EntityVulture-based at all, so it no longer needs this patch. Kept the Dictionary-based /// shape below (rather than reverting to a single cached id) in case a genuinely flying pet /// gets added again later - SpeciesByName just has one entry for now. /// [HarmonyPatch(typeof(EntityAlive), "SetAttackTarget", new System.Type[] { typeof(EntityAlive), typeof(int) })] public static class Patch_EntityAlive_SetAttackTarget_SwarmRetarget { public class VultureBasedPetInfo { public bool SkipAlreadyCharmedZombies; } /// HARDENED 2026-08-29 while chasing the user's "Griffin still attacks me" /// report - the Griffin's own AI is not XML-driven at all (see class comment), so this /// Harmony redirect not firing was the prime remaining suspect. Could not fully confirm /// or rule this out by decompilation alone, but the ORIGINAL lazy-cache pattern here had /// two real, independent failure modes worth closing regardless of which (if either) was /// the actual cause: (1) EntityClass.GetId("necroInsectSwarm") and /// EntityClass.GetId("necroZombieGriffin") were both looked up inside ONE dictionary /// object-initializer - if EITHER happened to still return -1 (not yet registered) at /// the exact moment some entirely unrelated zombie's very first SetAttackTarget call /// triggered this lazy build (plausible - that can happen extremely early, before every /// mod entity_class is guaranteed loaded), the -1 got cached FOREVER via the /// cachedClassIds==null guard, silently never re-resolving even once the real class WAS /// registered a moment later - and if BOTH happened to be -1 at once, the dictionary /// initializer would throw (duplicate key), which could break unrelated zombie AI too. /// Rewritten to resolve each species independently and only cache a REAL (non -1) id - /// an unresolved species is retried on every subsequent call instead of being poisoned /// permanently, and two entries can never collide on a shared -1 key. public static readonly Dictionary SpeciesByName = new Dictionary { { "necroInsectSwarm", new VultureBasedPetInfo { SkipAlreadyCharmedZombies = true } }, }; public static readonly Dictionary cachedClassIds = new Dictionary(); public static Dictionary ClassIds() { // Fast path once every species has resolved (the overwhelmingly common case, since // this runs on EntityAlive.SetAttackTarget - a hot path called for every zombie in // the game, not just ours) - skips the resolution loop below entirely instead of // re-scanning it on every single call. if (cachedClassIds.Count >= SpeciesByName.Count) { return cachedClassIds; } foreach (KeyValuePair species in SpeciesByName) { bool alreadyCached = false; foreach (KeyValuePair cached in cachedClassIds) { if (cached.Value == species.Value) { alreadyCached = true; break; } } if (alreadyCached) { continue; } int id = EntityClass.GetId(species.Key); if (id == -1) { continue; } cachedClassIds[id] = species.Value; } return cachedClassIds; } /// Kept separate from ClassIds() above (which is about retargeting, shared by /// both pets) - this one is Swarm-ONLY, used by PetFollowPatch.cs's "drop an /// already-charmed target so it moves on" behavior, which only makes sense for a species /// that actually charms zombies (the Griffin doesn't). Same retry-until-resolved shape as /// ClassIds() above, for the same reason - never cache a -1. public static int cachedSwarmOnlyClassId = -1; public static int SwarmOnlyClassId() { if (cachedSwarmOnlyClassId == -1) { cachedSwarmOnlyClassId = EntityClass.GetId("necroInsectSwarm"); } return cachedSwarmOnlyClassId; } public static void Prefix(EntityAlive __instance, ref EntityAlive _attackTarget) { if (__instance == null || !(_attackTarget is EntityPlayer)) { return; } if (!ClassIds().TryGetValue(__instance.entityClass, out VultureBasedPetInfo petInfo)) { return; } EntityAlive nearestZombie = FindNearestZombie(__instance, petInfo.SkipAlreadyCharmedZombies); Debug.Log("[NecromancerTome] SwarmTargetPatch: redirected " + __instance.entityId + " from player " + _attackTarget.entityId + " to " + (nearestZombie != null ? nearestZombie.entityId.ToString() : "nothing nearby")); _attackTarget = nearestZombie; } /// Same World.GetEntitiesInBounds(Type, Bounds, List<Entity>) API /// EntityVulture itself uses for its own player search (confirmed by decompiling it) - /// just pointed at EntityZombie instead of EntityPlayer. 80m box, matching FindTarget's /// own cTargetDistanceMax constant, for "ищут всех зомби в радиусе". /// /// BUG FIXED 2026-08-28 ("покусав одного, летят куда-то далеко, вместо соседнего /// незаражённого"): this didn't skip already-charmed zombies, so when /// PetFollowPatch.cs's "drop an already-charmed target" cleared the swarm's target, the /// very next FindTarget()->SetAttackTarget cycle would often just re-pick the SAME /// zombie it had just charmed (still the physically nearest one right after biting it) - /// PetFollowPatch would clear it again next tick, and in between, EntityVulture (a /// flying creature) fell into its own Wander state, which for a flier means big aerial /// loops away from its current spot, not calm circling. A second, genuinely uncharmed /// zombie standing right next to the first one would lose out to this loop instead of /// being picked immediately. Now skips any zombie that already carries /// buffNecroDeviatorCharm - the real "next AND uncharmed" search the user asked for. Only /// falls through to wide wandering when there truly isn't one nearby, same as before. /// /// added 2026-08-29 alongside the Griffin /// generalization above - true for the Swarm (its own charm-on-bite behavior, unchanged), /// false for the Griffin (a plain fighter with no reason to avoid already-charmed /// zombies). public static EntityAlive FindNearestZombie(EntityAlive swarm, bool skipAlreadyCharmed) { World world = swarm.world; if (world == null) { return null; } List nearby = new List(); Bounds bounds = new Bounds(swarm.position, new Vector3(80f, 80f, 80f)); world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby); EntityAlive nearest = null; float bestDistSq = float.MaxValue; foreach (Entity entity in nearby) { if (!(entity is EntityAlive zombie) || zombie.IsDead()) { continue; } if (skipAlreadyCharmed && zombie.Buffs != null && zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)) { continue; } float distSq = (zombie.position - swarm.position).sqrMagnitude; if (distSq < bestDistSq) { bestDistSq = distSq; nearest = zombie; } } return nearest; } } }