- Пирамида: StreamModeWrite/Read теперь глобальные перечисления (в 3.2 были внутри TileEntity). - Хранилище: Bag поверх ItemStackGrid (конструктор с местом хранения, Resize, ItemGrid), запись и чтение через PooledBinaryWriter/Reader. Формат хранилища 2; формат 1 из сейвов 3.2 читается. Незнакомый номер формата больше не считается «хранилища нет» - байты сохраняются. - Сфера на браслете: ModificationCount/GetModification/SetModification. - Непустой сундук: TEFeatureStorage.IsEmpty (ITileEntityLootable в 3.3 нет). - Поиск ножа в инвентаре через ItemGrid. - GameVersionGuard пускает только 3.3. Проверено в игре на V 3.3.0 b14: хранилище из сейва 3.2 (24 стопки) прочиталось, сохранилось и перечиталось; Пирамида включается; «Жертва», обыск трупа и мешки работают. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
225 lines
11 KiB
C#
225 lines
11 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% имеющегося ХП" - 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.
|
|
/// </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;
|
|
}
|
|
// 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);
|
|
|
|
// 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. At the 90% cost this is the
|
|
// big pain clip in every normal case (amount > 15 unless the player is already down to
|
|
// about 17 HP), 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);
|
|
}
|
|
}
|
|
}
|