From 8058f8dd1abfc62899a26438de0642b43b1c63fc Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:52:46 +0000 Subject: [PATCH] fix: drop gear weapons when their station furniture is gone Station displays were persistent and easy to leave floating after the furniture broke or a restart missed an unloaded chunk. Tag each display, keep a single one per saved station, and drop the weapon when the station block is no longer there. Co-authored-by: Cursor --- .../net/tfminecraft/magic/gear/GearKeys.java | 5 + .../magic/gear/GearStationListener.java | 56 ++- .../magic/gear/GearStationStore.java | 355 +++++++++++++++--- 3 files changed, 369 insertions(+), 47 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/gear/GearKeys.java b/src/main/java/net/tfminecraft/magic/gear/GearKeys.java index 08d7027..8ba4087 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearKeys.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearKeys.java @@ -55,4 +55,9 @@ public static NamespacedKey rift() { public static NamespacedKey partPick() { return new NamespacedKey(Magic.plugin, "gear_part_id"); } + + /** Marks the ItemDisplay that shows the weapon resting on a gear station. */ + public static NamespacedKey stationDisplay() { + return new NamespacedKey(Magic.plugin, "gear_station_display"); + } } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java index 350f736..25d25e0 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationListener.java @@ -16,11 +16,13 @@ import org.bukkit.event.Listener; import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import dev.lone.itemsadder.api.Events.FurnitureBreakEvent; import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.magic.Magic; import net.tfminecraft.magic.Messages; import net.tfminecraft.magic.charge.ChargeIds; import net.tfminecraft.magic.gear.gui.GearInventoryManager; @@ -32,6 +34,9 @@ public final class GearStationListener implements Listener { /** ItemsAdder breaks furniture two ticks after the swing, so an abort swing needs cover. */ private static final long ABORT_BREAK_GUARD_MILLIS = 1000L; + /** Reaches a frame in this station's block and not the center of a neighbouring block. */ + private static final double STATION_REACH = 0.75; + private final GearInventoryManager inventory = new GearInventoryManager(); private final Map recentAborts = new HashMap<>(); @@ -52,15 +57,56 @@ public void onFurnitureBreak(FurnitureBreakEvent event) { if (entity == null) { return; } - Location location = entity.getLocation().getBlock().getLocation(); - Long aborted = recentAborts.get(GearStationStore.key(location)); - boolean justAborted = aborted != null - && System.currentTimeMillis() - aborted < ABORT_BREAK_GUARD_MILLIS; - if (justAborted || GearStationStore.isOccupied(location) || GearOrbService.isActive(location)) { + Location at = entity.getLocation(); + if (recentlyAbortedNear(at) || GearStationStore.occupiedWithin(at, STATION_REACH) != null) { event.setCancelled(true); } } + /** + * A break that was not cancelled has removed the furniture. Drop that station's + * weapon instead of leaving the display in the air. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFurnitureBroken(FurnitureBreakEvent event) { + if (!isStationFurniture(event.getNamespacedID())) { + return; + } + Entity entity = event.getBukkitEntity(); + if (entity == null) { + return; + } + Location station = GearStationStore.occupiedWithin(entity.getLocation(), STATION_REACH); + if (station == null) { + return; + } + GearOrbService.abort(station); + ItemStack weapon = GearStationStore.takeForAbort(station); + if (weapon != null && station.getWorld() != null) { + station.getWorld().dropItem(station.clone().add(0.5, 1.0, 0.5), weapon); + Magic.plugin.getLogger().warning("[Magic] Gear station at " + GearStationStore.key(station) + + " lost its furniture. Dropped the weapon and removed its display."); + } + } + + @EventHandler + public void onChunkLoad(ChunkLoadEvent event) { + GearStationStore.reconcile(event.getChunk()); + } + + private boolean recentlyAbortedNear(Location at) { + long now = System.currentTimeMillis(); + recentAborts.entrySet().removeIf(entry -> now - entry.getValue() >= ABORT_BREAK_GUARD_MILLIS); + for (Map.Entry entry : recentAborts.entrySet()) { + Location station = GearStationStore.locationFromKey(entry.getKey()); + if (station != null + && GearStationStore.distanceSquaredToCenter(station, at) <= STATION_REACH * STATION_REACH) { + return true; + } + } + return false; + } + private static boolean isStationFurniture(String namespacedId) { String station = GearCache.station == null ? "" : GearCache.station.trim(); int open = station.indexOf('('); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java index 79167ee..541f24d 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearStationStore.java @@ -2,29 +2,38 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.UUID; import org.bukkit.Bukkit; +import org.bukkit.Chunk; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.block.Block; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Entity; import org.bukkit.entity.ItemDisplay; import org.bukkit.entity.Display.Billboard; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; import org.bukkit.util.Transformation; import org.joml.AxisAngle4f; import org.joml.Vector3f; import net.tfminecraft.magic.Magic; +import net.tfminecraft.tlibs.TLibs; public final class GearStationStore { private static final Map OCCUPIED = new HashMap<>(); + private static boolean loading; private GearStationStore() {} @@ -143,8 +152,12 @@ public static void update(Location location, ItemStack item) { public static void shutdown() { save(); - for (Occupancy occupancy : OCCUPIED.values()) { - if (occupancy != null && occupancy.displayId != null) { + for (Map.Entry entry : OCCUPIED.entrySet()) { + Location location = locationFromKey(entry.getKey()); + Occupancy occupancy = entry.getValue(); + if (location != null) { + removeDisplays(location, null, occupancy == null ? null : occupancy.displayId); + } else if (occupancy != null && occupancy.displayId != null) { Entity entity = Bukkit.getEntity(occupancy.displayId); if (entity != null) { entity.remove(); @@ -160,52 +173,128 @@ public static void clearAll() { } public static void load() { - OCCUPIED.clear(); - File file = file(); - if (!file.exists()) { - return; + loading = true; + try { + OCCUPIED.clear(); + File file = file(); + if (!file.exists()) { + return; + } + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + ConfigurationSection root = config.getConfigurationSection("stations"); + if (root == null) { + return; + } + boolean dropped = false; + for (String key : root.getKeys(false)) { + ConfigurationSection section = root.getConfigurationSection(key); + if (section == null) { + continue; + } + Location location = locationOf(section); + ItemStack item = section.getItemStack("item"); + if (location == null || item == null) { + continue; + } + UUID savedDisplay = uuidOf(section.getString("display")); + UUID owner = uuidOf(section.getString("owner")); + Map charged = readCharged(section); + location.getChunk().load(); + if (furniture(location) == Furniture.ABSENT) { + removeDisplays(location, null, savedDisplay); + drop(location, item); + dropped = true; + Magic.plugin.getLogger().warning("[Magic] Gear station at " + key(location) + + " has no furniture. Dropped the weapon and removed its display."); + continue; + } + UUID displayId = ensureDisplay(location, item, savedDisplay); + OCCUPIED.put(key(location), new Occupancy(item, displayId, owner, charged)); + } + if (dropped) { + save(); + } + } finally { + loading = false; } - YamlConfiguration config = YamlConfiguration.loadConfiguration(file); - ConfigurationSection root = config.getConfigurationSection("stations"); - if (root == null) { + } + + /** + * When a chunk loads, a saved station with no furniture drops its weapon. A station + * that is still there keeps a single display, including one left behind in an + * unloaded chunk. + */ + public static void reconcile(Chunk chunk) { + if (loading || chunk == null) { return; } - for (String key : root.getKeys(false)) { - ConfigurationSection section = root.getConfigurationSection(key); - if (section == null) { + List keys = new ArrayList<>(); + for (String stationKey : OCCUPIED.keySet()) { + Location location = locationFromKey(stationKey); + if (location == null || location.getWorld() != chunk.getWorld()) { continue; } - Location location = locationOf(section); - ItemStack item = section.getItemStack("item"); - if (location == null || item == null) { + if ((location.getBlockX() >> 4) == chunk.getX() && (location.getBlockZ() >> 4) == chunk.getZ()) { + keys.add(stationKey); + } + } + boolean changed = false; + for (String stationKey : keys) { + Location location = locationFromKey(stationKey); + Occupancy occupancy = OCCUPIED.get(stationKey); + if (location == null || occupancy == null) { continue; } - UUID displayId = spawnDisplay(location, item); - UUID owner = null; - String rawOwner = section.getString("owner"); - if (rawOwner != null && !rawOwner.isBlank()) { - try { - owner = UUID.fromString(rawOwner); - } catch (IllegalArgumentException ignored) { - owner = null; - } + if (furniture(location) == Furniture.ABSENT) { + abandon(location); + continue; } - Map charged = null; - ConfigurationSection chargedSection = section.getConfigurationSection("charged"); - if (chargedSection != null) { - charged = new LinkedHashMap<>(); - for (String index : chargedSection.getKeys(false)) { - String path = chargedSection.getString(index + ".path"); - int amount = chargedSection.getInt(index + ".amount"); - if (path != null && !path.isBlank() && amount > 0) { - charged.merge(path, amount, Integer::sum); - } - } + UUID displayId = ensureDisplay(location, occupancy.getItem(), occupancy.displayId); + if (displayId != null && !displayId.equals(occupancy.displayId)) { + occupancy.displayId = displayId; + changed = true; } - OCCUPIED.put(key(location), new Occupancy(item, displayId, owner, charged)); + } + if (changed) { + save(); } } + /** + * Occupied station whose block center is within {@code maxDistance} of {@code origin}. + * 0.75 reaches a frame in this block and stops short of the next block's center. + */ + public static Location occupiedWithin(Location origin, double maxDistance) { + if (origin == null || origin.getWorld() == null || maxDistance < 0) { + return null; + } + double maxSquared = maxDistance * maxDistance; + Location best = null; + double bestDistance = Double.MAX_VALUE; + for (String stationKey : OCCUPIED.keySet()) { + Location location = locationFromKey(stationKey); + double distance = distanceSquaredToCenter(location, origin); + if (distance > maxSquared || distance >= bestDistance) { + continue; + } + bestDistance = distance; + best = location; + } + return best; + } + + /** Squared distance from {@code at} to the center of {@code station}'s block. */ + public static double distanceSquaredToCenter(Location station, Location at) { + if (station == null || at == null || station.getWorld() == null || at.getWorld() == null + || station.getWorld() != at.getWorld()) { + return Double.MAX_VALUE; + } + double dx = at.getX() - (station.getBlockX() + 0.5); + double dy = at.getY() - (station.getBlockY() + 0.5); + double dz = at.getZ() - (station.getBlockZ() + 0.5); + return (dx * dx) + (dy * dy) + (dz * dz); + } + public static void save() { YamlConfiguration config = new YamlConfiguration(); int index = 0; @@ -221,6 +310,9 @@ public static void save() { config.set(path + ".y", location.getBlockY()); config.set(path + ".z", location.getBlockZ()); config.set(path + ".item", occupancy.getItem()); + if (occupancy.displayId != null) { + config.set(path + ".display", occupancy.displayId.toString()); + } if (occupancy.getOwner() != null) { config.set(path + ".owner", occupancy.getOwner().toString()); } @@ -245,15 +337,162 @@ public static void save() { private static void clear(Location location, boolean save) { Occupancy occupancy = OCCUPIED.remove(key(location)); - if (occupancy != null && occupancy.displayId != null) { - Entity entity = Bukkit.getEntity(occupancy.displayId); + removeDisplays(location, null, occupancy == null ? null : occupancy.displayId); + if (save) { + save(); + } + } + + /** Drops the weapon and forgets the station. The display is removed first. */ + private static void abandon(Location location) { + Occupancy occupancy = OCCUPIED.remove(key(location)); + removeDisplays(location, null, occupancy == null ? null : occupancy.displayId); + if (occupancy != null && occupancy.getItem() != null) { + drop(location, occupancy.getItem()); + Magic.plugin.getLogger().warning("[Magic] Gear station at " + key(location) + + " has no furniture. Dropped the weapon and removed its display."); + } + save(); + } + + private static UUID ensureDisplay(Location location, ItemStack item, UUID savedDisplay) { + World world = location.getWorld(); + if (world == null) { + return null; + } + if (!world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { + location.getChunk().load(); + } + ItemDisplay existing = displayOrNull(savedDisplay); + if (existing == null) { + existing = findTagged(location); + } + if (existing != null) { + existing.setItemStack(item); + mark(existing, location); + removeDisplays(location, existing.getUniqueId(), null); + return existing.getUniqueId(); + } + removeDisplays(location, null, savedDisplay); + return spawnDisplay(location, item); + } + + private static ItemDisplay displayOrNull(UUID displayId) { + if (displayId == null) { + return null; + } + Entity entity = Bukkit.getEntity(displayId); + if (entity instanceof ItemDisplay display && display.isValid()) { + return display; + } + return null; + } + + private static ItemDisplay findTagged(Location location) { + World world = location.getWorld(); + if (world == null) { + return null; + } + String stationKey = key(location); + for (Entity entity : world.getNearbyEntities(displayLocation(location), 0.5, 0.5, 0.5)) { + if (entity instanceof ItemDisplay display && stationKey.equals(markOf(display))) { + return display; + } + } + return null; + } + + /** + * Removes this station's displays. A kept display is the one still in use. + * Untagged displays are removed only when the item is a mage weapon, so a + * furniture display sitting nearby is left alone. + */ + private static void removeDisplays(Location location, UUID keep, UUID savedDisplay) { + if (location == null || location.getWorld() == null) { + return; + } + World world = location.getWorld(); + try { + if (!world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { + location.getChunk().load(); + } + } catch (RuntimeException ex) { + Magic.plugin.getLogger().warning("[Magic] Could not load gear station chunk at " + + key(location) + ": " + ex.getMessage()); + } + String stationKey = key(location); + for (Entity entity : world.getNearbyEntities(displayLocation(location), 0.5, 0.5, 0.5)) { + if (!(entity instanceof ItemDisplay display)) { + continue; + } + if (keep != null && keep.equals(display.getUniqueId())) { + continue; + } + if (stationKey.equals(markOf(display)) || isGearDisplay(display)) { + display.remove(); + } + } + if (savedDisplay != null && (keep == null || !keep.equals(savedDisplay))) { + Entity entity = Bukkit.getEntity(savedDisplay); if (entity != null) { entity.remove(); } } - if (save) { - save(); + } + + private static boolean isGearDisplay(ItemDisplay display) { + ItemStack stack = display.getItemStack(); + if (stack == null || !stack.hasItemMeta()) { + return false; + } + ItemMeta meta = stack.getItemMeta(); + if (meta == null) { + return false; + } + return meta.getPersistentDataContainer().has(GearKeys.archetype(), PersistentDataType.STRING) + || meta.getPersistentDataContainer().has(GearKeys.parts(), PersistentDataType.STRING); + } + + private static String markOf(ItemDisplay display) { + return display.getPersistentDataContainer().get(GearKeys.stationDisplay(), PersistentDataType.STRING); + } + + private static void mark(ItemDisplay display, Location location) { + display.getPersistentDataContainer().set( + GearKeys.stationDisplay(), PersistentDataType.STRING, key(location)); + } + + private static void drop(Location location, ItemStack item) { + if (location.getWorld() == null || item == null) { + return; } + location.getWorld().dropItem(location.clone().add(0.5, 1.0, 0.5), item.clone()); + } + + private enum Furniture { + PRESENT, + ABSENT, + UNKNOWN + } + + /** + * Present when the station furniture still occupies the block. Absent only when + * the barrier hitbox is gone, so a lookup miss on a still-standing station does + * not drop the weapon. + */ + private static Furniture furniture(Location location) { + Block block = location.getBlock(); + try { + if (TLibs.getBlockAPI().getChecker().checkBlock(block, GearCache.station)) { + return Furniture.PRESENT; + } + } catch (RuntimeException ex) { + return Furniture.UNKNOWN; + } + if (block.getType() != Material.BARRIER) { + return Furniture.ABSENT; + } + return Furniture.UNKNOWN; } private static UUID spawnDisplay(Location location, ItemStack item) { @@ -261,12 +500,13 @@ private static UUID spawnDisplay(Location location, ItemStack item) { if (world == null) { return null; } - Location at = location.clone().add(0.5, 1.15, 0.5); + Location at = displayLocation(location); ItemDisplay display = world.spawn(at, ItemDisplay.class, spawned -> { spawned.setItemStack(item); spawned.setBillboard(Billboard.CENTER); spawned.setPersistent(true); spawned.setInterpolationDuration(0); + mark(spawned, location); Transformation transform = spawned.getTransformation(); spawned.setTransformation(new Transformation( transform.getTranslation(), @@ -277,6 +517,37 @@ private static UUID spawnDisplay(Location location, ItemStack item) { return display.getUniqueId(); } + private static Location displayLocation(Location location) { + return location.clone().add(0.5, 1.15, 0.5); + } + + private static UUID uuidOf(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static Map readCharged(ConfigurationSection section) { + ConfigurationSection chargedSection = section.getConfigurationSection("charged"); + if (chargedSection == null) { + return null; + } + Map charged = new LinkedHashMap<>(); + for (String index : chargedSection.getKeys(false)) { + String path = chargedSection.getString(index + ".path"); + int amount = chargedSection.getInt(index + ".amount"); + if (path != null && !path.isBlank() && amount > 0) { + charged.merge(path, amount, Integer::sum); + } + } + return charged; + } + private static File file() { return new File(Magic.plugin.getDataFolder(), "data/gear-stations.yml"); } @@ -289,7 +560,7 @@ public static String key(Location location) { + location.getBlockY() + "," + location.getBlockZ(); } - private static Location locationFromKey(String key) { + public static Location locationFromKey(String key) { String[] bits = key.split(","); if (bits.length != 4) { return null;