Чинит поломку сейвов, внесённую предыдущим коммитом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 bye362c62and replaces the mechanic behind it with a safe one. WHAT WAS BROKEN.e362c62moved 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 against7172681and 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 against7172681: 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
682 lines
32 KiB
C#
682 lines
32 KiB
C#
using HarmonyLib;
|
|
using UnityEngine;
|
|
|
|
namespace NecromancerTome
|
|
{
|
|
/// <summary>
|
|
/// Holding the Spatial Bracelet's REGULAR attack on a block takes that block into the vault
|
|
/// after a ten-second timer - the same circular indicator a workbench shows when you take it
|
|
/// (user request 2026-09-14: "при зажатии обычной атаки игрок видел индикатор как при
|
|
/// демонтаже верстака... блок должен исчезнуть и появиться в пространственном хранилище").
|
|
/// Entry point is SpatialVaultPatch's existing Prefix, index 0, which until now deliberately
|
|
/// swallowed that click and did nothing.
|
|
///
|
|
/// THE WHOLE RECIPE IS VANILLA'S, not an imitation of it. Block.TakeItemWithTimer and its
|
|
/// TakeItemWithTimerDone are short enough to read in one sitting, and they are the workbench
|
|
/// pickup; what follows is the same sequence with two substitutions - ten seconds instead of
|
|
/// the block's own TakeDelay, and the vault's Bag instead of the player's backpack. Even the
|
|
/// refusal messages are vanilla's own keys, which means they are already translated into every
|
|
/// language the game ships, and a player who has ever taken a workbench has already been
|
|
/// taught what they mean.
|
|
///
|
|
/// A DAMAGED BLOCK IS REFUSED BEFORE THE TIMER EVER OPENS. That is vanilla's first line:
|
|
///
|
|
/// if (_blockValue.damage > 0)
|
|
/// GameManager.ShowTooltip(_player, Localization.Get("ttRepairBeforePickup"), "", "ui_denied");
|
|
/// else if (canTake(...))
|
|
/// XUiC_Timer.OpenTimer(...);
|
|
///
|
|
/// - and it is exactly what the user asked for: a message, and no indicator at all.
|
|
///
|
|
/// EVERY GUARD IS CHECKED TWICE, ONCE TO OPEN THE TIMER AND ONCE TO FINISH IT, because ten
|
|
/// seconds is a long time in this game. Vanilla does the same for its own two seconds: the
|
|
/// block can be shot, mined, replaced, or opened by someone else while the circle fills, and
|
|
/// each of those has its own message rather than a silent failure or, worse, a block quietly
|
|
/// deleted from the world with nothing to show for it.
|
|
///
|
|
/// THE TARGET IS ANY BLOCK UNDER THE CROSSHAIR (user's choice of 2026-09-14, over the
|
|
/// narrower "only what vanilla already lets you take"). That is a wider promise than vanilla
|
|
/// ever makes, and two things follow from it that the narrow version would never have had to
|
|
/// face:
|
|
///
|
|
/// - MULTIBLOCKS. A door or a bed occupies several cells, and the crosshair usually lands on
|
|
/// a child rather than on the parent. Setting that one cell to air would leave the other
|
|
/// half standing as debris. The child is resolved to its parent first, with the engine's
|
|
/// own idiom - `isMultiBlock && ischild -> multiBlockPos.GetParentPos(...)` - which is
|
|
/// what Block's own methods do a dozen times over, and the parent is what gets removed.
|
|
/// - BLOCKS WITH NO ITEM FORM. Not everything placed in the world converts to something a
|
|
/// player can hold; ToItemValue comes back empty for those. They are refused up front,
|
|
/// because the alternative is deleting a block and handing back nothing.
|
|
///
|
|
/// THE CHANNEL GETS LONGER WITH REACH - ten seconds against the block, one more per full
|
|
/// block of distance. The measurement is not computed from the player's position and the
|
|
/// block's position, which would mean picking a point in the player (feet? eyes?) and a point
|
|
/// in the block (centre? face?) and being wrong about one of them: the engine already fills in
|
|
/// HitInfoDetails.distanceSq for the very ray that chose this block, so the number used is the
|
|
/// length of that ray. It is also the honest one - it measures to the surface being looked at,
|
|
/// which is what "вплотную" means to a player standing against a wall.
|
|
///
|
|
/// FLOOR, NOT ROUND, and that is what makes the two anchors in the request both come out
|
|
/// right: flush against a block the ray is well under a metre, floors to zero, and the channel
|
|
/// is the plain ten seconds; a block five away floors to five and costs fifteen.
|
|
///
|
|
/// THE POWER ATTACK CANCELS THE CHANNEL AND OPENS THE VAULT (user request 2026-09-14, after
|
|
/// the feature was confirmed working: "можно случайно нажать и не иметь возможности прервать").
|
|
/// Ten seconds of standing still after a misclick is a long time, and the vanilla escapes are
|
|
/// both poor here: getting hit is not something the player chooses, and the activate key is
|
|
/// not the button a hand is already on. The bracelet's other button is - and it lands on the
|
|
/// thing the player most likely wanted in the first place.
|
|
///
|
|
/// WHAT A PICKAXE CANNOT BREAK, THE BRACELET CANNOT TAKE (user report 2026-09-14: it would
|
|
/// happily take a trader's compound apart, and bedrock with it). TWO SEPARATE ENGINE RULES
|
|
/// stand behind that one sentence, and they are worth keeping apart because they look
|
|
/// identical from inside the game and are nothing alike in the code:
|
|
///
|
|
/// - A TRADER'S GROUND. The blocks there are ordinary; it is the AREA that is protected.
|
|
/// Vanilla simply skips DamageBlock inside it, which is why a pickaxe does nothing while
|
|
/// this bracelet - asking about the block rather than about the place - saw nothing wrong.
|
|
/// The test is the same predicate that suppression uses, with its condition copied whole:
|
|
///
|
|
/// World.SandboxUseTraderArea != TraderAreaStates.Default || !world.IsWithinTraderArea(pos)
|
|
///
|
|
/// The sandbox half is not padding. Trader protection is a server setting, and a server
|
|
/// that turned it off should not find this mod enforcing it anyway: where vanilla
|
|
/// protects, so does the bracelet; where it does not, neither does this.
|
|
/// - INDESTRUCTIBLE MATERIAL. The world's floor is the opposite case - nothing special about
|
|
/// the place, everything special about the block. Bedrock's material carries
|
|
/// CanDestroy=false (Data/Config/materials.xml, Mbedrock), and the engine reads exactly
|
|
/// `blockValue.Block.blockMaterial.CanDestroy` wherever it must not break something. Asked
|
|
/// as a material question rather than by block name, so it covers whatever else in this
|
|
/// game - or in another mod - is declared unbreakable.
|
|
///
|
|
/// Both say so out loud, where vanilla stays silent. Vanilla can afford silence because a
|
|
/// pickaxe that does nothing is its own explanation - the block visibly refuses to break. An
|
|
/// indicator that simply never appears looks like this mod is broken instead, so these
|
|
/// refusals get a message like every other one in this file.
|
|
///
|
|
/// CONTENTS CANNOT TRAVEL, AND THAT IS NOT A SHORTCUT. "In the state the original block was
|
|
/// in" holds for the block's identity and its integrity, but an ItemStack in this game has
|
|
/// nowhere to put another container's inventory - ToItemValue maps a block to an item and
|
|
/// stops there. Vanilla solves this by refusing: a workstation with anything in it cannot be
|
|
/// taken, and says so through ttWorkstationNotEmpty. The same refusal is used here, extended
|
|
/// to composite storage (chests) through ITileEntityLootable, which is how this version of the
|
|
/// game models a container's contents.
|
|
/// </summary>
|
|
public static class SpatialVaultPickup
|
|
{
|
|
/// <summary>The floor: what it costs to take a block you are standing against. Vanilla's
|
|
/// workbench is two; the Blue Portal Stone's channel in this mod is also ten, and this
|
|
/// reads as the same kind of deliberate act.</summary>
|
|
public const float BaseChannelSeconds = 10f;
|
|
|
|
/// <summary>Added per full block of reach (user request 2026-09-14: "вплотную 10 сек,
|
|
/// если объект от персонажа в пяти блоках то 15 сек"). Distance is a cost, so pulling
|
|
/// something out of a wall across the room is a commitment rather than a trick.</summary>
|
|
public const float SecondsPerBlock = 1f;
|
|
|
|
/// <summary>Vanilla's own refusal messages, already translated into every shipped
|
|
/// language. Reused rather than re-worded: a player who has taken a workbench has already
|
|
/// learned what these mean, and a second vocabulary for the same refusal would be worse
|
|
/// than no message.</summary>
|
|
public const string MsgRepairFirst = "ttRepairBeforePickup";
|
|
public const string MsgBlockMissing = "ttBlockMissingPickup";
|
|
public const string MsgInUse = "ttCantPickupInUse";
|
|
public const string MsgNotEmpty = "ttWorkstationNotEmpty";
|
|
|
|
/// <summary>This mod's own, added with this feature - see Config/Localization.csv.</summary>
|
|
public const string MsgNoBlock = "braceletSpatialVaultNoBlock";
|
|
public const string MsgNoItemForm = "braceletSpatialVaultNoItemForm";
|
|
public const string MsgVaultFull = "braceletSpatialVaultFull";
|
|
public const string MsgChanneling = "braceletSpatialVaultPickupChanneling";
|
|
public const string MsgTraderArea = "braceletSpatialVaultTraderArea";
|
|
public const string MsgIndestructible = "braceletSpatialVaultIndestructible";
|
|
public const string MsgNoMod = "braceletSpatialVaultNoMod";
|
|
|
|
/// <summary>The denial sound vanilla plays with these tooltips.</summary>
|
|
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
|
|
/// 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
|
|
/// the same press. Whether the release even reaches the item action through the modal
|
|
/// window is unknown - it is exactly the input suppression that forced the raw mouse read
|
|
/// below - so this guards the case rather than assuming either answer.</summary>
|
|
public static float CancelOpenedVaultAt = -1f;
|
|
|
|
/// <summary>How long after a cancel a power-attack release is treated as the tail of that
|
|
/// same press. Long enough to cover a slow finger, far short of a deliberate second
|
|
/// click.</summary>
|
|
public const float CancelSwallowSeconds = 0.5f;
|
|
|
|
/// <summary>True once, if the vault was just opened by cancelling a channel. Consuming it
|
|
/// rather than only reading it means a genuine second press right afterwards still
|
|
/// works.</summary>
|
|
public static bool ConsumeCancelOpen()
|
|
{
|
|
if (CancelOpenedVaultAt < 0f || Time.unscaledTime - CancelOpenedVaultAt > CancelSwallowSeconds)
|
|
{
|
|
return false;
|
|
}
|
|
CancelOpenedVaultAt = -1f;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>What the timer is working on, handed through TimerEventData.Data - the same
|
|
/// use vanilla makes of that field (it packs a BlockValue, a position and the player into
|
|
/// an object[] there). A small class instead of an array because this one is read back in
|
|
/// a method that has to be right about which field is which.</summary>
|
|
public class PickupJob
|
|
{
|
|
public EntityPlayerLocal Player;
|
|
public Vector3i Position;
|
|
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
|
|
/// is asked to stand still for ten seconds.</summary>
|
|
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;
|
|
if (world == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
WorldRayHitInfo hitInfo = _player.HitInfo;
|
|
if (hitInfo == null || !hitInfo.bHitValid)
|
|
{
|
|
Deny(_player, MsgNoBlock);
|
|
return;
|
|
}
|
|
|
|
Vector3i position = hitInfo.hit.blockPos;
|
|
BlockValue blockValue = world.GetBlock(position);
|
|
if (blockValue.isair || blockValue.Block == null)
|
|
{
|
|
Deny(_player, MsgNoBlock);
|
|
return;
|
|
}
|
|
|
|
// A door or a bed is several cells and the crosshair lands on whichever one is
|
|
// nearest; removing that cell alone would leave the rest of the model standing.
|
|
if (blockValue.Block.isMultiBlock && blockValue.ischild)
|
|
{
|
|
position = blockValue.Block.multiBlockPos.GetParentPos(position, blockValue);
|
|
blockValue = world.GetBlock(position);
|
|
if (blockValue.isair || blockValue.Block == null)
|
|
{
|
|
Deny(_player, MsgNoBlock);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Before anything else about the block is considered: whether it may be touched at
|
|
// all outranks what state it happens to be in.
|
|
if (!CanTakeHere(world, position, blockValue, _player))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Vanilla's first line, and the user's explicit requirement: a damaged block gets the
|
|
// message and no indicator whatsoever.
|
|
if (blockValue.damage > 0)
|
|
{
|
|
Deny(_player, MsgRepairFirst);
|
|
return;
|
|
}
|
|
|
|
ItemValue itemValue = blockValue.ToItemValue();
|
|
if (itemValue == null || itemValue.IsEmpty())
|
|
{
|
|
Deny(_player, MsgNoItemForm);
|
|
return;
|
|
}
|
|
|
|
if (!CanTakeTileEntity(world, position, _player))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Asked before the timer rather than after it, because ten seconds spent to be told
|
|
// the vault was full the whole time is the worst version of this feature.
|
|
Bag bag = GetVault(_player);
|
|
if (bag == null)
|
|
{
|
|
return;
|
|
}
|
|
if (!bag.CanTakeItem(new ItemStack(itemValue, 1)))
|
|
{
|
|
Deny(_player, MsgVaultFull);
|
|
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
|
|
{
|
|
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
|
|
// activate key. Neither is built here - both are fields XUiC_Timer.Update reads.
|
|
CloseOnHit = true,
|
|
CancelWithActivateButton = true
|
|
};
|
|
timerData.FullTimeFinishEvent += OnChannelComplete;
|
|
// Every way this ends that is NOT completion: damage, the activate key, the power
|
|
// attack. XUiC_Timer sets skipCloseEvent around the completion path specifically so
|
|
// the two are mutually exclusive, which is why the colour is restored in both places
|
|
// and not only here.
|
|
timerData.CloseEvent += delegate
|
|
{
|
|
ChannelVision.End(_player);
|
|
};
|
|
|
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player);
|
|
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
|
|
// world grey with nothing running.
|
|
ChannelVision.Begin(_player);
|
|
|
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + _player.entityId + " started taking " +
|
|
blockValue.Block.GetBlockName() + " at " + position + " - " +
|
|
Mathf.Sqrt(hitInfo.hit.distanceSq).ToString("0.##") + " blocks away, " +
|
|
channelSeconds.ToString("0.#") + "s channel");
|
|
}
|
|
|
|
/// <summary>Ten seconds later. Everything is checked again from the live world rather than
|
|
/// trusted from the job, because the block that was there when the circle started filling
|
|
/// is not necessarily the block that is there now.</summary>
|
|
public static void OnChannelComplete(TimerEventData _timerData)
|
|
{
|
|
if (!(_timerData.Data is PickupJob job) || job.Player == null)
|
|
{
|
|
return;
|
|
}
|
|
// FIRST, before any of the checks below can take an early exit: the ten seconds are
|
|
// over however this turns out, so the colour comes back whether the block is taken or
|
|
// refused.
|
|
ChannelVision.End(job.Player);
|
|
|
|
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
|
if (world == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
BlockValue blockValue = world.GetBlock(job.Position);
|
|
if (!CanTakeHere(world, job.Position, blockValue, job.Player))
|
|
{
|
|
return;
|
|
}
|
|
if (blockValue.damage > 0)
|
|
{
|
|
Deny(job.Player, MsgRepairFirst);
|
|
return;
|
|
}
|
|
// Shot out, mined, or replaced while the circle was filling.
|
|
if (blockValue.isair || blockValue.Block == null || blockValue.type != job.Expected.type)
|
|
{
|
|
Deny(job.Player, MsgBlockMissing);
|
|
return;
|
|
}
|
|
if (!CanTakeTileEntity(world, job.Position, job.Player))
|
|
{
|
|
return;
|
|
}
|
|
|
|
ItemValue itemValue = blockValue.ToItemValue();
|
|
if (itemValue == null || itemValue.IsEmpty())
|
|
{
|
|
Deny(job.Player, MsgNoItemForm);
|
|
return;
|
|
}
|
|
|
|
Bag bag = GetVault(job.Player);
|
|
if (bag == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// ORDER MATTERS: the item goes in first, and the block is only removed if it got
|
|
// there. The other way round is how a block gets deleted out of the world in exchange
|
|
// for nothing when the vault filled up during those ten seconds.
|
|
if (!bag.AddItem(new ItemStack(itemValue, 1)))
|
|
{
|
|
Deny(job.Player, MsgVaultFull);
|
|
return;
|
|
}
|
|
|
|
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
|
|
// 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. The flask's UseTimes
|
|
// rides along in the same save - it lives on the bracelet in the player's inventory.
|
|
GameManager.Instance.SaveLocalPlayerData();
|
|
|
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + job.Player.entityId + " took " +
|
|
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
|
|
/// own length is the measurement and why it is floored rather than rounded.</summary>
|
|
public static float ChannelSecondsFor(WorldRayHitInfo _hitInfo)
|
|
{
|
|
float distance = Mathf.Sqrt(_hitInfo.hit.distanceSq);
|
|
int blocks = Mathf.Max(0, Mathf.FloorToInt(distance));
|
|
return BaseChannelSeconds + blocks * SecondsPerBlock;
|
|
}
|
|
|
|
/// <summary>False (with the reason already shown) when this block is one the game itself
|
|
/// would not let a player break - because of where it stands, or because of what it is
|
|
/// made of. Split out because, like every other guard here, it is asked twice: once to
|
|
/// open the timer and once to finish it.</summary>
|
|
public static bool CanTakeHere(World _world, Vector3i _position, BlockValue _blockValue, EntityPlayerLocal _player)
|
|
{
|
|
if (World.SandboxUseTraderArea == TraderAreaStates.Default && _world.IsWithinTraderArea(_position))
|
|
{
|
|
Deny(_player, MsgTraderArea);
|
|
return false;
|
|
}
|
|
if (_blockValue.Block != null && _blockValue.Block.blockMaterial != null &&
|
|
!_blockValue.Block.blockMaterial.CanDestroy)
|
|
{
|
|
Deny(_player, MsgIndestructible);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>False (with the reason already shown) when a tile entity at this position
|
|
/// stands in the way: someone has it open, or it has something inside it. Contents cannot
|
|
/// travel inside an ItemStack, so a container has to be emptied first - vanilla's own rule
|
|
/// for its workstations, applied here to chests as well.</summary>
|
|
public static bool CanTakeTileEntity(World _world, Vector3i _position, EntityPlayerLocal _player)
|
|
{
|
|
TileEntity tileEntity = _world.GetTileEntity(_position);
|
|
if (tileEntity == null)
|
|
{
|
|
return true;
|
|
}
|
|
if (tileEntity.IsUserAccessing())
|
|
{
|
|
Deny(_player, MsgInUse);
|
|
return false;
|
|
}
|
|
if (tileEntity is TileEntityWorkstation workstation && !workstation.IsEmpty)
|
|
{
|
|
Deny(_player, MsgNotEmpty);
|
|
return false;
|
|
}
|
|
if (tileEntity is TileEntityCollector collector && !collector.IsEmpty())
|
|
{
|
|
Deny(_player, MsgNotEmpty);
|
|
return false;
|
|
}
|
|
// Chests and everything else that holds loot: this version of the game models them as
|
|
// a composite tile entity with a storage FEATURE rather than as their own class, so
|
|
// the question has to be asked of the feature - the same TryGetSelfOrFeature call the
|
|
// engine's own storage code uses.
|
|
if (tileEntity.TryGetSelfOrFeature(out ITileEntityLootable lootable) && !lootable.IsEmpty())
|
|
{
|
|
Deny(_player, MsgNotEmpty);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>The player's vault, or null with the reason already shown. Deliberately the
|
|
/// SAME bag the bracelet's power attack opens, reached through the same cache - a block
|
|
/// taken here has to be in the window that opens there, and the level gate has to answer
|
|
/// the same way in both places.</summary>
|
|
public static Bag GetVault(EntityPlayerLocal _player)
|
|
{
|
|
ProgressionValue progressionValue = _player.Progression?.GetProgressionValue(
|
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.NecromancySkillName);
|
|
int level = progressionValue != null ? progressionValue.Level : 0;
|
|
int slotCount = Mathf.RoundToInt(level / 10f);
|
|
if (slotCount <= 0)
|
|
{
|
|
Deny(_player, "braceletSpatialVaultTooWeak");
|
|
return null;
|
|
}
|
|
|
|
if (!Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag))
|
|
{
|
|
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
|
|
}
|
|
if (bag.SlotCount < slotCount)
|
|
{
|
|
ItemStack[] oldSlots = bag.GetSlots();
|
|
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
|
|
System.Array.Copy(oldSlots, newSlots, oldSlots.Length);
|
|
bag.SetSlots(newSlots);
|
|
}
|
|
return bag;
|
|
}
|
|
|
|
/// <summary>A refusal, in vanilla's shape: the tooltip plus the denial sound. One method
|
|
/// so that no refusal in this file can accidentally go out silent.</summary>
|
|
public static void Deny(EntityPlayerLocal _player, string _localizationKey)
|
|
{
|
|
GameManager.ShowTooltip(_player, Localization.Get(_localizationKey), string.Empty, DeniedSound);
|
|
}
|
|
}
|
|
|
|
/// <summary>Lets the power attack cancel a block pickup in progress and open the vault
|
|
/// instead. A separate patch class on XUiC_Timer.Update, not on the item action, because this
|
|
/// has to be asked every frame WHILE the timer is open rather than once at click time - the
|
|
/// same shape Patch_XUiC_Timer_Update_PortalStoneCancel already uses for the Blue Portal
|
|
/// Stone's channel.
|
|
///
|
|
/// BOTH INPUT CHECKS ARE DELIBERATE, AND THE RAW ONE IS THE ONE THAT WORKS. The portal stone
|
|
/// shipped with only the semantic PlayerActionsLocal.Secondary check and the user reported
|
|
/// that cancelling did not work at all: the modal timer window has input focus, and the press
|
|
/// never reached PlayerAction's polling layer. The fix there was a second, independent read of
|
|
/// Unity's raw Input.GetMouseButtonDown(1) - right mouse, confirmed as Secondary's real
|
|
/// default KBM binding by decompiling PlayerActionsLocal.CreateActions - which reads hardware
|
|
/// state directly and bypasses whatever swallows the other one. That lesson is reused here
|
|
/// rather than re-learned: the semantic check is kept because it costs nothing and would cover
|
|
/// a gamepad's Secondary if that one does get through, and the raw check is what is actually
|
|
/// expected to fire. A gamepad-only player still has no cancel - the same open gap the portal
|
|
/// stone has, and the same fix would close both.
|
|
///
|
|
/// THE TIMER IS CLOSED BEFORE THE VAULT IS OPENED, not after: closing runs OnClose, which is
|
|
/// what hands control back to the player and drops the event data. Opening a window on top of
|
|
/// one that is still closing is how two windows end up fighting over the same input.</summary>
|
|
[HarmonyPatch(typeof(XUiC_Timer), "Update")]
|
|
public static class Patch_XUiC_Timer_Update_VaultPickupCancel
|
|
{
|
|
public static void Postfix(XUiC_Timer __instance)
|
|
{
|
|
if (__instance == null || __instance.eventData == null ||
|
|
!(__instance.eventData.Data is SpatialVaultPickup.PickupJob job) || job.Player == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PlayerActionsLocal input = __instance.xui?.playerUI?.playerInput;
|
|
bool cancelPressed = (input != null && input.Secondary.WasPressed) || Input.GetMouseButtonDown(1);
|
|
if (!cancelPressed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EntityPlayerLocal player = job.Player;
|
|
Debug.Log("[NecromancerTome] SpatialVaultPickup: pickup cancelled via power attack by owner=" + player.entityId);
|
|
__instance.xui.playerUI.windowManager.Close(__instance.windowGroup);
|
|
SpatialVaultPickup.CancelOpenedVaultAt = Time.unscaledTime;
|
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.OpenVault(player);
|
|
}
|
|
}
|
|
}
|