Files
necromants-tome-7d2d-3-2/HarmonySrc/SummonPatch.cs
T
AlexCubeandClaude Opus 5 d97f83e972 ИИ питомцев переписан, добавлен Дух крысы, пороги разнесены по шкале
Работа по указаниям 2026-09-18. В релиз пока не выходит.

НОВЫЙ ПИТОМЕЦ "Дух крысы" (necroRatSpirit), Некромантия 3 (60 убийств):
модель зомбоволка в масштабе 0.28, урон 5, кроличьи звуки. Сам не нападает
никогда, держится справа-сзади в двух блоках, упёршись в препятствие проходит
СКВОЗЬ него и блоков не ломает. Укус замедляет, оставляет метку духа (видна на
компасе, +25% получаемого урона) и рвёт жилу. Обгрызая труп, лечится.

СВОЯ ЗАДАЧА ИИ. Задачи "иди за сущностью" в игре нет вовсе - проверены все 32
типа EAI*. Написана NecroFollowOwnerTask по образцу EAIApproachSpot: сектор
"3-6 часов" от хозяина, FindPath с canBreak:false, проход сквозь препятствие
через Entity.IsStuck. Вешается в рантайме, минуя Type.GetType.

ПРИКАЗ АТАКОВАТЬ. Повторное применение свитка при живом питомце натравливает
его на цель под прицелом: EntityPlayerLocal.HitInfo + ItemActionAttack.
GetEntityFromHit. Торговцы и игроки отсеяны. Нет цели - "Нет цели для атаки".

ЗОМБОЖИВОТНЫЕ ПРИВЕДЕНЫ К ТОМУ ЖЕ ОБРАЗЦУ. Сняты BreakBlock, Territorial,
ApproachSpot, Wander, BlockingTargetTask и поедание трупов; цель они больше не
выбирают сами. Лестница урона 20/35/45/60 плюс расчленение у Пса, Медведя и
Волка. Кровотечение всем, метка и ослабление - только у крысы.

УБИЙСТВА ПИТОМЦЕМ ЗАСЧИТЫВАЮТСЯ ВЛАДЕЛЬЦУ, включая добивание кровотечением.
Префикс на AwardKillXPServer подменяет убийцу владельцем; для смерти от баффа
заведена память укусов, потому что в DamageSource от баффа нет того, кто его
наложил. Зомби под Камнем духов это не задело - решение от 17.09 в силе.

ГРИФ откачен на летающую ветку EntityVulture и переименован в Могильного
стервятника: модель наконец соответствует имени. Держится у игрока сам, через
собственный механизм "дома" (setHomeArea), на время погони дом отвязывается.
Попытка натянуть птичий префаб на наземный класс провалилась и записана - так
делать нельзя.

ПРОЧЕЕ: призрачный вид распространён с торговцев на питомцев (у Пса, Медведя и
Волка выключен по указанию), у Пса светятся фиолетовые глаза, белая иконка
книги снята со всех свитков призыва, "Жуки Властелина" переименованы в "Рой
фараона" на всех 13 языках.

ПОРОГИ: крыса 60, стервятник 500, пёс 1300, медведь 2000, волк 4000.

ИСПРАВЛЕНО ПО ХОДУ ИГРОВЫХ ПРОВЕРОК: питомцы не призывались обычным кликом
(AnimWait требовал удержания), отзыв срабатывал не с первого раза (автомат
состояний ItemActionSpawnEntity), крыса проваливалась сквозь мир (IsStuck
отключает и пол), питомец подбрасывал хозяина (коллайдеры разводились до
появления модели).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:51:42 +03:00

