using System.Collections.Generic; using UnityEngine; namespace NecromancerTome { /// /// Keeps a summoned pet (see SummonPatch.cs) from wandering off and getting permanently /// lost/unsummonable - user report 2026-08-28: the Zombie Dog didn't follow like a drone /// would, wandered off (heard, not seen), and a second summon attempt did nothing while the /// first was presumably still out there somewhere. /// /// Two separate problems, one fix: /// /// 1. "Doesn't follow": there is no generic "follow a specific entity" AI task anywhere in /// this game - confirmed by listing every EAI*-named type in Assembly-CSharp (EAIWander, /// EAITerritorial, EAIApproachSpot, EAIApproachAndAttackTarget, etc. - nothing /// follow-shaped). EntityDrone's follow behavior is hardcoded C# specific to that one /// class, not something an XML entity_class can opt into. Writing a real custom AITask /// (actual pathfinding, priority-tuned against the pet's existing Wander/Territorial/ /// ApproachSpot tasks) is real engineering the backlog didn't ask for - so this copies /// vanilla's own fallback for the identical problem instead: DroneManager.Update() /// teleports a drone back near its owner once it's more than 32m away (sqrMagnitude > /// 1024) and not doing something else (OrderState != Stay) - confirmed by decompiling /// it. Same threshold, same idea, generalized to any pet class instead of drones only, /// and skipped while the pet has a live attack target (don't yank it out of a fight). /// /// 2. "Doesn't reappear on retry": SummonPatch.cs's one-pet-per-species limit checks /// EntityAlive.ownedEntities, but nothing was ever removing a pet from that list once it /// died or its chunk unloaded - unlike the drone, where DroneManager's own death/unload /// callbacks do that cleanup. A dead or vanished pet left the slot "occupied" forever. /// The same periodic check below detects a tracked pet that's gone (world.GetEntity /// returns null - dead, or its chunk unloaded and it despawned like any untracked /// entity, per the known persistence gap documented in SummonPatch.cs) and clears /// ownership so the player can summon a fresh one. /// /// 3. (added 2026-08-28) Insect Swarm only: makes it move on to the next zombie once its /// current target is already Deviator-charmed, instead of camping the same converted /// zombie forever - see the inline comment below, right where it happens. /// /// Hooked via ModEvents.UnityUpdate - the same supported per-frame mod event GameManager's own /// gmUpdate() fires DroneManager.Update() from (confirmed by decompiling GameManager) - not a /// Harmony patch, since this is a genuine public extension point, no reason to patch around /// it. Throttled to run the real check once a second (CheckInterval): a distance/liveness /// check on a handful of pets is cheap, but no reason to do it 60x/sec either. /// public static class PetFollowPatch { public const float LeashDistance = 32f; // matches DroneManager's own 32m leash public const float LeashDistanceSq = LeashDistance * LeashDistance; public const float CheckInterval = 1f; public class TrackedPet { public int OwnerEntityId; public int PetEntityId; } public static readonly List TrackedPets = new List(); public static float timer; public static void Init() { ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate); } /// Called from SummonPatch.cs right after a pet is created and owned. public static void Register(EntityAlive owner, Entity pet) { TrackedPets.Add(new TrackedPet { OwnerEntityId = owner.entityId, PetEntityId = pet.entityId }); } /// Called from SummonPatch.cs's manual recall path so a recalled pet stops being /// tracked immediately, instead of lingering until the next tick notices it's gone. public static void Unregister(int petEntityId) { for (int i = TrackedPets.Count - 1; i >= 0; i--) { if (TrackedPets[i].PetEntityId == petEntityId) { TrackedPets.RemoveAt(i); } } } public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data) { timer += Time.deltaTime; if (timer < CheckInterval) { return; } timer = 0f; if (TrackedPets.Count == 0) { return; } World world = GameManager.Instance != null ? GameManager.Instance.World : null; if (world == null) { return; } for (int i = TrackedPets.Count - 1; i >= 0; i--) { TrackedPet tracked = TrackedPets[i]; EntityAlive pet = world.GetEntity(tracked.PetEntityId) as EntityAlive; if (pet == null || pet.IsDead()) { EntityAlive ownerForCleanup = world.GetEntity(tracked.OwnerEntityId) as EntityAlive; if (ownerForCleanup != null) { ownerForCleanup.RemoveOwnedEntity(tracked.PetEntityId); Debug.Log("[NecromancerTome] PetFollowPatch: pet " + tracked.PetEntityId + " gone, cleared ownership for " + ownerForCleanup.entityId); } TrackedPets.RemoveAt(i); continue; } EntityAlive owner = world.GetEntity(tracked.OwnerEntityId) as EntityAlive; if (owner == null) { // Owner not currently loaded (e.g. disconnected) - leave the pet tracked, // nothing useful to do until they're back. continue; } // User request 2026-08-28: "рой будет заражать зомби девиацией и лететь к // следующему незаражённому?" - not on its own, so this makes it one. EntityVulture // (the Swarm's real base class - see SwarmTargetPatch.cs) has no notion of // "this target is already converted, go find another" - once it has a live target // it just keeps attacking it until that target dies, charmed or not (repeated // AddBuff on an already-charmed zombie is a harmless no-op, so it wasn't wrong, // just stuck). Clearing the attack target here when it's already charmed makes // EntityVulture's own FindTarget()/SetAttackTarget cycle kick back in on its next // pass (SwarmTargetPatch.cs redirects that straight to the nearest zombie again, // same as any other retarget) - not instant (that cycle runs on its own ~2s timer, // not driven by us), but converts-then-moves-on within a couple seconds. if (pet.entityClass == Patch_EntityAlive_SetAttackTarget_SwarmRetarget.SwarmOnlyClassId()) { EntityAlive currentTarget = pet.GetAttackTarget(); if (currentTarget != null && currentTarget.Buffs != null && currentTarget.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)) { pet.SetAttackTarget(null, 0); Debug.Log("[NecromancerTome] PetFollowPatch: swarm " + pet.entityId + " dropped already-charmed target " + currentTarget.entityId); } } if (pet.GetAttackTarget() != null) { // Mid-fight - let it finish rather than teleporting it away. continue; } float distSq = (pet.position - owner.position).sqrMagnitude; if (distSq > LeashDistanceSq) { // 2m in front of the owner, not exactly on top of them - landing right on the // owner's own position stacks the pet's collider into the player's and shoves // them (this is exactly what happened during the "<=0 vs -1" bug above: a pile // of a dozen undespawned dogs all teleporting onto the same point as the player // every second launched them into the air). Vector3 dest = owner.position + owner.qrotation * new Vector3(0f, 0f, 2f); pet.SetPosition(dest, true); Debug.Log("[NecromancerTome] PetFollowPatch: teleported pet " + pet.entityId + " back to owner " + owner.entityId); } } } } }