4 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5 5a512260cc Кровавый камень: бесконечный заряд браслета
Эндгейм-версия расходника Пространственного браслета: то же место в слоте, что у
Кровавой сферы, но не тратится вовсе. Кода менять не пришлось - только конфиги.

ЗАОДНО ЭТО ПОЧИНКА УЖЕ ОТГРУЖЕННОГО ТЕКСТА. Сообщение о пустом слоте
(braceletSpatialVaultNoMod) и описание браслета обещают игроку "кровавую сферу
ИЛИ кровавый камень" на всех 13 языках с 15.09. Пока камня не было, оба текста
отправляли за предметом, которого в игре не существует.

СОВМЕСТИМОСТЬ СЕЙВОВ - ПЕРВОЕ, ЧТО ПРОВЕРЕНО. resourceBloodStone - НОВОЕ имя, в
существующих сейвах его нет, значит нет и ни одного стака, который читался бы по
другой байтовой раскладке. Это ровно то условие, нарушение которого в e362c62
убило персонажа: ItemValue.Read/Write гейтят блок модификаций одним условием
!(itemClass is ItemClassModifier), и класс предмета определяет раскладку каждого
стака в сейве. Камень заведён новым предметом, а не переводом существующего -
тем же решением, что и Кровавая сфера. Добавление нового <item_modifier>
раскладку уже записанных стаков не меняет: айди новых предметов дописываются в
name->id мэппинг сейва, байты старых стаков остаются как были.

БЕСКОНЕЧНАЯ ПРОЧНОСТЬ СДЕЛАНА ОТСУТСТВИЕМ СТРОК, А НЕ БОЛЬШИМ ЧИСЛОМ. У камня
сознательно нет ни ShowQuality, ни DegradationBreaksAfter, ни effect_group с
DegradationMax - сравнить со сферой, у которой все три есть. SpendCharge
(SpatialVaultPickupPatch.cs) списывает прочность только у модификации, чьё имя
равно ChargeItemName ("resourceBloodSphere"); камень под условие не попадает,
цикл делает continue, его UseTimes не трогает никто. Большой DegradationMax был
бы хуже: неподвижная полоска прочности бесполезна, а при MaxUseTimes == 0 она
рисуется ПОЛНОЙ, то есть забытая пассивка выглядела бы как "всё работает".

Три места в коде сверены на то, что камень пройдёт весь путь:

  1. Гейт пустого слота в Begin - это !_bracelet.HasMods(), то есть "в слоте
     что-нибудь есть", а НЕ "есть сфера по имени". Камень гейт проходит. Это был
     главный риск правки: искал бы гейт сферу по имени, камень не работал бы.
  2. SpendCharge вызывается ПОСЛЕ SetBlockRPC(..., Air) и bag.AddItem, а его
     результат только логируется - ветки "не заплатил -> откажи" нет. Блок с
     камнем уходит в хранилище, в логе "took ... into the vault for 0 of charge".
  3. Ветка max <= 0 в SpendCharge камня не касается: до неё доходят только
     предметы, прошедшие проверку имени.

ФАЙЛЫ

Config/item_modifiers.xml - новый item_modifier resourceBloodStone,
installable_tags="necroBracelet", свой modifier_tags="necroBraceletStone".
Extends="modGeneralMaster" param1="CustomIcon" - НЕ resourceRockSmall: с ним
приехали бы Action0 ThrowAway и ThrowableDecoy, и камень можно было бы метать
как отвлекающий (на сфере эта ошибка уже была и была исправлена). Вид в руке -
HoldType 40 плюс все три меша rock_smallPrefab плюс TintColor "220, 30, 45", как
у сферы и трёх камней мода. CustomIcon="BloodStone", без CustomIconTint:
иконка своя, рисованная, лежит в обеих папках атласа с 15.09.

Config/recipes.xml - рецепт на химстанции, craft_area="chemistryStation",
tags="learnable,chemStationCrafting,necroNecromancyApprentice": 50 праха зомби,
20 костей, 4 обычной крови, 1 кровь некроманта. Все четыре имени проверены по
файлам. Цена выше, чем читается по списку: крафт самой крови некроманта снимает
90% текущего ХП.

Config/progression.xml - камень добавлен в unlock_entry третьего display_entry
(unlock_level="2000", unlock_tier="1"). Это только строка в панели скилла;
открытие рецепта делает тег. 2000 здесь и 2000 у RecipeTagUnlocked обязаны
совпадать, иначе замок на панели разойдётся с доступностью рецепта.

Config/Localization.csv - два ключа, resourceBloodStone и
resourceBloodStoneDesc, 13 языков; ключей стало 135. Названия камня взяты из уже
переведённого braceletSpatialVaultNoMod, так что сообщение об отказе и имя в
инвентаре совпадают слово в слово. braceletSpatialVaultDesc дополнено на всех 13
языках: камень не тратится. Файл переписан через csv-модуль, round-trip без
правок даёт побайтово тот же файл - кавычки и CRLF остальных строк не поехали.

ОПИСАНИЯ. README.md, SITE_DESCRIPTION.html и NEXUS_DESCRIPTION.bbcode доведены
до текущего состояния мода: сфера и камень добавлены в таблицы тиров и в список
арсенала, у браслета сказано, что забор блоков питается зарядом в слоте,
счётчик ключей 130 -> 135. Заодно снято утверждение "весь контент проходит тесты
в игре" - оно перестало быть верным: расход сферы в игре подтверждён, камень в
игру ещё не грузился.

НЕ ПРОВЕРЕНО В ИГРЕ: камень целиком. Всё выше - сверка по файлам и по
декомпиляции. Сценарий проверки в BACKLOG.md; главное, на что смотреть, -
отсутствие полоски прочности у камня в слоте и "for 0 of charge" в логе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:24:22 +03:00
AlexCubeandClaude Opus 5 896501dcd8 Откат крови и Кровавая сфера вместо неё
Чинит поломку сейвов, внесённую предыдущим коммитом e362c62, и заменяет её
механику на безопасную.

ЧТО БЫЛО СЛОМАНО. e362c62 перенёс resourceNecromancerBlood из items.xml в
item_modifiers.xml, чтобы кровь вставлялась в Пространственный браслет. Класс
предмета определяет БАЙТОВУЮ РАСКЛАДКУ каждого его стака в сейве:

    ItemValue.Read  строка 1094:  if ((version > 4 || HasQuality) && !(itemClass is ItemClassModifier))
    ItemValue.Write строка 1228:  if (!(ItemClass is ItemClassModifier))

Обычный предмет пишет байт числа модификаций и байт числа косметических слотов;
ItemClassModifier не пишет ни того, ни другого. Сейв, записанный до переноса,
после переноса читается со сдвигом - поток съезжает на первом же стаке крови, и
PlayerDataFile.Load падает. Бэкап .ttp.bak умирает вместе с основным файлом: он
того же формата. В тестовом мире персонаж потерян вместе с бэкапом, игра
откатилась на Respawning: NewGame.

В коммите e362c62 написано "сейв цел" со ссылкой на assignIdsFromMapping. Айди
действительно берутся из сохранённого name->id мэппинга - но ломается не айди, а
раскладка байтов, и к мэппингу это отношения не имеет. Была проверена не та вещь.

ОТКАТ. Кровь вернулась в items.xml обычным <item>. Набор её свойств сверен с
7172681 и совпадает посимвольно: убраны и Stacknumber=1, и прочность
(ShowQuality + DegradationBreaksAfter + effect_group DegradationMax). Обе правки
были безвредны для сейва, но существовали ради отменённой механики - по указанию
пользователя откат доведён до "как было", а не до "как было плюс безвредное".
Стак снова 15, наследуется от medicalBloodBag.

В комментарии у предмета оставлено предупреждение с номерами строк Read/Write -
единственное, что вынесено из аварии, и единственное, ради чего стоит читать этот
комментарий целиком.

КРОВАВАЯ СФЕРА. Расходник браслета теперь отдельный предмет, и это ровно то, что
делает правку безопасной: resourceBloodSphere - НОВОЕ имя, в старых сейвах его
нет, значит нет и ни одного стака, который читался бы по другой раскладке. Общее
правило, выведенное из аварии: предмет, который уже мог попасть в чужой
инвентарь, нельзя переводить между ItemClass и ItemClassModifier ни в какую
сторону - нужна модификация, заводи новый предмет.

Продиктовано: доступна на первом грейде, без станка, 1 кровь некроманта + 5 праха
зомби дают две сферы, прочность 500. В руке - камень с алым тинтом.

  - item_modifiers.xml: installable_tags="necroBracelet", свой modifier_tags,
    type="attachment", DegradationMax 500 в tiered="false" группе.
    Extends=modGeneralMaster, а НЕ resourceRockSmall: вместе с камнем приезжали бы
    Action0 ThrowAway и ThrowableDecoy, то есть сферу можно было бы метать.
    Меш берётся строкой Meshfile, наследовать ради него весь предмет не нужно.
  - Вид в руке: HoldType 40 и все три меша (Meshfile/HandMeshfile/DropMeshfile) на
    rock_smallPrefab плюс TintColor "220, 30, 45". Путь проверенный - на этом же
    меше с таким же тинтом в моде живут Камень духов и оба портальных камня.
  - recipes.xml: count=2, без craft_area и без тега разблокировки (группа 1
    открыта с уровня 1, рецепт без тега доступен всегда - как у Камня духов).
  - progression.xml: сфера в unlock_entry группы 1, unlock_tier="1".
  - SpatialVaultPickupPatch: SpendBlood -> SpendCharge, имя из новой константы
    ChargeItemName. Гейт пустого слота в Begin снова включён - после отката он был
    временно закомментирован, потому что вставлять было нечего.

ИКОНКИ. Свои, от пользователя: BloodSphere.png и BloodStone.png, 160x160 RGBA, в
ItemIconAtlas и ItemIconAtlasGreyscale. Серая копия обязательна - без неё у
заблокированной записи в скиллах не будет картинки вообще. Способ её получения
подобран сверкой с существующими файлами и совпал ПОБИТОВО: convert('L') на RGB
без альфы, альфа приклеивается обратно отдельным каналом. Записано в BACKLOG.md,
раньше это нигде не было зафиксировано. Иконка камня положена заранее - самого
предмета ещё нет, он запланирован.

