Files
necromants-tome-7d2d-3-2/HarmonySrc/NecromancerBloodPatch.cs
T
AlexCubeandClaude Code 16639652b2 Forbid crafting necromancer's blood at workstations (#88)
Station crafts bypass XUiC_RecipeStack entirely - the queue lives in
TileEntityWorkstation and HandleRecipeQueue completes it server-side -
so neither the knife gate nor the 90% max-HP cost ever fired there.
Veto the recipe in XUiC_WorkstationWindowGroup.CraftingRequirementsValid
(greys the entry, disables the Craft action, blocks AddItemToQueue) and
supply the tooltip via CraftingRequirementsInvalidMessage. Personal
inventory crafting uses the base class and is unaffected. New
localization key resourceNecromancerBloodStationForbidden (13 languages).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-02 23:24:44 +03:00

293 lines
15 KiB
C#

using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml
/// (resourceNecromancerBlood) for the item and recipes.xml for the base recipe (an empty jar,
/// like any other resource conversion).
///
/// IT LIVES IN items.xml, AND THAT IS NOT AN ACCIDENT. On 2026-09-15 it was moved into
/// item_modifiers.xml so it could be installed in the Spatial Bracelet, and that move
/// DESTROYED a character in a save: ItemValue.Read/Write gate the modification block on
/// !(itemClass is ItemClassModifier), so the item's CLASS decides the byte layout of every
/// stack of it in the save, and an existing save read one byte off from the first blood stack
/// onward. It was rolled back the same day, the bracelet's charge became a brand-new item
/// (resourceBloodSphere, and later resourceBloodStone) instead, and the rule stands: an item
/// that could already be in someone's inventory must not change class in either direction.
/// The full account is in BACKLOG.md; the earlier wording of this comment claimed the modifier
/// home as current and outlived the code by a day. Two rules the user asked for have NO vanilla XML
/// equivalent at all, so both are enforced here instead:
/// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or
/// backpack) to craft this, but is NOT consumed. recipes.xml has no "required but not
/// consumed" ingredient flag (confirmed - only `craft_tool="itemName"` exists for
/// something adjacent, but it takes exactly ONE item name, not "any item of a category",
/// and its actual runtime enforcement point wasn't confirmed by decompilation either -
/// not risking an untested mechanism for this).
/// 2. "При крафте нужно отнимать у персонажа 90% имеющегося ХП" (2026-08-30), corrected
/// by the user on 2026-11-02: reading CURRENT health there was a bug. The cost is 90%
/// of the player's MAXIMUM health, and a necromancer whose current health is below
/// that dies from the ritual. Recipes have no cost hook beyond their ingredient list
/// at all.
///
/// "любого ножа" (ANY knife) is detected via ItemClass.DisplayType == "meleeKnife" - decompiled
/// Data/Config/items.xml directly: every real vanilla knife (meleeWpnBladeT0BoneKnife,
/// meleeWpnBladeT1HuntingKnife, even meleeWpnBladeT3Machete) shares this exact DisplayType,
/// which is how the game itself categorizes "the knife family" in its own UI - a single,
/// reliable check instead of hand-maintaining a list of item names. necroWpnBladeNecroKnife
/// (Extends meleeWpnBladeT0BoneKnife, never overrides DisplayType) is covered by the same
/// check automatically.
///
/// PATCH POINTS - the first two on XUiC_RecipeStack, decompiled directly (not guessed):
/// - SetRecipe(...) Prefix: the earliest confirmed point a "craft this recipe" click reaches
/// (XUiC_CraftingQueue.AddRecipeToCraftAtIndex calls straight into this). Blocking here
/// (return false) stops the craft from ever starting - recipe/recipeCount never get set,
/// isCrafting never becomes true.
/// - outputStack() Prefix+Postfix (via __state): outputStack() is where the output item is
/// actually granted, once per queued unit - HP is only deducted when __result is true,
/// i.e. the item genuinely was produced this call, not on a failed/blocked attempt.
/// - XUiC_WorkstationWindowGroup.CraftingRequirementsValid / InvalidMessage, Postfix
/// (2026-11-02): station crafting bypasses both points above entirely, so the blood
/// recipe is now vetoed at every workstation window. Details at the patch classes
/// near the bottom of this file.
///
/// CAVEAT - not glossed over: decompiling XUiC_RecipeStack/XUiC_CraftingQueue/XUiM_Recipes did
/// NOT turn up the exact line that removes ingredients from the player's inventory (it happens
/// somewhere upstream of SetRecipe, in whatever UI code handles the actual "Craft" button click
/// - not found within reasonable search). XUiC_RecipeStack.HandleOnPress (the CANCEL button)
/// refunds ingredients, which proves they're already gone by the time SetRecipe runs - so if
/// SetRecipe's Prefix blocks a no-knife attempt, the jar may already be spent with nothing
/// granted back. Blocking at the earliest CONFIRMED point was judged better than not blocking
/// at all; a lost jar on a rare misclick is a minor rough edge, not a correctness bug. Revisit
/// if this turns out to happen often in practice.
/// </summary>
public static class NecromancerBloodPatch
{
public const string BloodItemName = "resourceNecromancerBlood";
public const float HealthCostFraction = 0.9f;
/// <summary>Damage above which the player's BIG pain grunt is used instead of the small
/// one. Not invented: it is vanilla's own threshold, read out of
/// EntityPlayer.GetSoundHurt(DamageSource, int) - the override that every spike and every
/// strand of barbed wire goes through. Its IL is
///
/// ldarg.2 // _damageStrength
/// ldc.i4.s 15
/// bgt.s -> GetSoundHurt() // strictly MORE than 15 -> soundHurt (…painlg)
/// call GetSoundHurtSmall() // 15 or less -> soundHurtSmall (…painsm)
///
/// so the comparison is strictly greater-than, and 15 exactly still counts as small. The
/// same method has an earlier branch for damage type 16 that returns GetSoundDrownPain();
/// that one is the drowning case and has nothing to do with us.</summary>
public const int PainSoundBigDamage = 15;
/// <summary>Plays the player's own pain grunt, picking the big or the small one by the
/// same rule vanilla uses for spikes and barbed wire (user request 2026-09-16: "в игре
/// есть звук боли (когда персонаж напарывается на колья или на колючую проволоку). Пусть
/// этот звук воспроизводится при создании крови некроманта").
///
/// WHY THE SOUND NAME IS ASKED FOR AND NOT SPELLED OUT. The clip differs by gender -
/// playerMale carries SoundHurt="player1painlg"/SoundHurtSmall="player1painsm" and
/// playerFemale overrides both to player2pain* (Data/Config/entityclasses.xml). Hardcoding
/// "player1painlg" would have given every female character a male grunt. GetSoundHurt() and
/// GetSoundHurtSmall() are public on EntityAlive and are plain field reads (verified: each
/// one's whole body is "ldarg.0; ldfld soundHurt|soundHurtSmall; ret"), so they return
/// whatever this entity's own class declared and cost nothing.
///
/// WHY NOT GetSoundHurt(DamageSource, int), which would pick for us: it needs a
/// DamageSource, and this is not damage from a source - the HP here is spent by AddHealth,
/// deliberately (see the comment at the call site). Its selection rule is three lines, so
/// it is reproduced instead of faked with a synthetic DamageSource.
///
/// The null guard is vanilla's too: EntityAlive.OnUpdateEntity stores the result and skips
/// the call on null (brfalse right after the stloc) rather than handing PlayOneShot a null
/// clip name. The fallback to the big grunt covers an entity that declares SoundHurt but
/// not SoundHurtSmall - again exactly what vanilla's override does when
/// GetSoundHurtSmall() comes back empty.
///
/// PlayOneShot(name) with no further arguments is byte-for-byte what vanilla passes here:
/// its optional parameters default to sound_in_head:false, serverSignalOnly:false,
/// isUnique:false, _animEvent:null, volumeScale:1f, and OnUpdateEntity's own call pushes
/// exactly those five constants. So the grunt comes out of the character, not "in the
/// head", same as being spiked.</summary>
public static void PlayPainSound(EntityPlayerLocal _player, int _damage)
{
if (_player == null)
{
return;
}
string clip = _damage > PainSoundBigDamage
? _player.GetSoundHurt()
: _player.GetSoundHurtSmall();
if (string.IsNullOrEmpty(clip))
{
clip = _player.GetSoundHurt();
}
if (string.IsNullOrEmpty(clip))
{
return;
}
_player.PlayOneShot(clip);
}
public static bool HasAnyKnife(EntityPlayerLocal player)
{
return ContainsKnife(player.inventory?.ItemGrid) || ContainsKnife(player.bag?.ItemGrid);
}
// В 3.3 у пояса и рюкзака нет GetSlots: предметы лежат в ItemStackGrid.
private static bool ContainsKnife(ItemStackGrid grid)
{
if (grid == null)
{
return false;
}
for (int i = 0; i < grid.Length; i++)
{
ItemStack stack = grid.GetItem(i);
if (stack == null || stack.IsEmpty())
{
continue;
}
if (stack.itemValue?.ItemClass?.DisplayType == "meleeKnife")
{
return true;
}
}
return false;
}
}
[HarmonyPatch(typeof(XUiC_RecipeStack), "SetRecipe")]
public static class Patch_XUiC_RecipeStack_SetRecipe_NecromancerBlood
{
public static bool Prefix(XUiC_RecipeStack __instance, Recipe _recipe, bool recipeModification)
{
// recipeModification covers the "clear this slot" calls (ClearQueue/RefreshQueue/
// cancel) - never block those, only an actual attempt to start crafting our recipe.
if (recipeModification || _recipe == null || _recipe.GetName() != NecromancerBloodPatch.BloodItemName)
{
return true;
}
EntityPlayerLocal player = __instance.xui?.playerUI?.entityPlayer;
if (player == null)
{
return true;
}
if (!NecromancerBloodPatch.HasAnyKnife(player))
{
GameManager.ShowTooltip(player, "resourceNecromancerBloodNeedsKnife");
Debug.Log("[NecromancerTome] NecromancerBloodPatch: blocked craft (no knife present) for owner=" + player.entityId);
return false;
}
return true;
}
}
[HarmonyPatch(typeof(XUiC_RecipeStack), "outputStack")]
public static class Patch_XUiC_RecipeStack_outputStack_NecromancerBlood
{
public static void Prefix(XUiC_RecipeStack __instance, out EntityPlayerLocal __state)
{
__state = null;
if (__instance.recipe != null && __instance.recipe.GetName() == NecromancerBloodPatch.BloodItemName)
{
__state = __instance.xui?.playerUI?.entityPlayer;
}
}
public static void Postfix(bool __result, EntityPlayerLocal __state)
{
if (!__result || __state == null)
{
return;
}
// 90% of MAXIMUM health, per the user's correction of 2026-11-02 ("Это баг. Должно
// быть 90% от максимального здоровья. Таким образом, некромант погибнет, если
// здоровья в момент изготовления меньше 90%"). The original 2026-08-30 code read
// the CURRENT health, so a queue of jars got geometrically cheaper (100% -> 10% ->
// 1% of the starting HP) and could only kill at 1-4 HP through rounding.
//
// HOW DEATH HAPPENS, from the 3.4 decompile (don't "fix" this into a direct Kill
// call): Stat.Value's setter clamps to [0, ModifiedMax] (Utils.FastClamp), so HP
// bottoms out at exactly 0, never negative - and EntityAlive.OnUpdateLiving's own
// check `Health <= 0 && !IsDead() && !isEntityRemote && !IsGodMode.Value` kills the
// entity with DamageEntity(DamageSource.disease, 1) on the next update. A player
// who could not pay the full cost thus dies by vanilla's hand one frame later,
// logged as disease (god mode skips that check, leaving them merely at 0 HP).
// Survival takes strictly MORE than the cost: paying exactly it ends at 0 HP, and
// 0 HP is death in vanilla (the check is <= 0, not < 0). AddHealth stays the safe,
// non-combat HP-modification API vanilla itself uses - not DamageEntity/
// DamageResponse, since this isn't damage from a source, it's a direct self-cost.
int amount = Mathf.RoundToInt(__state.GetMaxHealth() * NecromancerBloodPatch.HealthCostFraction);
if (amount <= 0)
{
return;
}
__state.AddHealth(-amount);
// The grunt goes AFTER the HP is actually gone, so a craft that somehow bailed out
// above never makes a sound the player cannot account for. With the cost now a fixed
// 0.9 * MaxHealth this is the big pain clip in every normal case (amount > 15 whenever
// MaxHealth >= 18), and drops to the small one exactly where vanilla would drop it too.
NecromancerBloodPatch.PlayPainSound(__state, amount);
Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId);
}
}
// 2026-11-02, the user found station crafting bypassing everything above: "Кровь
// некроманта можно изготовить на верстаке, но тогда у некроманта не отнимается
// здоровье... запретить изготавливать кровь некроманта на верстаке. Только в
// собственном инвентаре."
//
// Root cause (3.4 decompile): a workstation never uses XUiC_RecipeStack. The queue
// lives in TileEntityWorkstation.RecipeQueueItem[] and HandleRecipeQueue completes
// crafts server-side into the station's own output slot - so both the knife gate
// (SetRecipe) and the HP cost (outputStack) are silently skipped there. XML cannot
// exclude this one recipe either: XUiM_Recipes.FilterRecipesByWorkstation shows
// no-craft_area recipes at every station whose block lists "player" in
// Workstation/CraftingAreaRecipes, and the recipe must stay craft_area-free to
// remain craftable in the personal window at all.
//
// The veto therefore hangs on the one gate every station window shares:
// XUiC_WorkstationWindowGroup (workbench, campfire, forge, chemistry station, cement
// mixer - every station xui group subclasses it). ItemActionEntryCraft disables the
// Craft entry AND refuses AddItemToQueue through CraftingRequirementsValid,
// XUiC_RecipeList greys the recipe name through the same call, and
// CraftingRequirementsInvalidMessage supplies the tooltip - exactly the vanilla path
// for a missing tool. The personal inventory window uses the BASE
// XUiC_CraftingWindowGroup, which these patches do not touch, so "только в
// собственном инвентаре" holds.
[HarmonyPatch(typeof(XUiC_WorkstationWindowGroup), "CraftingRequirementsValid")]
public static class Patch_XUiC_WorkstationWindowGroup_Valid_NecromancerBlood
{
// Postfix, not Prefix: vanilla's tool/input/output/fuel checks still run for
// every other recipe; we only veto ours afterwards. No console toggle: this
// closes a bypass of two core item rules, which have none either.
public static void Postfix(Recipe _recipe, ref bool __result)
{
if (_recipe != null && _recipe.GetName() == NecromancerBloodPatch.BloodItemName)
{
__result = false;
}
}
}
[HarmonyPatch(typeof(XUiC_WorkstationWindowGroup), "CraftingRequirementsInvalidMessage")]
public static class Patch_XUiC_WorkstationWindowGroup_InvalidMessage_NecromancerBlood
{
public static void Postfix(Recipe _recipe, ref string __result)
{
if (_recipe != null && _recipe.GetName() == NecromancerBloodPatch.BloodItemName)
{
__result = Localization.Get("resourceNecromancerBloodStationForbidden");
}
}
}
}