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);
}
}
}