Files
necromants-tome-7d2d-3-2/HarmonySrc/NecromancyKillCreditPatch.cs
T
AlexCubeandClaude Opus 5 ed5fea5192 1.3.0: шкала 20 убийств за уровень, два индикатора, мир между подчинёнными
Уровень навыка игра хранит ОДНИМ БАЙТОМ (ProgressionValue.Write/Read), поэтому
старая шкала "одно убийство - один уровень" при max_level=5000 на каждом
сохранении откатывала уровень на 256 назад. Снаружи это выглядело как
самопроизвольно закрывающиеся рецепты (жалоба со стрима: Слёзы мертвеца открыты,
Пир падальщика под замком при 250+ убитых), а тиры 500/2000/3000/5000 были
недостижимы в принципе. Подтверждено на двух живых сейвах: sezon8 - 384 убийства
при уровне 129, test8 - 303 при уровне 48.

Шкала переведена на 20 убийств = 1 уровень, max_level=250 - влезает в байт с
запасом. Уровень больше не накапливается, а вычисляется из necroZombieKillsCVar
(float, сохраняется честно) и на каждом убийстве, и постфиксом на
PlayerDataFile.ToPlayer - последнее чинит старые сейвы само, без команд и новой
игры. Открытое при этом не теряется: в старой шкале уровень всегда был не больше
счётчика, так что пересчёт может только вернуть украденное переполнением.
Отдельно закрыт случай "счётчик пуст, а уровень есть" - счётчик восстанавливается
из уровня по старой шкале.

Все пороги пересчитаны в уровни, числа убийств не тронуты, кроме воды: 30 на
сетку шагом 20 не ложится, по указанию пользователя мод переехал на 20 - туда же,
где браслет и Кровавая сфера. Тег necroNecromancyLvl30 удалён. Сходимость всех 18
рецептов (тег -> RecipeTagUnlocked -> unlock_tier) проверена скриптом.

Два индикатора: череп в статус-баре показывает уровень (раньше - общее число
убийств), фиолетовая шкала над полосой опыта - продвижение внутри уровня, 0..20.
Шкала сделана вёрсткой, а не баффом (бафф умеет число, но не полосу), заполнение
привязано через XUi-выражение cvar(). Закрывающая скобка у выражения - одна "}",
а не "%}": лишний "%" NCalc читает как остаток от деления и ждёт правый операнд,
на чём первая проверка в игре и споткнулась.

Третья правка: подчинённые зомби больше не дерутся между собой. Девиация выдаёт
приказ "бей зомби", а подчинённый сам EntityZombie, и в targetClasses выражается
только тип. Постфикс на EAITarget.check вычёркивает подчинённого из кандидатов
(зомби выбирает следующего, настоящего врага), префикс на
EntityAlive.SetAttackTarget гасит цель на путях мимо выбора - прежде всего месть,
когда подчиняют уже дерущихся.

Тексты на 13 языках, README (RU+EN), описания для сайта и Nexus приведены к новой
шкале.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:24:25 +03:00

338 lines
22 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 HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Счёт убийств для скилла "Некромантия" (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:
///
/// &lt;append xpath="/entity_classes/entity_class[@name='zombieTemplateMale']"&gt;
/// &lt;requirement name="EntityTagCompare" target="other" tags="player"/&gt;
/// &lt;triggered_effect trigger="onOtherKilledSelf" action="ModifyCVar" target="other" .../&gt;
/// &lt;triggered_effect trigger="onOtherKilledSelf" action="AddProgressionLevel" target="other" .../&gt;
///
/// 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 зомби и обнуляется вместе с повышением уровня.
/// Оба пишутся ВСЕГДА, в том числе когда уровень не изменился - иначе шкала стояла бы на
/// месте девятнадцать убийств подряд и дёргалась раз в двадцатое.
/// </summary>
[HarmonyPatch(typeof(EntityPlayer), "AddKillXP")]
public static class Patch_EntityPlayer_AddKillXP_NecromancyCount
{
public const string NecromancySkillName = "craftingNecroNecromancy";
public const string KillsCVarName = "necroZombieKillsCVar";
/// <summary>Сколько упокоенных зомби стоит один уровень Некромантии. Менять это число в
/// одиночку НЕЛЬЗЯ: на нём завязаны и max_level="250" скилла, и все пороги
/// RecipeTagUnlocked/unlock_level в Config/progression.xml (они записаны в уровнях), и
/// делитель фиолетовой шкалы в Config/XUi_InGame/windows.xml. Двадцатка выбрана не на
/// глаз: 5000 убийств / 20 = 250 уровней, а 250 - это максимум, который переживает
/// однобайтовую сериализацию уровня (см. большой комментарий выше).</summary>
public const int KillsPerLevel = 20;
/// <summary>Значения для двух индикаторов. Держатся в CVar'ах игрока, а не вычисляются в
/// XML, по двум причинам: (1) уровень обязан совпадать с ProgressionValue.Level бит в бит,
/// иначе череп и панель скилла разойдутся; (2) деление в ModifyCVar дало бы дробь (19.2), а
/// display_value показывает значение как есть.</summary>
public const string LevelCVarName = "necroNecromancyLevelCVar";
public const string ProgressCVarName = "necroNecromancyProgressCVar";
/// <summary>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.</summary>
private static readonly FastTags<TagGroup.Global> ZombieTag =
FastTags<TagGroup.Global>.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);
}
/// <summary>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. Возвращает новое значение, чтобы
/// уровень считался ровно от него, а не от повторного чтения.</summary>
private static float AddKillsCVar(EntityPlayer _player)
{
float kills = _player.GetCVar(KillsCVarName) + 1f;
_player.SetCVar(KillsCVarName, kills);
return kills;
}
/// <summary>Приводит уровень Некромантии и оба индикатора в соответствие числу убийств.
/// Идемпотентна: вызывай сколько угодно раз, результат зависит только от _kills.
///
/// Тело повторяет MinEventActionAddProgressionLevel.Execute шаг в шаг (его IL для этого
/// читался): GetProgressionValue, новое значение, кламп по ProgressionClass.MaxLevel,
/// затем - для крафтового скилла - тост о повышении и HandleCheckCrafting, затем два
/// флага "изменилось".
///
/// HandleCheckCrafting - та часть, которую легко выкинуть и дорого не заметить: именно её
/// игра зовёт при смене уровня крафтового скилла, и без неё рецепты рискуют не заметить,
/// что стали доступны. И она, и AddCraftingSkillNotification публичные.</summary>
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;
}
}
/// <summary>SetCVar идёт через EntityBuffs, а он на момент загрузки игрока может быть ещё
/// не создан - в ToPlayer буфы читаются отдельным блоком и только если они в файле есть.
/// Ронять из-за индикатора загрузку персонажа нельзя, поэтому проверка явная.</summary>
private static void SetCVarSafe(EntityPlayer _player, string _name, float _value)
{
if (_player.Buffs == null)
{
return;
}
_player.SetCVar(_name, _value);
}
}
/// <summary>
/// Пересчёт уровня Некромантии при загрузке игрока - вторая половина фикса однобайтового
/// уровня (см. большой комментарий в 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 уровень" и дальше всё идёт обычным путём. Оценка выйдет
/// заниженной (переполнение из уровня уже не вытащить), но это лучше, чем ноль.
/// </summary>
[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);
}
}
}
}