using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// 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. /// public static class SpatialVaultPickup { /// 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. public const float BaseChannelSeconds = 10f; /// 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. public const float SecondsPerBlock = 1f; /// 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. public const string MsgRepairFirst = "ttRepairBeforePickup"; public const string MsgBlockMissing = "ttBlockMissingPickup"; public const string MsgInUse = "ttCantPickupInUse"; public const string MsgNotEmpty = "ttWorkstationNotEmpty"; /// This mod's own, added with this feature - see Config/Localization.csv. 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"; /// The denial sound vanilla plays with these tooltips. public const string DeniedSound = "ui_denied"; /// 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. public static float CancelOpenedVaultAt = -1f; /// 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. public const float CancelSwallowSeconds = 0.5f; /// 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. public static bool ConsumeCancelOpen() { if (CancelOpenedVaultAt < 0f || Time.unscaledTime - CancelOpenedVaultAt > CancelSwallowSeconds) { return false; } CancelOpenedVaultAt = -1f; return true; } /// 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. public class PickupJob { public EntityPlayerLocal Player; public Vector3i Position; public BlockValue Expected; } /// Regular attack on the bracelet. Every refusal happens here, before the player /// is asked to stand still for ten seconds. public static void Begin(EntityPlayerLocal _player) { 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; } TimerEventData timerData = new TimerEventData { Data = new PickupJob { Player = _player, Position = position, Expected = blockValue }, // 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); }; float channelSeconds = ChannelSecondsFor(hitInfo); 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"); } /// 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. 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); // 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. GameManager.Instance.SaveLocalPlayerData(); Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + job.Player.entityId + " took " + blockValue.Block.GetBlockName() + " at " + job.Position + " into the vault"); } /// 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. 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; } /// 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. 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; } /// 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. 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; } /// 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. 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; } /// 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. public static void Deny(EntityPlayerLocal _player, string _localizationKey) { GameManager.ShowTooltip(_player, Localization.Get(_localizationKey), string.Empty, DeniedSound); } } /// 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. [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); } } }