Files
necromants-tome-7d2d-3-2/HarmonySrc/SpatialVaultPatch.cs
T
AlexCubeandClaude Opus 5 e362c627e7 Кровь некроманта - топливо Пространственного браслета
Шесть указаний одного захода, которые сложились в одну механику: поглощение блока
больше не бесплатно. Браслет требует модификацию в слоте, кровь ею стала, и она
на это тратится.

СТАК ПО ОДНОЙ БАНКЕ. Наследуемый medicalBloodBag даёт Stacknumber 15, и это надо
перебивать явно - Extends копирует свойство целиком, а не "если не задано иначе".
Дорого и так задумано: Чёрный портал просит десять банок, то есть десять ячеек.

ПРОЧНОСТЬ 1000. Свойства с именем вроде Durability в игре нет; ручек две, и обе
обязательны. Число - пассивный эффект DegradationMax
(ItemValue.MaxUseTimesBase -> EffectManager.GetValue), полоска - отдельное
свойство ShowQuality (XUiC_ItemStack.ShowDurability -> ItemClass.ShowQualityBar).
Без первой прочность равна нулю, а полоска при MaxUseTimes == 0 рисуется ПОЛНОЙ,
то есть забытый эффект выглядит как "всё работает". tiered="false" обязателен:
ItemClass.HasQuality читается как Effects.IsOwnerTiered(), и тированная группа
превратила бы банку в предмет с качеством, с тирами и рамкой.

КРОВЬ ПЕРЕЕХАЛА В item_modifiers.xml. Это не настройка, а смена класса предмета:
XUiC_ItemPartStack.CanSwap открывается строкой
`if (!(stack.itemValue.ItemClass is ItemClassModifier ...)) return false;` - слот
модификации не смотрит ни на теги, ни на свойства, пока предмет не
ItemClassModifier, а этот класс создаётся только из <item_modifier>. Свойства
вида CanBeInstalled не существует; остаться ресурсом в items.xml и вставляться в
браслет физически нельзя. На старом месте оставлен комментарий-указатель.

Что при этом проверено, а не понадеялось: рецепты резолвятся (ItemClassModifier
наследует ItemClass, имена лежат в общем ItemClass.nameToItem, Recipe ищет через
GetItemClass по тому же словарю); сейв цел (ItemClass.assignIdsFromMapping берёт
айди из сохранённого name->id мэппинга, перестановка в конфигах предмет не
подменит); Harmony-патч и ключи локализации ходят по имени, имя не менялось.

ЛОВУШКА ПЕРЕЕЗДА: у модификации effect_group применяется к предмету, В КОТОРЫЙ её
вставили - прочность 1000 начала бы выдаваться БРАСЛЕТУ. Пассивка гейтована
tags="necroBloodFlask", тег добавлен в Tags флакона: MaxUseTimesBase зовёт
GetValue с ItemTags того предмета, для которого считает.

СЛОТОВ У БРАСЛЕТА 1, было 4. Слот из набора улучшений стал выбором.

ПУСТОЙ СЛОТ ОТКАЗЫВАЕТ. Проверка стоит первой строкой Begin, впереди всех
остальных отказов: прочие про ЦЕЛЬ (нет блока, не тот блок, хранилище полно), эта
про ИНСТРУМЕНТ, и сказать "здесь нет блока", когда пуст браслет, значит отправить
игрока искать не там. Тест - ItemValue.HasMods(), игровой собственный: обходит
только Modifications, пропуская null и IsEmpty, и не считает CosmeticMods, иначе
краска читалась бы как "браслет заряжен". Звук отказа достался бесплатно -
Deny() в этом файле уже играет ванильный ui_denied.

РАСХОД. Цена пула считается ОДИН раз, при старте, и едет в PickupJob.ChannelSeconds
вместе с самим браслетом. Не потому, что так короче: ChannelSecondsFor меряет луч
игрока, а за десять секунд игрок успевает отвернуться - второй вызов насчитал бы
цену за другой блок, а не за тот, который забрали. Браслет хранится экземпляром по
той же причине: моды живут на ItemValue, а колесо прокручивается.

Списывается в SpendBlood, ПОСЛЕ SetBlockRPC и после того, как предмет лёг в
хранилище: все отказы выходят раньше через return, так что кровь за отменённое
поглощение невозможна по построению. Имя предмета берётся из
NecromancerBloodPatch.BloodItemName, а не вторым литералом, чтобы не разъехались.
Мод, который не кровь, не платит ничего и поглощению не мешает - слот задуман под
другие вещи.

КОНЧИЛАСЬ - РАЗБИВАЕТСЯ. Правило именно "прочность 0 или меньше", а не "не хватило
на пул", и разница не косметическая: по второй формулировке флакон, которому
хватило впритык, остался бы в слоте с нулём, HasMods() видел бы "что-то вставлено",
и браслет работал бы бесплатно до конца света. Поэтому зажим по MaxUseTimes убран,
а слот обнуляется через ItemValue.None - это type 0, ровно то, что проверяет
IsEmpty(). Последнее поглощение проходит всегда, флакон его просто не переживает.
Звук - ванильный itembreak, тот же, что играет ItemAction.HandleItemBreak.

Защита: если MaxUseTimes окажется 0 (снесли passive_effect или тег), флакон НЕ
удаляется, а в лог идёт предупреждение с указанием, где чинить. Без этой ветки
ошибка в XML съедала бы игроку предмет на первом же поглощении, и выглядело бы это
багом механики.

Локализация: новый ключ braceletSpatialVaultNoMod в 13 языках, плюс описания
флакона и браслета - механика стала условной и платной, и оба текста без этого
стали бы враньём.

Известное и намеренное: кровавого камня, который обещает сообщение о пустом слоте,
ещё нет - он запланирован, разбор в BACKLOG.md. В игре ничего из этого не
проверено.

---

Necromancer's Blood is the Spatial Bracelet's fuel

Six instructions from one session that add up to one mechanic: pulling a block
into the vault is no longer free. The bracelet needs a mod in its slot, the blood
became that mod, and it is spent doing the work.

ONE JAR PER STACK. The inherited medicalBloodBag sets Stacknumber 15 and it has to
be overridden explicitly - Extends copies a property wholesale, not "unless set".
Expensive on purpose: the Black Portal asks for ten jars, so ten slots.

DURABILITY 1000. There is no property called anything like Durability; there are
two knobs and both are required. The number is a DegradationMax passive effect
(ItemValue.MaxUseTimesBase -> EffectManager.GetValue); the bar is a separate
ShowQuality property (XUiC_ItemStack.ShowDurability -> ItemClass.ShowQualityBar).
Without the first, durability is zero - and the bar at MaxUseTimes == 0 draws
FULL, so a forgotten effect looks exactly like success. tiered="false" is
mandatory: ItemClass.HasQuality is Effects.IsOwnerTiered(), and a tiered group
would have turned the jar into a quality item with tiers and a frame.

THE BLOOD MOVED TO item_modifiers.xml. Not a setting but a change of item class:
XUiC_ItemPartStack.CanSwap opens with
`if (!(stack.itemValue.ItemClass is ItemClassModifier ...)) return false;` - a mod
slot looks at neither tags nor properties until the item is an ItemClassModifier,
and that class is only created from <item_modifier>. No CanBeInstalled property
exists; staying a resource in items.xml and going into the bracelet is impossible.
A pointer comment was left where it used to live.

Checked rather than hoped: recipes still resolve (ItemClassModifier extends
ItemClass, names live in the shared ItemClass.nameToItem, Recipe looks them up
through GetItemClass); saves are safe (ItemClass.assignIdsFromMapping takes ids
from the stored name->id mapping, so shuffling configs cannot swap the item); the
Harmony patch and the localization keys go by name, and the name did not change.