ПРОВЕРЕНО, ЧТО НИЧЕГО БОЛЬШЕ НЕ ЕДЕТ. Сверка с 7172681: набор <item> в items.xml
не изменился, в item_modifiers.xml единственное добавление - resourceBloodSphere,
и ни у одного существующего предмета не менялись Tags, ShowQuality и Stacknumber.
То есть ни один предмет не сменил класс и не сменил раскладку.

В игре: мир грузится без ошибок, сфера крафтится и тратится - в логе
"sphere in slot 0 now 12/500 used" за 12-секундный канал.

---

Revert the blood, and a Blood Sphere in its place

Fixes the save corruption introduced by e362c62 and replaces the mechanic behind
it with a safe one.

WHAT WAS BROKEN. e362c62 moved resourceNecromancerBlood from items.xml into
item_modifiers.xml so it could be installed in the Spatial Bracelet. An item's
CLASS decides the BYTE LAYOUT of every stack of it in a save:

    ItemValue.Read  line 1094:  if ((version > 4 || HasQuality) && !(itemClass is ItemClassModifier))
    ItemValue.Write line 1228:  if (!(ItemClass is ItemClassModifier))

A plain item writes a modifications count and a cosmetic-slot count; an
ItemClassModifier writes neither. A save written before the move reads out of
step after it - the stream slips on the first blood stack and PlayerDataFile.Load
throws. The .ttp.bak backup dies with the main file, being the same format. In the
test world the character was lost along with its backup and the game fell back to
Respawning: NewGame.

e362c62's message claimed "saves are safe", citing assignIdsFromMapping. Item ids
really do come from the stored name->id mapping - but what breaks is not the id,
it is the byte layout, and the mapping has nothing to do with it. The wrong thing
was verified.

THE REVERT. The blood is a plain <item> in items.xml again. Its property set was
diffed against 7172681 and matches character for character: both Stacknumber=1 and
the durability (ShowQuality + DegradationBreaksAfter + the DegradationMax
effect_group) are gone. Both were harmless to the save format, but both existed
only to serve the cancelled mechanic - on the user's instruction the revert goes
back to "as it was", not "as it was plus whatever I judged harmless". The stack is
15 again, inherited from medicalBloodBag.

A warning carrying the Read/Write line numbers stays in the item's comment - the
one thing worth keeping out of this accident.

THE BLOOD SPHERE. The bracelet's charge is its own item now, and that is precisely
what makes this safe: resourceBloodSphere is a NEW name, absent from every
existing save, so no stack of it can be read under the wrong layout. The general
rule the accident produced: an item that may already sit in someone's inventory
must never be moved between ItemClass and ItemClassModifier in either direction -
if a modifier is wanted, make a new item.

Dictated: available at the first grade, no workstation, 1 Necromancer's Blood +
5 zombie ash makes two spheres, durability 500. Held, it is a stone with a
scarlet tint.

  - item_modifiers.xml: installable_tags="necroBracelet", its own modifier_tags,
    type="attachment", DegradationMax 500 in a tiered="false" group.
    Extends=modGeneralMaster, NOT resourceRockSmall: the rock would have brought
    Action0 ThrowAway and ThrowableDecoy with it, making the sphere throwable.
    The mesh comes from the Meshfile line; inheriting a whole item for it is not
    needed.
  - Held look: HoldType 40 and all three meshes (Meshfile/HandMeshfile/
    DropMeshfile) on rock_smallPrefab, plus TintColor "220, 30, 45". A proven
    path - the Spirit Stone and both portal stones already live on that mesh with
    that same kind of tint.
  - recipes.xml: count=2, no craft_area and no unlock tag (group 1 opens at level
    1, and a recipe with no tag is simply always available, as with the Spirit
    Stone).
  - progression.xml: the sphere joins group 1's unlock_entry at unlock_tier="1".
  - SpatialVaultPickupPatch: SpendBlood -> SpendCharge, the name coming from a new
    ChargeItemName constant. The empty-slot gate in Begin is switched back on - it
    was commented out during the revert because nothing could be installed.

ICONS. The user's own art: BloodSphere.png and BloodStone.png, 160x160 RGBA, in
both ItemIconAtlas and ItemIconAtlasGreyscale. The greyscale copy is mandatory -
without it a locked skill entry has no picture at all. How those copies are made
was worked out by diffing against the existing files and matched BIT FOR BIT:
convert('L') over RGB without the alpha, with the alpha merged back as its own
channel. Written up in BACKLOG.md; it had never been recorded anywhere. The
stone's icon is filed ahead of the item, which is still only planned.

VERIFIED THAT NOTHING ELSE SHIFTS. Diffed against 7172681: the set of <item>
entries in items.xml is unchanged, the only addition to item_modifiers.xml is
resourceBloodSphere, and no existing item had its Tags, ShowQuality or Stacknumber
changed. No item changed class, and no item changed layout.

In game: the world loads clean, and the sphere crafts and drains - the log shows
"sphere in slot 0 now 12/500 used" for a 12-second channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnwP2Dt1vk8bUPJ452EoVL
2026-09-15 23:50:37 +03:00
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
AlexCubeandClaude Opus 5 20af2bbe6c Торговец снова становится призраком после выгрузки чанка
Баг со стрима: торговец, ставший чёрно-белым и полупрозрачным, наутро снова
обычный человек. Догадка была про обновление ассортимента - не подтвердилась, и
это стоит записать, потому что по часам лавки модель не трогает ВООБЩЕ ничего:
TraderData при сбросе переписывает только PrimaryInventory и lastInventoryUpdate,
а TraderArea.SetClosed ходит по блокам TraderOnOff - двери, замки, BlockLight,
динамик - и до сущности торговца не дотягивается.

Настоящая причина в EntityFactory.CreateEntityOperation.CompleteEntity:
`entity.entityId = ecd.id`, то есть сохранённый айди ВОССТАНАВЛИВАЕТСЯ. Торговцы
стримятся на подходе и точно так же выгружаются, когда игрок уходит на ночь.
Возвращается он - торговец собран заново: новый GameObject, новые рендереры,
ванильные материалы, ТОТ ЖЕ entityId. А в патче стоял HashSet<int> Ghosted -
"этот айди я уже обработал". Айди в наборе есть, свип проходит мимо, торговец
остаётся живым человеком до конца сессии. Отсюда и "на следующее утро": симптом
идёт не за часами лавки, а за чанком, в котором лавка стоит.

Ghosted стал Dictionary<int, GhostBody>, где GhostBody держит массив рендереров,
которые патч взял себе. IsIntact проверяет их: у Unity уничтоженный объект
сравнивается с null, так что подмена модели видна прямо, и та же проверка
бесплатно закрывает любую другую пересборку, не только выгрузку чанка.

Prune чистит Converted и TintedMaterials от уничтоженных объектов. Без него оба
списка росли бы на одного торговца за каждую пересборку, а Retint/Reapply ходили
бы по обломкам. Материал, выданный через renderer.materials, принадлежит
рендереру и умирает вместе с ним - одного прохода хватает на оба списка.

В лог добавлена строка "entity <id> came back with a new model" - ровно та,
которой не хватало, чтобы найти это за один заход вместо разбора в декомпиляторе.

Счётчик в necroghost переименован: со словарём он означает "торговцев под
присмотром сейчас", а не "id, которые когда-либо видели" - он теперь падает и
растёт.

Не проверено в игре: нужен заход к торговцу, выгрузка лавки и возвращение.

---

Traders go back to being ghosts after a chunk unload

Reported from the stream: a trader who had gone black-and-white and translucent
was an ordinary person again the next morning. The guess was the restock - it was
wrong, and that is worth recording, because nothing on the shop's clock touches
the model at all: TraderData's reset rewrites PrimaryInventory and
lastInventoryUpdate only, and TraderArea.SetClosed walks TraderOnOff blocks -
doors, locks, BlockLight, speaker - and never reaches the trader entity.

The real cause is in EntityFactory.CreateEntityOperation.CompleteEntity:
`entity.entityId = ecd.id`, so the saved id is RESTORED. Traders are streamed in
on approach and streamed out the same way when the player leaves for the night.
On return the trader is rebuilt from scratch - new GameObject, new renderers, the
game's own materials - carrying THE SAME entityId. The patch held a
HashSet<int> Ghosted, meaning "this id is done". The id was still in the set, the
sweep skipped him, and he stayed an ordinary person for the rest of the session.
Hence "the next morning": the symptom follows the chunk the shop sits in, not the
shop's clock.

Ghosted is now a Dictionary<int, GhostBody>, the value holding the renderers the
patch took over. IsIntact tests them: Unity's destroyed objects compare equal to
null, so a swapped model is directly visible, and the same check covers any other
rebuild for free.

Prune drops destroyed entries from Converted and TintedMaterials. Without it both
lists would grow by one trader's worth per rebuild and Retint/Reapply would be
walking the wreckage. A material assigned through renderer.materials is owned by
that renderer and dies with it, so one pass settles both.

A log line was added - "entity <id> came back with a new model" - the exact line
that was missing to find this in one visit rather than in a decompiler.

The necroghost counter was reworded: with a dictionary it means "traders held as
ghosts right now", not "ids ever seen" - it now falls as well as rises.

