Кровь некроманта - топливо Пространственного браслета

Шесть указаний одного захода, которые сложились в одну механику: поглощение блока
больше не бесплатно. Браслет требует модификацию в слоте, кровь ею стала, и она
на это тратится.

СТАК ПО ОДНОЙ БАНКЕ. Наследуемый 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
This commit is contained in:
AlexCube
2026-09-15 22:32:59 +03:00
co-authored by Claude Opus 5
parent 20af2bbe6c
commit e362c627e7
8 changed files with 336 additions and 80 deletions
File diff suppressed because one or more lines are too long
+118
View File
@@ -324,4 +324,122 @@
</item_modifier> </item_modifier>
</append> </append>
<!-- "Кровь некроманта" (Necromancer's Blood) - ПЕРЕЕХАЛА СЮДА ИЗ items.xml 2026-09-15.
Указание: «пусть флакон крови можно будет вставлять как модификацию для пространственного
хранилища» (уточнено: именно Крови некроманта).
ПОЧЕМУ ПЕРЕЕЗД, А НЕ НОВОЕ СВОЙСТВО. XUiC_ItemPartStack.CanSwap начинается с
if (!(stack.itemValue.ItemClass is ItemClassModifier itemClassModifier)) return false;
Слот модификации не смотрит ни на теги, ни на свойства, пока предмет не ItemClassModifier -
а этот класс создаётся только из <item_modifier>. Свойства вида "CanBeInstalled" в игре нет;
остаться ресурсом в items.xml и при этом вставляться в браслет физически нельзя.
ЧТО ПРИ ЭТОМ НЕ СЛОМАЛОСЬ - проверено по декомпилятору, а не понадеялись:
- Рецепты. ItemClassModifier наследует ItemClass, а имена регистрируются в ОДНОМ общем
ItemClass.nameToItem (ItemClass.cs:624). Recipe ищет ингредиент через
ItemClass.GetItemClass, то есть по тому же словарю - все четыре рецепта на крови
(10 на Чёрный портал, 3 на Пирамиду, два по одной) резолвятся как раньше.
- Свой рецепт самой крови и Harmony-патч NecromancerBloodPatch.cs - оба по имени
"resourceNecromancerBlood", имя не менялось.
- Сейв. Айди предметов раздаются при загрузке, но ItemClass.assignIdsFromMapping берёт их
из сохранённого в мире name->id мэппинга (nameIdMapping.GetIdForName), так что предмет в
старом сейве не превратится в другой из-за перестановки в конфигах.
- Локализация. Ключи "resourceNecromancerBlood"/"...Desc" те же, файл не трогали.
ЧЕГО НЕТ - у этой модификации пока НЕТ НИ ОДНОГО ЭФФЕКТА ДЛЯ БРАСЛЕТА, и это не
забывчивость: указание было про "вставлять", а что именно она даёт - не сказано.
effect_group ниже существует ради прочности самого флакона, а не ради браслета.
ЛОВУШКА, КОТОРУЮ ПРИШЛОСЬ ОБОЙТИ: у модификации effect_group применяется К ПРЕДМЕТУ, В
КОТОРЫЙ ЕЁ ВСТАВИЛИ. Прочность 1000 (сделана 2026-09-15, разбор в BACKLOG.md) живёт именно
в effect_group - и, переехав сюда, она стала бы выдавать 1000 прочности БРАСЛЕТУ. Поэтому
пассивка гейтована tags="necroBloodFlask", а сам тег добавлен в Tags флакона:
ItemValue.MaxUseTimesBase зовёт GetValue с ItemClass.ItemTags того предмета, для которого
считает, так что у флакона совпадение есть, а у браслета
(T0,weapon,attPerception,noMods,necroBracelet) - нет. Тот же механизм, что у "Мёртвой бури"
с tags="secondary", только фильтр не по действию, а по предмету.
installable_tags="necroBracelet" - положительная половина, ровно как требует комментарий к
Tags браслета в items.xml: без неё модификация лезла бы в ЛЮБОЙ предмет
(CanSwap короткозамыкается на InstallableTags.IsEmpty).
blocked_tags НЕ задан намеренно: у браслета в тегах есть "noMods", и объявить его здесь
значило бы заблокировать самому себе установку.
modifier_tags="necroBraceletBlood" - свой, ни с чем не пересекающийся: моды с общим
modifier_tags считаются против MaxModsAllowed (по умолчанию 1). Слот у браслета с
2026-09-15 всего один, так что слотов это больше не съедает - но тег всё равно должен быть
свой: общий сделал бы две модификации браслета взаимоисключающими и там, где интерфейс
этого никак не объясняет.
type="attachment" - флакон можно вынуть обратно; type="mod" вставляется навсегда. -->
<append xpath="/item_modifiers">
<item_modifier name="resourceNecromancerBlood" installable_tags="necroBracelet" modifier_tags="necroBraceletBlood" type="attachment">
<property name="Extends" value="medicalBloodBag"/>
<property name="DescriptionKey" value="resourceNecromancerBloodDesc"/>
<!-- Своя рисованная иконка, 2026-08-30. CustomIcon задаётся явно даже при Extends -
тот же урок, что у всех предметов мода: Extends сам по себе рабочей иконки не даёт. -->
<property name="CustomIcon" value="NecromantsBlood"/>
<!-- Своя банка с кровью, 2026-09-10 (указание: «берём чай из золотарника, и жёлтое
заменяем на кровавый цвет, с фиолетовыми оттенками»). Заодно чинилось расхождение
текста и модели: описание говорит «Банка, наполненная кровью», а наследуемый
medicalBloodBag показывал sackPrefab - обычный мешок.
ПОЧЕМУ НЕ ХВАТИЛО ТИНТА - ПРОВЕРЕНО В ИГРЕ. Сначала пробовали дёшево, без бандла:
ванильный префаб чая плюс TintColor. Банка осталась чаем из золотарника - тинт
предмета на этот меш НЕ ПОДЕЙСТВОВАЛ ВООБЩЕ: у шейдера Game_EntityTintMaskSSS
выигрывает собственный _Color материала. Поэтому TintColor здесь не задаётся
совсем - он ничего не даёт и только вводил бы в заблуждение.
И по сути: кровь отличается от чая не цветом, а тем, что она непрозрачная, тёмная
и густая, с плёнкой на стекле. Жидкость ПЕРЕРИСОВАНА по яркости, а не перекрашена
множителем - генератор _private/tools/make_necroblood_textures.py.
HoldType 3 - хват банки вместо 45 (мешок), Material Mglass - стекло вместо ткани
(звук удара и осколки при разбитии). -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroBloodPrefab.prefab"/>
<property name="HoldType" value="3"/>
<property name="Material" value="Mglass"/>
<!-- Теги medical/medicalSkill - те же, что давал medicalBloodBag через Extends;
перечислены явно, потому что своя строка Tags наследуемую перекрывает целиком.
necroBloodFlask добавлен ради гейта прочности - см. большой комментарий выше.
Внимание на будущее: ItemClassModifier ПЕРЕОПРЕДЕЛЯЕТ HasAnyTags на ModifierTags,
так что на вопрос "есть ли у этого предмета тег X" у модификации отвечают
modifier_tags, а не эта строка. Здешние теги читает только то, что лезет в
ItemTags напрямую - как раз GetValue с прочностью. -->
<property name="Tags" value="medical,medicalSkill,necroBloodFlask"/>
<!-- По одной банке на ячейку, указание 2026-09-15. Наследуемый medicalBloodBag даёт
15, и это надо перебивать явно: Extends копирует свойство целиком. Дорого и так
задумано - Чёрный портал просит десять банок, то есть десять ячеек. -->
<property name="Stacknumber" value="1"/>
<!-- Прочность 1000, указание 2026-09-15. Две разные ручки, обе обязательны:
ShowQuality рисует полоску (XUiC_ItemStack.ShowDurability => ItemClass.ShowQualityBar),
а само число идёт пассивным эффектом DegradationMax
(ItemValue.MaxUseTimesBase => EffectManager.GetValue(PassiveEffects.DegradationMax)).
Без эффекта прочность равна нулю, а полоска при MaxUseTimes == 0 рисуется ПОЛНОЙ -
то есть забытый эффект выглядит как "всё работает".
tiered="false" обязателен: ItemClass.HasQuality читается как Effects.IsOwnerTiered(),
и тированная группа превратила бы банку в предмет с качеством - тиры, рамка.
Прочность пока НЕ УБЫВАЕТ: UseTimes растёт только от ItemAction-ов, а у крови их
нет. Решение пользователя 2026-09-15: "прочность будет убавляться, но это
реализуем позже". -->
<property name="ShowQuality" value="true"/>
<!-- true: опустевшая банка должна исчезать, а не лежать "сломанной" в ожидании
ремонта, как топор. Ванильные инструменты ставят false именно потому, что их чинят. -->
<property name="DegradationBreaksAfter" value="true"/>
<property name="EconomicValue" value="0"/>
<effect_group name="resourceNecromancerBlood" tiered="false">
<passive_effect name="DegradationMax" operation="base_set" value="1000" tags="necroBloodFlask"/>
</effect_group>
</item_modifier>
</append>
</config> </config>
+39 -70
View File
@@ -1284,70 +1284,23 @@
</item> </item>
</append> </append>
<!-- "Кровь некроманта" (Necromancer's Blood): dictated 2026-08-30. Extends medicalBloodBag for <!-- "Кровь некроманта" (Necromancer's Blood) ПЕРЕЕХАЛА В Config/item_modifiers.xml 2026-09-15.
its mesh/material/hold/pickup-sound (reused wholesale, thematically it IS a bag of blood,
just a darker/necromantic one) - CustomIcon still set explicitly even though Extends is
used, same lesson as every other item in this mod (Extends alone never gives a working
icon, confirmed originally on the Knife). Reuses the REAL vanilla sprite named
"medicalBloodBag" itself (that item has no CustomIcon of its own, so its sprite name
equals its item name) rather than a generated-art file, per direct instruction ("Иконка
такая же как и у обычной крови, но тинт затемнённый") - only TintColor differs (dark,
near-black red vs. no tint on the vanilla bag).
Crafting rules ("для создания нужна пустая банка и наличие любого ножа. При крафте нужно Причина - указание «пусть флакон крови можно будет вставлять как модификацию для
отнимать у персонажа 90% имеющегося ХП") - the jar is a normal recipe ingredient (see пространственного хранилища», и это оказалось не настройкой, а сменой класса предмета.
recipes.xml), but "any knife present, not consumed" and "cost 90% of current HP" have NO XUiC_ItemPartStack.CanSwap открывается строкой
vanilla XML equivalent (recipes.xml has no per-ingredient "required but not consumed" flag,
and crafting a resource has no HP-cost hook at all) - both enforced in
HarmonySrc/NecromancerBloodPatch.cs instead. See that file for the exact decompiled
mechanism and an important caveat about ingredient-refund timing that's flagged there, not
glossed over. -->
<append xpath="/items">
<item name="resourceNecromancerBlood">
<property name="Extends" value="medicalBloodBag"/>
<property name="DescriptionKey" value="resourceNecromancerBloodDesc"/>
<!-- Custom art delivered 2026-08-30 (exch/NecromantsBlood.png, 160x160, copied to
UIAtlases/ItemIconAtlas/) - replaces the earlier placeholder that reused the
vanilla medicalBloodBag sprite with a darkened tint. No CustomIconTint here,
same reasoning as every other hand-drawn icon in this mod (Dog/Insect summon
books, etc.) - don't recolor finished art. -->
<property name="CustomIcon" value="NecromantsBlood"/>
<!-- СВОЯ БАНКА С КРОВЬЮ, 2026-09-10 (указание: «берём чай из золотарника, и жёлтое if (!(stack.itemValue.ItemClass is ItemClassModifier itemClassModifier)) return false;
заменяем на кровавый цвет, с фиолетовыми оттенками»).
- слот модификации не смотрит ни на теги, ни на свойства, пока предмет не ItemClassModifier,
Заодно чинится расхождение текста и модели: описание предмета а этот класс создаётся ТОЛЬКО из <item_modifier> в item_modifiers.xml. Никакого свойства
(resourceNecromancerBloodDesc) с самого начала говорит «Банка, наполненная кровью вида "CanBeInstalled" не существует; остаться в items.xml и стать модификацией нельзя.
самого некроманта», а наследуемый medicalBloodBag показывает
@:Other/Items/Misc/sackPrefab.prefab - обычный мешок. Банка вернее и по механике: Предмет при этом остался обычным во всём остальном: ItemClassModifier наследует ItemClass и
рецепт и так требует пустую банку (recipes.xml, NecromancerBloodPatch.cs). регистрируется в том же ItemClass.nameToItem, поэтому рецепты, крафт и Harmony-патч
(NecromancerBloodPatch.cs) находят его по имени как раньше. Разбор - в BACKLOG.md.
ПОЧЕМУ НЕ ХВАТИЛО ТИНТА - ПРОВЕРЕНО В ИГРЕ. Сначала пробовали дёшево, без бандла:
ванильный префаб чая плюс TintColor. Проверка 2026-09-10 показала, что банка Само определение, со всеми старыми комментариями про банку, текстуры и прочность - там. -->
осталась чаем из золотарника - тинт предмета на этот меш НЕ ПОДЕЙСТВОВАЛ ВООБЩЕ.
У шейдера Game_EntityTintMaskSSS выигрывает собственный _Color материала (у чая
жёлтый, 166,133,37), и свойство предмета его не перебивает. Поэтому TintColor здесь
не задаётся совсем: он ничего не даёт и только вводил бы в заблуждение.
И по сути: кровь отличается от чая не цветом, а тем, что она непрозрачная, тёмная и
густая, с плёнкой на стекле. Поэтому жидкость ПЕРЕРИСОВАНА по яркости, а не
перекрашена множителем - генератор _private/tools/make_necroblood_textures.py.
HoldType 3 - хват банки вместо 45 (мешок), Material Mglass - стекло вместо ткани
(звук удара и осколки при разбитии). Без них банка держалась бы как мешок.
ЧТО СМОТРЕТЬ ГЛАЗАМИ. Материал собран на встроенном Standard в режиме Fade: родной
шейдер переиспользовать нельзя, AssetRipper выгрузил шейдеры заглушками. У Standard
альфа текстуры - это прозрачность, поэтому она задана осознанно: стекло
полупрозрачное, жидкость плотная. Банка должна читаться как стекло с густой кровью,
а не как матовый сосуд. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroBloodPrefab.prefab"/>
<property name="HoldType" value="3"/>
<property name="Material" value="Mglass"/>
<property name="EconomicValue" value="0"/>
</item>
</append>
<!-- "Петля вора" (Thief's Loop) - REMOVED 2026-08-30. Existed briefly (dictated/implemented <!-- "Петля вора" (Thief's Loop) - REMOVED 2026-08-30. Existed briefly (dictated/implemented
2026-08-29, reworked several times through 2026-08-30 chasing load errors), removed per 2026-08-29, reworked several times through 2026-08-30 chasing load errors), removed per
@@ -1398,7 +1351,8 @@
<append xpath="/items"> <append xpath="/items">
<item name="braceletSpatialVault"> <item name="braceletSpatialVault">
<!-- MOD SLOTS ADDED 2026-09-13 ("добавь хранилищу 4 слота под модификации. Сами <!-- MOD SLOTS ADDED 2026-09-13 ("добавь хранилищу 4 слота под модификации. Сами
модификации реализуем потом"). Two tags, exactly the scheme necroWpnBladeNecroKnife модификации реализуем потом"), УБАВЛЕНЫ ДО ОДНОГО 2026-09-15 - число стоит в
effect_group в самом низу этого предмета, здесь только теги. Two tags, exactly the scheme necroWpnBladeNecroKnife
already proved on 2026-09-07 - see that item's own comment for the full already proved on 2026-09-07 - see that item's own comment for the full
decompiled reasoning: decompiled reasoning:
@@ -1536,14 +1490,29 @@
bar), and it is deliberately left unset here. The item behaves as tiered for the bar), and it is deliberately left unset here. The item behaves as tiered for the
mod system and still reads as a plain bracelet in the UI. mod system and still reads as a plain bracelet in the UI.
FOR THE MODS THEMSELVES, WHEN THEY GET WRITTEN: give each one its OWN FOR THE MODS THEMSELVES: give each one its OWN modifier_tags.
modifier_tags. XUiC_ItemPartStack.CanSwap counts already-installed mods whose XUiC_ItemPartStack.CanSwap counts already-installed mods whose modifier_tags
modifier_tags intersect the one being installed and refuses at intersect the one being installed and refuses at num >= ItemClass.MaxModsAllowed,
num >= ItemClass.MaxModsAllowed, which defaults to 1 - so a shared tag like which defaults to 1. With a single slot this no longer costs slots - but it still
"necroBraceletMod" across all four would leave exactly one of these four slots matters, because a shared tag would ALSO make two different bracelet mods mutually
usable. --> exclusive in ways nothing in the UI explains. Keep them distinct.
ОДИН СЛОТ, указание 2026-09-15 («убавь у пространственного браслета количество
слотов под модификации до одного»). Было 4, поставленные 2026-08-30 по прежнему
выбору пользователя («4 фиксированно»).
Что это меняет по сути: слот из «набора улучшений» превратился в ВЫБОР. Сейчас
единственный кандидат - флакон Крови некроманта (item_modifiers.xml, переехал туда
2026-09-15), так что выбирать пока не из чего; но каждая следующая модификация
браслета теперь конкурирует за один слот, а не добавляется к остальным. Это стоит
держать в голове при их придумывании - иначе получится набор, из которого всегда
берут одну и ту же.
Число только здесь. ModSlots - пассивка, а не свойство, и никакой другой файл на
него не смотрит; менять обратно - эта же строка. У Ножа некроманта свои 4 слота
(выше в этом файле, ~строка 1056) - их указание НЕ трогало. -->
<effect_group name="braceletSpatialVault"> <effect_group name="braceletSpatialVault">
<passive_effect name="ModSlots" operation="base_set" value="4"/> <passive_effect name="ModSlots" operation="base_set" value="1"/>
</effect_group> </effect_group>
</item> </item>
</append> </append>
+6 -2
View File
@@ -4,8 +4,12 @@ using UnityEngine;
namespace NecromancerTome namespace NecromancerTome
{ {
/// <summary> /// <summary>
/// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml /// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See item_modifiers.xml
/// (resourceNecromancerBlood) for the item, recipes.xml for the base recipe (an empty jar, /// (resourceNecromancerBlood - it lives THERE, not in items.xml, since 2026-09-15: it had to
/// become an ItemClassModifier to be installable in the Spatial Bracelet, and that class is
/// only created from <item_modifier>. Nothing here changed - the lookup is by name, and both
/// files register into the same ItemClass.nameToItem) 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 /// like any other resource conversion). Two rules the user asked for have NO vanilla XML
/// equivalent at all, so both are enforced here instead: /// equivalent at all, so both are enforced here instead:
/// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or /// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or
+3 -1
View File
@@ -105,7 +105,9 @@ namespace NecromancerTome
// пространственного браслета не делает ничего"), after the knockback+slow version did not // пространственного браслета не делает ничего"), after the knockback+slow version did not
// visibly do anything in testing. ShoveZombieAtCrosshair is kept below, unused, // visibly do anything in testing. ShoveZombieAtCrosshair is kept below, unused,
// because that abandoned version was never shown to be WRONG - only invisible. // because that abandoned version was never shown to be WRONG - only invisible.
SpatialVaultPickup.Begin(player); // 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. // Skip ItemActionEat's own logic entirely - the click has been fully handled here.
+167 -5
View File
@@ -130,6 +130,7 @@ namespace NecromancerTome
public const string MsgChanneling = "braceletSpatialVaultPickupChanneling"; public const string MsgChanneling = "braceletSpatialVaultPickupChanneling";
public const string MsgTraderArea = "braceletSpatialVaultTraderArea"; public const string MsgTraderArea = "braceletSpatialVaultTraderArea";
public const string MsgIndestructible = "braceletSpatialVaultIndestructible"; public const string MsgIndestructible = "braceletSpatialVaultIndestructible";
public const string MsgNoMod = "braceletSpatialVaultNoMod";
/// <summary>The denial sound vanilla plays with these tooltips.</summary> /// <summary>The denial sound vanilla plays with these tooltips.</summary>
public const string DeniedSound = "ui_denied"; public const string DeniedSound = "ui_denied";
@@ -169,12 +170,52 @@ namespace NecromancerTome
public EntityPlayerLocal Player; public EntityPlayerLocal Player;
public Vector3i Position; public Vector3i Position;
public BlockValue Expected; public BlockValue Expected;
/// <summary>The bracelet this pull was started with, so the blood that pays for it is
/// taken from the flask in THAT bracelet. Kept as the live ItemValue rather than
/// looked up again at the end: mods live on the instance, and ten seconds is long
/// enough for the player to have scrolled to another slot.</summary>
public ItemValue Bracelet;
/// <summary>What this particular pull costs, in seconds - computed once when the
/// channel starts (user request 2026-09-15: "пусть количество секунд требуемое для
/// поглощения блока записывается в отдельную переменную"). It is the same number the
/// timer counts down and the same number the flask pays, and that is the point of
/// storing it instead of recomputing: by the time the channel ends the player may
/// have turned away, the ray is gone, and a second call to ChannelSecondsFor would
/// quietly charge for a different block than the one that was taken.</summary>
public float ChannelSeconds;
} }
/// <summary>Regular attack on the bracelet. Every refusal happens here, before the player /// <summary>Regular attack on the bracelet. Every refusal happens here, before the player
/// is asked to stand still for ten seconds.</summary> /// is asked to stand still for ten seconds.</summary>
public static void Begin(EntityPlayerLocal _player) public static void Begin(EntityPlayerLocal _player, ItemValue _bracelet)
{ {
// AN EMPTY MOD SLOT REFUSES THE WHOLE ACTION (user request 2026-09-15: "пусть обычная
// атака (поглощение блока) у пространственного хранилища не работает, если у хранилища
// в слоте модификаций пусто"). FIRST, deliberately, ahead of every other check in this
// method: the others are about the TARGET (no block, wrong kind of block, vault full),
// and telling the player "no block there" when the real problem is his empty bracelet
// would send him looking in the wrong place. This one is about the tool, so it is
// answered before the tool is even pointed at anything.
//
// ItemValue.HasMods() is the game's own test and the right one: it walks Modifications
// only, skipping both nulls and IsEmpty() slots, and does NOT count CosmeticMods - a
// dye would otherwise have read as "the bracelet is loaded". The bracelet has no
// cosmetic slot anyway (canHaveCosmetic is deliberately absent from its Tags, see
// items.xml), so this is belt and braces rather than a live case - but the next item
// that reuses this pattern may well have one.
//
// The ItemValue is handed in rather than read from the player, because the caller
// already holds the exact instance the click came from (_actionData.invData.itemValue)
// and mods live on the INSTANCE, not on the ItemClass. Two bracelets in the same
// inventory can legitimately disagree about whether they are loaded.
if (_bracelet == null || !_bracelet.HasMods())
{
Deny(_player, MsgNoMod);
return;
}
World world = GameManager.Instance != null ? GameManager.Instance.World : null; World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null) if (world == null)
{ {
@@ -249,9 +290,20 @@ namespace NecromancerTome
return; return;
} }
// Computed HERE, while the ray still exists, and carried in the job from this point
// on - see PickupJob.ChannelSeconds.
float channelSeconds = ChannelSecondsFor(hitInfo);
TimerEventData timerData = new TimerEventData TimerEventData timerData = new TimerEventData
{ {
Data = new PickupJob { Player = _player, Position = position, Expected = blockValue }, Data = new PickupJob
{
Player = _player,
Position = position,
Expected = blockValue,
Bracelet = _bracelet,
ChannelSeconds = channelSeconds
},
// Vanilla's own two escapes: taking a hit stops the channel, and so does the // Vanilla's own two escapes: taking a hit stops the channel, and so does the
// activate key. Neither is built here - both are fields XUiC_Timer.Update reads. // activate key. Neither is built here - both are fields XUiC_Timer.Update reads.
CloseOnHit = true, CloseOnHit = true,
@@ -267,7 +319,6 @@ namespace NecromancerTome
ChannelVision.End(_player); ChannelVision.End(_player);
}; };
float channelSeconds = ChannelSecondsFor(hitInfo);
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player); LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player);
XUiC_Timer.OpenTimer(playerUI.xui, channelSeconds, timerData, -1f, Localization.Get(MsgChanneling)); XUiC_Timer.OpenTimer(playerUI.xui, channelSeconds, timerData, -1f, Localization.Get(MsgChanneling));
// After the window is up, so a channel that somehow fails to open never leaves the // After the window is up, so a channel that somehow fails to open never leaves the
@@ -344,13 +395,124 @@ namespace NecromancerTome
} }
world.SetBlockRPC(job.Position, BlockValue.Air); world.SetBlockRPC(job.Position, BlockValue.Air);
// AFTER the block is gone and the item is in the vault, never before: every refusal
// above returns early, and blood paid for a pull that was then refused would be blood
// charged for nothing. This is the only place the flask is spent.
float spent = SpendBlood(job);
// The vault lives in memory and is written out with the player's own save data; this // The vault lives in memory and is written out with the player's own save data; this
// is the same commit point closing the vault window uses, so a block taken and then // is the same commit point closing the vault window uses, so a block taken and then
// left alone is not waiting on the next autosave to become real. // left alone is not waiting on the next autosave to become real. The flask's UseTimes
// rides along in the same save - it lives on the bracelet in the player's inventory.
GameManager.Instance.SaveLocalPlayerData(); GameManager.Instance.SaveLocalPlayerData();
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + job.Player.entityId + " took " + Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + job.Player.entityId + " took " +
blockValue.Block.GetBlockName() + " at " + job.Position + " into the vault"); blockValue.Block.GetBlockName() + " at " + job.Position + " into the vault for " +
spent.ToString("0.#") + " of blood");
}
/// <summary>Charges this pull to the Necromancer's Blood in the bracelet's mod slot, one
/// point of durability per second of channel (user request 2026-09-15). Returns what was
/// actually taken, which is not always what was asked for - see the clamp.
///
/// WHY THE NAME CHECK AND NOT "whatever is in the slot". The instruction is explicit -
/// "если эта модификация кровь некроманта" - and it has to stay that way: the slot is
/// meant to take other things later (the message the empty slot prints already promises a
/// Blood Stone), and those will have their own price, or none. A mod that is not blood
/// pays nothing here and the pull still happens - deliberately, because refusing it would
/// be a second rule nobody asked for.
///
/// The name comes from NecromancerBloodPatch rather than a second literal in this file,
/// so the two cannot drift apart if the item is ever renamed.
///
/// NOT ENOUGH BLOOD IS NOT A REFUSAL (user request 2026-09-15: "пусть поглощение всё-равно
/// сработает, но флакон крови некроманта после этого пусть исчезнет из слота", clarified to
/// "если прочность 0 или меньше, пусть флакон исчезнет из слота"). So the last pull is
/// always free of charge in the sense that matters - it completes - and the flask simply
/// does not survive it. The charge is therefore NOT clamped: UseTimes is allowed to go past
/// MaxUseTimes, because the only thing that then reads it is the emptiness test right
/// below, and a clamp would have made "spent exactly to zero" and "overdrawn" look the
/// same at the moment the difference stopped mattering anyway.
///
/// The test is "durability 0 or less", not "could not cover the cost", and those are not
/// the same rule: a flask with exactly enough left is also gone afterwards. That is the
/// user's own correction and it closes the hole the first version would have left - a
/// flask sitting at 0/1000 in the slot, counting as "something is installed" for the empty-
/// slot gate in Begin, and pulling blocks for free forever.
///
/// WHY THE NAME CHECK AND NOT "whatever is in the slot" - see above; a mod that is not
/// blood pays nothing, is not emptied, and the pull still happens.</summary>
public static float SpendBlood(PickupJob _job)
{
ItemValue bracelet = _job.Bracelet;
if (bracelet == null || bracelet.Modifications == null || _job.ChannelSeconds <= 0f)
{
return 0f;
}
float spent = 0f;
bool emptied = false;
for (int i = 0; i < bracelet.Modifications.Length; i++)
{
ItemValue mod = bracelet.Modifications[i];
if (mod == null || mod.IsEmpty())
{
continue;
}
if (mod.ItemClass == null || mod.ItemClass.Name != NecromancerBloodPatch.BloodItemName)
{
continue;
}
int max = mod.MaxUseTimes;
mod.UseTimes += _job.ChannelSeconds;
spent += _job.ChannelSeconds;
// max <= 0 means this flask has no durability at all - a DegradationMax that did
// not resolve. Draining something with no capacity would delete it on the first
// pull, which is a config bug eating the player's item, so it is left alone and
// said out loud instead.
if (max <= 0)
{
Debug.LogWarning("[NecromancerTome] SpatialVaultPickup: blood in slot " + i +
" has MaxUseTimes 0 - nothing to spend, flask kept. Check the " +
"DegradationMax passive_effect in Config/item_modifiers.xml");
continue;
}
if (mod.UseTimes >= max)
{
// ItemValue.None is what an empty mod slot holds - type 0, which is exactly
// what IsEmpty() tests for, so the slot reads as free to every other piece of
// code including the gate in Begin.
bracelet.Modifications[i] = ItemValue.None;
emptied = true;
Debug.Log("[NecromancerTome] SpatialVaultPickup: blood in slot " + i +
" ran out (" + mod.UseTimes.ToString("0.#") + "/" + max +
") - flask removed from the bracelet");
}
else
{
Debug.Log("[NecromancerTome] SpatialVaultPickup: blood in slot " + i + " now " +
mod.UseTimes.ToString("0.#") + "/" + max + " used");
}
}
if (emptied)
{
// Vanilla's own answer to "the thing you were using is gone" - the same cue
// ItemAction.HandleItemBreak plays. An item vanishing out of a slot in silence is
// the one outcome here the player could miss entirely.
_job.Player.PlayOneShot("itembreak");
}
if (spent > 0f && _job.Player.inventory != null)
{
// Without this the number is right and the bar on the toolbelt icon is stale
// until something else happens to redraw it.
_job.Player.inventory.CallOnToolbeltChangedInternal();
}
return spent;
} }
/// <summary>How long this particular pull takes. See the class comment for why the ray's /// <summary>How long this particular pull takes. See the class comment for why the ray's
Binary file not shown.
Binary file not shown.