THE TRAP IN MOVING IT: a modifier's effect_group applies to the item it is
INSTALLED IN - the 1000 durability would have been granted to the BRACELET. The
passive is gated with tags="necroBloodFlask" and the tag added to the flask's own
Tags: MaxUseTimesBase calls GetValue with the ItemTags of whatever it is
computing for.

THE BRACELET HAS 1 MOD SLOT, down from 4. The slot stopped being a set of
upgrades and became a choice.

AN EMPTY SLOT REFUSES. The check is the first line of Begin, ahead of every other
refusal: the others are about the TARGET (no block, wrong block, vault full), this
one is about the TOOL, and saying "no block there" when the real problem is an
empty bracelet sends the player looking in the wrong place. The test is
ItemValue.HasMods(), the game's own: it walks Modifications only, skipping nulls
and IsEmpty, and does not count CosmeticMods - a dye would otherwise have read as
"loaded". The refusal sound came free: Deny() in this file already plays vanilla's
ui_denied.

THE COST. The price of a pull is computed ONCE, at the start, and carried in
PickupJob.ChannelSeconds along with the bracelet itself. Not for brevity:
ChannelSecondsFor measures the player's ray, and ten seconds is long enough to
turn away - a second call would charge for a different block than the one taken.
The bracelet is kept as an instance for the same reason: mods live on the
ItemValue and the hotbar scrolls.

It is charged in SpendBlood, AFTER SetBlockRPC and after the item is in the vault:
every refusal returns earlier, so blood charged for a cancelled pull is impossible
by construction. The item name comes from NecromancerBloodPatch.BloodItemName
rather than a second literal, so the two cannot drift. A mod that is not blood
pays nothing and does not block the pull - the slot is meant for other things.

RUNS OUT, SHATTERS. The rule is "durability 0 or less", not "could not cover the
pull", and the difference is not cosmetic: under the second wording a flask with
exactly enough left would sit in the slot at zero, HasMods() would see "something
installed", and the bracelet would work for free forever. So the MaxUseTimes clamp
is gone and the slot is cleared with ItemValue.None - type 0, exactly what
IsEmpty() tests. The last pull always completes; the flask simply does not survive
it. The sound is vanilla's itembreak, the same cue ItemAction.HandleItemBreak
plays.

A guard: if MaxUseTimes comes out 0 (the passive effect or the tag removed), the
flask is NOT deleted and a warning naming the fix goes to the log. Without that
branch a config error would eat the player's item on the first pull and look like
a bug in the mechanic.

Localization: a new braceletSpatialVaultNoMod key in 13 languages, plus the flask
and bracelet descriptions - the mechanic became conditional and paid, and both
texts would have been lies without it.

Known and deliberate: the Blood Stone the empty-slot message promises does not
exist yet - it is planned, written up in BACKLOG.md. None of this is tested in
game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnwP2Dt1vk8bUPJ452EoVL
2026-09-15 22:32:59 +03:00

