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"; public const string MsgNoMod = "braceletSpatialVaultNoMod"; /// 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; /// 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. public ItemValue Bracelet; /// 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. public float ChannelSeconds; } /// 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, 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"); } /// 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); // AFTER the block is gone and the item is in the vault, never before: every refusal // above returns early, and blood paid for a pull that was then refused would be blood // charged for nothing. This is the only place the flask is spent. float spent = SpendBlood(job); // The vault lives in memory and is written out with the player's own save data; this // 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 blood"); } /// Charges this pull to the Necromancer's Blood in the bracelet's mod slot, one /// point of durability per second of channel (user request 2026-09-15). Returns what was /// actually taken, which is not always what was asked for - see the clamp. /// /// WHY THE NAME CHECK AND NOT "whatever is in the slot". The instruction is explicit - /// "если эта модификация кровь некроманта" - and it has to stay that way: the slot is /// meant to take other things later (the message the empty slot prints already promises a /// Blood Stone), and those will have their own price, or none. A mod that is not blood /// pays nothing here and the pull still happens - deliberately, because refusing it would /// be a second rule nobody asked for. /// /// The name comes from NecromancerBloodPatch rather than a second literal in this file, /// so the two cannot drift apart if the item is ever renamed. /// /// NOT ENOUGH BLOOD IS NOT A REFUSAL (user request 2026-09-15: "пусть поглощение всё-равно /// сработает, но флакон крови некроманта после этого пусть исчезнет из слота", clarified to /// "если прочность 0 или меньше, пусть флакон исчезнет из слота"). So the last pull is /// always free of charge in the sense that matters - it completes - and the flask simply /// does not survive it. The charge is therefore NOT clamped: UseTimes is allowed to go past /// MaxUseTimes, because the only thing that then reads it is the emptiness test right /// below, and a clamp would have made "spent exactly to zero" and "overdrawn" look the /// same at the moment the difference stopped mattering anyway. /// /// The test is "durability 0 or less", not "could not cover the cost", and those are not /// the same rule: a flask with exactly enough left is also gone afterwards. That is the /// user's own correction and it closes the hole the first version would have left - a /// flask sitting at 0/1000 in the slot, counting as "something is installed" for the empty- /// slot gate in Begin, and pulling blocks for free forever. /// /// WHY THE NAME CHECK AND NOT "whatever is in the slot" - see above; a mod that is not /// blood pays nothing, is not emptied, and the pull still happens. public static float SpendBlood(PickupJob _job) { ItemValue bracelet = _job.Bracelet; if (bracelet == null || bracelet.Modifications == null || _job.ChannelSeconds <= 0f) { return 0f; } float spent = 0f; bool emptied = false; for (int i = 0; i < bracelet.Modifications.Length; i++) { ItemValue mod = bracelet.Modifications[i]; if (mod == null || mod.IsEmpty()) { continue; } if (mod.ItemClass == null || mod.ItemClass.Name != NecromancerBloodPatch.BloodItemName) { continue; } int max = mod.MaxUseTimes; mod.UseTimes += _job.ChannelSeconds; spent += _job.ChannelSeconds; // max <= 0 means this flask has no durability at all - a DegradationMax that did // not resolve. Draining something with no capacity would delete it on the first // pull, which is a config bug eating the player's item, so it is left alone and // said out loud instead. if (max <= 0) { Debug.LogWarning("[NecromancerTome] SpatialVaultPickup: blood in slot " + i + " has MaxUseTimes 0 - nothing to spend, flask kept. Check the " + "DegradationMax passive_effect in Config/item_modifiers.xml"); continue; } if (mod.UseTimes >= max) { // ItemValue.None is what an empty mod slot holds - type 0, which is exactly // what IsEmpty() tests for, so the slot reads as free to every other piece of // code including the gate in Begin. bracelet.Modifications[i] = ItemValue.None; emptied = true; Debug.Log("[NecromancerTome] SpatialVaultPickup: blood in slot " + i + " ran out (" + mod.UseTimes.ToString("0.#") + "/" + max + ") - flask removed from the bracelet"); } else { Debug.Log("[NecromancerTome] SpatialVaultPickup: blood in slot " + i + " now " + mod.UseTimes.ToString("0.#") + "/" + max + " used"); } } if (emptied) { // Vanilla's own answer to "the thing you were using is gone" - the same cue // ItemAction.HandleItemBreak plays. An item vanishing out of a slot in silence is // the one outcome here the player could miss entirely. _job.Player.PlayOneShot("itembreak"); } if (spent > 0f && _job.Player.inventory != null) { // Without this the number is right and the bar on the toolbelt icon is stale // until something else happens to redraw it. _job.Player.inventory.CallOnToolbeltChangedInternal(); } return spent; } /// 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); } } }