using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
///
/// Necromancer pet summon/ownership/recall - BACKLOG.md item 3 (Zombie Dog) plus the Insect
/// Swarm added alongside its bugfix (2026-08-28). Done the same way vanilla's own drone works
/// (per user request): one active pet per player PER SPECIES, and the pet is recorded as
/// owned via the same EntityAlive.ownedEntities API the drone itself uses
/// (ItemActionSpawnTurret.ExecuteAction calls holdingEntity.AddOwnedEntity(entityDrone) -
/// confirmed by decompiling it).
///
/// Each summon book's item block now carries the SpawnEntity action on BOTH Action0 and
/// Action1 (user's own suggestion 2026-08-28, in response to "how do I put the dog back in
/// the book?"), pointed at the same pet entity in both slots, but the two slots mean
/// something different: Action0 (primary click) SUMMONS, blocked with a tooltip if one is
/// already out; Action1 (secondary/"power attack" click) RECALLS the owned one if there is
/// one, or no-ops with a tooltip if there isn't. This split is entirely our own Prefix logic
/// below, keyed off ItemActionData.indexInEntityOfAction (0/1, public field, confirmed by
/// decompiling ItemActionData) - ItemActionSpawnEntity itself has no concept of "recall",
/// it's purely a spawner.
///
/// LimitedPets keys off entity_class name -> the three localization keys each species needs
/// (already-active, recalled, nothing-to-recall). Each species gets its own independent
/// 1-active limit (a Dog and a Swarm can be out at once; two Dogs can't).
///
/// Two patch points for the SUMMON path, because entity creation and item consumption/
/// ownership can't both live in one method without either a transpiler (to grab a local
/// variable) or a fragile "guess the newest entity of this class" lookup:
///
/// 1. Prefix on ItemActionSpawnEntity.Spawn - runs BEFORE anything is created. Handles both
/// the summon-side block/allow AND the entire recall path (recall never lets the
/// original method run at all - there's nothing for vanilla Spawn to do on a recall).
/// 2. Postfix on EntityFactory.CreateEntity(int,Vector3,Vector3) - the exact overload
/// ItemActionSpawnEntity.Spawn() calls, and it returns the created Entity directly
/// (unlike Spawn() itself, which is void), so this is the only point that has both "an
/// entity was just created" and "here it is" without needing IL tricks. Filtered to our
/// pets' entityClassIds so it's a no-op for every other CreateEntity call in the game
/// (turrets, drones, zombie spawns, everything else uses this exact same overload).
/// Because the Prefix above already guarantees at most one pet of that species per
/// player, this postfix doesn't need to re-check the limit - it only ever fires for
/// allowed summons.
///
/// This overload of CreateEntity has no "who spawned this" parameter, so the postfix finds
/// the owner as the nearest player to the spawn position - always correct here since
/// ItemActionSpawnEntity.Spawn() always spawns at (roughly) the caster's own head position.
/// Fine for this mod (no multiplayer/persistence layer exists anywhere else in it either);
/// not a general-purpose "find the real spawner" solution.
///
/// Known gap vs. the real drone: no drones.dat-style save file, no despawn-on-owner-death.
/// A pet is a plain EntityAlive with no special unload/reload handling, so if its chunk
/// unloads while the player is away it despawns like any other wandering entity - the real
/// drone avoids that via DroneManager's own persistence system, which is a lot of machinery
/// (network sync, its own save file) this mod doesn't have a reason to take on for a couple
/// of pet types. Revisit only if that turns out to matter.
///
/// See PetFollowPatch.cs (added 2026-08-28, after the Dog wandered off in-game and a second
/// one wouldn't summon) for the leash-back-to-owner behavior and the ownership cleanup that
/// runs when a tracked pet dies or its chunk unloads - both plug directly into this file's
/// LimitedPets/AddOwnedEntity machinery. The manual recall path added here calls
/// PetFollowPatch.Unregister so a recalled pet stops being tracked immediately instead of
/// waiting for that cleanup to notice it's gone.
///
/// DEBUG LOGGING: added 2026-08-28 after the Zombie Dog silently failed to spawn in-game
/// (root cause was unrelated to this patch - see items.xml's AnimWait comment - but there was
/// no logging anywhere in this file to even rule that out quickly, unlike CharmPatch.cs's
/// verbose Debug.Log on every step). Keeping these permanently, same as CharmPatch.cs does.
///
[HarmonyPatch(typeof(ItemActionSpawnEntity), "Spawn", new System.Type[] { typeof(ItemActionData) })]
public static class Patch_ItemActionSpawnEntity_Spawn_PetLimit
{
public class PetInfo
{
public string AlreadyActiveKey;
public string RecalledKey;
public string NothingToRecallKey;
public string SummonItemName;
/// User request 2026-08-28: "пусть книга вообще не тратится на призыв
/// собаки" - the Dog's book is a permanent bonded item, never consumed on summon (and
/// RecallPet never has anything to give back, since nothing was ever taken). The
/// Swarm keeps consuming its book per cast - it's the one-time/no-recall species, that
/// consumption is the actual "cost" of casting it. This single flag also happens to
/// fix the earlier "dog died, no way to get the book back" complaint: with nothing
/// ever taken, there's nothing to lose when the dog dies off-screen.
public bool ConsumesBook;
/// Указание 2026-09-18, пока только для Духа крысы: "если крыса уже
/// призвана, то призыв становится атакой". Для остальных питомцев Action0 при живом
/// питомце по-прежнему просто говорит "уже призван" (AlreadyActiveKey), как и было.
/// Разбор прицела - в PetCommandPatch.cs.
public bool CommandsAttack;
/// Что показать, когда команда атаки отдана, но под прицелом некого
/// атаковать. Читается только при CommandsAttack.
public string NoTargetKey;
/// Радиус "дома" для ЛЕТАЮЩИХ питомцев, в блоках. Ноль - выключено.
///
/// Указание 2026-09-18: "гриф улетает слишком далеко от игрока и срабатывает поводок.
/// Можем сделать ему следование, которое возвратит в радиус игрока, чтобы поводок
/// вообще не использовался?" Можем, и писать для этого ничего не пришлось: у
/// EntityVulture слежение за "домом" уже встроено. Каждые 60 тиков он проверяет
/// isWithinHomeDistanceCurrentPosition(), и если вышел за радиус - сам переходит в
/// State.Home и летит обратно СВОИМ полётом:
///
/// if (state != State.Home && --homeCheckDelay <= 0) {
/// homeCheckDelay = 60;
/// if (!isWithinHomeDistanceCurrentPosition()) SetState(State.AttackStop);
/// }
/// ... StartHome(getHomePosition().position.ToVector3());
///
/// Достаточно каждую секунду переставлять этот дом на позицию хозяина
/// (EntityAlive.setHomeArea), и получается настоящее следование за игроком, без
/// единого телепорта и без нашего пафайндинга.
public int FlyingHomeRadius;
/// Зажигать ли этому питомцу глаза (PetEyeGlowPatch.cs). Только у Пса и
/// Волка: у Медведя и Грифа модель - один материал на всё, и глаза там запечены в
/// текстуру шкуры, доставать их нечем.
public bool LitEyes;
/// За сколько блоков от хозяина держится этот питомец. Ноль означает "как
/// у всех" (NecroFollowOwnerTask.DefaultSlotDistance, два блока). Медведю и Волку
/// задан блок дальше - указание 2026-09-18, "они мощнее": сектор тот же, радиус
/// больше, чтобы туша не наступала хозяину на пятки.
public float FollowDistance;
/// Вешать ли этому питомцу собственную задачу следования
/// (NecroFollowOwnerTask): держаться справа-сзади, не бродить, не грызть блоки,
/// проходить сквозь препятствия. Старый телепорт-поводок из PetFollowPatch.cs для
/// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в
/// разные стороны.
public bool UsesFollowTask;
}
public static readonly Dictionary LimitedPets = new Dictionary
{
{
"necroZombieDog",
new PetInfo
{
AlreadyActiveKey = "necroZombieDogAlreadyActive",
RecalledKey = "necroZombieDogRecalled",
NothingToRecallKey = "necroZombieDogNothingToRecall",
SummonItemName = "bookSummonZombieDog",
ConsumesBook = false,
LitEyes = true,
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
// цель под прицелом, своя задача следования вместо телепорт-поводка.
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
UsesFollowTask = true,
}
},
{
// User request 2026-08-28: "пусть вызов насекомых будет одноразовым" - no
// Action1 on bookSummonInsectSwarm any more (see items.xml), so
// indexInEntityOfAction can never be RecallActionIndex for this species and
// RecalledKey/NothingToRecallKey below are simply never read. Left null rather
// than pointed at deleted localization keys.
"necroInsectSwarm",
new PetInfo
{
AlreadyActiveKey = "necroInsectSwarmAlreadyActive",
RecalledKey = null,
NothingToRecallKey = null,
SummonItemName = "bookSummonInsectSwarm",
ConsumesBook = true,
}
},
// Three more pets, BACKLOG.md item 4a. REPLACED 2026-08-29 - the original
// Stripper/Cop/Soldier (extending real EntityZombie-classed vanilla zombies) came
// back hostile to the player in testing despite copying the Dog's own recipe; see
// entityclasses.xml for the full story. New concept: Зомбогриф/Зомбомедведь/
// Зомбоволк, extending animal-family bases (same category as the Dog itself, which
// DID work) - same shape otherwise (recallable, book never consumed).
{
"necroZombieGriffin",
new PetInfo
{
AlreadyActiveKey = "necroZombieGriffinAlreadyActive",
RecalledKey = "necroZombieGriffinRecalled",
NothingToRecallKey = "necroZombieGriffinNothingToRecall",
SummonItemName = "bookSummonZombieGriffin",
ConsumesBook = false,
// 15 блоков: заметно меньше 32-метрового поводка, чтобы тот вообще не
// понадобился, и достаточно, чтобы Гриф не висел у игрока над головой.
FlyingHomeRadius = 15,
// UsesFollowTask у Грифа НЕТ с 18.09: он снова летающий (EntityVulture), а
// задача следования наземная - она гоняла бы его пафайндером по земле и
// дралась бы с его собственным полётом. Приказ атаковать оставлен.
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
// цель под прицелом, своя задача следования вместо телепорт-поводка.
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
}
},
{
"necroZombieBear",
new PetInfo
{
AlreadyActiveKey = "necroZombieBearAlreadyActive",
RecalledKey = "necroZombieBearRecalled",
NothingToRecallKey = "necroZombieBearNothingToRecall",
SummonItemName = "bookSummonZombieBear",
ConsumesBook = false,
FollowDistance = 3f,
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
// цель под прицелом, своя задача следования вместо телепорт-поводка.
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
UsesFollowTask = true,
}
},
{
"necroZombieWolf",
new PetInfo
{
AlreadyActiveKey = "necroZombieWolfAlreadyActive",
RecalledKey = "necroZombieWolfRecalled",
NothingToRecallKey = "necroZombieWolfNothingToRecall",
SummonItemName = "bookSummonZombieWolf",
ConsumesBook = false,
FollowDistance = 3f,
// 2026-09-18: "поведение других призванных животных зомби тоже подгони под
// крысу". Те же три флага, что у неё: повторный Action0 - приказ атаковать
// цель под прицелом, своя задача следования вместо телепорт-поводка.
// AlreadyActiveKey у всех четверых теперь не читается никогда.
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
UsesFollowTask = true,
}
},
// "Дух крысы", указание 2026-09-18 - питомец начального уровня (Некромантия 3) и
// первый, у кого поведение задано намеренно, а не унаследовано от зомбопса. На нём
// отлаживается ИИ питомцев вообще, поэтому он единственный, у кого стоят оба новых
// флага. AlreadyActiveKey у него НЕ ЧИТАЕТСЯ НИКОГДА: повторный Action0 - это не
// отказ "уже призван", а команда атаковать (CommandsAttack), так что вместо него
// показывается либо ничего (приказ принят), либо NoTargetKey.
{
"necroRatSpirit",
new PetInfo
{
AlreadyActiveKey = null,
RecalledKey = "necroRatSpiritRecalled",
NothingToRecallKey = "necroRatSpiritNothingToRecall",
SummonItemName = "bookSummonRatSpirit",
ConsumesBook = false,
CommandsAttack = true,
NoTargetKey = "necroPetNoTarget",
UsesFollowTask = true,
}
},
};
/// Action1 ("power attack" slot) is always recall-only - see items.xml, both
/// summon books now declare Action1 with the same Class="SpawnEntity"/Entity as Action0.
public const int RecallActionIndex = 1;
/// Правда ровно между "префикс разрешил призыв" и концом этого же вызова Spawn.
/// Нужна постфиксу ниже, чтобы отличить НАСТОЯЩИЙ призыв от команды атаковать: обе идут
/// через один и тот же Action0, постфикс Harmony отрабатывает в обоих случаях (возврат
/// false из префикса его не отменяет), а делать им надо противоположное - в одном случае
/// снять цель со свежего питомца, в другом ни в коем случае её не трогать, её только что
/// поставил игрок.
public static bool summonAllowedThisCall;
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData)
{
summonAllowedThisCall = false;
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo tooltips))
{
return true;
}
EntityAlive holdingEntity = _actionData?.invData?.holdingEntity;
if (holdingEntity == null)
{
return true;
}
// BUG FIXED 2026-08-28 (unlimited summons, recall never ran, dogs pile-launching the
// player): this used to check "<= 0" for "not found", on the wrong assumption that
// valid ids are small positive numbers. Confirmed by decompiling EntityClass.GetId:
// it returns -1 (a clean sentinel) when not found, and otherwise the real class id -
// which is hash-based and can absolutely be negative (necroZombieDog's is, e.g.,
// -779816341, confirmed by this method's own Debug.Log below during the actual bug).
// With "<= 0", that real, valid, negative id was misread as "not found" on every
// single call, so this returned true unconditionally - which skipped BOTH the
// summon-limit check AND the entire recall branch below (recall's check for it is
// also past this point), so every Action0 OR Action1 click just summoned yet another
// pet, forever, book and all.
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
if (petClassId == -1)
{
Debug.LogWarning("[NecromancerTome] SummonPatch: entity class '" + __instance.entityToSpawn + "' not found");
return true;
}
List owned = holdingEntity.GetOwnedEntities(petClassId);
Debug.Log("[NecromancerTome] SummonPatch: Spawn prefix for " + __instance.entityToSpawn + ", action index=" + _actionData.indexInEntityOfAction + ", owned count=" + owned.Count);
if (_actionData.indexInEntityOfAction == RecallActionIndex)
{
if (owned.Count > 0)
{
RecallPet(holdingEntity, owned[0].Id, tooltips.RecalledKey, tooltips.ConsumesBook ? tooltips.SummonItemName : null);
}
else if (holdingEntity.world != null)
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NothingToRecallKey);
}
return false;
}
if (owned.Count > 0)
{
// Action0 при уже живом питомце. Для всех, кроме Духа крысы, это тупик "уже
// призван". Для него - команда атаковать то, на что смотрит игрок: см.
// PetCommandPatch.cs, там же и весь отсев (торговцы, игроки, мёртвые).
if (tooltips.CommandsAttack)
{
EntityAlive pet = holdingEntity.world != null
? holdingEntity.world.GetEntity(owned[0].Id) as EntityAlive
: null;
if (!PetAttackCommand.TryOrderAttack(holdingEntity, pet) && holdingEntity.world != null)
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.NoTargetKey);
}
return false;
}
if (holdingEntity.world != null)
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.AlreadyActiveKey);
}
return false;
}
summonAllowedThisCall = true;
return true;
}
/// ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком.
/// Последней строкой ваниль делает вот что:
///
/// entityAlive.SetAttackTarget(holdingEntity.GetAttackTarget(), 600);
///
/// то есть свежепризванный питомец НАСЛЕДУЕТ ЦЕЛЬ ХОЗЯИНА. Для зомбопса это задумано и
/// полезно, а для Духа крысы прямо противоречит его единственному правилу - "сама не
/// атакует никогда". Призови её в драке, и она бросилась бы в бой сама, без приказа.
///
/// Почистить это в постфиксе на EntityFactory.CreateEntity нельзя: тот отрабатывает
/// ВНУТРИ Spawn, ещё до этой строки. Поэтому чистка здесь, после всего.
///
/// Флаг summonAllowedThisCall обязателен: без него постфикс сбрасывал бы цель и после
/// команды атаковать - то есть приказ гасил бы сам себя.
public static void Postfix(ItemActionSpawnEntity __instance)
{
if (!summonAllowedThisCall)
{
return;
}
summonAllowedThisCall = false;
if (!LimitedPets.TryGetValue(__instance.entityToSpawn, out PetInfo petInfo) || !petInfo.UsesFollowTask)
{
return;
}
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null || world.EntityAlives == null)
{
return;
}
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
for (int i = world.EntityAlives.Count - 1; i >= 0; i--)
{
EntityAlive candidate = world.EntityAlives[i];
if (candidate != null && candidate.entityClass == petClassId && candidate.GetAttackTarget() != null)
{
candidate.SetAttackTarget(null, 0);
Debug.Log("[NecromancerTome] SummonPatch: cleared the target vanilla Spawn handed to fresh pet " +
candidate.entityId + " (this species never attacks on its own)");
}
}
}
/// summonItemName is null when this species doesn't consume its book on summon
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.
public static void RecallPet(EntityAlive owner, int petEntityId, string recalledTooltipKey, string summonItemName)
{
World world = owner.world;
if (world != null)
{
world.RemoveEntity(petEntityId, EnumRemoveEntityReason.Killed);
}
owner.RemoveOwnedEntity(petEntityId);
PetFollowPatch.Unregister(petEntityId);
if (summonItemName != null)
{
GiveBackSummonItem(owner, summonItemName);
}
if (world != null)
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), recalledTooltipKey);
}
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " recalled pet " + petEntityId);
}
/// Hands one copy of the summon book back to whoever just recalled their pet -
/// the dog/swarm "goes back into the book" literally, not just for free. Tries the
/// toolbelt first (Inventory.AddItem - confirmed by decompiling it, only searches the
/// toolbelt's own slots), then the backpack (EntityPlayer.bag, same AddItem shape) if that
/// didn't fit. If both are full the book is just lost - not worth building actual overflow
/// handling (a "drop it on the ground" fallback) for something this minor.
public static void GiveBackSummonItem(EntityAlive owner, string itemName)
{
ItemValue itemValue = ItemClass.GetItem(itemName);
if (itemValue == null || itemValue.type <= 0)
{
Debug.LogWarning("[NecromancerTome] SummonPatch: could not resolve item '" + itemName + "' to give back on recall");
return;
}
ItemStack stack = new ItemStack(itemValue, 1);
bool added = owner.inventory != null && owner.inventory.AddItem(stack, out int slot);
if (!added && owner is EntityPlayer player && player.bag != null)
{
// Bag (InventoryBase) only exposes the single-arg AddItem overload, unlike
// Inventory's (ItemStack, out int) - confirmed by decompiling both.
added = player.bag.AddItem(stack);
}
if (!added)
{
Debug.LogWarning("[NecromancerTome] SummonPatch: " + itemName + " didn't fit back into " + owner.entityId + "'s inventory on recall (full?)");
}
}
}
///
/// ОТЗЫВ СРАБАТЫВАЕТ СРАЗУ ПО НАЖАТИЮ - вторая правка того же бага, 2026-09-18. Первая
/// (Patch_..._IsActionRunning_FreeTheRecall ниже) сняла блокировку вторичного действия
/// основным, и этого оказалось мало: "отозвать собаку получилось далеко не с первого раза.
/// Это проблема всех питомцев".
///
/// ОСТАВШАЯСЯ ПРИЧИНА - ТА ЖЕ САМАЯ СЕМЬЯ, что и у призыва, только в другом слоте. Отзыв идёт
/// через ItemActionSpawnEntity.Spawn, а до него надо ДОЖИТЬ через автомат состояний:
///
/// OnHoldingUpdate: stateTime += 0.05
/// Anim: если stateTime >= animWait -> state = Spawn
/// Spawn: Spawn(data); state = End
/// ExecuteAction(_bReleased: true): state = None // отпустил - всё сбросилось
///
/// При animWait = 0.1 это ТРИ тика: 0.05 (мало), 0.10 (переход в Spawn), и только третий
/// вызывает сам Spawn. То есть кнопку надо продержать около 0.15 секунды. У призыва это
/// лечилось уменьшением animWait до 0.05, но там и оставалось два тика; меньше двух автомат
/// не умеет в принципе - один переход и одно исполнение.
///
/// Поэтому отзыв вынут из автомата совсем: он делается прямо на НАЖАТИИ, в префиксе
/// ExecuteAction, и оригинал не запускается вовсе. Никакого удержания, никакого ожидания.
///
/// ЗАЩЁЛКА ОБЯЗАТЕЛЬНА. ExecuteAction(_bReleased: false) зовётся не один раз за клик, а
/// КАЖДЫЙ КАДР, пока кнопка нажата (PlayerMoveController проверяет Secondary.IsPressed, а не
/// WasPressed), и вся защита от повторов у ванили держится на её же state == None, который мы
/// теперь не выставляем. Без задержки ниже одно удержание правой кнопки отозвало бы питомца и
/// следом залило экран надписью "не призван" по разу в кадр.
///
[HarmonyPatch(typeof(ItemActionSpawnEntity), "ExecuteAction")]
public static class Patch_ItemActionSpawnEntity_ExecuteAction_InstantRecall
{
/// Секунды между двумя срабатываниями отзыва. Полсекунды заведомо больше любого
/// клика и заведомо меньше осмысленного повторного нажатия.
public const float RepeatGuardSeconds = 0.5f;
public static float lastRecallTime;
public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData, bool _bReleased)
{
if (_bReleased || _actionData == null ||
_actionData.indexInEntityOfAction != Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallActionIndex)
{
return true;
}
if (!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.TryGetValue(
__instance.entityToSpawn, out Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo))
{
return true;
}
EntityAlive holdingEntity = _actionData.invData != null ? _actionData.invData.holdingEntity : null;
if (holdingEntity == null || holdingEntity.world == null)
{
return true;
}
if (Time.time - lastRecallTime < RepeatGuardSeconds)
{
// Кнопка всё ещё зажата с прошлого кадра - молча проглатываем.
return false;
}
int petClassId = EntityClass.GetId(__instance.entityToSpawn);
if (petClassId == -1)
{
return true;
}
lastRecallTime = Time.time;
List owned = holdingEntity.GetOwnedEntities(petClassId);
if (owned.Count > 0)
{
Patch_ItemActionSpawnEntity_Spawn_PetLimit.RecallPet(holdingEntity, owned[0].Id,
petInfo.RecalledKey, petInfo.ConsumesBook ? petInfo.SummonItemName : null);
holdingEntity.PlayOneShot(__instance.soundWarn);
}
else
{
GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), petInfo.NothingToRecallKey);
}
return false;
}
}
///
/// "ОТЗЫВАЕТСЯ НЕ С ПЕРВОГО РАЗА" - баг-репорт пользователя 2026-09-18: "если прожал атаку
/// несколько раз, то при попытке отозвать крысу, она не отзывается с первого раза".
///
/// Причина не в нашем коде и не в питомце, а в гейте ввода. PlayerMoveController решает,
/// пускать ли вторичное действие, так:
///
/// bool flag16 = Actions[0].AllowConcurrentActions() || Actions[1].AllowConcurrentActions();
/// bool flag18 = Actions[0].IsActionRunning(actionData[0]); // основное ещё "идёт"
/// ...
/// if (flag12 && flag15 && (flag16 || !flag18)) // flag15 = Secondary.IsPressed
/// inventory.Execute(1, _bReleased: false, ...); // ВОТ ЭТО - отзыв
///
/// То есть пока основное действие считается идущим, вторичное просто не запускается.
/// А "идущим" оно считается вот по чему (ItemActionSpawnEntity):
///
/// public override bool IsActionRunning(ItemActionData _actionData)
/// => ((ItemActionDataSpawnEntity)_actionData).state != State.None;
///
/// Состояние проходит None -> Anim -> Spawn -> **End**, и в None возвращается ТОЛЬКО из
/// ExecuteAction(_bReleased: true), то есть по отпусканию кнопки. End - это уже
/// отработавшее, законченное действие, но формально всё ещё "не None". Чем чаще игрок
/// щёлкает приказом атаковать, тем выше шанс, что в момент нажатия правой кнопки Action0
/// висит именно в End, - и первый отзыв уходит в никуда.
///
/// ЧИНИМ РОВНО ЭТО И НИЧЕГО БОЛЬШЕ: для наших книг призыва End больше не считается "идёт".
/// Соблазн был переписывать сам state в None - так делать НЕЛЬЗЯ: ExecuteAction стартует
/// новое действие как раз по условию state == None, и при зажатой кнопке призыв/приказ пошёл
/// бы на повтор каждые два тика, вместе с воплем кролика на каждый. Здесь же меняется только
/// ОТВЕТ на вопрос "идёт ли действие": вторичное разблокировано, а повторный старт
/// основного по-прежнему заперт настоящим полем state, которое так и осталось End.
///
[HarmonyPatch(typeof(ItemActionSpawnEntity), "IsActionRunning")]
public static class Patch_ItemActionSpawnEntity_IsActionRunning_FreeTheRecall
{
public static void Postfix(ItemActionSpawnEntity __instance, ItemActionData _actionData, ref bool __result)
{
if (!__result || _actionData == null)
{
return;
}
if (!Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets.ContainsKey(__instance.entityToSpawn))
{
return;
}
if (_actionData is ItemActionSpawnEntity.ItemActionDataSpawnEntity data
&& data.state == ItemActionSpawnEntity.ItemActionDataSpawnEntity.State.End)
{
__result = false;
}
}
}
[HarmonyPatch(typeof(EntityFactory), "CreateEntity", new System.Type[] { typeof(int), typeof(Vector3), typeof(Vector3) })]
public static class Patch_EntityFactory_CreateEntity_PetOwnership
{
public static void Postfix(int _et, Entity __result)
{
if (__result == null)
{
return;
}
Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo = null;
foreach (KeyValuePair entry in Patch_ItemActionSpawnEntity_Spawn_PetLimit.LimitedPets)
{
if (EntityClass.GetId(entry.Key) == _et)
{
petInfo = entry.Value;
break;
}
}
if (petInfo == null)
{
return;
}
Debug.Log("[NecromancerTome] SummonPatch: CreateEntity postfix, entity=" + __result.entityId + " et=" + _et);
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null || world.Players == null || world.Players.list == null)
{
return;
}
EntityPlayer owner = null;
float bestDistSq = float.MaxValue;
foreach (EntityPlayer player in world.Players.list)
{
if (player == null)
{
continue;
}
float distSq = (player.position - __result.position).sqrMagnitude;
if (distSq < bestDistSq)
{
bestDistSq = distSq;
owner = player;
}
}
if (owner == null)
{
Debug.LogWarning("[NecromancerTome] SummonPatch: no player found to own " + __result.entityId);
return;
}
owner.AddOwnedEntity(__result);
if (petInfo.ConsumesBook && owner.inventory != null)
{
owner.inventory.DecHoldingItem(1);
}
// Первая попытка развести коллайдеры - здесь же, чтобы в удачном случае не ждать
// секунду до тика. Удачной она, скорее всего, НЕ БУДЕТ: модель питомца на этом шаге
// ещё не собрана и коллайдеров у него нет (разбор - в TryIgnoreCollisionWithOwner).
// Поэтому результат передаётся в Register, и тик повторяет попытку до успеха.
bool collisionIgnored = PetFollowPatch.TryIgnoreCollisionWithOwner(owner, __result);
PetFollowPatch.Register(owner, __result, petInfo.UsesFollowTask, collisionIgnored, petInfo.LitEyes,
petInfo.FlyingHomeRadius);
AttachFollowTask(owner, __result, petInfo);
// Полупрозрачность больше НЕ делается здесь. Бывший ApplyGhostlyTransparency ставил
// альфу в material.color и не работал ни разу: шейдеры моделей мобов альфу не
// поддерживают (на Рое это выяснилось ещё 28.08 - он только засорял лог). Призрачный
// вид всем питомцам теперь даёт GhostTraderPatch.cs - та же система, что у торговцев,
// с клонированием материала, обесцвечиванием и подбором blend-шейдера.
Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId);
}
/// Вешает питомцу собственную задачу следования - указание 2026-09-18, пока
/// только Духу крысы (PetInfo.UsesFollowTask).
///
/// ЧЕРЕЗ КОД, А НЕ ЧЕРЕЗ XML - см. шапку PetFollowTask.cs: XML-путь существует, но упирается
/// в Type.GetType с именем сборки, а этот не упирается ни во что и заодно позволяет отдать
/// владельца прямо в поле задачи, без поиска по ownedEntities на каждом тике.
///
/// Приоритет 3: у крысы в entityclasses.xml объявлены AITask-1 (ApproachAndAttackTarget) и
/// AITask-2 (Look), а AITask-3 пустой - то есть следование встаёт ровно туда, где в XML
/// кончился список, и ниже погони за целью. Разводить их приоритетом при этом всё равно
/// недостаточно: задача следования сама отказывается работать, пока у питомца есть цель
/// (NecroFollowOwnerTask.CanExecute), и несёт те же MutexBits=3, что ванильный
/// EAIApproachSpot.
public static void AttachFollowTask(EntityPlayer owner, Entity pet, Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo petInfo)
{
if (!petInfo.UsesFollowTask)
{
return;
}
EntityAlive alive = pet as EntityAlive;
if (alive == null || alive.aiManager == null || alive.aiManager.tasks == null)
{
Debug.LogWarning("[NecromancerTome] SummonPatch: pet " + pet.entityId +
" has no aiManager - follow task NOT attached");
return;
}
NecroFollowOwnerTask task = new NecroFollowOwnerTask { OwnerEntityId = owner.entityId };
if (petInfo.FollowDistance > 0f)
{
task.SlotDistance = petInfo.FollowDistance;
}
task.Init(alive);
alive.aiManager.tasks.AddTask(3, task);
Debug.Log("[NecromancerTome] SummonPatch: follow task attached to pet " + pet.entityId +
" (owner " + owner.entityId + ")");
}
}
}