Not tested in game: needs a visit, a shop unload and a return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnwP2Dt1vk8bUPJ452EoVL
2026-09-15 22:31:41 +03:00
19 changed files with 722 additions and 94 deletions
File diff suppressed because one or more lines are too long
+193
View File
@@ -324,4 +324,197 @@
</item_modifier> </item_modifier>
</append> </append>
<!-- "Кровавая сфера" (Blood Sphere) - расходный заряд Пространственного браслета.
Название и рецепт продиктованы 2026-09-15: «Кровавая сфера. Доступна на первом грейде.
Станки не нужны. Ингридиенты: Кровь некроманта, 5 праха зомби. По одному рецепту
изготавливается две сферы. Прочность сферы 500.»
ЗАЧЕМ ОНА ВООБЩЕ СУЩЕСТВУЕТ - и почему это НЕ «кровь, вставляемая в браслет». 15.09.2026
кровь перенесли в этот файл, чтобы она вставлялась в браслет, и это уничтожило персонажа в
сейве вместе с бэкапом: класс предмета определяет байтовую раскладку его стака
(ItemValue.Read:1094 / Write:1228 - обычный предмет пишет байт числа модификаций,
ItemClassModifier не пишет), и старый сейв стал нечитаемым. Полный разбор - в BACKLOG.md и
в большом предупреждении у крови в items.xml.
Сфера обходит это тем, что она НОВЫЙ предмет: в старых сейвах её нет, значит нет и ни
одного стака, который читался бы по другой раскладке. Это общее правило, а не уловка:
нужна модификация - заводи новый предмет, никогда не переводи существующий.
Атрибуты - по образцу модов ножа:
installable_tags="necroBracelet" - иначе модификация лезла бы в ЛЮБОЙ предмет
(CanSwap короткозамыкается на InstallableTags.IsEmpty);
modifier_tags="necroBraceletSphere" - свой, чтобы не конкурировать с будущим Кровавым
камнем через MaxModsAllowed;
blocked_tags НЕ задан - у браслета в тегах "noMods", объявить его тут значило бы
заблокировать самому себе установку;
type="attachment" - сферу можно вынуть обратно. -->
<append xpath="/item_modifiers">
<item_modifier name="resourceBloodSphere" installable_tags="necroBracelet" modifier_tags="necroBraceletSphere" type="attachment">
<!-- Extends на modGeneralMaster - та же база, что у шести модов ножа: даёт Group
"Mods", звуки mod_grab/mod_place, Stacknumber 1 и CreativeMode None.
param1="CustomIcon" исключает наследование родительского missingIcon.
РАНЬШЕ ЗДЕСЬ СТОЯЛ Extends="resourceRockSmall", И ЭТО БЫЛА ОШИБКА. Камень нужен
был только ради вида, а вместе с ним приезжали Action0 Class="ThrowAway",
ThrowableDecoy="true" и DistractionTags - то есть сферу можно было бы метать как
отвлекающий камень. Меш берётся строкой Meshfile ниже; наследовать ради него
весь предмет не нужно. -->
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="DescriptionKey" value="resourceBloodSphereDesc"/>
<!-- В РУКЕ - КАМЕНЬ С АЛЫМ ТИНТОМ (указание 2026-09-15: «в руке и сфера и кровавый
камень пусть будут как камень с алым тинтом»).
Это уже проверенный в этом моде путь, а не догадка: Камень духов, Синий и Чёрный
порталы (items.xml) сидят ровно на этом меше с ровно таким тинтом и в игре
работают - зелёный, синий и чёрный камни соответственно. Поэтому взят их набор
целиком: HoldType 40 плюс ВСЕ ТРИ меша. Три, а не один, потому что это три разные
ситуации - Meshfile общий, HandMeshfile в руке, DropMeshfile лежащим на земле, и
у тех трёх предметов они выписаны явно именно поэтому.
TintColor - ТРИПЛЕТ "R, G, B", а НЕ hex. Это другая ручка, чем CustomIconTint
выше (там hex): ItemClass парсит их разными путями - Color32 через запятые против
ParseHexColor. Перепутать легко, и на модах ножа это уже стоило круга.
ПОЧЕМУ ЗДЕСЬ ТИНТ РАБОТАЕТ, А НА БАНКЕ КРОВИ НЕ СРАБОТАЛ. 10.09 тинт предмета на
меш чая из золотарника не подействовал вообще: у шейдера Game_EntityTintMaskSSS
выигрывает собственный _Color материала. У rock_smallPrefab такого конфликта нет -
доказательство лежит в самом моде, три перекрашенных камня в игре видны. -->
<property name="HoldType" value="40"/>
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="HandMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="TintColor" value="220, 30, 45"/>
<!-- Своя рисованная иконка, получена 2026-09-15 (exch/bloodSphere.png, 160x160 RGBA,
как все остальные 28). Заглушка со спрайтом Камня духов и багровым тинтом,
стоявшая тут несколько часов, снята.
CustomIconTint НЕ ЗАДАЁТСЯ, и это тот же принцип, что у всех рисованных иконок
мода (шесть модов ножа, Кровь, Жертвенная кожа): тинт существует, чтобы
заимствованный ванильный спрайт не читался как предмет, из которого он взят. На
готовой работе он бы просто её затемнил. -->
<property name="CustomIcon" value="BloodSphere"/>
<!-- Прочность 500 (указание). Две ручки, обе обязательны - тот же разбор, что у крови
в items.xml: ShowQuality рисует полоску, а само число идёт пассивкой
DegradationMax. Без пассивки прочность равна нулю, а полоска при MaxUseTimes == 0
рисуется ПОЛНОЙ, то есть забытый эффект выглядит как "всё работает".
tiered="false" обязателен: HasQuality читается как Effects.IsOwnerTiered(), и
тированная группа превратила бы сферу в предмет с качеством - тиры, рамка.
Гейта по тегу здесь, в отличие от крови, НЕ НУЖНО. У модификации effect_group
применяется к предмету-хозяину, и у крови пассивку приходилось гейтить, чтобы
1000 прочности не досталась браслету. Сфера же и есть расходник браслета: пусть
он её и тратит. Браслету от DegradationMax ничего не будет - ему эту прочность
никто не списывает (SpendCharge трогает только сферу), а полоски у него нет. -->
<property name="ShowQuality" value="true"/>
<!-- true: опустевшая сфера должна исчезать, а не лежать "сломанной" в ожидании
ремонта. Само удаление из слота делает SpendCharge - см. SpatialVaultPickupPatch. -->
<property name="DegradationBreaksAfter" value="true"/>
<effect_group name="resourceBloodSphere" tiered="false">
<passive_effect name="DegradationMax" operation="base_set" value="500"/>
</effect_group>
<!-- Stacknumber 1 приходит из modGeneralMaster, своя строка не нужна. -->
<property name="EconomicValue" value="0"/>
<property name="SellableToTrader" value="false"/>
</item_modifier>
</append>
<!-- КРОВАВЫЙ КАМЕНЬ (Blood Stone), продиктовано 2026-09-15, сделано 2026-09-16: «По сути это
будет тот же флакон, но с бесконечной прочностью и иконкой камня с алым тинтом.»
Эндгейм-версия расходника браслета: то же, что Кровавая сфера, только не тратится.
ПОЧЕМУ ЭТО БЫЛО СРОЧНО, А НЕ «ФИЧА ИЗ СПИСКА». Сообщение о пустом слоте
(braceletSpatialVaultNoMod) и описание браслета с 15.09 обещают игроку «кровавую сферу
ИЛИ кровавый камень» на всех 13 языках. Пока камня не было, отгруженный текст отправлял
за предметом, которого в игре нет. Так что эта правка - ещё и починка текста.
ПОЧЕМУ ЭТО БЕЗОПАСНО ДЛЯ СЕЙВОВ, и это здесь главный вопрос, а не побочный.
15.09 перенос Крови некроманта из items.xml сюда уничтожил персонажа в сейве: ItemValue
гейтит блок модификаций одним условием на чтении (Read, ~1094) и на записи (Write, ~1228)
- "!(itemClass is ItemClassModifier)", то есть КЛАСС ПРЕДМЕТА ОПРЕДЕЛЯЕТ БАЙТОВУЮ
РАСКЛАДКУ КАЖДОГО ЕГО СТАКА В СЕЙВЕ. Обычный предмет пишет байт "сколько модификаций",
ItemClassModifier не пишет ничего; после переезда читатель этот байт пропустил, поток
съехал, ближайший ReadString() упал, и .ttp с .ttp.bak умерли оба.
resourceBloodStone - НОВОЕ имя. В сейвах, записанных до этой правки, нет ни одного его
стака, значит нет и ни одной записи, которую пришлось бы читать по другой раскладке.
Именно поэтому камень заведён новым предметом, а не переводом уже существующего - тем же
решением, что и Кровавая сфера. Правило целиком: раздел про аварию в BACKLOG.md.
Атрибуты - по образцу сферы:
- installable_tags="necroBracelet" - обязательно. У CanSwap есть короткое замыкание
"InstallableTags.IsEmpty || ...", то есть модификация БЕЗ этого атрибута лезет в любой
предмет игры.
- modifier_tags СВОЙ (necroBraceletStone, не ...Sphere). Мод с совпадающим modifier_tags
считается против ItemClass.MaxModsAllowed - разбор в шапке этого файла. Слот у
браслета сейчас один, так что практической разницы нет, но правило "свой тег на
каждую модификацию" в моде записано, и ломать его незачем.
- blocked_tags НЕ задаётся: у браслета в тегах noMods, отрицательную половину он
обеспечивает сам.
-->
<append xpath="/item_modifiers">
<item_modifier name="resourceBloodStone" installable_tags="necroBracelet" modifier_tags="necroBraceletStone" type="attachment">
<!-- Та же база, что у сферы и у шести модов ножа: Group "Mods", звуки
mod_grab/mod_place, Stacknumber 1, CreativeMode None. param1="CustomIcon"
исключает наследование родительского missingIcon.
НЕ наследовать resourceRockSmall ради вида камня: вместе с видом приедут
Action0 Class="ThrowAway", ThrowableDecoy="true" и DistractionTags, то есть
камень можно будет метать как отвлекающий. Меш берётся строками ниже. На сфере
эта ошибка уже была и была исправлена 15.09. -->
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="DescriptionKey" value="resourceBloodStoneDesc"/>
<!-- В РУКЕ - КАМЕНЬ С АЛЫМ ТИНТОМ (указание 2026-09-15: «в руке и сфера и кровавый
камень пусть будут как камень с алым тинтом»). Набор скопирован со сферы, а та -
с трёх камней мода (Камень духов, Синий и Чёрный порталы), которые на этом же
меше с этим же тинтом в игре видны зелёным, синим и чёрным.
Три меша, а не один, потому что это три разные ситуации: Meshfile общий,
HandMeshfile в руке, DropMeshfile лежащим на земле.
TintColor - ТРИПЛЕТ "R, G, B", а не hex; hex берёт CustomIconTint, и это другая
ручка (Color32 через запятые против ParseHexColor). Перепутать легко, на модах
ножа это уже стоило круга. -->
<property name="HoldType" value="40"/>
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="HandMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="TintColor" value="220, 30, 45"/>
<!-- Своя рисованная иконка, лежит с 15.09 в обеих папках атласа
(UIAtlases/ItemIconAtlas/BloodStone.png + ItemIconAtlasGreyscale/BloodStone.png).
Серая копия обязательна: заблокированная запись в панели скилла рисуется из
greyscale-атласа, без неё у записи не было бы картинки вообще.
CustomIconTint НЕ ЗАДАЁТСЯ - тинт нужен заимствованным ванильным спрайтам, на
готовой работе он бы её просто затемнил. Тот же принцип, что у сферы. -->
<property name="CustomIcon" value="BloodStone"/>
<!-- БЕСКОНЕЧНАЯ ПРОЧНОСТЬ СДЕЛАНА ОТСУТСТВИЕМ СТРОК, А НЕ БОЛЬШИМ ЧИСЛОМ.
Здесь СОЗНАТЕЛЬНО нет ни ShowQuality, ни DegradationBreaksAfter, ни effect_group
с DegradationMax - сравнить со сферой выше, у которой все три есть.
Почему этого достаточно: SpendCharge (HarmonySrc/SpatialVaultPickupPatch.cs)
списывает прочность ТОЛЬКО у модификации, чьё имя совпадает с ChargeItemName
("resourceBloodSphere"). Камень под это условие не попадает по определению -
цикл делает continue, UseTimes камня не трогает никто и никогда. Кода менять не
пришлось вообще; комментарий в SpendCharge это заранее и обещает («a mod that is
not the charge pays nothing, is not emptied, and the pull still happens»).
Почему НЕ выставлять огромный DegradationMax: полоска прочности, которая никогда
не двигается, хуже её отсутствия. Плюс ловушка, уже разобранная на флаконе крови:
при MaxUseTimes == 0 полоска рисуется ПОЛНОЙ, то есть забытая пассивка выглядит
как "всё работает".
Тег necroBloodFlask здесь тоже не нужен, но уже по другой причине, чем было
записано в спеке: после полного отката Крови некроманта (15.09) этого тега в моде
не осталось совсем - он существовал только чтобы гейтить пассивку прочности
флакона. Пункт снят сам собой. -->
<property name="EconomicValue" value="0"/>
<property name="SellableToTrader" value="false"/>
</item_modifier>
</append>
</config> </config>
+82 -50
View File
@@ -1288,63 +1288,79 @@
its mesh/material/hold/pickup-sound (reused wholesale, thematically it IS a bag of blood, 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 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 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 icon, confirmed originally on the Knife).
"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 ("Иконка ЭТОТ ПРЕДМЕТ НИКОГДА НЕ ДОЛЖЕН СТАТЬ <item_modifier>. ЭТО НЕ СТИЛЬ, ЭТО СЕЙВЫ.
такая же как и у обычной крови, но тинт затемнённый") - only TintColor differs (dark, 15.09.2026 он был перенесён в item_modifiers.xml, чтобы вставляться в Пространственный
near-black red vs. no tint on the vanilla bag). браслет, и это уничтожило персонажа в тестовом мире - вместе с бэкапом. Разбор целиком в
BACKLOG.md, здесь суть, потому что соблазн повторить велик:
ItemValue.Read строка 1094: if ((version > 4 || HasQuality) && !(itemClass is ItemClassModifier))
ItemValue.Write строка 1228: if (!(ItemClass is ItemClassModifier))
Класс предмета решает БАЙТОВУЮ РАСКЛАДКУ каждого его стака в сейве: обычный предмет пишет
байт числа модификаций, ItemClassModifier - не пишет. Значит любой сейв, записанный до
переноса, после переноса читается со сдвигом: поток съезжает, ближайший ReadString()
получает мусор, PlayerDataFile.Load падает с "output char buffer is too small", и игра
откатывается на NewGame. Бэкап .ttp.bak умирает вместе с основным файлом - он старого
формата ровно так же. Кровь лежит в сейвах у всех, кто поставил 1.1.0 с Nexus.
Правило на будущее: предмет, который уже мог попасть в чужой инвентарь, нельзя переводить
между ItemClass и ItemClassModifier ни в какую сторону. Нужна модификация - это НОВЫЙ
предмет с новым именем, которого в старых сейвах нет. Проверка "айди не поедут"
(assignIdsFromMapping) к этому отношения не имеет и ничего тут не гарантирует - именно на
неё я и посмотрел вместо раскладки.
Crafting rules ("для создания нужна пустая банка и наличие любого ножа. При крафте нужно Crafting rules ("для создания нужна пустая банка и наличие любого ножа. При крафте нужно
отнимать у персонажа 90% имеющегося ХП") - the jar is a normal recipe ingredient (see отнимать у персонажа 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 recipes.xml), but "any knife present, not consumed" and "cost 90% of current HP" have NO
vanilla XML equivalent (recipes.xml has no per-ingredient "required but not consumed" flag, 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 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 HarmonySrc/NecromancerBloodPatch.cs instead. -->
mechanism and an important caveat about ingredient-refund timing that's flagged there, not
glossed over. -->
<append xpath="/items"> <append xpath="/items">
<item name="resourceNecromancerBlood"> <item name="resourceNecromancerBlood">
<property name="Extends" value="medicalBloodBag"/> <property name="Extends" value="medicalBloodBag"/>
<property name="DescriptionKey" value="resourceNecromancerBloodDesc"/> <property name="DescriptionKey" value="resourceNecromancerBloodDesc"/>
<!-- Custom art delivered 2026-08-30 (exch/NecromantsBlood.png, 160x160, copied to <!-- Своя рисованная иконка, 2026-08-30. CustomIcon задаётся явно даже при Extends. -->
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"/> <property name="CustomIcon" value="NecromantsBlood"/>
<!-- СВОЯ БАНКА С КРОВЬЮ, 2026-09-10 (указание: «берём чай из золотарника, и жёлтое <!-- Своя банка с кровью, 2026-09-10 (указание: «берём чай из золотарника, и жёлтое
заменяем на кровавый цвет, с фиолетовыми оттенками»). заменяем на кровавый цвет, с фиолетовыми оттенками»). Заодно чинилось расхождение
текста и модели: описание говорит «Банка, наполненная кровью», а наследуемый
Заодно чинится расхождение текста и модели: описание предмета medicalBloodBag показывал sackPrefab - обычный мешок.
(resourceNecromancerBloodDesc) с самого начала говорит «Банка, наполненная кровью
самого некроманта», а наследуемый medicalBloodBag показывает ПОЧЕМУ НЕ ХВАТИЛО ТИНТА - ПРОВЕРЕНО В ИГРЕ. Ванильный префаб чая плюс TintColor:
@:Other/Items/Misc/sackPrefab.prefab - обычный мешок. Банка вернее и по механике: банка осталась чаем из золотарника, тинт предмета на этот меш НЕ ПОДЕЙСТВОВАЛ
рецепт и так требует пустую банку (recipes.xml, NecromancerBloodPatch.cs). ВООБЩЕ - у шейдера Game_EntityTintMaskSSS выигрывает собственный _Color материала.
Поэтому TintColor здесь не задаётся совсем.
ПОЧЕМУ НЕ ХВАТИЛО ТИНТА - ПРОВЕРЕНО В ИГРЕ. Сначала пробовали дёшево, без бандла:
ванильный префаб чая плюс TintColor. Проверка 2026-09-10 показала, что банка И по сути: кровь отличается от чая не цветом, а тем, что она непрозрачная, тёмная
осталась чаем из золотарника - тинт предмета на этот меш НЕ ПОДЕЙСТВОВАЛ ВООБЩЕ. и густая, с плёнкой на стекле. Жидкость ПЕРЕРИСОВАНА по яркости, а не перекрашена
У шейдера Game_EntityTintMaskSSS выигрывает собственный _Color материала (у чая множителем - генератор _private/tools/make_necroblood_textures.py.
жёлтый, 166,133,37), и свойство предмета его не перебивает. Поэтому TintColor здесь
не задаётся совсем: он ничего не даёт и только вводил бы в заблуждение. HoldType 3 - хват банки вместо 45 (мешок), Material Mglass - стекло вместо ткани. -->
И по сути: кровь отличается от чая не цветом, а тем, что она непрозрачная, тёмная и
густая, с плёнкой на стекле. Поэтому жидкость ПЕРЕРИСОВАНА по яркости, а не
перекрашена множителем - генератор _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="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroBloodPrefab.prefab"/>
<property name="HoldType" value="3"/> <property name="HoldType" value="3"/>
<property name="Material" value="Mglass"/> <property name="Material" value="Mglass"/>
<!-- НИ Stacknumber, НИ ПРОЧНОСТИ ЗДЕСЬ НЕТ, И ЭТО НАМЕРЕННО.
15.09.2026 крови на один заход выдали и то, и другое: стак по одной банке и
прочность 1000. Обе правки существовали ради одного - кровь должна была стать
расходником, вставляемым в Пространственный браслет. Эта затея откачена (она
ломала сейвы, см. предупреждение выше), расходником стала Кровавая сфера, и по
прямому указанию пользователя кровь возвращена к тому, чем была:
- стак снова 15 - наследуется от medicalBloodBag, своей строки Stacknumber
больше нет. Своя строка была нужна только чтобы перебить наследуемое 15 на 1;
- прочности нет вовсе: ни ShowQuality, ни DegradationBreaksAfter, ни
effect_group с DegradationMax. Тратил её браслет; тратить стало некому, а
полоска, которая никогда не двигается, хуже, чем её отсутствие.
Кровь снова то, чем была с самого начала: ингредиент рецептов, и только.
Если прочность когда-нибудь понадобится - разбор обеих ручек (пассивка
DegradationMax плюс отдельное свойство ShowQuality, и ловушка с полной полоской
при MaxUseTimes == 0) лежит в BACKLOG.md, повторно раскапывать не нужно. -->
<property name="EconomicValue" value="0"/> <property name="EconomicValue" value="0"/>
</item> </item>
</append> </append>
@@ -1398,7 +1414,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 +1553,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>
+17 -2
View File
@@ -101,7 +101,11 @@
скиллы") - unlock_tier="1" alongside the other always-available Tier-1 items, скиллы") - unlock_tier="1" alongside the other always-available Tier-1 items,
matching its recipe's own necroNecromancyAdept tag in recipes.xml (see that matching its recipe's own necroNecromancyAdept tag in recipes.xml (see that
file's comment - both express the same "available immediately" intent). --> file's comment - both express the same "available immediately" intent). -->
<unlock_entry item="thrownStoneSpirit,necroWpnBladeNecroKnife,thrownStonePortalBlue,necroHeresyPyramid" unlock_tier="1"/> <!-- resourceBloodSphere добавлена 2026-09-15 (указание «доступна на первом
грейде»): tier 1 - это уровень 1, то есть с самого начала, как и весь
остальной базовый набор. Её рецепт в recipes.xml намеренно без тега
разблокировки - для tier 1 тег не нужен, группа открыта сразу. -->
<unlock_entry item="thrownStoneSpirit,necroWpnBladeNecroKnife,thrownStonePortalBlue,necroHeresyPyramid,resourceBloodSphere" unlock_tier="1"/>
<unlock_entry item="braceletSpatialVault" unlock_tier="2"/> <unlock_entry item="braceletSpatialVault" unlock_tier="2"/>
<!-- ЧЕТЫРЕ МОДА НОЖА ПЕРЕЕХАЛИ СЮДА ИЗ ГРУППЫ 2, 2026-09-09. Продиктовано: <!-- ЧЕТЫРЕ МОДА НОЖА ПЕРЕЕХАЛИ СЮДА ИЗ ГРУППЫ 2, 2026-09-09. Продиктовано:
"Питьё важно в тот же день. Оно должно быть доступно после 30 убитых зомби. "Питьё важно в тот же день. Оно должно быть доступно после 30 убитых зомби.
@@ -159,7 +163,18 @@
<unlock_entry item="necroModKnifeDeadStorm" unlock_tier="3"/> <unlock_entry item="necroModKnifeDeadStorm" unlock_tier="3"/>
</display_entry> </display_entry>
<display_entry icon="SummonZombieDog" name_key="craftingNecroNecromancyTier3Name" has_quality="false" unlock_level="2000"> <display_entry icon="SummonZombieDog" name_key="craftingNecroNecromancyTier3Name" has_quality="false" unlock_level="2000">
<unlock_entry item="bookSummonZombieDog,bookSummonInsectSwarm,bookSummonZombieGriffin" unlock_tier="1"/> <!-- resourceBloodStone добавлен 2026-09-16 (указание «доступен на третьем грейде
некромантии, в самом его начале»). Тир 3 - это и есть третий грейд, группа
«Ученик», а его единственный unlock_level равен 2000, то есть unlock_tier="1"
здесь и означает «в самом начале грейда, без своего смещения».
ЭТА СТРОКА - ТОЛЬКО ОТОБРАЖЕНИЕ. Открытие рецепта делает тег
necroNecromancyApprentice в recipes.xml вместе с RecipeTagUnlocked
level="2000,5000" ниже; display_entry о тегах не знает и рисует замок сам по
себе, по unlock_tier. Поэтому 2000 здесь и 2000 там ОБЯЗАНЫ совпадать -
иначе замок на панели разойдётся с реальной доступностью рецепта. Совпадают:
один порог, одно значение. -->
<unlock_entry item="bookSummonZombieDog,bookSummonInsectSwarm,bookSummonZombieGriffin,resourceBloodStone" unlock_tier="1"/>
</display_entry> </display_entry>
<display_entry icon="SummonZombieBear" name_key="craftingNecroNecromancyTier4Name" has_quality="false" unlock_level="3000"> <display_entry icon="SummonZombieBear" name_key="craftingNecroNecromancyTier4Name" has_quality="false" unlock_level="3000">
<unlock_entry item="bookSummonZombieBear,bookSummonZombieWolf,bookBanshee" unlock_tier="1"/> <unlock_entry item="bookSummonZombieBear,bookSummonZombieWolf,bookBanshee" unlock_tier="1"/>
+72
View File
@@ -376,4 +376,76 @@
<ingredient name="resourceYuccaFibers" count="10"/> <ingredient name="resourceYuccaFibers" count="10"/>
</recipe> </recipe>
</append> </append>
<!-- Кровавая сфера, продиктовано 2026-09-15: «Станки не нужны. Ингридиенты: Кровь некроманта,
5 праха зомби. По одному рецепту изготавливается две сферы.»
Ни craft_area, ни тега разблокировки: «первый грейд» - это группа 1 «Адепт», а она открыта
с уровня 1, и рецепт БЕЗ тега просто доступен всегда. Ровно так же сделан Камень духов
выше в этом файле - см. комментарий в progression.xml, там это правило записано словами.
В скиллах сфера показана отдельной строкой группы 1 с unlock_tier="1".
ЗАМЕЧЕНО, НЕ ИСПРАВЛЕНО: сам Пространственный браслет открывается на 20 убийствах
(unlock_tier="2" в группе 1), то есть сферу можно скрафтить на 19 уровней раньше, чем
появится предмет, в который её вставляют. По записанному правилу «пороги - по нужде, а не
по силе» ей место рядом с браслетом. Оставлено как продиктовано; переезд - это unlock_tier
с 1 на 2 в progression.xml плюс тег necroNecromancyLvl20 сюда.
Цена реальная, а не по списку: одна кровь некроманта стоит ещё и 90% текущего ХП на её
собственный крафт (NecromancerBloodPatch.cs). Две сферы за один заход это и учитывают. -->
<append xpath="/recipes">
<recipe name="resourceBloodSphere" count="2" tags="packMuleCrafting">
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceZombieAsh" count="5"/>
</recipe>
</append>
<!-- Кровавый камень, продиктовано 2026-09-15: «Рецепт: 50 праха зомби, 20 костей, 4 флакона
обычной крови, 1 кровь некроманта. Рецепт будет доступен на третьем грейде некромантии
(в самом его начале)... Рабочее место - химическая станция.»
Все четыре имени проверены по файлам, а не по памяти:
resourceZombieAsh - свой предмет мода (Config/items.xml)
resourceBone - ванильный (Data/Config/items.xml:23781)
medicalBloodBag - ванильный (Data/Config/items.xml:19196)
resourceNecromancerBlood - свой предмет мода (Config/items.xml:1321). После отката
15.09 он снова обычный <item> в items.xml, а не модификация.
ЦЕНА ВЫШЕ, ЧЕМ ЧИТАЕТСЯ ПО СПИСКУ: одна кровь некроманта стоит ещё и 90% текущего ХП на
её собственный крафт (NecromancerBloodPatch.cs).
«ТРЕТИЙ ГРЕЙД, В САМОМ ЕГО НАЧАЛЕ» = ровно порог группы 3 «Ученик», без собственного
смещения, то есть тег necroNecromancyApprentice и уровень 2000 (progression.xml,
RecipeTagUnlocked level="2000,5000"). Тег уже заведён и уже работает - свой
necroNecromancyLvl*-тег здесь не нужен, такие одноразовые уровни в моде заведены ровно
один раз (necroNecromancyLvl20 для браслета) и считаются исключением, а не приёмом.
В progression.xml камень добавлен в display_entry третьего тира (unlock_level="2000")
строкой скилл-панели - это чисто отображение, механику открытия делает тег.
ДВА ИМЕНИ, КОТОРЫЕ ЛЕГКО ПЕРЕПУТАТЬ, выписаны проверенными по ванили (образцы ammoGasCan,
carBattery в Data/Config/recipes.xml): craft_area="chemistryStation" - ПОЛНОЕ слово, а
тег - chemStationCrafting, СОКРАЩЁННОЕ "chem", не "chemistry".
Тег learnable - как у остальных гейтованных рецептов мода, чтобы рецепт не светился в
меню до открытия группы.
ПОБОЧНОЕ СЛЕДСТВИЕ, замеченное заранее: химстанция добавляет камню ВТОРОЙ порог поверх
уровня 2000 - она открывается своим путём (перк/схема), с Некромантией не связанным.
Практически к 2000 уровню она у игрока почти наверняка есть, но формально «третий грейд»
перестаёт быть единственным условием. Ручка одна: убрать craft_area и вернуть личный
крафт.
БАЛАНСОВАЯ ЗАМЕТКА: слот у браслета один, значит камень с бесконечной прочностью не
дополняет Кровавую сферу, а ВЫТЕСНЯЕТ её в тот момент, когда игрок его получает. Судя по
формулировке («тот же флакон, но с бесконечной прочностью») это и задумано - камень это
эндгейм-версия расходника, уровень 2000 как раз про это. Записано, чтобы потом не
удивляться, что сфера перестала расходоваться. -->
<append xpath="/recipes">
<recipe name="resourceBloodStone" count="1" craft_area="chemistryStation" tags="learnable,chemStationCrafting,necroNecromancyApprentice">
<ingredient name="resourceZombieAsh" count="50"/>
<ingredient name="resourceBone" count="20"/>
<ingredient name="medicalBloodBag" count="4"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
</recipe>
</append>
</config> </config>
+7 -2
View File
@@ -148,7 +148,12 @@ namespace NecromancerTome
/// <summary>Current value plus what it actually reached, in both units, and which way the /// <summary>Current value plus what it actually reached, in both units, and which way the
/// body is being faded. The count is the half that answers "did it do anything": 0 /// body is being faded. The count is the half that answers "did it do anything": 0
/// materials means no trader has been converted yet - they stream in on approach - not /// materials means no trader has been converted yet - they stream in on approach - not
/// that the number was refused.</summary> /// that the number was refused.
///
/// The trader count is "held as a ghost RIGHT NOW", not "seen this session": since the
/// 2026-09-15 fix, Ghosted is keyed by entity id but re-entered when a trader is rebuilt,
/// and a trader whose chunk has unloaded keeps his entry only until the next sweep finds
/// his model gone. So the number falls as well as rises, and that is correct.</summary>
public static void Report(string _prefix) public static void Report(string _prefix)
{ {
float alpha = GhostTraderPatch.GhostAlpha; float alpha = GhostTraderPatch.GhostAlpha;
@@ -157,7 +162,7 @@ namespace NecromancerTome
_prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " + _prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " +
alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode + alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode +
", applied to " + applied + " live material(s) across " + ", applied to " + applied + " live material(s) across " +
GhostTraderPatch.Ghosted.Count + " trader(s) converted this session."); GhostTraderPatch.Ghosted.Count + " trader(s) currently held as ghosts.");
} }
/// <summary>Percent out of what the user typed. StringParsers is the game's own parser and /// <summary>Percent out of what the user typed. StringParsers is the game's own parser and
+110 -8
View File
@@ -67,6 +67,11 @@ namespace NecromancerTome
/// Polling with ModEvents.UnityUpdate - the same approach PetFollowPatch.cs already uses here - /// Polling with ModEvents.UnityUpdate - the same approach PetFollowPatch.cs already uses here -
/// avoids guessing at the right moment inside someone else's character pipeline. A trader with /// avoids guessing at the right moment inside someone else's character pipeline. A trader with
/// no renderers yet is simply not marked done and is picked up on the next sweep. /// no renderers yet is simply not marked done and is picked up on the next sweep.
///
/// The same tick is what puts a trader BACK once the game has rebuilt him - see Ghosted, and
/// the bug of 2026-09-15 that taught this file the difference between an entity id and a
/// model. A spawn hook would not have helped there either: the entity was never re-created as
/// far as its id is concerned.
/// </summary> /// </summary>
public static class GhostTraderPatch public static class GhostTraderPatch
{ {
@@ -135,6 +140,16 @@ namespace NecromancerTome
public Material[] Originals; public Material[] Originals;
} }
/// <summary>What one trader was actually given, kept so the sweep can ask "is he STILL a
/// ghost" instead of only "have I seen this id". The renderers are the answer: a trader
/// that streams out and back in is rebuilt from scratch - new GameObject, new renderers,
/// the game's own materials - while keeping the id he was saved under, so an id on its own
/// says nothing about the model standing there now. See the Ghosted comment.</summary>
public struct GhostBody
{
public Renderer[] Renderers;
}
/// <summary>Every renderer taken over, in the order it was found. Pruned of destroyed /// <summary>Every renderer taken over, in the order it was found. Pruned of destroyed
/// renderers as they are walked; dropped wholesale when the world unloads.</summary> /// renderers as they are walked; dropped wholesale when the world unloads.</summary>
public static readonly List<GhostRenderer> Converted = new List<GhostRenderer>(); public static readonly List<GhostRenderer> Converted = new List<GhostRenderer>();
@@ -192,8 +207,29 @@ namespace NecromancerTome
/// beyond doubt: 1 = solid, 0 = gone, exactly like an alpha.</summary> /// beyond doubt: 1 = solid, 0 = gone, exactly like an alpha.</summary>
public static readonly string[] FadeNameHints = { "_Fade" }; public static readonly string[] FadeNameHints = { "_Fade" };
/// <summary>Entity ids already converted. Cleared when the world unloads.</summary> /// <summary>Traders already converted, by entity id, WITH the renderers each was given.
public static readonly HashSet<int> Ghosted = new HashSet<int>(); /// Cleared when the world unloads.
///
/// THE VALUE IS NOT DECORATION - it is the fix for "the trader stopped being a ghost the
/// next morning" (2026-09-15). This was a HashSet of ids, and an id is not enough:
/// EntityFactory restores `entity.entityId = ecd.id` from the save, so a trader who is
/// streamed out while the player is away (they are streamed IN on approach in the first
/// place - see the class comment) comes back as a BRAND NEW GameObject carrying the SAME
/// id, with the game's own materials on it. The set still held the id, the sweep skipped
/// him, and he stayed an ordinary living person for the rest of the session.
///
/// It is NOT the restock, which was the first guess and is worth writing down as ruled
/// out: TraderData's reset rewrites PrimaryInventory and lastInventoryUpdate and touches
/// no renderer, and TraderArea.SetClosed - the whole open/close cycle - only works doors,
/// lights and speakers. Nothing on the shop's clock ever reaches the model. What does is
/// the chunk the shop sits in, which is why the symptom looks like it follows the morning:
/// the player is away for the night, the trader unloads with his chunk, and he is rebuilt
/// when they walk back.
///
/// Holding the renderers makes the question answerable: Unity's destroyed objects compare
/// equal to null, so a trader whose model is gone is visible as such, and the same check
/// covers any other rebuild of the model for free.</summary>
public static readonly Dictionary<int, GhostBody> Ghosted = new Dictionary<int, GhostBody>();
/// <summary>Source shader names already described in the log, so the probe says each /// <summary>Source shader names already described in the log, so the probe says each
/// distinct thing once rather than once per trader per part.</summary> /// distinct thing once rather than once per trader per part.</summary>
@@ -252,21 +288,84 @@ namespace NecromancerTome
{ {
continue; continue;
} }
if (Ghosted.Contains(trader.entityId)) if (Ghosted.TryGetValue(trader.entityId, out GhostBody body))
{ {
continue; if (IsIntact(body))
{
continue;
}
// His model was destroyed and rebuilt under him. Drop what is known about the
// old one before building the new, or Converted and TintedMaterials keep
// entries for renderers and materials that no longer exist.
Debug.Log("[NecromancerTome] GhostTraderPatch: entity " + trader.entityId +
" came back with a new model - ghosting him again");
Ghosted.Remove(trader.entityId);
Prune();
} }
if (ApplyGreyscale(trader)) if (ApplyGreyscale(trader, out GhostBody fresh))
{ {
Ghosted.Add(trader.entityId); Ghosted[trader.entityId] = fresh;
}
}
}
/// <summary>Is this trader still wearing what we put on him? False the moment any part of
/// the model we converted has been destroyed - which is what a stream-out and back in
/// looks like from here, and equally what any other rebuild of the model would look like.
///
/// Deliberately NOT "does he have renderers we have not converted": a trader gains and
/// loses renderers in normal play (a held item, worn equipment), and treating that as a
/// rebuild would re-run the conversion on renderers already carrying our materials - whose
/// sharedMaterials hand back OUR clones, so the "originals" kept for the next mode switch
/// would be re-shaded ones with no way back. The known gap that leaves is a part of the
/// model built AFTER the first sweep reached him: it stays in colour until he next
/// reloads. Nothing like that has been seen on the six traders.</summary>
public static bool IsIntact(GhostBody _body)
{
if (_body.Renderers == null || _body.Renderers.Length == 0)
{
return false;
}
foreach (Renderer renderer in _body.Renderers)
{
if (renderer == null)
{
return false;
}
}
return true;
}
/// <summary>Drops every entry whose Unity object the game has destroyed. Both lists are
/// session-long and keyed by nothing - without this they grow by one trader's worth of
/// renderers and materials every time a trader is rebuilt, and Retint/Reapply would be
/// walking the wreckage. A material assigned through renderer.materials is owned by that
/// renderer and dies with it, so one pass settles both.</summary>
public static void Prune()
{
for (int i = Converted.Count - 1; i >= 0; i--)
{
if (Converted[i].Renderer == null)
{
Converted.RemoveAt(i);
}
}
for (int i = TintedMaterials.Count - 1; i >= 0; i--)
{
if (TintedMaterials[i].Material == null)
{
TintedMaterials.RemoveAt(i);
} }
} }
} }
/// <summary>False when there is nothing to work on yet (model not built), so the caller /// <summary>False when there is nothing to work on yet (model not built), so the caller
/// leaves this trader unmarked and tries again on the next sweep.</summary> /// leaves this trader unmarked and tries again on the next sweep. On true, _body carries
public static bool ApplyGreyscale(EntityTrader _trader) /// the renderers taken over, which is how the next sweep tells this trader from a rebuilt
/// one standing under the same entity id.</summary>
public static bool ApplyGreyscale(EntityTrader _trader, out GhostBody _body)
{ {
_body = default(GhostBody);
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true); Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true);
if (renderers == null || renderers.Length == 0) if (renderers == null || renderers.Length == 0)
{ {
@@ -279,6 +378,7 @@ namespace NecromancerTome
int converted = 0; int converted = 0;
int leversBefore = TintedMaterials.Count; int leversBefore = TintedMaterials.Count;
List<Renderer> taken = new List<Renderer>(renderers.Length);
foreach (Renderer renderer in renderers) foreach (Renderer renderer in renderers)
{ {
if (renderer == null || renderer is ParticleSystemRenderer) if (renderer == null || renderer is ParticleSystemRenderer)
@@ -293,11 +393,13 @@ namespace NecromancerTome
} }
Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources }); Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources });
taken.Add(renderer);
if (Convert(renderer, sources)) if (Convert(renderer, sources))
{ {
converted++; converted++;
} }
} }
_body.Renderers = taken.ToArray();
// The lever count is the half that answers "will the console command reach him": // The lever count is the half that answers "will the console command reach him":
// desaturation and opacity come from different properties, and the body had the first // desaturation and opacity come from different properties, and the body had the first
+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.
+178 -5
View File
@@ -130,10 +130,27 @@ 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";
/// <summary>What the bracelet burns to pull a block: the Кровавая сфера, dictated
/// 2026-09-15. Its own definition is in Config/item_modifiers.xml.
///
/// WHY A SEPARATE ITEM AND NOT THE BLOOD ITSELF - this is the scar of the 15.09 accident
/// and the reason not to "simplify" it back. The blood was moved into item_modifiers.xml
/// so it could be installed here, and that destroyed a character's save along with its
/// backup: an item's CLASS decides the byte layout of every stack of it
/// (ItemValue.Read:1094 / Write:1228), so a save written before the move became
/// unreadable. The sphere is a NEW name that no old save contains, which is what makes it
/// safe. Full write-up in BACKLOG.md.
///
/// The Кровавый камень, when it exists, goes in the same slot and is NOT charged here -
/// it is the infinite one. Nothing to add for it: this check names the sphere, so
/// anything else in the slot simply pays nothing.</summary>
public const string ChargeItemName = "resourceBloodSphere";
/// <summary>Unscaled time at which a cancel last opened the vault, or -1. Exists to stop /// <summary>Unscaled time at which a cancel last opened the vault, or -1. Exists to stop
/// ONE press from opening the vault TWICE: the cancel reacts to the button going down, /// ONE press from opening the vault TWICE: the cancel reacts to the button going down,
/// while the bracelet's ordinary power attack reacts to it coming back up, and those are /// while the bracelet's ordinary power attack reacts to it coming back up, and those are
@@ -169,12 +186,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 +306,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 +335,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 +411,119 @@ 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 = SpendCharge(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 charge");
}
/// <summary>Charges this pull to the Кровавая сфера in the bracelet's mod slot, one point
/// of durability per second of channel (user request 2026-09-15). Returns what was
/// actually taken.
///
/// WHY THE NAME CHECK AND NOT "whatever is in the slot". The slot is meant to take the
/// Кровавый камень too, and that one is explicitly the infinite version - it must pay
/// nothing. Naming the sphere here gets that for free: anything else installed is simply
/// not charged, and the pull still happens.
///
/// 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 SpendCharge(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 != ChargeItemName)
{
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: sphere 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: sphere in slot " + i +
" ran out (" + mod.UseTimes.ToString("0.#") + "/" + max +
") - sphere removed from the bracelet");
}
else
{
Debug.Log("[NecromancerTome] SpatialVaultPickup: sphere 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
+3 -3
View File
@@ -20,10 +20,10 @@ To uninstall, delete the folder. The mod adds items and one block, so a save tha
[b]A skill that levels from kills, not perk points.[/b] Necromancy counts every zombie you put down. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal: [b]A skill that levels from kills, not perk points.[/b] Necromancy counts every zombie you put down. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal:
[list] [list]
[*][b]Adept[/b] (from the start) - Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits [*][b]Adept[/b] (from the start) - Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits, Blood Sphere
[*][b]Adept[/b] (20 / 30 / 60 / 100 / 300) - Spatial Bracelet, then four knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense [*][b]Adept[/b] (20 / 30 / 60 / 100 / 300) - Spatial Bracelet, then four knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense
[*][b]Journeyman[/b] (500 / 1400 / 1700) - Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm [*][b]Journeyman[/b] (500 / 1400 / 1700) - Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm
[*][b]Apprentice[/b] (2000) - Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin [*][b]Apprentice[/b] (2000) - Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin, Blood Stone
[*][b]Necromancer[/b] (3000) - Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll [*][b]Necromancer[/b] (3000) - Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll
[*][b]Master[/b] (5000) - Black Portal Stone [*][b]Master[/b] (5000) - Black Portal Stone
[/list] [/list]
@@ -36,7 +36,7 @@ To uninstall, delete the folder. The mod adds items and one block, so a save tha
[b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius. [b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius.
[b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level - and holding its regular attack on a block pulls that block straight into the rift, ten seconds up close and one more per block of distance, while the world drains to black and white around you. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can. [b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level - and holding its regular attack on a block pulls that block straight into the rift, ten seconds up close and one more per block of distance, while the world drains to black and white around you. That pull runs on a charge in the bracelet's mod slot: the [b]Blood Sphere[/b] is spent a point per second and crumbles when empty, while the [b]Blood Stone[/b] from the third grade is never spent at all. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can.
[b]The dead keep shop.[/b] Every trader in the world is rendered in black and white, half-transparent and matte. Nothing about the trade changes - but the necromancer deals with the dead, and the only people still doing business out here are not quite alive. [b]The dead keep shop.[/b] Every trader in the world is rendered in black and white, half-transparent and matte. Nothing about the trade changes - but the necromancer deals with the dead, and the only people still doing business out here are not quite alive.
Binary file not shown.
Binary file not shown.
+35 -14
View File
@@ -28,12 +28,12 @@
| Тир | Порог | Что открывается | | Тир | Порог | Что открывается |
|---|---|---| |---|---|---|
| Адепт | сразу | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов | | Адепт | сразу | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов, Кровавая сфера |
| Адепт (доп.) | 20 зомби | Пространственный браслет | | Адепт (доп.) | 20 зомби | Пространственный браслет |
| Адепт (доп.) | 30 / 60 / 100 / 300 зомби | Моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё | | Адепт (доп.) | 30 / 60 / 100 / 300 зомби | Моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё |
| Подмастерье | 500 зомби | Свиток девиации | | Подмастерье | 500 зомби | Свиток девиации |
| Подмастерье (доп.) | 1400 / 1700 зомби | Моды ножа: Хватка мертвеца, Мёртвая буря | | Подмастерье (доп.) | 1400 / 1700 зомби | Моды ножа: Хватка мертвеца, Мёртвая буря |
| Ученик | 2000 зомби | Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа | | Ученик | 2000 зомби | Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа, Кровавый камень |
| Некромант | 3000 зомби | Призыв зомбомедведя, Призыв зомбоволка, Свиток банши | | Некромант | 3000 зомби | Призыв зомбомедведя, Призыв зомбоволка, Свиток банши |
| Мастер | 5000 зомби | Чёрный портальный камень | | Мастер | 5000 зомби | Чёрный портальный камень |
@@ -67,6 +67,14 @@
круглым индикатором, что и у разбора верстака. Мир на это время обесцвечивается. Не поддаются круглым индикатором, что и у разбора верстака. Мир на это время обесцвечивается. Не поддаются
повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира — повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира —
каждый отказ со своим сообщением. Силовая атака прерывает утаскивание и открывает хранилище. каждый отказ со своим сообщением. Силовая атака прерывает утаскивание и открывает хранилище.
Утаскивание питается зарядом в слоте модификаций браслета — с пустым слотом обычная атака
отказывает.
- **Кровавая сфера** — заряд браслета. 500 прочности, каждая секунда утаскивания блока тратит
единицу; опустевшая сфера рассыпается. Доступна с самого начала и делается без верстака, по две
за раз, из крови некроманта и праха зомби.
- **Кровавый камень** — тот же слот, но не тратится вовсе: эндгейм-замена сфере. Открывается на
2000 упокоенных и варится на химической станции из праха зомби, костей, обычной крови и крови
некроманта.
- **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо - **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо
одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка
возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода; возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода;
@@ -117,7 +125,7 @@
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский, **13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
традиционный. Все 130 ключей `Config/Localization.csv` заполнены, пустых ячеек нет. традиционный. Все 135 ключей `Config/Localization.csv` заполнены, пустых ячеек нет.
## Установка ## Установка
@@ -133,13 +141,16 @@
блоке обычная атака утаскивает его туда через десять секунд плюс секунда за каждый блок блоке обычная атака утаскивает его туда через десять секунд плюс секунда за каждый блок
расстояния, с обесцвечиванием мира на время ожидания (оно же теперь висит и на каналах обоих расстояния, с обесцвечиванием мира на время ожидания (оно же теперь висит и на каналах обоих
порталов). Не поддаются повреждённые блоки, контейнеры с содержимым, территория торговца и порталов). Не поддаются повреждённые блоки, контейнеры с содержимым, территория торговца и
неразрушимое вроде дна мира. Плюс все торговцы стали чёрно-белыми, полупрозрачными и матовыми. неразрушимое вроде дна мира. Питается это зарядом в слоте браслета: **Кровавая сфера** тратится по
единице прочности за секунду и в конце рассыпается, **Кровавый камень** с третьего грейда не
тратится вовсе. Плюс все торговцы стали чёрно-белыми, полупрозрачными и матовыми.
Предыдущая версия 1.0.1 закрывала первый баг-репорт с Nexus: содержимое браслета больше не Предыдущая версия 1.0.1 закрывала первый баг-репорт с Nexus: содержимое браслета больше не
пропадает после выхода из игры (хранилище сохраняется в файле игрока, рядом с рюкзаком). пропадает после выхода из игры (хранилище сохраняется в файле игрока, рядом с рюкзаком).
Весь заявленный контент реализован и проходит тесты в игре. Из запланированного не сделана Весь заявленный контент реализован. Заряд браслета — самая свежая часть: расход Кровавой сферы
только часть фирменных звуков. Текст описания для сайта (RU + EN) — в проверен в игре, Кровавый камень пока нет. Остальное тесты в игре проходит. Из запланированного не
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом `SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня). в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
@@ -173,12 +184,12 @@ rest - its own counter, its own mechanic. Five tiers, each opening part of the a
| Tier | Threshold | What unlocks | | Tier | Threshold | What unlocks |
|---|---|---| |---|---|---|
| Adept | from the start | Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits | | Adept | from the start | Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits, Blood Sphere |
| Adept (extra) | 20 zombies | Spatial Bracelet | | Adept (extra) | 20 zombies | Spatial Bracelet |
| Adept (extra) | 30 / 60 / 100 / 300 zombies | Knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense | | Adept (extra) | 30 / 60 / 100 / 300 zombies | Knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense |
| Journeyman | 500 zombies | Scroll of Deviation | | Journeyman | 500 zombies | Scroll of Deviation |
| Journeyman (extra) | 1400 / 1700 zombies | Knife mods: Dead Man's Grip, Dead Storm | | Journeyman (extra) | 1400 / 1700 zombies | Knife mods: Dead Man's Grip, Dead Storm |
| Apprentice | 2000 zombies | Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin | | Apprentice | 2000 zombies | Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin, Blood Stone |
| Necromancer | 3000 zombies | Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll | | Necromancer | 3000 zombies | Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll |
| Master | 5000 zombies | Black Portal Stone | | Master | 5000 zombies | Black Portal Stone |
@@ -212,7 +223,14 @@ rest - its own counter, its own mechanic. Five tiers, each opening part of the a
workbench pickup uses. The world drains to black and white while it runs. Damaged blocks, workbench pickup uses. The world drains to black and white while it runs. Damaged blocks,
containers with anything inside, a trader's ground and indestructible things like the world's containers with anything inside, a trader's ground and indestructible things like the world's
floor all refuse, each with its own message. The power attack interrupts the pull and opens the floor all refuse, each with its own message. The power attack interrupts the pull and opens the
rift instead. rift instead. The pull runs on a charge in the bracelet's mod slot - with that slot empty, the
regular attack refuses.
- **Blood Sphere** - the bracelet's charge. 500 points of durability, one spent per second of a
pull; emptied, the sphere crumbles. Available from the very start and hand-crafted without a
workbench, two at a time, from Necromancer's Blood and Zombie Ash.
- **Blood Stone** - the same slot, but never spent at all: the endgame replacement for the sphere.
Opens at 2000 zombies put to rest and is brewed at a chemistry station from zombie ash, bones,
ordinary blood and Necromancer's Blood.
- **Tin cans** (empty / with river water / with boiled water) - a reusable cycle instead of - **Tin cans** (empty / with river water / with boiled water) - a reusable cycle instead of
single-use jars: fill them with water, boil it right on a campfire without a pot, drink, and the single-use jars: fill them with water, boil it right on a campfire without a pot, drink, and the
can comes back empty. River water from a can can cause dysentery, like any murky water; boiled can comes back empty. River water from a can can cause dysentery, like any murky water; boiled
@@ -262,7 +280,7 @@ deliberately not retold here: a README gets read before the playthrough.
## Localization ## Localization
**13 languages, complete:** Russian, English, German, Spanish, French, Italian, Japanese, Korean, **13 languages, complete:** Russian, English, German, Spanish, French, Italian, Japanese, Korean,
Polish, Brazilian Portuguese, Turkish, Simplified and Traditional Chinese. All 130 keys in Polish, Brazilian Portuguese, Turkish, Simplified and Traditional Chinese. All 135 keys in
`Config/Localization.csv` are filled in, with no empty cells. `Config/Localization.csv` are filled in, with no empty cells.
## Installation ## Installation
@@ -280,15 +298,18 @@ Version 1.1.0 - the Spatial Bracelet learned to take blocks straight into the va
regular attack on a block and it is pulled in after ten seconds, plus one more per block of regular attack on a block and it is pulled in after ten seconds, plus one more per block of
distance, with the world draining to black and white for the wait (which now also covers both distance, with the world draining to black and white for the wait (which now also covers both
portal channels). Damaged blocks, containers with anything inside, a trader's ground and portal channels). Damaged blocks, containers with anything inside, a trader's ground and
indestructible things like the world's floor all refuse. Every trader is now rendered in black and indestructible things like the world's floor all refuse. The pull runs on a charge in the
white, half-transparent and matte. bracelet's mod slot: the **Blood Sphere** spends a point of durability per second and crumbles when
empty, while the **Blood Stone** from the third grade is never spent at all. Every trader is now
rendered in black and white, half-transparent and matte.
The previous version, 1.0.1, closed the first bug report from Nexus: the bracelet's contents no The previous version, 1.0.1, closed the first bug report from Nexus: the bracelet's contents no
longer disappear after leaving the game (the storage is saved in the player's own file, next to longer disappear after leaving the game (the storage is saved in the player's own file, next to
the backpack). the backpack).
All the announced content is implemented and passes testing in game. Of what was planned, only All the announced content is implemented. The bracelet's charge is the newest part: the Blood
part of the mod's own sound effects is missing. The description text for the website Sphere's spending is confirmed in game, the Blood Stone is not tested yet. Everything else passes
testing in game. Of what was planned, only part of the mod's own sound effects is missing. The description text for the website
(RU + EN) is in `SITE_DESCRIPTION.html` (WordPress block markup). The full technical history of (RU + EN) is in `SITE_DESCRIPTION.html` (WordPress block markup). The full technical history of
development and the text of the finale sit next to the mod in `BACKLOG.md` and `FINAL_TEXT.md` - development and the text of the finale sit next to the mod in `BACKLOG.md` and `FINAL_TEXT.md` -
they are not part of the repository (spoilers and back-of-house). they are not part of the repository (spoilers and back-of-house).
+10 -6
View File
@@ -8,7 +8,7 @@
Отдельный навык <strong>«Некромантия»</strong> качается не за очки перков и не за книги, а по счётчику упокоенных зомби. Пять тиров, 5000 убийств до вершины, каждый порог открывает часть арсенала: Отдельный навык <strong>«Некромантия»</strong> качается не за очки перков и не за книги, а по счётчику упокоенных зомби. Пять тиров, 5000 убийств до вершины, каждый порог открывает часть арсенала:
<table><thead><tr><th>Тир</th><th>Порог</th><th>Что открывается</th></tr></thead><tbody><tr><td>Адепт</td><td>сразу</td><td>Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов</td></tr><tr><td>Адепт</td><td>20 / 30 / 60 / 100 / 300</td><td>Пространственный браслет, затем моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё</td></tr><tr><td>Подмастерье</td><td>500 / 1400 / 1700</td><td>Свиток девиации, моды ножа Хватка мертвеца и Мёртвая буря</td></tr><tr><td>Ученик</td><td>2000</td><td>Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа</td></tr><tr><td>Некромант</td><td>3000</td><td>Призыв зомбомедведя, Призыв зомбоволка, Свиток банши</td></tr><tr><td>Мастер</td><td>5000</td><td>Чёрный портальный камень</td></tr></tbody></table> <table><thead><tr><th>Тир</th><th>Порог</th><th>Что открывается</th></tr></thead><tbody><tr><td>Адепт</td><td>сразу</td><td>Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов, Кровавая сфера</td></tr><tr><td>Адепт</td><td>20 / 30 / 60 / 100 / 300</td><td>Пространственный браслет, затем моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё</td></tr><tr><td>Подмастерье</td><td>500 / 1400 / 1700</td><td>Свиток девиации, моды ножа Хватка мертвеца и Мёртвая буря</td></tr><tr><td>Ученик</td><td>2000</td><td>Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа, Кровавый камень</td></tr><tr><td>Некромант</td><td>3000</td><td>Призыв зомбомедведя, Призыв зомбоволка, Свиток банши</td></tr><tr><td>Мастер</td><td>5000</td><td>Чёрный портальный камень</td></tr></tbody></table>
<h2>Арсенал</h2> <h2>Арсенал</h2>
@@ -20,7 +20,9 @@
<li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li> <li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li>
<li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li> <li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li>
<li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li> <li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li>
<li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, мир на это время обесцвечивается. Повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира не поддаются. Силовая атака прерывает утаскивание и открывает хранилище.</li> <li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, мир на это время обесцвечивается. Повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира не поддаются. Силовая атака прерывает утаскивание и открывает хранилище. Утаскивание питается зарядом в слоте модификаций браслета — с пустым слотом обычная атака отказывает.</li>
<li><strong>Кровавая сфера</strong> — заряд браслета: 500 прочности, каждая секунда утаскивания блока тратит единицу, опустевшая сфера рассыпается. Доступна с самого начала и делается без верстака, по две за раз, из крови некроманта и праха зомби.</li>
<li><strong>Кровавый камень</strong> — тот же слот, но не тратится вовсе: эндгейм-замена сфере. Открывается на 2000 упокоенных и варится на химической станции из праха зомби, костей, обычной крови и крови некроманта.</li>
<li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li> <li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li>
</ul> </ul>
@@ -54,7 +56,7 @@
<h2>Локализация</h2> <h2>Локализация</h2>
<strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 130 строк переведены, пустых ячеек нет. <strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 135 строк переведены, пустых ячеек нет.
<h2>Установка</h2> <h2>Установка</h2>
@@ -84,7 +86,7 @@ Necromancy here is an answer to a curse, not a side branch of crafting. Instead
A dedicated <strong>Necromancy</strong> skill levels not from perk points and not from books, but from your zombie kill count. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal: A dedicated <strong>Necromancy</strong> skill levels not from perk points and not from books, but from your zombie kill count. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal:
<table><thead><tr><th>Tier</th><th>Threshold</th><th>What unlocks</th></tr></thead><tbody><tr><td>Adept</td><td>from the start</td><td>Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits</td></tr><tr><td>Adept</td><td>20 / 30 / 60 / 100 / 300</td><td>Spatial Bracelet, then the knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense</td></tr><tr><td>Journeyman</td><td>500 / 1400 / 1700</td><td>Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm</td></tr><tr><td>Apprentice</td><td>2000</td><td>Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin</td></tr><tr><td>Necromancer</td><td>3000</td><td>Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll</td></tr><tr><td>Master</td><td>5000</td><td>Black Portal Stone</td></tr></tbody></table> <table><thead><tr><th>Tier</th><th>Threshold</th><th>What unlocks</th></tr></thead><tbody><tr><td>Adept</td><td>from the start</td><td>Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits, Blood Sphere</td></tr><tr><td>Adept</td><td>20 / 30 / 60 / 100 / 300</td><td>Spatial Bracelet, then the knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense</td></tr><tr><td>Journeyman</td><td>500 / 1400 / 1700</td><td>Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm</td></tr><tr><td>Apprentice</td><td>2000</td><td>Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin, Blood Stone</td></tr><tr><td>Necromancer</td><td>3000</td><td>Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll</td></tr><tr><td>Master</td><td>5000</td><td>Black Portal Stone</td></tr></tbody></table>
<h3>Arsenal</h3> <h3>Arsenal</h3>
@@ -96,7 +98,9 @@ A dedicated <strong>Necromancy</strong> skill levels not from perk points and no
<li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li> <li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li>
<li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li> <li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li>
<li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li> <li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li>
<li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level. Hold the regular attack on a block and it is pulled straight into the rift: ten seconds up close, one more per block of distance, with the world draining to black and white while it runs. Damaged blocks, containers with anything inside, a trader's ground and indestructible things like the world's floor all refuse. The power attack interrupts the pull and opens the rift instead.</li> <li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level. Hold the regular attack on a block and it is pulled straight into the rift: ten seconds up close, one more per block of distance, with the world draining to black and white while it runs. Damaged blocks, containers with anything inside, a trader's ground and indestructible things like the world's floor all refuse. The power attack interrupts the pull and opens the rift instead. The pull runs on a charge in the bracelet's mod slot - with that slot empty, the regular attack refuses.</li>
<li><strong>Blood Sphere</strong> — the bracelet's charge: 500 points of durability, one spent per second of a pull, and the emptied sphere crumbles. Available from the very start and hand-crafted without a workbench, two at a time, from Necromancer's Blood and Zombie Ash.</li>
<li><strong>Blood Stone</strong> — the same slot, but never spent at all: the endgame replacement for the sphere. Opens at 2000 zombies put to rest and is brewed at a chemistry station from zombie ash, bones, ordinary blood and Necromancer's Blood.</li>
<li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li> <li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li>
</ul> </ul>
@@ -130,7 +134,7 @@ Activating it asks for confirmation, stops the game and unfolds a full-screen fi
<h3>Localization</h3> <h3>Localization</h3>
<strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 130 strings are translated, with no empty cells. <strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 135 strings are translated, with no empty cells.
<h3>Installation</h3> <h3>Installation</h3>
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB