using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml /// (resourceNecromancerBlood) for the item, recipes.xml for the base recipe (an empty jar, /// like any other resource conversion). 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% имеющегося ХП" - crafting this recipe /// costs 90% of the player's CURRENT health. 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 - both 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. /// /// 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. /// public static class NecromancerBloodPatch { public const string BloodItemName = "resourceNecromancerBlood"; public const float HealthCostFraction = 0.9f; public static bool HasAnyKnife(EntityPlayerLocal player) { return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots()); } private static bool ContainsKnife(ItemStack[] slots) { if (slots == null) { return false; } foreach (ItemStack stack in slots) { 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; } // Deliberately not clamped to leave the player at least 1 HP - the user asked for a // straight 90% cost, and a blood ritual that can genuinely kill you if you're already // badly hurt fits the theme. AddHealth is the same safe, non-combat HP-modification // API vanilla itself uses (decompiled EntityAlive.AddHealth) - not DamageEntity/ // DamageResponse, since this isn't damage from a source, it's a direct self-cost. int amount = Mathf.RoundToInt(__state.Health * NecromancerBloodPatch.HealthCostFraction); if (amount <= 0) { return; } __state.AddHealth(-amount); Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId); } } }