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; /// У питомца своя задача следования (NecroFollowOwnerTask, 2026-09-18) - /// значит телепорт-поводок ниже его НЕ трогает. Уборка владения при этом остаётся: /// она к способу передвижения отношения не имеет. public bool OwnFollowTask; /// Удалось ли развести коллайдеры питомца с хозяйскими. Пока false, попытка /// повторяется каждый тик - см. TryIgnoreCollisionWithOwner. public bool CollisionIgnored; /// Зажжены ли глаза (PetEyeGlowPatch.cs). Как и коллайдеры, с первого раза /// обычно не получается - модели ещё нет; попытка повторяется до успеха. public bool EyesLit; /// Нужно ли им вообще заниматься: копия PetInfo.LitEyes, чтобы тик не лазил /// в словарь на каждом питомце каждую секунду. public bool WantsLitEyes; /// Радиус "дома" для летающих (см. PetInfo.FlyingHomeRadius). Ноль - /// питомец не летающий, дом ему не переставляем. public int FlyingHomeRadius; } public static readonly List TrackedPets = new List(); public static float timer; public static void Init() { ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate); ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown); } /// ДОБАВЛЕНО 2026-09-18. TrackedPets - статический список, он переживает выход в /// меню, а вот мир, к которому относятся лежащие в нём entityId, - нет. Без этой уборки /// на следующей загрузке первый же тик начинал разбирать чужие идентификаторы: в новом /// мире тот же номер принадлежит совершенно другой сущности, и RemoveOwnedEntity уходил /// бы неизвестно куда. GhostTraderPatch.cs подписан на то же событие ровно по той же /// причине; здесь подписки не было - это был найденный, но не закрытый пробел из разбора /// ИИ питомцев (BACKLOG.md, 2026-09-18, п. 6). public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data) { if (TrackedPets.Count > 0) { Debug.Log("[NecromancerTome] PetFollowPatch: world shutting down, forgetting " + TrackedPets.Count + " tracked pet(s)"); TrackedPets.Clear(); } timer = 0f; } /// Called from SummonPatch.cs right after a pet is created and owned. public static void Register(EntityAlive owner, Entity pet, bool ownFollowTask, bool collisionIgnored, bool wantsLitEyes, int flyingHomeRadius) { TrackedPets.Add(new TrackedPet { OwnerEntityId = owner.entityId, PetEntityId = pet.entityId, OwnFollowTask = ownFollowTask, CollisionIgnored = collisionIgnored, WantsLitEyes = wantsLitEyes, FlyingHomeRadius = flyingHomeRadius, }); } /// Разводит коллайдеры питомца и его хозяина, чтобы они проходили друг сквозь /// друга. Возвращает true, только если РЕАЛЬНО что-то развела. /// /// БАГ-РЕПОРТ 2026-09-18: "когда крыса оказывается под ногами, игрока подбрасывает". /// Просьба сделать питомца нематериальным для игрока была ещё 28.08, и код для неё /// написан тогда же (SummonPatch.IgnoreCollisionWithOwner) - но он вызывался ровно один /// раз, из постфикса на EntityFactory.CreateEntity, а это СЛИШКОМ РАНО: там сущность /// только создана и в мир ещё не добавлена (SpawnEntityInWorld идёт следующей строкой в /// ItemActionSpawnEntity.Spawn), модель не собрана, и коллайдеров, привязанных к костям /// через PhysicsBody, попросту ещё нет. GetComponentsInChildren возвращал пустой массив, /// цикл не делал ни одной итерации, и никто этого не замечал - метод ничего не возвращал /// и ничего не логировал. Ровно тот же урок про "модель собирается позже" уже записан в /// GhostTraderPatch.cs, где из-за него пришлось опрашивать торговцев по таймеру. /// /// Поэтому теперь попытка повторяется в тике раз в секунду, пока не удастся. Толкать /// игрока питомец сможет в худшем случае одну секунду после призыва. /// /// Разводятся ВСЕ пары коллайдеров, включая капсулы передвижения: CharacterController - /// это тоже Collider, и именно на ней игрок и стоит, когда его подбрасывает. public static bool TryIgnoreCollisionWithOwner(EntityAlive owner, Entity pet) { if (owner == null || pet == null) { return false; } Collider[] ownerColliders = owner.GetComponentsInChildren(true); Collider[] petColliders = pet.GetComponentsInChildren(true); if (ownerColliders == null || petColliders == null || ownerColliders.Length == 0 || petColliders.Length == 0) { return false; } int pairs = 0; foreach (Collider oc in ownerColliders) { if (oc == null) { continue; } foreach (Collider pc in petColliders) { if (pc == null || pc == oc) { continue; } Physics.IgnoreCollision(oc, pc, true); pairs++; } } if (pairs > 0) { Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " passes through owner " + owner.entityId + " now (" + ownerColliders.Length + "x" + petColliders.Length + " collider pairs ignored)"); } return pairs > 0; } /// 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); } } } /// ПИТАНИЕ ПАДАЛЬЮ, 2026-09-18. Наблюдение пользователя: "Дух крысы после того как /// зомби погибает, начинает есть труп. Пусть тогда этот процесс восстанавливает ей здоровье". /// /// Поедание тут не отдельное поведение, а побочный эффект приказа: команда атаковать ставит /// цель на 6000 тиков (PetAttackCommand.OrderTicks), а EAIApproachAndAttackTarget.CanExecute /// сверяет только ТИП цели и ничего не знает про её смерть - так что, добив зомби, крыса /// продолжает грызть труп, пока тот не исчезнет. Само по себе это выглядело хорошо, поэтому /// не чинится, а используется. /// /// Лечение идёт отсюда, а не из triggered_effect в items.xml, по простой причине: в XML /// нельзя спросить "цель мертва". Полный список классов Requirement* в Assembly-CSharp /// просмотрен - там есть RequirementFullHealth, RequirementHasEntityTag, RequirementNearbyEntities /// и ещё сорок, но ни одного про смерть цели. Значит гейт всё равно оказался бы в коде, а /// тик раз в секунду тут уже есть и обходит ровно тех же питомцев. /// /// Условий три, и все три обязательны: цель есть, цель МЕРТВА (иначе это лечение в бою, а /// не питание), и крыса рядом с ней (иначе она лечилась бы, стоя в другом конце улицы и /// только СОБИРАЯСЬ дойти до трупа). public const float FeedRangeSq = 2.5f * 2.5f; /// Здоровья за секунду поедания. У Духа крысы всего 120 HP, так что 5 - это полное /// восстановление примерно за полминуты над одним трупом: заметно, но не бесплатно. public const int FeedHealthPerSecond = 5; /// ДЫРА, ЗАКРЫТАЯ 2026-09-18. Защита от падения живёт в NecroFollowOwnerTask, а /// та НЕ ИСПОЛНЯЕТСЯ, пока у питомца есть цель (CanExecute возвращает false) - то есть /// именно в бою, когда питомец и бегает по незнакомым местам, ловить его было нечем. /// Этот тик работает всегда, поэтому проверка переехала сюда. /// /// ПРОВЕРКИ ДВЕ, И ОНИ ПРО РАЗНОЕ - это важно не путать. /// /// 1. АБСОЛЮТНАЯ ВЫСОТА. Ниже отметки 5 не бывает законных причин находиться: мир /// кончается на нуле, и всё, что туда опустилось, игра удаляет молча /// (Entity.onUpdate: position.y < 0f -> MarkToUnload). Двусмысленности нет никакой, /// поэтому спасаем всегда, хоть в бою, хоть нет. Это и есть настоящий детектор /// падения, и он один закрывает тот случай, на котором 18.09 потерялась крыса. /// /// 2. НИЖЕ ЦЕЛИ (предложено пользователем). Это детектор НЕ падения, а /// НЕДОСТИЖИМОСТИ: питомец, который на десять метров ниже того, кого ему велели /// грызть, скорее всего не упал, а не может добраться - зомби на крыше, на этаже /// выше, за проломом. Без этой проверки он будет ломиться туда вечно: приказ игрока /// держится 6000 тиков и сам не истечёт. /// /// Поэтому здесь цель ещё и СБРАСЫВАЕТСЯ. Вернуть питомца к хозяину, не сняв /// приказ, значило бы получить маятник: прыжок наверх - бег вниз - прыжок наверх. /// /// Обратная сторона второй проверки названа честно: зомби, стоящий двумя этажами выше, /// теперь отменяет приказ вместо бесконечной беготни. По-моему это лучше, но если /// окажется, что питомец сдаётся слишком рано - крутить UnreachableBelowTarget. public const float UnreachableBelowTarget = 10f; public static void RescueFallen(EntityAlive pet, EntityAlive owner) { if (pet == null || owner == null || pet.world == null) { return; } bool outOfWorld = pet.position.y < NecroFollowOwnerTask.WorldFloorGuard; EntityAlive target = pet.GetAttackTarget(); bool unreachable = !outOfWorld && target != null && !target.IsDead() && pet.position.y < target.position.y - UnreachableBelowTarget; if (!outOfWorld && !unreachable) { return; } pet.IsStuck = false; pet.motion = Vector3.zero; if (pet.moveHelper != null) { pet.moveHelper.Stop(); } if (unreachable) { pet.SetAttackTarget(null, 0); } pet.SetPosition(NecroFollowOwnerTask.SlotPosition(owner, 0, pet.world), true); Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " rescued to owner " + owner.entityId + " - " + (outOfWorld ? "выпал из мира (высота " + pet.position.y.ToCultureInvariantString("0.0") + ")" : "цель недостижима, она выше на " + (target.position.y - pet.position.y).ToCultureInvariantString("0.0") + " м")); } /// Кому принадлежит этот питомец. null - значит не наш или уже не отслеживается. /// Нужна PetKillCreditPatch.cs, чтобы зачесть убийство владельцу. public static EntityPlayer FindOwnerOfPet(int petEntityId) { World world = GameManager.Instance != null ? GameManager.Instance.World : null; if (world == null) { return null; } for (int i = 0; i < TrackedPets.Count; i++) { if (TrackedPets[i].PetEntityId == petEntityId) { return world.GetEntity(TrackedPets[i].OwnerEntityId) as EntityPlayer; } } return null; } public static void FeedOnCorpse(EntityAlive pet) { EntityAlive target = pet.GetAttackTarget(); if (target == null || !target.IsDead()) { return; } if ((target.position - pet.position).sqrMagnitude > FeedRangeSq) { return; } int before = pet.Health; pet.AddHealth(FeedHealthPerSecond); if (pet.Health != before) { Debug.Log("[NecromancerTome] PetFollowPatch: pet " + pet.entityId + " fed on corpse " + target.entityId + ", health " + before + " -> " + pet.Health); } } 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 (!tracked.CollisionIgnored) { tracked.CollisionIgnored = TryIgnoreCollisionWithOwner(owner, pet); } // СЛЕДОВАНИЕ ДЛЯ ЛЕТАЮЩИХ, 2026-09-18. Дом переставляется на хозяина каждую // секунду, а возвращается питомец САМ, своим полётом: у EntityVulture проверка // "не ушёл ли из дома" встроена и работает каждые 60 тиков. Ни телепорта, ни // пафайндинга, ни своей задачи - см. PetInfo.FlyingHomeRadius. // // НА ВРЕМЯ АТАКИ ДОМ ОТВЯЗЫВАЕТСЯ СОВСЕМ (указание того же дня: "для атаки радиус // нужно увеличить, пусть летит сколько надо... а вот после атаки пусть // возвращается"). Иначе собственная проверка EntityVulture срывала бы его с цели // на полпути: она прерывает атаку, как только питомец вышел за радиус. // // Отвязка - это detachHome(), то есть maximumHomeDistance = -1, и тогда // isWithinHomeDistanceCurrentPosition() безусловно возвращает true. Ограничителем // вместо радиуса остаётся сама дальность приказа: цель назначается только по // прицелу, а луч прицела не длиннее 30 метров, так что отправить Грифа на другой // край карты нельзя при всём желании. // // Как только цель пропала (убита, недостижима, приказ сброшен), дом на следующей // же секунде встаёт обратно на хозяина - и питомец возвращается сам. if (tracked.FlyingHomeRadius > 0) { if (pet.GetAttackTarget() != null) { if (pet.hasHome()) { pet.detachHome(); Debug.Log("[NecromancerTome] PetFollowPatch: flying pet " + pet.entityId + " is on a target - home detached for the chase"); } } else { if (!pet.hasHome()) { Debug.Log("[NecromancerTome] PetFollowPatch: flying pet " + pet.entityId + " finished the chase - home re-anchored to owner " + owner.entityId); } pet.setHomeArea(new Vector3i(owner.position), tracked.FlyingHomeRadius); } } if (tracked.WantsLitEyes && !tracked.EyesLit) { PetEyeGlow.DumpMaterialsOnce(pet, EntityClass.list[pet.entityClass]?.entityClassName); tracked.EyesLit = PetEyeGlow.TryLightEyes(pet); } if (tracked.OwnFollowTask) { FeedOnCorpse(pet); RescueFallen(pet, owner); // У этого питомца есть настоящая задача следования (NecroFollowOwnerTask): // она сама держит его при хозяине, сама решает, когда пройти сквозь стену, и // сама сажает его на опорный блок. Телепорт-поводок здесь только мешал бы - // две системы дёргали бы питомца в разные стороны. Уборка владения выше при // этом уже отработала, и она остаётся общей для всех. continue; } 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); } } } } }