Files
necromants-tome-7d2d-3-2/HarmonySrc/NecromancyKillCreditPatch.cs
T
AlexCubeandClaude Opus 5 229b436420 Счёт убийств Некромантии переехал из XML в код
Чинит баг: скилл не засчитывал часть убийств зомби. Причин было две, и обе
закрываются одним ходом.

ПРИЧИНА 1: ОДИН КЛАСС - НЕ ВСЕ ЗОМБИ. Счёт висел append-ом на zombieTemplateMale.
Человекоподобные покрыты (effect_group у entity_class наследуется через extends, и
все шаблоны зомби сходятся к Male), но пять зомби-ЗВЕРЕЙ наследуют животную ветку
и до него не доходят вовсе:

    animalZombieBear   extends animalBear
    animalZombieBoar   extends animalBoar
    animalZombieDog    extends animalWolf
    animalZombieVulture extends animalTemplateHostile
    animalZombieVultureRadiated extends animalZombieVulture

Убийство зомбопса, зомбомедведя, зомбокабана и зомбоворона не считалось никак.
Вороны попадаются постоянно - это и была бОльшая часть "не всегда засчитывает".

ПРИЧИНА 2: target="other" - БУКВАЛЬНЫЙ УБИЙЦА. Триггер onOtherKilledSelf с
требованием EntityTagCompare tags="player" начислял только за убийство своей
рукой. Робомолот, кровотечение и питомцы требование не проходят, хотя опыт игрок
за них получает: ваниль определяет получателя не по убийце, а по DamageSource - в
EntityAlive.AwardKillXPServer, где для этого есть обращение к BuffClass (DoT) и
отдельный флаг bTrapKillXP (ловушки).

Требование было ОДНО на оба эффекта, поэтому недосчитывался и
necroZombieKillsCVar, а это урон Ножа некроманта (items.xml: Damage = CVar / 10).
Баг тихо занижал ещё и нож.

РЕШЕНИЕ: Postfix на EntityPlayer.AddKillXP. Этот метод вызывается ровно из одного
места во всей сборке - из AwardKillXPServer, то есть уже ПОСЛЕ того, как ваниль
разобрала DamageSource и решила, чей это фраг (проверено сканированием IL).
Мы не повторяем её логику и не угадываем владельца турели или автора
кровотечения - забираем готовый ответ. Наш счёт совпадает с опытом на экране по
построению, включая случаи, о которых мы не подумали.

ФИЛЬТР ПО ТЕГУ zombie, А НЕ ПО КЛАССУ. Проверено по данным: zombieBiker,
zombieArlene, zombieBoe, zombieSpider несут "entity,zombie,...", зомби-звери -
"entity,animal,zombie,zombieAnimal,...". Тег есть у всех. Работает это благодаря
тому, что Tags у entity_class НЕ наследуется через extends: каждый реально
спавнящийся зомби выписывает теги сам, а безтеговые шаблоны не спавнятся. Тег
переживёт и новых зомби из патчей игры, и чужие моды.

Повышение уровня воспроизводит MinEventActionAddProgressionLevel.Execute шаг в
шаг по его IL: GetProgressionValue, Level+1, кламп по MaxLevel, для крафтового
скилла AddCraftingSkillNotification и HandleCheckCrafting, затем
bProgressionStatsChanged и bPlayerStatsChanged под !isEntityRemote.
HandleCheckCrafting легко выбросить и дорого потерять - без него рецепты рискуют
не заметить, что открылись. Уведомление с _bAddOnlyIfNotExisting=true, чтобы в
орду не всплывал тост на каждый труп.

Config/entityclasses.xml: append снят ЦЕЛИКОМ, на его месте комментарий, почему
возвращать нельзя - XML-триггер рядом с патчем засчитает убийство своей рукой
ДВАЖДЫ. Это единственная ловушка переезда.

Сборка: 0 ошибок (4 прежних MSB3277). Проверено рефлексией по собранной DLL:
атрибут нацелен верно, перегрузка AddKillXP ровно одна, имя параметра killedEntity
совпадает с ванильным (Harmony инжектит по имени - опечатка дала бы молчаливо
неработающий патч), PatchAll подхватывает файл сам.

НЕ ПРОВЕРЕНО В ИГРЕ. Отдельно: питомцы НЕ гарантированы - патч следует решению
ванили, а не принимает его. Если ваниль не зачисляет владельцу убийство
питомцем, не зачислит и он; это отдельная работа, а не ошибка здесь.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:21:11 +03:00

173 lines
9.1 KiB
C#

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.
/// </summary>
[HarmonyPatch(typeof(EntityPlayer), "AddKillXP")]
public static class Patch_EntityPlayer_AddKillXP_NecromancyCount
{
public const string NecromancySkillName = "craftingNecroNecromancy";
public const string KillsCVarName = "necroZombieKillsCVar";
/// <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;
}
AddKillsCVar(__instance);
AddNecromancyLevel(__instance);
}
/// <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 void AddKillsCVar(EntityPlayer _player)
{
_player.SetCVar(KillsCVarName, _player.GetCVar(KillsCVarName) + 1f);
}
/// <summary>+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.</summary>
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;
}
}
}
}