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; } public static readonly Dictionary LimitedPets = new Dictionary { { "necroZombieDog", new PetInfo { AlreadyActiveKey = "necroZombieDogAlreadyActive", RecalledKey = "necroZombieDogRecalled", NothingToRecallKey = "necroZombieDogNothingToRecall", SummonItemName = "bookSummonZombieDog", ConsumesBook = false, } }, { // 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, } }, { "necroZombieBear", new PetInfo { AlreadyActiveKey = "necroZombieBearAlreadyActive", RecalledKey = "necroZombieBearRecalled", NothingToRecallKey = "necroZombieBearNothingToRecall", SummonItemName = "bookSummonZombieBear", ConsumesBook = false, } }, { "necroZombieWolf", new PetInfo { AlreadyActiveKey = "necroZombieWolfAlreadyActive", RecalledKey = "necroZombieWolfRecalled", NothingToRecallKey = "necroZombieWolfNothingToRecall", SummonItemName = "bookSummonZombieWolf", ConsumesBook = false, } }, }; /// 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; public static bool Prefix(ItemActionSpawnEntity __instance, ItemActionData _actionData) { 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) { if (holdingEntity.world != null) { GameManager.ShowTooltip(GameManager.Instance.World.GetPrimaryPlayer(), tooltips.AlreadyActiveKey); } return false; } return true; } /// 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?)"); } } } [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); } PetFollowPatch.Register(owner, __result); IgnoreCollisionWithOwner(owner, __result); ApplyGhostlyTransparency(__result); Debug.Log("[NecromancerTome] SummonPatch: owner=" + owner.entityId + " now owns pet " + __result.entityId); } /// User request 2026-08-28 ("нематериальными") - makes the pet pass through its /// own owner specifically, via Physics.IgnoreCollision on every collider pair between the /// two, rather than stripping the pet's PhysicsBody entirely the way vanilla's own /// animalInsectSwarm does for its "no physics body at all" look (confirmed by checking /// entityclasses.xml). That works for a flying swarm; a ground-walking dog with no /// collider at all would fall through the terrain. This keeps it solid against ground and /// zombies - just not its owner - which also directly closes the last piece of the /// spawn-launch bug documented in items.xml/PetFollowPatch.cs (nothing left to shove the /// player if the two colliders can't touch in the first place). public static void IgnoreCollisionWithOwner(EntityPlayer owner, Entity pet) { Collider[] ownerColliders = owner.GetComponentsInChildren(); Collider[] petColliders = pet.GetComponentsInChildren(); foreach (Collider oc in ownerColliders) { if (oc == null) { continue; } foreach (Collider pc in petColliders) { if (pc == null) { continue; } Physics.IgnoreCollision(oc, pc, true); } } } /// User request 2026-08-28 ("слегка прозрачными") - best effort only. Directly /// sets renderer.material.color's alpha, the same technique ItemActionSpawnTurret uses for /// its own placement-preview tint (confirmed by decompiling it), but that only visibly /// shows up if the model's actual shader supports alpha blending - most opaque mob /// shaders in this game don't, and there's no reliable XML/reflection-only way to swap a /// live renderer's shader to a transparent variant without risking breaking how it's lit. /// /// BUG FIXED 2026-08-28: the Insect Swarm's renderers use a particle shader /// ("Game Particles/surfaceShader_masked_particleEnhanced") that has no "_Color" property /// at all - setting .color on it doesn't throw, but Unity logs "doesn't have a color /// property '_Color'" on every single access, once per renderer per spawn (confirmed in /// output_log - this is what the user saw as "an error about colors"). HasProperty check /// added so this silently skips any renderer whose shader doesn't support it instead of /// spamming the log - the visual effect was never going to work on those anyway. public static void ApplyGhostlyTransparency(Entity pet) { Renderer[] renderers = pet.GetComponentsInChildren(); foreach (Renderer renderer in renderers) { if (renderer == null || renderer.material == null || !renderer.material.HasProperty("_Color")) { continue; } Color color = renderer.material.color; color.a = 0.55f; renderer.material.color = color; } } } }