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. /// /// /// ============================================================================================ /// ШКАЛА ПЕРЕДЕЛАНА 2026-09-17: 20 УБИЙСТВ = 1 УРОВЕНЬ, И УРОВЕНЬ БОЛЬШЕ НЕ ХРАНИТСЯ /// ============================================================================================ /// /// Баг, найденный на стриме: "Рецепты отображались в скилле серым и с замком, хотя при этом /// должен был бы быть доступным" - при 250+ убитых зомби Слёзы мертвеца (30) были открыты, а /// Пир падальщика (60) стоял под замком. /// /// ПРИЧИНА - ВАНИЛЬНАЯ СЕРИАЛИЗАЦИЯ, А НЕ НАША РАСКЛАДКА. ProgressionValue пишет и читает /// уровень ОДНИМ БАЙТОМ: /// /// public void Write(BinaryWriter _writer, bool _IsNetwork) { ... _writer.Write((byte)level); ... } /// public void Read(BinaryReader _reader) { ... level = _reader.ReadByte(); ... } /// /// Всё выше 255 при сохранении обрезается по модулю 256. Подтверждено не только декомпиляцией, /// но и на живом сейве пользователя (New Xisema Mountains/sezon8, 17.09.2026): в файле игрока /// necroZombieKillsCVar = 384, а уровень craftingNecroNecromancy = 129, то есть ровно 384-256. /// Со старой шкалой "одно убийство - один уровень" (max_level 5000) это означало, что уровень /// откатывался назад на каждом переходе через 256, панель скилла заново вешала замки на уже /// открытые рецепты, а группы 500/2000/3000/5000 были недостижимы в принципе. В ванили предел /// не всплывает: атрибуты идут до 10, перки до 5, крафтовые скиллы до 100. /// /// РЕШЕНИЕ (продиктовано пользователем): "пусть уровень навыка будет 1/20 от количества убитых /// зомби", то есть 20 убийств = 1 уровень, максимум 250 - влезает в байт с запасом. Чинится /// сама шкала, а не сериализация поверх неё. /// /// ЕДИНСТВЕННЫЙ ИСТОЧНИК ПРАВДЫ - necroZombieKillsCVar. Это float, он сохраняется честно (те /// самые 384 в сейве) и переполнению не подвержен. Уровень из него ВЫЧИСЛЯЕТСЯ, а не /// накапливается: и на каждом убийстве (ниже), и при загрузке игрока /// (Patch_PlayerDataFile_ToPlayer_NecromancyLevel). Второе важнее, чем кажется: оно чинит уже /// испорченные сейвы без ручного вмешательства - тот же sezon8 при первой же загрузке получит /// уровень 19 вместо сломанных 129. Именно поэтому здесь не "+1 к уровню", а "уровень = /// убийства / 20": прибавка к испорченному значению оставила бы его испорченным навсегда. /// /// ДВА ИНДИКАТОРА (указание пользователя от 2026-09-17). Оба значения пишутся здесь же, в /// CVar'ы, а рисуются данными: /// necroNecromancyLevelCVar - уровень Некромантии, показывает бафф с черепом /// (buffs.xml, buffNecroZombieKillTrackerDisplay). /// necroNecromancyProgressCVar - сколько зомби упокоено внутри текущего уровня, 0..19. /// Это фиолетовая шкала в HUD рядом с полосой опыта /// (Config/XUi_InGame/windows.xml), она заполняется каждые /// 20 зомби и обнуляется вместе с повышением уровня. /// Оба пишутся ВСЕГДА, в том числе когда уровень не изменился - иначе шкала стояла бы на /// месте девятнадцать убийств подряд и дёргалась раз в двадцатое. /// [HarmonyPatch(typeof(EntityPlayer), "AddKillXP")] public static class Patch_EntityPlayer_AddKillXP_NecromancyCount { public const string NecromancySkillName = "craftingNecroNecromancy"; public const string KillsCVarName = "necroZombieKillsCVar"; /// Сколько упокоенных зомби стоит один уровень Некромантии. Менять это число в /// одиночку НЕЛЬЗЯ: на нём завязаны и max_level="250" скилла, и все пороги /// RecipeTagUnlocked/unlock_level в Config/progression.xml (они записаны в уровнях), и /// делитель фиолетовой шкалы в Config/XUi_InGame/windows.xml. Двадцатка выбрана не на /// глаз: 5000 убийств / 20 = 250 уровней, а 250 - это максимум, который переживает /// однобайтовую сериализацию уровня (см. большой комментарий выше). public const int KillsPerLevel = 20; /// Значения для двух индикаторов. Держатся в CVar'ах игрока, а не вычисляются в /// XML, по двум причинам: (1) уровень обязан совпадать с ProgressionValue.Level бит в бит, /// иначе череп и панель скилла разойдутся; (2) деление в ModifyCVar дало бы дробь (19.2), а /// display_value показывает значение как есть. public const string LevelCVarName = "necroNecromancyLevelCVar"; public const string ProgressCVarName = "necroNecromancyProgressCVar"; /// 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; } float kills = AddKillsCVar(__instance); SyncNecromancyLevel(__instance, kills); } /// 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 float AddKillsCVar(EntityPlayer _player) { float kills = _player.GetCVar(KillsCVarName) + 1f; _player.SetCVar(KillsCVarName, kills); return kills; } /// Приводит уровень Некромантии и оба индикатора в соответствие числу убийств. /// Идемпотентна: вызывай сколько угодно раз, результат зависит только от _kills. /// /// Тело повторяет MinEventActionAddProgressionLevel.Execute шаг в шаг (его IL для этого /// читался): GetProgressionValue, новое значение, кламп по ProgressionClass.MaxLevel, /// затем - для крафтового скилла - тост о повышении и HandleCheckCrafting, затем два /// флага "изменилось". /// /// HandleCheckCrafting - та часть, которую легко выкинуть и дорого не заметить: именно её /// игра зовёт при смене уровня крафтового скилла, и без неё рецепты рискуют не заметить, /// что стали доступны. И она, и AddCraftingSkillNotification публичные. public static void SyncNecromancyLevel(EntityPlayer _player, float _kills) { 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 kills = (int)_kills; if (kills < 0) { kills = 0; } int maxLevel = pv.ProgressionClass.MaxLevel; int newLevel = kills / KillsPerLevel; int progressInLevel = kills - newLevel * KillsPerLevel; if (newLevel >= maxLevel) { // На потолке шкала остаётся залитой доверху, а не сбрасывается в ноль: уровней // больше не будет, и пустая полоса читалась бы как "вот-вот повысишься". newLevel = maxLevel; progressInLevel = KillsPerLevel; } // Оба индикатора обновляются независимо от того, сменился уровень или нет - шкала // должна ползти на каждом убийстве. SetCVarSafe(_player, LevelCVarName, newLevel); SetCVarSafe(_player, ProgressCVarName, progressInLevel); int oldLevel = pv.Level; if (newLevel == oldLevel) { return; } pv.Level = newLevel; EntityPlayerLocal local = _player as EntityPlayerLocal; if (pv.ProgressionClass.IsCrafting && local != null) { if (newLevel > oldLevel) { // true = add the notification only if one is not already up, so a horde night // does not stack a fresh toast per corpse. Только на РОСТЕ уровня: при // загрузке испорченного сейва уровень может поехать вниз (129 -> 19), и // поздравлять с этим игрока не за что. 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; } } /// SetCVar идёт через EntityBuffs, а он на момент загрузки игрока может быть ещё /// не создан - в ToPlayer буфы читаются отдельным блоком и только если они в файле есть. /// Ронять из-за индикатора загрузку персонажа нельзя, поэтому проверка явная. private static void SetCVarSafe(EntityPlayer _player, string _name, float _value) { if (_player.Buffs == null) { return; } _player.SetCVar(_name, _value); } } /// /// Пересчёт уровня Некромантии при загрузке игрока - вторая половина фикса однобайтового /// уровня (см. большой комментарий в Patch_EntityPlayer_AddKillXP_NecromancyCount). /// /// ПОЧЕМУ ИМЕННО PlayerDataFile.ToPlayer И ИМЕННО POSTFIX. Уровень восстанавливается из /// necroZombieKillsCVar, а CVar'ы лежат в EntityBuffs. В теле ToPlayer порядок жёсткий: /// сначала Progression.Read, следом Buffs.Read. Postfix - единственная точка, где уже готовы /// ОБА, и заодно это уже проверенный в этом моде хук: на том же методе висит /// SpatialVaultPersistence (две разные заплатки на один метод Harmony складывает без /// конфликта). /// /// ЧТО ЭТО ДАЁТ. Сейв, испорченный старой шкалой, чинится сам при первом входе: было 384 /// убийства и уровень 129 - станет уровень 19 и все четыре мода ножа снова открыты. Ручных /// команд, сброса скилла или новой игры не требуется. Проверено на двух реальных сейвах /// пользователя (17.09): sezon8 - 384 убийства при уровне 129, test8 - 303 при уровне 48. /// Ни в одном из них счётчик убийств не пострадал, потому что он float и переполняться ему /// нечем; портился только уровень. /// /// СТАРЫЙ СЕЙВ НИКОГДА НЕ ТЕРЯЕТ ОТКРЫТОЕ. В прежней шкале уровень был равен числу убийств /// (с поправкой на переполнение), то есть уровень ВСЕГДА был не больше счётчика. Пересчёт из /// счётчика поэтому может только вернуть украденное переполнением, но не отнять: тот же test8 /// на 303 убийствах получает Тёмное чутьё (порог 300), которое сломанный уровень 48 держал /// под замком. /// /// ЕДИНСТВЕННЫЙ СЛУЧАЙ, КОГДА ПЕРЕСЧЁТ МОГ БЫ НАВРЕДИТЬ, - счётчик пуст, а уровень есть. /// Тогда "уровень = убийства / 20" дало бы ноль и стёрло прогресс. Живьём такого сейва не /// видели (счётчик и уровень всегда росли одной и той же строкой кода, а CVar'ы при смерти не /// чистятся - в EntityBuffs нет ни одного сброса словаря CVars), но цена ошибки тут - чужой /// прогресс, поэтому случай обработан явно: счётчик восстанавливается из старого уровня по /// прежнему правилу "1 убийство = 1 уровень" и дальше всё идёт обычным путём. Оценка выйдет /// заниженной (переполнение из уровня уже не вытащить), но это лучше, чем ноль. /// [HarmonyPatch(typeof(PlayerDataFile), "ToPlayer")] public static class Patch_PlayerDataFile_ToPlayer_NecromancyLevel { public static void Postfix(EntityPlayer _player) { if (_player == null || _player.Buffs == null) { return; } float kills = _player.GetCVar(Patch_EntityPlayer_AddKillXP_NecromancyCount.KillsCVarName); ProgressionValue pv = _player.Progression != null ? _player.Progression.GetProgressionValue(Patch_EntityPlayer_AddKillXP_NecromancyCount.NecromancySkillName) : null; int oldLevel = pv != null ? pv.Level : 0; if (kills < 1f && oldLevel > 0) { kills = oldLevel; _player.SetCVar(Patch_EntityPlayer_AddKillXP_NecromancyCount.KillsCVarName, kills); Debug.LogWarning("[NecromancerTome] NecromancyLevel: счётчик убийств пуст при уровне " + oldLevel + " - восстановлен из уровня по старой шкале"); } Patch_EntityPlayer_AddKillXP_NecromancyCount.SyncNecromancyLevel(_player, kills); // Одна строка в лог на загрузку игрока - по ней видно, что конверсия старого сейва // произошла и во что именно (вопрос пользователя 2026-09-17: "не сломают ли новые // правки старые сейвы"). int newLevel = pv != null ? pv.Level : 0; if (newLevel != oldLevel) { Debug.Log("[NecromancerTome] NecromancyLevel: уровень пересчитан из счётчика убийств " + (int)kills + ": было " + oldLevel + ", стало " + newLevel); } } } }