using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// The core "Deviator" effect: whichever zombie the buff named in CharmBuffName lands on /// (via the item's own XML - see Config/items.xml/buffs.xml) stops targeting the player /// and starts targeting other zombies instead. /// /// Why this needs Harmony at all: a zombie's valid-target class list (who it's even allowed /// to look for/attack) is parsed once from its entity_class XML into per-instance AI task /// objects at spawn time (EAIApproachAndAttackTarget / EAISetNearestEntityAsTarget). There is /// no XML-level action to change it afterwards - the buff system alone cannot flip a live /// zombie's allegiance. This patch is the smallest hook that can: it reacts to our specific /// buff being added and then rewrites those same per-instance task objects directly, using /// the exact same fields the game's own EAIManager.SetTargetOnlyPlayers() helper uses for the /// mirror-image trick (restricting a task to players only). We do the same thing in reverse: /// restrict to zombies only. /// [HarmonyPatch(typeof(EntityBuffs), "AddBuff", new System.Type[] { typeof(string), typeof(Vector3i), typeof(int), typeof(bool), typeof(bool), typeof(float) })] public static class Patch_EntityBuffs_AddBuff_DeviatorCharm { public const string CharmBuffName = "buffNecroDeviatorCharm"; /// Diagnostic-only, added 2026-08-28 while chasing "the Knife's Victim debuff /// never sticks" - this patch already logs every AddBuff call for our other marker buff, /// so logging buffNecroVictim's calls here too (no CharmZombie() side effects for it, /// just visibility) answers the actual open question directly: does /// necroWpnBladeNecroKnife's onSelfAttackedOther trigger ever even call AddBuff at all, /// or does it call it but something downstream (target class mismatch, requirement gate, /// stacking) rejects it. No log line at all next time means the XML trigger itself isn't /// firing (likely because EntityDamage computes to 0 and a 0-damage swing doesn't count /// as a landed hit); a log line with a non-Added result narrows it further. public const string VictimBuffName = "buffNecroVictim"; public static void Postfix(EntityBuffs __instance, string _name, EntityBuffs.BuffStatus __result) { if (_name == CharmBuffName || _name == VictimBuffName) { // entityId added 2026-08-28 while chasing "the zombie that died didn't have the // buff even though AddBuff Added fired repeatedly" - logging just the TYPE name // couldn't tell whether it landed on the same zombie that later died or a // different one nearby (the knife swings a 90-degree arc, plausible it's hitting // more than one zombie in a group). This settles it directly against // VictimPatch's own "dropItemOnDeath Prefix entered for " line. string parentId = __instance.parent != null ? __instance.parent.entityId.ToString() : "?"; Debug.Log("[NecromancerTome] AddBuff(" + _name + ") result=" + __result + " parent=" + (__instance.parent != null ? __instance.parent.GetType().Name : "null") + " id=" + parentId); } if (__result != EntityBuffs.BuffStatus.Added) { return; } if (_name != CharmBuffName) { return; } // HUMANOID-ONLY IS A DELIBERATE DESIGN DECISION, NOT A GAP TO CLOSE (user, 2026-09-07: // "Камень духов не годится, он для зомбо-гуманоидов, и пусть так и остаётся"). // Do not "fix" this by widening the type check. // // The user had noticed the behaviour in play first ("камень душ на зомбособаках не // работает, это я проверил однажды") and was right. The reason is this line: zombie // ANIMALS sit on a completely different branch of the C# hierarchy, so the pattern // match below is false for every one of them - // EntityZombie : EntityHuman <- the only charmable one // EntityZombieDog : EntityEnemyAnimal : EntityEnemy // EntityEnemyAnimal : EntityEnemy (bear, boar) // EntityVulture : EntityFlying (not even EntityEnemy) // - while the XML side still lets the buff land on them, because they DO carry the // "zombie" tag ("entity,animal,zombie,zombieAnimal,hostile,..."), which is what // EntityTagCompare gates on. So the buff is added, this check rejects it, and the log // line below fires. That log line is expected on zombie animals and is not an error. // // Note the same decision applies to the other half of this patch: CharmZombie() below // restricts targetClasses to typeof(EntityZombie), so a charmed humanoid also ignores // zombie animals as targets. Consistent with "the Spirit Stone is a humanoid tool". // Countering zombie animals/birds is meant to be a separate mechanic - open topic, // see BACKLOG.md 2026-09-07. if (!(__instance.parent is EntityZombie zombie) || zombie.aiManager == null) { Debug.Log("[NecromancerTome] charm buff added but parent is not a charmable EntityZombie (aiManager null or wrong type)"); return; } CharmZombie(zombie); } public static void CharmZombie(EntityZombie zombie) { // AITask: whichever task actually chases/melees the current attack target. // Restrict it to EntityZombie only, same shape as SetTargetOnlyPlayers() but reversed. List approachTasks = zombie.aiManager.GetTasks(); Debug.Log("[NecromancerTome] CharmZombie: approachTasks=" + (approachTasks != null ? approachTasks.Count.ToString() : "null")); if (approachTasks != null) { foreach (EAIApproachAndAttackTarget task in approachTasks) { task.targetClasses.Clear(); task.targetClasses.Add(new EAIApproachAndAttackTarget.TargetClass { type = typeof(EntityZombie), chaseTimeMax = 0f }); } } // AITarget: whichever task picks the nearest valid entity to go attack in the first // place. Same restriction here, or the zombie would never even pick a target to hand // off to the approach task above. List targetTasks = zombie.aiManager.GetTargetTasks(); Debug.Log("[NecromancerTome] CharmZombie: targetTasks=" + (targetTasks != null ? targetTasks.Count.ToString() : "null")); if (targetTasks != null) { foreach (EAISetNearestEntityAsTarget task in targetTasks) { task.targetClasses.Clear(); task.targetClasses.Add(new EAISetNearestEntityAsTarget.TargetClass { type = typeof(EntityZombie), hearDistMax = 50f, seeDistMax = 0f }); task.playerTargetClassIndex = -1; } } // Drop whatever it was mid-attack on (almost certainly the player who just threw the // book at it) so the switch takes effect immediately instead of after its current target dies/despawns. zombie.SetAttackTarget(null, 0); Debug.Log("[NecromancerTome] CharmZombie: done, attack target cleared for " + zombie.EntityName); } } }