202 lines
10 KiB
C#
Raw 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 System;
using System.Collections.Generic;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// "Пространственный браслет" (Spatial Bracelet) - dictated 2026-08-30, implemented same
/// day. See items.xml (braceletSpatialVault) for the item - both Action0 and Action1 use
/// Class="Eat" purely as a click-catcher (same trick as every other Harmony-driven item this
/// mod already has), distinguished here by ItemActionData.indexInEntityOfAction (0/1), the
/// same field SummonPatch.cs already uses to tell a summon book's summon-click from its
/// recall-click.
///
/// POWER ATTACK (index 1) - personal storage, size scaling with Necromancy skill level:
/// - XUiC_BagStorageWindowGroup.Open(xui, entity, bag, lootContainer, title, ...) is the
/// REAL API EntityDrone.openStorageWindow() itself calls to show the drone's own cargo
/// window (decompiled EntityDrone directly to find this, not guessed) - reused directly
/// rather than reinventing a storage UI. LootContainer.GetLootContainer("roboticDrone")
/// is the same display/behavior template the drone's own window uses too - "как у дрона"
/// taken literally, not just as a vague size comparison.
/// - Slot count = Mathf.RoundToInt(necromancyLevel / 10f), per the user's own exact formula
/// ("1*скилл_некроманта/10 округлённый до целого") - read live from
/// player.Progression.GetProgressionValue("craftingNecroNecromancy").Level (decompiled
/// EntityAlive/Progression/ProgressionValue directly to confirm this exact call shape,
/// not guessed) - the SAME skill the Knife's own damage already scales with (capped at
/// level 5000, one level per zombie kill - see buffs.xml/progression.xml), so this grows
/// at the same pace as every other kill-count-tied payoff in this mod. Below level 10
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
/// instead of silently opening a useless empty window.
/// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved
/// when this item shipped, and that shortfall is exactly what became the mod's first
/// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will
/// disappear after leaving the game and going online again"). PlayerVaults below is still
/// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session
/// cache: the durable copy is written into the player's own PlayerDataFile, alongside the
/// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment
/// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live
/// in a chunk, and the bracelet's closest equivalent home is its owner's save data).
///
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
/// braceletThiefLoop (GetLookRay + Physics.Raycast + RootTransformRefEntity.
/// FindEntityUpwards) - reused verbatim, just resolving to EntityZombie instead of
/// EntityLootContainer.
/// - Slow: zombie.Buffs.AddBuff("buffInjurySlow") - the exact same vanilla debuff already
/// reused elsewhere in this mod (the Dog's own bite, necroMeleeHandZombieDog).
/// - Knockback: DELIBERATELY a straight Entity.SetPosition "shove" (same API
/// PetFollowPatch.cs already uses to reposition pets), NOT a physics/ragdoll impulse.
/// Found real candidates for "proper" knockback while researching this
/// (EntityAlive.DoRagdoll(in DamageResponse), DamageResponse.ImpulseScale/HitDirection),
/// but fully reverse-engineering how a real DamageResponse gets built and fed into that
/// during normal combat - all its other fields (Source, Strength, Stun, ArmorSlot, etc.)
/// - would have taken real additional decompilation with no guarantee of getting all the
/// coordinate/enum conventions right on the first try. A direct position shove is cruder
/// (no animation, the zombie just appears further away) but uses an API this exact file's
/// own family already relies on successfully - chosen for certainty over polish. Revisit
/// with DoRagdoll if the teleport-shove feels too crude in testing.
/// </summary>
[HarmonyPatch(typeof(ItemActionEat), "ExecuteAction")]
public static class Patch_ItemActionEat_ExecuteAction_SpatialVault
{
public const string ItemName = "braceletSpatialVault";
public const string NecromancySkillName = "craftingNecroNecromancy";
public const float MaxRange = 50f;
public const float ShoveDistance = 6f;
/// <summary>Session cache only - the durable copy lives on disk, see
/// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded
/// afterwards cannot inherit this world's vault through a recycled entityId.</summary>
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
{
if (!_bReleased)
{
return true;
}
string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name;
if (itemName != ItemName)
{
return true;
}
if (!(_actionData.invData.holdingEntity is EntityPlayerLocal player))
{
return true;
}
if (_actionData.indexInEntityOfAction == 1)
{
// Not when this very press just cancelled a block pickup and opened the vault on
// the way - see SpatialVaultPickup.ConsumeCancelOpen. Down and up are one press.
if (!SpatialVaultPickup.ConsumeCancelOpen())
{
OpenVault(player);
}
}
else
{
// The regular attack takes the block under the crosshair into the vault from
// 2026-09-14 - see SpatialVaultPickupPatch.cs. Before that it did nothing at all,
// by direct user request of 2026-08-30 ("пусть тогда обычная атака у
// пространственного браслета не делает ничего"), after the knockback+slow version did not
// visibly do anything in testing. ShoveZombieAtCrosshair is kept below, unused,
// because that abandoned version was never shown to be WRONG - only invisible.
// The ItemValue goes with it: the pickup refuses outright when this particular
// bracelet has nothing in its mod slot, and mods live on the instance.
SpatialVaultPickup.Begin(player, _actionData.invData.itemValue);
}
// Skip ItemActionEat's own logic entirely - the click has been fully handled here.
return false;
}
public static void OpenVault(EntityPlayerLocal player)
{
ProgressionValue progressionValue = player.Progression?.GetProgressionValue(NecromancySkillName);
int level = progressionValue != null ? progressionValue.Level : 0;
int slotCount = Mathf.RoundToInt(level / 10f);
if (slotCount <= 0)
{
GameManager.ShowTooltip(player, "braceletSpatialVaultTooWeak");
return;
}
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
{
// Normally a restored vault is already here - the ToPlayer postfix puts it in
// when the game applies the save file to the spawning player. LastLoadedVault is
// the safety net for when that chain does not complete: opening the bracelet must
// never be what silently starts an empty vault over a saved one. Only then is a
// genuinely new bag created.
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
if (bag == SpatialVaultPersistence.LastLoadedVault)
{
Debug.Log("[NecromancerTome] SpatialVaultPatch: session cache was empty, adopted the last loaded vault (" +
bag.SlotCount + " slots, " + bag.GetUsedSlotCount() + " used)");
}
PlayerVaults[player.entityId] = bag;
}
else if (bag.SlotCount < slotCount)
{
// Grow, never shrink - the skill level only ever goes up, so this only ever
// copies existing stacks into a bigger array, same shape
// EntityLootContainer.SetContent itself uses when it needs to resize a bag.
ItemStack[] oldSlots = bag.GetSlots();
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
Array.Copy(oldSlots, newSlots, oldSlots.Length);
bag.SetSlots(newSlots);
}
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
// The trailing callbacks are vanilla's own optional parameters (_onModified, _onClose).
// _onModified is not needed: the vault lives in PlayerVaults, and PlayerDataFile's
// FromPlayer postfix reads it fresh every time the game saves the player, so there is
// nothing to flush per item move. _onClose asks for a player-data save right away, so
// closing the window is a commit point rather than waiting for the next autosave -
// SaveLocalPlayerData is the game's own routine call and no-ops when saving is not
// active (which is the correct behaviour on a client, where the server owns the file).
XUiC_BagStorageWindowGroup.Open(
playerUI.xui,
player,
bag,
LootContainer.GetLootContainer("roboticDrone"),
Localization.Get("braceletSpatialVaultWindowTitle"),
null,
() => GameManager.Instance.SaveLocalPlayerData());
}
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
{
Ray ray = player.GetLookRay();
if (!Physics.Raycast(ray, out RaycastHit hit, MaxRange))
{
GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget");
return;
}
Transform entityTransform = RootTransformRefEntity.FindEntityUpwards(hit.collider.transform);
Entity entity = entityTransform != null ? entityTransform.GetComponent<Entity>() : null;
if (!(entity is EntityZombie zombie) || zombie.IsDead())
{
GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget");
return;
}
zombie.Buffs?.AddBuff("buffInjurySlow");
Vector3 shoveDir = zombie.position - player.position;
shoveDir.y = 0f;
shoveDir = shoveDir.sqrMagnitude > 0.01f ? shoveDir.normalized : player.transform.forward;
Vector3 destination = zombie.position + shoveDir * ShoveDistance + Vector3.up * 1f;
zombie.SetPosition(destination, true);
player.PlayOneShot("swoosh");
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " shoved zombie " + zombie.entityId);
}
}
}