using System; using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// "Пространственный браслет" (Spatial Bracelet) - dictated 2026-08-30, implemented same /// day. See items.xml (braceletSpatialVault) for the item - both Action0 and Action1 use /// Class="Eat" purely as a click-catcher (same trick as every other Harmony-driven item this /// mod already has), distinguished here by ItemActionData.indexInEntityOfAction (0/1), the /// same field SummonPatch.cs already uses to tell a summon book's summon-click from its /// recall-click. /// /// POWER ATTACK (index 1) - personal storage, size scaling with Necromancy skill level: /// - XUiC_BagStorageWindowGroup.Open(xui, entity, bag, lootContainer, title, ...) is the /// REAL API EntityDrone.openStorageWindow() itself calls to show the drone's own cargo /// window (decompiled EntityDrone directly to find this, not guessed) - reused directly /// rather than reinventing a storage UI. LootContainer.GetLootContainer("roboticDrone") /// is the same display/behavior template the drone's own window uses too - "как у дрона" /// taken literally, not just as a vague size comparison. /// - Slot count = Mathf.RoundToInt(necromancyLevel / 10f), per the user's own exact formula /// ("1*скилл_некроманта/10 округлённый до целого") - read live from /// player.Progression.GetProgressionValue("craftingNecroNecromancy").Level (decompiled /// EntityAlive/Progression/ProgressionValue directly to confirm this exact call shape, /// not guessed) - the SAME skill the Knife's own damage already scales with (capped at /// level 5000, one level per zombie kill - see buffs.xml/progression.xml), so this grows /// at the same pace as every other kill-count-tied payoff in this mod. Below level 10 /// this rounds to 0 - deliberately left as-is, not special-cased away, matching the /// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it /// instead of silently opening a useless empty window. /// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved /// when this item shipped, and that shortfall is exactly what became the mod's first /// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will /// disappear after leaving the game and going online again"). PlayerVaults below is still /// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session /// cache: the durable copy is written into the player's own PlayerDataFile, alongside the /// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment /// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live /// in a chunk, and the bracelet's closest equivalent home is its owner's save data). /// /// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at: /// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for /// braceletThiefLoop (GetLookRay + Physics.Raycast + RootTransformRefEntity. /// FindEntityUpwards) - reused verbatim, just resolving to EntityZombie instead of /// EntityLootContainer. /// - Slow: zombie.Buffs.AddBuff("buffInjurySlow") - the exact same vanilla debuff already /// reused elsewhere in this mod (the Dog's own bite, necroMeleeHandZombieDog). /// - Knockback: DELIBERATELY a straight Entity.SetPosition "shove" (same API /// PetFollowPatch.cs already uses to reposition pets), NOT a physics/ragdoll impulse. /// Found real candidates for "proper" knockback while researching this /// (EntityAlive.DoRagdoll(in DamageResponse), DamageResponse.ImpulseScale/HitDirection), /// but fully reverse-engineering how a real DamageResponse gets built and fed into that /// during normal combat - all its other fields (Source, Strength, Stun, ArmorSlot, etc.) /// - would have taken real additional decompilation with no guarantee of getting all the /// coordinate/enum conventions right on the first try. A direct position shove is cruder /// (no animation, the zombie just appears further away) but uses an API this exact file's /// own family already relies on successfully - chosen for certainty over polish. Revisit /// with DoRagdoll if the teleport-shove feels too crude in testing. /// [HarmonyPatch(typeof(ItemActionEat), "ExecuteAction")] public static class Patch_ItemActionEat_ExecuteAction_SpatialVault { public const string ItemName = "braceletSpatialVault"; public const string NecromancySkillName = "craftingNecroNecromancy"; public const float MaxRange = 50f; public const float ShoveDistance = 6f; /// Session cache only - the durable copy lives on disk, see /// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded /// afterwards cannot inherit this world's vault through a recycled entityId. public static readonly Dictionary PlayerVaults = new Dictionary(); public static bool Prefix(ItemActionData _actionData, bool _bReleased) { if (!_bReleased) { return true; } string itemName = _actionData?.invData?.itemValue?.ItemClass?.Name; if (itemName != ItemName) { return true; } if (!(_actionData.invData.holdingEntity is EntityPlayerLocal player)) { return true; } if (_actionData.indexInEntityOfAction == 1) { OpenVault(player); } // else: regular attack (index 0) deliberately does nothing, per direct user request // 2026-08-30 ("пусть тогда обычная атака у пространственного браслета не делает // ничего") after the knockback+slow version didn't visibly do anything in testing - // rather than debug ShoveZombieAtCrosshair blind (kept below, unused, in case this // gets revisited), just absorb the click silently. // Skip ItemActionEat's own logic entirely - the click has been fully handled here. return false; } public static void OpenVault(EntityPlayerLocal player) { ProgressionValue progressionValue = player.Progression?.GetProgressionValue(NecromancySkillName); int level = progressionValue != null ? progressionValue.Level : 0; int slotCount = Mathf.RoundToInt(level / 10f); if (slotCount <= 0) { GameManager.ShowTooltip(player, "braceletSpatialVaultTooWeak"); return; } if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag)) { // Normally a restored vault is already here - the ToPlayer postfix puts it in // when the game applies the save file to the spawning player. LastLoadedVault is // the safety net for when that chain does not complete: opening the bracelet must // never be what silently starts an empty vault over a saved one. Only then is a // genuinely new bag created. bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount); if (bag == SpatialVaultPersistence.LastLoadedVault) { Debug.Log("[NecromancerTome] SpatialVaultPatch: session cache was empty, adopted the last loaded vault (" + bag.SlotCount + " slots, " + bag.GetUsedSlotCount() + " used)"); } PlayerVaults[player.entityId] = bag; } else if (bag.SlotCount < slotCount) { // Grow, never shrink - the skill level only ever goes up, so this only ever // copies existing stacks into a bigger array, same shape // EntityLootContainer.SetContent itself uses when it needs to resize a bag. ItemStack[] oldSlots = bag.GetSlots(); ItemStack[] newSlots = ItemStack.CreateArray(slotCount); Array.Copy(oldSlots, newSlots, oldSlots.Length); bag.SetSlots(newSlots); } Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")"); LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player); // The trailing callbacks are vanilla's own optional parameters (_onModified, _onClose). // _onModified is not needed: the vault lives in PlayerVaults, and PlayerDataFile's // FromPlayer postfix reads it fresh every time the game saves the player, so there is // nothing to flush per item move. _onClose asks for a player-data save right away, so // closing the window is a commit point rather than waiting for the next autosave - // SaveLocalPlayerData is the game's own routine call and no-ops when saving is not // active (which is the correct behaviour on a client, where the server owns the file). XUiC_BagStorageWindowGroup.Open( playerUI.xui, player, bag, LootContainer.GetLootContainer("roboticDrone"), Localization.Get("braceletSpatialVaultWindowTitle"), null, () => GameManager.Instance.SaveLocalPlayerData()); } public static void ShoveZombieAtCrosshair(EntityPlayerLocal player) { Ray ray = player.GetLookRay(); if (!Physics.Raycast(ray, out RaycastHit hit, MaxRange)) { GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget"); return; } Transform entityTransform = RootTransformRefEntity.FindEntityUpwards(hit.collider.transform); Entity entity = entityTransform != null ? entityTransform.GetComponent() : null; if (!(entity is EntityZombie zombie) || zombie.IsDead()) { GameManager.ShowTooltip(player, "braceletSpatialVaultNoTarget"); return; } zombie.Buffs?.AddBuff("buffInjurySlow"); Vector3 shoveDir = zombie.position - player.position; shoveDir.y = 0f; shoveDir = shoveDir.sqrMagnitude > 0.01f ? shoveDir.normalized : player.transform.forward; Vector3 destination = zombie.position + shoveDir * ShoveDistance + Vector3.up * 1f; zombie.SetPosition(destination, true); player.PlayOneShot("swoosh"); Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " shoved zombie " + zombie.entityId); } } }