using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Счёт убийств для скилла "Некромантия" (user report 2026-09-16: "Почему-то наш скилл /// некроманта не всегда засчитывает убийство зомби... если робомолот убьёт зомбака, то игрок /// получает за это опыт. Если зомби умрёт от кровотечения, которое навесил игрок, то игрок /// получит опыт. У нас скилл некроманта в этих случаях не прибавляется. Это баг."). /// /// WHAT WAS BROKEN, AND IT WAS TWO SEPARATE THINGS. /// /// Until this patch the whole count was four lines of XML appended to ONE entity class in /// Config/entityclasses.xml: /// /// <append xpath="/entity_classes/entity_class[@name='zombieTemplateMale']"> /// <requirement name="EntityTagCompare" target="other" tags="player"/> /// <triggered_effect trigger="onOtherKilledSelf" action="ModifyCVar" target="other" .../> /// <triggered_effect trigger="onOtherKilledSelf" action="AddProgressionLevel" target="other" .../> /// /// 1. ONE CLASS IS NOT EVERY ZOMBIE. Humanoids were fine - effect_group DOES inherit through /// extends on entity_class (unlike items.xml, see progression.xml's header), and every /// zombie template chains back to zombieTemplateMale. But the five zombie ANIMALS inherit /// the animal branch and never reach it: /// animalZombieBear extends animalBear, animalZombieBoar extends animalBoar, /// animalZombieDog extends animalWolf, animalZombieVulture extends animalTemplateHostile, /// animalZombieVultureRadiated extends animalZombieVulture /// Killing a zombie dog, bear, boar or vulture counted for nothing at all. Zombie vultures /// are everywhere on roads, which is most of what "не всегда засчитывает" was. /// /// 2. target="other" IS THE LITERAL KILLER, NOT THE PLAYER WHO EARNED IT. With /// trigger="onOtherKilledSelf" plus a requirement that "other" be tagged player, anything /// that kills on the player's behalf fails the requirement: a robotic sledge (the turret is /// "other"), a bleed the player applied (no direct killer at the moment of death), a summoned /// pet (the pet is "other"). Vanilla still awards XP in all of these because it does NOT use /// the literal killer - it resolves the crediting player from the DamageSource, in /// EntityAlive.AwardKillXPServer(DamageSource, EntityAlive), whose body reads BuffClass /// (DoT damage) and a dedicated bTrapKillXP flag (trap kills) before calling AddKillXP. /// /// BOTH EFFECTS SHARED ONE REQUIREMENT, so every missed kill also failed to raise /// necroZombieKillsCVar - and that CVar is the Necromancer's Knife's damage (items.xml: "Damage /// = necroZombieKillsCVar / 10", recomputed continuously in buffs.xml). The bug was quietly /// underpowering the knife too, which is why the fix keeps both effects together. /// /// WHY THIS HOOK AND NOT A WIDER XML PATCH. Adding the five animal classes by XML would have /// fixed cause 1 and left cause 2 untouched. EntityPlayer.AddKillXP is the single point where /// vanilla has ALREADY decided which player gets the kill - it is called from exactly one place /// in the whole assembly, AwardKillXPServer, after all the DamageSource resolution is done. /// Hooking it means our count agrees with the XP number the player sees on screen by /// construction, for every case vanilla handles, including ones nobody has thought of yet. /// Verified by metadata scan: AwardKillXPServer is the only caller of AddKillXP. /// /// THE XML TRIGGERS ARE GONE, NOT LEFT ALONGSIDE. Config/entityclasses.xml no longer carries /// the effect_group - if it stayed, a kill by the player's own hand would satisfy both it and /// this patch and count TWICE. That was the one trap of moving the count into code, and it is /// the first thing to check if levels ever start rising two at a time. /// /// PETS ARE NOT GUARANTEED BY THIS PATCH. The user also asked that summoned creatures count. /// They will count if and only if vanilla itself credits the owner for a pet kill - this patch /// follows vanilla's decision, it does not make it. Whether it does is NOT verified and is the /// specific thing to watch for in game; if pets turn out not to be credited, that is a separate /// piece of work (giving the pet's DamageSource an owner), not a bug in this file. /// [HarmonyPatch(typeof(EntityPlayer), "AddKillXP")] public static class Patch_EntityPlayer_AddKillXP_NecromancyCount { public const string NecromancySkillName = "craftingNecroNecromancy"; public const string KillsCVarName = "necroZombieKillsCVar"; /// The tag every zombie carries, humanoid and animal alike. Checked against the /// real data rather than assumed: zombieBiker/zombieArlene/zombieBoe/zombieSpider all /// declare "entity,zombie,..." and the five zombie animals declare /// "entity,animal,zombie,zombieAnimal,...". Note that entity Tags do NOT inherit through /// extends (entityclasses.xml says so in a comment right on the property), which is exactly /// why this works: every concrete, spawnable zombie spells its own tags out, and the bare /// templates that do not are never spawned. /// /// A tag test also ages better than the class list it replaces: any zombie added by a /// future game version or another mod counts the moment it calls itself a zombie. private static readonly FastTags ZombieTag = FastTags.Parse("zombie"); public static void Postfix(EntityPlayer __instance, EntityAlive killedEntity) { if (__instance == null || killedEntity == null) { return; } if (!killedEntity.HasAnyTags(ZombieTag)) { return; } AddKillsCVar(__instance); AddNecromancyLevel(__instance); } /// necroZombieKillsCVar += 1 - the same thing the removed ModifyCVar action did, /// and the reason it is here rather than left in XML is that it shared the broken /// requirement with the progression effect. GetCVar/SetCVar are public on EntityAlive and /// are the same storage the buffs.xml formula reads. private static void AddKillsCVar(EntityPlayer _player) { _player.SetCVar(KillsCVarName, _player.GetCVar(KillsCVarName) + 1f); } /// +1 level of Necromancy, replicating MinEventActionAddProgressionLevel.Execute /// step for step rather than inventing a shorter version of it - its IL was read for this: /// GetProgressionValue, Level + amount, clamp to ProgressionClass.MaxLevel, then (for a /// crafting skill) the level-up toast and HandleCheckCrafting, then the two dirty flags. /// /// HandleCheckCrafting is the part that would be easy to drop and expensive to miss: it is /// what the game calls on a crafting-skill level change, and skipping it risks recipes not /// noticing they became available. Both it and AddCraftingSkillNotification are public. /// /// The clamp matters for a different reason than it looks: max_level is 5000, and without /// the clamp Level would keep climbing past it forever, because nothing else limits it. private static void AddNecromancyLevel(EntityPlayer _player) { Progression progression = _player.Progression; if (progression == null) { return; } ProgressionValue pv = progression.GetProgressionValue(NecromancySkillName); if (pv == null || pv.ProgressionClass == null) { // Not a crash, and not silent either: this means the skill did not load, which is a // config problem worth seeing once in the log rather than a reason to throw inside // a kill handler. Debug.LogWarning("[NecromancerTome] NecromancyKillCredit: progression '" + NecromancySkillName + "' not found - kill not counted"); return; } int oldLevel = pv.Level; int maxLevel = pv.ProgressionClass.MaxLevel; int newLevel = oldLevel + 1; if (newLevel > maxLevel) { newLevel = maxLevel; } if (newLevel == oldLevel) { // Already at 5000. The CVar above still went up on purpose - the knife's damage is // not capped by the skill's max_level, and the player who is past the cap should // keep getting stronger knives. return; } pv.Level = newLevel; EntityPlayerLocal local = _player as EntityPlayerLocal; if (pv.ProgressionClass.IsCrafting && local != null) { // true = add the notification only if one is not already up, so a horde night does // not stack a fresh toast per corpse. local.PlayerUI?.xui?.CollectedItemList?.AddCraftingSkillNotification(pv, true); pv.ProgressionClass.HandleCheckCrafting(local, oldLevel, newLevel); } // isEntityRemote guards these in vanilla too: a remote player's stats are the server's // business, and marking them dirty here would be claiming an authority we do not have. if (!_player.isEntityRemote) { progression.bProgressionStatsChanged = true; _player.bPlayerStatsChanged = true; } } } }