685 lines
38 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>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.</summary>
public bool ConsumesBook;
/// <summary>Указание 2026-09-18, пока только для Духа крысы: "если крыса уже
/// призвана, то призыв становится атакой". Для остальных питомцев Action0 при живом
/// питомце по-прежнему просто говорит "уже призван" (AlreadyActiveKey), как и было.
/// Разбор прицела - в PetCommandPatch.cs.</summary>
public bool CommandsAttack;
/// <summary>Что показать, когда команда атаки отдана, но под прицелом некого
/// атаковать. Читается только при CommandsAttack.</summary>
public string NoTargetKey;
/// <summary>Радиус "дома" для ЛЕТАЮЩИХ питомцев, в блоках. Ноль - выключено.
///
/// Указание 2026-09-18: "гриф улетает слишком далеко от игрока и срабатывает поводок.
/// Можем сделать ему следование, которое возвратит в радиус игрока, чтобы поводок
/// вообще не использовался?" Можем, и писать для этого ничего не пришлось: у
/// EntityVulture слежение за "домом" уже встроено. Каждые 60 тиков он проверяет
/// isWithinHomeDistanceCurrentPosition(), и если вышел за радиус - сам переходит в
/// State.Home и летит обратно СВОИМ полётом:
///
/// if (state != State.Home &amp;&amp; --homeCheckDelay &lt;= 0) {
/// homeCheckDelay = 60;
/// if (!isWithinHomeDistanceCurrentPosition()) SetState(State.AttackStop);
/// }
/// ... StartHome(getHomePosition().position.ToVector3());
///
/// Достаточно каждую секунду переставлять этот дом на позицию хозяина
/// (EntityAlive.setHomeArea), и получается настоящее следование за игроком, без
/// единого телепорта и без нашего пафайндинга.</summary>
public int FlyingHomeRadius;
/// <summary>Зажигать ли этому питомцу глаза (PetEyeGlowPatch.cs). Только у Пса и
/// Волка: у Медведя и Грифа модель - один материал на всё, и глаза там запечены в
/// текстуру шкуры, доставать их нечем.</summary>
public bool LitEyes;
/// <summary>За сколько блоков от хозяина держится этот питомец. Ноль означает "как
/// у всех" (NecroFollowOwnerTask.DefaultSlotDistance, два блока). Медведю и Волку
/// задан блок дальше - указание 2026-09-18, "они мощнее": сектор тот же, радиус
/// больше, чтобы туша не наступала хозяину на пятки.</summary>
public float FollowDistance;
/// <summary>Вешать ли этому питомцу собственную задачу следования
/// (NecroFollowOwnerTask): держаться справа-сзади, не бродить, не грызть блоки,
/// проходить сквозь препятствия. Старый телепорт-поводок из PetFollowPatch.cs для
/// таких питомцев выключается - две системы на одно и то же дёргали бы питомца в
/// разные стороны.</summary>
public bool UsesFollowTask;
}
public static readonly Dictionary<string, PetInfo> LimitedPets = new Dictionary<string, PetInfo>
{
{
"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,
}
},
};
/// <summary>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.</summary>
public const int RecallActionIndex = 1;
/// <summary>Правда ровно между "префикс разрешил призыв" и концом этого же вызова Spawn.
/// Нужна постфиксу ниже, чтобы отличить НАСТОЯЩИЙ призыв от команды атаковать: обе идут
/// через один и тот же Action0, постфикс Harmony отрабатывает в обоих случаях (возврат
/// false из префикса его не отменяет), а делать им надо противоположное - в одном случае
/// снять цель со свежего питомца, в другом ни в коем случае её не трогать, её только что
/// поставил игрок.</summary>
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<OwnedEntityData> 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;
}
/// <summary>ДОБАВЛЕНО 2026-09-18, после чтения ItemActionSpawnEntity.Spawn целиком.
/// Последней строкой ваниль делает вот что:
///
/// entityAlive.SetAttackTarget(holdingEntity.GetAttackTarget(), 600);
///
/// то есть свежепризванный питомец НАСЛЕДУЕТ ЦЕЛЬ ХОЗЯИНА. Для зомбопса это задумано и
/// полезно, а для Духа крысы прямо противоречит его единственному правилу - "сама не
/// атакует никогда". Призови её в драке, и она бросилась бы в бой сама, без приказа.
///
/// Почистить это в постфиксе на EntityFactory.CreateEntity нельзя: тот отрабатывает
/// ВНУТРИ Spawn, ещё до этой строки. Поэтому чистка здесь, после всего.
///
/// Флаг summonAllowedThisCall обязателен: без него постфикс сбрасывал бы цель и после
/// команды атаковать - то есть приказ гасил бы сам себя.</summary>
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)");
}
}
}
/// <summary>summonItemName is null when this species doesn't consume its book on summon
/// (see PetInfo.ConsumesBook) - nothing was taken, so nothing is given back.</summary>
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);
}
/// <summary>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.</summary>
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?)");
}
}
}
/// <summary>
/// ОТЗЫВ СРАБАТЫВАЕТ СРАЗУ ПО НАЖАТИЮ - вторая правка того же бага, 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, который мы
/// теперь не выставляем. Без задержки ниже одно удержание правой кнопки отозвало бы питомца и
/// следом залило экран надписью "не призван" по разу в кадр.
/// </summary>
[HarmonyPatch(typeof(ItemActionSpawnEntity), "ExecuteAction")]
public static class Patch_ItemActionSpawnEntity_ExecuteAction_InstantRecall
{
/// <summary>Секунды между двумя срабатываниями отзыва. Полсекунды заведомо больше любого
/// клика и заведомо меньше осмысленного повторного нажатия.</summary>
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<OwnedEntityData> 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;
}
}
/// <summary>
/// "ОТЗЫВАЕТСЯ НЕ С ПЕРВОГО РАЗА" - баг-репорт пользователя 2026-09-18: "если прожал атаку
/// несколько раз, то при попытке отозвать крысу, она не отзывается с первого раза".
///
/// Причина не в нашем коде и не в питомце, а в гейте ввода. PlayerMoveController решает,
/// пускать ли вторичное действие, так:
///
/// bool flag16 = Actions[0].AllowConcurrentActions() || Actions[1].AllowConcurrentActions();
/// bool flag18 = Actions[0].IsActionRunning(actionData[0]); // основное ещё "идёт"
/// ...
/// if (flag12 &amp;&amp; flag15 &amp;&amp; (flag16 || !flag18)) // flag15 = Secondary.IsPressed
/// inventory.Execute(1, _bReleased: false, ...); // ВОТ ЭТО - отзыв
///
/// То есть пока основное действие считается идущим, вторичное просто не запускается.
/// А "идущим" оно считается вот по чему (ItemActionSpawnEntity):
///
/// public override bool IsActionRunning(ItemActionData _actionData)
/// =&gt; ((ItemActionDataSpawnEntity)_actionData).state != State.None;
///
/// Состояние проходит None -&gt; Anim -&gt; Spawn -&gt; **End**, и в None возвращается ТОЛЬКО из
/// ExecuteAction(_bReleased: true), то есть по отпусканию кнопки. End - это уже
/// отработавшее, законченное действие, но формально всё ещё "не None". Чем чаще игрок
/// щёлкает приказом атаковать, тем выше шанс, что в момент нажатия правой кнопки Action0
/// висит именно в End, - и первый отзыв уходит в никуда.
///
/// ЧИНИМ РОВНО ЭТО И НИЧЕГО БОЛЬШЕ: для наших книг призыва End больше не считается "идёт".
/// Соблазн был переписывать сам state в None - так делать НЕЛЬЗЯ: ExecuteAction стартует
/// новое действие как раз по условию state == None, и при зажатой кнопке призыв/приказ пошёл
/// бы на повтор каждые два тика, вместе с воплем кролика на каждый. Здесь же меняется только
/// ОТВЕТ на вопрос "идёт ли действие": вторичное разблокировано, а повторный старт
/// основного по-прежнему заперт настоящим полем state, которое так и осталось End.
/// </summary>
[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<string, Patch_ItemActionSpawnEntity_Spawn_PetLimit.PetInfo> 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);
}
/// <summary>Вешает питомцу собственную задачу следования - указание 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.</summary>
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 + ")");
}
}
}