From 6e8654ecd2a744440018c684caef85c0fafa9c32 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Thu, 16 Jul 2026 23:16:40 +0800 Subject: [PATCH 01/12] Reduce display hot paths and retained state --- .../InteractionVisualizer.java | 6 + .../blocks/BeeHiveDisplay.java | 4 +- .../blocks/BeeNestDisplay.java | 4 +- .../blocks/BlastFurnaceDisplay.java | 6 +- .../blocks/FurnaceDisplay.java | 6 +- .../blocks/SmokerDisplay.java | 6 +- .../entities/DroppedItemDisplay.java | 59 ++- .../entities/VisibilityTokenBucket.java | 26 +- .../managers/PlayerLocationManager.java | 5 + .../managers/PreferenceManager.java | 343 +++++++++++++++++- .../managers/TaskManager.java | 25 ++ .../managers/TileEntityManager.java | 289 ++++++++++++--- .../entities/VisibilityTokenBucketTest.java | 37 ++ ...enBlockUpdateListenerRegistrationTest.java | 3 + .../PreferenceManagerSessionGateTest.java | 247 +++++++++++++ .../managers/TaskManagerShutdownTest.java | 93 +++++ .../TileEntityManagerLifecycleOrderTest.java | 244 +++++++++++++ 17 files changed, 1288 insertions(+), 115 deletions(-) create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/managers/TaskManagerShutdownTest.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index 633efe8e..63c14ae9 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -34,6 +34,7 @@ import com.loohp.interactionvisualizer.managers.DisplayManager; import com.loohp.interactionvisualizer.managers.PreferenceManager; import com.loohp.interactionvisualizer.managers.PerformanceMetrics; +import com.loohp.interactionvisualizer.managers.PlayerLocationManager; import com.loohp.interactionvisualizer.managers.TaskManager; import com.loohp.interactionvisualizer.managers.TileEntityManager; import com.loohp.interactionvisualizer.metrics.Charts; @@ -45,6 +46,7 @@ import com.loohp.interactionvisualizer.updater.Updater.UpdaterResponse; import com.loohp.interactionvisualizer.scheduler.Scheduler; import com.loohp.interactionvisualizer.config.SparrowConfiguration; +import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -255,7 +257,11 @@ public void onDisable() { if (preferenceManager != null) { preferenceManager.close(); } + TaskManager.shutdown(); DisplayManager.shutdown(); + TileEntityManager.shutdown(); + PlayerLocationManager.clearCache(); + LegacyTextComponentCache.invalidateAll(); if (asyncExecutorManager != null) { asyncExecutorManager.close(); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java index fe5ad973..816042c4 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java @@ -433,7 +433,7 @@ public void updateBlock(Block block) { Map map = beehiveMap.get(block); - org.bukkit.block.Beehive beehiveState = (org.bukkit.block.Beehive) block.getState(); + org.bukkit.block.Beehive beehiveState = (org.bukkit.block.Beehive) block.getState(false); org.bukkit.block.data.type.Beehive beehiveData = (org.bukkit.block.data.type.Beehive) block.getBlockData(); { @@ -468,7 +468,7 @@ public boolean isActive(Location loc) { public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); - BlockData blockData = block.getState().getBlockData(); + BlockData blockData = block.getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); Location loc0 = labelLocation(block.getLocation(), facing, 0.25); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java index d707eb79..321ac7e6 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java @@ -433,7 +433,7 @@ public void updateBlock(Block block) { Map map = beenestMap.get(block); - org.bukkit.block.Beehive beehiveState = (org.bukkit.block.Beehive) block.getState(); + org.bukkit.block.Beehive beehiveState = (org.bukkit.block.Beehive) block.getState(false); org.bukkit.block.data.type.Beehive beehiveData = (org.bukkit.block.data.type.Beehive) block.getBlockData(); { @@ -468,7 +468,7 @@ public boolean isActive(Location loc) { public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); - BlockData blockData = block.getState().getBlockData(); + BlockData blockData = block.getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); Location loc0 = labelLocation(block.getLocation(), facing, 0.25); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java index a7c1c3d4..9c679bd5 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java @@ -217,7 +217,7 @@ private ScheduledTask legacyRun() { if (!block.getType().equals(Material.BLAST_FURNACE)) { return; } - org.bukkit.block.BlastFurnace blastfurnace = (org.bukkit.block.BlastFurnace) block.getState(); + org.bukkit.block.BlastFurnace blastfurnace = (org.bukkit.block.BlastFurnace) block.getState(false); { Inventory inv = blastfurnace.getInventory(); @@ -336,7 +336,7 @@ private boolean updateHybridBlock(Block block) { values.putAll(spawnDisplayEntitys(block)); blastfurnaceMap.put(block, values); } - org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(false); return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, filledColor, noFuelColor, progressBarLength, amountPending); } @@ -628,7 +628,7 @@ public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); - BlockData blockData = block.getState().getBlockData(); + BlockData blockData = block.getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); Location target = block.getRelative(facing).getLocation(); Vector direction = target.toVector().subtract(origin.toVector()).multiply(0.7); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java index 8db85a5d..ae54a8fb 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java @@ -216,7 +216,7 @@ private ScheduledTask legacyRun() { if (!isFurnace(block.getType())) { return; } - org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(false); { Inventory inv = furnace.getInventory(); @@ -335,7 +335,7 @@ private boolean updateHybridBlock(Block block) { values.putAll(spawnDisplayEntitys(block)); furnaceMap.put(block, values); } - org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(false); return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, filledColor, noFuelColor, progressBarLength, amountPending); } @@ -624,7 +624,7 @@ public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); - BlockData blockData = block.getState().getBlockData(); + BlockData blockData = block.getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); Location target = block.getRelative(facing).getLocation(); Vector direction = target.toVector().subtract(origin.toVector()).multiply(0.7); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java index 7b99b12a..a026375b 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java @@ -218,7 +218,7 @@ private ScheduledTask legacyRun() { if (!block.getType().equals(Material.SMOKER)) { return; } - org.bukkit.block.Smoker smoker = (org.bukkit.block.Smoker) block.getState(); + org.bukkit.block.Smoker smoker = (org.bukkit.block.Smoker) block.getState(false); { Inventory inv = smoker.getInventory(); @@ -337,7 +337,7 @@ private boolean updateHybridBlock(Block block) { values.putAll(spawnDisplayEntitys(block)); smokerMap.put(block, values); } - org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(); + org.bukkit.block.Furnace furnace = (org.bukkit.block.Furnace) block.getState(false); return FurnaceDisplayUpdater.update(furnace, values, KEY, progressBarCharacter, emptyColor, filledColor, noFuelColor, progressBarLength, amountPending); } @@ -636,7 +636,7 @@ public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); - BlockData blockData = block.getState().getBlockData(); + BlockData blockData = block.getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); Location target = block.getRelative(facing).getLocation(); Vector direction = target.toVector().subtract(origin.toVector()).multiply(0.7); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java index fc103da8..0c811c79 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -21,15 +21,14 @@ import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.ChatColorUtils; -import com.loohp.interactionvisualizer.utils.ComponentFont; import com.loohp.interactionvisualizer.utils.ItemNameUtils; +import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; import com.loohp.interactionvisualizer.scheduler.ScheduledRunnable; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextReplacementConfig; import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Bukkit; import org.bukkit.Color; @@ -81,6 +80,7 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private final Map trackedItems = new HashMap<>(); private final Map labels = new HashMap<>(); private final Set eligibleViewers = new HashSet<>(); + private final Map desiredViewers = new HashMap<>(); private final Map visibilityStates = new HashMap<>(); private String regularFormatting; @@ -95,6 +95,7 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private int ticksUntilUpdate; private int despawnTicks = 6000; private DroppedItemVisibilityPolicy visibilityPolicy = DroppedItemVisibilityPolicy.legacyDefaults(); + private boolean visibilityQueuesPending; private boolean stripColorBlacklist; private DroppedItemBlacklist blacklist = DroppedItemBlacklist.compile(List.of(), DroppedItemDisplay::warn); @@ -448,30 +449,32 @@ private static void setLabelVerticalTranslation(TextDisplay label, float targetY } private Collection reconcileEligibleViewers() { - Map desired = new HashMap<>(); + desiredViewers.clear(); for (Player player : InteractionVisualizerAPI.getPlayerModuleList(Modules.HOLOGRAM, KEY)) { if (player.isOnline()) { - desired.put(player.getUniqueId(), player); + desiredViewers.put(player.getUniqueId(), player); } } - for (UUID uuid : new HashSet<>(eligibleViewers)) { - if (!desired.containsKey(uuid)) { + Iterator eligibleIterator = eligibleViewers.iterator(); + while (eligibleIterator.hasNext()) { + UUID uuid = eligibleIterator.next(); + if (!desiredViewers.containsKey(uuid)) { Player player = Bukkit.getPlayer(uuid); if (visibilityPolicy.controlsPerViewerVisibility()) { removeVisibilityState(uuid, player); } else if (player != null) { setAllLabelsVisible(player, false); } - eligibleViewers.remove(uuid); + eligibleIterator.remove(); } } - for (Map.Entry entry : desired.entrySet()) { + for (Map.Entry entry : desiredViewers.entrySet()) { if (eligibleViewers.add(entry.getKey()) && !visibilityPolicy.controlsPerViewerVisibility()) { setAllLabelsVisible(entry.getValue(), true); } } - return desired.values(); + return desiredViewers.values(); } private void switchVisibilityMode(boolean controlled) { @@ -485,6 +488,7 @@ private void switchVisibilityMode(boolean controlled) { state.pending.clear(); } visibilityStates.clear(); + visibilityQueuesPending = false; } private void showToEligibleViewers(TextDisplay label) { @@ -519,7 +523,8 @@ private void reconcileLabelVisibility(Collection viewers, List new VisibilityState(visibilityPolicy.bucketSize())); - Set desired = new HashSet<>(); + Set desired = state.nextDesired; + desired.clear(); Location playerLocation = player.getLocation(); int trackingDistance = InteractionVisualizer.playerTrackingRange .getOrDefault(player.getWorld(), DEFAULT_TRACKING_DISTANCE); @@ -546,13 +551,15 @@ private void reconcileLabelVisibility(Collection viewers, List(state.shown)) { + Iterator shownIterator = state.shown.iterator(); + while (shownIterator.hasNext()) { + UUID itemId = shownIterator.next(); if (!desired.contains(itemId)) { TextDisplay label = labels.get(itemId); if (label != null && label.isValid()) { setLabelVisible(player, label, false); } - state.shown.remove(itemId); + shownIterator.remove(); } } for (UUID itemId : state.desired) { @@ -560,27 +567,38 @@ private void reconcileLabelVisibility(Collection viewers, List entry : visibilityStates.entrySet()) { Player player = Bukkit.getPlayer(entry.getKey()); VisibilityState state = entry.getValue(); if (player == null || !player.isOnline() || !eligibleViewers.contains(entry.getKey())) { + state.pending.clear(); continue; } - List ready = visibilityPolicy.rateLimitEnabled() - ? state.pending.drain( - visibilityPolicy.bucketSize(), visibilityPolicy.restorePerTick(), - id -> isPendingVisibilityWanted(state, id)) - : state.pending.drainAll(id -> isPendingVisibilityWanted(state, id)); + List ready = state.ready; + ready.clear(); + if (visibilityPolicy.rateLimitEnabled()) { + state.pending.drainInto( + visibilityPolicy.bucketSize(), visibilityPolicy.restorePerTick(), + id -> isPendingVisibilityWanted(state, id), ready); + } else { + state.pending.drainAllInto(id -> isPendingVisibilityWanted(state, id), ready); + } for (UUID itemId : ready) { TextDisplay label = labels.get(itemId); if (label != null && label.isValid()) { @@ -588,7 +606,10 @@ private void drainVisibilityQueues() { state.shown.add(itemId); } } + ready.clear(); + stillPending |= state.pending.hasPending(); } + visibilityQueuesPending = stillPending; } private boolean isPendingVisibilityWanted(VisibilityState state, UUID itemId) { @@ -627,7 +648,7 @@ private Component format(ItemStack stack, int ticksLeft) { template = amount == 1 ? singularFormatting : regularFormatting; } String rendered = template.replace("{Amount}", Integer.toString(amount)).replace("{Timer}", timer); - Component component = ComponentFont.parseFont(LegacyComponentSerializer.legacySection().deserialize(rendered)); + Component component = LegacyTextComponentCache.parse(rendered); return component.replaceText(TextReplacementConfig.builder() .matchLiteral("{Item}") .replacement(ItemNameUtils.getDisplayName(stack)) @@ -708,8 +729,10 @@ private record TrackedItem(UUID itemId, Item item, UUID worldId, Location locati private static final class VisibilityState { private Set desired = new HashSet<>(); + private Set nextDesired = new HashSet<>(); private final Set shown = new HashSet<>(); private final VisibilityTokenBucket pending; + private final List ready = new ArrayList<>(); private VisibilityState(int initialTokens) { this.pending = new VisibilityTokenBucket<>(initialTokens); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java index 92cd10ca..1150d190 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucket.java @@ -13,6 +13,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collection; import java.util.Deque; import java.util.HashSet; import java.util.List; @@ -41,14 +42,20 @@ void cancel(T value) { } List drain(int capacity, int refill, Predicate stillWanted) { + List ready = new ArrayList<>(); + drainInto(capacity, refill, stillWanted, ready); + return ready; + } + + void drainInto(int capacity, int refill, Predicate stillWanted, + Collection ready) { int safeCapacity = Math.max(1, capacity); long replenished = (long) tokens + Math.max(0, refill); tokens = (int) Math.min(safeCapacity, replenished); if (tokens == 0 || pending.isEmpty()) { - return List.of(); + return; } - List ready = new ArrayList<>(Math.min(tokens, pending.size())); while (tokens > 0 && !pending.isEmpty()) { T value = pending.removeFirst(); if (!queued.remove(value) || !stillWanted.test(value)) { @@ -57,26 +64,29 @@ List drain(int capacity, int refill, Predicate stillWanted) { ready.add(value); tokens--; } - return ready; } List drainAll(Predicate stillWanted) { - if (pending.isEmpty()) { - return List.of(); - } + List ready = new ArrayList<>(); + drainAllInto(stillWanted, ready); + return ready; + } - List ready = new ArrayList<>(pending.size()); + void drainAllInto(Predicate stillWanted, Collection ready) { while (!pending.isEmpty()) { T value = pending.removeFirst(); if (queued.remove(value) && stillWanted.test(value)) { ready.add(value); } } - return ready; } void clear() { pending.clear(); queued.clear(); } + + boolean hasPending() { + return !pending.isEmpty(); + } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PlayerLocationManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PlayerLocationManager.java index c364be64..bc1b0f9f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PlayerLocationManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PlayerLocationManager.java @@ -80,6 +80,11 @@ public static Location getPlayerEyeLocation(Player player) { return player.getEyeLocation(); } + /** Releases per-world snapshots retained by the previous plugin lifecycle. */ + public static void clearCache() { + PLAYER_CHUNKS.clear(); + } + public static Collection filterOutOfRange(Collection players, VisualizerEntity entity) { return filterOutOfRange(players, entity.getLocation()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java index 7c510a20..192e449b 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java @@ -35,6 +35,7 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.BitSet; @@ -45,10 +46,18 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; +import java.util.Queue; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BooleanSupplier; +import java.util.logging.Level; public class PreferenceManager implements Listener, AutoCloseable { @@ -60,28 +69,56 @@ public class PreferenceManager implements Listener, AutoCloseable { private final SynchronizedFilteredCollection backingPlayerList; private final AtomicBoolean valid; + private final PlayerSessionGate playerSessions; + private final ExecutorService playerIoExecutor; + private final PlayerIoQueue playerIo; public PreferenceManager(InteractionVisualizer plugin) { this.plugin = plugin; this.valid = new AtomicBoolean(true); + this.playerSessions = new PlayerSessionGate(); + this.playerIoExecutor = Executors.newThreadPerTaskExecutor( + Thread.ofVirtual().name("InteractionVisualizer-PlayerIO-", 0).factory()); + this.playerIo = new PlayerIoQueue(playerIoExecutor); this.entries = Collections.synchronizedList(ArrayUtils.putToArrayList(Database.getBitIndex(), new ArrayList<>())); this.preferences = new ConcurrentHashMap<>(); this.backingPlayerList = SynchronizedFilteredCollection.from(new LinkedHashSet<>()); for (Player player : Bukkit.getOnlinePlayers()) { + UUID uuid = player.getUniqueId(); + Object session = playerSessions.open(uuid, valid); backingPlayerList.add(player); - loadPlayer(player.getUniqueId(), player.getName(), true); + playerIo.runAndWait(uuid, () -> loadPlayerForSession(uuid, player.getName(), session)); } Bukkit.getPluginManager().registerEvents(this, plugin); } @Override public synchronized void close() { + if (!playerSessions.close(valid)) { + return; + } backingPlayerList.clear(); - for (UUID uuid : preferences.keySet()) { - savePlayer(uuid, true); + Map> snapshots = new HashMap<>(); + for (Entry> entry : preferences.entrySet()) { + snapshots.put(entry.getKey(), copyPreferences(entry.getValue())); + } + preferences.clear(); + for (Entry> entry : snapshots.entrySet()) { + playerIo.submit(entry.getKey(), () -> savePlayerInfo(entry.getKey(), entry.getValue())); + } + RuntimeException playerIoFailure = null; + try { + playerIo.closeAndWait(); + } catch (RuntimeException exception) { + playerIoFailure = exception; + } finally { + playerIoExecutor.shutdown(); } saveBitmaskIndex(); - valid.set(false); + if (playerIoFailure != null) { + plugin.getLogger().log(Level.SEVERE, + "One or more queued player preference operations failed before shutdown", playerIoFailure); + } } public boolean isValid() { @@ -132,23 +169,53 @@ public void registerEntry(List entryKeys) { @EventHandler public void onJoinEvent(PlayerJoinEvent event) { Player player = event.getPlayer(); + UUID uuid = player.getUniqueId(); + String name = player.getName(); + Object session = playerSessions.open(uuid, valid); + if (session == null) { + return; + } backingPlayerList.add(player); - InteractionVisualizer.asyncExecutorManager.runTaskAsynchronously(() -> { - loadPlayer(player.getUniqueId(), player.getName(), true); - updatePlayer(player, false); + playerIo.submit(uuid, () -> { + if (loadPlayerForSession(uuid, name, session)) { + InteractionVisualizer.asyncExecutorManager.runTaskSynchronously(() -> { + if (valid.get() && playerSessions.isCurrent(uuid, session) && player.isOnline()) { + updatePlayer(player, false); + } + }); + } }); } @EventHandler public void onQuitEvent(PlayerQuitEvent event) { Player player = event.getPlayer(); + UUID uuid = player.getUniqueId(); backingPlayerList.remove(player); - InteractionVisualizer.asyncExecutorManager.runTaskAsynchronously(() -> { - savePlayer(event.getPlayer().getUniqueId(), true); - }); + playerSessions.end(uuid); + Map snapshot = copyPreferences(preferences.remove(uuid)); + playerIo.submit(uuid, () -> savePlayerInfo(uuid, snapshot)); } public void loadPlayer(UUID uuid, String name, boolean createIfNotFound) { + if (!valid.get()) { + return; + } + playerIo.runAndWait(uuid, () -> { + Map info = readPlayerInfo(uuid, name, createIfNotFound); + if (info != null) { + playerSessions.publishIfValid(valid, () -> preferences.put(uuid, info)); + } + }); + } + + private boolean loadPlayerForSession(UUID uuid, String name, Object session) { + Map info = readPlayerInfo(uuid, name, true); + return info != null && playerSessions.publishIfCurrent( + uuid, session, valid, () -> preferences.put(uuid, info)); + } + + private Map readPlayerInfo(UUID uuid, String name, boolean createIfNotFound) { if (createIfNotFound) { boolean newPlayer = false; if (!Database.playerExists(uuid)) { @@ -156,21 +223,25 @@ public void loadPlayer(UUID uuid, String name, boolean createIfNotFound) { newPlayer = true; } Map info = Database.getPlayerInfo(uuid); - preferences.put(uuid, info); if (newPlayer && InteractionVisualizer.defaultDisabledAll) { - setPlayerAllPreference(uuid, false, false); - savePlayer(uuid, false); + disableAll(info); + savePlayerInfo(uuid, info); } + return info; } else { if (Database.playerExists(uuid)) { - Map info = Database.getPlayerInfo(uuid); - preferences.put(uuid, info); + return Database.getPlayerInfo(uuid); } + return null; } } public void savePlayer(UUID uuid, boolean unload) { - Map info = unload ? preferences.remove(uuid) : preferences.get(uuid); + Map info = copyPreferences(unload ? preferences.remove(uuid) : preferences.get(uuid)); + playerIo.runAndWait(uuid, () -> savePlayerInfo(uuid, info)); + } + + private void savePlayerInfo(UUID uuid, Map info) { if (info != null) { for (Entry entry : info.entrySet()) { switch (entry.getKey()) { @@ -188,6 +259,26 @@ public void savePlayer(UUID uuid, boolean unload) { } } + private void disableAll(Map info) { + synchronized (entries) { + int entryCount = entries.size(); + for (Modules module : Modules.values()) { + info.computeIfAbsent(module, ignored -> new BitSet()).set(0, entryCount, true); + } + } + } + + private static Map copyPreferences(Map source) { + if (source == null) { + return null; + } + Map copy = new HashMap<>(); + for (Entry entry : source.entrySet()) { + copy.put(entry.getKey(), (BitSet) entry.getValue().clone()); + } + return copy; + } + public void unloadPlayerWithoutSaving(UUID uuid) { preferences.remove(uuid); } @@ -453,4 +544,224 @@ public Collection getPlayerList() { return Collections.unmodifiableCollection(backingPlayerList); } + static final class PlayerSessionGate { + + private final Map sessions = new HashMap<>(); + + synchronized Object open(UUID uuid, AtomicBoolean valid) { + if (!valid.get()) { + return null; + } + Object session = new Object(); + sessions.put(uuid, session); + return session; + } + + synchronized void end(UUID uuid) { + sessions.remove(uuid); + } + + synchronized boolean isCurrent(UUID uuid, Object session) { + return sessions.get(uuid) == session; + } + + synchronized boolean publishIfCurrent(UUID uuid, Object session, AtomicBoolean valid, Runnable publish) { + if (!valid.get() || sessions.get(uuid) != session) { + return false; + } + publish.run(); + return true; + } + + synchronized boolean publishIfValid(AtomicBoolean valid, Runnable publish) { + if (!valid.get()) { + return false; + } + publish.run(); + return true; + } + + synchronized boolean close(AtomicBoolean valid) { + if (!valid.compareAndSet(true, false)) { + return false; + } + sessions.clear(); + return true; + } + } + + /** + * Serializes database work for one player without coupling unrelated players to the same queue. + */ + static final class PlayerIoQueue { + + private final Executor executor; + private final Map queues = new HashMap<>(); + private final ThreadLocal activePlayer = new ThreadLocal<>(); + private boolean accepting = true; + private CompletableFuture closed; + private Throwable failure; + + PlayerIoQueue(Executor executor) { + this.executor = Objects.requireNonNull(executor, "executor"); + } + + CompletableFuture submit(UUID uuid, Runnable operation) { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(operation, "operation"); + + QueuedOperation queued = new QueuedOperation(operation); + QueueState state; + boolean startDrain; + synchronized (this) { + if (!accepting) { + return null; + } + state = queues.computeIfAbsent(uuid, ignored -> new QueueState()); + state.operations.add(queued); + startDrain = !state.running; + if (startDrain) { + state.running = true; + } + } + + if (startDrain) { + try { + executor.execute(() -> drain(uuid, state)); + } catch (RuntimeException | Error throwable) { + reject(uuid, state, throwable); + } + } + return queued.completion; + } + + boolean runAndWait(UUID uuid, Runnable operation) { + if (uuid.equals(activePlayer.get())) { + throw new IllegalStateException("Cannot synchronously enqueue player I/O from its own queue"); + } + CompletableFuture completion = submit(uuid, operation); + if (completion == null) { + return false; + } + await(completion); + return true; + } + + void closeAndWait() { + if (activePlayer.get() != null) { + throw new IllegalStateException("Cannot close the player I/O queue from one of its drains"); + } + await(seal()); + } + + synchronized CompletableFuture seal() { + if (closed != null) { + return closed; + } + accepting = false; + closed = new CompletableFuture<>(); + completeCloseIfIdle(); + return closed; + } + + private void drain(UUID uuid, QueueState state) { + while (true) { + QueuedOperation queued; + synchronized (this) { + queued = state.operations.poll(); + if (queued == null) { + state.running = false; + if (queues.get(uuid) == state) { + queues.remove(uuid); + } + completeCloseIfIdle(); + return; + } + } + + activePlayer.set(uuid); + try { + queued.operation.run(); + queued.completion.complete(null); + } catch (Throwable throwable) { + recordFailure(throwable); + queued.completion.completeExceptionally(throwable); + } finally { + activePlayer.remove(); + } + } + } + + private void reject(UUID uuid, QueueState state, Throwable throwable) { + List rejected = new ArrayList<>(); + synchronized (this) { + if (queues.get(uuid) != state) { + return; + } + recordFailureLocked(throwable); + state.running = false; + QueuedOperation queued; + while ((queued = state.operations.poll()) != null) { + rejected.add(queued); + } + queues.remove(uuid); + completeCloseIfIdle(); + } + for (QueuedOperation queued : rejected) { + queued.completion.completeExceptionally(throwable); + } + } + + private void completeCloseIfIdle() { + if (closed != null && queues.isEmpty()) { + if (failure == null) { + closed.complete(null); + } else { + closed.completeExceptionally(failure); + } + } + } + + private synchronized void recordFailure(Throwable throwable) { + recordFailureLocked(throwable); + } + + private void recordFailureLocked(Throwable throwable) { + if (failure == null) { + failure = throwable; + } + } + + private static void await(CompletableFuture completion) { + try { + completion.join(); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw exception; + } + } + + private static final class QueueState { + + private final Queue operations = new ArrayDeque<>(); + private boolean running; + } + + private static final class QueuedOperation { + + private final Runnable operation; + private final CompletableFuture completion = new CompletableFuture<>(); + + private QueuedOperation(Runnable operation) { + this.operation = operation; + } + } + } + } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java index 4aac7f97..d1802f1f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java @@ -449,6 +449,31 @@ public static void run() { } } + /** + * Stops every scheduler task owned by this plugin and releases the display + * registries that otherwise retain a full graph of listeners and task handles + * until the old plugin class loader is collected. + */ + public static void shutdown() { + try { + if (plugin != null) { + Bukkit.getScheduler().cancelTasks(plugin); + } + } finally { + clearRuntimeState(); + } + } + + static void clearRuntimeState() { + pendingInventoryOpenProcesses.clear(); + pendingInventoryRefreshes.clear(); + for (List displays : processes.values()) { + displays.clear(); + } + processes.clear(); + runnables.clear(); + } + public static void processOpenInventory(Player player) { if (!player.isOnline()) { return; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java index 6db4bb88..5928446a 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java @@ -66,13 +66,15 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BooleanSupplier; public class TileEntityManager implements Listener { enum LifecycleChange { REMOVED, ADDED, - ACTIVATED + ACTIVATED, + DEACTIVATED } @FunctionalInterface @@ -88,8 +90,12 @@ interface LifecycleDispatcher { private static final Map lastActiveTypes = new HashMap<>(); private static final Map> watchedChunksByPlayer = new HashMap<>(); private static final Map watcherCounts = new HashMap<>(); + private static final Set dirtyWatcherChunks = new LinkedHashSet<>(); + private static final Set unloadingChunks = new LinkedHashSet<>(); + private static boolean drainingWatcherChanges; - public static void _init_() { + public synchronized static void _init_() { + clearRuntimeState(); for (TileEntityType type : tileEntityTypes) { active.put(type, ConcurrentHashMap.newKeySet()); } @@ -181,36 +187,112 @@ private static void addTileEntities(Collection chunks) { } private synchronized static void addTileEntities(ChunkPosition chunk) { - if (!chunk.isLoaded()) { + if (unloadingChunks.contains(chunk) || !chunk.isLoaded()) { return; } Collection list = chunk.getChunk().getTileEntities(block -> TileEntity.isTileEntityType(block.getType()), false); - Set blocks = byChunk.get(chunk); - if (blocks == null) { - blocks = new LinkedHashSet<>(); - byChunk.put(chunk, blocks); - } - boolean activate = !InteractionVisualizer.eventDrivenBlockUpdates || watcherCounts.getOrDefault(chunk, 0) > 0; Map newBlocks = new LinkedHashMap<>(); for (BlockState state : list) { Block block = state.getBlock(); TileEntityType type = TileEntity.getTileEntityType(state.getType()); if (type != null) { newBlocks.put(block, type); - blocks.add(block); } } - Iterator itr = blocks.iterator(); - while (itr.hasNext()) { - Block block = itr.next(); + Set previousGeneration = byChunk.get(chunk); + if (previousGeneration == null && newBlocks.isEmpty()) { + return; + } + Set currentGeneration = installPendingIndexGeneration( + byChunk, unloadingChunks, chunk, newBlocks.keySet()); + if (currentGeneration == null) { + return; + } + Set reconciliationCandidates = new LinkedHashSet<>(currentGeneration); + boolean activate = !InteractionVisualizer.eventDrivenBlockUpdates || watcherCounts.getOrDefault(chunk, 0) > 0; + if (!activate && !deactivateTileEntitiesIfCurrent(chunk, currentGeneration)) { + return; + } + for (Block block : reconciliationCandidates) { TileEntityType type = newBlocks.get(block); - if (type == null) { - itr.remove(); + if (!reconcileActiveTypeIfCurrent(byChunk, chunk, currentGeneration, + block, type, activate, InteractionVisualizer.eventDrivenBlockUpdates, + active, lastActiveTypes, TileEntityManager::dispatchLifecycleChange)) { + return; } - reconcileActiveType(block, type, activate, InteractionVisualizer.eventDrivenBlockUpdates, - active, lastActiveTypes, TileEntityManager::dispatchLifecycleChange); } + finishIndexGeneration(byChunk, chunk, currentGeneration, newBlocks.keySet()); + } + + static Set installPendingIndexGeneration( + Map> index, K key, Collection currentValues) { + return installPendingIndexGeneration(index, Set.of(), key, currentValues); + } + + static Set installPendingIndexGeneration( + Map> index, Set blockedKeys, K key, Collection currentValues) { + if (blockedKeys.contains(key)) { + return null; + } + Set previousGeneration = index.get(key); + Set generation = new LinkedHashSet<>(); + if (previousGeneration != null) { + generation.addAll(previousGeneration); + } + generation.addAll(currentValues); + index.put(key, generation); + return generation; + } + + static boolean finishIndexGeneration( + Map> index, K key, Set generation, Collection currentValues) { + if (index.get(key) != generation) { + return false; + } + generation.retainAll(currentValues); + updateNonEmptyIndex(index, key, generation); + return true; + } + + static void updateNonEmptyIndex(Map> index, K key, Set values) { + if (values.isEmpty() && index.get(key) == values) { + index.remove(key); + } + } + + /** Releases block, chunk and viewer indexes retained by this plugin lifecycle. */ + public synchronized static void shutdown() { + clearRuntimeState(); + } + + private static void clearRuntimeState() { + for (Set blocks : active.values()) { + blocks.clear(); + } + active.clear(); + byChunk.clear(); + lastActiveTypes.clear(); + watchedChunksByPlayer.clear(); + watcherCounts.clear(); + dirtyWatcherChunks.clear(); + unloadingChunks.clear(); + drainingWatcherChanges = false; + } + + static boolean reconcileActiveTypeIfCurrent( + Map> index, K key, Set indexedValues, + T value, TileEntityType currentType, boolean activate, + boolean trackLifecycleEvents, + Map> activeByType, + Map lastActiveByValue, + LifecycleDispatcher dispatcher) { + if (index.get(key) != indexedValues) { + return false; + } + return reconcileActiveType(value, currentType, activate, trackLifecycleEvents, + activeByType, lastActiveByValue, dispatcher, + () -> index.get(key) == indexedValues); } static void reconcileActiveType(T value, TileEntityType currentType, boolean activate, @@ -218,6 +300,19 @@ static void reconcileActiveType(T value, TileEntityType currentType, boolean Map> activeByType, Map lastActiveByValue, LifecycleDispatcher dispatcher) { + reconcileActiveType(value, currentType, activate, trackLifecycleEvents, + activeByType, lastActiveByValue, dispatcher, () -> true); + } + + private static boolean reconcileActiveType(T value, TileEntityType currentType, boolean activate, + boolean trackLifecycleEvents, + Map> activeByType, + Map lastActiveByValue, + LifecycleDispatcher dispatcher, + BooleanSupplier stillCurrent) { + if (!stillCurrent.getAsBoolean()) { + return false; + } if (currentType == null && trackLifecycleEvents) { // Preserve the legacy removal contract for re-entrant listeners: // a removed tile is no longer considered last-active when notified. @@ -230,20 +325,27 @@ static void reconcileActiveType(T value, TileEntityType currentType, boolean Set values = activeByType.get(type); if (values != null && values.remove(value)) { dispatcher.dispatch(value, LifecycleChange.REMOVED, type); + if (!stillCurrent.getAsBoolean()) { + return false; + } } } if (currentType == null) { - return; + return true; } + if (!stillCurrent.getAsBoolean()) { + return false; + } Set currentValues = activeByType.get(currentType); if (!activate || currentValues == null || !currentValues.add(value) || !trackLifecycleEvents) { - return; + return true; } TileEntityType lastActiveType = lastActiveByValue.put(value, currentType); dispatcher.dispatch(value, currentType.equals(lastActiveType) ? LifecycleChange.ACTIVATED : LifecycleChange.ADDED, currentType); + return stillCurrent.getAsBoolean(); } private static void dispatchLifecycleChange(Block block, LifecycleChange change, TileEntityType type) { @@ -251,77 +353,146 @@ private static void dispatchLifecycleChange(Block block, LifecycleChange change, case REMOVED -> Bukkit.getPluginManager().callEvent(new TileEntityRemovedEvent(block, type)); case ADDED -> callAddedEvent(block, type); case ACTIVATED -> callActivatedEvent(block, type); + case DEACTIVATED -> callDeactivatedEvent(block, type); } } private synchronized static void deactivateTileEntities(ChunkPosition chunk) { - Set blocks = byChunk.get(chunk); - if (blocks == null || blocks.isEmpty()) { + if (unloadingChunks.contains(chunk)) { return; } - for (Block block : blocks) { + Set indexedBlocks = byChunk.get(chunk); + if (indexedBlocks == null || indexedBlocks.isEmpty()) { + return; + } + Set currentGeneration = installPendingIndexGeneration(byChunk, chunk, indexedBlocks); + deactivateTileEntitiesIfCurrent(chunk, currentGeneration); + } + + private static boolean deactivateTileEntitiesIfCurrent(ChunkPosition chunk, Set currentGeneration) { + return deactivateIfCurrent( + byChunk, chunk, currentGeneration, active, TileEntityManager::dispatchLifecycleChange); + } + + static boolean deactivateIfCurrent( + Map> index, K key, Set currentGeneration, + Map> activeByType, + LifecycleDispatcher dispatcher) { + for (T block : currentGeneration) { + if (index.get(key) != currentGeneration) { + return false; + } for (TileEntityType type : tileEntityTypes) { - if (active.get(type).remove(block)) { - callDeactivatedEvent(block, type); + Set values = activeByType.get(type); + if (values != null && values.remove(block)) { + dispatcher.dispatch(block, LifecycleChange.DEACTIVATED, type); + if (index.get(key) != currentGeneration) { + return false; + } } } } + return index.get(key) == currentGeneration; } private synchronized static void unloadTileEntities(ChunkPosition chunk) { - deactivateTileEntities(chunk); - Set blocks = byChunk.remove(chunk); - if (blocks != null) { - for (Block block : blocks) { - lastActiveTypes.remove(block); - } + if (!unloadingChunks.add(chunk)) { + return; + } + Set indexedBlocks = byChunk.get(chunk); + if (indexedBlocks == null) { + return; + } + Set currentGeneration = installPendingIndexGeneration(byChunk, chunk, indexedBlocks); + if (!deactivateTileEntitiesIfCurrent(chunk, currentGeneration) + || !byChunk.remove(chunk, currentGeneration)) { + return; + } + for (Block block : currentGeneration) { + lastActiveTypes.remove(block); + } + } + + private synchronized static void finishChunkUnload(ChunkPosition chunk) { + if (!unloadingChunks.remove(chunk)) { + return; + } + if (chunk.isLoaded() && (!InteractionVisualizer.eventDrivenBlockUpdates + || watcherCounts.getOrDefault(chunk, 0) > 0)) { + addTileEntities(chunk); } } private synchronized static void updateWatchedChunks(UUID playerId, Set nextChunks) { - Set previousChunks = watchedChunksByPlayer.put(playerId, nextChunks); + commitWatcherUpdate( + watchedChunksByPlayer, watcherCounts, dirtyWatcherChunks, + playerId, nextChunks); + drainWatcherChanges(); + } + + private synchronized static void clearWatchedChunks(UUID playerId) { + if (!watchedChunksByPlayer.containsKey(playerId)) { + return; + } + updateWatchedChunks(playerId, Set.of()); + } + + static void commitWatcherUpdate( + Map> watchedByPlayer, Map counts, Set dirty, + P playerId, Collection requestedChunks) { + Set nextChunks = new LinkedHashSet<>(requestedChunks); + Set previousChunks = nextChunks.isEmpty() + ? watchedByPlayer.remove(playerId) + : watchedByPlayer.put(playerId, nextChunks); if (previousChunks == null) { previousChunks = Set.of(); } - for (ChunkPosition chunk : previousChunks) { + for (K chunk : previousChunks) { if (nextChunks.contains(chunk)) { continue; } - Integer count = watcherCounts.get(chunk); - if (count == null || count <= 1) { - watcherCounts.remove(chunk); - deactivateTileEntities(chunk); + int count = counts.getOrDefault(chunk, 0); + if (count <= 1) { + counts.remove(chunk); + dirty.add(chunk); } else { - watcherCounts.put(chunk, count - 1); + counts.put(chunk, count - 1); } } - - for (ChunkPosition chunk : nextChunks) { + for (K chunk : nextChunks) { if (previousChunks.contains(chunk)) { continue; } - int count = watcherCounts.getOrDefault(chunk, 0); - watcherCounts.put(chunk, count + 1); + int count = counts.getOrDefault(chunk, 0); + counts.put(chunk, count + 1); if (count == 0) { - addTileEntities(chunk); + dirty.add(chunk); } } } - private synchronized static void clearWatchedChunks(UUID playerId) { - Set chunks = watchedChunksByPlayer.remove(playerId); - if (chunks == null) { + private static void drainWatcherChanges() { + if (drainingWatcherChanges) { return; } - for (ChunkPosition chunk : chunks) { - Integer count = watcherCounts.get(chunk); - if (count == null || count <= 1) { - watcherCounts.remove(chunk); - deactivateTileEntities(chunk); - } else { - watcherCounts.put(chunk, count - 1); + drainingWatcherChanges = true; + try { + while (!dirtyWatcherChunks.isEmpty()) { + Iterator iterator = dirtyWatcherChunks.iterator(); + ChunkPosition chunk = iterator.next(); + iterator.remove(); + if (unloadingChunks.contains(chunk)) { + continue; + } + if (watcherCounts.getOrDefault(chunk, 0) > 0) { + addTileEntities(chunk); + } else { + deactivateTileEntities(chunk); + } } + } finally { + drainingWatcherChanges = false; } } @@ -452,16 +623,18 @@ public void onChunkLoad(ChunkLoadEvent event) { return; } ChunkPosition chunk = new ChunkPosition(event.getWorld(), event.getChunk().getX(), event.getChunk().getZ()); + unloadingChunks.remove(chunk); if (watcherCounts.getOrDefault(chunk, 0) > 0) { addTileEntities(chunk); } } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onChunkUnload(ChunkUnloadEvent event) { - if (!InteractionVisualizer.eventDrivenBlockUpdates) { - return; - } - unloadTileEntities(new ChunkPosition(event.getWorld(), event.getChunk().getX(), event.getChunk().getZ())); + ChunkPosition chunk = new ChunkPosition( + event.getWorld(), event.getChunk().getX(), event.getChunk().getZ()); + unloadTileEntities(chunk); + Scheduler.runTask(plugin, () -> finishChunkUnload(chunk)); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -538,10 +711,6 @@ public void onChunkLoad(ChunkLoadEvent event) { manager.onChunkLoad(event); } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onChunkUnload(ChunkUnloadEvent event) { - manager.onChunkUnload(event); - } } private boolean isMovingTooFast(Player player, Location from, Location to) { diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java index 4c2d29b8..7c112bdd 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/VisibilityTokenBucketTest.java @@ -13,9 +13,12 @@ import org.junit.jupiter.api.Test; +import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class VisibilityTokenBucketTest { @@ -63,4 +66,38 @@ void largeConfiguredLimitsDoNotOverflowTokenRefill() { assertEquals(List.of(1), bucket.drain(Integer.MAX_VALUE, Integer.MAX_VALUE, ignored -> true)); } + + @Test + void drainsIntoReusableCollectionWithoutRetainingPreviousResults() { + VisibilityTokenBucket bucket = new VisibilityTokenBucket<>(2); + bucket.request(1); + bucket.request(2); + bucket.request(3); + List ready = new ArrayList<>(); + + bucket.drainInto(2, 0, ignored -> true, ready); + assertEquals(List.of(1, 2), ready); + + ready.clear(); + bucket.drainInto(2, 1, ignored -> true, ready); + assertEquals(List.of(3), ready); + + ready.clear(); + bucket.request(4); + bucket.request(5); + bucket.drainAllInto(value -> value != 5, ready); + assertEquals(List.of(4), ready); + assertFalse(bucket.hasPending()); + } + + @Test + void canceledEntriesRemainPendingOnlyUntilTheNextDrain() { + VisibilityTokenBucket bucket = new VisibilityTokenBucket<>(1); + bucket.request(1); + bucket.cancel(1); + + assertTrue(bucket.hasPending()); + bucket.drainAll(ignored -> true); + assertFalse(bucket.hasPending()); + } } diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListenerRegistrationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListenerRegistrationTest.java index a1be0778..ccfc1bfa 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListenerRegistrationTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListenerRegistrationTest.java @@ -34,6 +34,7 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; @@ -61,6 +62,7 @@ void legacyHandlersRemainOnAlwaysRegisteredListeners() throws Exception { assertRegistered(BeeHiveDisplay.class, "onBeeEnterBeehive", EntityEnterBlockEvent.class); assertRegistered(BeeNestDisplay.class, "onBeeEnterBeenest", EntityEnterBlockEvent.class); assertRegistered(TileEntityManager.class, "onPlayerMove", PlayerMoveEvent.class); + assertRegistered(TileEntityManager.class, "onChunkUnload", ChunkUnloadEvent.class); } @Test @@ -83,6 +85,7 @@ void conditionalListenerOwnsTheEventDrivenSurface() throws Exception { assertRegistered(EventDrivenBlockUpdateListener.class, "onTileEntityRemoved", TileEntityRemovedEvent.class); Class lifecycleListener = findDeclaredClass(TileEntityManager.class, "EventDrivenLifecycleListener"); assertRegistered(lifecycleListener, "onChunkLoad", ChunkLoadEvent.class); + assertNoRegisteredHandler(lifecycleListener, ChunkUnloadEvent.class); } @Test diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java new file mode 100644 index 00000000..bc3a83b9 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java @@ -0,0 +1,247 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PreferenceManagerSessionGateTest { + + @Test + void quitRejectsAJoinLoadThatFinishesLate() { + PreferenceManager.PlayerSessionGate gate = new PreferenceManager.PlayerSessionGate(); + AtomicBoolean valid = new AtomicBoolean(true); + UUID uuid = UUID.randomUUID(); + Object session = gate.open(uuid, valid); + AtomicInteger publishes = new AtomicInteger(); + + gate.end(uuid); + + assertFalse(gate.publishIfCurrent(uuid, session, valid, publishes::incrementAndGet)); + assertEquals(0, publishes.get()); + } + + @Test + void reconnectRejectsThePreviousJoinSession() { + PreferenceManager.PlayerSessionGate gate = new PreferenceManager.PlayerSessionGate(); + AtomicBoolean valid = new AtomicBoolean(true); + UUID uuid = UUID.randomUUID(); + Object first = gate.open(uuid, valid); + Object second = gate.open(uuid, valid); + AtomicInteger publishes = new AtomicInteger(); + + assertFalse(gate.publishIfCurrent(uuid, first, valid, publishes::incrementAndGet)); + assertTrue(gate.publishIfCurrent(uuid, second, valid, publishes::incrementAndGet)); + assertEquals(1, publishes.get()); + } + + @Test + void closeInvalidatesAllSessionsBeforeLateLoadsCanPublish() { + PreferenceManager.PlayerSessionGate gate = new PreferenceManager.PlayerSessionGate(); + AtomicBoolean valid = new AtomicBoolean(true); + UUID uuid = UUID.randomUUID(); + Object session = gate.open(uuid, valid); + assertNotNull(session); + + assertTrue(gate.close(valid)); + assertFalse(valid.get()); + assertFalse(gate.publishIfCurrent(uuid, session, valid, () -> { })); + assertFalse(gate.publishIfValid(valid, () -> { })); + assertFalse(gate.close(valid)); + } + + @Test + void quitSaveFinishesBeforeTheSamePlayersReconnectLoad() throws Exception { + ExecutorService workers = Executors.newFixedThreadPool(2); + ExecutorService synchronousCaller = Executors.newSingleThreadExecutor(); + CountDownLatch saveStarted = new CountDownLatch(1); + CountDownLatch releaseSave = new CountDownLatch(1); + CountDownLatch loadCallStarted = new CountDownLatch(1); + CountDownLatch loadStarted = new CountDownLatch(1); + try { + PreferenceManager.PlayerIoQueue queue = new PreferenceManager.PlayerIoQueue(workers); + UUID uuid = UUID.randomUUID(); + List order = Collections.synchronizedList(new ArrayList<>()); + + CompletableFuture save = queue.submit(uuid, () -> { + order.add("save-start"); + saveStarted.countDown(); + await(releaseSave); + order.add("save-end"); + }); + CompletableFuture load = CompletableFuture.supplyAsync(() -> { + loadCallStarted.countDown(); + return queue.runAndWait(uuid, () -> { + order.add("load"); + loadStarted.countDown(); + }); + }, synchronousCaller); + + assertNotNull(save); + assertTrue(saveStarted.await(5, TimeUnit.SECONDS)); + assertTrue(loadCallStarted.await(5, TimeUnit.SECONDS)); + assertEquals(1L, loadStarted.getCount(), "same-player load ran while its prior save was blocked"); + releaseSave.countDown(); + CompletableFuture.allOf(save, load).get(5, TimeUnit.SECONDS); + + assertTrue(load.get()); + assertEquals(List.of("save-start", "save-end", "load"), order); + queue.closeAndWait(); + } finally { + releaseSave.countDown(); + synchronousCaller.shutdownNow(); + workers.shutdownNow(); + assertTrue(synchronousCaller.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void differentPlayersCanUseSeparateDrainsConcurrently() throws Exception { + ExecutorService workers = Executors.newFixedThreadPool(2); + CountDownLatch bothStarted = new CountDownLatch(2); + CountDownLatch releaseBoth = new CountDownLatch(1); + try { + PreferenceManager.PlayerIoQueue queue = new PreferenceManager.PlayerIoQueue(workers); + CompletableFuture first = queue.submit(UUID.randomUUID(), () -> { + bothStarted.countDown(); + await(releaseBoth); + }); + CompletableFuture second = queue.submit(UUID.randomUUID(), () -> { + bothStarted.countDown(); + await(releaseBoth); + }); + + assertNotNull(first); + assertNotNull(second); + assertTrue(bothStarted.await(5, TimeUnit.SECONDS), + "unrelated player queues were serialized behind one drain"); + releaseBoth.countDown(); + CompletableFuture.allOf(first, second).get(5, TimeUnit.SECONDS); + queue.closeAndWait(); + } finally { + releaseBoth.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void sealingWaitsForQueuedFinalSaveAndRejectsLateWork() throws Exception { + ExecutorService worker = Executors.newSingleThreadExecutor(); + CountDownLatch earlierIoStarted = new CountDownLatch(1); + CountDownLatch releaseEarlierIo = new CountDownLatch(1); + CountDownLatch finalSaveStarted = new CountDownLatch(1); + CountDownLatch releaseFinalSave = new CountDownLatch(1); + try { + PreferenceManager.PlayerIoQueue queue = new PreferenceManager.PlayerIoQueue(worker); + UUID uuid = UUID.randomUUID(); + queue.submit(uuid, () -> { + earlierIoStarted.countDown(); + await(releaseEarlierIo); + }); + queue.submit(uuid, () -> { + finalSaveStarted.countDown(); + await(releaseFinalSave); + }); + + assertTrue(earlierIoStarted.await(5, TimeUnit.SECONDS)); + CompletableFuture closing = queue.seal(); + assertFalse(closing.isDone()); + assertNull(queue.submit(UUID.randomUUID(), () -> { })); + + releaseEarlierIo.countDown(); + assertTrue(finalSaveStarted.await(5, TimeUnit.SECONDS)); + assertFalse(closing.isDone(), "close completed while the final save was still blocked"); + releaseFinalSave.countDown(); + closing.get(5, TimeUnit.SECONDS); + queue.closeAndWait(); + } finally { + releaseEarlierIo.countDown(); + releaseFinalSave.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void synchronousReentryFromTheSamePlayerDrainFailsInsteadOfDeadlocking() throws Exception { + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + PreferenceManager.PlayerIoQueue queue = new PreferenceManager.PlayerIoQueue(worker); + UUID uuid = UUID.randomUUID(); + CompletableFuture outer = queue.submit(uuid, + () -> queue.runAndWait(uuid, () -> { })); + + assertNotNull(outer); + ExecutionException exception = assertThrows( + ExecutionException.class, () -> outer.get(5, TimeUnit.SECONDS)); + assertSame(IllegalStateException.class, exception.getCause().getClass()); + IllegalStateException closeFailure = assertThrows( + IllegalStateException.class, queue::closeAndWait); + assertSame(exception.getCause(), closeFailure); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void rejectedFinalSaveMakesCloseFailInsteadOfSilentlyDroppingIt() throws Exception { + RejectedExecutionException rejection = new RejectedExecutionException("controlled rejection"); + PreferenceManager.PlayerIoQueue queue = new PreferenceManager.PlayerIoQueue(command -> { + throw rejection; + }); + + CompletableFuture finalSave = queue.submit(UUID.randomUUID(), () -> { }); + assertNotNull(finalSave); + ExecutionException operationFailure = assertThrows( + ExecutionException.class, () -> finalSave.get(5, TimeUnit.SECONDS)); + assertSame(rejection, operationFailure.getCause()); + + RejectedExecutionException closeFailure = assertThrows( + RejectedExecutionException.class, queue::closeAndWait); + assertSame(rejection, closeFailure); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting for test latch"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted while waiting for test latch", exception); + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/TaskManagerShutdownTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/TaskManagerShutdownTest.java new file mode 100644 index 00000000..a2c30d59 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/TaskManagerShutdownTest.java @@ -0,0 +1,93 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.loohp.interactionvisualizer.api.VisualizerRunnableDisplay; +import com.loohp.interactionvisualizer.objectholders.EntryKey; +import com.loohp.interactionvisualizer.scheduler.ScheduledTask; +import org.bukkit.event.inventory.InventoryType; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TaskManagerShutdownTest { + + @Test + void clearsRegistriesAndPendingPlayerRequests() { + UUID playerId = UUID.randomUUID(); + Map> originalProcesses = + TaskManager.processes; + Map> originalProcessEntries = + new HashMap<>(); + originalProcesses.forEach((type, displays) -> + originalProcessEntries.put(type, new ArrayList<>(displays))); + List originalRunnables = new ArrayList<>(TaskManager.runnables); + TrackingMap> processes = + new TrackingMap<>(); + try { + TaskManager.processes = processes; + TaskManager.runnables.add(runnableDisplay()); + assertTrue(TaskManager.markInventoryOpenProcessQueued(playerId)); + + TaskManager.clearRuntimeState(); + + assertTrue(processes.cleared); + assertTrue(TaskManager.processes.isEmpty()); + assertTrue(TaskManager.runnables.isEmpty()); + assertTrue(TaskManager.markInventoryOpenProcessQueued(playerId), + "shutdown must release the pending request token"); + } finally { + TaskManager.clearRuntimeState(); + TaskManager.processes = originalProcesses; + originalProcesses.clear(); + originalProcesses.putAll(originalProcessEntries); + TaskManager.runnables.addAll(originalRunnables); + } + } + + private static VisualizerRunnableDisplay runnableDisplay() { + return new VisualizerRunnableDisplay() { + @Override + public EntryKey key() { + return new EntryKey("shutdown_runnable_test"); + } + + @Override + public ScheduledTask gc() { + return null; + } + + @Override + public ScheduledTask run() { + return null; + } + }; + } + + private static final class TrackingMap extends ConcurrentHashMap { + + private boolean cleared; + + @Override + public void clear() { + cleared = true; + super.clear(); + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/TileEntityManagerLifecycleOrderTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/TileEntityManagerLifecycleOrderTest.java index a5ad278f..c40dd022 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/TileEntityManagerLifecycleOrderTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/TileEntityManagerLifecycleOrderTest.java @@ -18,12 +18,16 @@ import java.util.EnumMap; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; class TileEntityManagerLifecycleOrderTest { @@ -120,6 +124,246 @@ void legacyModeReplacesTheActiveTypeWithoutTrackingAnAddedEvent() { assertTrue(lastActive.isEmpty()); } + @Test + void chunkIndexOnlyRetainsNonEmptyEntries() { + Map> index = new HashMap<>(); + Set empty = new HashSet<>(); + Set occupied = new HashSet<>(Set.of("block")); + index.put("empty", empty); + index.put("occupied", occupied); + + TileEntityManager.updateNonEmptyIndex(index, "empty", empty); + TileEntityManager.updateNonEmptyIndex(index, "occupied", occupied); + TileEntityManager.updateNonEmptyIndex(index, "detached", new HashSet<>(Set.of("block"))); + + assertFalse(index.containsKey("empty")); + assertSame(occupied, index.get("occupied")); + assertFalse(index.containsKey("detached")); + } + + @Test + void unloadReloadReentryCannotReviveDetachedChunkGeneration() { + Map> index = new HashMap<>(); + Set indexedBlocks = new HashSet<>(Set.of("block")); + Set reloadedBlocks = new HashSet<>(Set.of("block")); + index.put("chunk", indexedBlocks); + Map> active = emptyActiveSets(); + Map lastActive = new HashMap<>(); + List events = new ArrayList<>(); + active.get(TileEntityType.FURNACE).add("block"); + lastActive.put("block", TileEntityType.FURNACE); + + boolean stillCurrent = TileEntityManager.reconcileActiveTypeIfCurrent( + index, "chunk", indexedBlocks, + "block", TileEntityType.BLAST_FURNACE, true, true, + active, lastActive, + (value, change, type) -> { + events.add(change + ":" + type); + if (change == TileEntityManager.LifecycleChange.REMOVED) { + index.remove("chunk"); + for (Set values : active.values()) { + values.removeAll(indexedBlocks); + } + lastActive.keySet().removeAll(indexedBlocks); + index.put("chunk", reloadedBlocks); + active.get(TileEntityType.BEE_NEST).add("block"); + lastActive.put("block", TileEntityType.BEE_NEST); + } + }); + TileEntityManager.updateNonEmptyIndex(index, "chunk", indexedBlocks); + + assertFalse(stillCurrent); + assertSame(reloadedBlocks, index.get("chunk")); + assertFalse(active.get(TileEntityType.FURNACE).contains("block")); + assertFalse(active.get(TileEntityType.BLAST_FURNACE).contains("block")); + assertTrue(active.get(TileEntityType.BEE_NEST).contains("block")); + assertEquals(TileEntityType.BEE_NEST, lastActive.get("block")); + assertEquals(List.of("REMOVED:FURNACE"), events); + } + + @Test + void staleEmptyGenerationCannotRemoveReloadedIndex() { + Map> index = new HashMap<>(); + Set stale = new HashSet<>(); + Set reloaded = new HashSet<>(); + index.put("chunk", reloaded); + + TileEntityManager.updateNonEmptyIndex(index, "chunk", stale); + + assertSame(reloaded, index.get("chunk")); + } + + @Test + void everyScanInstallsANewGenerationEvenWhenContentsMatch() { + Map> index = new HashMap<>(); + Set previous = new LinkedHashSet<>(Set.of("block")); + index.put("chunk", previous); + + Set current = TileEntityManager.installPendingIndexGeneration( + index, "chunk", Set.of("block")); + + assertNotSame(previous, current); + assertSame(current, index.get("chunk")); + assertEquals(previous, current); + } + + @Test + void pendingGenerationKeepsOldBlocksVisibleUntilReconciliationFinishes() { + Map> index = new HashMap<>(); + index.put("chunk", new LinkedHashSet<>(Set.of("old"))); + + Set generation = TileEntityManager.installPendingIndexGeneration( + index, "chunk", Set.of("new")); + + assertEquals(Set.of("old", "new"), generation); + assertTrue(TileEntityManager.finishIndexGeneration( + index, "chunk", generation, Set.of("new"))); + assertEquals(Set.of("new"), index.get("chunk")); + } + + @Test + void unloadMarkerRejectsNestedScanWithoutReplacingTheAuthoritativeGeneration() { + Map> index = new HashMap<>(); + Set unloadingGeneration = new LinkedHashSet<>(List.of("first", "second")); + index.put("chunk", unloadingGeneration); + + Set nestedGeneration = TileEntityManager.installPendingIndexGeneration( + index, Set.of("chunk"), "chunk", Set.of("replacement")); + + assertNull(nestedGeneration); + assertSame(unloadingGeneration, index.get("chunk")); + } + + @Test + void nestedScanDuringDeactivationStillClearsEveryPendingBlock() { + Map> index = new HashMap<>(); + Set outerGeneration = new LinkedHashSet<>(List.of("first", "second")); + index.put("chunk", outerGeneration); + Map> active = emptyActiveSets(); + active.get(TileEntityType.FURNACE).addAll(outerGeneration); + List events = new ArrayList<>(); + + boolean outerCurrent = TileEntityManager.deactivateIfCurrent( + index, "chunk", outerGeneration, active, + (value, change, type) -> { + events.add(value + ":" + change + ":" + type); + if (value.equals("first")) { + Set nestedGeneration = TileEntityManager.installPendingIndexGeneration( + index, "chunk", Set.of("first", "second")); + assertTrue(TileEntityManager.deactivateIfCurrent( + index, "chunk", nestedGeneration, active, + (nestedBlock, nestedChange, nestedType) -> + events.add(nestedBlock + ":" + nestedChange + ":" + nestedType))); + } + }); + + assertFalse(outerCurrent); + assertTrue(active.get(TileEntityType.FURNACE).isEmpty()); + assertEquals(List.of( + "first:DEACTIVATED:FURNACE", + "second:DEACTIVATED:FURNACE"), events); + } + + @Test + void reentrantWatcherUpdateCommitsCountsBeforeAnyLifecycleSideEffects() { + Map> watched = new HashMap<>(); + watched.put("player", new LinkedHashSet<>(List.of("a", "d"))); + Map counts = new HashMap<>(); + counts.put("a", 1); + counts.put("d", 1); + Set dirty = new LinkedHashSet<>(); + + TileEntityManager.commitWatcherUpdate( + watched, counts, dirty, "player", Set.of("b")); + TileEntityManager.commitWatcherUpdate( + watched, counts, dirty, "player", Set.of("c")); + + assertEquals(Set.of("c"), watched.get("player")); + assertEquals(Map.of("c", 1), counts); + assertEquals(Set.of("a", "d", "b", "c"), dirty); + } + + @Test + void nestedSameChunkScanCannotBeOverwrittenByOuterStaleType() { + Map> index = new HashMap<>(); + Set initial = new LinkedHashSet<>(Set.of("block")); + index.put("chunk", initial); + Set outerGeneration = TileEntityManager.installPendingIndexGeneration( + index, "chunk", Set.of("block")); + Map> active = emptyActiveSets(); + Map lastActive = new HashMap<>(); + List events = new ArrayList<>(); + active.get(TileEntityType.FURNACE).add("block"); + lastActive.put("block", TileEntityType.FURNACE); + + boolean outerCurrent = TileEntityManager.reconcileActiveTypeIfCurrent( + index, "chunk", outerGeneration, + "block", TileEntityType.BLAST_FURNACE, true, true, + active, lastActive, + (value, change, type) -> { + events.add(change + ":" + type); + if (change == TileEntityManager.LifecycleChange.REMOVED) { + Set nestedGeneration = TileEntityManager.installPendingIndexGeneration( + index, "chunk", Set.of("block")); + assertTrue(TileEntityManager.reconcileActiveTypeIfCurrent( + index, "chunk", nestedGeneration, + "block", TileEntityType.BEE_NEST, true, true, + active, lastActive, + (nestedValue, nestedChange, nestedType) -> + events.add(nestedChange + ":" + nestedType))); + } + }); + + assertFalse(outerCurrent); + assertFalse(active.get(TileEntityType.FURNACE).contains("block")); + assertFalse(active.get(TileEntityType.BLAST_FURNACE).contains("block")); + assertTrue(active.get(TileEntityType.BEE_NEST).contains("block")); + assertEquals(TileEntityType.BEE_NEST, lastActive.get("block")); + assertEquals(List.of("REMOVED:FURNACE", "ADDED:BEE_NEST"), events); + } + + @Test + void nestedScanStillCleansOldBlocksTheOuterScanHasNotVisited() { + Map> index = new HashMap<>(); + index.put("chunk", new LinkedHashSet<>(List.of("first", "second"))); + Set outerGeneration = TileEntityManager.installPendingIndexGeneration( + index, "chunk", Set.of()); + Map> active = emptyActiveSets(); + Map lastActive = new HashMap<>(); + List events = new ArrayList<>(); + active.get(TileEntityType.FURNACE).addAll(List.of("first", "second")); + lastActive.put("first", TileEntityType.FURNACE); + lastActive.put("second", TileEntityType.FURNACE); + + boolean outerCurrent = TileEntityManager.reconcileActiveTypeIfCurrent( + index, "chunk", outerGeneration, + "first", null, true, true, + active, lastActive, + (value, change, type) -> { + events.add(value + ":" + change + ":" + type); + Set nestedGeneration = TileEntityManager.installPendingIndexGeneration( + index, "chunk", Set.of()); + for (String nestedValue : new LinkedHashSet<>(nestedGeneration)) { + assertTrue(TileEntityManager.reconcileActiveTypeIfCurrent( + index, "chunk", nestedGeneration, + nestedValue, null, true, true, + active, lastActive, + (nestedBlock, nestedChange, nestedType) -> + events.add(nestedBlock + ":" + nestedChange + ":" + nestedType))); + } + assertTrue(TileEntityManager.finishIndexGeneration( + index, "chunk", nestedGeneration, Set.of())); + }); + + assertFalse(outerCurrent); + assertFalse(index.containsKey("chunk")); + assertTrue(active.get(TileEntityType.FURNACE).isEmpty()); + assertTrue(lastActive.isEmpty()); + assertEquals(List.of( + "first:REMOVED:FURNACE", + "second:REMOVED:FURNACE"), events); + } + private static Map> emptyActiveSets() { Map> active = new EnumMap<>(TileEntityType.class); for (TileEntityType type : TileEntityType.values()) { From 73d53ae873b413eaa96de8d478223e7465525d89 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 13:48:48 +0800 Subject: [PATCH 02/12] Index display visibility by viewer chunks --- .../managers/DisplayManager.java | 369 ++++++++++++++---- .../DisplayManagerViewerChunkIndexTest.java | 66 ++++ 2 files changed, 350 insertions(+), 85 deletions(-) create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerViewerChunkIndexTest.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java index 59b545e9..804d980b 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java @@ -66,6 +66,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; import java.util.function.Predicate; import java.util.logging.Level; @@ -98,6 +99,8 @@ public final class DisplayManager implements Listener { private static final Map actualUuidByLogical = new ConcurrentHashMap<>(); private static final Map> logicalsByChunk = new ConcurrentHashMap<>(); private static final Map chunkByLogical = new ConcurrentHashMap<>(); + private static final ViewerChunkIndex viewerChunks = new ViewerChunkIndex<>(); + private static final Map> shownByViewer = new ConcurrentHashMap<>(); private static final Map itemAnimations = new ConcurrentHashMap<>(); private static final Map> virtualItemIds = new ConcurrentHashMap<>(); private static final Map renderedItemStacks = new ConcurrentHashMap<>(); @@ -108,6 +111,8 @@ public final class DisplayManager implements Listener { private static final Map> dynamicViewerPositions = new ConcurrentHashMap<>(); private static final Set dirtyDynamicViewers = ConcurrentHashMap.newKeySet(); private static final Map> visibilityShowQueues = new ConcurrentHashMap<>(); + private static final Map> pendingViewersByLogical = new ConcurrentHashMap<>(); + private static final Set pendingVisibilityViewers = ConcurrentHashMap.newKeySet(); private static boolean itemAnimationTickScheduled; private static boolean dynamicViewerTickScheduled; private static boolean dynamicTeleportFailureLogged; @@ -141,7 +146,12 @@ public static void run() { "This Paper runtime cannot provide exact packet-only legacy text displays", ClientTextDisplayBridge.initializationFailure()); } - runSync(() -> removeOwnedEntities(false)); + runSync(() -> { + removeOwnedEntities(false); + for (Player player : Bukkit.getOnlinePlayers()) { + seedSentChunks(player); + } + }); } /** One-shot liveness and viewer reconciliation, used after configuration reloads. */ @@ -150,9 +160,7 @@ public static void update() { // Active viewer collections are live filtered views. A reload can // therefore invalidate requests that have not reached shownViewers // yet; clear them once and rebuild from the new desired sets below. - for (VisibilityShowQueue queue : visibilityShowQueues.values()) { - queue.clear(); - } + clearAllVisibilityShowQueues(); for (VisualizerEntity logical : new HashSet<>(active.keySet())) { index(logical); if (logical instanceof Item item) { @@ -229,6 +237,8 @@ public static void shutdown() { actualUuidByLogical.clear(); logicalsByChunk.clear(); chunkByLogical.clear(); + viewerChunks.clear(); + shownByViewer.clear(); itemAnimations.clear(); virtualItemIds.clear(); renderedItemStacks.clear(); @@ -238,10 +248,10 @@ public static void shutdown() { virtualTextDisplays.clear(); dynamicViewerPositions.clear(); dirtyDynamicViewers.clear(); - for (VisibilityShowQueue queue : visibilityShowQueues.values()) { - queue.clear(); - } + clearAllVisibilityShowQueues(); visibilityShowQueues.clear(); + pendingViewersByLogical.clear(); + pendingVisibilityViewers.clear(); itemAnimationTickScheduled = false; dynamicViewerTickScheduled = false; dynamicTeleportFailureLogged = false; @@ -388,20 +398,29 @@ public static void reset(Player player) { public static void removeAll(Player player) { runSync(() -> { - clearVisibilityShowQueue(player.getUniqueId()); + UUID viewer = player.getUniqueId(); + clearVisibilityShowQueue(viewer); VirtualItemIdBuffer virtualItemIdsToRemove = new VirtualItemIdBuffer(); - for (Map.Entry> entry : shownViewers.entrySet()) { - if (entry.getValue().remove(player.getUniqueId())) { - if (entry.getKey() instanceof DisplayEntity display + Set shown = shownByViewer.remove(viewer); + if (shown != null) { + for (VisualizerEntity logical : shown) { + Set viewers = shownViewers.get(logical); + if (viewers != null) { + viewers.remove(viewer); + if (viewers.isEmpty()) { + shownViewers.remove(logical, viewers); + } + } + if (logical instanceof DisplayEntity display && virtualTextDisplays.containsKey(display)) { - removeVirtualText(display, player.getUniqueId(), player); - } else if (entry.getKey() instanceof Item item) { - Integer id = forgetVirtualItem(item, player.getUniqueId()); + removeVirtualText(display, viewer, player); + } else if (logical instanceof Item item) { + Integer id = forgetVirtualItem(item, viewer); if (id != null) { virtualItemIdsToRemove.add(id); } } - entry.getKey().getBukkitEntity().ifPresent(actual -> { + logical.getBukkitEntity().ifPresent(actual -> { PerformanceMetrics.bukkitHide(); player.hideEntity(plugin(), actual); }); @@ -415,7 +434,11 @@ public static void removeAll(Player player) { public static void sendPlayerPackets(Player player) { runSync(() -> { playerStatus.putIfAbsent(player, ConcurrentHashMap.newKeySet()); - for (VisualizerEntity logical : active.keySet()) { + UUID viewer = player.getUniqueId(); + if (viewerChunks.chunks(viewer).isEmpty()) { + seedSentChunks(player); + } + forEachViewerLogical(viewer, logical -> { boolean virtualTextReady = logical instanceof DisplayEntity display && usesVirtualText(display) && virtualTextDisplays.containsKey(display); if (logical.getBukkitEntity().isPresent() || packetOnlyItems.contains(logical) @@ -426,7 +449,7 @@ public static void sendPlayerPackets(Player player) { } else if (requiresActualEntity(logical)) { schedule(logical, true); } - } + }); }); } @@ -444,12 +467,18 @@ private static void schedule(VisualizerEntity logical, boolean force) { try { boolean forceSync = forceScheduled.remove(logical); if (active.containsKey(logical)) { + ChunkKey previousChunk = chunkByLogical.get(logical); + RepresentationKey previousRepresentation = representationKey(logical); index(logical); int revision = logical.cacheCode(); if (forceSync || (requiresActualEntity(logical) && logical.getBukkitEntity().isEmpty()) || renderedRevision.getOrDefault(logical, Integer.MIN_VALUE) != revision) { sync(logical, revision); } + if (!Objects.equals(previousChunk, chunkByLogical.get(logical)) + || !previousRepresentation.equals(representationKey(logical))) { + reconcileViewers(logical); + } } } finally { scheduled.remove(logical); @@ -471,7 +500,6 @@ private static void sync(VisualizerEntity logical, int revision) { syncItemFrame(frame); } renderedRevision.put(logical, revision); - reconcileViewers(logical); } private static void syncDisplay(DisplayEntity logical) { @@ -1463,6 +1491,44 @@ private static void untrackActual(VisualizerEntity logical) { } } + private static RepresentationKey representationKey(VisualizerEntity logical) { + boolean packetOnly = logical instanceof Item item && packetOnlyItems.contains(item); + boolean virtualTextReady = logical instanceof DisplayEntity display && usesVirtualText(display) + && virtualTextDisplays.containsKey(display) && shouldRender(display); + return new RepresentationKey(actualUuidByLogical.get(logical), packetOnly, virtualTextReady); + } + + private static void seedSentChunks(Player player) { + UUID viewer = player.getUniqueId(); + viewerChunks.removeViewer(viewer); + if (!player.isOnline()) { + return; + } + Location location = player.getLocation(); + UUID world = player.getWorld().getUID(); + int centerX = location.getBlockX() >> 4; + int centerZ = location.getBlockZ() >> 4; + int radius = Math.max(2, Bukkit.getServer().getViewDistance() + 1); + for (int x = centerX - radius; x <= centerX + radius; x++) { + for (int z = centerZ - radius; z <= centerZ + radius; z++) { + if (player.isChunkSent(Chunk.getChunkKey(x, z))) { + viewerChunks.add(viewer, new ChunkKey(world, x, z)); + } + } + } + } + + private static void forEachViewerLogical(UUID viewer, Consumer action) { + for (ChunkKey key : viewerChunks.chunks(viewer)) { + Set logicals = logicalsByChunk.get(key); + if (logicals != null) { + for (VisualizerEntity logical : logicals) { + action.accept(logical); + } + } + } + } + private static void index(VisualizerEntity logical) { if (logical instanceof Item item) { ItemAnimationState animation = itemAnimations.get(item); @@ -1536,6 +1602,13 @@ private static void clearViewerTracking(VisualizerEntity logical, org.bukkit.ent } for (UUID uuid : shown) { Player player = Bukkit.getPlayer(uuid); + Set viewerShown = shownByViewer.get(uuid); + if (viewerShown != null) { + viewerShown.remove(logical); + if (viewerShown.isEmpty()) { + shownByViewer.remove(uuid, viewerShown); + } + } if (logical instanceof DisplayEntity display && virtualTextDisplays.containsKey(display)) { removeVirtualText(display, uuid, player); } @@ -1595,14 +1668,18 @@ private static boolean isViewerRenderable(VisualizerEntity logical, Player playe return false; } PerformanceMetrics.virtualViewerChecks(1); - return packetOnlyQualified && player.isChunkSent(Chunk.getChunkKey(packetOnlyLocation)); + ChunkKey key = chunkByLogical.get(logical); + return packetOnlyQualified && key != null + && viewerChunks.contains(player.getUniqueId(), key); } if (logical instanceof Item && packetOnlyItems.contains((Item) logical)) { if (packetOnlyLocation == null || !player.getWorld().equals(packetOnlyLocation.getWorld())) { return false; } PerformanceMetrics.virtualViewerChecks(1); - return packetOnlyQualified && player.isChunkSent(Chunk.getChunkKey(packetOnlyLocation)); + ChunkKey key = chunkByLogical.get(logical); + return packetOnlyQualified && key != null + && viewerChunks.contains(player.getUniqueId(), key); } org.bukkit.entity.Entity actual = logical.getBukkitEntity().orElse(null); if (actual == null || !player.getWorld().equals(actual.getWorld())) { @@ -1620,7 +1697,7 @@ private static boolean isViewerRenderable(VisualizerEntity logical, Player playe if (key == null || !key.world().equals(player.getWorld().getUID())) { return false; } - return player.isChunkSent(Chunk.getChunkKey(key.x(), key.z())); + return viewerChunks.contains(player.getUniqueId(), key); } private static void requestViewerShow(VisualizerEntity logical, Player player) { @@ -1639,6 +1716,10 @@ private static void requestViewerShow(VisualizerEntity logical, Player player) { player.getUniqueId(), ignored -> new VisibilityShowQueue<>( InteractionVisualizer.visibilityRateLimitBucketSize, currentServerTick())); if (queue.request(logical)) { + UUID viewer = player.getUniqueId(); + pendingViewersByLogical.computeIfAbsent(logical, + ignored -> ConcurrentHashMap.newKeySet()).add(viewer); + pendingVisibilityViewers.add(viewer); PerformanceMetrics.visibilityShowQueued(); } scheduleVisibilityTick(); @@ -1650,6 +1731,7 @@ private static void showViewerNow(VisualizerEntity logical, Player player) { // the drain action. Avoid a second queue lookup on every successful show. Set shown = shownViewers.computeIfAbsent(logical, ignored -> ConcurrentHashMap.newKeySet()); UUID viewer = player.getUniqueId(); + forgetPending(logical, viewer); if (!shown.add(viewer)) { return; } @@ -1669,6 +1751,7 @@ private static void showViewerNow(VisualizerEntity logical, Player player) { } if (shownSuccessfully) { + shownByViewer.computeIfAbsent(viewer, ignored -> ConcurrentHashMap.newKeySet()).add(logical); playerStatus.computeIfAbsent(player, ignored -> ConcurrentHashMap.newKeySet()).add(logical); } else { forgetShownViewer(logical, viewer, player); @@ -1682,6 +1765,13 @@ private static void hideViewer(VisualizerEntity logical, UUID viewer, Player pla if (shown != null && shown.isEmpty()) { shownViewers.remove(logical, shown); } + Set viewerShown = shownByViewer.get(viewer); + if (viewerShown != null) { + viewerShown.remove(logical); + if (viewerShown.isEmpty()) { + shownByViewer.remove(viewer, viewerShown); + } + } if (wasShown && logical instanceof DisplayEntity display && virtualTextDisplays.containsKey(display)) { removeVirtualText(display, viewer, player); } else { @@ -1713,6 +1803,13 @@ private static Integer forgetShownViewer(VisualizerEntity logical, UUID viewer, shownViewers.remove(logical, shown); } } + Set viewerShown = shownByViewer.get(viewer); + if (viewerShown != null) { + viewerShown.remove(logical); + if (viewerShown.isEmpty()) { + shownByViewer.remove(viewer, viewerShown); + } + } Integer forgottenVirtualId = logical instanceof Item item ? forgetVirtualItem(item, viewer) : null; if (player != null) { Set status = playerStatus.get(player); @@ -1725,21 +1822,51 @@ private static Integer forgetShownViewer(VisualizerEntity logical, UUID viewer, private static void cancelVisibilityShow(VisualizerEntity logical, UUID viewer) { VisibilityShowQueue queue = visibilityShowQueues.get(viewer); - if (queue != null) { - queue.cancel(logical); + if (queue != null && queue.cancel(logical)) { + forgetPending(logical, viewer); + if (!queue.hasPending()) { + pendingVisibilityViewers.remove(viewer); + } } } private static void cancelVisibilityShows(VisualizerEntity logical) { - for (VisibilityShowQueue queue : visibilityShowQueues.values()) { - queue.cancel(logical); + Set viewers = pendingViewersByLogical.remove(logical); + if (viewers == null) { + return; + } + for (UUID viewer : viewers) { + VisibilityShowQueue queue = visibilityShowQueues.get(viewer); + if (queue != null) { + queue.cancel(logical); + if (!queue.hasPending()) { + pendingVisibilityViewers.remove(viewer); + } + } } } private static void clearVisibilityShowQueue(UUID viewer) { VisibilityShowQueue queue = visibilityShowQueues.remove(viewer); if (queue != null) { - queue.clear(); + queue.clear(logical -> forgetPending(logical, viewer)); + } + pendingVisibilityViewers.remove(viewer); + } + + private static void clearAllVisibilityShowQueues() { + for (UUID viewer : visibilityShowQueues.keySet()) { + clearVisibilityShowQueue(viewer); + } + } + + private static void forgetPending(VisualizerEntity logical, UUID viewer) { + Set viewers = pendingViewersByLogical.get(logical); + if (viewers != null) { + viewers.remove(viewer); + if (viewers.isEmpty()) { + pendingViewersByLogical.remove(logical, viewers); + } } } @@ -1764,9 +1891,12 @@ private static void drainVisibilityShowQueues() { int capacity = InteractionVisualizer.visibilityRateLimitBucketSize; int refill = InteractionVisualizer.visibilityRateLimitRestorePerTick; long currentTick = limited ? currentServerTick() : 0L; - for (Map.Entry> entry : visibilityShowQueues.entrySet()) { - UUID viewer = entry.getKey(); - VisibilityShowQueue queue = entry.getValue(); + for (UUID viewer : pendingVisibilityViewers) { + VisibilityShowQueue queue = visibilityShowQueues.get(viewer); + if (queue == null) { + pendingVisibilityViewers.remove(viewer); + continue; + } // Queue states deliberately survive while empty to retain token-bucket // credit. Skip them before player lookup/lambda/list allocation. if (!queue.hasPending()) { @@ -1774,12 +1904,12 @@ private static void drainVisibilityShowQueues() { } Player player = Bukkit.getPlayer(viewer); if (player == null || !player.isOnline()) { - queue.clear(); - visibilityShowQueues.remove(viewer, queue); + clearVisibilityShowQueue(viewer); continue; } VisibilityShowQueue.DrainAction showIfRenderable = logical -> { + forgetPending(logical, viewer); if (!isQueuedViewerStillRenderable(logical, player)) { return false; } @@ -1792,16 +1922,14 @@ private static void drainVisibilityShowQueues() { } else { queue.drainAllTo(showIfRenderable); } + if (!queue.hasPending()) { + pendingVisibilityViewers.remove(viewer); + } } } private static boolean hasPendingVisibilityShows() { - for (VisibilityShowQueue queue : visibilityShowQueues.values()) { - if (queue.hasPending()) { - return true; - } - } - return false; + return !pendingVisibilityViewers.isEmpty(); } private static long currentServerTick() { @@ -1975,49 +2103,21 @@ private static void removeOwnedEntities(boolean clearVisibilityOverrides) { } private static void reconcileViewers(VisualizerEntity logical) { - Collection desiredPlayers = active.get(logical); - Location packetOnlyLocation = null; - boolean packetOnlyQualified = false; - if (logical instanceof DisplayEntity display && usesVirtualText(display)) { - packetOnlyLocation = display.getLocation(); - packetOnlyQualified = virtualTextDisplays.containsKey(display) && shouldRender(display); - } else if (logical instanceof Item item && packetOnlyItems.contains(item)) { - // Qualification and location are entity-invariant for this main-thread - // reconciliation. Evaluate them once rather than once per viewer. - packetOnlyLocation = item.getLocation(); - packetOnlyQualified = true; - } + ChunkKey key = chunkByLogical.get(logical); + Set candidates = key == null ? Set.of() : viewerChunks.viewers(key); Set shown = shownViewers.get(logical); - if (shown == null) { - if (desiredPlayers != null) { - for (Player player : desiredPlayers) { - if (isViewerRenderable(logical, player, packetOnlyLocation, packetOnlyQualified)) { - requestViewerShow(logical, player); - } - } - } - return; - } - Set desired = new HashSet<>(); - if (desiredPlayers != null) { - for (Player player : desiredPlayers) { - if (isViewerRenderable(logical, player, packetOnlyLocation, packetOnlyQualified)) { - desired.add(player.getUniqueId()); + if (shown != null) { + for (UUID viewer : shown) { + Player player = Bukkit.getPlayer(viewer); + if (!candidates.contains(viewer) || player == null || !isViewerDesired(logical, player)) { + hideViewer(logical, viewer, player); } } } - for (UUID uuid : new HashSet<>(shown)) { - if (!desired.contains(uuid)) { - hideViewer(logical, uuid, Bukkit.getPlayer(uuid)); - } - } - for (UUID uuid : desired) { - shown = shownViewers.get(logical); - if (shown == null || !shown.contains(uuid)) { - Player player = Bukkit.getPlayer(uuid); - if (player != null) { - requestViewerShow(logical, player); - } + for (UUID viewer : candidates) { + Player player = Bukkit.getPlayer(viewer); + if (player != null) { + reconcileViewer(logical, player); } } } @@ -2144,21 +2244,21 @@ private static void markDynamicPassengersDirty(Entity vehicle) { public void onJoin(PlayerJoinEvent event) { forgetDynamicViewer(event.getPlayer().getUniqueId()); forgetPacketOnlyViewer(event.getPlayer()); - Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); + resetViewerChunksAndSend(event.getPlayer()); } @EventHandler public void onWorldChange(PlayerChangedWorldEvent event) { forgetDynamicViewer(event.getPlayer().getUniqueId()); forgetPacketOnlyViewer(event.getPlayer()); - Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); + resetViewerChunksAndSend(event.getPlayer()); } @EventHandler public void onRespawn(PlayerRespawnEvent event) { forgetDynamicViewer(event.getPlayer().getUniqueId()); forgetPacketOnlyViewer(event.getPlayer()); - Bukkit.getScheduler().runTask(plugin(), () -> sendPlayerPackets(event.getPlayer())); + resetViewerChunksAndSend(event.getPlayer()); } @EventHandler @@ -2166,21 +2266,40 @@ public void onLeave(PlayerQuitEvent event) { UUID uuid = event.getPlayer().getUniqueId(); forgetDynamicViewer(uuid); clearVisibilityShowQueue(uuid); - for (Map.Entry> entry : shownViewers.entrySet()) { - entry.getValue().remove(uuid); - if (entry.getKey() instanceof Item item) { + viewerChunks.removeViewer(uuid); + Set shown = shownByViewer.remove(uuid); + if (shown != null) { + for (VisualizerEntity logical : shown) { + Set viewers = shownViewers.get(logical); + if (viewers != null) { + viewers.remove(uuid); + if (viewers.isEmpty()) { + shownViewers.remove(logical, viewers); + } + } + if (logical instanceof Item item) { // The connection is closing; discard bookkeeping without // sending a remove packet that cannot be observed. - forgetVirtualItem(item, uuid); + forgetVirtualItem(item, uuid); + } } } playerStatus.remove(event.getPlayer()); } + private static void resetViewerChunksAndSend(Player player) { + viewerChunks.removeViewer(player.getUniqueId()); + Bukkit.getScheduler().runTask(plugin(), () -> { + seedSentChunks(player); + sendPlayerPackets(player); + }); + } + @EventHandler(priority = EventPriority.MONITOR) public void onPlayerChunkLoad(PlayerChunkLoadEvent event) { Chunk chunk = event.getChunk(); ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + viewerChunks.add(event.getPlayer().getUniqueId(), key); Set logicals = logicalsByChunk.get(key); if (logicals == null) { return; @@ -2200,6 +2319,7 @@ public void onPlayerChunkLoad(PlayerChunkLoadEvent event) { public void onPlayerChunkUnload(PlayerChunkUnloadEvent event) { Chunk chunk = event.getChunk(); ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + viewerChunks.remove(event.getPlayer().getUniqueId(), key); Set logicals = logicalsByChunk.get(key); if (logicals == null) { return; @@ -2291,8 +2411,8 @@ boolean request(T value) { return pending.add(value); } - void cancel(T value) { - pending.remove(value); + boolean cancel(T value) { + return pending.remove(value); } List drain(int capacity, int refill, Predicate desired) { @@ -2369,6 +2489,12 @@ void clear() { pending.clear(); } + void clear(Consumer removed) { + while (!pending.isEmpty()) { + removed.accept(pending.removeFirst()); + } + } + @FunctionalInterface interface DrainAction { @@ -2427,6 +2553,76 @@ private static boolean sameLocation(Location first, Location second) { } } + static final class ViewerChunkIndex { + + private final Map> chunksByViewer = new ConcurrentHashMap<>(); + private final Map> viewersByChunk = new ConcurrentHashMap<>(); + + boolean add(V viewer, C chunk) { + boolean added = chunksByViewer.computeIfAbsent(viewer, + ignored -> ConcurrentHashMap.newKeySet()).add(chunk); + if (added) { + viewersByChunk.computeIfAbsent(chunk, + ignored -> ConcurrentHashMap.newKeySet()).add(viewer); + } + return added; + } + + boolean remove(V viewer, C chunk) { + Set chunks = chunksByViewer.get(viewer); + if (chunks == null || !chunks.remove(chunk)) { + return false; + } + if (chunks.isEmpty()) { + chunksByViewer.remove(viewer, chunks); + } + Set viewers = viewersByChunk.get(chunk); + if (viewers != null) { + viewers.remove(viewer); + if (viewers.isEmpty()) { + viewersByChunk.remove(chunk, viewers); + } + } + return true; + } + + void removeViewer(V viewer) { + Set chunks = chunksByViewer.remove(viewer); + if (chunks == null) { + return; + } + for (C chunk : chunks) { + Set viewers = viewersByChunk.get(chunk); + if (viewers != null) { + viewers.remove(viewer); + if (viewers.isEmpty()) { + viewersByChunk.remove(chunk, viewers); + } + } + } + } + + boolean contains(V viewer, C chunk) { + Set chunks = chunksByViewer.get(viewer); + return chunks != null && chunks.contains(chunk); + } + + Set chunks(V viewer) { + Set chunks = chunksByViewer.get(viewer); + return chunks == null ? Set.of() : chunks; + } + + Set viewers(C chunk) { + Set viewers = viewersByChunk.get(chunk); + return viewers == null ? Set.of() : viewers; + } + + void clear() { + chunksByViewer.clear(); + viewersByChunk.clear(); + } + } + private record DynamicViewerPosition(double x, double y, double z) { private static DynamicViewerPosition at(Location location) { @@ -2487,6 +2683,9 @@ private void copyInto(int sourceOffset, int[] destination) { } } + private record RepresentationKey(UUID actualUuid, boolean packetOnly, boolean virtualTextReady) { + } + private record ChunkKey(UUID world, int x, int z) { } } diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerViewerChunkIndexTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerViewerChunkIndexTest.java new file mode 100644 index 00000000..010286db --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerViewerChunkIndexTest.java @@ -0,0 +1,66 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DisplayManagerViewerChunkIndexTest { + + @Test + void maintainsBothDirectionsWithoutDuplicates() { + DisplayManager.ViewerChunkIndex index = + new DisplayManager.ViewerChunkIndex<>(); + + assertTrue(index.add("alice", "world:0:0")); + assertFalse(index.add("alice", "world:0:0")); + assertTrue(index.add("bob", "world:0:0")); + assertTrue(index.add("alice", "world:1:0")); + + assertEquals(Set.of("world:0:0", "world:1:0"), index.chunks("alice")); + assertEquals(Set.of("alice", "bob"), index.viewers("world:0:0")); + assertTrue(index.contains("alice", "world:1:0")); + } + + @Test + void viewerRemovalTouchesOnlyThatViewersChunks() { + DisplayManager.ViewerChunkIndex index = + new DisplayManager.ViewerChunkIndex<>(); + index.add("alice", "world:0:0"); + index.add("alice", "world:1:0"); + index.add("bob", "world:0:0"); + + index.removeViewer("alice"); + + assertEquals(Set.of(), index.chunks("alice")); + assertEquals(Set.of("bob"), index.viewers("world:0:0")); + assertEquals(Set.of(), index.viewers("world:1:0")); + assertTrue(index.contains("bob", "world:0:0")); + } + + @Test + void chunkRemovalCleansEmptyReverseBuckets() { + DisplayManager.ViewerChunkIndex index = + new DisplayManager.ViewerChunkIndex<>(); + index.add("alice", "world:0:0"); + + assertTrue(index.remove("alice", "world:0:0")); + assertFalse(index.remove("alice", "world:0:0")); + assertEquals(Set.of(), index.chunks("alice")); + assertEquals(Set.of(), index.viewers("world:0:0")); + } +} From ebe3d7db7d5ffb245a99363451db6be14d014c20 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 13:57:55 +0800 Subject: [PATCH 03/12] Index dropped label visibility spatially --- .../entities/DroppedItemDisplay.java | 216 +++++++++++++----- .../entities/DroppedItemVisibilityIndex.java | 176 ++++++++++++++ .../DroppedItemVisibilityIndexTest.java | 108 +++++++++ 3 files changed, 445 insertions(+), 55 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityIndex.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityIndexTest.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java index 0c811c79..a515352d 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -79,9 +79,12 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private final Map trackedItems = new HashMap<>(); private final Map labels = new HashMap<>(); + private final Map contentCache = new HashMap<>(); private final Set eligibleViewers = new HashSet<>(); private final Map desiredViewers = new HashMap<>(); private final Map visibilityStates = new HashMap<>(); + private final Map> viewersByLabel = new HashMap<>(); + private final Set pendingVisibilityViewers = new HashSet<>(); private String regularFormatting; private String singularFormatting; @@ -95,7 +98,6 @@ public final class DroppedItemDisplay extends VisualizerRunnableDisplay implemen private int ticksUntilUpdate; private int despawnTicks = 6000; private DroppedItemVisibilityPolicy visibilityPolicy = DroppedItemVisibilityPolicy.legacyDefaults(); - private boolean visibilityQueuesPending; private boolean stripColorBlacklist; private DroppedItemBlacklist blacklist = DroppedItemBlacklist.compile(List.of(), DroppedItemDisplay::warn); @@ -148,6 +150,7 @@ public void onReload(InteractionVisualizerReloadEvent event) { blacklist = DroppedItemBlacklist.compile( InteractionVisualizer.plugin.getConfiguration().getList("Entities.Item.Options.Blacklist.List"), DroppedItemDisplay::warn); + contentCache.clear(); } private static String configString(String path) { @@ -223,6 +226,7 @@ public void onEntityRemove(EntityRemoveEvent event) { if (event.getEntity() instanceof Item item) { UUID itemId = item.getUniqueId(); trackedItems.remove(itemId); + contentCache.remove(itemId); TextDisplay label = labels.remove(itemId); if (label != null) { // EntityRemoveEvent is monitoring-only. Defer entity mutation @@ -252,6 +256,10 @@ private void tickAll() { private void tickAllInternal() { Collection viewers = reconcileEligibleViewers(); + if (viewers.isEmpty()) { + removeAllLabels(); + return; + } List validItems = new ArrayList<>(trackedItems.size()); UUID singleItemWorldId = null; int singleWorldItemCount = 0; @@ -262,6 +270,7 @@ private void tickAllInternal() { Item item = entry.getValue(); if (!item.isValid() || item.isDead()) { iterator.remove(); + contentCache.remove(entry.getKey()); removeLabel(entry.getKey()); continue; } @@ -282,13 +291,6 @@ private void tickAllInternal() { } } - if (viewers.isEmpty() && visibilityPolicy.cullingEnabled()) { - for (UUID itemId : new HashSet<>(labels.keySet())) { - removeLabel(itemId); - } - return; - } - DroppedItemSpatialIndex.ViewerIndex viewerIndex = null; if (visibilityPolicy.cullingEnabled()) { viewerIndex = new DroppedItemSpatialIndex.ViewerIndex(viewers.size()); @@ -320,7 +322,19 @@ private void tickAllInternal() { update(tracked, itemIndex, viewerIndex, remainingWorldItems); } if (visibilityPolicy.controlsPerViewerVisibility()) { - reconcileLabelVisibility(viewers, validItems); + DroppedItemVisibilityIndex visibilityIndex = null; + if (visibilityPolicy.cullingEnabled()) { + visibilityIndex = new DroppedItemVisibilityIndex<>(); + for (TrackedItem tracked : validItems) { + TextDisplay label = labels.get(tracked.itemId()); + if (label != null && label.isValid()) { + Location location = tracked.location(); + visibilityIndex.add(tracked.worldId(), location.getX(), location.getY(), + location.getZ(), tracked.itemId()); + } + } + } + reconcileLabelVisibility(viewers, validItems, visibilityIndex); } } @@ -345,20 +359,21 @@ private void update(TrackedItem tracked, DroppedItemSpatialIndex itemIndex, } ItemStack stack = item.getItemStack(); - String matchingName = matchingName(stack); - NamespacedKey customItemId = blacklist.requiresCustomItemId() - ? CustomContentManager.customItemId(stack).orElse(null) - : null; int ticksLeft = despawnTicks - item.getTicksLived(); - if (stack.isEmpty() || blacklist.matches(matchingName, stack.getType(), customItemId) - || item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 + if (stack.isEmpty() || item.getPickupDelay() >= Short.MAX_VALUE || ticksLeft <= 0 || (itemIndex != null && itemIndex.exceedsItemLimit(tracked.worldId(), itemLocation.getX(), itemLocation.getY(), itemLocation.getZ(), cramp))) { + contentCache.remove(tracked.itemId()); + removeLabel(item.getUniqueId()); + return; + } + CachedItemContent content = cachedContent(tracked.itemId(), stack); + if (content.blacklisted) { removeLabel(item.getUniqueId()); return; } - Component text = format(stack, ticksLeft); + Component text = format(content, ticksLeft); boolean created = false; if (label == null || !label.isValid() || !label.getWorld().equals(item.getWorld())) { removeLabel(item.getUniqueId()); @@ -488,7 +503,8 @@ private void switchVisibilityMode(boolean controlled) { state.pending.clear(); } visibilityStates.clear(); - visibilityQueuesPending = false; + viewersByLabel.clear(); + pendingVisibilityViewers.clear(); } private void showToEligibleViewers(TextDisplay label) { @@ -518,7 +534,8 @@ private static void setLabelVisible(Player player, TextDisplay label, boolean vi } } - private void reconcileLabelVisibility(Collection viewers, List validItems) { + private void reconcileLabelVisibility(Collection viewers, List validItems, + DroppedItemVisibilityIndex visibilityIndex) { for (Player player : viewers) { UUID playerId = player.getUniqueId(); VisibilityState state = visibilityStates.computeIfAbsent(playerId, @@ -531,23 +548,16 @@ private void reconcileLabelVisibility(Collection viewers, List viewers, List entry : visibilityStates.entrySet()) { - Player player = Bukkit.getPlayer(entry.getKey()); - VisibilityState state = entry.getValue(); - if (player == null || !player.isOnline() || !eligibleViewers.contains(entry.getKey())) { - state.pending.clear(); + Iterator pendingViewers = pendingVisibilityViewers.iterator(); + while (pendingViewers.hasNext()) { + UUID playerId = pendingViewers.next(); + Player player = Bukkit.getPlayer(playerId); + VisibilityState state = visibilityStates.get(playerId); + if (state == null || player == null || !player.isOnline() || !eligibleViewers.contains(playerId)) { + if (state != null) { + state.pending.clear(); + } + pendingViewers.remove(); + continue; + } + if (!state.pending.hasPending()) { + pendingViewers.remove(); continue; } List ready = state.ready; @@ -607,9 +627,10 @@ private void drainVisibilityQueues() { } } ready.clear(); - stillPending |= state.pending.hasPending(); + if (!state.pending.hasPending()) { + pendingViewers.remove(); + } } - visibilityQueuesPending = stillPending; } private boolean isPendingVisibilityWanted(VisibilityState state, UUID itemId) { @@ -631,28 +652,59 @@ private void removeVisibilityState(UUID playerId, Player player) { } } } + for (UUID itemId : state.desired) { + unlinkLabelViewer(itemId, playerId); + } + for (UUID itemId : state.shown) { + unlinkLabelViewer(itemId, playerId); + } state.pending.clear(); + pendingVisibilityViewers.remove(playerId); } - private Component format(ItemStack stack, int ticksLeft) { - int amount = stack.getAmount(); - String durability = durability(stack); + private CachedItemContent cachedContent(UUID itemId, ItemStack stack) { + CachedItemContent cached = contentCache.get(itemId); + if (cached != null && cached.stack.equals(stack)) { + return cached; + } + String matchingName = matchingName(stack); + NamespacedKey customItemId = blacklist.requiresCustomItemId() + ? CustomContentManager.customItemId(stack).orElse(null) + : null; + CachedItemContent replacement = new CachedItemContent( + stack.clone(), + blacklist.matches(matchingName, stack.getType(), customItemId), + durability(stack), + ItemNameUtils.getDisplayName(stack)); + contentCache.put(itemId, replacement); + return replacement; + } + + private Component format(CachedItemContent content, int ticksLeft) { int secondsLeft = Math.max(0, ticksLeft / 20); + if (content.lastSeconds == secondsLeft && content.lastFormatted != null) { + return content.lastFormatted; + } + ItemStack stack = content.stack; + int amount = stack.getAmount(); String timerColor = secondsLeft <= 30 ? lowColor : secondsLeft <= 120 ? mediumColor : highColor; String timer = timerColor + String.format(java.util.Locale.ROOT, "%02d:%02d", secondsLeft / 60, secondsLeft % 60); String template; - if (ticksLeft >= 600 && durability != null) { - template = toolsFormatting.replace("{Durability}", durability); + if (ticksLeft >= 600 && content.durability != null) { + template = toolsFormatting.replace("{Durability}", content.durability); } else { template = amount == 1 ? singularFormatting : regularFormatting; } String rendered = template.replace("{Amount}", Integer.toString(amount)).replace("{Timer}", timer); Component component = LegacyTextComponentCache.parse(rendered); - return component.replaceText(TextReplacementConfig.builder() + Component formatted = component.replaceText(TextReplacementConfig.builder() .matchLiteral("{Item}") - .replacement(ItemNameUtils.getDisplayName(stack)) + .replacement(content.itemName) .build()); + content.lastSeconds = secondsLeft; + content.lastFormatted = formatted; + return formatted; } private String durability(ItemStack stack) { @@ -677,6 +729,7 @@ private String matchingName(ItemStack stack) { private void remove(UUID itemId) { trackedItems.remove(itemId); + contentCache.remove(itemId); removeLabel(itemId); } @@ -686,17 +739,52 @@ private void removeLabel(UUID itemId) { removeLabel(label); } + private void removeAllLabels() { + Iterator> iterator = labels.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + iterator.remove(); + forgetLabelVisibility(entry.getKey(), entry.getValue()); + removeLabel(entry.getValue()); + } + } + private void forgetLabelVisibility(UUID itemId, TextDisplay label) { - for (Map.Entry entry : visibilityStates.entrySet()) { - VisibilityState state = entry.getValue(); + Set viewers = viewersByLabel.remove(itemId); + if (viewers == null) { + return; + } + for (UUID viewer : viewers) { + VisibilityState state = visibilityStates.get(viewer); + if (state == null) { + continue; + } state.desired.remove(itemId); + state.nextDesired.remove(itemId); state.pending.cancel(itemId); if (state.shown.remove(itemId) && label != null && label.isValid()) { - Player player = Bukkit.getPlayer(entry.getKey()); + Player player = Bukkit.getPlayer(viewer); if (player != null) { setLabelVisible(player, label, false); } } + if (!state.pending.hasPending()) { + pendingVisibilityViewers.remove(viewer); + } + } + } + + private void linkLabelViewer(UUID itemId, UUID viewer) { + viewersByLabel.computeIfAbsent(itemId, ignored -> new HashSet<>()).add(viewer); + } + + private void unlinkLabelViewer(UUID itemId, UUID viewer) { + Set viewers = viewersByLabel.get(itemId); + if (viewers != null) { + viewers.remove(viewer); + if (viewers.isEmpty()) { + viewersByLabel.remove(itemId); + } } } @@ -726,6 +814,24 @@ private static NamespacedKey ownerKey() { private record TrackedItem(UUID itemId, Item item, UUID worldId, Location location) { } + private static final class CachedItemContent { + + private final ItemStack stack; + private final boolean blacklisted; + private final String durability; + private final Component itemName; + private int lastSeconds = Integer.MIN_VALUE; + private Component lastFormatted; + + private CachedItemContent(ItemStack stack, boolean blacklisted, String durability, + Component itemName) { + this.stack = stack; + this.blacklisted = blacklisted; + this.durability = durability; + this.itemName = itemName; + } + } + private static final class VisibilityState { private Set desired = new HashSet<>(); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityIndex.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityIndex.java new file mode 100644 index 00000000..03b2530e --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityIndex.java @@ -0,0 +1,176 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * A rebuildable spatial index for exact per-viewer dropped-label queries. + * + *

The table uses primitive packed X/Z cell keys. Y remains on each point + * and participates in the final squared-distance check, preserving the old + * three-dimensional visibility result without allocating lookup keys.

+ */ +final class DroppedItemVisibilityIndex { + + private static final double CELL_SIZE = 16.0D; + + private final Map> worlds = new HashMap<>(); + + void add(UUID worldId, double x, double y, double z, T value) { + worlds.computeIfAbsent(worldId, ignored -> new WorldGrid<>()) + .add(x, y, z, value); + } + + /** + * Adds every exact match to {@code output} and returns the number of points + * inspected after the spatial lookup. The count is exposed for IV_PERF. + */ + int queryInto(UUID worldId, double x, double y, double z, double range, + Collection output) { + WorldGrid world = worlds.get(worldId); + if (world == null || !Double.isFinite(range) || range < 0.0D) { + return 0; + } + return world.queryInto(x, y, z, range, output); + } + + private static int coordinate(double value) { + return (int) Math.floor(value / CELL_SIZE); + } + + private static long cellKey(int x, int z) { + return (long) x << 32 | z & 0xFFFF_FFFFL; + } + + private static final class WorldGrid { + + private static final int INITIAL_CAPACITY = 16; + private static final float LOAD_FACTOR = 0.65F; + + private long[] keys = new long[INITIAL_CAPACITY]; + private Object[] buckets = new Object[INITIAL_CAPACITY]; + private boolean[] occupied = new boolean[INITIAL_CAPACITY]; + private int mask = INITIAL_CAPACITY - 1; + private int resizeAt = (int) (INITIAL_CAPACITY * LOAD_FACTOR); + private int size; + + private void add(double x, double y, double z, T value) { + long key = cellKey(coordinate(x), coordinate(z)); + int slot = findSlot(key); + if (!occupied[slot]) { + if (size + 1 > resizeAt) { + resize(); + slot = findSlot(key); + } + occupied[slot] = true; + keys[slot] = key; + buckets[slot] = new ArrayList>(); + size++; + } + bucket(slot).add(new Point<>(x, y, z, value)); + } + + private int queryInto(double x, double y, double z, double range, + Collection output) { + int minimumX = coordinate(x - range); + int maximumX = coordinate(x + range); + int minimumZ = coordinate(z - range); + int maximumZ = coordinate(z + range); + double rangeSquared = range * range; + int inspected = 0; + for (int cellX = minimumX; cellX <= maximumX; cellX++) { + for (int cellZ = minimumZ; cellZ <= maximumZ; cellZ++) { + int slot = findSlot(cellKey(cellX, cellZ)); + if (!occupied[slot]) { + continue; + } + for (Point point : bucket(slot)) { + inspected++; + double deltaX = point.x - x; + double deltaY = point.y - y; + double deltaZ = point.z - z; + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + output.add(point.value); + } + } + } + } + return inspected; + } + + private int findSlot(long key) { + int slot = mix(key) & mask; + while (occupied[slot] && keys[slot] != key) { + slot = slot + 1 & mask; + } + return slot; + } + + @SuppressWarnings("unchecked") + private List> bucket(int slot) { + return (List>) buckets[slot]; + } + + private void resize() { + long[] previousKeys = keys; + Object[] previousBuckets = buckets; + boolean[] previousOccupied = occupied; + int capacity = previousKeys.length << 1; + keys = new long[capacity]; + buckets = new Object[capacity]; + occupied = new boolean[capacity]; + mask = capacity - 1; + resizeAt = (int) (capacity * LOAD_FACTOR); + size = 0; + for (int index = 0; index < previousKeys.length; index++) { + if (!previousOccupied[index]) { + continue; + } + int slot = findSlot(previousKeys[index]); + occupied[slot] = true; + keys[slot] = previousKeys[index]; + buckets[slot] = previousBuckets[index]; + size++; + } + } + + private static int mix(long value) { + value ^= value >>> 33; + value *= 0xff51afd7ed558ccdl; + value ^= value >>> 33; + value *= 0xc4ceb9fe1a85ec53l; + value ^= value >>> 33; + return (int) value; + } + } + + private static final class Point { + + private final double x; + private final double y; + private final double z; + private final T value; + + private Point(double x, double y, double z, T value) { + this.x = x; + this.y = y; + this.z = z; + this.value = value; + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityIndexTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityIndexTest.java new file mode 100644 index 00000000..d6ec8958 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityIndexTest.java @@ -0,0 +1,108 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.entities; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DroppedItemVisibilityIndexTest { + + @Test + void matchesBruteForceAcrossWorldsAndCellBoundaries() { + UUID firstWorld = UUID.randomUUID(); + UUID secondWorld = UUID.randomUUID(); + List points = new ArrayList<>(); + DroppedItemVisibilityIndex index = new DroppedItemVisibilityIndex<>(); + Random random = new Random(0x1A2B3C4DL); + for (int id = 0; id < 4096; id++) { + UUID world = (id & 3) == 0 ? secondWorld : firstWorld; + double x = random.nextDouble(-2048.0D, 2048.0D); + double y = random.nextDouble(-64.0D, 384.0D); + double z = random.nextDouble(-2048.0D, 2048.0D); + points.add(new TestPoint(world, x, y, z, id)); + index.add(world, x, y, z, id); + } + + for (int query = 0; query < 250; query++) { + UUID world = (query & 1) == 0 ? firstWorld : secondWorld; + double x = random.nextDouble(-2048.0D, 2048.0D); + double y = random.nextDouble(-64.0D, 384.0D); + double z = random.nextDouble(-2048.0D, 2048.0D); + double range = random.nextDouble(0.0D, 96.0D); + Set expected = bruteForce(points, world, x, y, z, range); + Set actual = new HashSet<>(); + + index.queryInto(world, x, y, z, range, actual); + + assertEquals(expected, actual); + } + } + + @Test + void includesTheExactSphereEdgeAndRejectsOutsideY() { + UUID world = UUID.randomUUID(); + DroppedItemVisibilityIndex index = new DroppedItemVisibilityIndex<>(); + index.add(world, 16.0D, 74.0D, -16.0D, "edge"); + index.add(world, 16.0D, 74.0001D, -16.0D, "outside"); + Set matches = new HashSet<>(); + + int candidates = index.queryInto(world, 16.0D, 64.0D, -16.0D, 10.0D, matches); + + assertEquals(Set.of("edge"), matches); + assertEquals(2, candidates); + } + + @Test + void sparseSceneInspectsFarLessThanTheGlobalItemCount() { + UUID world = UUID.randomUUID(); + DroppedItemVisibilityIndex index = new DroppedItemVisibilityIndex<>(); + for (int id = 0; id < 2048; id++) { + index.add(world, id * 32.0D, 64.0D, id * 32.0D, id); + } + Set matches = new HashSet<>(); + + int candidates = index.queryInto(world, 0.0D, 64.0D, 0.0D, 64.0D, matches); + + assertTrue(candidates < 16); + assertEquals(Set.of(0, 1), matches); + } + + private static Set bruteForce(List points, UUID world, + double x, double y, double z, double range) { + Set matches = new HashSet<>(); + double rangeSquared = range * range; + for (TestPoint point : points) { + if (!point.world.equals(world)) { + continue; + } + double deltaX = point.x - x; + double deltaY = point.y - y; + double deltaZ = point.z - z; + if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= rangeSquared) { + matches.add(point.id); + } + } + return matches; + } + + private record TestPoint(UUID world, double x, double y, double z, int id) { + } +} From 5802d5eda30cb02299d2288f2eb8f6632717811e Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 14:10:39 +0800 Subject: [PATCH 04/12] Track safe item animations with packets --- .../InteractionVisualizer.java | 4 + .../managers/DisplayManager.java | 246 ++++++++++++++---- common/src/main/resources/config.yml | 3 + .../DisplayManagerItemAnimationTest.java | 17 ++ 4 files changed, 218 insertions(+), 52 deletions(-) diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index 63c14ae9..dad93939 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -112,6 +112,8 @@ public class InteractionVisualizer extends JavaPlugin { public static boolean staticVirtualItemAnchorsDuringAnimation = false; /** A/B switch: eligible stationary virtual items are tracked and rendered entirely by packets. */ public static boolean packetOnlyStaticVirtualItems = false; + /** A/B switch: safe animated virtual items use Typewriter-style per-viewer packet tracking. */ + public static boolean packetOnlyAnimatedVirtualItems = false; /** A/B switch: smooths visibility recovery bursts; hides are always immediate. */ public static boolean visibilityRateLimiting = false; public static int visibilityRateLimitBucketSize = 128; @@ -341,6 +343,8 @@ public void loadConfig() { "Settings.Performance.VirtualItems.StaticAnchorDuringAnimation"); packetOnlyStaticVirtualItems = getConfiguration().getBoolean( "Settings.Performance.VirtualItems.PacketOnlyStatic"); + packetOnlyAnimatedVirtualItems = getConfiguration().getBoolean( + "Settings.Performance.VirtualItems.PacketOnlyAnimated"); visibilityRateLimiting = getConfiguration().getBoolean( "Settings.Performance.VisibilityRateLimit.Enabled"); visibilityRateLimitBucketSize = Math.max(1, getConfiguration().getInt( diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java index 804d980b..6db9a7da 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java @@ -105,6 +105,7 @@ public final class DisplayManager implements Listener { private static final Map> virtualItemIds = new ConcurrentHashMap<>(); private static final Map renderedItemStacks = new ConcurrentHashMap<>(); private static final Map renderedItemLocations = new ConcurrentHashMap<>(); + private static final Map packetItemPositions = new ConcurrentHashMap<>(); private static final Set packetOnlyItems = ConcurrentHashMap.newKeySet(); private static final Set fixedItemDisplays = ConcurrentHashMap.newKeySet(); private static final Map virtualTextDisplays = new ConcurrentHashMap<>(); @@ -171,7 +172,7 @@ public static void update() { boolean desiredStaticAnchor = useStaticAnchorForAnimation( InteractionVisualizer.staticVirtualItemAnchorsDuringAnimation, item.isCustomNameVisible()); - boolean packetOnly = qualifiesForPacketOnlyStatic(item); + boolean packetOnly = qualifiesForPacketOnly(item); boolean representationChanged = fixedItemDisplays.contains(item) != item.isFixedDisplay() || packetOnlyItems.contains(item) != packetOnly || animation != null && animation.staticAnchor != desiredStaticAnchor; @@ -243,6 +244,7 @@ public static void shutdown() { virtualItemIds.clear(); renderedItemStacks.clear(); renderedItemLocations.clear(); + packetItemPositions.clear(); packetOnlyItems.clear(); fixedItemDisplays.clear(); virtualTextDisplays.clear(); @@ -658,14 +660,18 @@ private static void syncItem(Item logical) { clearViewerTracking(logical); } } - boolean packetOnly = qualifiesForPacketOnlyStatic(logical); + boolean packetOnly = qualifiesForPacketOnly(logical); boolean wasPacketOnly = packetOnlyItems.contains(logical); org.bukkit.entity.Entity current = logical.getBukkitEntity().orElse(null); Location logicalLocation = logical.getLocation(); + ItemAnimationState previousAnimation = itemAnimations.get(logical); if (packetOnly) { PerformanceMetrics.packetOnlyItemSync(); ItemStack itemStack = logical.getItemStack(); + Location previousPosition = previousAnimation == null ? null : previousAnimation.position(); + Location previousLogicalLocation = previousAnimation == null + ? null : previousAnimation.logicalLocation(); if (current != null) { discardActual(logical, current); @@ -676,22 +682,43 @@ private static void syncItem(Item logical) { } packetOnlyItems.add(logical); - itemAnimations.remove(logical); // Visualizer Item getters already return defensive copies. Retain those // snapshots directly instead of cloning them a second time on every sync. ItemStack previousItemStack = renderedItemStacks.put(logical, itemStack); Location previousLocation = renderedItemLocations.put(logical, logicalLocation); boolean itemChanged = previousItemStack == null || !previousItemStack.equals(itemStack); boolean locationChanged = previousLocation == null || !previousLocation.equals(logicalLocation); - index(logical, logicalLocation); - if (wasPacketOnly && (itemChanged || locationChanged)) { + Vector velocity = logical.getVelocity(); + boolean gravity = logical.hasGravity(); + boolean animated = requiresItemAnimation(gravity, velocity); + ItemAnimationState nextAnimation = null; + if (animated) { + Location position = itemAnimationStartPosition( + logicalLocation, previousPosition, previousLogicalLocation, logicalLocation); + nextAnimation = new ItemAnimationState(velocity, gravity, position, false, logicalLocation); + itemAnimations.put(logical, nextAnimation); + rememberPacketItemPosition(logical, nextAnimation.world, + nextAnimation.positionX, nextAnimation.positionY, nextAnimation.positionZ); + scheduleItemAnimationTick(); + index(logical, nextAnimation.world, nextAnimation.positionX, nextAnimation.positionZ); + } else { + itemAnimations.remove(logical); + rememberPacketItemPosition(logical, logicalLocation.getWorld(), logicalLocation.getX(), + logicalLocation.getY(), logicalLocation.getZ()); + index(logical, logicalLocation); + } + if (wasPacketOnly && itemChanged) { respawnVirtualItems(logical); + } else if (wasPacketOnly && (locationChanged || previousAnimation != null || animated)) { + Location visualLocation = nextAnimation == null ? logicalLocation : nextAnimation.position(); + synchronizeVirtualItemMotion(logical, visualLocation); } return; } if (wasPacketOnly) { packetOnlyItems.remove(logical); + packetItemPositions.remove(logical); renderedItemLocations.remove(logical); itemAnimations.remove(logical); clearViewerTracking(logical); @@ -737,9 +764,8 @@ private static void syncItem(Item logical) { Vector velocity = logical.getVelocity(); boolean gravity = logical.hasGravity(); boolean animated = requiresItemAnimation(gravity, velocity); - ItemAnimationState previousAnimation = itemAnimations.get(logical); - Location previousPosition = previousAnimation == null ? null : previousAnimation.position; - Location previousLogicalLocation = previousAnimation == null ? null : previousAnimation.logicalLocation; + Location previousPosition = previousAnimation == null ? null : previousAnimation.position(); + Location previousLogicalLocation = previousAnimation == null ? null : previousAnimation.logicalLocation(); boolean applyLogicalLocation = shouldApplyItemLogicalLocation( animated, previousPosition, previousLogicalLocation, logicalLocation); boolean anchorMoved = applyItemBase(actual, logical, logicalLocation, applyLogicalLocation); @@ -760,7 +786,7 @@ private static void syncItem(Item logical) { Location anchorLocation = actual.getLocation(); Location visualLocation = itemAnimationIndexLocation( nextAnimation != null && nextAnimation.staticAnchor, - anchorLocation, nextAnimation == null ? null : nextAnimation.position); + anchorLocation, nextAnimation == null ? null : nextAnimation.position()); index(logical, visualLocation); if (itemChanged) { @@ -926,7 +952,7 @@ private static boolean requiresActualEntity(VisualizerEntity logical) { return shouldRender(frame); } if (logical instanceof Item item) { - return item.isFixedDisplay() ? !item.getItemStack().isEmpty() : !qualifiesForPacketOnlyStatic(item); + return item.isFixedDisplay() ? !item.getItemStack().isEmpty() : !qualifiesForPacketOnly(item); } return false; } @@ -991,16 +1017,28 @@ static boolean useStaticAnchorForAnimation(boolean configured, boolean customNam } static boolean qualifiesForPacketOnlyStatic(boolean configured, boolean gravity, Vector velocity, - boolean customNameVisible, boolean glowing) { + boolean customNameVisible, boolean glowing) { return configured && !gravity && velocity != null && velocity.getX() == 0.0D && velocity.getY() == 0.0D && velocity.getZ() == 0.0D && !customNameVisible && !glowing; } - private static boolean qualifiesForPacketOnlyStatic(Item logical) { - return !logical.isFixedDisplay() && qualifiesForPacketOnlyStatic( - InteractionVisualizer.packetOnlyStaticVirtualItems, - logical.hasGravity(), logical.getVelocity(), logical.isCustomNameVisible(), logical.isGlowing()); + static boolean qualifiesForPacketOnlyAnimated(boolean configured, boolean gravity, Vector velocity, + boolean customNameVisible, boolean glowing) { + return configured && velocity != null && requiresItemAnimation(gravity, velocity) + && !customNameVisible && !glowing; + } + + private static boolean qualifiesForPacketOnly(Item logical) { + if (logical.isFixedDisplay()) { + return false; + } + Vector velocity = logical.getVelocity(); + boolean gravity = logical.hasGravity(); + return qualifiesForPacketOnlyStatic(InteractionVisualizer.packetOnlyStaticVirtualItems, + gravity, velocity, logical.isCustomNameVisible(), logical.isGlowing()) + || qualifiesForPacketOnlyAnimated(InteractionVisualizer.packetOnlyAnimatedVirtualItems, + gravity, velocity, logical.isCustomNameVisible(), logical.isGlowing()); } private static void scheduleItemAnimationTick() { @@ -1046,43 +1084,62 @@ private static void tickItemAnimations() { private static void tickItemAnimation(Item logical, ItemAnimationState animation) { org.bukkit.entity.Entity entity = logical.getBukkitEntity().orElse(null); - if (!active.containsKey(logical) || !(entity instanceof ItemDisplay actual)) { + boolean packetOnly = packetOnlyItems.contains(logical); + if (!active.containsKey(logical) || !packetOnly && !(entity instanceof ItemDisplay)) { itemAnimations.remove(logical, animation); return; } - - Vector movement = itemMovementForTick(animation.gravity, animation.velocity); - Location destination = animation.position.clone().add(movement); - destination.checkFinite(); - World world = destination.getWorld(); - if (destination.getY() < world.getMinHeight() - ITEM_VOID_MARGIN - || !world.isChunkLoaded(destination.getBlockX() >> 4, destination.getBlockZ() >> 4)) { + ItemDisplay actual = entity instanceof ItemDisplay display ? display : null; + + double movementX = animation.velocityX; + double movementY = animation.velocityY - (animation.gravity ? ITEM_GRAVITY_PER_TICK : 0.0D); + double movementZ = animation.velocityZ; + double destinationX = animation.positionX + movementX; + double destinationY = animation.positionY + movementY; + double destinationZ = animation.positionZ + movementZ; + if (!Double.isFinite(destinationX) || !Double.isFinite(destinationY) + || !Double.isFinite(destinationZ)) { + throw new IllegalArgumentException("Non-finite item animation position"); + } + World world = animation.world; + if (destinationY < world.getMinHeight() - ITEM_VOID_MARGIN + || !world.isChunkLoaded(blockCoordinate(destinationX) >> 4, + blockCoordinate(destinationZ) >> 4)) { remove(null, logical, true); return; } - animation.velocity = itemVelocityAfterMovement(movement); - animation.position = destination.clone(); - if (!animation.staticAnchor) { + animation.positionX = destinationX; + animation.positionY = destinationY; + animation.positionZ = destinationZ; + animation.velocityX = movementX * ITEM_HORIZONTAL_DRAG_PER_TICK; + animation.velocityY = movementY * ITEM_VERTICAL_DRAG_PER_TICK; + animation.velocityZ = movementZ * ITEM_HORIZONTAL_DRAG_PER_TICK; + if (packetOnlyItems.contains(logical)) { + rememberPacketItemPosition(logical, world, destinationX, destinationY, destinationZ); + } + if (!packetOnly && !animation.staticAnchor) { + Location destination = animation.position(); PerformanceMetrics.bukkitEntityTeleport(); if (!actual.teleport(destination)) { remove(null, logical, true); return; } } - boolean chunkChanged = index(logical, itemAnimationIndexLocation( - animation.staticAnchor, actual.getLocation(), animation.position)); + boolean chunkChanged = !animation.staticAnchor + && index(logical, world, destinationX, destinationZ); if (chunkChanged) { reconcileViewers(logical); } if (animation.gravity) { // Heart's fake item is no-gravity. Absolute correction preserves the // vanilla gravity trajectory; no-gravity motion remains client-run. - synchronizeVirtualItemMotion(logical, destination); + synchronizeVirtualItemMotion(logical, world, destinationX, destinationY, destinationZ); } - if (!animation.gravity && animation.velocity.lengthSquared() <= ITEM_ANIMATION_EPSILON) { + if (!animation.gravity && animation.velocityLengthSquared() <= ITEM_ANIMATION_EPSILON) { itemAnimations.remove(logical, animation); - if (animation.staticAnchor) { + if (!packetOnly && animation.staticAnchor) { + Location destination = animation.position(); PerformanceMetrics.bukkitEntityTeleport(); if (!actual.teleport(destination)) { remove(null, logical, true); @@ -1092,7 +1149,7 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation reconcileViewers(logical); } } - synchronizeVirtualItemMotion(logical, destination); + synchronizeVirtualItemMotion(logical, world, destinationX, destinationY, destinationZ); } } @@ -1189,7 +1246,8 @@ private static boolean spawnVirtualItem(Item logical, Player player) { return false; } if (packetOnly) { - source = logical.getLocation(); + PacketItemPosition packetPosition = packetItemPositions.get(logical); + source = packetPosition == null ? logical.getLocation() : packetPosition.location(); if (!player.getWorld().equals(source.getWorld()) || !player.isChunkSent(Chunk.getChunkKey(source))) { return false; @@ -1198,7 +1256,7 @@ private static boolean spawnVirtualItem(Item logical, Player player) { if (!(anchor instanceof ItemDisplay) || !player.getWorld().equals(anchor.getWorld())) { return false; } - source = animation == null ? anchor.getLocation() : animation.position.clone(); + source = animation == null ? anchor.getLocation() : animation.position(); } Map ids = virtualItemIds.computeIfAbsent(logical, ignored -> new ConcurrentHashMap<>()); @@ -1214,7 +1272,7 @@ private static boolean spawnVirtualItem(Item logical, Player player) { PerformanceMetrics.virtualSpawnBundle(); ids.put(viewer, spawnedId); if (animation != null) { - Vector motion = itemMovementForTick(animation.gravity, animation.velocity); + Vector motion = animation.nextMovement(); if (motion.lengthSquared() > ITEM_ANIMATION_EPSILON) { SparrowHeart.getInstance().sendClientSideEntityMotion(player, motion, spawnedId); PerformanceMetrics.virtualMotionBundle(); @@ -1243,14 +1301,34 @@ private static Vector nextVirtualItemMotion(Item logical) { ItemAnimationState animation = itemAnimations.get(logical); return animation == null ? new Vector() - : itemMovementForTick(animation.gravity, animation.velocity); + : animation.nextMovement(); + } + + private static void rememberPacketItemPosition(Item logical, World world, + double x, double y, double z) { + PacketItemPosition position = packetItemPositions.get(logical); + if (position == null) { + packetItemPositions.put(logical, new PacketItemPosition(world, x, y, z)); + } else { + position.world = world; + position.x = x; + position.y = y; + position.z = z; + } } private static void synchronizeVirtualItemMotion(Item logical, Location location) { + synchronizeVirtualItemMotion(logical, location.getWorld(), + location.getX(), location.getY(), location.getZ()); + } + + private static void synchronizeVirtualItemMotion(Item logical, World world, + double x, double y, double z) { Map ids = virtualItemIds.get(logical); if (ids == null || ids.isEmpty()) { return; } + Location location = new Location(world, x, y, z); Vector motion = nextVirtualItemMotion(logical); for (Map.Entry entry : ids.entrySet()) { Player player = Bukkit.getPlayer(entry.getKey()); @@ -1533,11 +1611,14 @@ private static void index(VisualizerEntity logical) { if (logical instanceof Item item) { ItemAnimationState animation = itemAnimations.get(item); if (animation != null) { - Location anchorLocation = logical.getBukkitEntity() - .map(org.bukkit.entity.Entity::getLocation) - .orElseGet(logical::getLocation); - index(logical, itemAnimationIndexLocation( - animation.staticAnchor, anchorLocation, animation.position)); + if (animation.staticAnchor) { + Location anchorLocation = logical.getBukkitEntity() + .map(org.bukkit.entity.Entity::getLocation) + .orElseGet(logical::getLocation); + index(logical, anchorLocation); + } else { + index(logical, animation.world, animation.positionX, animation.positionZ); + } return; } } @@ -1545,16 +1626,20 @@ private static void index(VisualizerEntity logical) { } static boolean index(VisualizerEntity logical, Location location) { - UUID world = location.getWorld().getUID(); - int chunkX = location.getBlockX() >> 4; - int chunkZ = location.getBlockZ() >> 4; + return index(logical, location.getWorld(), location.getX(), location.getZ()); + } + + private static boolean index(VisualizerEntity logical, World world, double x, double z) { + UUID worldId = world.getUID(); + int chunkX = blockCoordinate(x) >> 4; + int chunkZ = blockCoordinate(z) >> 4; ChunkKey previous = chunkByLogical.get(logical); - if (previous != null && previous.world().equals(world) + if (previous != null && previous.world().equals(worldId) && previous.x() == chunkX && previous.z() == chunkZ) { return false; } ChunkKey current = new ChunkKey( - world, chunkX, chunkZ); + worldId, chunkX, chunkZ); previous = chunkByLogical.put(logical, current); boolean migrated = previous != null && !previous.equals(current); if (migrated) { @@ -1570,6 +1655,10 @@ static boolean index(VisualizerEntity logical, Location location) { return migrated; } + private static int blockCoordinate(double coordinate) { + return (int) Math.floor(coordinate); + } + static void unindex(VisualizerEntity logical) { ChunkKey key = chunkByLogical.remove(logical); if (key == null) { @@ -2181,6 +2270,7 @@ private static void clearRemovedLogicalState(VisualizerEntity logical) { itemAnimations.remove(item); renderedItemStacks.remove(item); renderedItemLocations.remove(item); + packetItemPositions.remove(item); packetOnlyItems.remove(item); fixedItemDisplays.remove(item); } @@ -2638,19 +2728,71 @@ private boolean matches(Location location) { private static final class ItemAnimationState { - private Vector velocity; + private double velocityX; + private double velocityY; + private double velocityZ; private final boolean gravity; - private Location position; + private final World world; + private double positionX; + private double positionY; + private double positionZ; private final boolean staticAnchor; - private final Location logicalLocation; + private final World logicalWorld; + private final double logicalX; + private final double logicalY; + private final double logicalZ; private ItemAnimationState(Vector velocity, boolean gravity, Location position, boolean staticAnchor, Location logicalLocation) { - this.velocity = velocity.clone(); + this.velocityX = velocity.getX(); + this.velocityY = velocity.getY(); + this.velocityZ = velocity.getZ(); this.gravity = gravity; - this.position = position.clone(); + this.world = Objects.requireNonNull(position.getWorld(), "position world"); + this.positionX = position.getX(); + this.positionY = position.getY(); + this.positionZ = position.getZ(); this.staticAnchor = staticAnchor; - this.logicalLocation = logicalLocation.clone(); + this.logicalWorld = Objects.requireNonNull(logicalLocation.getWorld(), "logical world"); + this.logicalX = logicalLocation.getX(); + this.logicalY = logicalLocation.getY(); + this.logicalZ = logicalLocation.getZ(); + } + + private Location position() { + return new Location(world, positionX, positionY, positionZ); + } + + private Location logicalLocation() { + return new Location(logicalWorld, logicalX, logicalY, logicalZ); + } + + private Vector nextMovement() { + return new Vector(velocityX, + velocityY - (gravity ? ITEM_GRAVITY_PER_TICK : 0.0D), velocityZ); + } + + private double velocityLengthSquared() { + return velocityX * velocityX + velocityY * velocityY + velocityZ * velocityZ; + } + } + + private static final class PacketItemPosition { + + private World world; + private double x; + private double y; + private double z; + + private PacketItemPosition(World world, double x, double y, double z) { + this.world = world; + this.x = x; + this.y = y; + this.z = z; + } + + private Location location() { + return new Location(world, x, y, z); } } diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index 6af5491e..3c5edda1 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -340,6 +340,9 @@ Settings: #A/B switch: eligible stationary virtual items without a visible custom name use no Bukkit tracker entity. #Requires separate packet-capture and client compatibility validation before enabling in production. PacketOnlyStatic: false + #A/B switch: safe animated items use per-viewer packet entities and create no Bukkit tracker anchor. + #Visible custom names and glowing items retain the Paper-owned fallback representation. + PacketOnlyAnimated: false VisibilityRateLimit: #A/B switch: hides remain immediate; newly visible entities are restored through a per-player token bucket. Enabled: false diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java index 025fac30..c64717fd 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/DisplayManagerItemAnimationTest.java @@ -77,6 +77,23 @@ void packetOnlyStaticItemsRequireEverySafeProperty() { true, false, new Vector(), false, true)); } + @Test + void packetOnlyAnimatedItemsRequireMotionAndNoAnchorOnlyMetadata() { + assertTrue(DisplayManager.qualifiesForPacketOnlyAnimated( + true, true, new Vector(), false, false)); + assertTrue(DisplayManager.qualifiesForPacketOnlyAnimated( + true, false, new Vector(0.1D, 0.0D, 0.0D), false, false)); + + assertFalse(DisplayManager.qualifiesForPacketOnlyAnimated( + false, true, new Vector(), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyAnimated( + true, false, new Vector(), false, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyAnimated( + true, true, new Vector(), true, false)); + assertFalse(DisplayManager.qualifiesForPacketOnlyAnimated( + true, true, new Vector(), false, true)); + } + @Test void copiesTheNmsAirMovementOrderAndPrecision() { Vector movement = DisplayManager.itemMovementForTick(true, new Vector(0.18, 0.15, 0.05)); From c1dfbc4537cfaff48a8d8c8a25cfe943fb19ad39 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 14:14:58 +0800 Subject: [PATCH 05/12] Coordinate plugin tasks on one tick wheel --- .../managers/TaskManager.java | 1 + .../scheduler/MainThreadTaskCoordinator.java | 200 ++++++++++++++++++ .../scheduler/Scheduler.java | 18 +- 3 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/scheduler/MainThreadTaskCoordinator.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java index d1802f1f..51db058c 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java @@ -457,6 +457,7 @@ public static void run() { public static void shutdown() { try { if (plugin != null) { + Scheduler.shutdown(plugin); Bukkit.getScheduler().cancelTasks(plugin); } } finally { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/scheduler/MainThreadTaskCoordinator.java b/common/src/main/java/com/loohp/interactionvisualizer/scheduler/MainThreadTaskCoordinator.java new file mode 100644 index 00000000..4dc605c9 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/scheduler/MainThreadTaskCoordinator.java @@ -0,0 +1,200 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.scheduler; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.PriorityQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; + +/** One Bukkit driver task multiplexes every InteractionVisualizer task. */ +final class MainThreadTaskCoordinator { + + private static final AtomicInteger NEXT_TASK_ID = new AtomicInteger(-1); + private static final Map COORDINATORS = new ConcurrentHashMap<>(); + private static final Map TASKS_BY_ID = new ConcurrentHashMap<>(); + + private MainThreadTaskCoordinator() { + } + + static ScheduledTask schedule(Plugin plugin, Runnable runnable, long delay, long period) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(runnable, "runnable"); + Coordinator coordinator = COORDINATORS.computeIfAbsent(plugin, Coordinator::new); + CoordinatedTask task = coordinator.schedule(runnable, delay, period); + TASKS_BY_ID.put(task.getTaskId(), task); + return new ScheduledTask(task); + } + + static boolean cancel(int taskId) { + CoordinatedTask task = TASKS_BY_ID.remove(taskId); + if (task == null) { + return false; + } + task.cancel(); + return true; + } + + static void shutdown(Plugin plugin) { + Coordinator coordinator = COORDINATORS.remove(plugin); + if (coordinator != null) { + coordinator.shutdown(); + } + } + + static int retainedTaskCount(Plugin plugin) { + Coordinator coordinator = COORDINATORS.get(plugin); + return coordinator == null ? 0 : coordinator.retainedTaskCount(); + } + + private static final class Coordinator { + + private final Plugin plugin; + private final ConcurrentLinkedQueue incoming = new ConcurrentLinkedQueue<>(); + private final PriorityQueue scheduled = new PriorityQueue<>( + Comparator.comparingLong(CoordinatedTask::dueTick) + .thenComparingInt(CoordinatedTask::getTaskId)); + private final BukkitTask driver; + private volatile long currentTick; + private volatile boolean closed; + + private Coordinator(Plugin plugin) { + this.plugin = plugin; + this.currentTick = Integer.toUnsignedLong(Bukkit.getCurrentTick()); + this.driver = Bukkit.getScheduler().runTaskTimer(plugin, this::tick, 1L, 1L); + } + + private CoordinatedTask schedule(Runnable runnable, long delay, long period) { + if (closed) { + throw new IllegalStateException("The task coordinator is shut down"); + } + long firstTick = currentTick + Math.max(1L, delay); + CoordinatedTask task = new CoordinatedTask(this, NEXT_TASK_ID.getAndDecrement(), + runnable, firstTick, period <= 0L ? 0L : Math.max(1L, period)); + incoming.add(task); + return task; + } + + private void tick() { + if (closed) { + return; + } + currentTick = Integer.toUnsignedLong(Bukkit.getCurrentTick()); + CoordinatedTask added; + while ((added = incoming.poll()) != null) { + if (!added.isCancelled()) { + scheduled.add(added); + } + } + while (true) { + CoordinatedTask task = scheduled.peek(); + if (task == null || task.dueTick() > currentTick) { + break; + } + scheduled.poll(); + if (task.isCancelled()) { + continue; + } + try { + task.runnable.run(); + } catch (Throwable throwable) { + task.cancel(); + plugin.getLogger().log(Level.SEVERE, + "Cancelled a coordinated InteractionVisualizer task after an exception", throwable); + continue; + } + if (task.period > 0L && !task.isCancelled() && !closed) { + task.dueTick = currentTick + task.period; + scheduled.add(task); + } else { + TASKS_BY_ID.remove(task.getTaskId(), task); + } + } + } + + private void shutdown() { + closed = true; + driver.cancel(); + CoordinatedTask task; + while ((task = incoming.poll()) != null) { + task.cancel(); + } + for (CoordinatedTask scheduledTask : scheduled) { + scheduledTask.cancel(); + } + scheduled.clear(); + } + + private int retainedTaskCount() { + return incoming.size() + scheduled.size(); + } + } + + private static final class CoordinatedTask implements BukkitTask { + + private final Coordinator coordinator; + private final int taskId; + private final Runnable runnable; + private final long period; + private volatile long dueTick; + private volatile boolean cancelled; + + private CoordinatedTask(Coordinator coordinator, int taskId, Runnable runnable, + long dueTick, long period) { + this.coordinator = coordinator; + this.taskId = taskId; + this.runnable = runnable; + this.dueTick = dueTick; + this.period = period; + } + + private long dueTick() { + return dueTick; + } + + @Override + public int getTaskId() { + return taskId; + } + + @Override + public Plugin getOwner() { + return coordinator.plugin; + } + + @Override + public boolean isSync() { + return true; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void cancel() { + if (!cancelled) { + cancelled = true; + TASKS_BY_ID.remove(taskId, this); + } + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/scheduler/Scheduler.java b/common/src/main/java/com/loohp/interactionvisualizer/scheduler/Scheduler.java index 5feafc52..1b19f8ac 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/scheduler/Scheduler.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/scheduler/Scheduler.java @@ -67,23 +67,29 @@ public static ScheduledTask runTaskTimer(Plugin plugin, Runnable runnable, long } public static ScheduledTask runTask(Plugin plugin, Runnable runnable) { - return wrap(Bukkit.getScheduler().runTask(plugin, runnable)); + return MainThreadTaskCoordinator.schedule(plugin, runnable, 1L, 0L); } public static ScheduledTask runTaskLater(Plugin plugin, Runnable runnable, long delay) { - return wrap(Bukkit.getScheduler().runTaskLater(plugin, runnable, delay)); + return MainThreadTaskCoordinator.schedule(plugin, runnable, delay, 0L); } public static ScheduledTask runTaskTimer(Plugin plugin, Runnable runnable, long delay, long period) { - return wrap(Bukkit.getScheduler().runTaskTimer(plugin, runnable, delay, period)); + return MainThreadTaskCoordinator.schedule(plugin, runnable, delay, period); } public static void cancelTask(int taskId) { - Bukkit.getScheduler().cancelTask(taskId); + if (!MainThreadTaskCoordinator.cancel(taskId)) { + Bukkit.getScheduler().cancelTask(taskId); + } + } + + public static void shutdown(Plugin plugin) { + MainThreadTaskCoordinator.shutdown(plugin); } - private static ScheduledTask wrap(org.bukkit.scheduler.BukkitTask task) { - return new ScheduledTask(task); + public static int retainedTaskCount(Plugin plugin) { + return MainThreadTaskCoordinator.retainedTaskCount(plugin); } } From a6d620b08853c0c3620446a49fed1dcd18ec342c Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 14:35:53 +0800 Subject: [PATCH 06/12] Replace LightAPI with on-demand CraftEngine lighting --- README.md | 8 +- build.gradle.kts | 11 +- common/lib/LightAPI-fork-3.5.2.jar | Bin 134473 -> 0 bytes .../InteractionVisualizer.java | 36 +- .../craftengine/CraftEngineLightManager.java | 514 ++++++++++++++++++ .../craftengine/RequestedLightLevels.java | 30 + .../managers/LightManager.java | 159 ++---- .../objectholders/ILightManager.java | 20 +- common/src/main/resources/config.yml | 2 +- common/src/main/resources/plugin.yml | 1 - .../craftengine/RequestedLightLevelsTest.java | 41 ++ .../managers/LightManagerLoaderTest.java | 57 ++ 12 files changed, 726 insertions(+), 153 deletions(-) delete mode 100644 common/lib/LightAPI-fork-3.5.2.jar create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/RequestedLightLevels.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/integration/craftengine/RequestedLightLevelsTest.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/managers/LightManagerLoaderTest.java diff --git a/README.md b/README.md index 92eb233e..34bc7379 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,11 @@ against Paper 26.2. - [CraftEngine](https://github.com/Xiao-MoMi/craft-engine) 26.7.2: optional custom-item ID recognition. CraftEngine items can be selected by the third field of an item-label blacklist rule, and their display pose can be - overridden in `material.yml` under `CustomItems`. CraftEngine is not bundled - and the plugin behaves exactly as before when it is absent. -- [LightAPI](https://www.spigotmc.org/resources/lightapi-fork.48247/) + overridden in `material.yml` under `CustomItems`. It also supplies display + lighting: InteractionVisualizer shares its light-block reference counts so + CraftEngine furniture and IV displays do not remove each other's light, and + performs no lighting task while idle. CraftEngine is not bundled and the + plugin behaves exactly as before when it is absent. - [OpenInv](https://dev.bukkit.org/projects/openinv) - [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) - Essentials, SuperVanish, PremiumVanish, and CMI diff --git a/build.gradle.kts b/build.gradle.kts index be787198..a9326ef6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { compileOnly("me.clip:placeholderapi:2.11.7") compileOnly("net.momirealms:craft-engine-core:$craftEngineVersion") compileOnly("net.momirealms:craft-engine-bukkit:$craftEngineVersion") - compileOnly(files("common/lib/LightAPI-fork-3.5.2.jar")) implementation("net.momirealms:sparrow-yaml:1.0.7") implementation("net.momirealms:sparrow-heart:$sparrowHeartVersion") @@ -49,7 +48,6 @@ dependencies { paper26_2CompileClasspath("me.clip:placeholderapi:2.11.7") paper26_2CompileClasspath("net.momirealms:craft-engine-core:$craftEngineVersion") paper26_2CompileClasspath("net.momirealms:craft-engine-bukkit:$craftEngineVersion") - paper26_2CompileClasspath(files("common/lib/LightAPI-fork-3.5.2.jar")) } java { @@ -204,15 +202,24 @@ val verifyCustomContentIsolation = tasks.register("verifyCustomContentIsolation" val allowedCraftEngineSource = file( "common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineCustomContentBridge.java", ).canonicalFile + val allowedCraftEngineLightSource = file( + "common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java", + ).canonicalFile val managerSource = file( "common/src/main/java/com/loohp/interactionvisualizer/integration/CustomContentManager.java", ).canonicalFile + val lightLoaderSource = file( + "common/src/main/java/com/loohp/interactionvisualizer/managers/LightManager.java", + ).canonicalFile val stableApiClass = "net.momirealms.craftengine.bukkit.api.CraftEngineItems" + val lightSentinelClass = "net.momirealms.craftengine.bukkit.api.BukkitAdaptor" val craftEngineToken = Regex("net\\.momirealms\\.craftengine(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+") val violations = sources.files.flatMap { source -> craftEngineToken.findAll(source.readText()).mapNotNull { match -> val allowed = when (source.canonicalFile) { allowedCraftEngineSource, managerSource -> match.value == stableApiClass + lightLoaderSource -> match.value == lightSentinelClass + allowedCraftEngineLightSource -> true else -> false } if (allowed) null else "${source.relativeTo(rootDir)}: ${match.value}" diff --git a/common/lib/LightAPI-fork-3.5.2.jar b/common/lib/LightAPI-fork-3.5.2.jar deleted file mode 100644 index b7b1844ca9d0fb59f657b8de19581ce1c262c2c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 134473 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

U5V$+FB3|3QGzBrU!ZAG z4e^msV{1r!ZHUSYfI}B`JBPKz=r7=<$YfZrBOiA)P3VYhYlQsb z%+Ywz##LC$?Q@%RDtC|-r7E$YVzBU7j9waBhQnf;sG}e?ee8j&;~aBQrez`{q^GdJ#NnD{qE%`WWRGc+o1X_${dg5KPiTF@)YuCLU{ z_6}KwPRg_vok2|emP&*=+~hXFJJ=kbnnp)3{MMisr}QMui7<~dOv%sidnE5bX@U>n|8+~!{NrJP{(ovq@^|t3 zw|BD;1&}{V^wsQ}R+cntt0TZpOrW-kRcQ(eDoN)WXH!kT$Jbb=nyw7q98BK~4h&d% zFRWxd>w7HKOmQ&%(ChYn`?~YhR(K{b&c804=EP)V^e1Jw zs?3P$WQ%rb`@ucRuqF5^^4whtma4|xZ(oYCMeOY{3NO|5$jJx8*N44qXYT%)b2^Ub zXXM${%%gG2U@aM8c$1a21KSO|YVZ&tzr5yT1~K?c^Nf`k9B7|01*1FN#oH0TvBw}g zn#5`p@6>~c68m*xn}dx6i@fatz<$IK&OK3!_Nij&a_)=5BgLh~u+tkx zh&&+&UN5{W7CLLa7zN%cKMe z4FmmDT-_&(OgMI*!51;*IQD*HHy3^TPY%U9I({9H(26G|=**U&3AW(lb=iKjoH&gy z!{~FHv+~|wAVRYFG?j0>6;K!-%mG`Ih(dIw(O9K%#9*(d1W=VH38WNCPAUv>ChTk4 z5?S^LiYN^B-kDFLhwPvC?xV%9e6nw$`*pj`*RFbG#~6KWp21%qR~zlA)M+6&mQyzD zmd7`;kLMJwvttchIx+4tr%7{08HFQc7x#Vf7YXA0#R{pyKHXD0Lf3K@yGcS4bzYmF zsQMoZrpKEyy^9kq8`a^AHMAWT#&fod>B~7~ipRZ7TDI`lGW+x@)S$HuS{qM6(py77 z`oAMDga9B4Cj%szx?*K^#EplYm4T6e&)84}UWT!v767=FqMleba!TPjz zOW=&tqXnkFn-ZaEAlW(Zh$<`QPzzk?=<0ytAvJ>{Qq_8zU%)}u@gBX~h z%>DRNd)Rr^G@d+_vw_Ak=L+?F1h&CBvR$FKupPR)KpN{?2G^LPeEVfQOS#aY%W<0S z(Os9x5M7MFd`N!eDF-!11k#&(i)!!;)9cRsdO~#Q6a=fKc*Ux=O{(ipdhwWu3xU=^ zvsb9hy&~@xhAWSNLvU2Jn%ZsnNEueqCZm#k#?8j+{$kD*m+0ezx%EFmW=+Cv_ z-)lWr20eB?MW%n=%xY5oucl4yZ7i0sCS)pt?^q-t0g5D+G7xkukO4M-n>H^>{x)s; zi4Nn*_dFNJAeo*?5Br`!Og{+qxLQtkbx;Gx>zieyZ}NCKU2#l%OpR~yeY^nbb||=G z@<4$x1V9PIp(}J$(0*wuv{;FV(O^i8$EdO{MA+7zHCxswFN`LS!Aw#oIeST0o%{JQ9y>6`RrnOrud=}->i7ZvWsC5PT6 z*>r$mdKl{XQF2vA6;cibbq=&76Sm1w&x$z2dy(qE-z$J^$lm7jRfBe;B6-vkpN)5T zCf=q%JY-u)88EbbCJ$~RDzGwjAyKP}POkEf&o?uBjvniikru#uLckoCEgm*$WFmHL z#BY|>Lg{CqW(wGM&n3GdE+)zuHEuT}Joy>Q$cd5#s4KhOY#9B?9MW;?_Os{58EGC7 zhz8moV>=4*Ouv1bMK4QA9Cr}vgC`UgwumWHZ`@TN=KhPL5@wCFv04H1pSaWFj|l|hNOSQGj*&Y{1_O?H3KiH28mvL*I47w(9u}9)LJ9N z+YfC(ZJLTQ#d_P+g613tV>w>YTC;zOmNRK1vpYYx8Kr;2`<-2pLR!0{puc4qXy_xZ z7E*($6MIrcX_G~opK$Io9XLdhvGV>R!ZE>-qhd)NLc9V9qwOA=Mv~|DS z{d^F3_`!+r*!eQd7SM2W`61I&lS*`Zdt~>}vmD6&wvlsgF@I8aBGi!XN*;2a)0)8?D)#ioA zh17{F@{4;U1PD^bO~%DJYbK5~M0lwa4vaT?0@KtY2vtVWVt^S*5An8h{UO&}X>{4fh} z6IlBnxCwCRDgJ9?)}*TCG_MN(A!{vFkMM+P4?Y7#5`z@iOQ0rBK#I&ZRg8{^xKZOlw) z3$zS1MNHxAFiQ918r_I^9T_(=JtS43xU^+VV>M?qHF`Bu)n@Bq>b83b$lNu(B&du8 z;}aF>qalavMA&lug!Y-uX)Is2oTYd12Q+1K2CZZxSQQFf^y*^#M!pyZ4nbNMi#zJ8 z)=Z!ANgo9bsc}XU0r>zjzzAi(F@UrZizS_~WJs)yMw49S7P_9gVxxp&LIB0t5Y^}A zNz6)`9fe;<5U|dsQGs43Pba`0bjbOMloVr5nS7QGl@CP*=vubN#A6WFGD-BefOadq z-u;}a$i&7$$#Ef~EEE%f?t)g!K-D&a2%p11yGyY0C$K)%sE(^fW!B1Ff-q=PZlZ+l zpLG@@iNF^N&noe7^X9JH!FIdR|I$9gFo7~LyKBS?UPGT{v-E~!*zqI2-3(g-V;Na`$Mc?ab7i!ymO99I zl-Na-Bf*{*dj(zLYgXlphn0hoV&)f zW-$xVu+f>7fo+Xd;uBkZyn|G)%>+v(`+X)%eL>oDB%p7TLQKiUF2hw;EpoiwMM~>P z>5ac#IGd?d1I!Q5xA|Bh(8ci)Lu=i2T~6s{LgUj#g(3Q3)S+>C@g+A3Gvwg|--HxJd0X6?0#gCL{S z?U6^1eZ)fc%CetiaOlb*M$JgvOh&)hde&UC?w!R!F@gUO78T9t@r@^{M)Ni8CQwHHh|* zP|j-DpwxS`JQ%#gXjex)CrFjqIYM2v1j`86@kEZqH`pD1f;SwnyulCHV!FF0g2#ZS zWs*Nl%e+y)?pnn2Vwg5^_P{kONm-ljLQ zE+@;q^93mwZYA8JmOFR?78hTt!*}5c5$ryo7mgKvkAG@5|FdklCRIR^h)k1k0oJhW zMA0{0$=CGH`d9_$-Eeh0SmpQh!x56D2MKTXSs{Y1TC z516TE=wAMn@%lKq*2c$if18%k`0h{?%zWPt|VTMYIhL;P0KLdE`hJ{fTrcDr$6ie4UbcQ#uZXJ zxBDY#$=UM-gz=}+v(NO~bf>m!*jT%Os$vkQ#Iprr>e=0E$5X=9QhX7d{CvgK? zV`~#fK?5g~e`Ta-dbukdVti&F5R=KsW;WYH=2wrfHlY!N)>|5g=Mx6cQ3eb4+Q?)I zLC7R#6p5dge9v>=%!=3}&D>imw=R^`0>0*CyJX!cx!UtVV`Iyn{_$SOZT{rD&GF)x zQBtk#jSqN2KYaJSzV0G@o-+&0`AUz5*Jco?<6qSWTpF|?<39=P#}04t#MWp#w~mOI zbuSbzZ|MKkOTr@Y8?7;gedP^*@#U;~M^95nAyLvzZTL{10EA^njyl3hgECx`o%D@R zlkLIJN_;gD+&hwIqsVdDHNcsI8K}EPe=Y-5J9&aMVz`BF2B-4E-VP%*uVjQwS>hg zPEM}TQ`Jc8GtA94B^Vx{h+-18%^evL`79KW8pjg+KxHX-D~vx^;zf^SMwM3F7YhlY zL?iYfPMCCZtA_~taKZqA%c+5814X}rSdx^ai6Ko3AH=~f)~k(jT%g!j1nouKDxPF< zGfH%bo}zE-(j5UMF9KVnw}k^f+%`J5X{CG0DSGZ*a-B+o97}%&{=CuZWS<`q9FmFtz>(V!xPNwcsL3UT{Sxr!KP*g>s z3Zj97{eb7M3}8hOkl&D*#xCb3SN-KY3p-rU9x^yiGEtVPPo-9-GbkD~g0ODriJ!lW z5wD%Bkh}&#vXsKgkdH&zcS4*mz*7a4kCv4uFJua+f1@p>w`1BcxE;C@|K?$T2_aOC zCBaroR72=*+aBE6EDA>kLh33eVN)GHRwG{z%7c7=0PJs1Zhn+ydK8Bdmz1!sN3lCG zVZYXj=vyOYV=R<(cxPAE3aKV#kMy-)Nqqb*0ovZHK6)|i645kTK(GX#@ns|5kgTRS z3u&210QTnsad$hFcn-e^!H*e-;wwYN+En`~b+h3ZFy9YR*AGC@0n_kY*+G_p=?0eR z4l2sFakv1)Al=!8En}J>iPtXG`g?Yn-ZI+b681$I7nFGs>M^(obF2 zR#HlIxEq#8za`b{7${IQMTw-m7EPvL()OZm^znL*iem{HP~w^np$W8^6OuKBh#gtn z_16@&2GlpR8+mqeNynsS=6qSnM-VNVFlF?V6}7k@A2ziC4$n+??SH#%F=Gi?Bk~t^ zi=usdJvKA)Mour!0B^I4ic*qP(IFI)M4m-lokL({Z0``gHdM%%88z{t>X6Vcwbg9p zL<}cwRV$k40TmFe!aX3-7})oJxrM$>=^<=iYGPZ77&i?b=-oC1*w zS}qfduG6pGgtHiKLqo6XkM(@n%Xbt$)JJiphn);~L`D6qQD2*I&zVd9dueHNYj9FoGO6()%uL04%R1HC--Zau`V7iq_`6(*YN;@hzRntVO)$ zC_#>G3Vc*aX4LutAGg5k$tiOM5|xotZeMkCxQV%L+;KO>c}9vl&xAnp2n2?LkF|Iq z(Hsn4&39wonvM#Se|Aa=xBv9p95HRn_em5)C(spbq^b#D3IuSN!137K3FjafBm6fA z-!VBBqig~Z#gi#&O|9!hxPq*+(!z~n^q&{NS#E6DkMAbDfbvwzY!Pc$|2eEGWo8kl zt3WX&#Eg62=F+rkl^-mrM}}6qlgawi&f{64u-};Hf-C7kE5k2H70qE@t~3do(gV9E zz7z4+q7hdO1IvP7{hA+{5|buLpQ;+@f5w>Q$@V@&&~!;SSanYlg&bX?=O;pIc{FB^ zF^CA@$TbWhmKc_&cG!p8V@Q}Pdk;HkJ<)JNXPFFCS3K0e(3ZXcv~dNh6Bxiru9PYC z&8KM;Cncky{7w?6S`D<$?Jvb1@Wfhrfa%$D!rmERx-3N?%>%t~OF?W@6mK>FcD`GH z(Y`MZY7B}RVm9Pn1c$Z!`i&(Z5VKbR26hkrSRWU5-=Xg#h@Mx=OR>usI={qTS(1%;0JM1JUj_JbX_V%=_p*G2oaN=_#rD>4}mmAed4~JzBO7| z7f2J@bb48r`8cs$=Ls@|bmP^=yVYwTTXbW)G> zFRdFu`s*^f>2mi%513aw`yS>z(8e}QQ8s^KYD@av_{Xdhw@ELCvl?u|3;8HhD6 zuyuNchVmM5HJtqDEiLLZ!fB(nXK6cAZP;U#{8*iYp!L}>8Ax&o6d)XR|{pJe!sDlj7HJaLz$a1|4hJk-pQ$(*1d{Df3dE~eULAG7?=F88F__H^q{9p<`sz-#v4pv zmL4RN6NmvpBjErA9^Ekf_*M$FeiB>HE8RD=S*`9i-#0w+@)h>gN%{FK_tY-ngmFwM zP^0!9G7?de?q$)ntysL9(m}a@_3#WB)i|Osp#<|iT*l? z8o=!(dJ7mt7~K5AJm%qp6Qeud#lhYQF*>Rv@#$uq!Mx~)@O_9CQ#a)T1_xn}J z7r`VQynQ6u?F1tEI-)2&ip5Cok%sJK-} zye#TFk;etjr6LbS5)bo!6$keeB{5(gEJG||DgWLoqKmPM7qnA~e1TsDu81R3!zLtlpA)>ge|cS%ewS+@>WlZu=+7+$ zJXM6|7)wRfavRg1{F%+me%CYJ9jKi+h88l)habY*2tZ1L#EkERv`^${Lo$-4+%6kPF9SD z!kREU2aWSr=7+X9su|jJHC*(_Ww2@bmfd&W5R181lHWe@uUBdEIFTOEE@f^7T%`y~ zWTi3j&`M`fV0NH3MxPaCN5w)%)6k1Si5-cdp9?>`z3-(5j~m9H!=`NyIi#dph)b24 zqLz&%M7t08%MS(wF-ut%Ku7zk(-FubMzzk4`T`a39+u``yiHMRTKEJ$(Pc8wpNXMD1k04ATL zOAp{rWl?$_V3N>^DC58L&Jg}`V9@#zt|4Z{rT9^ij45JS?90zrapq1y%%9w1S#&#b zt35q+&Bx~p>>g@^g<(u{AURU_Q=WJCcmt;OZWAsLX?GAhxI~$e*LY;O64CRPZ`_oF_V#o!FpkDTQkJH*}jSJMw9q zVz@0MF8F&q97wPLnTznm!(+Au184 z(<|$(uA@OZpuybH51#HtZo2KF?A5kLTfYzo~RE$Tbj1nJ& z1bO3ght#sE=*>P6Q|^(ha%<~+!|&J<@4}oxIp)@j+~fynQP%s71X;;O-2+O$*XJlG ziQZw;mC$JKfAKixs;d2w0Q!ZqMA`z8o~Hi>v6kGDp{+B``zH2aG(#(>i*+gU-`<%1 zbCT@U9**#bIMqM`0WtkQ{<6pz*czCbIR5J#K~+a-T@;ze3hS#NKXh6NGevQd{(W)L z3^PrTqBglKw8i-VMG+ptogtL*DA8!qjocm7YoCyWsknJ*HuqtEPx<_*j>h+%5dU<~ zAm@|kU7@~Bd zbjQ{jOdbmKdbwI9|1ZkEva1d#%Q6t`!Nc9{L2!3>cXxMpm*DR1P9R8d3HosN;O-LK zNr0iIyJxy;t)5x+>HdLpkLdOI9GeBee#sz zw()|;OwetfjAxUEUf&a>3~+`9#4_s%da_3uUt3aV+on(9O4xpyP6-S)-qm}qDXuDC z)L^B}PGfB+=dqOuip~M56q%T2${13@94v+>V9J?>f5>TJu-6u44GBy?pLdXc_tOP` zE4E{ak{&dikJe+`w1gKJraP!wOaF$O?ptulJdo|a7OoYj3FhlAtSe)2KggpzV*sVX z-IoQ6&tBCaF|Ky#^=U2~TGC5~-ZktwjsK9l`dQo#eJYMC9g4fkDzQ1&*&s^cqqJ7z z8t3oPUCzE{;*k%E0-Y0Jul=ff(rIrS&5Kwqmrl=bsWh^(xZfN^lSt?pqM6bJj#<2uptXWn#Fn#Y#+K z7JiT3IDAmaZfMPTC*x*YVA;*EAcI*IbTMccO z(^A&mSiF9Do)*S5AO*{}956JERYyoe& zhvH{(`|$6|hquT$0z#KwjLB@p@poK`&X#J}tc2nABW5pCn+@j7E}?eaoS zK5Z8TGSL-z3Kz#zCy$GgUBhWdQ%aeQlEFPBguM@JqlR0-5gP9!4jG!<)FHV;GRtX{ z{Yn^q${)JN8;6NgJjN#-M}|0641Kw+bYOkcDcoas;Ex!9tcdo}hXSB=V79_}tSW+l zl&YcDTeo7~*UAP)H}s`&)))d6w7vFQ&CsOYK}gnoVOU&(nAc)qp*NCG`#7E=Uh(Ny z!bXKI6Jgg}E>78j$mXAami%OveNVVZ&c0RW0UD>U4Fi5r6`hPgTb%_Bd`{!h6eTnz zU4XJk>Z6p7p!hJWz?=|L+W%A@W5m@0nQ0qDhN>dwhES!lOLXu4ruJ&wlV18pELGdn zHzz9<;f~aQNB87{_pdcE8g?Vu4D!<_vyW8wKc_i&Co^NW588zKf4nO04i3g9cK->N z#;Msm&M9F8FfhS4$3nPHJ#%onr#e(UwNSfGP5efm7ehgRb)(iiK8B|@w|F0N3@|9p z%sfWElEj^+XWu?V6Ig%PJj%VzJ<6S5GwkVkhr0TdLqirx6KS0(l52eUmDqWH$6?-2 zT@WRG8fB|Ne{IVm^6*{<4FJNdz+(_H)w+GL&?jgO^IU(BA9iT*+iac;Xa)Q4WI2Xk zF8MDVxJxoo6@?j>@hMwLpXMwk4gp1rssHLnJo*6JI}M1W5qni2wb4%Q^`RSqC#n{h z+!NMqq2A#@)Gn0te*W2I?0~9yqvg>AR1LprkDRhcOP5&7`vKSp9y26dsT0Urs;E7_{_?Pez|{?Nm;`ggPd@;(GH@ z{NbS30+`V!2ZmqBqdrEOrh&G;5l5KOhN}?Isi0WXYQ-n*C@UQd5&vJ5{~xs((cN6{ z`eVz>0sQod;eV{n{~WYx-cJ7+uW1_3n&_GsZ$otGyP{TN)f$7lSa3MldOC^0D5QAl zXuT3^tE92UByziE5@mn7H9J}tYd!v8)_-sse_tQHDL&k7N=Jqa5P@>NvmU$uY+P=7 zZuvhSZS}mt%milu^o2RE5?2ZZr8V;Wa=MVcB2<4Qhkt zyOK$Sl?d0q@f=2gx;YmOSUQgISC}&1JU|TH0sjaYvY>@uXpCYND=+W0-$CRZu8&1Xu* zItE*I#@Ij(sr_X5Ypih47iP5~`7JWN) zkl(00wFgHkQ)OT>R+6bg3Jy&lfy9v%ZZW$1A(BI`F*1aQlJxZawRi!!U>xGw(wbDH z#OOvA6-e}%3Ks%sNotl3mJ^`((FN}5hmVq0`+>$`E}B#bQjg}Sg}T32>uVZR}MHgldK6}%QUXgUF20ajJ5{l1=FQbRh!(^(cG3IB@ig-((y zvZFzEjFX2Qa|iI4+0d2n)u}DykK6B@s8NG33NKfbpV{&t)PY`-+xjqGKH+3Z0mPd} z@snYF*#a0(a2ai;Yly7FU=BW^Y093M@Qd0BuDrJIndMcli+7neKw8svtkv=IO9(wE zldRHYB6VOKAg0>ERfPNprZasRZMk{HBemA|fGi&Qo{fwp5bM0!woRq%v-`csNm;|p zrp!@gx!yaemarK>1D7*xU#%dyIc>CUY-yV8=;or{tC~8rDZRw?&{noQhEwiCd`@X? zY<@u@^v!FCCE2D3Uvoo$eX^R+*Sd#ujuGB*80O9W(~Ooffw*(^E^5^i%jfT$H|`3T zYLRWRvS#nhUBBi>0^hjRj+9;T&n!;l`@T%r|M-O(z$!q$s~eeM`3h0#41w<;n%(Tr zbzLy4ilGF()ua#+mOn9rfW01T1+>qW)Hl5?SoY`|s1Mf&(jUMauwh%)Yi=5fEm*t0 z;n9UvDgeHhq z&W(5xsrC16;#irsO*|r*4qtyr?O;17K=l4FnYRA!;(^4FzSCg-8|2wbq-Mf+J`^w} zgD~h4nICa7A5C;R{Nod;cQ7m)gSiQB0R3RKwm;=-hbDc2T0?oG<(Cof~OA z&@B8N6`?;zouSydB%l{WA?}L_hY1^auR`e8B#rZW6G|(R`(7(SzU-yERvEL34OOq} zEz!lu%gqpW>5jrOZB)M3p*anNie6OU>(C4rJVS$G0X@+MDP}{ulyS}|(yQt-$|=xc z(S$Ky!syq0NsfedzNxLR;}R2XvXMu)*U6tr*CI9fC2@V~wMOYt~=A-vdv`}Bzx{nIDT z|G9X$S=+fXinu#inwyJS8M{fD+c}xLh}s#uy8g#-m!|pbi?4~vKb_8Q>OBV{ldl5F zr`B9E$Yht*qwg*&>`J8Pc}eG%8H3`=qRgeG$T=B83Gv1hsAX5+>2YMJnxMJuspw(g zQ~e`uJ`?|i{tNAw%gNfXzoAudM1OnX{h050nfJ%w&t;4tloR9Ijwt4LOfR;DUfQ~g zXn~>#6(1hd(-;G0lk~5x8*({|s6nSM;l`NXdpE56Z3q(#H<$3}OVPt;zgtc&YN@p9 zz{XW+SX%JqNaI_N!xEojSm7;ha|AV;A5W#`Pa{y3Wa)_!b3%D6MT|SbR_A6ZG_i*s z0h*|_zM$lC;*V+#vxd2K{;`=D(XpUB@7H-^?-b5%V&`Kevbe9OxF}dw~nzIGwI&Gcn zXych&R(<^ifJl%2Vejh)?tjrw8Z@LH`TI1I8)_jBH(KxF_u2V*q#J-OLX&Z=yg2Wg zRo1mGG43pVr{91eiQ&c~DE{Rg){)sW8EL6#2ZslGaUZdYjzX!2#%=9OM+W(gnML17 zyI6j&G&Dcn#c<_8Bwfq|nl`Uf!{zS-@P%v0_*|P%vhXJDOkER2*_E5Wfq21JQ$r@} z{aBLEtT?iG1V3S!Slx|wib7En6Ik(Jgb=&xbHU!cpBU(B&YAfgCuoEi&-?25RvRIY z(n$l0;;J_LxUW$WkDS5W*DT97UknV6g7xOEJ~6Q7-*LHq__>zVS1pM`L$j6l9;0#+ zzo3Zf1r-@%0D^q66gi;BH{MS<(LPn{jA=n9vm1(a+l+)oPzfoFe!QX; zuh`577yeNwe|)s|>X|sVF%0zFznRIZi;jTyNaG;8KIyEHt72n0fIAcw15`edG;T%3 zrq!#4WgWWeWfve4KrPUKdK4zP1rIES9 zwB@8&xV6jCda=b+gZ}3SvhbnXr~Kf{@nfUp^_D$n312^JBPO{mndl5y*WL}`El z;;DhVg}Qv!gXO$CE$NimF3%`ngIuy*i7)8h?>SGutsX;=Q3+l41erN~iLoLT8p?Bn zIWR!DW+Dv#=#)2${-o2m+s$_mwbsy7bH2-GQU7rAr=Oz^kMnAk8U4tOe;G)ok?sp7 z0=Qdxfgfef_@aJ#Q zbX4FpA&1bEE~U_3E?c6RM_HiD7%x zs%qSi;vrX4_|wWEvPI3Ux~*T=`OR2F`0lhA0*@$N`5E0XWUW{F=!->JWs+50YZ5lw z2bUBhRYyEB-Laa>f0C-AZXLo)h;I$Wn?2;fI>`6Ct*gJz+%NWwbGpM2$=~AU+Zr_= z9E@*A*e-4y>B*MW25QMFEbAgUU4%;yupZ+1BK+T4tO*!)sfP6lh{^PpM5K}Ma&jEVbS!=(Ee1WJY5(H9epwIquO@rk1Fn%07-e~&01SAC zoJU&PXJ@el!HVbPGJEQOV_Tk3hNrI)iaCnVf7ENr^9E`+G@foX^14yVPRGxc;#-aC_wi z0d9DK#ruNHk?%%QYQ|a>c4uDr*L%5eyPa0MWkrP8;!qg_Bx{XcLLaj@2o&9<0xthQ_?>`N{4S9)VoEbdI_R$06=a%>u2bGGDg zH5M?xK9)eZY?-%CH_mwxHoyP3d*7Rnf!jNUrA__AiYyuV|KBr-nVYy1BX&X6cRo0BOfW}OQ5`;DY(=j3I6)q)} znaI`L=(eTX!RYZr|F?mEg;C9-hsCOb<<{fi%3J93gkm1|Q8%nu;ry88li!HmVh~uQDf37?wzX9&m%o}CWv~D<;~$0 z8FMNUD}#1}CIk9Oo6MReI^Xd{4UXC6?y5xDsDA}`9LdsYQtl1dfc$`5b_&>13k?jS z^piOqw^Gj(8rLGqARKsiJJVy4DE6_}mPz`Jv178Rn0gJNstW*0M4iyagEq!SocmKl zcr)6)70G+O8IQSAS0{T>-V4J4^sf=2G`m}W*|E5IvjVS)Pe&CVI zA_+4&=jsodU@!8h8I>fV=O~cyG0Z2iT%HS6@nl6WyxivUWMh-*EFe4k?8WCI!mR-O zg(it*)ip$!e7;69C6sM?W{kZNj%*uHX6n-*ISpEFJm1WkgGDsR!3te$c#Se1+Tv?tV)Q8edbn>c=<5uIM4?zs)d-bS-5~=2`Uo zE!A}ge!3c~E=ly*YYnUEpRIgv!Vr`1!dM@HaIeW?_}60eRc9#h>umWZ@jOn_15X zZTIU$Sk=OKNO$DBK%8v)8Y=4B@h}pv`D!$nG5cIc0 zW%Hnnkr(q+Bf+UYS}xchH17gjE3g4&e%I)l;G+CCq(vId;+fjsA5pXFPjPvYj5`xf zm6s?y4}AxKOUWY3s#~rWae~lhLSZxDQt0r^Fo5R<&5xWBT*eW!H7DqQGyYX#3p&{0 zH)~|$E$c49Qzus^V^8B=b@OA{!a(6ARkzMp+?K-)zJxN3wbYL1=(@c1qA>|wHG=>G zvR$SfILTbfCa2Pw(pyYw7Hd8+HuQs?$nk-d9Vu?%TPc*5uTVkiplJDA+2Cv&KolDK zxReyS0os5QbQCwGMluy^Jr7^kf!-6pK$8}x7nSKQg#({NE}tQ}#!~iExGBaKUXZ9U zC)#k1+`5TzcAo?JP-{-xeBU~JN6yHmsYyau8qIGAlefaC2>NP0;{+AFgsf#-V@ulK z)KhoX=+ipee9!{Z-j}S`#n22&+v9p+uVS;6y)@9>W9;5>TsqtB@O~k*ca%v@y%g|% zIcM%)wRE;wptJS5P>ZdD44{b)b%;pwvD!p}O!Zbu2Ie6(X|8aVp7nRFgQ|v|OrYF{ zuY}Dows67QI081ec~(vz2AJ*0Y2Duu)+*y{kc-+Ms05si*65*4-UHvfor;BhPwavP z@(y<)ysk;`u26WcnO?5oY6nZ&uoH}liH4~j=6=0HDa>Ne_gtk?f#-Mm_{bUh8jCS~ z#lA?0F~&!NBnI6^aWs%MqOX#FrAGz#7;WyZ zNx8y$*cm|4->x2q!v4En&Oe~>C~WbLx(|+n#|OuO?f-*r$eX*lSev>Uny9)NySak? zj|fMaioPtqFb03hK(%%&o@w(|^RK5Qtp>mo8GW}DLReE6jd$A0WTKp&rMYFx_B0w@ zy1)}j6Y{I_Zr%pQiLR&dQNTp|(Rx;UphKX+=jA~SM2b{GHNJ_$u^DLVX}RcDCEw)S`QD65Zu8*Cc4bE;2c{@frxx0}>*Y6A?q2Yi@o-F_+X7 z_7~@o5(vf8xNoD;T{Phiq=YKPmNF{hu!6DkAPnMC3f&j%<$B3th0Q9a=w>zYRia^0 z@WRbZ4JTgBTS4| zHE1wxU|Aha7YvOk^eoPlb5>g3gVk{p6Gy{f{ar9J-9}0~E|{^QhgF0zLIE(YHJ*58 zm0UBE>tK}933G6m6j-*t{x2+-%FJTow2zIR@yCLM<=>2e|Ae;G>=oz#vCzX;`$_g0 zpIOZ7!a@u;e?0`GaC^bUL6zwWSR)A*MD4GGicjPZ(+=)AJq&al--ehNd6+a zBjI?eK5A~3jtgTZD`pY(n+BHCO>IIH2BwG=SnIr%YBGp`D?#JjW`4JPN>L_K zhR$*Bv+jUk{o}=173!fel#5$#H#2+>MqwM&zaJ2-hJZ>B(|O?=2g+FDQP_(Or~YiI z{?PB+@NGh$s4f4;9-kZ{Hc)SXf@aSUm54GFY~|B%+R`cUn^tsu6`JdXUi$vsiU$uK z*x1DuroCuvth2EU6m8F-$u|F{G;WO55}q8yQp=PH8#G!N<~(yAezf?eIeX4n04x7T zoB&kj@umxRFlU^3S&992vMUlt+A(A8ZQ1s_S41yews<=q;37jwNi!a*u-~F z@vzGpuLX8Bm~c{)wsVT^Y@hk6JTA8`NfE*!kDN)Gx=>qYr&k3~FlCf(UU<+S>2dMo zhz7hR&Cpa(se}(2ltJ6}E!7Q~+^j5zRpQ;j?J4(XG3$6Lq}Tp7cnMJ$EhSY6=jizy zqrohBtHFd3dr`027j*7^`+e02)az97$|oW_1oE{^Bd-Vo z!e(Ty=K3(#%6-;qO^m9xxN+Q0HB?sV0q2`fex#R%);%ErzmT{~0A$G-uej5fdSmn^ zF2FYTIz@Jwv@7y$6F!9j@y~)^6g{8*c>`EIuVD%L2ypBVc8>7B7v#cb9>xx)=4K+s zE+0dRi`#$1{6`sdMOVjp&zlG3*tN`H1tTgk=+s#w=%NO5l);z%64I#yQ>lfWTS#Y6 zb~ray#&zEyUsdd(Gd}Ha$3ix<0{6mjg@>8}9Zs&NR$U` zO}mor^VJ}NQW5tjZ3!+t*@H%Sd94;@thyFGX|WL^k@2zCB2S2-S6|&vS`W<`^>HXd zb1MTjrtg^jR>M5dXR~0Etg;=E*&465VJORzVEJXa9n^4DX?zUpGyDF0XfldEFJK;8 z3)OP;@Y!!)c;R_=g2`sWaCY~fqj-71ff@QDN#P$(CH9qY&m3iGL$TVLBMzE{(P~e0 zWKv$i#_IZVdHsEBA2qd91W`O0oV#PuSKk^A2b-7G#N@sx7`QsJjM3%yQzp3baqg(M}@`)0Hb^^d%md- zkA+F^4zu*-ph8dYcz=&gpJcH+m0`cJ<#NQRPyx)c`EwO+7s3e$fr;5@&?InmE(oD! z?IS|Z`+>Z86QhCKP|OOS3?eDQl~yOTvvoZxkR{WJLxR-wM`6;*6hW})A!B^J)NvB_%%IxrxVh=NS^~hh=sZ$`AQ(lM-A#T*?1}U>Jsbq7@ zkt!l;F&z69`P^cWvjvmj!|-}W6yCZ%IgbXc7j2v$kO8M1v2SmQQQvv>TrvGSc5&1E$(J`LEht_ zzuRgrQ-mb$%d7G`!A~ehjo&SRyzVz4Hl_$dlb*91UbtM{wponFNmEYkz@LUXW)H9> z;PGoERlWF;q#13yco_1V2QMy~$&z;~kRRYPcKsz}xZ?Ql^*+Zg5mst8Rpk@Np(*;@ z_$R&}KC1@D6e82!@WV$G4g^)yyS8vQXI49g#UX)V4sYx}&zwrntvE+pJ5cXLJns-+ zo)hYx6}kEYhqQIJ_IkF_dV;*)Xlt*wt^x0#4X*^d_7haqrwcLhD5;w{lGS?XK*a^2 z!iqwmCXH+o0dXW%wMeXqlmQHi?$4L~1JpD{(Von4e9yFF&Ea0^_|Of?X$?|il$%V! ztpk`Fzh75Z1hqy%18+kb0Dpw#$hy#NvK%e3j5^&u1_^m5^gPa4ETLIhr2aD~^_v&4 zc-CU`hZ{qPcEOV>M+m%#TVV6vq|mY9Kc`@#?{HcgAFLs&k6jwyzn6kV-Cf-r?f+Z$ z%~Q8kMc2d$P=Ir2pbLf&L)|N`429B{scarx6@h|BlnOGb$L?4m;G`XQ_}QWSOdW8Z z5n#=`JliW(Rg!0I`I5q8f9}hnR|-|W2U>A;*<8Cdm}(6Cd%I8ll~jv`%qkH@eI}9| zn9&)nv0XL3uB(s4w#FhGWYE$iW3xMwc1_S3z^zdUkK5T(Gc!8 z`LI9W)JvWv1kX0%;ZZlkO(#vZd3X67Tgs>={pY$^oLw|CXn7?Zoy%iS_sK;<)in>N z{pbTNuzGm7+g5RGiB;0f_iY?B--RPUmMM!=x?qb6l7{GO4%#a2)P;@w>IYlOcYu|a zb{#Nuf!|4>Rg2#5{L$B%HB{jJ+-nQc+5WCtZ~yCFjY02W#Ht?kY#Le3#SCgs)4M8`F6qJw@k{8;7LVdwBS=Zyw$WkkI+qwmERr}lXC&-) z7|LCjw|lr~iSjI@xC861&V%|tOeQ8qY|4c0U{*Ff_PG>KoPmtO%dB_8(F+V@q!~dUFYiXt|zH zM@MDPieVC`9J;e;mv904W)b4*O8c7AM@cZE|H3gpWLm|lnB#K|=A+EN$71*f89X4& z{ib$4X8q|h%{(4}*f@c*sK7S=!u_l;e}v$NYa!5@<_%PRiY{%QP>uXpWS}MA`BUE@ z$tFUj6uySt-u*?V!@~?!z-sNoV)om>6NMPk$YIl|l(H)2PzDR{h{5-L`n`sduA(Z$ z9Et*gT+nJfN0UM)0>~)74*Zw7D0%wxi0?3;;D|vT2H)}a-hkq~u{;Ofk z`w)i0SH}prFi$}o6`{s}qVrd7LNUOyjY%h2EN?1DY~o1M@vF?4WY{6O1a1ij&y?QMB~1?0y9jP_Qhv(4BM~7>9hg#ksVZ)NH$!d{c1#mPPVwhX&3siNGMWikvrPJ?_LW1w_J0f#9U%@hHc@O zZ9snc?#l4|PvNMV%*DoP<+INffCCMtPH*RpjFvq-H|a2w6p=v$puRji;?$U(X=ZN> zUNgG3#7&az_uZPHref)idNkeNKAnJ$*4k@N{yhgD;f^h<8M=zU((cpO|yuTN#Gll^#|Z*_Dcs z1FJp~|5TYVj2ann6i2%xW1lLf1YOd+n~sujBmy1vMk$6k^{6QWPVvE3f;H77LqP(_ z);QdTF)sBb3`@F`xnJv~dvhUA3M(y6DHQ#;P{Om7c!7TzO3pXku$vvbSwtWNx#TiX zzS$=DC>n^em7m0!#s`>%dgC!B4yAgM#nnHJ@Yg*ZD=GyRE@A1FT6W~_5{ax!rfOcp zoFM(0bOzv(Br@#D2d5?j2kM0yT8fe&&We5rfpyz-kSE4MOOoH#3}-yZI3OVkVri=v(%2yKlS6~8)w4bhb=OJjud39@ zRe7EGhFJ3)%A_tsnK;9)yv6fVM`R_B7l|ez}iolv1h=vA6 zBE6x)JPk+QDl&~Z3LO>fqP+vPyCK1j#l3>Vh;PsuPxR}%teR!FL=eytPqorRTOwaSb$}h2n-)$wXY$` zh(zviwK$x3?!KMOy1h6WU;1R>L=Q#GgYl&9RvcrzSYs`2V0h%Th;;k!`v|Ml&Q~-T2DB3 z%s_+{3c+G!QGAjjdmgrmt(CXWFWte?u-gA1z4t9wm29HKd;YN1HjT{ll4CIXkTBU+ z+*6+0k^W`y`-w96;Qbp$WU0wWpDx|#RmU>R-T6SZX5ii?pPP&CcM!V9xE|Zs`%eb9 zaATVASA=-3XIaD^a7Y(fYE^xAsitnIC;~E~0cN?WFFXUw-CyJRP0}C1s;-Cu{i%{Z z9;X}>iYOc6YljX~bNmUa72M$qJ+_MXZF^m?Yo0;MK5GJ}ly2xZ?(N2`7)z?sCQu@> zs#(=JCSW;qaeG)h*A_ysn2SZ_1(WQtr9wzmv8YohbqDb!gT)bbjBmKtv~N>RGGa9T z8|T^p%#8Se0BNrP`A|2>RJTd2zi6`mmITWN8E!Avlxa5AU`0|aiaxQsw)EWNXJ=?s z=vN@N)u5t4W0P;C@Doez3=YM31YPZmdC!Ci5%^!GmZ_>X+_R70a{lpKg#O*%;$UfK z{;zF-+L^*X{)`2+oi0{lRpdcAmA&vH!fqM#Y_T+Aq;!2$M#cQO7FPO(g{RCuuHWYZ zNOWk%sO#3t(rY>0b}E$X;fsk}?jygg_KD}W+gr^~DVZ@Mq9O5#K@x1ta?hBJBpe!H zJ@qv5!wmGY1}&4(O?ka~D08hf+v22O+JO=*X+l1>S>_kA;pI)|t1jV1yaGLaOYJ94 zt!9TKz4T>h&v1Q_$B}qe;b5m*9479Y?MUx6O!tbqLBa@Z0x*+SzlulsLA%D_V;JqE zoIYt;Nm|kL&EiDzQ4)3|hF7h3K|o9xrtv71j+02(=F|*|;(pF-6a(P~jE_@t(Yw?o zkaRG#h&F#2-F1>xxWL3}3)51cTBa|d82cOIz>H$ha_xlcH?r^BD@-uBcuQ$Q)`<9A zA&o#E^@gYDqA&Ry2x^a+D#E#}PDCZZ$xbc5V_)-~am4W1&o=dvH-!xvB!9&6Ns}98 z88R>n+T@7x7VaQ0EL6-qsFsTklt+qUH9<2R>f3llWjZdrhx{^*`F~n?mcmHe?O=f? z&+m>T#ou*pQs`+Cd$?5dAITk414}Fu2zSAwD$e*6q*Z>0ZTrB_3!)K^)JgD|RX+74 z&06V2VSOzc*k0{ZJU)-j6k~G_>q3&*MXnihQ;60Fozad_zAY>#>jixPbK%s+TH3#{ z{UJh$RcpY}K*rDahuuN5A9B1*a^xZbyhObA#u1X6e^J^q_#_DM!2f8ax&B|rwC?|#M*Gi;rvCt3^K<|o zQUY1;-6{JU85y#009~#9AS%)|bnyi!Y`8+OT`~@y()GasrT6~9#uXNcPE}2Zdu2!A zMvYs&26cDn(D(C+W%t}H9{t}o*r#&5`sMTD^doT4scT)RUo`GX-r3jTG{!G2E$>|f~4Ah2%-|e!zM>LfrzYH(CVG2 zx=T9vQRN1*EX03z%Y&T;PFGCMWptcz_v{6X?y}XS1SlQXqaWtE!82^uFqo{*yQ3Fi zr#|5<&H4t_V@76ctTYyHf@(Pgh0ZjR9Q!$fY=o!g9V;$p2-R(~rgPv05)2vG%CL}d zIN`xM`cSo97y$}7l4dlROANm$vQjPq{s{8Sq<-5;l`H>hR)hvSIU{rbTNw%I0&y&0 zOD4Nv8&cbGrc!RaArqe*wTcl(8~aQaNoRIosEgE=3bm!gfG5tnRG-nbH_5!g-((2K0)Vq)#;tQX1t(81#y5CAbvPdb`C4NRUR&Xdf&`@QPI- z8|ob=_e~>qmbk=LF){uzj9c{ z5IG(V(_wrqu&SVm<5niEIg2!t9cJjbhDuA7yg?WXs#!FsupDgBJ9j2MH9sqq-oStq z2j}M_YB)0Cg5PM%mwT*>ldR91E0zicQeLPh)NMQ?Ac7GW357_36q8X3i45;v4M%d&xRFY6vC_fecv&{7AY z?Cq03OeUkPWy^nQBS4%Rzl4;8yd@$wA{U{z!qNvKOI<&%GWVDgzZtR2Iu7r?NqP0& zJg)iv4FMn5Z6(>SBiB59+2cwQoOKMFkJ)YRM#Aq^E_5DxT&@qYd9Ta68~$@{Ryxec zQ2K=coBK~cp!YFi@9oWKowzx;euU}pRl|{Sjz)*T)|%eLK` z0tgHwW5)P_0F_Jr3tsmxU=0~FQv5Z9qoP{(q{R5=P@%2752OBY)8WQ7Qp=hnbn`5y zd|BV621sP%o^nIzGGYy|BHCYDZqo0geC`?7lI6g5LirQdfrDrr(UQ})*xwV+&6X$K zsIH!U@5%iBoN_~$IJCGw#{9R#1%~8Si%SmIa);b0SweN^a4GU^mt2@umdE`!#nDD- z(ct5N6r!2@Rd-MwC2qh7D2H#(cc4_iVLrfTp>SC4g~=c4+qm(9&qgm@*e4P|M75VI z6W(w1lRS=1zx70Bi0bZ00SRKxV{}UYWFkwQmv~wfgu1mdWnV=${*Yz1DMmIfm@oMa zk@-qI=Wj@p@ZXSAFxKWW;fu zv|%yxO!T}XylxUvdA4Vw`^0G)ISdXj9&4u0IzC0AU3x>D?8@sMtlIn9#- z1ySI3e~Ho$c{*o?tHCPYb*NmK;{#Q7d#}i#+O7;l$dBc?5tfTnOZKW%l9ZQ z4|s=9>93mHm30lp(&CX9PU+V6#PQLrlsco&{~+_pdYdc^iw}K@=&h4*~`}OWM)$Ny-@FA3dh9PM2v!eKf z*idJSEePkT?CU<```c3PR3AS!AgqV!B;$^jtH!Hh>r`5z+Fj{q7ZqACz^e@!6CKqZ zYVY%X4(GUaK-@F>5~Ki+$U*YSYpZlm(N=It@`?7f65WS0uIaDTfPPeWq{1BNxA*|Z z8DhDYl7V;3_idix6%VrB)BO&G$QQwsH@QIJu}?uQgLVVLvlpHtCN5~hJ*3F)4{q0Z zca*BnYPmCIp$VHP5}kAxCpg6iY#4>4xQfdmI{N-@sqr~fD_V0uP;!m^p0g}-!3*4k zT_b~V$S%n;NNTP`c0spUe=$`>9AdRp*5Oo_$?G{{@SmShAI5G(%2~geob!^>xj-t_ zkngX05Vmgti-6g<~|^8&R(+`-+OhZX|G$o@<}eYj3`AH=e6L zwT_BLw&)q($ke8qQTfG{A&g3dRApf~Om7h1gz6%3{jTL~4J zn__#Wi|S@&*|ll$r5jZYU`%N2@{8A1S-kMDn;AZG&I8|5{zwy}Ze=5qI2M-I12vb} zUKn%>-Mo9*W5Dkp#N=ie(Jfpa+}AO7nq4_;$H6$VJezcz=^ZptM5Q49B%-`!vCO?% z-|+!AtnAB%>6>RFN`mxZ*-eHj0G)SVGjyFwNhizQLpZQr+Ts{ly%3@~9Ee-pP1%f3 zpsXFlO)oqyyt;6RzYhqy9`U|3r|%oJ#v>U7BoOfR+c7$RekUX?fBj+zfnr%9&U!&p zr|x9_sW#kW#o4k!_i@R(Tx1gA+zSS%Zea}9EEXd0h5CY%dxDoRh!^in4=+!}S>;O@ zT?jcPvdaSVIX%y;(~SoxhW3jk{+AIVL?RKsYy!2Gwyc<6DWj~vH;fnP1X{Q)T=eYL zGUTJXa@`!MbFD%};r5AA9%Yh`PYZ{(4{T8c2M5mwf^7D7z(tYj9qnDs6oi6Dx4!b- z3Lc4SO7n~tr#>l;MhhGiJ5N8o_}0J4{*`Ce-TEUZaK*v61ve?P9K5Rh3GC81+8Q~Z zQ&!?a75vgTd@Z$0TEnls1^^qWq-r}m$W>X z6xULRy(~gN8}91b->(OZocXu#c(5;}m+ZC=cX6=M$M~urP6wT9*5nI^@J0hQlMDK$ zP!WDexasy(UnLzAPJLJw*b zSZxFf2K6L`#MP7yq(Q=Ss8}3{(+4rr%#xoZIH-u@*SoKHYdIHW20ynQ#N4}}a-L(r0OzbJ3)Jz6*0smfDK3NgE6Dfm!6_ z7U3XM2y0^YgfdN3dcaFFmJ9b4H@RxuBM?9`f%XLwWf?U1mnW#kuG%66MxD%^P^^9F zK|K=vo4YNp&hf*<&n@yN5IYlFE$$4V(wQXXGV1YGFy}8L8i{52UWFe_ zNWJmVf`LR=vyLGIqK)(uY*Om;#z7Iu^48vE*pC@b;Sg}09$>VuOdudy1LyB#lfseJ z=!+Rw@&97&9oX|;`*q!>aqZQDj;+itAJPi)(0Y&&UV+h${{F&Zc9=@{>vbIiH- z+G`!hew+4azQFIi|JRM{)Z5@JT^oCc!~fcp&+n*KRMU*oxVbHoMM(5wNAb@44hRFHk6eg`bTu=ra9;zt+PKw%V4Yz$;&i2xs3Dh537zVZ>k>XKx{fG2YO zmPzy-kl3ot`y|4A^e~UfN840$fBlO~UB}upG}d7Eo#T9$36*KE#4VMYhZ^ZO(3w#lOSu7|ALp>PxMm_y2CwPG%=z|V@gW< z9csu+)ax=R6o!tMq&85TUM8LSA)fb}Q*2Oj3+tVLTA{RIRXL!#&BzLCnKt(z)v7uj z+Uz_$xJaF$bh&dQjhb`eqs>T1yh*!(I7ZqFCJjb{Jo5s|zH9mRpar7XjU&})S}7+Q z%1g^}+TF;8J}kP}jsS3}t? zCS^@?1cgZ$OY?ArqL2DW)iZ>|7LvH<3p1@ru&d6jgYEXOmym!l?M&`b``%f|Yp${~ zHC5Zv?{nJ{63jh zo2lrPr)Q$o(qrMk{FW;RTQj6mhtIMpU35rm=d~;y8yryPl!arh`Z}#r`f;%t&xJHj zkgP1)X}!j~9sRCk5SAOsC1e!ECuL0s{pt*A8gTt9a~xcuW*qJ(JFJXkhBOR>!) zu~gI{Yywrlq1wBt34l2-azOcL(uN`??E_AOm|9kvq7o03{E{>k&Lv&QiUmK`#+#b+ z*-OMeM?Orv71>o8c`9xz{xJRtud-;LG+P zb?^3_GNGwa*^LW4C>Sa|P=rC#kgId%u@#zS``*cofskk8u1K*Y2pW2<I zM_6-A9awLNvoS;|tqRNJ8US{0aHfl1A2WRhRgYAI#Onj&e%g1>(6tj}%Q-X(r(Q?4 zXY7r#jH%Q~BZ)52@2n$nP63IgyqZdx@{V-(8l+aw9-49d4ATr2JW75TN}*1qTbb0P zXuppMFmI8^(u)y8jrP~&3DU~iiY1xzRP*UWS|=2d_I#?x$Opg0qwC7)^MOUlq|4*9 z`Ymk&uXELMI@*JJfGoh{o}EhykJxUG1_tGkDTlc2{%#ZA#;6@Rkg97#vZhcta~Zfk|>XhBR} z$*W!@+yHbSz_W~7nw(%s-VmeyD#golPd5HhYaN6kC{v>99c>mhQ(ry1PYQg3Pga|n z9UndUW?}0bp(8KccFShVL9{kkWEEZxV%~_!H%^+J) zTFx1AAF@bCdA=^EG5?+*%0^~pfrU$&pL*rE9dKYeSn%^)u{a}N^Y|R?Y(in?*{=le z3>r~UD62%7RBpy*YBp<9-qkn$d=88GLCh#x$(3*(%lUqLR0Mb^a5Ap5i(TX$dR!nI zdaP0lU9iR_n`68pF;;lbHWvvjozyv}mn*UWTEF2;fn6`66L7tWRMM&?t3e+>_T5fF zvJHJFtjEr)5d?oLIU3V+h1a;$Y7faS@S-n^ordFUQW3BTv9HNxX-&YGL(~@6n4^Ic zMrb|$SbxNFG9XKMH!UAAON1ceZ|i@(Cgb1{m9X`u&m*~H`0ZES)N|b&!mV0snm*J# z(vkWT`MP*l$?CD(;~n(+h&zx|V>75LS)RfIUC{xQw&UfQ=3t*o8ybFL*pa%fSY1eWabCwbYvRR$22*0_OD++ zzJ!mx4vS=dm7}xgK${}jiX1vhu73ISWUI1(^_p8zZx!UjmEpsZ;ggb|W9}`&EY-s< z9Xx$5mP^^TY2?}^oL=QpmdfOx*gp3&MB_`A|4%(*AVl@{9Yv1VR2Vq)R*UK?5enIj)H<6IPv2%9)*@e>pgD!mlF2< zY_tmSp)MV@K}dFLj`HluT5E)f?i4YO0FiTZ)mubI^W zt@qP4Ul=65P}KY))&IcYtjvssEm4E6&jywPzfO z^lR|l81)5LmIS1BUW`Ss2Y9+DFe(KXh-Pf}Au{1YvRY*wrWoDd{n7Lk*Ytao%yp-1 zsIu;gZq`mqkwT%{i~vmy!9F=wPR@>sK_MmL4ct|6QA>gD-OCC%TY-BLxQE9XRn3Zo zxIm`u#$!Qyt7+0ybsvSb_~Tk@0E>WuwgR>RRh81RquMW zrokUEEMJJ|N)jHDJn>}!Q_ZI}4DmM)P@V{;@|T)HVb}E5JbPl?sR6WS0-h>nUe0gD zxVH+@2av?$KBJc#I!9xDJZ!_65f5GaNqaQ+LU4;_;hxzf#Cg4yBsi*(`whwUg~=dT z9}Mq`-;3RYpm|591(zRO{=ws1XiCO)hTt4BMc5-OUwg?*yCx;>5DU@nvK>Q!0SXba zFVH$hnHW<)NL15j{JebLz;6vA{!lH(-XPK1Y+{#0T_)PvLc*G58*cG1i1u^ui`(ax z83d>!;Vv?MFms(#=7F|RuI-bG_H&m8rH?xrN5cZ?T6(tak`n#;)arwM)Am7PqytOB zZ!ewRK%thVD8a4V=CbPAb|%4rrWDK+Tx^?~%A4pbd_7Vx^A^Sd*P@4w*8G!el4wV% ztb3v>0Ta?4%ZE?JfiIfazQS>WK7M9P*w7Zmd|ua7-MbGsmhgyp=5DcpOw7hnNY2p? z((S~tF_)SVIlfSUcIb^A%ok+7fz|1#sguP%g_FjxoLL)KK{>GOz8lt_CtFd}odMNu z84D=JfzvV|Vve{*54V1#q6=wF&`U)g0{e7=b{uZ5QqXeE4UI=K=oy3qzKfn*mvHkd zLQWt*L+`mYH&RvTl=&m}XHX*t>vYOxu{t^e$oQW(i2D^nP zK8Sb6IIH=F=|kZso(YwU;OfUCwd>MfxrSo5OnUhnlzF%W4q(6+kK5`ZT+5VJuoZW7 zeB}!b!Vd}`Z*}l@Zp2!2;iG#itjtnk`FMZddQ^ni5qS{C(w zdi$n5XvfvjWR*?0Um*Y9{F$bjLFLhOl(gd|={lw?zxpI8ojKnuoP4ox{%f-`y8!__ zb6s=w7g_o<9F-E6WX!12IdN67tf3;T11LT;`51iv`2<;i7(k_ z+U`Io50EMMu226md;%DVFgbd}Y|0pH|2iV?BoMfVdZPCA(?85OQt)EM-__B&vY9?! zbtm^#{B?pJGrxud&9BkaI7@qC<_urh9@trAs|HefC?_d4Rq*CUZx&a~LoZ~-Jt~w* ztrwk#WOv9ToDkd*A6*cZ>aWvos9EcxyPKtCx?~E7>t|i#Ge5A4tecmTh^*U{7D4$2 zBC8${xoDI@?>B`FJ6H`<=8WU*=kwOKm`JK68w5TH@)xT+N0)w2ADlm_F8|a}+|pVv zo0X`1STwHcst7%%wwQtSa?(rlScDM!4nyrcF!|3*DKm!_Z~si1)UEn9#_J!&Af3`s$oPgrc~ zMI=%%frHQFx3qag_Gq~hOR@ghUIM{gDXGb=0FCgZBQBO;ic|yUadN7EiLbKxEcXM^ zd~C@sLCM@&fvFXLecHFu`QxNA1nLG$!B>wcnl@=O{=>6T+B)9WYZ)VaJxFqIe7)?1 zl^BMC!nT4j9h8eBnyVzTOE&2MHxK77#n@+v>O#p6GUEwk*{sq~mvoC$Y2=1{G9Lu< z89G%jOiwyOOc`~4OONU^|Ey5|jWg>Bd8bd@vE;WwTY7&o=cyG?Ri;{`YAnsJaT1tR zY>=m&(VtV+Sk2O4D(c)G$G!{swDJXeLPFUCHDT*Q5%M)&oy5I-QgWMOr9dDr`IPsE zvbVB`7Ori_OlOhNv6h~WxFp%+U()9 z55bv=MUHsnM-A^!XT(3Ke~?-r6}+OX9F|_b|Km#BA6v>I&4LtJOU>Yg#w*ynxB1fG4O0@cH+?$=lQY zx6KckA`Pfu5T;>pWI+&kO()0gi&WO?1z$@LIyCDS%Ci}gPRv?TvJGVxz0^M;r%%hR zXQMEs535xg@fa|EbLRE#GQy@!`^u4_qn1-~+**ZuoEBkd0jX1ynifAGA2dT;keJn^ z@Hr?0yIf0lbEGCA+3$;yNnN0#Gxc(;E!b=mU9n&G`XSSIa5#_4@DJONul2HX6}hq2 zIA2{Ag`zGNf7*WTAqs&2YHAD34cW zN(yT8Ng}Uxu$`51{H=;dEXg0(OLF z3QDBKn_^pnz4|nbIj?KZRLV!?lSZ+AM07*P^%dj%?rOQj`u}nl%u`DgpT?sdSKn{JydrBNk zOc+dsYrYdak47?vn`>_=7=p6ZFtLKKPj$FC8j)Ewk+yCs>Dtaet$t)Dai+yDjXU=G zg~+{}>FDbV!__rZf#jU0yy_rKSLX^I)8u=<{X zHIP}P8-W3&a<31{Kz4{^a}KhA29m1;LPG!s`ZB2 zkGptbsG^SkFm9sAuDlW)UXz%sJc72r@a}jO8l2x+tDL$vA02fLDfjU9CXG!dHbzcO z9)mYupEz89I=gBcn#1!2pFm}j_^QV!I{Hb!twbKOnZH|E!l9eRQxhlO$I3aJ3C-M4fQldHI&!~3fSlFy)fsipq(g~5f$cQeG}2pP zmIs@gg?QGuwtwY>es7Bw$l?GWn!|DTN#W$a@MvzN2VpanZpJq4g8&&(tbKsCMHCOQ z2&AcBXco9$w~$pE%2Zhp{PbgLWr7ckziU{%-MZiBpJ*GO5M2AvPi4mUFPp}CB#S#k z$!rTZgyC!p-#%~gGQ7leSN2JBOO54lp{#v*&iPcrBxeW@Ydh(dz;cZu5=9mM$~e=P znt_q$Rr%#KD9t|K-CqvOZ3ztflx__{WF)#LDyL z_cXxhoXMnO&huNv9EANm1Kx(i4ZRdU3t{uo@$>8s?tAV-zQcV6(0h%snh%q^&&WJm ziQ5{;e*L-H?9Ng(n7|7C+J*eOb|Y(ak*br$a@#VqvU2%v<(zD+2J=<0RpqRb-vD#9 zL)cz3o!r=2fV%pIN`?hE$Ub$a6qikx{e_-^p8lN)A@%{x!!dPc!hoo_06j&lfU(vy zB36LtGfS~;1$o~*WPd9(LCA78dZh7xb0H4JZTD7r1aEo&1i28iW#Pp>`XK$wg?PHo zpvP)m^fgaFiS7^%@Jj#DVi6;R1g^C?xm(nXf}Oe`gZRxBrp~odm;GzO*ARQOA$JS~ zMfv<=V5=QN5J6^=gLDrx*TnP^M*%?AxEXU@3WgrY`N%_&`AbYyJSPBa2cV&iq3=W; z4wsI6z0(RCQd@NR!>C$){;Rh(Wmu8pL^+FYlv%p8azD2?M1ySC?w()3_qD!2xg+5r zrf=MRu#kaV5=r}50`KoszuLy4vo!3%L;%EADf7}3bh>rabxG&yuOvxBCQ(den^oKu z`*5xNI`b#2?4HsUVOhig<0OqGEY78rDmFssZ`hL!IMA(@xGw7>)SMMM#JatqUUXjl z_Ko{g%@n_RdCL+h;u0Lxdf0XC3QRd0Sxzfph}AfxauO%wEDSwe#$xJ_ZHef2Q6UqD zNiaAkQTR7-`*qb(s>Q%#p&<1`*B)%4k~`6~Tg#E)BfAnj4L{*vM9 zj(uY5`iVvx?zF`aD5y3$SGb;$S2fo#n_#;>oAO{G7)PywJ7Jm9v$-zT)nTMqP`ojl zz?=F}HK)xnrE{YIQ1D*&SmtGk0Z$V=r^8IOECDP!E}y$gMXcQ!ghi#|S#;(9nq7W{ zk7t!MR4I2{gos8q;`SN6bpfG1ZYi=A)5d%t3$tpj)4d&JLrkXB=KO_beCI;0z&G2y zoZSn&DPQfk#dJI`#2ae;&Y&oFTGiUEqTTRWCCZ6jtXm+gWKRNWLZ zXA@0f+iz0`Z{xh2VmTZ+UTS8YEd8_IS-N+c1JEF#AC{n)5>uM#v%aN={IfcDN?=-@ z{p4K#NpaT~1?ye4;F+(Z?AGKK(7)L2Ly-sWs!P6whH+HQ1=k!obtGkucHWZlo?lZV z?-A2)AY@C#MI*5%xy9}B9<=3z=LL7n9n;=F6MSxL_>KdzAwq4$4m|3RO?PA4PEnrX z`{kT>z>q}v@Jxq(!fdqTd8oEw@b(wmo`UHNq7cUEL6H(5-j}?Fr&dS>BR>8?@u@!I zS(74G!{UyFN1rr69=n4DFqj&}54`i+)JHwd=mLUdxKnf?AGr^a70JH*CBuD(3Mbyq zmL5_RhG0EE^5~(`XNtg#+JP)^>!Gq`@_EDG)_zOe;ARhZJ}IKerbe2@bq|Y6=jlqm zz7ZXV#Op4&$vLTxMWkVzb z*$@e>iMUrDQTpKHCu`e7A`3t^M7VD_Sib(W@n66R?f%1t$khux$`OS-v{$pY-~3}k z^uQxia^RNSq5T#}2C^ZNm6QFkA&UR8Ay)itLkyGnV?(sdjT!1k>sxvlL^zQU-p8tY zu#3wf+VVUIVP9utakt-sEXH}b{ih8v{ErQB_Kyt_`~PV}#Q&!a(KbI@AXoB2r)S0W zpEkrlWf0p2BSER4HQ?93V+~;We_aDKH*wh>;M8Fu);^;lC#mNzCi%@%g;f>F)c2;F zB-(OVoJPKagMPryFuUd*dS$bYmld*(bB;rO&gQc{U9~>u!)8&lwt4}ugMHu+0u^X%^`R}d>9v}vN2Yn%lq-%NU_+%tOqbFF#L$2awEktc z6MH|Ri~)77B4Hg)MG=O$;56Fwz0dgd+xfz@YBo{*Ma>=;_CUM8g!(}&adg31d5P8a^SDZIJc&Q%#?{=zjcKw)+<+7wY5$GhG@I(trX2IDPM~o zxSGeA*1qB$727PcQh6s%;(wy_uemcX$%~e~72N^NB!Bxee!!PiAtd|kx7>ct_Hk+&}jBn1r z8XLzYo_m1dD;E0S0dMCFPiJa@IP8xpDQg30xlgCV+E3!KEK%=62PM}va6RzTDfNi)Y#3h(O#+Bdo zyo~83-oM-oxyES~e%KCD4~}Lz#<&&w*Rckd4XQPTuQTkeNomD6yNcwdFKamXoBxo` zE+*>pA4I?3N*cB0b?Ly*5wcv9@`A?&m}}$6t%a=`VQQb$+RVjarYVi6j_oIy{z?jp z$H%hrX-6i{1K=xZy|(tW5!I^*<#?7>S3em4o8%dQx1FN zF$@H}1ix!`-89R0Rehtn#6CJ_?*I1c-4J>>-iRd1$I1owMq@U7P6@nNIhZ}vM0tza zhPNOoWTs2pLsd>8>xy#>!bVPzw?-kZaPvdIc1u=g%s*o%0o4K1_I-&OS(ee@d^lt8 z>3o#IT{n3eHyTcKLIi{Sn8GENA%px*(;1_TUvzm?+YGh zsi1D=>RWB4T@!W0hr*PV!LL#aD)j4qrW7BZnt>YK&Hm;vHP(D+`nMa`wF97x!##tN z=kiHJ?A!HN=Jj`-aP%aC^)%ppliZ42(%P0UWgxK!Mem#aR@Is}a_BR>n}h{CrzvkBI4FSk7qD)E;V{VRyV&#C%MNSkT{L zb=)&9qWXPQZn%8<2TmR_(k)z}g{FVP{}u%Qp0G&kBoO=${vGgV{Vx~#KLdb)<@6SB zl{8H43>8T(ePO34sd4~OV+z9voOLh7`q$~@rPx=5f3bh(AMCH;zdta84I={Tm0hE7M|Z zYf|pY7OoNcCedZhXQ?s(qs^u|=jc!d20(c+N)g`~t;KINkL~*>T0?F}((Y%cJ3sq( z#<}@NFb^Z;i7^8r;zIPMK>?sxN5Ty2G_u%cZb5^76%82yi4Rc6Pl_=VupHPy^V@Pc z7923t6hk}_{L?CY%Nta%4WOK(|BL-r_Sn-zZF-)>du?3#rsUkNH2PdtY z{R~5p*<~Dyj1DPvrRj>=)Vg5ynbAoB;b-h6(O)-!CGg^R@&g?}m zjo#n4#UO*p+GptjIfSM-iVN4qEEFA%GhKd28zwysP`8J&jjUyJpk>H{<_$E}(pTT)0Zd^hS9#^>4D~i<&zHub*2b zZP?7YFnqs7`nV3{i_3Kukh?NLmI}C<;(wp{?*SkK6aXlJMMveccS(q~y-%P3Fkf}y zvBsxn%=;Jnzx~C2<*P6n;CB%F%`s#dS0)oO{{#RBPypEb697~g2d~$a|6)ITaM^*{ z&;ELNZdd6P<8^BoRaDP0l!r#8$vHHW<;`{yB>5t;)w;v=2Y~jC{Ep7@7p{dxiyd*3 zNIi>+J`On%eG_$V73$=O7;9yekftxCcUQtmK`cN~(+O*gi-2KuNdDVlP1e%3sp(Lh zca`pk5}ftJW4RK_0`uH=@?}Oc;8wQkM~}MCu2t23&ksr2K=Ez=wHHorZ|7MjvfnsX z2t7*d`Qjg^>YgbfateN#Q-S!8j>w3|jq|&!f2I~xf%%5z8wp{>HF5SB)B%1u_sSNq zckm$}sCf&&%IfT8nIV&7tn|Pd!1yIBPP|q$K|xY0;YE1#li6oUiYn@$%3b1=kzI7{ zj-VLMuw0b^Y;+7`*#Ux9`GCz0EMdn`b|0tTnLNDKG7jzH+QQ>=->{0KD(gv-F0IH-%8J$KI?8wv=J>0Bk_w9cDSfv`>dM64bKKBBB z{(A{lN+ZVLRS`3XU1BMOt?PrpA5rob z__d+#!1ElVHkL0GeMP(PzkW8Ul@aG;fB4vB{jitzp7yPeKuKu{_7y5`yRvI`pfa^KOegP0lyK0rUeVA{>>0n z*edn^cjt!<^wm$s^7r0Pm70zMt_0c}6fOrxh9f=L3WTJbDQ>S(C|MDOWw&!gUvaz< zgq8mEa(4Q_7*@0^{Udc3k+O$MQTf!FQ$d`hd+AdqA-abdK$1pwP3rSl zhtF(ZT#D+P8$C5iaUn24(F*DOvSdwxL7nA!;Kdz%nHde*Iyux{Q-C-F?_s)0nXgvo z7#VT!;%n87DvLat+MnbWPq;Q<@lH4kL!|;}B;W zWojPMS-WbfOFLU=heY12P`#@nE)TThf8jTJA&BiMLLgXN8wWpg)g#VF2Ol945ecvVz zj-D8N$r?%+F8TWS^x~E{5A#q>rw6jznON`jKmZlMwj|#{?OwRbswr`N|-dD!ueBU+nTi%9Q^d_3d z5YK+((z)3H!|HpW$FhxTbJsv}$!4se_??hBRc4kVNrrJ)a(nuoQ0c9fVqW0_W|D87 zCkxw6CM+#0oeSaO&n&>93|P(l#Z{njz?CwoD!Rka93$#Gl$TI!Dvo*_de_XY$yqjx z3$7ZxE0+4y8V2q!DwZfJp^2Ix0i+-yIcKbtL|c)C%-zegkn4&TqdK>f#c8L;$OL)S zjl+pzi&?p85e;0uh%f-+Nd*QGNJbuiYo41UhQv?WEPl;n`HQT8Ok&O{^vm(1h~_bE z=#fHKcQ@hSGgOs#5~^F&lm$L3ws>9C6j;F>j_0IXY(k@vde*DPXjO;C?9>^d|6PO0{@(_3>c0k4*bhZG{X>ayexc9D6y3pKK`NxsF1{ zo<b0~4U~9>20M3e}SO zd3Dg;tD7koJX%3$kx%L>r$rfJlHeGs2evS=K8p|kFj|k?O-_)4BA-V9i#$txFDGzlNUQLoxB8jbn3 z3dv7rsB$af5&P}C%m@}__B;}#*qE4|<`ByXpOAwTI$OJvh}OsN9C+)G8>aXAJ`=(r zhK;`xLQL_TQX843m~LCIJ^r4|oWzT5)y&rCO+(KT4($9#@nj~%W|IeoHCp(F86o~X z4w|*iz0tXgA;N;ETvoJ6d8+sc=6-2?xoaZTC%RI_*%SCt!Bu|jboa@iWbA`hq+h4< zAvZ;S8YixgZ_GVzWuM9;G_P*Qia5b?7D$Uj&`k#H*!Z>DXam;87kc6PX46)UjM2qH z49QC$lcx{;nM%tL`_2ki71lS_} z?AsBsYPiz1E=TIk!-^N}7Ot0!cIJyGfV2JMf1k`nlK?qiP&Z6B{J&wzobZ0Bn%u__q{fNq_D&a((WxN zIx%|M*|CsD0Gso2EjZC|d0rL+T{kybP@89nG5R`O^8$%E)&r8291MIRiGO(nD%Z#~ zgP$^`<)LiGB_?}DTfB2Z5ws`6Sz6WAd=jQQezm!~I*UuG8>ps3FTi2ohqH{8ReLWz zR0;}DO~sKoQXma5A~@kV9WsM>t$VLfjp^FS`L3C;WTs*Z0o~j_CC^x{=IE15lT-Z9 zaU4!v=^M2fhaK8ZfkGB{iNBeK&XV)G) z2FbQ8QEXuc?ly2{FhY>Tpe5Qcj6KU!x@t0x&}z`)@=~YrSnZsQcFl$T`<`P4uLEQO z+Fl;^a5pZYYJe-`8_L4y*o-C3hu#VPiYi{Y73*AYKS2iJ zHacC;9K*8svcN6zL9=n$!5!3?0*w(xJPq`yisld`EQ**lL9CZn!%LLA0xIW1!VrBS z8LRqgoa3%$_Oh+~0N43l2ozX>=bn59jW$^v$=k6S376s>Z@z3ds)^7^J&9B% z)cG#aOsxBN09OnvW)~|+ktPi1A+)JGu~;`)7P43ZA0iC%%c674q2_t&8iA&zQ}_hx)}sK**=pOplDmA_qyS#P6{+ZFXg)nEUZ^g^ zh#&WFI#3c~rn8geHhuZ{4Nh}wdh{l0Sh<`s65(w(Pa0EQLp1CDta~9C zwfp`%yz(=h6z9HV%CfiH5j-E*vh$I=!L<5v==Fk`X1JoX09L1;vdXnYb%s80U3Nk9^$bSJxz=4D|MqhHycT0{u z#2W&Wa#u(;DXEJynq%y)kT+-6Xp9XCbw6T{eiu^4@ZQ4ehYJ{<33g3Zv<~_Xn?|`# zV!KOV$z7{RGBN-)+8hg4XN!EX)UP{m{~&(g80EMW4coh(AB8Y`V;*Ej{Nm<)Mv(u2 zXE2;8Sbpdg?j!WzGE=fOrguvPv#DnP$~gMYz;nNtAk)AOXRKvAp?AdQc@&(Qhx{3g zSxP3y_aCLHf4)D!D8B0dJ1NKV|3Nv)WQg97{}bf~vCp`!WhH8N0}B}Ohu(*=2d~yL z6t%^1N#rQ*LFdAou0Cr{QyYTsZ~Gb_j5T3%VJG0cXkIJ~!`jH{Cuq6P)g80VbbhK$ zQE6(&x0#+>O?oJ?>KPBnd~@U;o7)l2PP%q<2fE*0U%w3aD}`X#Ezlr>C@08bAPb_L z*ax8yW79r@OB%?@>3hQYR%8rGv5#u#l%QSJ~HHQ`8i!6S0>#e&(-iK`}8Lo8=_{*mV=v7*(9{0BRu z&pnV}9+AW^j_)Y)mi);TlAPEfzeI1^IUrfX;sR^~B+R?YB4rQ4SaV zoMLqC3RrRE5ZcBJN%|M%tdKwbO}V>2loN>*((MIN4&sgF59O%;MY%SFm5Kro4Yl=B!gs|2nbpjKO}4l4qd&_I-nxySyCaseTh*Cc;a4$1&Tx!UTJKa?Ap z{!hx8tU9@mU6qP+%IYJ7C^x9)MKoFEtDF<9K;1mTPxfp`C zZi^`5xcd5-?PT4V)^!wo_b&Q~NW&LNo0#RV@;)(m*}>X+=qbelu5OwQ)U273?jdOz z9B?d3aOW)jG00%}m#Uq6=`Y3Pvm38=*m$3krFg&D%VxODkH|aOvF9im25ivTh@DU1 z8VI}-bn%oPu2+J3^jF=NCg&M@$-G6gN~J*7{mVrPq~7P)=Q|uypaS`z8-tWEc~sNU^@~|R|kDLgXxC$20Lne`x54!5F;r5 zj<~TH>Lam7?4={pMa&}is|v}%G4&t@-{LRIH8%&k=Io&HGqsSQyj)vfb zhiCt$oD)nM>H5d>Edk4xS|zfPJ}7B7KbAj~)9Bajxqs09i*n&0$`wc5&E1p)*^#`s z*_;XFKLB#ZK$P1DQH~hGWu{;&QV&QSwx(jg`-gHX_lqbp3x89NAB*i7M7iSs4dsTy z($oHJ+aI8BIhOz0{+O~u1#N#&xYRFeN!rh{y#T0->$BqIWOIuU{rgjm>d{~&!biz* zR#nWe%<%r~f6RB~D#KUNdlHAGE>6+?^=UPD|hbC6+3&vW|#e$H6B zQ;C;>>PI7iLn+2o^RU8MTIQ%N9ryx{7|ToJ=MTlXv$HkKncY(GCBSLRkns{F43lQ1 z@+Qnz@uEql7NnBth=}Ac}QfB}avA-Fr!I1EYKzg~h(ZjHpnN z8-5dYq?%Px4W2rr;Nn0v$-fj9{n6XpoHE8Kb^4Uj(za};!}B=b_ALoP!nMwY^Sn}`OA(|rsiY`)0ID=~s@;HF7LQc44(ooP4j2ry|`Yw_#<5kXl!`ate#KkzYEL8telNx4UiX z98kqVi2oU~_LD#%%be4z;qQ=j-u8bPvXB`P+C^97Kq0FyE!?Jde2=4Y{Lu#Vz{7Ye z{OZkrQD7AI#h=g;pBX#E0b3AE{1^N0%qs#4o5@O^XWt|p&neUF-j!a?_gys0R~@c5 zfN_oeWR_%M!HoHErNH1-%zk^F?1ff+1&&yME*C=NqG0%f0oDL`)Gl$t1FqJK$ih4O z(PZ2O6?0dBdv6^18BI0zty5bVCMaZ$e7e?UOTB5f?L@z1wT;K1d?)Bbu=^jkjh%3? z4}AWOA&d3@@c9ANqn)7)&%7`!9It4FF2k82qgnrTc7h@S+uZBOV9VN^rvP6f`xwT> z%l@`#_kobRk1N{~ReH=qq;OPTb`D`LNFmcz@e$I z$eny>U3zlvD{b2eHlz%#W7D|agchS!v2~@}u`+|XySDDIin2aKEJLimZZ;tD=10g;v?<2R$|{GZwyd><)9!j=l&x5* zc`C!0^i!j}fOsB?lGFCIjEb_M0Iq0TNW0QM&099s;ySerV+)i3lX^8+P8aB(bO4`0zTSe}BZ#~FtvWb{wgkoi+uS^ec+RA~>Y6|ZIRZ<}s z=&~%CtAdx`$yc+-bYdjfTMe%Rrcot0t9L{kY+qHLq91+)`~r}My2xoGkocEkF@$fU z*7eN+`EY3|j;{bB&z(az%94 z;TX4Nb0T=o*wbI>>fDBZBNEv;+D3{R#QI20`wL5d1ifmNekT5YHNP2@3g-{PCJy!v zhqtv#7t{Z$@MNVNpO@k@y%T$ll=#ZSPzo+zkQcC;b z1UvM@QX=aVaIb!YjZSeiF{{3}(SEgXb8$5m=&>B{~WG7RS1dxhLDi)SAjZa{(~3>=c^)IAPSl$@c>R zgJ2^BCFbj{Tk~8~^rC%}FE0Lej)Rp9$)UwpV$W_CfucH@`a2^6>d(?XkM9BV0y495 z+zI3^gaS_=V|xXK+@rDynH6TQhK@FSR-dLX!ub5`tdBTF%`7_Zz~m9<*6P4*P%#T1 zATCa~I!d_2kYuZ21}K}`VK+}HiYtIB?5(2F3=V$h1#-7%bKWm#_*11WacHVKd(UVC zK-8s6dV(F^^bFI}$RrseId=}J(OhV}S4|-pt=7wG6su@^L}I5xoo<^oQhH2|2r~EO z&#?DO6pIKuuj#3@4+NXTx-lx}Jj_zfI!Sdt9&b^XazQzzRqG;XIlJ&_Hxdn*j5CZu z-3%URUM-OfknwGB)vscYn_|x!+D2uq;)Zl%RW0y=W`rHcb7&u_h$t+NZ~k%Z_Ro_F zyZXs@RkRNu-WmT5*KR>yAFO{Ly-aAsyQwZTylJqonwvRZlcBP-17K2RC7~2irI7tU zpx{Z8eUkhUk~l&fl{Vg=CPid*_4>7FVMSfBj)q;J7Cnhf61}wX?nZl6r)6Qor%{{c ztiz_trlU%o+4am@K17NouHU2U;sD;s<@bj5+1<<1in-hV+@byld&G`CQ8;CEO;t@p zQo3-UN5n9tP%9ig{^vE~r;aAGswkPRJ zY&DgZXFr4Rq#F6=abS)Gs&r!6EFr3{O8h!YvoNQiexcZ)=%CliuXh9P@&S$&&&qFxQQ@{Mk0{b z+8Dos`|zEaI|4BkCi=puQ_H`kN3be>9u`~>7xqw%rN4Tv;#BwsFYp_OWtv28vEx)-0n)u| z%-<^`gGse3Y=<7{TqC!<{UZiQG#vv;UD)fiQeq^w;7PTI%y?6n?I!%un-zPc(HAtu zhPf==47lb;^OQFg+cFcsh}!dRp$1jBpY16-Qx!MC8PA6`tPoqv^lj%>2M4iOtIflj zq9Bk5Tg{^B3a8p9SUswDZ4fhw5N&#(<4$)2>n>L&73rp{hrYp@Q;O4gvbBrtej!Au z>Z0H52dta9%nF=bMG&(3bl#4%D2$k~zYlb$U%;XNa z!IIIGk3sc+FixnY!HfA}Z(6?ak>vTJms4#m5?}XqFvuS^#Q5Z_S;6hjNBG726u)=mCddMsX5CrbB;7^A9$Uyw^(!im z=FzCEB;r;P#PVL^4Lh{`mV!(@-lzcj7p`o@%fbkLH>>$#GcImu2UVm7tZj+_w;#Lv z;*)`FUYH8B%N0h1+1vU&JqN|^(8<7XI#?(-7roD$^>oP5mc(5QGX>ggwa;p|`LXY| z=qTgzU!|?mYrZgt0?7`N0>J62GS8zR1O69j?-X5$|7H7D#je<>*tRRyj&0j^Qn77& z$F^;w;-q5R&dsm?-KV?H7=6#V;+s4F#Do(r(kQQplH|6U1I>kAPs9uBiGj@nN#gzX zZ!*1?%5NzxzYZfSY|lEyO~tiPK67_so=}8Le=eZe%p2cw_SuXA(rA=EC#<(dUe#G0 zA1noHJZSrBb7oq)L%K`861_xgBKBsv2JgKM^yTM#w0l1hVUU%6K-{a)nrZ3o0xFk= znDxN<6s~2x0CH^&dc`{RVca%s`L;*OK!t_^awRV+|Gz+y!AulB5EK-o8<=twi3xyV z8MY*vCXIoV;STz(V85mgX@(@siZuA(yZDG4%g&q;IeCOS2epP0gH(snPvs(~F$Rn3 zZ^T_wVQP|vEXDhb>Qu&x2;CW%KmZ?AQVI;PvtqMw;9N7_&A(W+ko!6NEtwcPOb& zN?h|LgZ}(Wmat5~OaV)VV)WF`YGR<&4fPfmBwCzUozczlS2S_*r995%pQz?}d1_e(1a>_!7vontUx%9J-D=c#zW}c`@cQoi;3M~qu zn5%&@Q#8UnOi)U>EZ_L-b^C4NTMB6+ZGs|)pa^-EEOnqEJu0n~Lw^=YDHDyg7V2C{ zx(cOJoFrcy4wevSJ$+*`I%H)Yc94b0H!YsvCSZMMRi);pIFzI4d5+A*!EcHpt}Zz_ z-aPAu2SccRju*LS1#@?d6xzHzeB@?Fjp6v{cW_0`W`caQ ziQ1sU?%cWkd`kszOfyZ@6VWsEvOd~O$uz#r_OIVjk6FOUjoaOoMA9l20ok#2oJ)#r zBiEf_NLew&P^FNGX~Qfywy5mqVVRsBT=Jq!cypd!MozZ2-7o<2m=eeLnqMCK zEPK}d`sXUuP3(qjQ6aE<*MQDjGHhHsu$Nz{j_KaNm8)+zGlE5W>_3!I)<7F}DH%R| ztr23W65@V*ur||japYyy2g}nH;~h**F+2?XB@Rp0<&r_+;TA?qCN2`SmhUy3G9mP* zOY#0RsgIQnS``NZ(yAR$KNwN9m~Wj4yZ%Zxl^SMy%Ha7SQG77{af`Dwr*7Cr3tM$? zzSqI1EhDOvE`n<*KhGklHOs;@R!?4FFE_oXh3g@Zrs0N7&1ip6^LP(-Ndbaz8c98P zF4ggs9=D)TKm;@Fm4{@qZY0MkQmjLuGwljBm848WHau&^RDjhrn=TecHD}X0x(Q=1 zf(s*;8AhfM4bM39RIN}+YL#Y_x;dEztA8LE3wo6CLwB$B{6W&CQ zLuzvQLlkXwazixUCvx9rg{n@xYhmkknKIVi%@q+6Chc#O|D>`IkPR+B4fVVS#I z3&`OjIgOthODnTT(N}-s|_}4MaS@|WA&j;p0o1v8k{pU{To$4A9at7ECH&ljE9WL z$?eF+VBmY|kvlpHZD%SCu=sIj;+-R+cxtEAKvu?lN{f>mC@J=4QU_2gV=Px^U!2o)Jzz?DOjL|Gu=3%z?j}gdH5Ysz6~hWo@R!BFBu$tnB_UH-_slNj&p9(g|7T1I;gW>I|*W|W~ z*A&m1WYSLRTCBXy?ocE0)>b3NA7O5#{Fr0Max7z`vG)p8@H^&yvPCBpOrpjTwH(7( zBwW}l#)_3XEKCtc;0Bd|sc^#FW=0Jj|2BbfhIeedX7-gUS(qPJnkKY|CNs!NG>;R& zNulJYQ}odSzO?|J?bB|g(-a2Sb4@}yx3+$RT15OPXvLsK8xydyK{Tm**bawj*E|;S zq|Zk~NICbRDCHe-S*c@k?axkH0WBX_Ik;=VgPh&iO51g5$+Yn#sJiH9%kx8mNGNQG z@|r5D=rzv7O>tyksg0^mS#`oZ9BwR)vQ(5(t?Qt4KxJ$!Eh;IMrX6%MN-B^s*N3Dl zq+9K4$_fQv4{&uvmI+DWzAAUG1aWtQzjPeS9Thz(zM_DlT&~Dvu?`E}jIxL98~_MI zctTDZIIaPNDEdg)&jO9BhbM>HT6=e!+LHLz(}hnnp4T-wo@@`dx zg>n!w6{a7HH=##7j9H;D*8hr2%a%bBDV)Royyf@$jt-9}=CLIhQObzDZxAWQbkf>7 zI(z@YW1WnZB}IK(#p0>QH3%qvhWA>upX;qv$8mPn$mQ^(<8_^i&A59I;maSNj>Q0; z?1}mfM}oN@J7z*t)2~+td0KCE1h)5ZJnH(FSZ+>J-#f;g$2bIU4juQtH)Z?rhla}& zwcPJ?FFocK_AvspY=-mj_bT*RH!(Q6fdjuhsaKIiFrv3DntFFDo_F08`23cx9XrT# zx`!aGZ1kt0Lin$6=kbgfxCflCpn&$-SrG(w(r)16*Hl86F<2BjyB1d{2OrD_zKODK zc$&}dtUkSm9YJ_*kTE;#UUQmmal`!k*QZZ-Ic{*aTg_fO?QS^3&$o})u}as(t1`OV zvG%*1FH{?YT~tGcPR(#_=F76W1)j^hwsx@_Qo4uUw&NPN@L0g%T_1irl&HO<3@3WA zF$8?aMmE{y(NX&N8fYxg36<^e0rQfr7Jn!3ccKMIj-B9Xli%AN~Z>NC)I3) z2RoPVPqC*jnL1uXVv7|MrL^yuTQ4@p#feWWVXW|c0CCfl{VV0hJn=*Ku zGYVR=axuEC76gak?dxXw`EyE3DVUxrla6wSw5HS&-WI(#o!4Cw&`L$^xCWmbrAV`8 zuog^+T_oNM6FF&5Z8|>Ww|BILTq4o2Zz5@u?SWA~-ie#kQu$hPCC?!Od&N)($&GGP zT5@+Bz|x%;GgT^A(wD7pV8=-lvt^hv$sAN<&-rq+p3GD{-U@qJm{`<0=XNcfGQV3F z`iG^ptZhNtZL`P&c#|1a>{8%U=8$A;sNPn*oU|zg>ym2`?x3t5Jl>a+26_AzY@ixE zUOSsqW#w$#HpkX>&L6m8ed(v+^_%V|i_-F|nY&1`roAa%ZW5*1G~D={_A%v;yo*~y z55<>X(>;ppMe@_Xwxtn1_M(3YM|}6zPNm|m1UMK_9iCcA9-buoJtu~dCI+D;Mq!T$ zph*9M-6s;Hb5g{&DGY0gx~a`tcMAn>f6Ma2Fo)17V7?}s9MEKe9X4b8kQq!AaHPmF znPC|NroE+vOeIJ2!C=@36;*U$Ek`>V-%k#FzX-rD!net8ZyiK;X3YBTZ1=ae*PCX4 zt1G4TmF0nT8IF%#c@ z;`;9GxE1!>BZ-wilVm{;Gm4p(B1U21Sj_rll7o#n>S6Mq5C z#srlYMU7%d@w^5dI?*_f1K(}ZTOJr%C>p*Ylo=_hg-ST_0q2PlYkm@Y6 z)i?j*Vhmy|kYg(?T}Q|vv@8~B6B7yF1)GX=Eq!E4s>Wqv{YP9F5jzD(f-0jGOsHWk@E4)nWu;8Q;5vcPFKLi*HUE+;F z7sV$GFAGg)WS-smt2dlEjnHVgaM2X-21SeG|1HLNPV2YaVDR~ZsDJ)%c@8QBKjU-1 z>_Y2bb|KOKpI76Y|7jIf(R7>_Mg3swva%9x{qtLhq9|{`9(nKgD4Ig3-_B4gs*s@I zno_L@k=1w=4J4qNzo7pI1${@N{sbWHc&1tA6F~5?O3B-LW2peJfJzRIv=wHl_Hj6hge8d%b?dB^uol|2lvcn2vl)eW1N`5I2}!G zt4xMdOhI4#UXc;oUhiN{6X;qKW7zEv$}wVR@CwBw*rHw?>#XbI;J0lA3?jnv!9a@c z@4G^Ru@nzmBD5f7 zTVdSal8#WF3q?W4ftNv;hPN2au`%D|be>CHjc%!es$ZODUMY-egpsJ&coQ%39cBvZ zcSw4L6kfj;(is^l+!oDslA+pL_tS|wYE?YUlwm9(fHq#EGA(GFttb(BBVVfU0&|$5 ze+3WCv{6f-*)&4K8)zbHj@HT;Nf;SkhF!2~ildS+;&*RrrwhD||QH|I01ZCdKZ;bXG0?4=zCaO8VB;bh=)XEi%X+ z&pcWd6+k33^VfpixS=e=J;Xb17JaG+Kgl%I!a$=J&IIdW6T_L;GHEHYBec{OBfwER zW9IL#oL&0d(J*GHR|%?Eg#q9>!kBOt1xsIKj}aPb?;|2hBN*0LpV3F2GHwPSf7K)LWa$1k+T1! zQZif3h@+X;@AyEu*?HptWdhu>Z}HsO55_?E4=YcRGAzp@CIU-!i>Xn~PLuv{#yK;^ zBzKtgalrP1%|YDN_JfK8du5~Xw{Q^VvK5UYg(j;kCWaPx{z_RCB9hD^6v|>Yb0Q(eU# zgF5ibD=Ieg03+Yv=;VkQ$r$Z=#072dDPp4gSsZ1P*;R}Q12#pND;^x+pC6U`!X^$U zoCmt((2V!qEzU#k+jq5*Or|U6InUFnqwJ6y!nhZHiCp zs}o>AZ*mYh&AAc=Ikpf-Yd;tR_{s9IAegOjl{{aRRegOkW?CuPs7CD|d z{9fID-MP%F`r`cvqYAFRj>7p9W2C$s`uG9y0rmYa3VYQtU)q?^`!vI!p5mCE_n7TR z-fRBr_!UB_Q!aOjgg+8>Ilb}#dG^T>x1DHX`s@}ASuJ2?l#8(OsuzXL)6&KB>|46^=FVm8#D4fAa=N$Ve-S>)RMv89V&DMM%Z+p9s0m z04PC`a1ik8_xz;cF=8?e10rmp#;08WS=;%IFEg`A#)`TDifKeUaG+r5%14RnonuO!Wq7^Rkm-vpC=AXMiNrA=#aBM< z4I)M4so22+Ajd);06YQX!Dl%>qbjmk2eg88G-5Wt6{}fQv|?P9axHFMFVYUL?>X!E zezQvzz|;u6AAa0<3vZ$4*S}N1Jt>Q-OAor>pa2+}<{Ftieuty85u<#Oh12)g;~969 zG0=;sTnV^TAbh9HQUlUr20=LCi?NZdU5Fe?)d+&>BCfZ|6Y%qS2Ox$J*yquSZXybJ zRH9q+D{46CNmUXTnDF4OmxsD~Zxz;cI|?pSQ0J7sd6&nLuy9kr34vs0^I?8JT4>R^e02>bdbr(o{B|i z53^@|ig)ZfU~9P5I+~Be?ejVpbG5(NjB&%319vA3W`0d8eTa6kWe14tFS2V> zbNA}IJR46ymD0HlGM#c)N)Wj}vViy<1mmZN$+hn;5nS&#xQrK4uaFv7u%5ffbSCf= z+ECQUw3~1=Mg;STKSyajuHUML5SLaF6Bq|jeb`@swb_1YY(rK!#Gp&G9tT^ZIFBZ9 z_Tyvc$*xoB?4ZqSEv^Qk?J5_Yz|U9UM^6KPpcpVSq#}s(2d5%maF9u$NCkawow@5) z7^P}HGEzB2?E}VtngRZyW}v$lzR`c#bjTt8->7)bc1HS6#t!rf#*TKjHje)up%|s~ zu81s;%1ceqKEuogxGkq)24NkoVp8Z_(4zR1zVYM(W(6{GQW%g2u$K~Kg$AUguY_jI4 zgt+FJRZF|5RlJNnGR@a;ByYE~_z1J%rIoMF)v;wQGa1^k@$gMCY1z4yTl5I$Om*2qep1@U;A@VW8`co zvg#-#{@}#%9HE$B0s7`QC^lXp1JAs^*YkHqc<9xAig$86jN3syForM>Ec4u7du(}Oa9V+ir7ls$Z$LL%) z9rFWsEa%;o-mLFV&|2Y@t0}DSj?t2GzrzH=ADfyMZj~~O{Q28{V%rt#@>F#Q}%0UPJ+Pja=ok)hyb7Z ziym)-A?kzdVs3Uap_6p)m_avwMZ0cecj^HvDsuR+`@h_$|5JGCVqB$Qh~K_R{rvWg z`hR?W{-f}e|5bqhdUvwaq1|vz-F^7AnUjZ?#!}9fb=GQWE2A4v2Wm;lqpikk{j0?5 zP)6-D<6KB8J6AU*rl?e+oAMe0*=7P*qC7#Y8GwF65*`+VH+-4Am1>ywEV_T**H z#$CyXA~%TEgce8(a!`Vzav4srzl0Dlmp<2T)YC`}k)&HbZb*oPt|~Jxx6TPrsE3yN z8FjKH=xi64n`g3kanK4~k|0GBC?~S0Gp375Q<>%+=$WG5>W7A|lCM!SIw=a76^0Vc zZQu75N={smofR!sf1G;GX>(VzYqC>Xn9F0`jwLpurBp*rB)C{^pXA3nW?@M`{u29% zs-^1~bZ08Xmb;E5n&x-!Kd3ma9Wb0lXp7IbV*#2$V>N`{dyq3-G(1|UC}AAno~1pE z;CyL&Y>B2N)AUw);I7oQC@5@SW|swE+fFONP--H9pnQ{~Iw+xyLeF28^jssBnL`OQ zp*G%10aI3-uOtu2Y!JgH6Np%~VQ0mU;jF!Cu}!x7$}|FY*bQOreobMl$ua}yh0R^a zYc)d8J6yeW1~$AEV5`P?63|M=T*pde7e(vPtetCp_dj={@+wX(B$~DIQWlPJXdCqC zWfk*#6YEOhFqi3T8c-#MBk)hm+3kPT7%tq-r2)+n8?eQ8HqBgW;<$txIc7aC;U61Z zT<@Fio$v2LF1vH)p@*k9uuve6uccC`?U^?XayJAaxlDf&5imm>lbsPtHu$zlJVdS9 zR6D9JN+57fI!UaOv?QjQ0S+-KNa$$&F zs`v|ZVY(C~|N2QP){P)&=QI2lFRs-arvSu`>Av?TV~bt$@1|tnx<@7=r>e9(xnuI~ zL-pFS0+&lP9q`>U9Yt!v4@L13CcIfws{2wx-+D_~6+F1{S*{RZyTkq>)6jQCfs^mL z^?K@hO-LA$P6-A>^x%OXe%XM0-W5{(JnOF*5KX*bh6y2+Ss{y z&RP&!S7`DeWUkG8DrwWEJV;KL4L|CTuVZN57(W7=NPYvY<*Hza z=tkJ)`r04&Wf`NiK{j1r+!ZyU<%T_j2qCIrgDAA#O#-Gpd-2i6)|RW)16b3^QO@b&%7)wH}Ra}fEKw|^NR$q@A*!9 z8g<(}JWZEMS&rXuTODm7LRpf32zz~gb#rxYZMA%v-*ns1EgNu*r#!V9PW5b0)SVjt z0fCaE^q#=$Kw};=JA!dVDRLhqRe>o--S_JrTME!I@Cy?#NTxLRt_e%O%S?hZeRopG zE>?*r=l2mF)IBf4O0i*aaf`0gr}C%qy)%e@N2+YEE^w{N5xkX5*U=IZ@D`AJp+_ZR zyEwnTy{*KC1-n+i>R;pQ(pmSUO5>%hzJA2pV5@6sTOC#no&Iw4;bqGhHx|rnZc4$2 zOOC#?nzao!ZQ9X?e}XMr;?%S`z>YcTCnxZbQ_m|8@>U<@D-S{gqqXT;(cBndEYe6v ziSjwN#l5_=q%HoT|3{QHsCuPMTlwWu?a9?ErOA})>dcLaSdG_IUX|?SM{-NX&SvkK z9>)-gV3dM7JFeY~goM<64!TcnVY;uP=HFf+&zN)D^E3AAe$JrQp@A5%`!O5ErgV$q z7W#}(b7D9MpINJqwZ2aZHdxp(f+Mbl*1rR?hg(kPi@lU&S zT>+FuCg;p}GBFB}rm1OEDirgEK9klgMOsD3cn9Chqas)E@eWuUftdbLou!OoB66vs zu1Sq1{gNUB$ck>Gt8z}w^q#SNfNgk7?OAU{A3f-#pkx}DIyEUGzKfDp(&WIz1LI-E z>q1H?^DRDCLGnH&waK9OHmi{vFPwtyqRuUo`D_&G2F9d1fQp7f$u%BO;EyJjuP?M%q-X(1iNG!{vm%vEcmqMJuB-Wq7lmz&c!VI6&lPI9lzwKNg zB=d14=&U|y)~J(q%PPfO%qrAVjoWkEJ0$1_pb$>cJb4J6(bdt6w= zU0JO;HPb#nkE$12pU)kpg$H%%=Yq}ed(VusJ2n2P3aKd~9*Z7>Im9hAgsZEZc+Mg`(aFb{iOrtzcu@>20w>priP zklTLQW%qTJ^*o?%;Qf-Ze;La(*=~yRG=ALy%J*Z*HGY1`TQ+=S8mz7ZsPo zlt~pP+PFl|y{|hPM?%5N?y9LYVCF5cfvo4UW-M(SffBoeEpdZ8w#^?Q|{d$AE5)V%Pe z%Eu+iYfmbO$ZWT5Ny9+YVL0f{PwLBMCAkVwW(w$z$+%hDf(np%Pqs^xq8dPq^8=}O ztE@F@x_;G!aH#g0z9&I9Rdj?J{fHgbY&2qjYJiFVgF#YGFRr2+Td|n}h)J{`HysOg zg=BKElR3{JC@ny|AB?<3%(+}33Vg2wM>?3-)og^iWo?g_g0rX4bD%yP1Ey9`_U*~@ z$(oZP`JS!=?!=FkJfPeAzqeAdL!H*dgs|W zkTHch$C1}=<602qwKo-PcNtlt*eWzRk#;r22Y-q0L-p_=oAw7V;M< z-7~TpHSeWFBsL|&Q9lwyo!t>+ zGy4=|c<|Ei@a~bgPOO~W%@GJXf5S?n{Xxr<-Md=i>{vFkdt>iiuJ}UA%ooof$p;i2 zUag2Xx+CNiEa!NCSG}FB2JOe(3r>vvzhU?B1FxK1D+P z;V6H1%SoJPe3T0Fc}ac!8G!4SCh&ko(?_i)@ELOLZZ(WC6G>ld;Dx3A1jG2CJaENI za^(y>vfbkIN&O+#@37Kr8+rCPdV%t|*#_6BIWK3EB4H|F;Yv_U#PoFb%50J{vuDveEGk~MTo4eQ@q zS=*=3Z^Ek{!E=aQW!)8NRkR|D>umWMz_pFvYd#l>uDqal;w=kHF4 zChY;Pk1Fj~>|`dW=y7O%C<^JY2f>nPH+MK#CAu+BNI!N#D(pdjLRhy^olw#`HOtO* ze)Z6~SQ52C5h-;TeWYYYCGbcs)}n|jl1(KyZzL&Hrt-qTnv(cB^0#IfaAa^(#vBUq z8p$L!^(n!&_^@v5aU-;{6$z`vMh#?YiXKH3qIwGl8A+e=tXZ%44$UKgA<308uxz~@W^0`k>&_C% zHLRHW@i%(&9#Ipcmd6!udHBvn{`vO0^!J<1RypbmqGFy|XhDv?-Mohp7W{ZA z&f^lh`ybPzoLaoK9K%p!$0QPD@iK99H*oBJV|s?g=PC3@mnN?Q{UvsBKDlZeBIlLD6$ZyioIyRdgiNcJY-f@ z4}SG+HI@$T7zxN<8%zab8cjZRjv&-2LtzooFK!M)rgs=#z$&UeRPj>%AO@-Wa!m)5 zFQ#4b7;GysOA$h-8J#|0&=Ol`7zc@^sOmP>M03$ax@U-0OCZ^y)YE3h0COt674&us{_G?fBXO2@HW4@7@GzlDC4>JFSKNBFm(f$l$$A z1EkdmGZ$XY#}nLAw*^IE%Zij!EdHucFEfoN<8$=KVvT~ zTB05)DF2>&8DWlzE|U6z!(Z-#)_uhlETnN!RRJEOgMWDD3>-o4Yr{XbjTz4(Kp6K{ z+6O0ytZhpPIA(cLFM0&99Or8`Vxx`M2jNno`St@5bSUvp=}`A#6)iDg;+#3EFhmok zT#g>$Cua*TM5G9uv78Cp62=(h9x6{%MlFI?N|c)=u4g5t@tKp?vEc)Tn*$)=HOtN* zbdu;-`_jZ-4BMDo^HzcXAdlGiTP_IaQ z2x&R-vfIutu9=H#O5I*K7zD&Qs;ez;RP&$N`*V4YhmgTQ4g!#6+=XXvx;7AjGw?DJiQrCC~uCBfXY#OTdn-ca< z?@UKONSqwWNwt!QxegT@=0^|Qk2iGu5=Ape2;lS^MQ}R~ zYhW9kA@9-2dQAP{#pa(J-}e{D-<-=Z5SS)?E$enaE?T#HJ4?Odnf#+w&0o(MAcI-NAk>2=!p)Uz7_^vPf2twEQHjYg6gHKWgXHXN#oJq#YiuB9 zGvfyIB{Y@;TU&{>=#(IaGZsnbha_$B+%Akg$;3c<2_6} zRZ@%(Ii_|&lBBJ128fZ7(^zdyYX5`ise`?xn5;fT>Vwt>+P4U zMH9KB*ilhA!YSl6KS`7V++u{!xP8uSHceVIIOMgjvt&%S1N*}T>CI>}wFD5wKiE0x z^phVfW??(b!t`5aG}WnXA`a*sJA$Z5G^)mXEU1{U!K+~6UClssm81r#9H?5t1<>5l zgs4Q7A|v`NamN`3TqMU3D&;92n)68B)vep+Hc8b3fEX<}*E;?1r!xKIG1nm4gM6x^ z8Rx)VTkbq^4wN0It+Q65MIqVRqiC^wCY_qoP16x|9csIL4pd|CRn%a=HCpUl!CKKA z_!lLN3?B!Wy`n(N>8xb~-SiakK$FJZnq!^EKn-b=2^bj&9as?d#QGf<)H4Ra2R)~= zJ)qkwZOS(N18nuCq-E~gik5-90VGO>?3tW_7&120H*df?Z6C~aMbFO&^YD30-40-9BLx!K78rezNTld9!M7~?P&r8RXF?BV;&RE6J zz-B!)F&OhW`Sdim$h5iE9C;tN3H#vu9`=EITA6L`QC0wBuUf);X!VcM0#ut3ssxp` zuqffbNy0fA9)Wc}AN2$|ijVKm&K7+mHO~b1LD??^9;eRITJ?n=#49)q)#cp*E2VS_ z$ypbIwP%17h@mML`_;N6I;U}!b`HxGuGZkuSlfk@4eullewpPT=I8^rTkJbi-C2^n zL+gYJ$ekH7Qib9yIiuo*5_dXm)U)Pco2sZ`PGRUu%K7!kv?uEC6s~GJKb%DP$MC)V zi-Z6?Jm?(K0pHK&7 z_}lCSI2xf6wH)ihFYc{uCu`;mBssw0fy(B_^;?P{2kAHPdl2amZ*o{*qs21S$(HKu zZ)TV&3p3$_o?PwF;>axN{>>Gq-Q+ZN*ZU4E`WE2+fGuXJAkfi5+qKw1WvVM9V}y$g z)b>=RH&quUHH^%`g)E!O$|l{WuIT`fT`UCp4N;M#e>h*NELiFUvVSMa11M{ z$SZf7hY$6tGA@pJwqOe1MtDaD@hAenv74YpU&k<%cm5az-aQgCQTa(%N?nSP;Q)p%Hc zS!wKt8B_|m;CP&P;0i+$^^}>)xm~d!T@tr%CVsS4 zwTZ?Oi`H=Q9vy9GLCUv^>^e9YVVcsGFijY~`72H!xJ-9KPvPSZ z#A3pTF;g%tnzE$m*9NCW^2u#>jDlI5BJ~eL$n$%E)6DEL8;xJj-8wB7i>iM-{#kFz z-HAgRE}F!;vYl){$OUXBHyuag0Jz`BQEe{TKzk2`Vym)HE}gzfRBNBgSk0xR14&eC zgAMIb6Xjvi;O}-DE)YEO9Jz3jLh#^^esy5vZIKR}iVB!)VgeP4+V+(^nuMyX?bOM$ z!oi>D?F(w7olh8tyinBzB=s=5QPRQXC1vI8)I^y(f^5MR$c?Gkm0%bX@CASwjdc>= zl?9V(&HNCp;*bh2!s|ZScWbT;)-y^eSKI(S=a5w8o=guJo6xVjrrL=cOeB0F0$XAN zCHHs^h5he=9qfS}$xmspzTQJh49FO~fA( z^2AItaR%!ws;5X5*YAEZ;Lg^E&(ho0(aM;l!6pOFOy!fDFz^x>ZlT$n?L=Ii8+f`f4jt62UGbjW9k8YnvHJ30Ob z11Cx8ROKI@p^P|IDgvS){aro~n-w}mAlx8J!&-4Q7$FZaT=ha3qSsu^XUiA?W@^VbJH&vX9Imv^+^q*gIRA%vjru%-+X3^+wD znmp&>N9OFcpvRXa9pvXMaROG=VI=W7Z{F=mS0r@wOmO7fOmy^enXeCi(L=bk3@{1$ zMlq242(ldg%1esvP1T76P-`*DX4P}HUz)I6m|7pQ$J zrBK?s0cM#}votEB@m6NgZA*`wr>6@X*&`2ug|5Hv5QFAqe|a8}>5KDYUV$wuJR> z*5KJsb{_0(RP{y{_B z!E7kZNLw3QHk`>U86MNf?Ww=lJoGZShYjJ?q%XrHYw%I|Ce=oCu{fDe-ZK=fpz7BPO`V2J0qHZaQs}Xm$(vz41x+@u4%! zPB*fLUwCPr(WX2etfohA>U&;WhBa9^QZP{&Bu}(RByuG^?48;O8jMH8cc;0R!l?8D zTM%leP2$q3l=8&|6P@3GOmurnItNw`({{aIekfwsK;XxE=_9W9P{m4^GQIXswEv3F zcz5109WmW3Z`AEYV2}v0{~a;C)dJ~uXi5Wr*S(G1Pyh7vW5W9t*~MO;Afj42hjGT4 zKzHJ7cl=L0dgrB5waqjWT;sb4k z2USg`{8JG50l{`Wq4*0nMuLfwBqb@V`@fKK{;AWTiFaS;ujz#W`r9|2|HJh1ztyRL zk&C{Kp|O#i`M+NzWvTp2%$TZMnH&}bMFpdImuo=eL${1TLtFzk0O3(HBx!vXCJ$0+ zR?bXZ*a41*09)KnhmJDe+-7IdV?FL@Rh=i4H{w++@W1uY3k^LD0P%-2&&@~ooJY<} z&Jcb!-)FQQbEVzb8KB@IqF*1w{+~OZ>XcShddN0wG$}v%#ybtYm9DNH&}pXkwP!6o zun)Pm14tWEZKjUj0%?u7)TxF?SW{KlmcceSGV;^%zZEmF)T0T*Q^Q+=!5UJe9J^{v zILPQ})H4Y!u$!#+tg-Pe_mCLdj=4-GZ(C4~j)DD=;RwOs=PQpymS|VC=wGS|bn|j7 zpOtMO>>G$F$1aS_aPZY-a&bbG@63({so?@%^O;Bdq5HSD<39ha{Q@q$!=1~1M~}1s z@pe=0M}lK2N#@rrg}7(93{T|*Zga@UxhM9QA96siw!-5Q#hR1(Eio4Ez{A__7{U5r zA~u3tVELUg-Zc%Fq`QD%3P=u?1TzX0oVsJ|s4%^W_>d!Zp_K$)bZ|}WRQlUf(Y>;QZ7GCAdnPR0WP2#U z9FvxmJEmj3nlh9!ZENWksoee6qZ@)FQccA%gZknRRa(he_*$ieOmg6h(W7>ljCD{V zz5J4A?Z8#TaPt^5SK*DD57L|6gH+~AK{q#j++SC6G>PVe zD}JQ0o9-kW88vVI)q!A48^SK!GJAAZRUy^cY2}veW{bU_YxE3fS`WF_Dy+ylC=o{) z3O0RRlx;n!QEg+|q}$d3YDsA{Tk_b)e4dYl2`6SweeNBMcq!gue7rt>QO9nkLl?*) z_8?|nj;UOWPK#+2qbSi@r(EeU7gfnHaglr=LR}c}s5dubE+l1*_Ym%m>Z z8o09sw!w?M;j-Yi^N?qnyh%bclag5@K5c2lIH)W>Eo^Z|sAh3yTEahPiIaTPECi_S zM%4Eup32@5s>8Ew+2wNh#nI*$1EmltOawj7vgkrXq)&V=J@SOvj2)&PWT#Z;`w4a4 zjIV&fk^@_slWUV3vCV!*8~ur3?F9-7_ZT_|>MjXvE`Y;vi_+(sBUtIFN#!ON-6b+{ z&^T6Qvy+URuGq~Y$X);YQg1DZk8*&epS@aeN;h$|_)t`y#rsN@cJDKn}y z0t_^pADWjSF;yF-F6!SMMK4hUQhSUPa}s?-%O~7s34AvOcxghw916QPZiGBMuHImcc1!LtzMbJMh6Sseg zx+)N8kv0_=SV_px+p9V?XXGT)Z-w>{u`k>=St>)~j% zM{$Ovg__~Gi;JQ34ucJk83duqL(M`34LMrjnK88H>*}V+9~vkz1+n_;BEcg@-qD1R z3r%sk6&O`&;JovhxGd8=sF5wYm~Jy=FsaIj9={-4&a z1RTom3!_aUyX=Y<`>qhmnq3i!BFi*1W^6Nrs0kCwzLxBRNVYbi5+T_oL}g9dEG620 z`QJ~rnPyav|L5_{JRbLb?>YC}bI<+ma%|rSUMkYlJdZPbe|+R*zQ}fdqz7u3M6H+Sfk25(d-CdI3)wwsX zUvOiZNwp2#K9?I>_vxuGCcDrzyK-0{3Ykq+go3u@r(3Frqca>csK4HFV99~f~lE^L`CMDbXuw6m?ZfH7Rt=x(2QMAJWo`+*#+Tew>YlvEd&g(EVvY{<2n9J7T zmG1GB!u0RL5?6N}$PA zUAjR|@d1O6NwSi(PPpfJ1vO%k*;G+c4BG`cc4ek$;#?j#Jj0p0#-*OyNkOwn_UY+{x~O-%qqD?>y-F=EVzcn-CJ{T2nB-s8 z2YIW7Kt|aag`uJBOOe@+HOwr!`Xq09UVG+d^J4OKPuQ60uw|BEhR5gdM;mZ($@du# zo`2IwZ{hcA{^G6)cXW$(6-YZH@vv}uO{ri_d(>e;YTd1g8Q5{7c=Sz?*t{|0qb~&? zb$2B2qy0swt$BTQYabo|S?;98K74ykuU`$vaqGi!Z%Q%^8$}tfbR!}sEIAI0>(`3s zN_K{8@qIaY4sUE&*BEp@D?ql>!#71^|9SncTQ0St8HZT#A$up{Q@DjsG%DvkwTh?Q zQ!B6CDUrP)Wmw0mV)CAbNLc-(k(zd!Pn0~zSGm1o4&^=^qAx!ww~sj-J}2Zo8e#c^ zfvTLD(U09c$TO8c%(0yAOFOMG-q^T}q1H70W-Yg4q3t)Gfy@b1wWRY_ z$LDZe9jp7Id_}~;_T-scIWMhKKj$B#ui8XiSf;C%_EZJOTHYkd;y|C5syFJpJ;y5e z4Ihn}99@nse|rz_kq2j1U+yy=G#PR6DG3>kKFG@BXx<~BGDK;pcjAySd~7hZRAZ#6 z+gU74qSYdPzjtm(W{fxArqR4u3zpux(X89vL9M>==_WsvvRc&`YZ(SyWyewIao&es zeBLMbp#5lNPEmAzaXs?dzogNqPb9fw*N?6DUS+$4r_@OERTi9Def0;e-|N@o>-egz zf0SE?Pczmazdbrhe?6#=Q?#Mgq#BcOW18~j16ti1mo)buFSEGK@bjTkU2BUSE3<|x zx^KrRYX7(Cg9uRuvk8Qdh*eR`JBI3it~U}`pQ>JBz;=(*jqbGOuwM%k+uZw_Qz zFD7Q1w{`EPNHaZ#OrdKxD06chbN0M#9Oh)(S?20lC95KzVH7r_n$o`R+Ta&|MT@l; zT~B>{u&2&7_ccmQ3i&W@ecfH3Cgs<=!Ov)axDRxJBGXuHYN~;zwmLf_?g`3NBB=Y4 z3j%Qubup7Q#a#dH+r+qR3F=-sx-qRSuibmO9v@Wh6@r_Uypz$$GU%Cvvu~t>n}1d8 z6hp;Na&vTLqNRJo1iMWi%02{#YJf=ejmwMUHQD*?IBY+4k%24k_I7i}Z`5xmr1HH?lXr;Rl6!kBcDqtfofBA>`V@&hvq+=spn8FGX*UA1uoB;c={RYnYM~RJgo5ft-Y!?}lba7HhGl~gk%Ml>xKs2@#pV;=Dqr0jl%CxAtZCQK(B|Nn5JtKT z=?kOh!uLCgJhx=oNU&H6Fhn@1Q>wAtfPdC&cM%*{<|bz012 z8wNyO8_sLu9`rkjd{NMg3I23LbVk0(F(LX3W99v=g4Z_~jC{t$`N~N6TH7Z$w2saA zGi%0Cbih1#)qR>o!3l%X_5(qb zcoj<71NS}4L^|Vu|LQF%gDP-&-RTeatz}s}vmex92RlB7mhz#zR7xb#O5EjZuiUvV zHp=xzy_#a`(aTgM%qsFJ;-l)vH)d9Ra;GmbeHRF`I&{$8E>xdW?tJ45S&iylJUu2w z#h={PH;f`i4mRbK;^dumqmze!DhP>P;rw(UiWuu6z(&}$peImBJl&x3f<5uWD z!^fSyC99FiRgzBGO-;ePuMey%6*e*-#yud>m0dlk{i>^ zV5YK9|0`8Nv{RuUx55$1voE+FMeE&(-b<6;W>qE;c?L6rptj-Yd{-f=XCz#==Y>}7 z$L&w=7K!8i&XxwEaJ?shLUn(9kt zmOU{NjHdV0;@!AbJ?_*N>OFSwE1TY9&yuWD^p;;K`GmQ?Xy5TSis4&@I?j+LYSd>? zu35H?bD#4d4}W5@(>QB&Z3)&hI7$4}8PN|cw5haVR1?A%-)+-*(YCq1G+$d$wNxcw zv@WuX@yF+)`>8qFKZ7@%ln6>n)95%}F(usL@L`AE8n*WlKMHEj`g?G4`(DxLo>?;8 zg^ryK-mh4z0*)Tr{l&|AQ|RT?+%mmcj}Nmc!%Wg}aUwc0f~l~(n~DlaL)S?~=jO;j zed7o%OPU}m>Iu3B$+R<(>Sgu!|MIUGjSrls|HAa~O?H7_%IO&S>t%FErWU_9&#t>4 z_mlaQcrp6Nf6dOPsk#C!ZO|3j6$M+)r0%shvu zB+Dv?8Xfs@)pS_I!Hf~(LJM_T#A>shNgD#{eqKmUe6pbpGrhNpnYm_g#B4kOx1&{E z>b3*5V1S!$O7@GlFb0ku>5xEhlw-A`IQ_ZSa12ccqU%g{eB>)(n$^OvXq_&lmJn(A zr^0qByBxb3u&%<-?CRr17HU;H*9J2LS+1uK zk@Ilq3O;&J_wJFn>hg*XkN%rMN_;vv_SV946Q*TDyt!Tb#9S5juxK%TXI0ARr`Sl7 zcRe>auUu-!H7<9LN?972TUI-nOnW3nfSAQTwhbQ@Kc^w}&ZnEOKbCwYAkuy!!G7IqIsE}Dn}&BD zC|tJlYvT*;7f`UFr@5lT_FVY&ok14=@nc?UiGOABY=mKxu{$Rvrf?yfbnd4QY@?JE zIZUUpALjpL+j)M*uFmbznbCbOWj>s533sGh)3DzxPBhUwEa9+Bgzin#ul$wN( zMxab)Zfx|vBn6t?7ieYJJ6iX9o|#c&_~Ew(o)raLGA#G^d|cGt6|=R+%=!EHmGRnt0n;gRfc1{xeo!$-ER9>xoi=f^JR`p$rr0ZJ;CRp6#tYHErp;Dn1$ zQ;l+gP?7`ug1;_2cr5hQK=Yugn2w>Agn_D|j+Um$L2(1EUkJcwQ&Y?$EEEA>KwLWa zFi?C@H2~ii5SA8$UUts^BlHQ@CgvWf7#e(c!_3w67Y}g(a9PEY0>MyCg!?~0Nl?y;z9`8;bzmMa6nc3m zUV>VkAPeONbH%{im)8=Zgjr;<++l7W;56)Vjk|@QTK#{5LLrxzQj7p@GdQ*mY=wlv z=4uAP*}2&qN84f$2*>4xCR8ATEcEj5_WJ7xzLsEiDK2ks5}?v-O}ib%K>X$%RrB6nJbd_$z5e%^9gW zWUKnlb~fmR4Uxp?UXoYZ_yLX;*jov$BhEi~Y!&#+pV5CE-PA!Hcm5U05#w8K&r}rv z)7wBGvdla<_{3x7K!P^X_;dY21Lg!n!}Oufs|ocK(gb?u&H6|{U<2R?)ssX22Lclq z`cLSr^%MDhpm}(KG6<`04_bEF>i^QrY!fU~{ooP{UWgzG1kKwC^b@;@9Kmd*Rrl-6 zCru#Mx$sT50}cT56;KNy<{i#IVoH-%vsZhVoE*Sx2AF~b^@IVxLRt|E^;}5rKwMLU zv5l8ofc0Gm;|^g>`}qEhO1h>GZr!9E16BNKM?tZbpr(@of5QA75$d@?nRoUsO|~@; zipOpP!y#$e$+#+Y$Ze_3x-DxDDQl_O)Sq(+2A3)aqTMa|j(HC-3n^gP!2A{-Jaz*Z zY4vr~H4RNPmllF1VC~-Jm~mDLio?KK_bn8G`kS-)3hTOS;VP)5q)<&)yH+dR2V`7e zARu&Y50MlUE>0M@BZ}CyK|3~;j;p*E21d0N7}Yj{%3ej0tL&^%9rSXUTN7Pc706rK zhcYoU0gDm@8dD&EWOpS8IX`$B{~AP1&@daMfsLJmBML^YiK7Co`{6JoZrN3Q1?PUX$rzUD3?Xw16Le5yweFn? zX}~wlUu?K2ZR6z1Xyq5uvns zaOuZ@FCN4%a)g`|lz;9vhDi6ho4nL)=5I417E7M%YilnUuIoT65{AuV8~;FIGCMys zaJDBO_4||tGeTyW|6A10&G@(I1~w=g`21yL#MKX3_ae$3L=N_VgfK2&#YT=`ev0=i zFn2S?pinlpPGsT;It=W{1Zei1V2~FlXo3>1iN_oP>0~SkTZmpe+dJ zr%$E+je)cTc~8vXTLJbaV5zDE5|qmR6aM^eJ;LSKrL6*q!<$Qe$PC!5AZUJQJK+3- z$0{rS6P-H79qo);SEl z3pA-X&jdd4*lP8EBKx(Zy|6=wSg59}B^>Vn&j-M>mw@M!)<5!~;7;J(!m?6XQS4do z0RsFz^29z;N%vpaez!cx$#)1-+=~KoL;w#XN+1ixadPNO-|LX~zDlm}=@fuG^;-!4 z96=8GFQYC=l2I3A+5Oga0i-7@eXc7cl_p6F#g`9)&Lc=qR$h3nkQ7W-NQ}-9sjc`S zhoxC*=i3gC?eJY8>Fi{M3~m3YkckY*n9CWvLfYB45M*dO?9UpIe)+LJaD}w9GY2w3 zJaK~aMJ0~~ubg%v-T6`n1GO!2#^FWUr(ypOS`tczd;xJj)kRuV)XHf|zJ7yfi8CB7 z(wfBoA83E~dyu0bzMXuLls0c=q>FczFKt#xP~zKg7daV<|AOnJfY@kNS@$l)b_LY@nBNjC%Wr3#C{&ma8>nBaP#HXniLC2pi4@z`qgGB#^?1T6e$Rd8ivwy;$k21+d zNW>eo7n$HM|B31M?b=IQ0uqXN=i(yOoxy*iBDIfkDH+s*i8mH4lARk_o@~iBqy2ho U) exemptBlocks = new HashSet<>(); @@ -162,18 +165,9 @@ public void onEnable() { new ThreadPoolExecutor.CallerRunsPolicy()); asyncExecutorManager = new AsyncExecutorManager(threadPool); - if (isPluginEnabled("LightAPI")) { - try { - Class.forName("ru.beykerykt.lightapi.utils.Debug"); - hookMessage("LightAPI"); - lightapi = true; - lightManager = new LightManager(this); - } catch (ClassNotFoundException ignored) { - } - } - if (!lightapi) { - lightManager = ILightManager.DUMMY_INSTANCE; - } + lightapi = false; + craftEngineLight = false; + lightManager = ILightManager.DUMMY_INSTANCE; if (isPluginEnabled("OpenInv")) { hookMessage("OpenInv"); openinv = true; @@ -191,7 +185,16 @@ public void onEnable() { } loadConfig(); - if (CustomContentManager.initialize(this).contains("craftengine")) { + boolean craftEngineHooked = CustomContentManager.initialize(this).contains("craftengine"); + if (isPluginEnabled("CraftEngine")) { + ILightManager craftEngineManager = LightManager.createCraftEngine(this, lightUpdatePeriod).orElse(null); + if (craftEngineManager != null) { + lightManager = craftEngineManager; + craftEngineLight = true; + craftEngineHooked = true; + } + } + if (craftEngineHooked) { hookMessage("CraftEngine"); } @@ -255,6 +258,10 @@ public void onEnable() { @Override public void onDisable() { shutdownPerformanceScenes(); + if (lightManager != null) { + lightManager.shutdown(); + lightManager = ILightManager.DUMMY_INSTANCE; + } CustomContentManager.shutdown(); if (preferenceManager != null) { preferenceManager.close(); @@ -330,6 +337,9 @@ public void loadConfig() { hideIfObstructed = getConfiguration().getBoolean("Settings.HideIfViewObstructed"); lightUpdatePeriod = getConfiguration().getInt("LightUpdate.Period"); + if (lightManager != null) { + lightManager.setUpdatePeriod(lightUpdatePeriod); + } updaterEnabled = getConfiguration().getBoolean("Options.Updater"); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java new file mode 100644 index 00000000..d16757d7 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java @@ -0,0 +1,514 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration.craftengine; + +import com.loohp.interactionvisualizer.objectholders.ILightManager; +import com.loohp.interactionvisualizer.objectholders.LightType; +import com.loohp.interactionvisualizer.scheduler.ScheduledTask; +import com.loohp.interactionvisualizer.scheduler.Scheduler; +import net.momirealms.craftengine.bukkit.api.BukkitAdaptor; +import net.momirealms.craftengine.bukkit.entity.furniture.behavior.GlowingFurnitureBehaviorTemplate; +import net.momirealms.craftengine.bukkit.util.BlockStateUtils; +import net.momirealms.craftengine.bukkit.world.BukkitWorld; +import net.momirealms.craftengine.core.block.BlockKeys; +import net.momirealms.craftengine.core.block.BlockStateWrapper; +import net.momirealms.craftengine.core.block.UpdateFlags; +import net.momirealms.craftengine.core.entity.furniture.behavior.FurnitureLightData; +import net.momirealms.craftengine.core.world.BlockPos; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; +import org.bukkit.event.world.WorldUnloadEvent; +import org.bukkit.plugin.Plugin; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; + +/** + * CraftEngine-backed light blocks. This class is reflection-loaded and is the + * only light implementation allowed to link against CraftEngine internals. + * + *

InteractionVisualizer contributes its levels to CraftEngine's shared + * furniture light registry. That preserves the strongest light when CE + * furniture and an IV display occupy the same block. SKY and BLOCK requests + * are coalesced to their strongest value because vanilla light blocks expose a + * single block-light channel.

+ */ +public final class CraftEngineLightManager implements ILightManager, Listener { + + private final Plugin plugin; + private final Object lock = new Object(); + private final Map desired = new HashMap<>(); + private final Map applied = new HashMap<>(); + private final Map externalBaseline = new HashMap<>(); + private final Set dirty = new HashSet<>(); + private final Set waitingForChunk = new HashSet<>(); + private final Set restoreOnLoad = new HashSet<>(); + private final Map> trackedByChunk = new HashMap<>(); + + private long updatePeriod; + private ScheduledTask flushTask; + private boolean closed; + private boolean linkageFailureReported; + + public CraftEngineLightManager(Plugin plugin, long updatePeriod) { + this.plugin = plugin; + this.updatePeriod = sanitizePeriod(updatePeriod); + verifyProviderShape(); + Bukkit.getPluginManager().registerEvents(this, plugin); + } + + @Override + public void createLight(Location location, int lightLevel, LightType lightType) { + LightKey key = LightKey.from(location); + if (key == null || lightType == null) { + return; + } + int sanitizedLevel = Math.max(0, Math.min(15, lightLevel)); + synchronized (lock) { + if (closed) { + return; + } + RequestedLightLevels previous = desired.getOrDefault(key, RequestedLightLevels.NONE); + RequestedLightLevels updated = previous.with(lightType, sanitizedLevel); + if (updated.equals(previous)) { + return; + } + if (updated.effective() == 0) { + desired.remove(key); + } else { + desired.put(key, updated); + } + trackLocked(key); + dirty.add(key); + scheduleLocked(); + } + } + + @Override + public void deleteLight(Location location) { + LightKey key = LightKey.from(location); + if (key == null) { + return; + } + synchronized (lock) { + if (closed) { + return; + } + boolean hadDesired = desired.remove(key) != null; + if (!hadDesired && !applied.containsKey(key) && !waitingForChunk.contains(key) + && !restoreOnLoad.contains(key)) { + return; + } + trackLocked(key); + dirty.add(key); + scheduleLocked(); + } + } + + @Override + public ScheduledTask run() { + synchronized (lock) { + if (!dirty.isEmpty()) { + scheduleLocked(); + } + return flushTask; + } + } + + @Override + public void setUpdatePeriod(long updatePeriod) { + synchronized (lock) { + this.updatePeriod = sanitizePeriod(updatePeriod); + } + } + + private void scheduleLocked() { + if (!closed && flushTask == null) { + flushTask = Scheduler.runTaskLater(plugin, this::flush, updatePeriod); + } + } + + private void flush() { + Set batch; + synchronized (lock) { + flushTask = null; + if (closed) { + return; + } + batch = Set.copyOf(dirty); + dirty.clear(); + } + + for (LightKey key : batch) { + try { + apply(key); + } catch (LinkageError error) { + disableAfterLinkageFailure(error); + return; + } catch (RuntimeException exception) { + plugin.getLogger().log(Level.WARNING, + "Could not update a CraftEngine light at " + key, exception); + } + } + + synchronized (lock) { + if (!dirty.isEmpty()) { + scheduleLocked(); + } + } + } + + private void apply(LightKey key) { + World world = Bukkit.getWorld(key.worldId()); + if (world == null || !world.isChunkLoaded(key.x() >> 4, key.z() >> 4)) { + synchronized (lock) { + waitingForChunk.add(key); + trackLocked(key); + } + return; + } + + int target; + Integer previous; + boolean restore; + synchronized (lock) { + target = desired.getOrDefault(key, RequestedLightLevels.NONE).effective(); + previous = applied.get(key); + restore = restoreOnLoad.contains(key); + waitingForChunk.remove(key); + } + + BukkitWorld craftWorld = BukkitAdaptor.adapt(world); + BlockPos blockPos = key.toBlockPos(); + FurnitureLightData lightData = lightData(key.worldId(), target > 0 || previous != null); + int baseline = captureExternalBaseline(key, craftWorld, blockPos, lightData); + boolean changed = false; + + if (previous != null && previous != target) { + if (lightData != null) { + lightData.removeLightData(blockPos, previous); + } + changed = true; + } + if (target > 0 && (previous == null || previous != target)) { + lightData = lightData == null ? lightData(key.worldId(), true) : lightData; + lightData.addLightData(blockPos, target); + changed = true; + } + + synchronized (lock) { + if (target == 0) { + applied.remove(key); + } else { + applied.put(key, target); + } + } + + if (changed || restore) { + int effective = lightData == null ? 0 : lightData.getLightPower(blockPos); + updateServerLightBlock(craftWorld, key, Math.max(effective, baseline)); + } + + synchronized (lock) { + restoreOnLoad.remove(key); + cleanupKeyLocked(key); + } + } + + private int captureExternalBaseline(LightKey key, BukkitWorld world, BlockPos blockPos, + FurnitureLightData lightData) { + synchronized (lock) { + Integer baseline = externalBaseline.get(key); + if (baseline != null) { + return baseline; + } + } + + int baseline = 0; + BlockStateWrapper state = world.getBlockState(key.x(), key.y(), key.z()); + int managedLevel = lightData == null ? 0 : lightData.getLightPower(blockPos); + if (managedLevel == 0 && BlockKeys.LIGHT.equals(state.ownerId())) { + Object value = state.getProperty("level"); + if (value instanceof Number number) { + baseline = number.intValue(); + } else if (value != null) { + try { + baseline = Integer.parseInt(value.toString()); + } catch (NumberFormatException ignored) { + baseline = 0; + } + } + } + baseline = Math.max(0, Math.min(15, baseline)); + synchronized (lock) { + Integer existing = externalBaseline.get(key); + if (existing != null) { + return existing; + } + externalBaseline.put(key, baseline); + return baseline; + } + } + + private void updateServerLightBlock(BukkitWorld world, LightKey key, int level) { + int sanitizedLevel = Math.max(0, Math.min(15, level)); + BlockStateWrapper state = world.getBlockState(key.x(), key.y(), key.z()); + int stateId = state.registryId(); + BlockStateWrapper target = null; + if (stateId == GlowingFurnitureBehaviorTemplate.AIR_BLOCK_STATE_ID) { + target = BlockStateUtils.toBlockStateWrapper( + GlowingFurnitureBehaviorTemplate.LIGHT_BLOCK_STATES[sanitizedLevel]); + } else if (stateId == GlowingFurnitureBehaviorTemplate.WATER_BLOCK_STATE_ID) { + target = BlockStateUtils.toBlockStateWrapper( + GlowingFurnitureBehaviorTemplate.WATERLOGGED_LIGHT_BLOCK_STATES[sanitizedLevel]); + } else if (BlockKeys.LIGHT.equals(state.ownerId())) { + if (sanitizedLevel == 0) { + boolean waterlogged = Boolean.TRUE.equals(state.getProperty("waterlogged")); + Object vanillaState = waterlogged + ? GlowingFurnitureBehaviorTemplate.WATERLOGGED_LIGHT_BLOCK_STATES[0] + : GlowingFurnitureBehaviorTemplate.LIGHT_BLOCK_STATES[0]; + target = BlockStateUtils.toBlockStateWrapper(vanillaState); + } else { + target = state.withProperty("level", Integer.toString(sanitizedLevel)); + } + } + if (target != null) { + world.setBlockState(key.x(), key.y(), key.z(), target, UpdateFlags.UPDATE_ALL); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChunkUnload(ChunkUnloadEvent event) { + Chunk chunk = event.getChunk(); + ChunkKey chunkKey = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + List keys; + synchronized (lock) { + Set tracked = trackedByChunk.get(chunkKey); + if (closed || tracked == null || tracked.isEmpty()) { + return; + } + keys = List.copyOf(tracked); + } + + for (LightKey key : keys) { + Integer previous; + synchronized (lock) { + previous = applied.remove(key); + if (previous != null) { + restoreOnLoad.add(key); + } + if (desired.containsKey(key) || previous != null) { + waitingForChunk.add(key); + } + } + if (previous != null) { + FurnitureLightData data = lightData(key.worldId(), false); + if (data != null) { + data.removeLightData(key.toBlockPos(), previous); + } + } + synchronized (lock) { + cleanupKeyLocked(key); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChunkLoad(ChunkLoadEvent event) { + Chunk chunk = event.getChunk(); + ChunkKey chunkKey = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + synchronized (lock) { + Set tracked = trackedByChunk.get(chunkKey); + if (closed || tracked == null || tracked.isEmpty()) { + return; + } + for (LightKey key : tracked) { + if (desired.containsKey(key) || waitingForChunk.contains(key) || restoreOnLoad.contains(key)) { + dirty.add(key); + } + } + if (!dirty.isEmpty()) { + scheduleLocked(); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldUnload(WorldUnloadEvent event) { + UUID worldId = event.getWorld().getUID(); + List keys; + synchronized (lock) { + if (closed) { + return; + } + keys = applied.keySet().stream().filter(key -> key.worldId().equals(worldId)).toList(); + } + for (LightKey key : keys) { + Integer previous; + synchronized (lock) { + previous = applied.remove(key); + if (previous != null) { + restoreOnLoad.add(key); + } + waitingForChunk.add(key); + } + if (previous != null) { + FurnitureLightData data = lightData(worldId, false); + if (data != null) { + data.removeLightData(key.toBlockPos(), previous); + } + } + } + } + + @Override + public void shutdown() { + List> active; + Map baselines; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + if (flushTask != null) { + flushTask.cancel(); + flushTask = null; + } + active = new ArrayList<>(applied.entrySet()); + baselines = Map.copyOf(externalBaseline); + } + HandlerList.unregisterAll(this); + + for (Map.Entry entry : active) { + LightKey key = entry.getKey(); + try { + FurnitureLightData data = lightData(key.worldId(), false); + BlockPos blockPos = key.toBlockPos(); + if (data != null) { + data.removeLightData(blockPos, entry.getValue()); + } + World world = Bukkit.getWorld(key.worldId()); + if (world != null && world.isChunkLoaded(key.x() >> 4, key.z() >> 4)) { + int remaining = data == null ? 0 : data.getLightPower(blockPos); + updateServerLightBlock(BukkitAdaptor.adapt(world), key, + Math.max(remaining, baselines.getOrDefault(key, 0))); + } + } catch (LinkageError | RuntimeException exception) { + plugin.getLogger().log(Level.WARNING, + "Could not restore a CraftEngine light while shutting down", exception); + } + } + + synchronized (lock) { + desired.clear(); + applied.clear(); + externalBaseline.clear(); + dirty.clear(); + waitingForChunk.clear(); + restoreOnLoad.clear(); + trackedByChunk.clear(); + } + } + + private void disableAfterLinkageFailure(LinkageError error) { + synchronized (lock) { + closed = true; + desired.clear(); + applied.clear(); + externalBaseline.clear(); + dirty.clear(); + waitingForChunk.clear(); + restoreOnLoad.clear(); + trackedByChunk.clear(); + if (!linkageFailureReported) { + linkageFailureReported = true; + plugin.getLogger().log(Level.WARNING, + "CraftEngine lighting was disabled after an API linkage failure", error); + } + } + HandlerList.unregisterAll(this); + } + + private FurnitureLightData lightData(UUID worldId, boolean create) { + if (create) { + return GlowingFurnitureBehaviorTemplate.LIGHT_DATA.computeIfAbsent( + worldId, ignored -> new FurnitureLightData()); + } + return GlowingFurnitureBehaviorTemplate.LIGHT_DATA.get(worldId); + } + + private void trackLocked(LightKey key) { + trackedByChunk.computeIfAbsent(key.chunk(), ignored -> new HashSet<>()).add(key); + } + + private void cleanupKeyLocked(LightKey key) { + if (desired.containsKey(key) || applied.containsKey(key) || waitingForChunk.contains(key) + || restoreOnLoad.contains(key)) { + return; + } + externalBaseline.remove(key); + Set tracked = trackedByChunk.get(key.chunk()); + if (tracked != null) { + tracked.remove(key); + if (tracked.isEmpty()) { + trackedByChunk.remove(key.chunk()); + } + } + } + + private static void verifyProviderShape() { + if (GlowingFurnitureBehaviorTemplate.LIGHT_BLOCK_STATES.length != 16 + || GlowingFurnitureBehaviorTemplate.WATERLOGGED_LIGHT_BLOCK_STATES.length != 16) { + throw new IllegalStateException("The installed CraftEngine light-state API is not compatible"); + } + } + + private static long sanitizePeriod(long updatePeriod) { + return Math.max(1L, updatePeriod); + } + + private record ChunkKey(UUID worldId, int x, int z) { + } + + private record LightKey(UUID worldId, int x, int y, int z) { + + private static LightKey from(Location location) { + if (location == null || location.getWorld() == null) { + return null; + } + return new LightKey(location.getWorld().getUID(), location.getBlockX(), + location.getBlockY(), location.getBlockZ()); + } + + private ChunkKey chunk() { + return new ChunkKey(worldId, x >> 4, z >> 4); + } + + private BlockPos toBlockPos() { + return new BlockPos(x, y, z); + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/RequestedLightLevels.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/RequestedLightLevels.java new file mode 100644 index 00000000..723cb560 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/RequestedLightLevels.java @@ -0,0 +1,30 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration.craftengine; + +import com.loohp.interactionvisualizer.objectholders.LightType; + +record RequestedLightLevels(int block, int sky) { + + static final RequestedLightLevels NONE = new RequestedLightLevels(0, 0); + + RequestedLightLevels with(LightType type, int level) { + return switch (type) { + case BLOCK -> new RequestedLightLevels(level, sky); + case SKY -> new RequestedLightLevels(block, level); + }; + } + + int effective() { + return Math.max(block, sky); + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/LightManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/LightManager.java index a7413adb..7878923e 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/LightManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/LightManager.java @@ -1,149 +1,56 @@ /* * This file is part of InteractionVisualizer. * - * Copyright (C) 2025. LoohpJames - * Copyright (C) 2025. Contributors + * Copyright (C) 2026. Contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . */ package com.loohp.interactionvisualizer.managers; -import com.loohp.interactionvisualizer.InteractionVisualizer; import com.loohp.interactionvisualizer.objectholders.ILightManager; -import com.loohp.interactionvisualizer.objectholders.LightData; -import com.loohp.interactionvisualizer.scheduler.ScheduledTask; -import com.loohp.interactionvisualizer.scheduler.Scheduler; -import org.bukkit.Location; -import ru.beykerykt.lightapi.LightAPI; -import ru.beykerykt.lightapi.LightType; -import ru.beykerykt.lightapi.chunks.ChunkInfo; - -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.Queue; -import java.util.Set; +import org.bukkit.plugin.Plugin; -public class LightManager implements ILightManager { +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; +import java.util.logging.Level; - private static LightType convert(com.loohp.interactionvisualizer.objectholders.LightType lightType) { - if (lightType == null) { - return null; - } - switch (lightType) { - case BLOCK: - return LightType.BLOCK; - case SKY: - return LightType.SKY; - } - return null; - } +/** + * Resolves the optional CraftEngine light implementation without linking the + * core plugin classes against CraftEngine when it is absent. + */ +public final class LightManager { - private final InteractionVisualizer plugin; - private Set addqueue; - private Set deletequeue; + static final String CRAFT_ENGINE_SENTINEL = + "net.momirealms.craftengine.bukkit.api.BukkitAdaptor"; + static final String CRAFT_ENGINE_IMPLEMENTATION = + "com.loohp.interactionvisualizer.integration.craftengine.CraftEngineLightManager"; - public LightManager(InteractionVisualizer plugin) { - this.plugin = plugin; - this.addqueue = new HashSet<>(); - this.deletequeue = new HashSet<>(); + private LightManager() { } - @Override - public void createLight(Location location, int lightlevel, com.loohp.interactionvisualizer.objectholders.LightType lightType) { - addqueue.add(LightData.of(location, lightlevel, lightType)); + public static Optional createCraftEngine(Plugin plugin, long updatePeriod) { + try { + return Optional.of(load(plugin.getClass().getClassLoader(), CRAFT_ENGINE_SENTINEL, + CRAFT_ENGINE_IMPLEMENTATION, plugin, updatePeriod)); + } catch (ReflectiveOperationException | LinkageError | ClassCastException exception) { + Throwable cause = exception instanceof InvocationTargetException invocation + && invocation.getCause() != null ? invocation.getCause() : exception; + plugin.getLogger().log(Level.WARNING, + "Could not enable CraftEngine lighting; continuing without display lighting", cause); + return Optional.empty(); + } } - @Override - public void deleteLight(Location location) { - addqueue.remove(LightData.of(location, com.loohp.interactionvisualizer.objectholders.LightType.BLOCK)); - addqueue.remove(LightData.of(location, com.loohp.interactionvisualizer.objectholders.LightType.SKY)); - deletequeue.add(LightData.of(location)); + static ILightManager load(ClassLoader classLoader, String sentinelClass, String implementationClass, + Plugin plugin, long updatePeriod) throws ReflectiveOperationException { + Class.forName(sentinelClass, false, classLoader); + return Class.forName(implementationClass, true, classLoader) + .asSubclass(ILightManager.class) + .getDeclaredConstructor(Plugin.class, long.class) + .newInstance(plugin, updatePeriod); } - - @Override - public ScheduledTask run() { - return Scheduler.runTaskTimer(plugin, () -> { - boolean changed = false; - - Queue updateQueue = new LinkedList<>(); - - Set addqueue = this.addqueue; - Set deletequeue = this.deletequeue; - - this.addqueue = new HashSet<>(); - this.deletequeue = new HashSet<>(); - - if (!deletequeue.isEmpty()) { - changed = true; - } - - for (Iterator itr = deletequeue.iterator(); itr.hasNext();) { - LightData lightdata = itr.next(); - if (lightdata.isLocationLoaded()) { - Location location = lightdata.getLocation(); - if (LightAPI.isSupported(location.getWorld(), LightType.SKY)) { - LightAPI.deleteLight(location, LightType.SKY, false); - } - LightAPI.deleteLight(location, LightType.BLOCK, false); - updateQueue.add(LightData.of(location, 14, com.loohp.interactionvisualizer.objectholders.LightType.SKY)); - updateQueue.add(LightData.of(location, 14, com.loohp.interactionvisualizer.objectholders.LightType.BLOCK)); - } - itr.remove(); - } - - if (!addqueue.isEmpty()) { - changed = true; - } - - for (Iterator itr = addqueue.iterator(); itr.hasNext();) { - LightData lightdata = itr.next(); - if (lightdata.isLocationLoaded()) { - Location location = lightdata.getLocation(); - int lightlevel = lightdata.getLightLevel(); - if (LightAPI.isSupported(location.getWorld(), convert(lightdata.getLightType()))) { - LightAPI.createLight(location, convert(lightdata.getLightType()), lightlevel, false); - updateQueue.add(lightdata); - } - } - itr.remove(); - } - - if (changed) { - Set blockinfos = new HashSet<>(); - Set skyinfos = new HashSet<>(); - while (!updateQueue.isEmpty()) { - LightData lightdata = updateQueue.poll(); - LightType lightType = convert(lightdata.getLightType()); - switch (lightType) { - case BLOCK: - blockinfos.addAll(LightAPI.collectChunks(lightdata.getLocation(), lightType, lightdata.getLightLevel())); - break; - case SKY: - skyinfos.addAll(LightAPI.collectChunks(lightdata.getLocation(), lightType, lightdata.getLightLevel())); - break; - } - } - for (ChunkInfo info : skyinfos) { - LightAPI.updateChunk(info, LightType.SKY); - } - for (ChunkInfo info : blockinfos) { - LightAPI.updateChunk(info, LightType.BLOCK); - } - } - }, 0, InteractionVisualizer.lightUpdatePeriod); - } - } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/ILightManager.java b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/ILightManager.java index d9d5b424..7e4895a6 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/ILightManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/ILightManager.java @@ -26,12 +26,6 @@ public interface ILightManager { ILightManager DUMMY_INSTANCE = new ILightManager() { - - @Override - public ScheduledTask run() { - return null; - } - @Override public void deleteLight(Location location) { //do nothing @@ -47,6 +41,18 @@ public void createLight(Location location, int lightlevel, LightType lightType) void deleteLight(Location location); - ScheduledTask run(); + /** + * Kept for compatibility with the original manager lifecycle. Implementations + * may schedule work lazily from {@link #createLight(Location, int, LightType)}. + */ + default ScheduledTask run() { + return null; + } + + default void setUpdatePeriod(long updatePeriod) { + } + + default void shutdown() { + } } diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index 3c5edda1..345ed3df 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -83,7 +83,7 @@ TileEntityUpdate: LightUpdate: #How often should the plugin update the light level a block that requires it is used - #Only used if LightAPI is installed + #Only used if CraftEngine is installed; updates are scheduled only while changes are pending #Measured in ticks, 20 ticks = 1 second #Adjust this if your server is unable to handle fast light updates Period: 10 diff --git a/common/src/main/resources/plugin.yml b/common/src/main/resources/plugin.yml index 1730aeac..7ec01525 100644 --- a/common/src/main/resources/plugin.yml +++ b/common/src/main/resources/plugin.yml @@ -6,7 +6,6 @@ api-version: '26.1.2' description: Makes function blocks better softdepend: - CraftEngine - - LightAPI - OpenInv - SuperVanish - PremiumVanish diff --git a/common/src/test/java/com/loohp/interactionvisualizer/integration/craftengine/RequestedLightLevelsTest.java b/common/src/test/java/com/loohp/interactionvisualizer/integration/craftengine/RequestedLightLevelsTest.java new file mode 100644 index 00000000..5ebd2020 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/integration/craftengine/RequestedLightLevelsTest.java @@ -0,0 +1,41 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration.craftengine; + +import com.loohp.interactionvisualizer.objectholders.LightType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RequestedLightLevelsTest { + + @Test + void coalescesSkyAndBlockRequestsToTheStrongestVanillaLightBlock() { + RequestedLightLevels levels = RequestedLightLevels.NONE + .with(LightType.SKY, 13) + .with(LightType.BLOCK, 7); + + assertEquals(13, levels.effective()); + assertEquals(13, levels.sky()); + assertEquals(7, levels.block()); + } + + @Test + void aLaterRequestReplacesOnlyItsOwnChannel() { + RequestedLightLevels levels = new RequestedLightLevels(12, 8) + .with(LightType.BLOCK, 3); + + assertEquals(8, levels.effective()); + assertEquals(3, levels.block()); + assertEquals(8, levels.sky()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/LightManagerLoaderTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/LightManagerLoaderTest.java new file mode 100644 index 00000000..d4104e34 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/LightManagerLoaderTest.java @@ -0,0 +1,57 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.loohp.interactionvisualizer.objectholders.ILightManager; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LightManagerLoaderTest { + + @Test + void loadsTheImplementationOnlyAfterItsSentinelIsVisible() throws Exception { + ILightManager manager = LightManager.load(getClass().getClassLoader(), "java.lang.String", + FakeLightManager.class.getName(), null, 17L); + + FakeLightManager fake = assertInstanceOf(FakeLightManager.class, manager); + assertEquals(17L, fake.period); + } + + @Test + void leavesCraftEngineClassesUnresolvedWhenTheProviderIsAbsent() { + assertThrows(ClassNotFoundException.class, () -> LightManager.load( + getClass().getClassLoader(), LightManager.CRAFT_ENGINE_SENTINEL, + LightManager.CRAFT_ENGINE_IMPLEMENTATION, null, 10L)); + } + + public static final class FakeLightManager implements ILightManager { + + private final long period; + + public FakeLightManager(Plugin plugin, long period) { + this.period = period; + } + + @Override + public void createLight(org.bukkit.Location location, int lightlevel, + com.loohp.interactionvisualizer.objectholders.LightType lightType) { + } + + @Override + public void deleteLight(org.bukkit.Location location) { + } + } +} From edb0423a225bf824111bb06d233d0c81344ca67d Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 15:07:22 +0800 Subject: [PATCH 07/12] Cull sent-chunk displays with CraftEngine --- README.md | 6 +- build.gradle.kts | 10 +- .../InteractionVisualizer.java | 56 +++ .../integration/CullingBounds.java | 33 ++ .../integration/ViewerCullingManager.java | 88 +++++ .../ViewerCullingManagerLoader.java | 55 +++ .../CraftEngineViewerCullingManager.java | 344 ++++++++++++++++++ .../craftengine/CullingRegistrationIndex.java | 130 +++++++ .../managers/DisplayManager.java | 132 ++++++- .../managers/PerformanceMetrics.java | 69 +++- common/src/main/resources/config.yml | 6 +- .../integration/CullingBoundsTest.java | 43 +++ .../ViewerCullingManagerLoaderTest.java | 98 +++++ .../CullingRegistrationIndexTest.java | 67 ++++ .../PerformanceMetricsSlowestTickTest.java | 5 +- 15 files changed, 1125 insertions(+), 17 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/integration/CullingBounds.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManager.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoader.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineViewerCullingManager.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CullingRegistrationIndex.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/integration/CullingBoundsTest.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoaderTest.java create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/integration/craftengine/CullingRegistrationIndexTest.java diff --git a/README.md b/README.md index 34bc7379..04da294a 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,10 @@ against Paper 26.2. overridden in `material.yml` under `CustomItems`. It also supplies display lighting: InteractionVisualizer shares its light-block reference counts so CraftEngine furniture and IV displays do not remove each other's light, and - performs no lighting task while idle. CraftEngine is not bundled and the - plugin behaves exactly as before when it is absent. + performs no lighting task while idle. With `Settings.HideIfViewObstructed`, + IV registers only its sent-chunk candidates in CraftEngine's entity-culling + API for a second-stage ray-traced wall-occlusion check. CraftEngine is not + bundled and the plugin behaves exactly as before when it is absent. - [OpenInv](https://dev.bukkit.org/projects/openinv) - [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) - Essentials, SuperVanish, PremiumVanish, and CMI diff --git a/build.gradle.kts b/build.gradle.kts index a9326ef6..6877ac91 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -205,21 +205,29 @@ val verifyCustomContentIsolation = tasks.register("verifyCustomContentIsolation" val allowedCraftEngineLightSource = file( "common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java", ).canonicalFile + val allowedCraftEngineCullingSource = file( + "common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineViewerCullingManager.java", + ).canonicalFile val managerSource = file( "common/src/main/java/com/loohp/interactionvisualizer/integration/CustomContentManager.java", ).canonicalFile val lightLoaderSource = file( "common/src/main/java/com/loohp/interactionvisualizer/managers/LightManager.java", ).canonicalFile + val cullingLoaderSource = file( + "common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoader.java", + ).canonicalFile val stableApiClass = "net.momirealms.craftengine.bukkit.api.CraftEngineItems" val lightSentinelClass = "net.momirealms.craftengine.bukkit.api.BukkitAdaptor" + val cullingSentinelClass = "net.momirealms.craftengine.core.entity.culling.Cullable" val craftEngineToken = Regex("net\\.momirealms\\.craftengine(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+") val violations = sources.files.flatMap { source -> craftEngineToken.findAll(source.readText()).mapNotNull { match -> val allowed = when (source.canonicalFile) { allowedCraftEngineSource, managerSource -> match.value == stableApiClass lightLoaderSource -> match.value == lightSentinelClass - allowedCraftEngineLightSource -> true + cullingLoaderSource -> match.value == cullingSentinelClass + allowedCraftEngineLightSource, allowedCraftEngineCullingSource -> true else -> false } if (allowed) null else "${source.relativeTo(rootDir)}: ${match.value}" diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index 8bf8f17a..a00e4265 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -26,6 +26,8 @@ import com.loohp.interactionvisualizer.debug.PerformanceBlockScene; import com.loohp.interactionvisualizer.debug.PerformanceScene; import com.loohp.interactionvisualizer.integration.CustomContentManager; +import com.loohp.interactionvisualizer.integration.ViewerCullingManager; +import com.loohp.interactionvisualizer.integration.ViewerCullingManagerLoader; import com.loohp.interactionvisualizer.managers.AsyncExecutorManager; import com.loohp.interactionvisualizer.managers.LangManager; import com.loohp.interactionvisualizer.managers.LightManager; @@ -126,8 +128,11 @@ public class InteractionVisualizer extends JavaPlugin { public static int blockUpdateMaxDirtyPerTick = 64; private boolean blockUpdateModeInitialized; + private boolean cullingLifecycleInitialized; + private boolean cullingProviderWarningLogged; public static ILightManager lightManager; + public static ViewerCullingManager viewerCullingManager = ViewerCullingManager.DISABLED; public static PreferenceManager preferenceManager; public static AsyncExecutorManager asyncExecutorManager; @@ -194,6 +199,10 @@ public void onEnable() { craftEngineHooked = true; } } + if (configureViewerCulling()) { + craftEngineHooked = true; + } + cullingLifecycleInitialized = true; if (craftEngineHooked) { hookMessage("CraftEngine"); } @@ -262,6 +271,8 @@ public void onDisable() { lightManager.shutdown(); lightManager = ILightManager.DUMMY_INSTANCE; } + viewerCullingManager.shutdown(); + viewerCullingManager = ViewerCullingManager.DISABLED; CustomContentManager.shutdown(); if (preferenceManager != null) { preferenceManager.close(); @@ -335,6 +346,9 @@ public void loadConfig() { disabledWorlds = new HashSet<>(getConfiguration().getStringList("Settings.DisabledWorlds")); hideIfObstructed = getConfiguration().getBoolean("Settings.HideIfViewObstructed"); + if (cullingLifecycleInitialized) { + configureViewerCulling(); + } lightUpdatePeriod = getConfiguration().getInt("LightUpdate.Period"); if (lightManager != null) { @@ -376,4 +390,46 @@ public void loadConfig() { getServer().getPluginManager().callEvent(new InteractionVisualizerReloadEvent()); } + private boolean configureViewerCulling() { + if (!hideIfObstructed) { + boolean backendChanged = viewerCullingManager.enabled(); + viewerCullingManager.shutdown(); + viewerCullingManager = ViewerCullingManager.DISABLED; + if (backendChanged && cullingLifecycleInitialized) { + DisplayManager.onCullingBackendChanged(); + } + return false; + } + if (viewerCullingManager.enabled()) { + return true; + } + viewerCullingManager.shutdown(); + viewerCullingManager = ViewerCullingManager.DISABLED; + if (!isPluginEnabled("CraftEngine")) { + if (!cullingProviderWarningLogged) { + cullingProviderWarningLogged = true; + getLogger().warning("Settings.HideIfViewObstructed requires CraftEngine 26.7+; " + + "using sent-chunk visibility without occlusion culling."); + } + return false; + } + ViewerCullingManager manager = ViewerCullingManagerLoader.createCraftEngine( + this, DisplayManager::onCullingVisibility).orElse(ViewerCullingManager.DISABLED); + if (!manager.enabled()) { + manager.shutdown(); + if (!cullingProviderWarningLogged) { + cullingProviderWarningLogged = true; + getLogger().warning("CraftEngine entity culling and ray tracing must both be enabled " + + "for Settings.HideIfViewObstructed; using sent-chunk visibility only."); + } + return false; + } + cullingProviderWarningLogged = false; + viewerCullingManager = manager; + if (cullingLifecycleInitialized) { + DisplayManager.onCullingBackendChanged(); + } + return true; + } + } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/CullingBounds.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/CullingBounds.java new file mode 100644 index 00000000..3793169c --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/CullingBounds.java @@ -0,0 +1,33 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration; + +/** Immutable provider-neutral bounds for one logical display. */ +public record CullingBounds( + double minX, double minY, double minZ, + double maxX, double maxY, double maxZ, + int maxDistance, double expansion, boolean rayTracing) { + + public CullingBounds { + if (!Double.isFinite(minX) || !Double.isFinite(minY) || !Double.isFinite(minZ) + || !Double.isFinite(maxX) || !Double.isFinite(maxY) || !Double.isFinite(maxZ) + || !Double.isFinite(expansion)) { + throw new IllegalArgumentException("Culling bounds must be finite"); + } + if (maxX < minX || maxY < minY || maxZ < minZ) { + throw new IllegalArgumentException("Culling bounds must be ordered"); + } + if (maxDistance < 0 || expansion < 0.0D) { + throw new IllegalArgumentException("Culling distance and expansion cannot be negative"); + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManager.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManager.java new file mode 100644 index 00000000..522a46cd --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManager.java @@ -0,0 +1,88 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration; + +import org.bukkit.entity.Player; + +import java.util.UUID; +import java.util.Set; + +/** Optional second-stage occlusion culling for sent-chunk display candidates. */ +public interface ViewerCullingManager { + + ViewerCullingManager DISABLED = new ViewerCullingManager() { + @Override + public boolean enabled() { + return false; + } + + @Override + public boolean track(Player viewer, UUID logicalId, CullingBounds bounds) { + return false; + } + + @Override + public void update(UUID logicalId, CullingBounds bounds) { + } + + @Override + public void untrack(UUID viewerId, UUID logicalId) { + } + + @Override + public void clearViewer(UUID viewerId) { + } + + @Override + public void clearLogical(UUID logicalId) { + } + + @Override + public void retainLogical(UUID logicalId, Set viewerIds) { + } + + @Override + public void shutdown() { + } + + @Override + public int retainedRegistrations() { + return 0; + } + }; + + boolean enabled(); + + /** Returns true when this backend owns visibility for the candidate. */ + boolean track(Player viewer, UUID logicalId, CullingBounds bounds); + + void update(UUID logicalId, CullingBounds bounds); + + void untrack(UUID viewerId, UUID logicalId); + + void clearViewer(UUID viewerId); + + void clearLogical(UUID logicalId); + + /** Removes registrations for this logical that are no longer local candidates. */ + void retainLogical(UUID logicalId, Set viewerIds); + + void shutdown(); + + int retainedRegistrations(); + + @FunctionalInterface + interface VisibilityListener { + + void visibilityChanged(UUID viewerId, UUID logicalId, boolean visible); + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoader.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoader.java new file mode 100644 index 00000000..28e4611f --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoader.java @@ -0,0 +1,55 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration; + +import org.bukkit.plugin.Plugin; + +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; +import java.util.logging.Level; + +/** Reflection loader for the optional CraftEngine culling implementation. */ +public final class ViewerCullingManagerLoader { + + static final String CRAFT_ENGINE_SENTINEL = + "net.momirealms.craftengine.core.entity.culling.Cullable"; + static final String CRAFT_ENGINE_IMPLEMENTATION = + "com.loohp.interactionvisualizer.integration.craftengine.CraftEngineViewerCullingManager"; + + private ViewerCullingManagerLoader() { + } + + public static Optional createCraftEngine( + Plugin plugin, ViewerCullingManager.VisibilityListener listener) { + try { + return Optional.of(load(plugin.getClass().getClassLoader(), CRAFT_ENGINE_SENTINEL, + CRAFT_ENGINE_IMPLEMENTATION, plugin, listener)); + } catch (ReflectiveOperationException | LinkageError | ClassCastException exception) { + Throwable cause = exception instanceof InvocationTargetException invocation + && invocation.getCause() != null ? invocation.getCause() : exception; + plugin.getLogger().log(Level.WARNING, + "Could not enable CraftEngine display culling; using sent-chunk visibility only", cause); + return Optional.empty(); + } + } + + static ViewerCullingManager load(ClassLoader classLoader, String sentinelClass, + String implementationClass, Plugin plugin, + ViewerCullingManager.VisibilityListener listener) + throws ReflectiveOperationException { + Class.forName(sentinelClass, false, classLoader); + return Class.forName(implementationClass, true, classLoader) + .asSubclass(ViewerCullingManager.class) + .getDeclaredConstructor(Plugin.class, ViewerCullingManager.VisibilityListener.class) + .newInstance(plugin, listener); + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineViewerCullingManager.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineViewerCullingManager.java new file mode 100644 index 00000000..74aecd5f --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineViewerCullingManager.java @@ -0,0 +1,344 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration.craftengine; + +import com.loohp.interactionvisualizer.integration.CullingBounds; +import com.loohp.interactionvisualizer.integration.ViewerCullingManager; +import com.loohp.interactionvisualizer.scheduler.ScheduledTask; +import com.loohp.interactionvisualizer.scheduler.Scheduler; +import net.momirealms.craftengine.bukkit.api.BukkitAdaptor; +import net.momirealms.craftengine.bukkit.plugin.user.BukkitServerPlayer; +import net.momirealms.craftengine.core.entity.culling.Cullable; +import net.momirealms.craftengine.core.entity.culling.CullingData; +import net.momirealms.craftengine.core.plugin.config.Config; +import net.momirealms.craftengine.core.world.collision.AABB; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; + +/** + * Adds only InteractionVisualizer's sent-chunk candidates to CraftEngine's + * per-player tracked set. CraftEngine performs its distance/occlusion pass on + * its own worker; callbacks are coalesced onto IV's main-thread coordinator. + */ +public final class CraftEngineViewerCullingManager implements ViewerCullingManager { + + private static final AtomicInteger NEXT_TRACKING_ID = new AtomicInteger(Integer.MIN_VALUE); + + private final Plugin plugin; + private final VisibilityListener listener; + private final boolean providerEnabled; + private final CullingRegistrationIndex registrations = new CullingRegistrationIndex<>(); + private final Map statesByLogical = new ConcurrentHashMap<>(); + private final Map pendingDecisions = new ConcurrentHashMap<>(); + private final AtomicBoolean drainScheduled = new AtomicBoolean(); + + private volatile ScheduledTask drainTask; + private volatile boolean closed; + private final AtomicBoolean failureReported = new AtomicBoolean(); + + public CraftEngineViewerCullingManager(Plugin plugin, VisibilityListener listener) { + this.plugin = plugin; + this.listener = listener; + this.providerEnabled = Config.enableEntityCulling() && Config.entityCullingRayTracing(); + } + + @Override + public boolean enabled() { + return providerEnabled && !closed; + } + + @Override + public boolean track(Player viewer, UUID logicalId, CullingBounds bounds) { + if (!enabled() || viewer == null || !viewer.isOnline() || logicalId == null || bounds == null) { + return false; + } + + SharedCullingState state = statesByLogical.compute(logicalId, (ignored, current) -> { + if (current == null) { + return new SharedCullingState(bounds); + } + current.update(bounds); + return current; + }); + CullingRegistrationIndex.Key key = new CullingRegistrationIndex.Key( + viewer.getUniqueId(), logicalId); + Registration current = registrations.get(key); + if (current != null) { + pendingDecisions.put(key, current.visible); + scheduleDecisionDrain(); + return true; + } + + try { + BukkitServerPlayer craftPlayer = BukkitAdaptor.adapt(viewer); + if (craftPlayer == null) { + return false; + } + Registration registration = new Registration(key, NEXT_TRACKING_ID.getAndIncrement(), + craftPlayer, state); + Registration raced = registrations.putIfAbsent(key, registration); + if (raced != null) { + return true; + } + + // Start pessimistically hidden. If CE observes visibility on its + // next 50 ms pass, that newer true decision replaces this one. + pendingDecisions.put(key, false); + scheduleDecisionDrain(); + craftPlayer.addTrackedEntity(registration.trackingId, registration.cullable); + return true; + } catch (LinkageError | RuntimeException exception) { + Registration registration = registrations.remove(key); + if (registration != null) { + detachRegistration(registration); + removeStateIfUnused(logicalId, state); + } + pendingDecisions.remove(key); + disableAfterFailure(exception); + return false; + } + } + + @Override + public void update(UUID logicalId, CullingBounds bounds) { + if (!enabled() || logicalId == null || bounds == null) { + return; + } + SharedCullingState state = statesByLogical.get(logicalId); + if (state != null) { + state.update(bounds); + } + } + + @Override + public void untrack(UUID viewerId, UUID logicalId) { + if (viewerId == null || logicalId == null) { + return; + } + Registration registration = registrations.remove( + new CullingRegistrationIndex.Key(viewerId, logicalId)); + if (registration != null) { + detachRegistration(registration); + removeStateIfUnused(logicalId, registration.state); + } + } + + @Override + public void clearViewer(UUID viewerId) { + List removed = registrations.removeViewer(viewerId); + for (Registration registration : removed) { + detachRegistration(registration); + removeStateIfUnused(registration.key.logicalId(), registration.state); + } + } + + @Override + public void clearLogical(UUID logicalId) { + for (Registration registration : registrations.removeLogical(logicalId)) { + detachRegistration(registration); + } + statesByLogical.remove(logicalId); + } + + @Override + public void retainLogical(UUID logicalId, Set viewerIds) { + List removed = registrations.retainLogical(logicalId, viewerIds); + for (Registration registration : removed) { + detachRegistration(registration); + } + if (!removed.isEmpty()) { + removeStateIfUnused(logicalId, removed.getFirst().state); + } + } + + @Override + public int retainedRegistrations() { + return registrations.size(); + } + + private void detachRegistration(Registration registration) { + registration.active = false; + pendingDecisions.remove(registration.key); + try { + registration.player.removeTrackedEntity(registration.trackingId); + } catch (LinkageError | RuntimeException exception) { + if (!closed) { + disableAfterFailure(exception); + } + } + } + + private void removeStateIfUnused(UUID logicalId, SharedCullingState state) { + if (!registrations.hasLogical(logicalId)) { + statesByLogical.remove(logicalId, state); + } + } + + private void decision(Registration registration, boolean visible) { + if (!registration.active || closed || registrations.get(registration.key) != registration) { + return; + } + registration.visible = visible; + pendingDecisions.put(registration.key, visible); + scheduleDecisionDrain(); + } + + private void scheduleDecisionDrain() { + if (closed || !plugin.isEnabled() || !drainScheduled.compareAndSet(false, true)) { + return; + } + drainTask = Scheduler.runTask(plugin, this::drainDecisions); + } + + private void drainDecisions() { + try { + Map snapshot = new HashMap<>(pendingDecisions); + for (Map.Entry entry : snapshot.entrySet()) { + Registration registration = registrations.get(entry.getKey()); + if (registration != null && registration.active + && pendingDecisions.remove(entry.getKey(), entry.getValue())) { + listener.visibilityChanged(entry.getKey().viewerId(), + entry.getKey().logicalId(), entry.getValue()); + } + } + } finally { + drainTask = null; + drainScheduled.set(false); + if (!pendingDecisions.isEmpty()) { + scheduleDecisionDrain(); + } + } + } + + private void disableAfterFailure(Throwable throwable) { + if (closed) { + return; + } + closed = true; + List snapshot = registrations.clear(); + for (Registration registration : snapshot) { + registration.active = false; + try { + registration.player.removeTrackedEntity(registration.trackingId); + } catch (Throwable suppressed) { + throwable.addSuppressed(suppressed); + } + } + statesByLogical.clear(); + pendingDecisions.clear(); + if (failureReported.compareAndSet(false, true)) { + plugin.getLogger().log(Level.WARNING, + "CraftEngine display culling failed and was disabled; candidates will remain visible", throwable); + } + if (plugin.isEnabled()) { + Scheduler.runTask(plugin, () -> { + for (Registration registration : snapshot) { + listener.visibilityChanged(registration.key.viewerId(), + registration.key.logicalId(), true); + } + }); + } + } + + @Override + public void shutdown() { + if (closed && registrations.isEmpty()) { + return; + } + closed = true; + ScheduledTask pendingDrain = drainTask; + if (pendingDrain != null) { + pendingDrain.cancel(); + } + for (Registration registration : registrations.clear()) { + registration.active = false; + try { + registration.player.removeTrackedEntity(registration.trackingId); + } catch (LinkageError | RuntimeException exception) { + if (failureReported.compareAndSet(false, true)) { + plugin.getLogger().log(Level.WARNING, + "Could not fully unregister CraftEngine culling during shutdown", exception); + } + } + } + statesByLogical.clear(); + pendingDecisions.clear(); + drainScheduled.set(false); + drainTask = null; + } + + private final class Registration { + + private final CullingRegistrationIndex.Key key; + private final int trackingId; + private final BukkitServerPlayer player; + private final SharedCullingState state; + private final Cullable cullable; + private volatile boolean active = true; + private volatile boolean visible; + + private Registration(CullingRegistrationIndex.Key key, int trackingId, BukkitServerPlayer player, + SharedCullingState state) { + this.key = key; + this.trackingId = trackingId; + this.player = player; + this.state = state; + this.cullable = new Cullable() { + @Override + public void show(net.momirealms.craftengine.core.entity.player.Player ignored) { + decision(Registration.this, true); + } + + @Override + public void hide(net.momirealms.craftengine.core.entity.player.Player ignored) { + decision(Registration.this, false); + } + + @Override + public CullingData cullingData() { + return state.data; + } + }; + } + } + + private static final class SharedCullingState { + + private volatile CullingBounds bounds; + private volatile CullingData data; + + private SharedCullingState(CullingBounds bounds) { + update(bounds); + } + + private void update(CullingBounds updated) { + if (updated.equals(bounds)) { + return; + } + bounds = updated; + data = new CullingData(new AABB( + updated.minX(), updated.minY(), updated.minZ(), + updated.maxX(), updated.maxY(), updated.maxZ()), + updated.maxDistance(), updated.expansion(), updated.rayTracing()); + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CullingRegistrationIndex.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CullingRegistrationIndex.java new file mode 100644 index 00000000..17e39b1b --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CullingRegistrationIndex.java @@ -0,0 +1,130 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration.craftengine; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Atomic forward/reverse bookkeeping for optional per-viewer culling handles. + * Provider callbacks only perform lock-free key lookups; lifecycle mutations + * are rare and serialized so quit/remove races cannot retain one-sided state. + */ +final class CullingRegistrationIndex { + + private final Map registrations = new ConcurrentHashMap<>(); + private final Map> registrationsByViewer = new HashMap<>(); + private final Map> registrationsByLogical = new HashMap<>(); + + T get(Key key) { + return registrations.get(key); + } + + synchronized T putIfAbsent(Key key, T registration) { + T current = registrations.putIfAbsent(key, registration); + if (current != null) { + return current; + } + registrationsByViewer.computeIfAbsent(key.viewerId(), ignored -> new HashSet<>()).add(key); + registrationsByLogical.computeIfAbsent(key.logicalId(), ignored -> new HashSet<>()).add(key); + return null; + } + + synchronized T remove(Key key) { + return removeLocked(key); + } + + synchronized List removeViewer(UUID viewerId) { + Set keys = registrationsByViewer.get(viewerId); + return removeKeysLocked(keys == null ? Set.of() : Set.copyOf(keys)); + } + + synchronized List removeLogical(UUID logicalId) { + Set keys = registrationsByLogical.get(logicalId); + return removeKeysLocked(keys == null ? Set.of() : Set.copyOf(keys)); + } + + synchronized List retainLogical(UUID logicalId, Set viewerIds) { + Set keys = registrationsByLogical.get(logicalId); + if (keys == null || keys.isEmpty()) { + return List.of(); + } + List removedKeys = new ArrayList<>(); + for (Key key : keys) { + if (!viewerIds.contains(key.viewerId())) { + removedKeys.add(key); + } + } + return removeKeysLocked(removedKeys); + } + + synchronized boolean hasLogical(UUID logicalId) { + Set keys = registrationsByLogical.get(logicalId); + return keys != null && !keys.isEmpty(); + } + + int size() { + return registrations.size(); + } + + boolean isEmpty() { + return registrations.isEmpty(); + } + + synchronized List clear() { + List removed = List.copyOf(registrations.values()); + registrations.clear(); + registrationsByViewer.clear(); + registrationsByLogical.clear(); + return removed; + } + + private List removeKeysLocked(Iterable keys) { + List removed = new ArrayList<>(); + for (Key key : keys) { + T registration = removeLocked(key); + if (registration != null) { + removed.add(registration); + } + } + return removed; + } + + private T removeLocked(Key key) { + T removed = registrations.remove(key); + if (removed == null) { + return null; + } + removeReverseKey(registrationsByViewer, key.viewerId(), key); + removeReverseKey(registrationsByLogical, key.logicalId(), key); + return removed; + } + + private static void removeReverseKey(Map> index, K owner, Key key) { + Set keys = index.get(owner); + if (keys != null) { + keys.remove(key); + if (keys.isEmpty()) { + index.remove(owner); + } + } + } + + record Key(UUID viewerId, UUID logicalId) { + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java index 6db9a7da..0f04cd2e 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java @@ -21,6 +21,8 @@ import com.loohp.interactionvisualizer.entityholders.VisualizerEntity; import com.loohp.interactionvisualizer.integration.packet.ClientPickupAnimationBridge; import com.loohp.interactionvisualizer.integration.packet.ClientTextDisplayBridge; +import com.loohp.interactionvisualizer.integration.CullingBounds; +import com.loohp.interactionvisualizer.integration.ViewerCullingManager; import com.loohp.interactionvisualizer.objectholders.SynchronizedFilteredCollection; import com.loohp.interactionvisualizer.utils.DisplayTransformFactory; import io.papermc.paper.event.packet.PlayerChunkLoadEvent; @@ -97,6 +99,7 @@ public final class DisplayManager implements Listener { private static final Set forceScheduled = ConcurrentHashMap.newKeySet(); private static final Map logicalByActualUuid = new ConcurrentHashMap<>(); private static final Map actualUuidByLogical = new ConcurrentHashMap<>(); + private static final Map logicalById = new ConcurrentHashMap<>(); private static final Map> logicalsByChunk = new ConcurrentHashMap<>(); private static final Map chunkByLogical = new ConcurrentHashMap<>(); private static final ViewerChunkIndex viewerChunks = new ViewerChunkIndex<>(); @@ -213,6 +216,8 @@ public static void dynamicEntity() { public static void shutdown() { Runnable cleanup = () -> { + InteractionVisualizer.viewerCullingManager.shutdown(); + InteractionVisualizer.viewerCullingManager = ViewerCullingManager.DISABLED; // An asynchronous removal can leave active before its main-thread // cleanup task runs. Include every representation index so disable // and hot reload still destroy client-only IDs instead of leaving @@ -236,6 +241,7 @@ public static void shutdown() { forceScheduled.clear(); logicalByActualUuid.clear(); actualUuidByLogical.clear(); + logicalById.clear(); logicalsByChunk.clear(); chunkByLogical.clear(); viewerChunks.clear(); @@ -401,6 +407,7 @@ public static void reset(Player player) { public static void removeAll(Player player) { runSync(() -> { UUID viewer = player.getUniqueId(); + InteractionVisualizer.viewerCullingManager.clearViewer(viewer); clearVisibilityShowQueue(viewer); VirtualItemIdBuffer virtualItemIdsToRemove = new VirtualItemIdBuffer(); Set shown = shownByViewer.remove(viewer); @@ -469,9 +476,11 @@ private static void schedule(VisualizerEntity logical, boolean force) { try { boolean forceSync = forceScheduled.remove(logical); if (active.containsKey(logical)) { + logicalById.put(logical.getUniqueId(), logical); ChunkKey previousChunk = chunkByLogical.get(logical); RepresentationKey previousRepresentation = representationKey(logical); index(logical); + updateCullingBounds(logical); int revision = logical.cacheCode(); if (forceSync || (requiresActualEntity(logical) && logical.getBukkitEntity().isEmpty()) || renderedRevision.getOrDefault(logical, Integer.MIN_VALUE) != revision) { @@ -1128,6 +1137,7 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation } boolean chunkChanged = !animation.staticAnchor && index(logical, world, destinationX, destinationZ); + updateCullingBounds(logical); if (chunkChanged) { reconcileViewers(logical); } @@ -1678,6 +1688,7 @@ private static void clearViewerTracking(VisualizerEntity logical) { } private static void clearViewerTracking(VisualizerEntity logical, org.bukkit.entity.Entity actual) { + InteractionVisualizer.viewerCullingManager.clearLogical(logical.getUniqueId()); cancelVisibilityShows(logical); if (logical instanceof BillboardDisplayEntity billboard) { forgetDynamicEntity(billboard); @@ -1728,6 +1739,59 @@ private static boolean isViewerDesired(VisualizerEntity logical, Player player) return isViewerRenderable(logical, player) && isViewerEnabledFor(logical, player); } + private static boolean usesCraftEngineCulling(VisualizerEntity logical) { + // Per-viewer billboard paths move around their anchor and therefore do + // not have one shared AABB. Keep their exact existing visibility path. + return InteractionVisualizer.viewerCullingManager.enabled() + && !(logical instanceof BillboardDisplayEntity); + } + + private static void updateCullingBounds(VisualizerEntity logical) { + if (!usesCraftEngineCulling(logical) || !active.containsKey(logical)) { + return; + } + InteractionVisualizer.viewerCullingManager.update( + logical.getUniqueId(), cullingBounds(logical)); + } + + private static CullingBounds cullingBounds(VisualizerEntity logical) { + double x; + double y; + double z; + if (logical instanceof Item item) { + ItemAnimationState animation = itemAnimations.get(item); + if (animation != null) { + x = animation.positionX; + y = animation.positionY; + z = animation.positionZ; + } else { + PacketItemPosition packetPosition = packetItemPositions.get(item); + if (packetPosition != null) { + x = packetPosition.x; + y = packetPosition.y; + z = packetPosition.z; + } else { + Location location = logical.getLocation(); + x = location.getX(); + y = location.getY(); + z = location.getZ(); + } + } + } else { + Location location = logical.getLocation(); + x = location.getX(); + y = location.getY(); + z = location.getZ(); + } + double width = logical instanceof ItemFrame ? 0.75D + : logical instanceof Item ? 0.5D : 1.0D; + double height = Math.max(0.25D, logical.getHeight()); + double halfWidth = width * 0.5D; + return new CullingBounds(x - halfWidth, y, z - halfWidth, + x + halfWidth, y + height, z + halfWidth, + 0, 0.2D, true); + } + private static boolean isQueuedViewerStillRenderable(VisualizerEntity logical, Player player) { // Eligibility was checked before enqueueing. Every internal eligibility // mutation (preference reset, reload, world/chunk lifecycle, removal) @@ -2194,33 +2258,83 @@ private static void removeOwnedEntities(boolean clearVisibilityOverrides) { private static void reconcileViewers(VisualizerEntity logical) { ChunkKey key = chunkByLogical.get(logical); Set candidates = key == null ? Set.of() : viewerChunks.viewers(key); + PerformanceMetrics.viewerReconcile(candidates.size()); + ViewerCullingManager culling = InteractionVisualizer.viewerCullingManager; Set shown = shownViewers.get(logical); if (shown != null) { for (UUID viewer : shown) { Player player = Bukkit.getPlayer(viewer); if (!candidates.contains(viewer) || player == null || !isViewerDesired(logical, player)) { + culling.untrack(viewer, logical.getUniqueId()); hideViewer(logical, viewer, player); } } } + Set retainedCullViewers = usesCraftEngineCulling(logical) + ? new HashSet<>() : Set.of(); for (UUID viewer : candidates) { Player player = Bukkit.getPlayer(viewer); - if (player != null) { - reconcileViewer(logical, player); + if (player != null && reconcileViewer(logical, player)) { + retainedCullViewers.add(viewer); } } + culling.retainLogical(logical.getUniqueId(), retainedCullViewers); } - private static void reconcileViewer(VisualizerEntity logical, Player player) { + /** Returns true when the optional culling backend owns this candidate. */ + private static boolean reconcileViewer(VisualizerEntity logical, Player player) { UUID viewer = player.getUniqueId(); if (!isViewerDesired(logical, player)) { + InteractionVisualizer.viewerCullingManager.untrack(viewer, logical.getUniqueId()); hideViewer(logical, viewer, player); - return; + return false; + } + if (usesCraftEngineCulling(logical)) { + PerformanceMetrics.craftEngineCullingCandidate(); + if (InteractionVisualizer.viewerCullingManager.track( + player, logical.getUniqueId(), cullingBounds(logical))) { + return true; + } } Set shown = shownViewers.get(logical); if (shown == null || !shown.contains(viewer)) { requestViewerShow(logical, player); } + return false; + } + + /** Called on the main thread after CraftEngine's async decisions are coalesced. */ + public static void onCullingVisibility(UUID viewer, UUID logicalId, boolean visible) { + PerformanceMetrics.craftEngineCullingDecision(visible); + runSync(() -> { + VisualizerEntity logical = logicalById.get(logicalId); + Player player = Bukkit.getPlayer(viewer); + if (logical == null || player == null || !player.isOnline()) { + InteractionVisualizer.viewerCullingManager.untrack(viewer, logicalId); + return; + } + if (!isViewerDesired(logical, player)) { + InteractionVisualizer.viewerCullingManager.untrack(viewer, logicalId); + hideViewer(logical, viewer, player); + return; + } + if (!InteractionVisualizer.viewerCullingManager.enabled()) { + requestViewerShow(logical, player); + } else if (visible) { + requestViewerShow(logical, player); + } else { + hideViewer(logical, viewer, player); + } + }); + } + + /** Rebuilds only sent-chunk candidates after the optional backend changes on reload. */ + public static void onCullingBackendChanged() { + runSync(() -> { + for (VisualizerEntity logical : active.keySet()) { + reconcileViewers(logical); + } + }); } private static void remove(Collection players, VisualizerEntity logical, boolean removeFromActive) { @@ -2261,6 +2375,8 @@ private static void remove(Collection players, VisualizerEntity logical, private static void clearRemovedLogicalState(VisualizerEntity logical) { active.remove(logical); + logicalById.remove(logical.getUniqueId(), logical); + InteractionVisualizer.viewerCullingManager.clearLogical(logical.getUniqueId()); renderedRevision.remove(logical); forceScheduled.remove(logical); if (logical instanceof BillboardDisplayEntity billboard) { @@ -2354,6 +2470,7 @@ public void onRespawn(PlayerRespawnEvent event) { @EventHandler public void onLeave(PlayerQuitEvent event) { UUID uuid = event.getPlayer().getUniqueId(); + InteractionVisualizer.viewerCullingManager.clearViewer(uuid); forgetDynamicViewer(uuid); clearVisibilityShowQueue(uuid); viewerChunks.removeViewer(uuid); @@ -2378,7 +2495,9 @@ public void onLeave(PlayerQuitEvent event) { } private static void resetViewerChunksAndSend(Player player) { - viewerChunks.removeViewer(player.getUniqueId()); + UUID viewer = player.getUniqueId(); + InteractionVisualizer.viewerCullingManager.clearViewer(viewer); + viewerChunks.removeViewer(viewer); Bukkit.getScheduler().runTask(plugin(), () -> { seedSentChunks(player); sendPlayerPackets(player); @@ -2417,6 +2536,8 @@ public void onPlayerChunkUnload(PlayerChunkUnloadEvent event) { Player player = event.getPlayer(); VirtualItemIdBuffer virtualItemIdsToRemove = new VirtualItemIdBuffer(); for (VisualizerEntity logical : logicals) { + InteractionVisualizer.viewerCullingManager.untrack( + player.getUniqueId(), logical.getUniqueId()); if (logical instanceof Item item && packetOnlyItems.contains(item)) { Integer id = forgetShownViewer(item, player.getUniqueId(), player); if (id != null) { @@ -2462,6 +2583,7 @@ public void onEntityRemove(EntityRemoveEvent event) { clearViewerTracking(logical); if (event.getCause() == EntityRemoveEvent.Cause.UNLOAD && movingItem) { active.remove(logical); + logicalById.remove(logical.getUniqueId(), logical); renderedRevision.remove(logical); unindex(logical); renderedItemStacks.remove((Item) logical); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java index 52f3df41..b268a80b 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -42,6 +42,7 @@ public final class PerformanceMetrics implements Listener { private String label = ""; private boolean configStaticAnchor; private boolean configPacketOnlyStatic; + private boolean configHideIfViewObstructed; private boolean configVisibilityRateLimit; private int configVisibilityBucketSize; private int configVisibilityRestorePerTick; @@ -69,6 +70,11 @@ public final class PerformanceMetrics implements Listener { private long virtualViewerChecks; private long visibilityShowsQueued; private long visibilityShowsDrained; + private long viewerFullReconciles; + private long viewerCandidates; + private long craftEngineCullingCandidates; + private long craftEngineCullingShowDecisions; + private long craftEngineCullingHideDecisions; private long itemAnimationNanos; private long droppedItemNanos; private long blockUpdateChecks; @@ -101,6 +107,8 @@ public static boolean start(String requestedLabel) { INSTANCE.label = sanitizeLabel(requestedLabel); INSTANCE.configStaticAnchor = InteractionVisualizer.staticVirtualItemAnchorsDuringAnimation; INSTANCE.configPacketOnlyStatic = InteractionVisualizer.packetOnlyStaticVirtualItems; + INSTANCE.configHideIfViewObstructed = InteractionVisualizer.hideIfObstructed + && InteractionVisualizer.viewerCullingManager.enabled(); INSTANCE.configVisibilityRateLimit = InteractionVisualizer.visibilityRateLimiting; INSTANCE.configVisibilityBucketSize = InteractionVisualizer.visibilityRateLimitBucketSize; INSTANCE.configVisibilityRestorePerTick = InteractionVisualizer.visibilityRateLimitRestorePerTick; @@ -126,6 +134,11 @@ public static boolean start(String requestedLabel) { INSTANCE.virtualViewerChecks = 0; INSTANCE.visibilityShowsQueued = 0; INSTANCE.visibilityShowsDrained = 0; + INSTANCE.viewerFullReconciles = 0; + INSTANCE.viewerCandidates = 0; + INSTANCE.craftEngineCullingCandidates = 0; + INSTANCE.craftEngineCullingShowDecisions = 0; + INSTANCE.craftEngineCullingHideDecisions = 0; INSTANCE.itemAnimationNanos = 0; INSTANCE.droppedItemNanos = 0; INSTANCE.blockUpdateChecks = 0; @@ -249,6 +262,29 @@ public static void visibilityShowDrained() { } } + public static void viewerReconcile(int candidates) { + if (INSTANCE.collecting) { + INSTANCE.viewerFullReconciles++; + INSTANCE.viewerCandidates += candidates; + } + } + + public static void craftEngineCullingCandidate() { + if (INSTANCE.collecting) { + INSTANCE.craftEngineCullingCandidates++; + } + } + + public static void craftEngineCullingDecision(boolean visible) { + if (INSTANCE.collecting) { + if (visible) { + INSTANCE.craftEngineCullingShowDecisions++; + } else { + INSTANCE.craftEngineCullingHideDecisions++; + } + } + } + public static void itemAnimationNanos(long nanos) { if (INSTANCE.collecting) { INSTANCE.itemAnimationNanos += nanos; @@ -299,7 +335,9 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) } mean = samples == 0 ? 0.0D : mean / samples; long elapsedNanos = Math.max(1L, System.nanoTime() - startedNanos); - return new Snapshot(label, configStaticAnchor, configPacketOnlyStatic, configVisibilityRateLimit, + int retainedCullingRegistrations = InteractionVisualizer.viewerCullingManager.retainedRegistrations(); + return new Snapshot(label, configStaticAnchor, configPacketOnlyStatic, configHideIfViewObstructed, + configVisibilityRateLimit, configVisibilityBucketSize, configVisibilityRestorePerTick, configDroppedLabelVisibility, configEventDrivenBlockUpdates, configBlockUpdateMaxDirtyPerTick, LegacyTextComponentCache.isEnabled(), @@ -313,6 +351,9 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) virtualPickupPackets, bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, packetOnlyItemSyncs, virtualViewerChecks, visibilityShowsQueued, visibilityShowsDrained, + viewerFullReconciles, viewerCandidates, craftEngineCullingCandidates, + craftEngineCullingShowDecisions, craftEngineCullingHideDecisions, + retainedCullingRegistrations, itemAnimationNanos, droppedItemNanos, blockUpdateChecks, blockUpdateNanos, textCache.requests(), textCache.misses(), textCache.sameRawFastPaths()); } @@ -345,6 +386,7 @@ public record Snapshot( String label, boolean staticAnchorDuringAnimation, boolean packetOnlyStatic, + boolean hideIfViewObstructed, boolean visibilityRateLimit, int visibilityBucketSize, int visibilityRestorePerTick, @@ -382,6 +424,12 @@ public record Snapshot( long virtualViewerChecks, long visibilityShowsQueued, long visibilityShowsDrained, + long viewerFullReconciles, + long viewerCandidates, + long craftEngineCullingCandidates, + long craftEngineCullingShowDecisions, + long craftEngineCullingHideDecisions, + int craftEngineCullingRetainedRegistrations, long itemAnimationNanos, long droppedItemNanos, long blockUpdateChecks, @@ -418,8 +466,9 @@ public double legacyTextCacheHitRate() { public String summary() { return String.format(Locale.ROOT, - "label=%s modes=%s/%s/%s/%s textCache=%s/%.1f%% samples=%d tps=%.3f p50/p95/p99=%.3f/%.3f/%.3fms virtualPackets=%d anchors=%d/%d/%d", - label, staticAnchorDuringAnimation, packetOnlyStatic, visibilityRateLimit, + "label=%s modes=%s/%s/%s/%s/%s textCache=%s/%.1f%% samples=%d tps=%.3f p50/p95/p99=%.3f/%.3f/%.3fms virtualPackets=%d anchors=%d/%d/%d", + label, staticAnchorDuringAnimation, packetOnlyStatic, hideIfViewObstructed, + visibilityRateLimit, eventDrivenBlockUpdates, legacyTextComponentCache, legacyTextCacheHitRate() * 100.0D, tickSamples, observedTps(), msptP50, msptP95, msptP99, knownVirtualPackets(), bukkitEntitySpawns, bukkitEntityTeleports, bukkitEntityRemoves); @@ -428,7 +477,8 @@ tickSamples, observedTps(), msptP50, msptP95, msptP99, knownVirtualPackets(), public String json() { return String.format(Locale.ROOT, "{\"label\":\"%s\",\"staticAnchorDuringAnimation\":%b," + - "\"packetOnlyStatic\":%b,\"visibilityRateLimit\":%b," + + "\"packetOnlyStatic\":%b,\"hideIfViewObstructed\":%b," + + "\"visibilityRateLimit\":%b," + "\"visibilityBucketSize\":%d,\"visibilityRestorePerTick\":%d," + "\"droppedLabelVisibilityCulling\":%b,\"droppedLabelViewDistance\":%d," + "\"droppedLabelVisibilityRateLimit\":%b," + @@ -451,12 +501,18 @@ public String json() { "\"bukkitHideCalls\":%d,\"displaySyncs\":%d,\"itemSyncs\":%d," + "\"packetOnlyItemSyncs\":%d,\"virtualViewerChecks\":%d," + "\"visibilityShowsQueued\":%d,\"visibilityShowsDrained\":%d," + + "\"viewerFullReconciles\":%d,\"viewerCandidates\":%d," + + "\"craftEngineCullingCandidates\":%d," + + "\"craftEngineCullingShowDecisions\":%d," + + "\"craftEngineCullingHideDecisions\":%d," + + "\"craftEngineCullingRetainedRegistrations\":%d," + "\"itemAnimationMs\":%.6f,\"droppedItemMs\":%.6f," + "\"blockUpdateChecks\":%d,\"blockUpdateMs\":%.6f," + "\"legacyTextCacheRequests\":%d,\"legacyTextCacheMisses\":%d," + "\"legacyTextCacheHits\":%d,\"legacyTextCacheHitRate\":%.6f," + "\"legacyTextSameRawFastPaths\":%d}", - label, staticAnchorDuringAnimation, packetOnlyStatic, visibilityRateLimit, + label, staticAnchorDuringAnimation, packetOnlyStatic, hideIfViewObstructed, + visibilityRateLimit, visibilityBucketSize, visibilityRestorePerTick, droppedLabelVisibility.cullingEnabled(), droppedLabelVisibility.viewDistance(), droppedLabelVisibility.rateLimitEnabled(), droppedLabelVisibility.bucketSize(), @@ -470,6 +526,9 @@ public String json() { knownVirtualPackets(), bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, packetOnlyItemSyncs, virtualViewerChecks, visibilityShowsQueued, visibilityShowsDrained, + viewerFullReconciles, viewerCandidates, craftEngineCullingCandidates, + craftEngineCullingShowDecisions, craftEngineCullingHideDecisions, + craftEngineCullingRetainedRegistrations, itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D, blockUpdateChecks, blockUpdateNanos / 1_000_000.0D, legacyTextCacheRequests, legacyTextCacheMisses, legacyTextCacheHits(), diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index 345ed3df..3f890114 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -328,9 +328,9 @@ Settings: UseHandSwingAnimation: true PickupAnimationPlayerYOffset: 0 DisabledWorlds: [] - #MIGHT BE RESOURCE INTENSIVE - #MIGHT BE RESOURCE INTENSIVE - #MIGHT BE RESOURCE INTENSIVE + #Optional second-stage wall-occlusion culling for entities in chunks already sent to each viewer. + #Requires CraftEngine 26.7+ with both entity culling and ray tracing enabled. + #CraftEngine checks distance/occlusion; this is not camera-direction or frustum culling. HideIfViewObstructed: false Performance: VirtualItems: diff --git a/common/src/test/java/com/loohp/interactionvisualizer/integration/CullingBoundsTest.java b/common/src/test/java/com/loohp/interactionvisualizer/integration/CullingBoundsTest.java new file mode 100644 index 00000000..a3deacdd --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/integration/CullingBoundsTest.java @@ -0,0 +1,43 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CullingBoundsTest { + + @Test + void acceptsAnUnlimitedFiniteBox() { + assertDoesNotThrow(() -> new CullingBounds( + -1.0D, 2.0D, -3.0D, 4.0D, 5.0D, 6.0D, + 0, 0.2D, true)); + } + + @Test + void rejectsInvalidProviderInputsAtTheNeutralBoundary() { + assertThrows(IllegalArgumentException.class, () -> new CullingBounds( + Double.NaN, 0.0D, 0.0D, 1.0D, 1.0D, 1.0D, + 0, 0.0D, true)); + assertThrows(IllegalArgumentException.class, () -> new CullingBounds( + 2.0D, 0.0D, 0.0D, 1.0D, 1.0D, 1.0D, + 0, 0.0D, true)); + assertThrows(IllegalArgumentException.class, () -> new CullingBounds( + 0.0D, 0.0D, 0.0D, 1.0D, 1.0D, 1.0D, + -1, 0.0D, true)); + assertThrows(IllegalArgumentException.class, () -> new CullingBounds( + 0.0D, 0.0D, 0.0D, 1.0D, 1.0D, 1.0D, + 0, -0.1D, true)); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoaderTest.java b/common/src/test/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoaderTest.java new file mode 100644 index 00000000..94b37b40 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/integration/ViewerCullingManagerLoaderTest.java @@ -0,0 +1,98 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration; + +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ViewerCullingManagerLoaderTest { + + @Test + void loadsTheImplementationOnlyAfterItsSentinelIsVisible() throws Exception { + ViewerCullingManager manager = ViewerCullingManagerLoader.load( + getClass().getClassLoader(), "java.lang.String", + FakeViewerCullingManager.class.getName(), null, + (viewer, logical, visible) -> { + }); + + assertInstanceOf(FakeViewerCullingManager.class, manager); + } + + @Test + void leavesCraftEngineClassesUnresolvedWhenTheProviderIsAbsent() { + assertThrows(ClassNotFoundException.class, () -> ViewerCullingManagerLoader.load( + getClass().getClassLoader(), ViewerCullingManagerLoader.CRAFT_ENGINE_SENTINEL, + ViewerCullingManagerLoader.CRAFT_ENGINE_IMPLEMENTATION, null, + (viewer, logical, visible) -> { + })); + } + + @Test + void rejectsImplementationsOutsideTheNeutralContract() { + assertThrows(ClassCastException.class, () -> ViewerCullingManagerLoader.load( + getClass().getClassLoader(), "java.lang.String", "java.lang.String", + null, (viewer, logical, visible) -> { + })); + } + + public static final class FakeViewerCullingManager implements ViewerCullingManager { + + public FakeViewerCullingManager(Plugin plugin, VisibilityListener listener) { + } + + @Override + public boolean enabled() { + return true; + } + + @Override + public boolean track(Player viewer, UUID logicalId, CullingBounds bounds) { + return true; + } + + @Override + public void update(UUID logicalId, CullingBounds bounds) { + } + + @Override + public void untrack(UUID viewerId, UUID logicalId) { + } + + @Override + public void clearViewer(UUID viewerId) { + } + + @Override + public void clearLogical(UUID logicalId) { + } + + @Override + public void retainLogical(UUID logicalId, Set viewerIds) { + } + + @Override + public void shutdown() { + } + + @Override + public int retainedRegistrations() { + return 0; + } + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/integration/craftengine/CullingRegistrationIndexTest.java b/common/src/test/java/com/loohp/interactionvisualizer/integration/craftengine/CullingRegistrationIndexTest.java new file mode 100644 index 00000000..194ef8f4 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/integration/craftengine/CullingRegistrationIndexTest.java @@ -0,0 +1,67 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.integration.craftengine; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CullingRegistrationIndexTest { + + @Test + void reverseIndexesRemoveOnlyTheRequestedViewerAndLogical() { + CullingRegistrationIndex index = new CullingRegistrationIndex<>(); + UUID viewerA = UUID.randomUUID(); + UUID viewerB = UUID.randomUUID(); + UUID logicalA = UUID.randomUUID(); + UUID logicalB = UUID.randomUUID(); + CullingRegistrationIndex.Key aA = new CullingRegistrationIndex.Key(viewerA, logicalA); + CullingRegistrationIndex.Key bA = new CullingRegistrationIndex.Key(viewerB, logicalA); + CullingRegistrationIndex.Key aB = new CullingRegistrationIndex.Key(viewerA, logicalB); + + assertNull(index.putIfAbsent(aA, "a/a")); + assertNull(index.putIfAbsent(bA, "b/a")); + assertNull(index.putIfAbsent(aB, "a/b")); + assertEquals("a/a", index.putIfAbsent(aA, "duplicate")); + assertEquals(3, index.size()); + + assertEquals(List.of("a/a"), index.retainLogical(logicalA, Set.of(viewerB))); + assertEquals("b/a", index.get(bA)); + assertTrue(index.hasLogical(logicalB)); + assertEquals("a/b", index.get(aB)); + + assertEquals(List.of("a/b"), index.removeViewer(viewerA)); + assertFalse(index.hasLogical(logicalB)); + assertEquals(List.of("b/a"), index.removeLogical(logicalA)); + assertTrue(index.isEmpty()); + } + + @Test + void clearReturnsEveryRetainedHandleAndEmptiesAllIndexes() { + CullingRegistrationIndex index = new CullingRegistrationIndex<>(); + UUID logical = UUID.randomUUID(); + index.putIfAbsent(new CullingRegistrationIndex.Key(UUID.randomUUID(), logical), 1); + index.putIfAbsent(new CullingRegistrationIndex.Key(UUID.randomUUID(), logical), 2); + + assertEquals(Set.of(1, 2), Set.copyOf(index.clear())); + assertEquals(0, index.size()); + assertFalse(index.hasLogical(logical)); + assertTrue(index.clear().isEmpty()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java index aae3795a..71a8ce0c 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java @@ -76,7 +76,7 @@ void resetRestoresNoSampleSentinel() { @Test void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { PerformanceMetrics.Snapshot snapshot = new PerformanceMetrics.Snapshot( - "diagnostic", false, false, false, 128, 32, + "diagnostic", false, false, false, false, 128, 32, new PerformanceMetrics.DroppedLabelVisibilityConfig(true, 64, true, 128, 32), true, 64, true, 1_000_000_000L, 20, 0L, @@ -86,6 +86,7 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, + 0L, 0L, 0L, 0L, 0L, 0, 0L, 0L, 7L, 12_345_678L, 100L, 5L, 200L); String json = snapshot.json(); @@ -100,6 +101,8 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { assertTrue(json.contains("\"droppedLabelVisibilityRateLimit\":true")); assertTrue(json.contains("\"droppedLabelVisibilityBucketSize\":128")); assertTrue(json.contains("\"droppedLabelVisibilityRestorePerTick\":32")); + assertTrue(json.contains("\"viewerFullReconciles\":0")); + assertTrue(json.contains("\"craftEngineCullingRetainedRegistrations\":0")); assertTrue(json.contains("\"legacyTextCacheHits\":95")); assertTrue(json.contains("\"legacyTextCacheHitRate\":0.950000")); assertTrue(json.contains("\"legacyTextSameRawFastPaths\":200")); From 09103ffbdcdd2003d0d6238585cc538f6ca36074 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 15:39:22 +0800 Subject: [PATCH 08/12] Coordinate block and workstation updates --- .../blocks/BannerDisplay.java | 78 +++++- .../blocks/BeaconDisplay.java | 109 ++++++++- .../blocks/BeeHiveDisplay.java | 36 +-- .../blocks/BeeNestDisplay.java | 36 +-- .../blocks/BlastFurnaceDisplay.java | 34 +-- .../blocks/BlockUpdateCoordinator.java | 224 ++++++++++++++++++ .../blocks/BrewingStandDisplay.java | 118 ++++++++- .../blocks/CampfireDisplay.java | 50 +++- .../blocks/CampfireDisplayUpdater.java | 75 ++++++ .../blocks/CartographyTableDisplay.java | 73 ++---- .../blocks/ConduitDisplay.java | 80 ++++++- .../blocks/CrafterDisplay.java | 99 ++++++-- .../blocks/CraftingTableDisplay.java | 106 +++------ .../blocks/FurnaceDisplay.java | 32 +-- .../blocks/JukeBoxDisplay.java | 78 +++++- .../blocks/LecternDisplay.java | 78 +++++- .../blocks/LoomDisplay.java | 88 +++---- .../blocks/NoteBlockDisplay.java | 40 ++-- .../blocks/SmokerDisplay.java | 35 ++- .../blocks/SoulCampfireDisplay.java | 52 +++- .../blocks/SpawnerDisplay.java | 69 +++++- .../blocks/StonecutterDisplay.java | 76 ++---- .../listeners/Events.java | 9 + .../EventDrivenBlockUpdateListener.java | 98 ++++++-- .../InteractionSessionCoordinator.java | 135 +++++++++++ .../managers/PerformanceMetrics.java | 23 ++ .../managers/TaskManager.java | 5 + .../PerformanceMetricsSlowestTickTest.java | 3 +- 28 files changed, 1536 insertions(+), 403 deletions(-) create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateCoordinator.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplayUpdater.java create mode 100644 common/src/main/java/com/loohp/interactionvisualizer/managers/InteractionSessionCoordinator.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BannerDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BannerDisplay.java index f7d70200..8eacb4b7 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BannerDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BannerDisplay.java @@ -92,6 +92,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = bannerMap.entrySet().iterator(); int count = 0; @@ -130,6 +133,16 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, + BlockUpdateCoordinator.materialsMatching(this::isBanner), this::nearbyBanner, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBanner(); for (Block block : list) { @@ -200,19 +213,76 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbyBanner().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || !isBanner(block.getType())) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = bannerMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + bannerMap.put(block, values); + } + updateTrackedBlock(block, values); + return false; + } + + private void updateTrackedBlock(Block block, Map values) { + Component name = ((Banner) block.getState(false)).customName(); + DisplayEntity line = (DisplayEntity) values.get("1"); + if (name == null || PlainTextComponentSerializer.plainText().serialize(name).isEmpty()) { + if (!PlainTextComponentSerializer.plainText().serialize(line.getCustomName()).isEmpty() + || line.isCustomNameVisible()) { + line.setCustomName(""); + line.setCustomNameVisible(false); + DisplayManager.updateDisplay(line); + } + return; + } + String matchingName = LegacyComponentSerializer.legacySection().serialize(name); + if (stripColorBlacklist) { + matchingName = ChatColorUtils.stripColor(matchingName); + } + if (blacklist.test(matchingName)) { + if (!PlainTextComponentSerializer.plainText().serialize(line.getCustomName()).isEmpty() + || line.isCustomNameVisible()) { + line.setCustomName(""); + line.setCustomNameVisible(false); + DisplayManager.updateDisplay(line); + } + } else if (!line.getCustomName().equals(name) || !line.isCustomNameVisible()) { + line.setCustomName(name); + line.setCustomNameVisible(true); + DisplayManager.updateDisplay(line); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBreakBanner(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!bannerMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = bannerMap.get(block); + private void removeTrackedDisplay(Block block) { + Map map = bannerMap.remove(block); + if (map == null) { + return; + } if (map.get("1") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - bannerMap.remove(block); } public Set nearbyBanner() { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeaconDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeaconDisplay.java index cedd3cad..5449b4b0 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeaconDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeaconDisplay.java @@ -88,6 +88,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = beaconMap.entrySet().iterator(); int count = 0; @@ -143,6 +146,15 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.BEACON), this::nearbyBeacon, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBeacon(); for (Block block : list) { @@ -178,7 +190,7 @@ public ScheduledTask run() { if (!block.getType().equals(Material.BEACON)) { return; } - org.bukkit.block.Beacon beacon = (org.bukkit.block.Beacon) block.getState(); + org.bukkit.block.Beacon beacon = (org.bukkit.block.Beacon) block.getState(false); { String arrow = "\u27f9"; String up = "\u25b2"; @@ -277,14 +289,102 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbyBeacon().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || block.getType() != Material.BEACON) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = beaconMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + beaconMap.put(block, values); + } + updateTrackedBlock(block, values); + return false; + } + + private void updateTrackedBlock(Block block, Map values) { + org.bukkit.block.Beacon beacon = (org.bukkit.block.Beacon) block.getState(false); + String arrow = "\u27f9"; + String up = "\u25b2"; + NamedTextColor color = getBeaconColor(block); + DisplayEntity line1 = (DisplayEntity) values.get("1"); + DisplayEntity line2 = (DisplayEntity) values.get("2"); + DisplayEntity line3 = (DisplayEntity) values.get("3"); + + Component summary = Component.text(up + beacon.getTier() + " " + arrow + " " + + DECIMAL_FORMAT.format(beacon.getEffectRange()) + "m", color); + if (beacon.getTier() == 0) { + updateLine(line1, null); + updateLine(line2, summary); + updateLine(line3, null); + return; + } + + Component primary = null; + Component secondary = null; + if (beacon.getPrimaryEffect() != null) { + TranslatableComponent effect = Component.translatable( + TranslationUtils.getEffect(beacon.getPrimaryEffect().getType())).color(color); + Component level = ComponentFont.parseFont(Component.text(" " + + RomanNumberUtils.toRoman(beacon.getPrimaryEffect().getAmplifier() + 1), color)); + primary = effect.append(level); + } + if (beacon.getSecondaryEffect() != null) { + TranslatableComponent effect = Component.translatable( + TranslationUtils.getEffect(beacon.getSecondaryEffect().getType())).color(color); + Component level = ComponentFont.parseFont(Component.text(" " + + RomanNumberUtils.toRoman(beacon.getSecondaryEffect().getAmplifier() + 1), color)); + secondary = effect.append(level); + } + if (secondary == null) { + updateLine(line1, null); + updateLine(line2, summary); + updateLine(line3, primary); + } else { + updateLine(line1, summary); + updateLine(line2, primary); + updateLine(line3, secondary); + } + } + + private static void updateLine(DisplayEntity line, Component component) { + if (component == null) { + if (!PlainTextComponentSerializer.plainText().serialize(line.getCustomName()).isEmpty() + || line.isCustomNameVisible()) { + line.setCustomName(""); + line.setCustomNameVisible(false); + DisplayManager.updateDisplay(line); + } + } else if (!line.getCustomName().equals(component) || !line.isCustomNameVisible()) { + line.setCustomName(component); + line.setCustomNameVisible(true); + DisplayManager.updateDisplay(line); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBreakBeacon(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!beaconMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = beaconMap.get(block); + private void removeTrackedDisplay(Block block) { + Map map = beaconMap.remove(block); + if (map == null) { + return; + } if (map.get("1") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -297,7 +397,6 @@ public void onBreakBeacon(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("3"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - beaconMap.remove(block); } public Set nearbyBeacon() { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java index 816042c4..a5523d55 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeHiveDisplay.java @@ -83,12 +83,9 @@ public class BeeHiveDisplay extends VisualizerRunnableDisplay implements Listene private String filledColor = "&e"; private String noCampfireColor = "&c"; private String beeCountText = "&e{Current}&6/{Max}"; - private final BlockUpdateScheduler blockUpdates; public BeeHiveDisplay() { onReload(new InteractionVisualizerReloadEvent()); - this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBeehive, - this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -162,15 +159,9 @@ public ScheduledTask run() { if (!InteractionVisualizer.eventDrivenBlockUpdates) { return legacyRun(); } - return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { - boolean collecting = PerformanceMetrics.isCollecting(); - long start = collecting ? System.nanoTime() : 0L; - int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), - InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); - if (collecting) { - PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); - } - }, 0, 1); + BlockUpdateCoordinator.register(this, Set.of(Material.BEEHIVE), this::nearbyBeehive, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; } private ScheduledTask legacyRun() { @@ -247,7 +238,7 @@ private boolean updateHybridBlock(Block block) { private void markDirty(Block block) { if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && block.getType() == Material.BEEHIVE && beehiveMap.containsKey(block)) { - blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty(this, block, (long) Bukkit.getCurrentTick() + 1L); } } @@ -342,21 +333,23 @@ public void onDispenserHarvest(BlockDispenseEvent event) { public void onBeehiveAdded(TileEntityAddedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.BEEHIVE) { - blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty( + this, event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); } } public void onBeehiveActivated(TileEntityActivatedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.BEEHIVE) { - blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty( + this, event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); } } public void onBeehiveDeactivated(TileEntityDeactivatedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.BEEHIVE) { - removeTrackedDisplay(event.getBlock()); + BlockUpdateCoordinator.remove(this, event.getBlock()); } } @@ -401,11 +394,18 @@ public void onInteractBeehive(PlayerInteractEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakBeehive(TileEntityRemovedEvent event) { - removeTrackedDisplay(event.getBlock()); + invalidateTrackedDisplay(event.getBlock()); + } + + private void invalidateTrackedDisplay(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); + } } private void removeTrackedDisplay(Block block) { - blockUpdates.remove(block); Map map = beehiveMap.remove(block); if (map == null) { return; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java index 321ac7e6..7d5206b4 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BeeNestDisplay.java @@ -83,12 +83,9 @@ public class BeeNestDisplay extends VisualizerRunnableDisplay implements Listene private String filledColor = "&e"; private String noCampfireColor = "&c"; private String beeCountText = "&e{Current}&6/{Max}"; - private final BlockUpdateScheduler blockUpdates; public BeeNestDisplay() { onReload(new InteractionVisualizerReloadEvent()); - this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBeenest, - this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -162,15 +159,9 @@ public ScheduledTask run() { if (!InteractionVisualizer.eventDrivenBlockUpdates) { return legacyRun(); } - return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { - boolean collecting = PerformanceMetrics.isCollecting(); - long start = collecting ? System.nanoTime() : 0L; - int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), - InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); - if (collecting) { - PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); - } - }, 0, 1); + BlockUpdateCoordinator.register(this, Set.of(Material.BEE_NEST), this::nearbyBeenest, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; } private ScheduledTask legacyRun() { @@ -247,7 +238,7 @@ private boolean updateHybridBlock(Block block) { private void markDirty(Block block) { if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && block.getType() == Material.BEE_NEST && beenestMap.containsKey(block)) { - blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty(this, block, (long) Bukkit.getCurrentTick() + 1L); } } @@ -342,21 +333,23 @@ public void onDispenserHarvest(BlockDispenseEvent event) { public void onBeenestAdded(TileEntityAddedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.BEE_NEST) { - blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty( + this, event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); } } public void onBeenestActivated(TileEntityActivatedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.BEE_NEST) { - blockUpdates.markDirty(event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty( + this, event.getBlock(), (long) Bukkit.getCurrentTick() + 1L); } } public void onBeenestDeactivated(TileEntityDeactivatedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.BEE_NEST) { - removeTrackedDisplay(event.getBlock()); + BlockUpdateCoordinator.remove(this, event.getBlock()); } } @@ -401,11 +394,18 @@ public void onInteractBeenest(PlayerInteractEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakBeenest(TileEntityRemovedEvent event) { - removeTrackedDisplay(event.getBlock()); + invalidateTrackedDisplay(event.getBlock()); + } + + private void invalidateTrackedDisplay(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); + } } private void removeTrackedDisplay(Block block) { - blockUpdates.remove(block); Map map = beenestMap.remove(block); if (map == null) { return; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java index 9c679bd5..8548b4b3 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlastFurnaceDisplay.java @@ -87,12 +87,9 @@ public class BlastFurnaceDisplay extends VisualizerRunnableDisplay implements Li private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; - private final BlockUpdateScheduler blockUpdates; public BlastFurnaceDisplay() { onReload(new InteractionVisualizerReloadEvent()); - this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyBlastFurnace, - this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -167,15 +164,10 @@ public ScheduledTask run() { if (!InteractionVisualizer.eventDrivenBlockUpdates) { return legacyRun(); } - return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { - boolean collecting = PerformanceMetrics.isCollecting(); - long start = collecting ? System.nanoTime() : 0L; - int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), - InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); - if (collecting) { - PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); - } - }, 0, 1); + BlockUpdateCoordinator.register(this, Set.of(Material.BLAST_FURNACE), + this::nearbyBlastFurnace, checkingPeriod, gcPeriod, + this::updateHybridBlock, this::removeTrackedDisplay); + return null; } private ScheduledTask legacyRun() { @@ -343,13 +335,14 @@ private boolean updateHybridBlock(Block block) { private void markDirty(Block block) { if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isBlastFurnace(block.getType())) { - blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty(this, block, (long) Bukkit.getCurrentTick() + 1L); } } private void markDirtyUnlessActive(Block block) { if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isBlastFurnace(block.getType())) { - blockUpdates.markDirtyUnlessActive(block, (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirtyUnlessActive( + this, block, (long) Bukkit.getCurrentTick() + 1L); } } @@ -409,7 +402,7 @@ public void onBlastFurnaceActivated(TileEntityActivatedEvent event) { public void onBlastFurnaceDeactivated(TileEntityDeactivatedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.BLAST_FURNACE) { - removeTrackedDisplay(event.getBlock()); + BlockUpdateCoordinator.remove(this, event.getBlock()); } } @@ -574,11 +567,18 @@ public void onDragBlastFurnace(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakBlastFurnace(TileEntityRemovedEvent event) { - removeTrackedDisplay(event.getBlock()); + invalidateTrackedDisplay(event.getBlock()); + } + + private void invalidateTrackedDisplay(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); + } } private void removeTrackedDisplay(Block block) { - blockUpdates.remove(block); Map map = blastfurnaceMap.remove(block); if (map == null) { return; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateCoordinator.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateCoordinator.java new file mode 100644 index 00000000..de3b8c0c --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BlockUpdateCoordinator.java @@ -0,0 +1,224 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; +import com.loohp.interactionvisualizer.scheduler.ScheduledTask; +import com.loohp.interactionvisualizer.scheduler.Scheduler; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.Block; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * One main-thread heartbeat for every built-in autonomous block display. + * Events enqueue exact material lanes, active timers rotate at their existing + * CheckingPeriod, and each lane keeps a bounded low-frequency safety audit. + */ +public final class BlockUpdateCoordinator { + + private static final Map LANES = new IdentityHashMap<>(); + private static final Map> LANES_BY_MATERIAL = new EnumMap<>(Material.class); + + private static ScheduledTask heartbeat; + + private BlockUpdateCoordinator() { + } + + static void register(Object owner, Collection materials, + Supplier> auditSource, + int activePeriod, int auditPeriod, + BlockUpdateScheduler.Updater updater, + Consumer remover) { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(materials, "materials"); + if (LANES.containsKey(owner)) { + return; + } + Lane lane = new Lane(Set.copyOf(materials), auditSource, + activePeriod, auditPeriod, updater, remover); + LANES.put(owner, lane); + for (Material material : lane.materials) { + LANES_BY_MATERIAL.computeIfAbsent(material, ignored -> new ArrayList<>()).add(lane); + } + if (heartbeat == null || heartbeat.isCancelled()) { + heartbeat = Scheduler.runTaskTimer(InteractionVisualizer.plugin, + BlockUpdateCoordinator::tick, 0L, 1L); + } + } + + static Set materialsMatching(Predicate predicate) { + EnumSet materials = EnumSet.noneOf(Material.class); + for (Material material : Material.values()) { + if (predicate.test(material)) { + materials.add(material); + } + } + return materials; + } + + static void markDirty(Object owner, Block block, long readyTick) { + Lane lane = LANES.get(owner); + if (lane != null && block != null && lane.materials.contains(block.getType())) { + lane.scheduler.markDirty(block, readyTick); + } + } + + static void markDirtyUnlessActive(Object owner, Block block, long readyTick) { + Lane lane = LANES.get(owner); + if (lane != null && block != null && lane.materials.contains(block.getType())) { + lane.scheduler.markDirtyUnlessActive(block, readyTick); + } + } + + static void remove(Object owner, Block block) { + Lane lane = LANES.get(owner); + if (lane != null) { + lane.remove(block); + } + } + + /** Marks every registered display lane matching the block's current material. */ + public static void markDirty(Block block) { + markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + } + + public static void markDirty(Block block, long readyTick) { + if (block == null) { + return; + } + List lanes = LANES_BY_MATERIAL.get(block.getType()); + if (lanes != null) { + for (Lane lane : lanes) { + lane.scheduler.markDirty(block, readyTick); + } + } + } + + public static void markDirtyUnlessActive(Block block) { + if (block == null) { + return; + } + List lanes = LANES_BY_MATERIAL.get(block.getType()); + if (lanes != null) { + long readyTick = (long) Bukkit.getCurrentTick() + 1L; + for (Lane lane : lanes) { + lane.scheduler.markDirtyUnlessActive(block, readyTick); + } + } + } + + /** Invalidates a block in every lane, including after its material changed. */ + public static void remove(Block block) { + if (block == null) { + return; + } + for (Lane lane : LANES.values()) { + lane.remove(block); + } + } + + public static boolean isEmpty() { + return LANES.isEmpty(); + } + + public static boolean tracks(Material material) { + List lanes = LANES_BY_MATERIAL.get(material); + return lanes != null && !lanes.isEmpty(); + } + + public static int retainedLaneCount() { + return LANES.size(); + } + + public static int pendingDirtyCount() { + int count = 0; + for (Lane lane : LANES.values()) { + count += lane.scheduler.pendingDirtyCount(); + } + return count; + } + + public static int activeCount() { + int count = 0; + for (Lane lane : LANES.values()) { + count += lane.scheduler.activeCount(); + } + return count; + } + + public static void shutdown() { + ScheduledTask task = heartbeat; + heartbeat = null; + if (task != null && !task.isCancelled()) { + task.cancel(); + } + LANES.clear(); + LANES_BY_MATERIAL.clear(); + } + + private static void tick() { + boolean collecting = PerformanceMetrics.isCollecting(); + long started = collecting ? System.nanoTime() : 0L; + int checks = 0; + long tick = Bukkit.getCurrentTick(); + for (Lane lane : LANES.values()) { + checks += lane.scheduler.tick(tick, + InteractionVisualizer.blockUpdateMaxDirtyPerTick, lane.updater); + } + if (collecting) { + PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - started); + PerformanceMetrics.blockUpdateQueues( + LANES.size(), pendingDirtyCount(), activeCount()); + } + } + + private static final class Lane { + + private final Set materials; + private final BlockUpdateScheduler scheduler; + private final BlockUpdateScheduler.Updater updater; + private final Consumer remover; + + private Lane(Set materials, + Supplier> auditSource, + int activePeriod, int auditPeriod, + BlockUpdateScheduler.Updater updater, + Consumer remover) { + this.materials = materials; + this.scheduler = new BlockUpdateScheduler<>(auditSource, activePeriod, auditPeriod); + this.updater = Objects.requireNonNull(updater, "updater"); + this.remover = Objects.requireNonNull(remover, "remover"); + } + + private void remove(Block block) { + if (block == null) { + return; + } + scheduler.remove(block); + remover.accept(block); + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplay.java index 2b4e1c85..14ad7162 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/BrewingStandDisplay.java @@ -93,6 +93,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = brewstand.entrySet().iterator(); int count = 0; @@ -140,6 +143,16 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.BREWING_STAND), + this::nearbyBrewingStand, checkingPeriod, gcPeriod, + this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyBrewingStand(); for (Block block : list) { @@ -175,7 +188,8 @@ public ScheduledTask run() { if (!block.getType().equals(Material.BREWING_STAND)) { return; } - org.bukkit.block.BrewingStand brewingstand = (org.bukkit.block.BrewingStand) block.getState(); + org.bukkit.block.BrewingStand brewingstand = + (org.bukkit.block.BrewingStand) block.getState(false); { Inventory inv = brewingstand.getInventory(); @@ -266,6 +280,91 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbyBrewingStand().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || block.getType() != Material.BREWING_STAND) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = brewstand.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + brewstand.put(block, values); + } + org.bukkit.block.BrewingStand state = (org.bukkit.block.BrewingStand) block.getState(false); + updateTrackedBlock(state, values); + return state.getBrewingTime() > 0; + } + + private void updateTrackedBlock(org.bukkit.block.BrewingStand state, Map values) { + Inventory inventory = state.getInventory(); + ItemStack ingredient = inventory.getItem(3); + if (ingredient != null && ingredient.getType() == Material.AIR) { + ingredient = null; + } + if (values.get("Item") instanceof String) { + if (ingredient != null) { + Item item = new Item(state.getLocation().clone().add(0.5, 1.0, 0.5)); + item.setItemStack(ingredient); + item.setVelocity(new Vector()); + item.setPickupDelay(32767); + item.setGravity(false); + values.put("Item", item); + DisplayManager.sendItemSpawn( + InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + } + } else { + Item item = (Item) values.get("Item"); + if (ingredient == null) { + values.put("Item", "N/A"); + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); + } else if (!item.getItemStack().equals(ingredient)) { + item.setItemStack(ingredient); + DisplayManager.updateItem(item); + } + } + + DisplayEntity stand = (DisplayEntity) values.get("Stand"); + if (!hasPotion(state)) { + if (stand.updateCustomName("", false)) { + DisplayManager.updateDisplay(stand); + } + return; + } + if (state.getFuelLevel() == 0) { + if (stand.updateCustomName(noFuelColor + progressBarCharacter.repeat( + Math.max(0, progressBarLength)), true)) { + DisplayManager.updateDisplay(stand); + } + return; + } + double scaled = (double) (max - state.getBrewingTime()) / (double) max + * (double) progressBarLength; + StringBuilder symbol = new StringBuilder(Math.max(16, progressBarLength * 3)); + double index = 1.0D; + for (; index < scaled; index++) { + symbol.append(filledColor).append(progressBarCharacter); + } + index--; + if (scaled - index > 0.0D && scaled - index < 0.67D) { + symbol.append(emptyColor).append(progressBarCharacter); + } else if (scaled - index > 0.0D) { + symbol.append(filledColor).append(progressBarCharacter); + } + for (index = progressBarLength - 1.0D; index >= scaled; index--) { + symbol.append(emptyColor).append(progressBarCharacter); + } + if (stand.updateCustomName(symbol.toString(), true)) { + DisplayManager.updateDisplay(stand); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onUseBrewingStand(InventoryClickEvent event) { if (event.isCancelled()) { @@ -292,6 +391,7 @@ public void onUseBrewingStand(InventoryClickEvent event) { } if (event.getRawSlot() >= 0 && event.getRawSlot() <= 4) { + BlockUpdateCoordinator.markDirty(event.getView().getTopInventory().getLocation().getBlock()); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); } } @@ -323,6 +423,8 @@ public void onDragBrewingStand(InventoryDragEvent event) { for (int slot : event.getRawSlots()) { if (slot >= 0 && slot <= 4) { + BlockUpdateCoordinator.markDirty( + event.getView().getTopInventory().getLocation().getBlock()); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); break; } @@ -332,11 +434,18 @@ public void onDragBrewingStand(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakBrewingStand(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!brewstand.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = brewstand.get(block); + private void removeTrackedDisplay(Block block) { + Map map = brewstand.remove(block); + if (map == null) { + return; + } if (map.get("Item") instanceof Item) { Item item = (Item) map.get("Item"); DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); @@ -345,7 +454,6 @@ public void onBreakBrewingStand(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("Stand"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - brewstand.remove(block); } public boolean hasPotion(org.bukkit.block.BrewingStand brewingstand) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplay.java index ee36c1e3..af6d779d 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplay.java @@ -89,6 +89,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = campfireMap.entrySet().iterator(); int count = 0; @@ -152,6 +155,15 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.CAMPFIRE), this::nearbyCampfire, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyCampfire(); for (Block block : list) { @@ -186,7 +198,7 @@ public ScheduledTask run() { if (!block.getType().equals(Material.CAMPFIRE)) { return; } - org.bukkit.block.Campfire campfire = (org.bukkit.block.Campfire) block.getState(); + org.bukkit.block.Campfire campfire = (org.bukkit.block.Campfire) block.getState(false); boolean isLit = ((Campfire) block.getBlockData()).isLit(); { @@ -342,14 +354,41 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbyCampfire().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || block.getType() != Material.CAMPFIRE) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = campfireMap.get(block); + if (values == null) { + values = new HashMap<>(spawnDisplayEntitys(block)); + campfireMap.put(block, values); + } + org.bukkit.block.Campfire state = (org.bukkit.block.Campfire) block.getState(false); + return CampfireDisplayUpdater.update(state, values, progressBarCharacter, + emptyColor, filledColor, progressBarLength); + } + @EventHandler(priority = EventPriority.MONITOR) public void onBreakCampfire(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!campfireMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = campfireMap.get(block); + private void removeTrackedDisplay(Block block) { + Map map = campfireMap.remove(block); + if (map == null) { + return; + } if (map.get("1") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -366,7 +405,6 @@ public void onBreakCampfire(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("4"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - campfireMap.remove(block); } public Set nearbyCampfire() { @@ -381,7 +419,7 @@ public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); - BlockData blockData = block.getState().getBlockData(); + BlockData blockData = block.getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); Location target = block.getRelative(facing).getLocation(); Vector direction = rotateVectorAroundY(target.toVector().subtract(origin.toVector()).multiply(0.44194173), 135); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplayUpdater.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplayUpdater.java new file mode 100644 index 00000000..0b0b0be9 --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CampfireDisplayUpdater.java @@ -0,0 +1,75 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.blocks; + +import com.loohp.interactionvisualizer.entityholders.DisplayEntity; +import com.loohp.interactionvisualizer.managers.DisplayManager; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import java.util.Map; + +/** Shared allocation-light progress update for normal and soul campfires. */ +final class CampfireDisplayUpdater { + + private CampfireDisplayUpdater() { + } + + static boolean update(org.bukkit.block.Campfire campfire, Map values, + String progressBarCharacter, String emptyColor, String filledColor, + int progressBarLength) { + boolean lit = campfire.getBlockData() instanceof org.bukkit.block.data.type.Campfire data + && data.isLit(); + boolean active = false; + for (int slot = 0; slot < 4; slot++) { + ItemStack item = campfire.getItem(slot); + boolean cooking = lit && item != null && item.getType() != Material.AIR; + active |= cooking; + DisplayEntity line = (DisplayEntity) values.get(Integer.toString(slot + 1)); + if (!cooking) { + if (line.updateCustomName("", false)) { + DisplayManager.updateDisplay(line); + } + continue; + } + String progress = progress(campfire.getCookTime(slot), campfire.getCookTimeTotal(slot), + progressBarCharacter, emptyColor, filledColor, progressBarLength); + if (line.updateCustomName(progress, true)) { + DisplayManager.updateDisplay(line); + } + } + return active; + } + + static String progress(int time, int maximum, String character, + String emptyColor, String filledColor, int length) { + int boundedLength = Math.max(0, length); + double scaled = maximum <= 0 ? 0.0D + : Math.max(0.0D, Math.min((double) boundedLength, + (double) time / (double) maximum * (double) boundedLength)); + StringBuilder symbol = new StringBuilder(Math.max(16, boundedLength * 3)); + double index = 1.0D; + for (; index < scaled; index++) { + symbol.append(filledColor).append(character); + } + index--; + if (scaled - index > 0.0D && scaled - index < 0.67D) { + symbol.append(emptyColor).append(character); + } else if (scaled - index > 0.0D) { + symbol.append(filledColor).append(character); + } + for (index = boundedLength - 1.0D; index >= scaled; index--) { + symbol.append(emptyColor).append(character); + } + return symbol.toString(); + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CartographyTableDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CartographyTableDisplay.java index 3f34fa79..9a1bb381 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CartographyTableDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CartographyTableDisplay.java @@ -27,9 +27,9 @@ import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.entityholders.Item.RenderMode; import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.managers.InteractionSessionCoordinator; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.VanishUtils; -import com.loohp.interactionvisualizer.scheduler.ScheduledRunnable; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import org.bukkit.FluidCollisionMode; import org.bukkit.GameMode; @@ -49,7 +49,6 @@ import org.bukkit.inventory.ItemStack; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; public class CartographyTableDisplay extends VisualizerInteractDisplay implements Listener { @@ -66,49 +65,9 @@ public EntryKey key() { @Override public ScheduledTask run() { - return new ScheduledRunnable() { - public void run() { - - Iterator itr = openedCTable.keySet().iterator(); - int count = 0; - int maxper = (int) Math.ceil((double) openedCTable.size() / (double) 5); - int delay = 1; - while (itr.hasNext()) { - count++; - if (count > maxper) { - count = 0; - delay++; - } - Block block = itr.next(); - new ScheduledRunnable() { - public void run() { - if (!openedCTable.containsKey(block)) { - return; - } - Map map = openedCTable.get(block); - if (block.getType().equals(Material.CARTOGRAPHY_TABLE)) { - Player player = (Player) map.get("Player"); - if (!GameMode.SPECTATOR.equals(player.getGameMode())) { - if (player.getOpenInventory() != null) { - if (player.getOpenInventory().getTopInventory() != null) { - if (player.getOpenInventory().getTopInventory() instanceof CartographyInventory) { - return; - } - } - } - } - } - - if (map.get("Item") instanceof Item item) { - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); - } - openedCTable.remove(block); - playermap.remove((Player) map.get("Player"), block); - } - }.runTaskLater(InteractionVisualizer.plugin, delay, block.getLocation()); - } - } - }.runTaskTimer(InteractionVisualizer.plugin, 0, 6); + InteractionSessionCoordinator.register(this, playermap::keySet, + this::isSessionValid, this::cleanupSession); + return null; } @Override @@ -146,6 +105,7 @@ public void process(Player player) { if (!map.get("Player").equals(player)) { return; } + InteractionSessionCoordinator.touch(); ItemStack input = view.getItem(0); if (input != null) { @@ -237,21 +197,36 @@ public void onDragCartographyTable(InventoryDragEvent event) { @EventHandler public void onCloseCartographyTable(InventoryCloseEvent event) { - Player player = (Player) event.getPlayer(); + cleanupSession((Player) event.getPlayer()); + } + + private boolean isSessionValid(Player player) { + Block block = playermap.get(player); + if (block == null) { + return false; + } + Map map = openedCTable.get(block); + return player.isOnline() && !GameMode.SPECTATOR.equals(player.getGameMode()) + && map != null && player.equals(map.get("Player")) + && block.getWorld().equals(player.getWorld()) + && block.getType() == Material.CARTOGRAPHY_TABLE + && player.getOpenInventory().getTopInventory() instanceof CartographyInventory; + } + + private void cleanupSession(Player player) { Block block = playermap.remove(player); if (block == null) { return; } - Map map = openedCTable.get(block); - if (map == null || !map.get("Player").equals(player)) { + if (map == null || !player.equals(map.get("Player"))) { return; } if (map.get("Item") instanceof Item item) { DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } - openedCTable.remove(block); + openedCTable.remove(block, map); } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ConduitDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ConduitDisplay.java index 8157285d..26eced24 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/ConduitDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/ConduitDisplay.java @@ -86,6 +86,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = conduitMap.entrySet().iterator(); int count = 0; @@ -133,6 +136,15 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.CONDUIT), this::nearbyConduit, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyConduit(); for (Block block : list) { @@ -208,6 +220,58 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbyConduit().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || block.getType() != Material.CONDUIT) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = conduitMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put("Item", "N/A"); + values.putAll(spawnDisplayEntitys(block)); + conduitMap.put(block, values); + } + updateTrackedBlock(block, values); + return false; + } + + private void updateTrackedBlock(Block block, Map values) { + int amount = getFrameAmount(block); + int range = getRange(amount); + NamedTextColor color = range > 0 ? NamedTextColor.AQUA : NamedTextColor.YELLOW; + Component summary = Component.text("\u2b1b" + amount + " \u27f9 " + range + "m", color); + DisplayEntity line1 = (DisplayEntity) values.get("1"); + DisplayEntity line2 = (DisplayEntity) values.get("2"); + if (!line1.getCustomName().equals(summary) || !line1.isCustomNameVisible()) { + line1.setCustomName(summary); + line1.setCustomNameVisible(true); + DisplayManager.updateDisplay(line1); + } + if (range < 96) { + if (!PlainTextComponentSerializer.plainText().serialize(line2.getCustomName()).isEmpty() + || line2.isCustomNameVisible()) { + line2.setCustomName(""); + line2.setCustomNameVisible(false); + DisplayManager.updateDisplay(line2); + } + } else { + Component damage = Component.text("4(", NamedTextColor.AQUA) + .append(Component.text("\u2665\u2665", NamedTextColor.RED)) + .append(Component.text(") / 2s", NamedTextColor.AQUA)); + if (!line2.getCustomName().equals(damage) || !line2.isCustomNameVisible()) { + line2.setCustomName(damage); + line2.setCustomNameVisible(true); + DisplayManager.updateDisplay(line2); + } + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onPlaceConduit(BlockPlaceEvent event) { if (event.isCancelled()) { @@ -223,16 +287,25 @@ public void onPlaceConduit(BlockPlaceEvent event) { } placemap.put(block, new float[] {event.getPlayer().getLocation().getYaw(), event.getPlayer().getLocation().getPitch()}); + BlockUpdateCoordinator.markDirty(block); } @EventHandler(priority = EventPriority.MONITOR) public void onBreakConduit(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!conduitMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = conduitMap.get(block); + private void removeTrackedDisplay(Block block) { + Map map = conduitMap.remove(block); + placemap.remove(block); + if (map == null) { + return; + } if (map.get("1") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -241,7 +314,6 @@ public void onBreakConduit(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("2"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - conduitMap.remove(block); } public Set nearbyConduit() { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CrafterDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CrafterDisplay.java index 9ee6cc48..d3c1601a 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CrafterDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CrafterDisplay.java @@ -85,6 +85,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = crafterMap.entrySet().iterator(); int count = 0; @@ -128,6 +131,15 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.CRAFTER), this::nearbyCrafter, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyCrafter(); for (Block block : list) { @@ -158,9 +170,28 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbyCrafter().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || block.getType() != Material.CRAFTER || getCardinalDirection(block) < 0.0F) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = crafterMap.get(block); + if (values == null) { + values = new HashMap<>(spawnDisplayEntitys(block)); + crafterMap.put(block, values); + } + handleUpdate(block, values); + return false; + } + public void handleUpdate(Block block, Map map) { if (block.getType() != Material.CRAFTER || crafterMap.get(block) != map - || !(block.getState() instanceof Crafter crafter)) { + || !(block.getState(false) instanceof Crafter crafter)) { return; } Inventory inventory = crafter.getInventory(); @@ -214,12 +245,16 @@ public void onCrafterDropItem(BlockDispenseEvent event) { } Block block = event.getBlock(); if (block.getType().equals(Material.CRAFTER)) { - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - Map map = crafterMap.get(block); - if (map != null) { - handleUpdate(block, map); - } - }, 1, block.getLocation()); + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.markDirty(block); + } else { + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + Map map = crafterMap.get(block); + if (map != null) { + handleUpdate(block, map); + } + }, 1, block.getLocation()); + } } } @@ -232,28 +267,31 @@ public void onCrafterMoveItems(InventoryMoveItemEvent event) { if (initiatorLocation != null) { Block block = initiatorLocation.getBlock(); if (block.getType().equals(Material.CRAFTER)) { - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - Map map = crafterMap.get(block); - if (map != null) { - handleUpdate(block, map); - } - }, 1, block.getLocation()); + markCrafterChanged(block); } } Location destinationLocation = event.getDestination().getLocation(); if (destinationLocation != null) { Block block = destinationLocation.getBlock(); if (block.getType().equals(Material.CRAFTER)) { - Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { - Map map = crafterMap.get(block); - if (map != null) { - handleUpdate(block, map); - } - }, 1, block.getLocation()); + markCrafterChanged(block); } } } + private void markCrafterChanged(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.markDirty(block); + } else { + Scheduler.runTaskLater(InteractionVisualizer.plugin, () -> { + Map map = crafterMap.get(block); + if (map != null) { + handleUpdate(block, map); + } + }, 1, block.getLocation()); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onUseCrafter(InventoryClickEvent event) { if (event.isCancelled()) { @@ -281,29 +319,38 @@ public void onUseCrafter(InventoryClickEvent event) { } if (event.getRawSlot() >= 0 && event.getRawSlot() <= 8) { + BlockUpdateCoordinator.markDirty(block); DisplayManager.sendHandMovement(InteractionVisualizerAPI.getPlayers(), (Player) event.getWhoClicked()); } - Map map = crafterMap.get(block); - if (map != null) { - handleUpdate(block, map); + if (!InteractionVisualizer.eventDrivenBlockUpdates) { + Map map = crafterMap.get(block); + if (map != null) { + handleUpdate(block, map); + } } } @EventHandler(priority = EventPriority.MONITOR) public void onBreakCrafter(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!crafterMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = crafterMap.get(block); + private void removeTrackedDisplay(Block block) { + Map map = crafterMap.remove(block); + if (map == null) { + return; + } for (int i = 1; i <= 9; i++) { if (map.get(String.valueOf(i)) instanceof Item) { Item stand = (Item) map.get(String.valueOf(i)); DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), stand); } } - crafterMap.remove(block); } public Set nearbyCrafter() { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CraftingTableDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CraftingTableDisplay.java index 8eac80a2..e146284d 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/CraftingTableDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/CraftingTableDisplay.java @@ -26,6 +26,7 @@ import com.loohp.interactionvisualizer.api.VisualizerInteractDisplay; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.managers.InteractionSessionCoordinator; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.objectholders.LightType; import com.loohp.interactionvisualizer.utils.InventoryUtils; @@ -33,7 +34,6 @@ import com.loohp.interactionvisualizer.utils.MaterialUtils.MaterialMode; import com.loohp.interactionvisualizer.utils.VanishUtils; import com.loohp.interactionvisualizer.utils.WorkstationDisplayPositioning; -import com.loohp.interactionvisualizer.scheduler.ScheduledRunnable; import com.loohp.interactionvisualizer.scheduler.ScheduledTask; import com.loohp.interactionvisualizer.scheduler.Scheduler; import org.bukkit.Bukkit; @@ -58,7 +58,6 @@ import org.bukkit.util.Vector; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; public class CraftingTableDisplay extends VisualizerInteractDisplay implements Listener { @@ -75,55 +74,9 @@ public EntryKey key() { @Override public ScheduledTask run() { - return new ScheduledRunnable() { - public void run() { - Iterator itr = openedBenches.keySet().iterator(); - int count = 0; - int maxper = (int) Math.ceil((double) openedBenches.size() / (double) 5); - int delay = 1; - while (itr.hasNext()) { - count++; - if (count > maxper) { - count = 0; - delay++; - } - Block block = itr.next(); - new ScheduledRunnable() { - public void run() { - if (!openedBenches.containsKey(block)) { - return; - } - Map map = openedBenches.get(block); - - Player player = (Player) map.get("Player"); - if (!GameMode.SPECTATOR.equals(player.getGameMode())) { - if (player.getOpenInventory() != null) { - if (player.getOpenInventory().getTopInventory() != null) { - if (player.getOpenInventory().getTopInventory().getLocation().getBlock().getType() == Material.CRAFTING_TABLE) { - return; - } - } - } - } - - for (int i = 0; i <= 9; i++) { - if (!(map.get(String.valueOf(i)) instanceof String)) { - Object entity = map.get(String.valueOf(i)); - if (i == 5) { - InteractionVisualizer.lightManager.deleteLight(((Item) entity).getLocation()); - } - if (entity instanceof Item) { - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), (Item) entity); - } - } - } - openedBenches.remove(block); - playermap.remove(player, block); - } - }.runTaskLater(InteractionVisualizer.plugin, delay, block.getLocation()); - } - } - }.runTaskTimer(InteractionVisualizer.plugin, 0, 5); + InteractionSessionCoordinator.register(this, playermap::keySet, + this::isSessionValid, this::cleanupSession); + return null; } @Override @@ -160,12 +113,12 @@ public void process(Player player) { map.putAll(spawnDisplayEntitys(player, block)); openedBenches.put(block, map); } - Map map = openedBenches.get(block); if (!map.get("Player").equals(player)) { return; } + InteractionSessionCoordinator.touch(); ItemStack[] items = new ItemStack[] { view.getItem(1), view.getItem(2), @@ -438,35 +391,48 @@ public void onDragCraftingBench(InventoryDragEvent event) { @EventHandler public void onCloseCraftingBench(InventoryCloseEvent event) { - Player player = (Player) event.getPlayer(); + cleanupSession((Player) event.getPlayer()); + } + + private boolean isSessionValid(Player player) { + Block block = playermap.get(player); + if (block == null) { + return false; + } + Map map = openedBenches.get(block); + if (!player.isOnline() || GameMode.SPECTATOR.equals(player.getGameMode()) + || map == null || !player.equals(map.get("Player")) + || !block.getWorld().equals(player.getWorld()) + || block.getType() != Material.CRAFTING_TABLE) { + return false; + } + try { + Location location = player.getOpenInventory().getTopInventory().getLocation(); + return location != null && block.equals(location.getBlock()); + } catch (Exception | AbstractMethodError ignored) { + return false; + } + } + + private void cleanupSession(Player player) { Block block = playermap.remove(player); if (block == null) { return; } - Map map = openedBenches.get(block); - if (map == null || !map.get("Player").equals(player)) { + if (map == null || !player.equals(map.get("Player"))) { return; } - for (int i = 0; i <= 9; i++) { - if (!(map.get(String.valueOf(i)) instanceof String)) { - Object entity = map.get(String.valueOf(i)); - if (entity instanceof Item) { - Item item = (Item) entity; - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); - int finalI = i; - new ScheduledRunnable() { - public void run() { - if (finalI == 5) { - InteractionVisualizer.lightManager.deleteLight(item.getLocation()); - } - } - }.runTaskLater(InteractionVisualizer.plugin, 20); + Object entity = map.get(Integer.toString(i)); + if (entity instanceof Item item) { + if (i == 5) { + InteractionVisualizer.lightManager.deleteLight(item.getLocation()); } + DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } } - openedBenches.remove(block); + openedBenches.remove(block, map); } public Item.RenderMode standMode(Item stand) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java index ae54a8fb..eeef3177 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/FurnaceDisplay.java @@ -87,11 +87,9 @@ public class FurnaceDisplay extends VisualizerRunnableDisplay implements Listene private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; - private final BlockUpdateScheduler blockUpdates; public FurnaceDisplay() { onReload(new InteractionVisualizerReloadEvent()); - this.blockUpdates = new BlockUpdateScheduler<>(this::nearbyFurnace, this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -166,15 +164,9 @@ public ScheduledTask run() { if (!InteractionVisualizer.eventDrivenBlockUpdates) { return legacyRun(); } - return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { - boolean collecting = PerformanceMetrics.isCollecting(); - long start = collecting ? System.nanoTime() : 0L; - int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), - InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); - if (collecting) { - PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); - } - }, 0, 1); + BlockUpdateCoordinator.register(this, Set.of(Material.FURNACE), this::nearbyFurnace, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; } private ScheduledTask legacyRun() { @@ -342,13 +334,14 @@ private boolean updateHybridBlock(Block block) { private void markDirty(Block block) { if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isFurnace(block.getType())) { - blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty(this, block, (long) Bukkit.getCurrentTick() + 1L); } } private void markDirtyUnlessActive(Block block) { if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isFurnace(block.getType())) { - blockUpdates.markDirtyUnlessActive(block, (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirtyUnlessActive( + this, block, (long) Bukkit.getCurrentTick() + 1L); } } @@ -408,7 +401,7 @@ public void onFurnaceActivated(TileEntityActivatedEvent event) { public void onFurnaceDeactivated(TileEntityDeactivatedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.FURNACE) { - removeTrackedDisplay(event.getBlock()); + BlockUpdateCoordinator.remove(this, event.getBlock()); } } @@ -574,11 +567,18 @@ public void onDragFurnace(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakFurnace(TileEntityRemovedEvent event) { - removeTrackedDisplay(event.getBlock()); + invalidateTrackedDisplay(event.getBlock()); + } + + private void invalidateTrackedDisplay(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); + } } private void removeTrackedDisplay(Block block) { - blockUpdates.remove(block); Map map = furnaceMap.remove(block); if (map == null) { return; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplay.java index d1cd6033..b0b46ae1 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/JukeBoxDisplay.java @@ -87,6 +87,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = jukeboxMap.entrySet().iterator(); int count = 0; @@ -124,6 +127,15 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.JUKEBOX), this::nearbyJukeBox, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyJukeBox(); for (Block block : list) { @@ -163,7 +175,7 @@ public ScheduledTask run() { if (!block.getType().equals(Material.JUKEBOX)) { return; } - org.bukkit.block.Jukebox jukebox = (org.bukkit.block.Jukebox) block.getState(); + org.bukkit.block.Jukebox jukebox = (org.bukkit.block.Jukebox) block.getState(false); { ItemStack itemstack = jukebox.getRecord() == null ? null : (jukebox.getRecord().getType().equals(Material.AIR) ? null : jukebox.getRecord().clone()); @@ -203,13 +215,73 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbyJukeBox().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || block.getType() != Material.JUKEBOX) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = jukeboxMap.get(block); + if (values == null) { + values = new HashMap<>(); + values.put(ITEM_KEY, EMPTY_VISUAL); + values.put(LABEL_KEY, EMPTY_VISUAL); + jukeboxMap.put(block, values); + } + updateTrackedBlock(block, values); + return false; + } + + private void updateTrackedBlock(Block block, Map values) { + org.bukkit.block.Jukebox jukebox = (org.bukkit.block.Jukebox) block.getState(false); + ItemStack record = jukebox.getRecord(); + ItemStack itemStack = record == null || record.getType() == Material.AIR ? null : record.clone(); + if (itemStack == null) { + removeVisuals(values); + return; + } + + Item item; + if (values.get(ITEM_KEY) instanceof Item existing) { + item = existing; + boolean changed = suppressNativeName(item); + if (!item.getItemStack().equals(itemStack)) { + item.setItemStack(itemStack); + changed = true; + } + if (changed) { + DisplayManager.updateItem(item); + } + } else { + item = new Item(jukebox.getLocation().clone().add(0.5, 1.0, 0.5)); + item.setItemStack(itemStack); + item.setVelocity(new Vector()); + item.setPickupDelay(32767); + item.setGravity(false); + suppressNativeName(item); + values.put(ITEM_KEY, item); + DisplayManager.sendItemSpawn( + InteractionVisualizerAPI.getPlayerModuleList(Modules.ITEMDROP, KEY), item); + } + Component text = showDiscName ? discName(itemStack, jukebox.getPlaying().toString()) : null; + reconcileLabel(values, item.getLocation(), text); + } + @EventHandler(priority = EventPriority.MONITOR) public void onBreakJukeBox(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!jukeboxMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } + private void removeTrackedDisplay(Block block) { Map values = jukeboxMap.remove(block); removeVisuals(values); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/LecternDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/LecternDisplay.java index de2c6e9a..46f56d56 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/LecternDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/LecternDisplay.java @@ -86,6 +86,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = lecternMap.entrySet().iterator(); int count = 0; @@ -133,6 +136,15 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.LECTERN), this::nearbyLectern, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbyLectern(); for (Block block : list) { @@ -167,7 +179,7 @@ public ScheduledTask run() { if (!block.getType().equals(Material.LECTERN)) { return; } - org.bukkit.block.Lectern lectern = (org.bukkit.block.Lectern) block.getState(); + org.bukkit.block.Lectern lectern = (org.bukkit.block.Lectern) block.getState(false); { Inventory inv = lectern.getInventory(); @@ -212,14 +224,69 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbyLectern().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || block.getType() != Material.LECTERN) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = lecternMap.get(block); + if (values == null) { + values = new HashMap<>(spawnDisplayEntitys(block)); + lecternMap.put(block, values); + } + updateTrackedBlock((org.bukkit.block.Lectern) block.getState(false), values); + return false; + } + + private void updateTrackedBlock(org.bukkit.block.Lectern lectern, Map values) { + ItemStack itemStack = lectern.getInventory().getItem(0); + DisplayEntity line1 = (DisplayEntity) values.get("1"); + DisplayEntity line2 = (DisplayEntity) values.get("2"); + if (itemStack != null && itemStack.getType() == Material.WRITTEN_BOOK + && itemStack.getItemMeta() instanceof BookMeta meta) { + String title = meta.getTitle() == null ? "" : meta.getTitle(); + String author = meta.getAuthor() == null ? "" : meta.getAuthor(); + String page = Integer.toString(lectern.getPage()); + String first = format1.replace("{Title}", title) + .replace("{Author}", author).replace("{Page}", page); + String second = format2.replace("{Title}", title) + .replace("{Author}", author).replace("{Page}", page); + if (line1.updateCustomName(first, true)) { + DisplayManager.updateDisplay(line1); + } + if (line2.updateCustomName(second, true)) { + DisplayManager.updateDisplay(line2); + } + } else { + if (line1.updateCustomName("", false)) { + DisplayManager.updateDisplay(line1); + } + if (line2.updateCustomName("", false)) { + DisplayManager.updateDisplay(line2); + } + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBreakLectern(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!lecternMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = lecternMap.get(block); + private void removeTrackedDisplay(Block block) { + Map map = lecternMap.remove(block); + if (map == null) { + return; + } if (map.get("1") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -228,7 +295,6 @@ public void onBreakLectern(TileEntityRemovedEvent event) { DisplayEntity stand = (DisplayEntity) map.get("2"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - lecternMap.remove(block); } public Set nearbyLectern() { @@ -243,7 +309,7 @@ public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); - BlockData blockData = block.getState().getBlockData(); + BlockData blockData = block.getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); Location loc = firstLineLocation(origin, facing); DisplayEntity slot1 = new DisplayEntity(loc.clone()); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/LoomDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/LoomDisplay.java index 3e1d3111..bede5452 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/LoomDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/LoomDisplay.java @@ -26,6 +26,7 @@ import com.loohp.interactionvisualizer.api.VisualizerInteractDisplay; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.managers.InteractionSessionCoordinator; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.InventoryUtils; import com.loohp.interactionvisualizer.utils.LocationUtils; @@ -56,7 +57,6 @@ import org.bukkit.util.Vector; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; import java.util.UUID; @@ -66,6 +66,7 @@ public class LoomDisplay extends VisualizerInteractDisplay implements Listener { public Map> openedLooms = new HashMap<>(); private final Map loomBlocksByPlayer = new HashMap<>(); + private final Map sessionPlayersById = new HashMap<>(); @Override public EntryKey key() { @@ -74,45 +75,9 @@ public EntryKey key() { @Override public ScheduledTask run() { - return new ScheduledRunnable() { - public void run() { - - Iterator itr = openedLooms.keySet().iterator(); - int count = 0; - int maxper = (int) Math.ceil((double) openedLooms.size() / (double) 5); - int delay = 1; - while (itr.hasNext()) { - count++; - if (count > maxper) { - count = 0; - delay++; - } - Block block = itr.next(); - new ScheduledRunnable() { - public void run() { - if (!openedLooms.containsKey(block)) { - return; - } - Map map = openedLooms.get(block); - if (block.getType().equals(Material.LOOM)) { - Player player = (Player) map.get("Player"); - if (!GameMode.SPECTATOR.equals(player.getGameMode())) { - Block openBlock = resolveLoomBlock(player, player.getOpenInventory()); - if (block.equals(openBlock)) { - return; - } - } - } - - if (map.get("Banner") instanceof Item item) { - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); - } - openedLooms.remove(block); - } - }.runTaskLater(InteractionVisualizer.plugin, delay, block.getLocation()); - } - } - }.runTaskTimer(InteractionVisualizer.plugin, 0, 5); + InteractionSessionCoordinator.register(this, sessionPlayersById::values, + this::isSessionValid, this::cleanupSession); + return null; } @Override @@ -139,6 +104,8 @@ public void process(Player player) { if (!map.get("Player").equals(player)) { return; } + sessionPlayersById.put(player.getUniqueId(), player); + InteractionSessionCoordinator.touch(); ItemStack input = view.getItem(0); if (input != null) { @@ -219,7 +186,7 @@ private Block resolveLoomBlock(Player player, InventoryView view) { @EventHandler public void onLoomPlayerQuit(PlayerQuitEvent event) { - loomBlocksByPlayer.remove(event.getPlayer().getUniqueId()); + cleanupSession(event.getPlayer()); } @EventHandler(priority = EventPriority.MONITOR) @@ -359,17 +326,19 @@ public void onCloseLoom(InventoryCloseEvent event) { return; } Block block = resolveLoomBlock(player, event.getView()); - loomBlocksByPlayer.remove(player.getUniqueId()); if (block == null) { + cleanupSession(player); return; } if (!openedLooms.containsKey(block)) { + cleanupSession(player); return; } Map map = openedLooms.get(block); if (!map.get("Player").equals(event.getPlayer())) { + cleanupSession(player); return; } @@ -384,10 +353,43 @@ public void onCloseLoom(InventoryCloseEvent event) { } } + cleanupSession(player); + } + + private boolean isSessionValid(Player player) { + Block block = loomBlocksByPlayer.get(player.getUniqueId()); + if (!player.isOnline() || GameMode.SPECTATOR.equals(player.getGameMode()) || block == null + || !block.getWorld().equals(player.getWorld()) || block.getType() != Material.LOOM) { + return false; + } + Map map = openedLooms.get(block); + return map != null && player.equals(map.get("Player")) + && block.equals(resolveLoomBlock(player, player.getOpenInventory())); + } + + private void cleanupSession(Player player) { + UUID playerId = player.getUniqueId(); + sessionPlayersById.remove(playerId); + Block block = loomBlocksByPlayer.remove(playerId); + if (block == null) { + for (Map.Entry> entry : openedLooms.entrySet()) { + if (player.equals(entry.getValue().get("Player"))) { + block = entry.getKey(); + break; + } + } + } + if (block == null) { + return; + } + Map map = openedLooms.get(block); + if (map == null || !player.equals(map.get("Player"))) { + return; + } if (map.get("Banner") instanceof Item item) { DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), item); } - openedLooms.remove(block); + openedLooms.remove(block, map); } public Map spawnDisplayEntitys(Player player, Block block) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplay.java index e739fd91..dfe815da 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/NoteBlockDisplay.java @@ -49,7 +49,6 @@ import org.bukkit.inventory.ItemStack; import java.util.Collection; -import java.util.Iterator; import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; @@ -58,6 +57,7 @@ public class NoteBlockDisplay extends VisualizerRunnableDisplay implements Liste public static final EntryKey KEY = new EntryKey("note_block"); public ConcurrentHashMap> displayingNotes = new ConcurrentHashMap<>(); + private ScheduledTask expiryTask; @Override public EntryKey key() { @@ -71,19 +71,7 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { - return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { - Iterator>> itr = displayingNotes.entrySet().iterator(); - while (itr.hasNext()) { - Entry> entry = itr.next(); - long unix = System.currentTimeMillis(); - long timeout = (long) entry.getValue().get("Timeout"); - if (unix > timeout) { - DisplayEntity stand = (DisplayEntity) entry.getValue().get("Stand"); - Scheduler.runTask(InteractionVisualizer.plugin, () -> DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand)); - itr.remove(); - } - } - }, 0, 20); + return null; } @SuppressWarnings("deprecation") @@ -122,6 +110,7 @@ public void onUseNoteBlock(PlayerInteractEvent event) { map.put("Stand", stand); map.put("Timeout", System.currentTimeMillis() + 3000); displayingNotes.put(block, map); + ensureExpiryTask(); Block topBlock = block.getRelative(BlockFace.UP); Collection topBlockDrops; @@ -146,6 +135,29 @@ public void onUseNoteBlock(PlayerInteractEvent event) { }, 1, block.getLocation()); } + private void ensureExpiryTask() { + if (expiryTask == null || expiryTask.isCancelled()) { + expiryTask = Scheduler.runTaskTimer(InteractionVisualizer.plugin, this::expireNotes, 20, 20); + } + } + + private void expireNotes() { + long now = System.currentTimeMillis(); + for (Entry> entry : displayingNotes.entrySet()) { + ConcurrentHashMap state = entry.getValue(); + if (now > (long) state.get("Timeout") && displayingNotes.remove(entry.getKey(), state)) { + DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), (DisplayEntity) state.get("Stand")); + } + } + if (displayingNotes.isEmpty()) { + ScheduledTask task = expiryTask; + expiryTask = null; + if (task != null && !task.isCancelled()) { + task.cancel(); + } + } + } + public void setStand(DisplayEntity stand) { stand.useLegacyNameTagStyle(); stand.setArms(true); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java index a026375b..d20be9ac 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SmokerDisplay.java @@ -88,12 +88,9 @@ public class SmokerDisplay extends VisualizerRunnableDisplay implements Listener private String noFuelColor = "&c"; private int progressBarLength = 10; private String amountPending = " &7+{Amount}"; - private final BlockUpdateScheduler blockUpdates; public SmokerDisplay() { onReload(new InteractionVisualizerReloadEvent()); - this.blockUpdates = new BlockUpdateScheduler<>(this::nearbySmoker, - this.checkingPeriod, this.gcPeriod); } @EventHandler @@ -168,15 +165,9 @@ public ScheduledTask run() { if (!InteractionVisualizer.eventDrivenBlockUpdates) { return legacyRun(); } - return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { - boolean collecting = PerformanceMetrics.isCollecting(); - long start = collecting ? System.nanoTime() : 0L; - int checks = this.blockUpdates.tick(Bukkit.getCurrentTick(), - InteractionVisualizer.blockUpdateMaxDirtyPerTick, this::updateHybridBlock); - if (collecting) { - PerformanceMetrics.blockUpdateChecks(checks, System.nanoTime() - start); - } - }, 0, 1); + BlockUpdateCoordinator.register(this, Set.of(Material.SMOKER), this::nearbySmoker, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; } private ScheduledTask legacyRun() { @@ -344,13 +335,14 @@ private boolean updateHybridBlock(Block block) { private void markDirty(Block block) { if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isSmoker(block.getType())) { - blockUpdates.markDirty(block, (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirty(this, block, (long) Bukkit.getCurrentTick() + 1L); } } private void markDirtyUnlessActive(Block block) { if (InteractionVisualizer.eventDrivenBlockUpdates && block != null && isSmoker(block.getType())) { - blockUpdates.markDirtyUnlessActive(block, (long) Bukkit.getCurrentTick() + 1L); + BlockUpdateCoordinator.markDirtyUnlessActive( + this, block, (long) Bukkit.getCurrentTick() + 1L); } } @@ -410,7 +402,7 @@ public void onSmokerActivated(TileEntityActivatedEvent event) { public void onSmokerDeactivated(TileEntityDeactivatedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.SMOKER) { - removeTrackedDisplay(event.getBlock()); + BlockUpdateCoordinator.remove(this, event.getBlock()); } } @@ -575,18 +567,25 @@ public void onDragSmoker(InventoryDragEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onBreakSmoker(BlockBreakEvent event) { - removeTrackedDisplay(event.getBlock()); + invalidateTrackedDisplay(event.getBlock()); } public void onRemoveSmoker(TileEntityRemovedEvent event) { if (InteractionVisualizer.eventDrivenBlockUpdates && event.getTileEntityType() == TileEntityType.SMOKER) { - removeTrackedDisplay(event.getBlock()); + BlockUpdateCoordinator.remove(this, event.getBlock()); + } + } + + private void invalidateTrackedDisplay(Block block) { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } } private void removeTrackedDisplay(Block block) { - blockUpdates.remove(block); Map map = smokerMap.remove(block); if (map == null) { return; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SoulCampfireDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SoulCampfireDisplay.java index eae9d594..5e235bb6 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SoulCampfireDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SoulCampfireDisplay.java @@ -89,6 +89,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = soulcampfireMap.entrySet().iterator(); int count = 0; @@ -152,6 +155,16 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.SOUL_CAMPFIRE), + this::nearbySoulCampfire, checkingPeriod, gcPeriod, + this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbySoulCampfire(); for (Block block : list) { @@ -186,7 +199,8 @@ public ScheduledTask run() { if (!block.getType().equals(Material.SOUL_CAMPFIRE)) { return; } - org.bukkit.block.Campfire soulcampfire = (org.bukkit.block.Campfire) block.getState(); + org.bukkit.block.Campfire soulcampfire = + (org.bukkit.block.Campfire) block.getState(false); boolean isLit = ((Campfire) block.getBlockData()).isLit(); { @@ -342,6 +356,26 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbySoulCampfire().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || block.getType() != Material.SOUL_CAMPFIRE) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = soulcampfireMap.get(block); + if (values == null) { + values = new HashMap<>(spawnDisplayEntitys(block)); + soulcampfireMap.put(block, values); + } + org.bukkit.block.Campfire state = (org.bukkit.block.Campfire) block.getState(false); + return CampfireDisplayUpdater.update(state, values, progressBarCharacter, + emptyColor, filledColor, progressBarLength); + } + @EventHandler(priority = EventPriority.MONITOR) public void onBreakSoulCampfire(TileEntityRemovedEvent event) { removeSoulCampfire(event.getBlock()); @@ -353,11 +387,18 @@ public void onBreakSoulCampfire(BlockBreakEvent event) { } private void removeSoulCampfire(Block block) { - if (!soulcampfireMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = soulcampfireMap.get(block); + private void removeTrackedDisplay(Block block) { + Map map = soulcampfireMap.remove(block); + if (map == null) { + return; + } if (map.get("1") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); @@ -374,7 +415,6 @@ private void removeSoulCampfire(Block block) { DisplayEntity stand = (DisplayEntity) map.get("4"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - soulcampfireMap.remove(block); } public Set nearbySoulCampfire() { @@ -389,7 +429,7 @@ public Map spawnDisplayEntitys(Block block) { Map map = new HashMap<>(); Location origin = block.getLocation(); - BlockData blockData = block.getState().getBlockData(); + BlockData blockData = block.getBlockData(); BlockFace facing = ((Directional) blockData).getFacing(); Location target = block.getRelative(facing).getLocation(); Vector direction = rotateVectorAroundY(target.toVector().subtract(origin.toVector()).multiply(0.44194173), 135); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SpawnerDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SpawnerDisplay.java index 4bfbea94..61616e58 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/SpawnerDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/SpawnerDisplay.java @@ -90,6 +90,9 @@ public EntryKey key() { @Override public ScheduledTask gc() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + return null; + } return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Iterator>> itr = spawnerMap.entrySet().iterator(); int count = 0; @@ -129,6 +132,15 @@ public ScheduledTask gc() { @Override public ScheduledTask run() { + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.register(this, Set.of(Material.SPAWNER), this::nearbySpawner, + checkingPeriod, gcPeriod, this::updateHybridBlock, this::removeTrackedDisplay); + return null; + } + return legacyRun(); + } + + private ScheduledTask legacyRun() { return Scheduler.runTaskTimer(InteractionVisualizer.plugin, () -> { Set list = nearbySpawner(); for (Block block : list) { @@ -163,7 +175,8 @@ public ScheduledTask run() { if (!isSpawner(block.getType())) { return; } - org.bukkit.block.CreatureSpawner spawner = (org.bukkit.block.CreatureSpawner) block.getState(); + org.bukkit.block.CreatureSpawner spawner = + (org.bukkit.block.CreatureSpawner) block.getState(false); { DisplayEntity stand = (DisplayEntity) entry.getValue().get("1"); @@ -207,19 +220,65 @@ public ScheduledTask run() { }, 0, checkingPeriod); } + private boolean updateHybridBlock(Block block) { + if (!nearbySpawner().contains(block) + || !block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4) + || !isSpawner(block.getType())) { + removeTrackedDisplay(block); + return false; + } + if (!isActive(block.getLocation())) { + return false; + } + Map values = spawnerMap.get(block); + if (values == null) { + values = new HashMap<>(spawnDisplayEntitys(block)); + spawnerMap.put(block, values); + } + updateTrackedBlock((org.bukkit.block.CreatureSpawner) block.getState(false), values); + return true; + } + + private void updateTrackedBlock(org.bukkit.block.CreatureSpawner spawner, + Map values) { + DisplayEntity stand = (DisplayEntity) values.get("1"); + int activeRange = spawner.getRequiredPlayerRange(); + boolean running = PlayerLocationManager.hasPlayerNearby(spawner.getLocation(), activeRange, + false, player -> !GameMode.SPECTATOR.equals(player.getGameMode())); + if (!running) { + if (stand.updateCustomName("", false)) { + DisplayManager.updateDisplay(stand); + } + return; + } + String progress = CampfireDisplayUpdater.progress( + spawner.getMaxSpawnDelay() - spawner.getDelay(), spawner.getMaxSpawnDelay(), + progressBarCharacter, emptyColor, filledColor, progressBarLength); + progress += spawnRange.replace("{SpawnRange}", Integer.toString(spawner.getSpawnRange())); + if (stand.updateCustomName(progress, true)) { + DisplayManager.updateDisplay(stand); + } + } + @EventHandler(priority = EventPriority.MONITOR) public void onBreakSpawner(TileEntityRemovedEvent event) { Block block = event.getBlock(); - if (!spawnerMap.containsKey(block)) { - return; + if (InteractionVisualizer.eventDrivenBlockUpdates) { + BlockUpdateCoordinator.remove(this, block); + } else { + removeTrackedDisplay(block); } + } - Map map = spawnerMap.get(block); + private void removeTrackedDisplay(Block block) { + Map map = spawnerMap.remove(block); + if (map == null) { + return; + } if (map.get("1") instanceof DisplayEntity) { DisplayEntity stand = (DisplayEntity) map.get("1"); DisplayManager.removeDisplay(InteractionVisualizerAPI.getPlayers(), stand); } - spawnerMap.remove(block); } public Set nearbySpawner() { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/StonecutterDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/StonecutterDisplay.java index f271a6fa..59fe79e0 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/StonecutterDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/StonecutterDisplay.java @@ -26,6 +26,7 @@ import com.loohp.interactionvisualizer.api.VisualizerInteractDisplay; import com.loohp.interactionvisualizer.entityholders.Item; import com.loohp.interactionvisualizer.managers.DisplayManager; +import com.loohp.interactionvisualizer.managers.InteractionSessionCoordinator; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.InventoryUtils; import com.loohp.interactionvisualizer.utils.VanishUtils; @@ -54,7 +55,6 @@ import org.bukkit.util.Vector; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; public class StonecutterDisplay extends VisualizerInteractDisplay implements Listener { @@ -71,50 +71,9 @@ public EntryKey key() { @Override public ScheduledTask run() { - return new ScheduledRunnable() { - public void run() { - - Iterator itr = openedStonecutter.keySet().iterator(); - int count = 0; - int maxper = (int) Math.ceil((double) openedStonecutter.size() / (double) 5); - int delay = 1; - while (itr.hasNext()) { - count++; - if (count > maxper) { - count = 0; - delay++; - } - Block block = itr.next(); - new ScheduledRunnable() { - public void run() { - if (!openedStonecutter.containsKey(block)) { - return; - } - Map map = openedStonecutter.get(block); - if (block.getType().equals(Material.STONECUTTER)) { - Player player = (Player) map.get("Player"); - if (!GameMode.SPECTATOR.equals(player.getGameMode())) { - if (player.getOpenInventory() != null) { - if (player.getOpenInventory().getTopInventory() != null) { - if (player.getOpenInventory().getTopInventory() instanceof StonecutterInventory) { - return; - } - } - } - } - } - - if (map.get("Item") instanceof Item) { - Item entity = (Item) map.get("Item"); - DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), entity); - } - openedStonecutter.remove(block); - playermap.remove((Player) map.get("Player"), block); - } - }.runTaskLater(InteractionVisualizer.plugin, delay, block.getLocation()); - } - } - }.runTaskTimer(InteractionVisualizer.plugin, 0, 5); + InteractionSessionCoordinator.register(this, playermap::keySet, + this::isSessionValid, this::cleanupSession); + return null; } @Override @@ -152,6 +111,7 @@ public void process(Player player) { if (!map.get("Player").equals(player)) { return; } + InteractionSessionCoordinator.touch(); ItemStack input = view.getItem(0); if (input != null) { @@ -339,22 +299,36 @@ public void onDragStonecutter(InventoryDragEvent event) { @EventHandler public void onCloseStonecutter(InventoryCloseEvent event) { - Player player = (Player) event.getPlayer(); + cleanupSession((Player) event.getPlayer()); + } + + private boolean isSessionValid(Player player) { + Block block = playermap.get(player); + if (block == null) { + return false; + } + Map map = openedStonecutter.get(block); + return player.isOnline() && !GameMode.SPECTATOR.equals(player.getGameMode()) + && map != null && player.equals(map.get("Player")) + && block.getWorld().equals(player.getWorld()) + && block.getType() == Material.STONECUTTER + && player.getOpenInventory().getTopInventory() instanceof StonecutterInventory; + } + + private void cleanupSession(Player player) { Block block = playermap.remove(player); if (block == null) { return; } - Map map = openedStonecutter.get(block); - if (map == null || !map.get("Player").equals(player)) { + if (map == null || !player.equals(map.get("Player"))) { return; } - if (map.get("Item") instanceof Item) { - Item entity = (Item) map.get("Item"); + if (map.get("Item") instanceof Item entity) { DisplayManager.removeItem(InteractionVisualizerAPI.getPlayers(), entity); } - openedStonecutter.remove(block); + openedStonecutter.remove(block, map); } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/listeners/Events.java b/common/src/main/java/com/loohp/interactionvisualizer/listeners/Events.java index 75131067..1b84c574 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/listeners/Events.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/listeners/Events.java @@ -23,6 +23,7 @@ import com.destroystokyo.paper.event.inventory.PrepareResultEvent; import com.loohp.interactionvisualizer.InteractionVisualizer; import com.loohp.interactionvisualizer.managers.TaskManager; +import com.loohp.interactionvisualizer.managers.InteractionSessionCoordinator; import io.papermc.paper.event.player.PlayerLoomPatternSelectEvent; import io.papermc.paper.event.player.PlayerStonecutterRecipeSelectEvent; import org.bukkit.World; @@ -38,6 +39,7 @@ import org.bukkit.event.inventory.InventoryType; import org.bukkit.event.inventory.PrepareItemCraftEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.WorldUnloadEvent; @@ -88,6 +90,13 @@ public void onLoomPatternSelect(PlayerLoomPatternSelectEvent event) { @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { TaskManager.clearPendingInventoryProcess(event.getPlayer().getUniqueId()); + InteractionSessionCoordinator.invalidate(event.getPlayer()); + } + + @EventHandler + public void onPlayerChangedWorld(PlayerChangedWorldEvent event) { + TaskManager.clearPendingInventoryProcess(event.getPlayer().getUniqueId()); + InteractionSessionCoordinator.invalidate(event.getPlayer()); } private static void queueNativeInventoryRefresh(HumanEntity entity, InventoryType type) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListener.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListener.java index 33ac1bf7..fd800d7a 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListener.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/EventDrivenBlockUpdateListener.java @@ -18,6 +18,7 @@ import com.loohp.interactionvisualizer.blocks.BeeHiveDisplay; import com.loohp.interactionvisualizer.blocks.BeeNestDisplay; import com.loohp.interactionvisualizer.blocks.BlastFurnaceDisplay; +import com.loohp.interactionvisualizer.blocks.BlockUpdateCoordinator; import com.loohp.interactionvisualizer.blocks.FurnaceDisplay; import com.loohp.interactionvisualizer.blocks.SmokerDisplay; import com.loohp.interactionvisualizer.objectholders.TileEntity.TileEntityType; @@ -96,7 +97,7 @@ public void add(BeeNestDisplay display) { } public boolean isEmpty() { - return furnace == null && blastFurnace == null && smoker == null && beeHive == null && beeNest == null; + return BlockUpdateCoordinator.isEmpty(); } /* @@ -108,6 +109,7 @@ public boolean isEmpty() { */ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) { + BlockUpdateCoordinator.markDirtyUnlessActive(event.getBlock()); switch (furnaceTarget(event.getBlock().getType())) { case FURNACE -> { if (furnace != null) { @@ -131,6 +133,7 @@ public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onFurnaceSmelt(FurnaceSmeltEvent event) { + BlockUpdateCoordinator.markDirtyUnlessActive(event.getBlock()); switch (furnaceTarget(event.getBlock().getType())) { case FURNACE -> { if (furnace != null) { @@ -154,6 +157,7 @@ public void onFurnaceSmelt(FurnaceSmeltEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onFurnaceExtract(FurnaceExtractEvent event) { + BlockUpdateCoordinator.markDirty(event.getBlock()); switch (furnaceTarget(event.getBlock().getType())) { case FURNACE -> { if (furnace != null) { @@ -190,6 +194,7 @@ public void onInventoryMoveItem(InventoryMoveItemEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onTileEntityAdded(TileEntityAddedEvent event) { + BlockUpdateCoordinator.markDirty(event.getBlock()); TileEntityType type = event.getTileEntityType(); if (type == null) { return; @@ -227,6 +232,7 @@ public void onTileEntityAdded(TileEntityAddedEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onTileEntityActivated(TileEntityActivatedEvent event) { + BlockUpdateCoordinator.markDirty(event.getBlock()); TileEntityType type = event.getTileEntityType(); if (type == null) { return; @@ -264,6 +270,7 @@ public void onTileEntityActivated(TileEntityActivatedEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onTileEntityDeactivated(TileEntityDeactivatedEvent event) { + BlockUpdateCoordinator.remove(event.getBlock()); TileEntityType type = event.getTileEntityType(); if (type == null) { return; @@ -301,6 +308,7 @@ public void onTileEntityDeactivated(TileEntityDeactivatedEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onTileEntityRemoved(TileEntityRemovedEvent event) { + BlockUpdateCoordinator.remove(event.getBlock()); if (event.getTileEntityType() == TileEntityType.SMOKER && smoker != null) { smoker.onRemoveSmoker(event); } @@ -308,37 +316,47 @@ public void onTileEntityRemoved(TileEntityRemovedEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedBlockPlace(BlockPlaceEvent event) { + routeCoordinatorChange(event.getBlockPlaced()); routeAffectedColumn(event.getBlockPlaced()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedBlockBreak(BlockBreakEvent event) { + BlockUpdateCoordinator.remove(event.getBlock()); + routeCoordinatorStructureChange(event.getBlock()); routeAffectedColumn(event.getBlock()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedBlockBurn(BlockBurnEvent event) { + BlockUpdateCoordinator.remove(event.getBlock()); + routeCoordinatorStructureChange(event.getBlock()); routeAffectedColumn(event.getBlock()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedBlockFade(BlockFadeEvent event) { + routeCoordinatorChange(event.getBlock()); routeAffectedColumn(event.getBlock()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedBlockIgnite(BlockIgniteEvent event) { + routeCoordinatorChange(event.getBlock()); routeAffectedColumn(event.getBlock()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedFluidFlow(BlockFromToEvent event) { + routeCoordinatorChange(event.getToBlock()); routeAffectedColumn(event.getToBlock()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedBlockExplode(BlockExplodeEvent event) { for (Block block : event.blockList()) { + BlockUpdateCoordinator.remove(block); + routeCoordinatorStructureChange(block); routeAffectedColumn(block); } } @@ -346,12 +364,15 @@ public void onAffectedBlockExplode(BlockExplodeEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedEntityExplode(EntityExplodeEvent event) { for (Block block : event.blockList()) { + BlockUpdateCoordinator.remove(block); + routeCoordinatorStructureChange(block); routeAffectedColumn(block); } } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onDispenserHarvest(BlockDispenseEvent event) { + BlockUpdateCoordinator.markDirty(event.getBlock()); Material dispensed = event.getItem().getType(); if (dispensed != Material.GLASS_BOTTLE && dispensed != Material.SHEARS) { return; @@ -371,10 +392,8 @@ public void onBeeEnterBlock(EntityEnterBlockEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onEntityChangeBlock(EntityChangeBlockEvent event) { - if (!hasBeeDisplays()) { - return; - } Block block = event.getBlock(); + routeCoordinatorChange(block); if (event.getEntity() instanceof Bee && beeTarget(block.getType()) != BeeTarget.NONE) { routeBeeBlock(block); return; @@ -386,13 +405,11 @@ public void onEntityChangeBlock(EntityChangeBlockEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onBeeRelevantInteract(PlayerInteractEvent event) { - if (!hasBeeDisplays()) { - return; - } Block block = event.getClickedBlock(); if (block == null) { return; } + BlockUpdateCoordinator.markDirty(block); Material material = block.getType(); if (beeTarget(material) != BeeTarget.NONE) { routeBeeBlock(block); @@ -403,35 +420,35 @@ public void onBeeRelevantInteract(PlayerInteractEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedPistonExtend(BlockPistonExtendEvent event) { - if (!hasBeeDisplays()) { - return; - } - routeAffectedColumns(pistonAffectedBlocks(event.getBlock(), event.getBlocks(), - event.getDirection(), event.getDirection())); + Set affected = pistonAffectedBlocks(event.getBlock(), event.getBlocks(), + event.getDirection(), event.getDirection()); + routeCoordinatorChanges(affected); + routeAffectedColumns(affected); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onAffectedPistonRetract(BlockPistonRetractEvent event) { - if (!hasBeeDisplays()) { - return; - } - routeAffectedColumns(pistonAffectedBlocks(event.getBlock(), event.getBlocks(), - event.getDirection(), event.getDirection().getOppositeFace())); + Set affected = pistonAffectedBlocks(event.getBlock(), event.getBlocks(), + event.getDirection(), event.getDirection().getOppositeFace()); + routeCoordinatorChanges(affected); + routeAffectedColumns(affected); } @EventHandler(priority = EventPriority.MONITOR) public void onAffectedRedstone(BlockRedstoneEvent event) { - if (!hasBeeDisplays() || event.getOldCurrent() == event.getNewCurrent()) { + if (event.getOldCurrent() == event.getNewCurrent()) { return; } Set changedOpenables = null; Block source = event.getBlock(); + BlockUpdateCoordinator.markDirty(source); if (isRedstoneOpenable(source.getType())) { changedOpenables = new LinkedHashSet<>(); changedOpenables.add(source); } for (BlockFace face : REDSTONE_NEIGHBORS) { Block neighbor = source.getRelative(face); + BlockUpdateCoordinator.markDirty(neighbor); if (isRedstoneOpenable(neighbor.getType())) { if (changedOpenables == null) { changedOpenables = new LinkedHashSet<>(); @@ -448,6 +465,7 @@ private void routeInventoryBlock(Block block) { if (block == null) { return; } + BlockUpdateCoordinator.markDirty(block); switch (furnaceTarget(block.getType())) { case FURNACE -> { if (furnace != null) { @@ -473,6 +491,50 @@ private boolean hasBeeDisplays() { return beeHive != null || beeNest != null; } + private void routeCoordinatorChanges(Collection changedBlocks) { + if (changedBlocks == null) { + return; + } + for (Block block : changedBlocks) { + routeCoordinatorChange(block); + } + } + + private void routeCoordinatorChange(Block changedBlock) { + BlockUpdateCoordinator.markDirty(changedBlock); + routeCoordinatorStructureChange(changedBlock); + } + + private void routeCoordinatorStructureChange(Block changedBlock) { + if (changedBlock == null) { + return; + } + if (BlockUpdateCoordinator.tracks(Material.BEACON)) { + for (int vertical = 1; vertical <= 4; vertical++) { + for (int x = -vertical; x <= vertical; x++) { + for (int z = -vertical; z <= vertical; z++) { + Block candidate = changedBlock.getRelative(x, vertical, z); + if (candidate.getType() == Material.BEACON) { + BlockUpdateCoordinator.markDirty(candidate); + } + } + } + } + } + if (BlockUpdateCoordinator.tracks(Material.CONDUIT)) { + for (int x = -2; x <= 2; x++) { + for (int y = -2; y <= 2; y++) { + for (int z = -2; z <= 2; z++) { + Block candidate = changedBlock.getRelative(x, y, z); + if (candidate.getType() == Material.CONDUIT) { + BlockUpdateCoordinator.markDirty(candidate); + } + } + } + } + } + } + private void routeAffectedColumn(Block changedBlock) { if (changedBlock == null || beeHive == null && beeNest == null) { return; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/InteractionSessionCoordinator.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/InteractionSessionCoordinator.java new file mode 100644 index 00000000..542691fd --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/InteractionSessionCoordinator.java @@ -0,0 +1,135 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.scheduler.ScheduledTask; +import com.loohp.interactionvisualizer.scheduler.Scheduler; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Close/quit/world-change driven workstation cleanup with one on-demand, + * low-frequency audit as a safety net for inventories closed without events. + */ +public final class InteractionSessionCoordinator { + + private static final long AUDIT_PERIOD_TICKS = 100L; + private static final Map LANES = new IdentityHashMap<>(); + + private static ScheduledTask auditTask; + + private InteractionSessionCoordinator() { + } + + public static void register(Object owner, Supplier> players, + Predicate valid, Consumer cleanup) { + LANES.putIfAbsent(Objects.requireNonNull(owner, "owner"), + new Lane(players, valid, cleanup)); + } + + /** Called when a lane creates or refreshes a session. */ + public static void touch() { + if (auditTask == null || auditTask.isCancelled()) { + auditTask = Scheduler.runTaskTimer(InteractionVisualizer.plugin, + InteractionSessionCoordinator::audit, AUDIT_PERIOD_TICKS, AUDIT_PERIOD_TICKS); + } + } + + public static void invalidate(Player player) { + if (player == null) { + return; + } + for (Lane lane : LANES.values()) { + lane.cleanup.accept(player); + } + stopIfIdle(); + } + + public static int retainedLaneCount() { + return LANES.size(); + } + + public static int retainedSessionCount() { + int count = 0; + for (Lane lane : LANES.values()) { + count += lane.players.get().size(); + } + return count; + } + + public static void shutdown() { + ScheduledTask task = auditTask; + auditTask = null; + if (task != null && !task.isCancelled()) { + task.cancel(); + } + for (Lane lane : LANES.values()) { + lane.buffer.clear(); + } + LANES.clear(); + } + + private static void audit() { + for (Lane lane : LANES.values()) { + lane.audit(); + } + stopIfIdle(); + } + + private static void stopIfIdle() { + for (Lane lane : LANES.values()) { + if (!lane.players.get().isEmpty()) { + return; + } + } + ScheduledTask task = auditTask; + auditTask = null; + if (task != null && !task.isCancelled()) { + task.cancel(); + } + } + + private static final class Lane { + + private final Supplier> players; + private final Predicate valid; + private final Consumer cleanup; + private final ArrayList buffer = new ArrayList<>(); + + private Lane(Supplier> players, + Predicate valid, Consumer cleanup) { + this.players = Objects.requireNonNull(players, "players"); + this.valid = Objects.requireNonNull(valid, "valid"); + this.cleanup = Objects.requireNonNull(cleanup, "cleanup"); + } + + private void audit() { + buffer.clear(); + buffer.addAll(players.get()); + for (Player player : buffer) { + if (!valid.test(player)) { + cleanup.accept(player); + } + } + buffer.clear(); + } + } +} diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java index b268a80b..0c65116f 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -79,6 +79,9 @@ public final class PerformanceMetrics implements Listener { private long droppedItemNanos; private long blockUpdateChecks; private long blockUpdateNanos; + private int blockUpdateCoordinatorLanesMax; + private int blockUpdateDirtyQueueMax; + private int blockUpdateActiveQueueMax; private PerformanceMetrics() { } @@ -143,6 +146,9 @@ public static boolean start(String requestedLabel) { INSTANCE.droppedItemNanos = 0; INSTANCE.blockUpdateChecks = 0; INSTANCE.blockUpdateNanos = 0; + INSTANCE.blockUpdateCoordinatorLanesMax = 0; + INSTANCE.blockUpdateDirtyQueueMax = 0; + INSTANCE.blockUpdateActiveQueueMax = 0; INSTANCE.slowestTickTracker.reset(); LegacyTextComponentCache.startMeasurement(); INSTANCE.collecting = true; @@ -305,6 +311,15 @@ public static void blockUpdateChecks(int checks, long nanos) { } } + public static void blockUpdateQueues(int lanes, int dirty, int active) { + if (INSTANCE.collecting) { + INSTANCE.blockUpdateCoordinatorLanesMax = Math.max( + INSTANCE.blockUpdateCoordinatorLanesMax, lanes); + INSTANCE.blockUpdateDirtyQueueMax = Math.max(INSTANCE.blockUpdateDirtyQueueMax, dirty); + INSTANCE.blockUpdateActiveQueueMax = Math.max(INSTANCE.blockUpdateActiveQueueMax, active); + } + } + @EventHandler public void onServerTickEnd(ServerTickEndEvent event) { if (!collecting) { @@ -355,6 +370,7 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) craftEngineCullingShowDecisions, craftEngineCullingHideDecisions, retainedCullingRegistrations, itemAnimationNanos, droppedItemNanos, blockUpdateChecks, blockUpdateNanos, + blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, textCache.requests(), textCache.misses(), textCache.sameRawFastPaths()); } @@ -434,6 +450,9 @@ public record Snapshot( long droppedItemNanos, long blockUpdateChecks, long blockUpdateNanos, + int blockUpdateCoordinatorLanesMax, + int blockUpdateDirtyQueueMax, + int blockUpdateActiveQueueMax, long legacyTextCacheRequests, long legacyTextCacheMisses, long legacyTextSameRawFastPaths) { @@ -508,6 +527,9 @@ public String json() { "\"craftEngineCullingRetainedRegistrations\":%d," + "\"itemAnimationMs\":%.6f,\"droppedItemMs\":%.6f," + "\"blockUpdateChecks\":%d,\"blockUpdateMs\":%.6f," + + "\"blockUpdateCoordinatorLanesMax\":%d," + + "\"blockUpdateDirtyQueueMax\":%d," + + "\"blockUpdateActiveQueueMax\":%d," + "\"legacyTextCacheRequests\":%d,\"legacyTextCacheMisses\":%d," + "\"legacyTextCacheHits\":%d,\"legacyTextCacheHitRate\":%.6f," + "\"legacyTextSameRawFastPaths\":%d}", @@ -531,6 +553,7 @@ public String json() { craftEngineCullingRetainedRegistrations, itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D, blockUpdateChecks, blockUpdateNanos / 1_000_000.0D, + blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, legacyTextCacheRequests, legacyTextCacheMisses, legacyTextCacheHits(), legacyTextCacheHitRate(), legacyTextSameRawFastPaths); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java index 51db058c..06794a60 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java @@ -30,6 +30,7 @@ import com.loohp.interactionvisualizer.blocks.BeeHiveDisplay; import com.loohp.interactionvisualizer.blocks.BeeNestDisplay; import com.loohp.interactionvisualizer.blocks.BlastFurnaceDisplay; +import com.loohp.interactionvisualizer.blocks.BlockUpdateCoordinator; import com.loohp.interactionvisualizer.blocks.BrewingStandDisplay; import com.loohp.interactionvisualizer.blocks.CampfireDisplay; import com.loohp.interactionvisualizer.blocks.CartographyTableDisplay; @@ -122,6 +123,8 @@ public class TaskManager { @SuppressWarnings("deprecation") public static void setup() { + BlockUpdateCoordinator.shutdown(); + InteractionSessionCoordinator.shutdown(); pendingInventoryOpenProcesses.clear(); pendingInventoryRefreshes.clear(); anvil = false; @@ -461,6 +464,8 @@ public static void shutdown() { Bukkit.getScheduler().cancelTasks(plugin); } } finally { + BlockUpdateCoordinator.shutdown(); + InteractionSessionCoordinator.shutdown(); clearRuntimeState(); } } diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java index 71a8ce0c..8f397f8e 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java @@ -87,7 +87,7 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0, - 0L, 0L, 7L, 12_345_678L, 100L, 5L, 200L); + 0L, 0L, 7L, 12_345_678L, 0, 0, 0, 100L, 5L, 200L); String json = snapshot.json(); @@ -103,6 +103,7 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { assertTrue(json.contains("\"droppedLabelVisibilityRestorePerTick\":32")); assertTrue(json.contains("\"viewerFullReconciles\":0")); assertTrue(json.contains("\"craftEngineCullingRetainedRegistrations\":0")); + assertTrue(json.contains("\"blockUpdateDirtyQueueMax\":0")); assertTrue(json.contains("\"legacyTextCacheHits\":95")); assertTrue(json.contains("\"legacyTextCacheHitRate\":0.950000")); assertTrue(json.contains("\"legacyTextSameRawFastPaths\":200")); From 4e5b5b5af5640ba02abf06c3ee09066389f46cca Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 16:04:03 +0800 Subject: [PATCH 09/12] Cache preference viewers and serialize database IO --- .../InteractionVisualizer.java | 1 + .../api/InteractionVisualizerAPI.java | 23 +- .../database/Database.java | 636 +++++++++-------- .../managers/DisplayManager.java | 3 + .../managers/PerformanceMetrics.java | 65 ++ .../managers/PreferenceManager.java | 637 +++++++++++++----- .../database/DatabaseRetryTest.java | 85 +++ .../PerformanceMetricsSlowestTickTest.java | 4 +- .../PreferenceManagerSessionGateTest.java | 95 ++- 9 files changed, 1087 insertions(+), 462 deletions(-) create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/database/DatabaseRetryTest.java diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index a00e4265..e17c873e 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -277,6 +277,7 @@ public void onDisable() { if (preferenceManager != null) { preferenceManager.close(); } + Database.close(); TaskManager.shutdown(); DisplayManager.shutdown(); TileEntityManager.shutdown(); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPI.java b/common/src/main/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPI.java index e9f62391..d1e77134 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPI.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/api/InteractionVisualizerAPI.java @@ -41,6 +41,7 @@ import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -48,7 +49,6 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -import java.util.stream.Stream; public class InteractionVisualizerAPI { @@ -113,13 +113,19 @@ public static Collection getPlayerModuleList(Modules module, EntryKey en */ public static Collection getPlayerModuleList(Modules module, EntryKey entry, boolean excludeDisabledWorlds, Player... excludes) { Collection players = InteractionVisualizer.preferenceManager.getPlayerList(module, entry); - Set excludedPlayers = Stream.of(excludes).collect(Collectors.toSet()); - if (excludeDisabledWorlds) { - Set disabledWorlds = getDisabledWorlds(); - players = SynchronizedFilteredCollection.filter(players, each -> !excludedPlayers.contains(each) && !disabledWorlds.contains(each.getWorld().getName())); - } else { - players = SynchronizedFilteredCollection.filter(players, each -> !excludedPlayers.contains(each)); + Set disabledWorlds = excludeDisabledWorlds + ? InteractionVisualizer.disabledWorlds : Set.of(); + if (excludes.length == 0 && disabledWorlds.isEmpty()) { + return players; } + Set excludedPlayers = excludes.length == 0 + ? Set.of() + : excludes.length == 1 ? Collections.singleton(excludes[0]) + : new HashSet<>(Arrays.asList(excludes)); + players = SynchronizedFilteredCollection.filter(players, + each -> !excludedPlayers.contains(each) + && (disabledWorlds.isEmpty() + || !disabledWorlds.contains(each.getWorld().getName()))); return SynchronizedFilteredCollection.unmodifiableCollection(players); } @@ -176,7 +182,8 @@ public static boolean hasPlayerEnabledModule(UUID uuid, Modules module, EntryKey return hasPlayerEnabledModule(player, module, entry); } else { InteractionVisualizer.preferenceManager.loadPlayer(uuid, "", false); - boolean value = hasPlayerEnabledModule(player, module, entry); + boolean value = InteractionVisualizer.preferenceManager + .getPlayerPreference(uuid, module, entry); InteractionVisualizer.preferenceManager.unloadPlayerWithoutSaving(uuid); return value; } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java b/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java index 87c0734d..fcd5a812 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java @@ -1,27 +1,19 @@ /* * This file is part of InteractionVisualizer. * - * Copyright (C) 2025. LoohpJames - * Copyright (C) 2025. Contributors + * Copyright (C) 2026. Contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . */ package com.loohp.interactionvisualizer.database; import com.loohp.interactionvisualizer.InteractionVisualizer; import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; +import com.loohp.interactionvisualizer.managers.PerformanceMetrics; import com.loohp.interactionvisualizer.objectholders.EntryKey; import com.loohp.interactionvisualizer.utils.ArrayUtils; import com.loohp.interactionvisualizer.utils.BitSetUtils; @@ -34,351 +26,445 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.sql.Statement; import java.util.BitSet; +import java.util.EnumMap; import java.util.HashMap; import java.util.Map; -import java.util.Map.Entry; +import java.util.Properties; import java.util.UUID; import java.util.regex.Pattern; -public class Database { +/** + * Persistent preference connection used exclusively by PreferenceManager's + * single I/O queue. A failed JDBC operation discards the connection and + * retries the complete idempotent operation once on a fresh connection. + */ +public final class Database { public static final String EMPTY_BITSET = "0"; public static final Pattern VALID_BITSET = Pattern.compile("^[0-9]*$"); - private static final String preferenceTable = "USER_PERFERENCES"; - private static final String indexMappingTable = "INDEX_MAPPING"; - private static final Object syncdb = new Object(); - public static boolean isMYSQL = false; + + private static final String PREFERENCE_TABLE = "USER_PERFERENCES"; + private static final String INDEX_MAPPING_TABLE = "INDEX_MAPPING"; + private static final int QUERY_TIMEOUT_SECONDS = 10; + private static final int CONNECT_TIMEOUT_MILLIS = 5_000; + private static final int SOCKET_TIMEOUT_MILLIS = 10_000; + private static final Object DATABASE_LOCK = new Object(); + + public static boolean isMYSQL; + private static Connection connection; - private static String host, database, username, password; + private static String host; + private static String database; + private static String username; + private static String password; private static int port; + private static boolean configured; + + private Database() { + } public static void setup() { - String type = InteractionVisualizer.plugin.getConfiguration().getString("Database.Type"); - isMYSQL = type.equalsIgnoreCase("MYSQL"); - synchronized (syncdb) { - if (isMYSQL) { - mysqlSetup(true); - createTable(); - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); - } - } else { - sqliteSetup(true); - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); - } + synchronized (DATABASE_LOCK) { + closeConnection(); + String type = InteractionVisualizer.plugin.getConfiguration().getString("Database.Type"); + isMYSQL = type.equalsIgnoreCase("MYSQL"); + host = InteractionVisualizer.plugin.getConfiguration().getString("Database.MYSQL.Host"); + port = InteractionVisualizer.plugin.getConfiguration().getInt("Database.MYSQL.Port"); + database = InteractionVisualizer.plugin.getConfiguration().getString("Database.MYSQL.Database"); + username = InteractionVisualizer.plugin.getConfiguration().getString("Database.MYSQL.Username"); + password = InteractionVisualizer.plugin.getConfiguration().getString("Database.MYSQL.Password"); + configured = true; + + try { + Class.forName(isMYSQL ? "com.mysql.cj.jdbc.Driver" : "org.sqlite.JDBC"); + ensureConnection(); + createTables(connection); + log(isMYSQL + ? "[InteractionVisualizer] MySQL connected" + : "[InteractionVisualizer] Opened SQLite database successfully", + NamedTextColor.GREEN); + } catch (ClassNotFoundException exception) { + configured = false; + log("[InteractionVisualizer] Database driver not found", NamedTextColor.RED); + throw new DatabaseException("Database driver not found", exception); + } catch (SQLException exception) { + closeConnection(); + configured = false; + log("[InteractionVisualizer] Unable to initialize the preference database", NamedTextColor.RED); + throw new DatabaseException("Unable to initialize the preference database", exception); } } } - private static void open() { - if (isMYSQL) { - mysqlSetup(false); - } else { - sqliteSetup(false); - } + public static Map getBitIndex() { + return executeWithReconnect("load bitmask index", connection -> { + Map index = new HashMap<>(); + try (PreparedStatement statement = prepare(connection, + "SELECT ENTRY, BITMASK_INDEX FROM " + INDEX_MAPPING_TABLE); + ResultSet results = executeQuery(statement)) { + while (results.next()) { + index.put(results.getInt("BITMASK_INDEX"), + new EntryKey(results.getString("ENTRY"))); + } + } + return index; + }); } - public static void mysqlSetup(boolean echo) { - host = InteractionVisualizer.plugin.getConfiguration().getString("Database.MYSQL.Host"); - port = InteractionVisualizer.plugin.getConfiguration().getInt("Database.MYSQL.Port"); - database = InteractionVisualizer.plugin.getConfiguration().getString("Database.MYSQL.Database"); - username = InteractionVisualizer.plugin.getConfiguration().getString("Database.MYSQL.Username"); - password = InteractionVisualizer.plugin.getConfiguration().getString("Database.MYSQL.Password"); - - try { - if (getConnection() != null && !getConnection().isClosed()) { - return; + /** Replaces the complete index atomically without changing its schema. */ + public static void setBitIndex(Map index) { + executeWithReconnect("save bitmask index", connection -> inTransaction(connection, transactional -> { + try (PreparedStatement delete = prepare(transactional, + "DELETE FROM " + INDEX_MAPPING_TABLE)) { + executeUpdate(delete); } + try (PreparedStatement insert = prepare(transactional, + "INSERT INTO " + INDEX_MAPPING_TABLE + " (BITMASK_INDEX,ENTRY) VALUES (?,?)")) { + for (Map.Entry entry : index.entrySet()) { + if (entry.getValue() == null) { + continue; + } + insert.setInt(1, entry.getKey()); + insert.setString(2, entry.getValue().toString()); + insert.addBatch(); + } + executeBatch(insert); + } + return null; + })); + } - Class.forName("com.mysql.cj.jdbc.Driver"); - setConnection(DriverManager.getConnection("jdbc:mysql://" + host + ":" + port + "/" + database, username, password)); + /** + * Loads an existing player or creates it in the same transaction. The + * optional initial-disabled mask is written in the INSERT itself, avoiding + * a follow-up three-column save for first-time players. + */ + public static Map loadPlayer(UUID uuid, String name, + boolean createIfNotFound, + BitSet initialDisabled) { + return executeWithReconnect("load player preferences", connection -> + inTransaction(connection, transactional -> { + Map found = selectPlayer(transactional, uuid); + if (found != null || !createIfNotFound) { + return found; + } - if (echo == true) { - log("[InteractionVisualizer] MySQL connected", NamedTextColor.GREEN); - } - } catch (SQLException e) { - log("[InteractionVisualizer] MySQL failed to connect (SQLException)", NamedTextColor.RED); - e.printStackTrace(); - } catch (ClassNotFoundException e) { - log("[InteractionVisualizer] MySQL failed to connect (driver not found)", NamedTextColor.RED); - e.printStackTrace(); - } + BitSet defaults = initialDisabled == null ? new BitSet() : initialDisabled; + String encoded = BitSetUtils.toNumberString(defaults); + try (PreparedStatement insert = prepare(transactional, + "INSERT INTO " + PREFERENCE_TABLE + + " (UUID,NAME,ITEMSTAND,ITEMDROP,HOLOGRAM) VALUES (?,?,?,?,?)")) { + insert.setString(1, uuid.toString()); + insert.setString(2, name == null ? "" : name); + insert.setString(3, encoded); + insert.setString(4, encoded); + insert.setString(5, encoded); + executeUpdate(insert); + } + + Map created = new EnumMap<>(Modules.class); + created.put(Modules.ITEMSTAND, (BitSet) defaults.clone()); + created.put(Modules.ITEMDROP, (BitSet) defaults.clone()); + created.put(Modules.HOLOGRAM, (BitSet) defaults.clone()); + return created; + })); } - private static void sqliteSetup(boolean echo) { - try { - Class.forName("org.sqlite.JDBC"); - connection = DriverManager.getConnection("jdbc:sqlite:plugins/InteractionVisualizer/database.db"); - if (echo) { - log("[InteractionVisualizer] Opened SQLite database successfully", NamedTextColor.GREEN); + /** Saves all three preference modules with one SQL statement. */ + public static void savePlayer(UUID uuid, Map preferences) { + if (preferences == null) { + return; + } + executeWithReconnect("save player preferences", connection -> { + try (PreparedStatement statement = prepare(connection, + "UPDATE " + PREFERENCE_TABLE + + " SET ITEMSTAND=?, ITEMDROP=?, HOLOGRAM=? WHERE UUID=?")) { + statement.setString(1, encode(preferences.get(Modules.ITEMSTAND))); + statement.setString(2, encode(preferences.get(Modules.ITEMDROP))); + statement.setString(3, encode(preferences.get(Modules.HOLOGRAM))); + statement.setString(4, uuid.toString()); + executeUpdate(statement); } + return null; + }); + } - Statement stmt0 = connection.createStatement(); - String sql0 = "CREATE TABLE IF NOT EXISTS " + preferenceTable + " (UUID TEXT PRIMARY KEY, NAME TEXT NOT NULL, ITEMSTAND TEXT, ITEMDROP TEXT, HOLOGRAM TEXT);"; - stmt0.executeUpdate(sql0); - stmt0.close(); - - Statement stmt1 = connection.createStatement(); - String sql1 = "CREATE TABLE IF NOT EXISTS " + indexMappingTable + " (ENTRY TEXT PRIMARY KEY, BITMASK_INDEX INTEGER);"; - stmt1.executeUpdate(sql1); - stmt1.close(); + public static void close() { + synchronized (DATABASE_LOCK) { + configured = false; + closeConnection(); + } + } - } catch (Exception e) { - log("[InteractionVisualizer] Unable to connect to SQLite database", NamedTextColor.RED); - e.printStackTrace(); - Bukkit.getPluginManager().disablePlugin(InteractionVisualizer.plugin); + /** Compatibility hook for older internal callers. */ + public static void runExclusive(Runnable operation) { + synchronized (DATABASE_LOCK) { + operation.run(); } } public static Connection getConnection() { - return connection; + synchronized (DATABASE_LOCK) { + try { + ensureConnection(); + return connection; + } catch (SQLException exception) { + throw new DatabaseException("Unable to obtain database connection", exception); + } + } } - public static void setConnection(Connection connection) { - Database.connection = connection; + public static void setConnection(Connection replacement) { + synchronized (DATABASE_LOCK) { + closeConnection(); + connection = replacement; + configured = replacement != null; + } } - private static void createTable() { - open(); - try { - PreparedStatement statement0 = getConnection().prepareStatement("CREATE TABLE IF NOT EXISTS " + preferenceTable + " (UUID Text, NAME Text, ITEMSTAND Text, ITEMDROP Text, HOLOGRAM Text)"); - statement0.execute(); + public static boolean playerExists(UUID uuid) { + return executeWithReconnect("check player preferences", connection -> + selectPlayer(connection, uuid) != null); + } - PreparedStatement statement1 = getConnection().prepareStatement("CREATE TABLE IF NOT EXISTS " + indexMappingTable + " (ENTRY Text, BITMASK_INDEX INT)"); - statement1.execute(); + public static void createPlayer(UUID uuid, String name) { + loadPlayer(uuid, name, true, new BitSet()); + } - } catch (SQLException e) { - e.printStackTrace(); - } - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); - } + public static Map getPlayerInfo(UUID uuid) { + Map preferences = loadPlayer(uuid, "", false, null); + return preferences == null ? new EnumMap<>(Modules.class) : preferences; } - public static void runExclusive(Runnable operation) { - synchronized (syncdb) { - operation.run(); - } + public static void setItemStand(UUID uuid, BitSet bitset) { + setSingleModule(uuid, "ITEMSTAND", bitset); } - private static void log(String message, NamedTextColor color) { - Bukkit.getConsoleSender().sendMessage(Component.text(message, color)); + public static void setItemDrop(UUID uuid, BitSet bitset) { + setSingleModule(uuid, "ITEMDROP", bitset); } - public static Map getBitIndex() { - Map index = new HashMap<>(); - synchronized (syncdb) { - open(); - try { - PreparedStatement statement = getConnection().prepareStatement("SELECT * FROM " + indexMappingTable); - ResultSet results = statement.executeQuery(); - while (results.next()) { - index.put(results.getInt("BITMASK_INDEX"), new EntryKey(results.getString("ENTRY"))); - } - } catch (SQLException e) { - e.printStackTrace(); - } - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); + public static void setHologram(UUID uuid, BitSet bitset) { + setSingleModule(uuid, "HOLOGRAM", bitset); + } + + private static void setSingleModule(UUID uuid, String column, BitSet bitset) { + executeWithReconnect("save player preference module", connection -> { + try (PreparedStatement statement = prepare(connection, + "UPDATE " + PREFERENCE_TABLE + " SET " + column + "=? WHERE UUID=?")) { + statement.setString(1, encode(bitset)); + statement.setString(2, uuid.toString()); + executeUpdate(statement); } - return index; - } + return null; + }); } - public static void setBitIndex(Map index) { - synchronized (syncdb) { - open(); - try { - for (Entry entry : index.entrySet()) { - PreparedStatement statement = getConnection().prepareStatement("SELECT * FROM " + indexMappingTable + " WHERE ENTRY=?"); - statement.setString(1, entry.getValue().toString()); - ResultSet results = statement.executeQuery(); - if (results.next()) { - PreparedStatement statement1 = getConnection().prepareStatement("UPDATE " + indexMappingTable + " SET BITMASK_INDEX=? WHERE ENTRY=?"); - statement1.setInt(1, entry.getKey()); - statement1.setString(2, entry.getValue().toString()); - statement1.executeUpdate(); - } else { - PreparedStatement insert = getConnection().prepareStatement("INSERT INTO " + indexMappingTable + " (BITMASK_INDEX,ENTRY) VALUES (?,?)"); - insert.setInt(1, entry.getKey()); - insert.setString(2, entry.getValue().toString()); - insert.executeUpdate(); - } + private static Map selectPlayer(Connection connection, UUID uuid) throws SQLException { + try (PreparedStatement statement = prepare(connection, + "SELECT ITEMSTAND, ITEMDROP, HOLOGRAM FROM " + PREFERENCE_TABLE + " WHERE UUID=?")) { + statement.setString(1, uuid.toString()); + try (ResultSet results = executeQuery(statement)) { + if (!results.next()) { + return null; } - } catch (SQLException e) { - e.printStackTrace(); + Map preferences = new EnumMap<>(Modules.class); + preferences.put(Modules.ITEMSTAND, decode(results.getString("ITEMSTAND"), uuid)); + preferences.put(Modules.ITEMDROP, decode(results.getString("ITEMDROP"), uuid)); + preferences.put(Modules.HOLOGRAM, decode(results.getString("HOLOGRAM"), uuid)); + return preferences; } - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); + } + } + + private static BitSet decode(String encoded, UUID uuid) { + if (encoded == null || encoded.isEmpty()) { + return new BitSet(); + } + try { + if (VALID_BITSET.matcher(encoded).matches()) { + return BitSetUtils.fromNumberString(encoded); } + return BitSet.valueOf(ArrayUtils.fromBase64String(encoded)); + } catch (Throwable throwable) { + InteractionVisualizer.plugin.getLogger().warning( + "Unable to decode player preference for " + uuid + "; using enabled defaults"); + return new BitSet(); } } - public static boolean playerExists(UUID uuid) { - synchronized (syncdb) { - boolean exist = false; - open(); - try { - PreparedStatement statement = getConnection().prepareStatement("SELECT * FROM " + preferenceTable + " WHERE UUID=?"); - statement.setString(1, uuid.toString()); + private static String encode(BitSet bitset) { + return BitSetUtils.toNumberString(bitset == null ? new BitSet() : bitset); + } - ResultSet results = statement.executeQuery(); - if (results.next()) { - exist = true; - } - } catch (SQLException e) { - e.printStackTrace(); - } - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); - } - return exist; + private static void createTables(Connection connection) throws SQLException { + String preferenceDefinition = isMYSQL + ? " (UUID Text, NAME Text, ITEMSTAND Text, ITEMDROP Text, HOLOGRAM Text)" + : " (UUID TEXT PRIMARY KEY, NAME TEXT NOT NULL, ITEMSTAND TEXT, ITEMDROP TEXT, HOLOGRAM TEXT)"; + String indexDefinition = isMYSQL + ? " (ENTRY Text, BITMASK_INDEX INT)" + : " (ENTRY TEXT PRIMARY KEY, BITMASK_INDEX INTEGER)"; + try (Statement statement = connection.createStatement()) { + configure(statement); + PerformanceMetrics.preferenceSqlStatement(); + statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + PREFERENCE_TABLE + + preferenceDefinition); + } + try (Statement statement = connection.createStatement()) { + configure(statement); + PerformanceMetrics.preferenceSqlStatement(); + statement.executeUpdate("CREATE TABLE IF NOT EXISTS " + INDEX_MAPPING_TABLE + + indexDefinition); } } - public static void createPlayer(UUID uuid, String name) { - synchronized (syncdb) { - open(); - try { - PreparedStatement insert = getConnection().prepareStatement("INSERT INTO " + preferenceTable + " (UUID,NAME,ITEMSTAND,ITEMDROP,HOLOGRAM) VALUES (?,?,?,?,?)"); - insert.setString(1, uuid.toString()); - insert.setString(2, name); - insert.setString(3, EMPTY_BITSET); - insert.setString(4, EMPTY_BITSET); - insert.setString(5, EMPTY_BITSET); - insert.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); + private static void ensureConnection() throws SQLException { + if (!configured) { + throw new SQLException("Preference database is closed"); + } + if (connection != null && !connection.isClosed()) { + return; + } + if (isMYSQL) { + Properties properties = new Properties(); + properties.setProperty("user", username); + properties.setProperty("password", password); + properties.setProperty("connectTimeout", Integer.toString(CONNECT_TIMEOUT_MILLIS)); + properties.setProperty("socketTimeout", Integer.toString(SOCKET_TIMEOUT_MILLIS)); + properties.setProperty("tcpKeepAlive", "true"); + connection = DriverManager.getConnection( + "jdbc:mysql://" + host + ":" + port + "/" + database, properties); + } else { + connection = DriverManager.getConnection( + "jdbc:sqlite:plugins/InteractionVisualizer/database.db"); + try (Statement statement = connection.createStatement()) { + PerformanceMetrics.preferenceSqlStatement(); + statement.execute("PRAGMA busy_timeout=" + CONNECT_TIMEOUT_MILLIS); } } } - public static void setItemStand(UUID uuid, BitSet bitset) { - synchronized (syncdb) { - open(); - try { - PreparedStatement statement = getConnection().prepareStatement("UPDATE " + preferenceTable + " SET ITEMSTAND=? WHERE UUID=?"); - statement.setString(1, BitSetUtils.toNumberString(bitset)); - statement.setString(2, uuid.toString()); - statement.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); - } + private static T executeWithReconnect(String description, CheckedOperation operation) { + synchronized (DATABASE_LOCK) { + return retryOnce(description, + () -> { + ensureConnection(); + return connection; + }, operation, Database::closeConnection, + PerformanceMetrics::preferenceDatabaseReconnect); } } - public static void setItemDrop(UUID uuid, BitSet bitset) { - synchronized (syncdb) { - open(); + static T retryOnce(String description, CheckedSupplier connectionSupplier, + CheckedOperation operation, Runnable discardConnection, + Runnable reconnectMetric) { + SQLException firstFailure = null; + for (int attempt = 0; attempt < 2; attempt++) { try { - PreparedStatement statement = getConnection().prepareStatement("UPDATE " + preferenceTable + " SET ITEMDROP=? WHERE UUID=?"); - statement.setString(1, BitSetUtils.toNumberString(bitset)); - statement.setString(2, uuid.toString()); - statement.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); + return operation.execute(connectionSupplier.get()); + } catch (SQLException exception) { + discardConnection.run(); + if (firstFailure == null) { + firstFailure = exception; + reconnectMetric.run(); + } else { + exception.addSuppressed(firstFailure); + throw new DatabaseException("Unable to " + description, exception); + } } } + throw new DatabaseException("Unable to " + description, firstFailure); } - public static void setHologram(UUID uuid, BitSet bitset) { - synchronized (syncdb) { - open(); + private static T inTransaction(Connection connection, + CheckedOperation operation) throws SQLException { + boolean autoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + T result = operation.execute(connection); + connection.commit(); + return result; + } catch (SQLException | RuntimeException exception) { try { - PreparedStatement statement = getConnection().prepareStatement("UPDATE " + preferenceTable + " SET HOLOGRAM=? WHERE UUID=?"); - statement.setString(1, BitSetUtils.toNumberString(bitset)); - statement.setString(2, uuid.toString()); - statement.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); + connection.rollback(); + } catch (SQLException rollbackFailure) { + exception.addSuppressed(rollbackFailure); } + throw exception; + } finally { try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); + connection.setAutoCommit(autoCommit); + } catch (SQLException ignored) { + // The operation committed, but this connection is no longer safe to reuse. + closeConnection(); } } } - public static Map getPlayerInfo(UUID uuid) { - Map map = new HashMap<>(); - synchronized (syncdb) { - open(); - try { - PreparedStatement statement = getConnection().prepareStatement("SELECT * FROM " + preferenceTable + " WHERE UUID=?"); - statement.setString(1, uuid.toString()); - ResultSet results = statement.executeQuery(); - results.next(); - - String itemstand = results.getString("ITEMSTAND"); - String itemdrop = results.getString("ITEMDROP"); - String hologram = results.getString("HOLOGRAM"); - - try { - if (VALID_BITSET.matcher(itemstand).matches()) { - map.put(Modules.ITEMSTAND, BitSetUtils.fromNumberString(itemstand)); - } else { - map.put(Modules.ITEMSTAND, BitSet.valueOf(ArrayUtils.fromBase64String(itemstand))); - } + private static PreparedStatement prepare(Connection connection, String sql) throws SQLException { + PreparedStatement statement = connection.prepareStatement(sql); + configure(statement); + return statement; + } - if (VALID_BITSET.matcher(itemdrop).matches()) { - map.put(Modules.ITEMDROP, BitSetUtils.fromNumberString(itemdrop)); - } else { - map.put(Modules.ITEMDROP, BitSet.valueOf(ArrayUtils.fromBase64String(itemdrop))); - } + private static void configure(Statement statement) throws SQLException { + try { + statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS); + } catch (SQLFeatureNotSupportedException | AbstractMethodError ignored) { + // The MySQL socket timeout and SQLite busy timeout remain in force. + } + } - if (VALID_BITSET.matcher(hologram).matches()) { - map.put(Modules.HOLOGRAM, BitSetUtils.fromNumberString(hologram)); - } else { - map.put(Modules.HOLOGRAM, BitSet.valueOf(ArrayUtils.fromBase64String(hologram))); - } - } catch (Throwable e) { - new RuntimeException("Unable to load player preference (" + uuid + ")", e).printStackTrace(); - map.put(Modules.ITEMSTAND, new BitSet()); - map.put(Modules.ITEMDROP, new BitSet()); - map.put(Modules.HOLOGRAM, new BitSet()); - } + private static ResultSet executeQuery(PreparedStatement statement) throws SQLException { + PerformanceMetrics.preferenceSqlStatement(); + return statement.executeQuery(); + } - } catch (SQLException e) { - e.printStackTrace(); - } - try { - connection.close(); - } catch (SQLException e) { - e.printStackTrace(); - } + private static int executeUpdate(PreparedStatement statement) throws SQLException { + PerformanceMetrics.preferenceSqlStatement(); + return statement.executeUpdate(); + } + + private static int[] executeBatch(PreparedStatement statement) throws SQLException { + PerformanceMetrics.preferenceSqlStatement(); + return statement.executeBatch(); + } + + private static void closeConnection() { + Connection current = connection; + connection = null; + if (current == null) { + return; + } + try { + current.close(); + } catch (SQLException ignored) { } - return map; } + private static void log(String message, NamedTextColor color) { + Bukkit.getConsoleSender().sendMessage(Component.text(message, color)); + } + + @FunctionalInterface + interface CheckedSupplier { + + C get() throws SQLException; + } + + @FunctionalInterface + interface CheckedOperation { + + T execute(C connection) throws SQLException; + } + + public static final class DatabaseException extends RuntimeException { + + public DatabaseException(String message, Throwable cause) { + super(message, cause); + } + } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java index 0f04cd2e..a868fa2e 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java @@ -1731,6 +1731,9 @@ private static boolean isViewerEnabledFor(VisualizerEntity logical, Player playe return false; } UUID viewer = player.getUniqueId(); + if (desiredPlayers instanceof PreferenceManager.ViewerMembership membership) { + return membership.containsViewer(viewer); + } return SynchronizedFilteredCollection.anyMatch(desiredPlayers, candidate -> candidate != null && viewer.equals(candidate.getUniqueId())); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java index 0c65116f..1a112d9b 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -82,6 +82,11 @@ public final class PerformanceMetrics implements Listener { private int blockUpdateCoordinatorLanesMax; private int blockUpdateDirtyQueueMax; private int blockUpdateActiveQueueMax; + private long preferenceIoOperations; + private long preferenceIoFailures; + private int preferenceIoQueueDepthMax; + private long preferenceSqlStatements; + private long preferenceDatabaseReconnects; private PerformanceMetrics() { } @@ -149,6 +154,11 @@ public static boolean start(String requestedLabel) { INSTANCE.blockUpdateCoordinatorLanesMax = 0; INSTANCE.blockUpdateDirtyQueueMax = 0; INSTANCE.blockUpdateActiveQueueMax = 0; + INSTANCE.preferenceIoOperations = 0; + INSTANCE.preferenceIoFailures = 0; + INSTANCE.preferenceIoQueueDepthMax = 0; + INSTANCE.preferenceSqlStatements = 0; + INSTANCE.preferenceDatabaseReconnects = 0; INSTANCE.slowestTickTracker.reset(); LegacyTextComponentCache.startMeasurement(); INSTANCE.collecting = true; @@ -320,6 +330,47 @@ public static void blockUpdateQueues(int lanes, int dirty, int active) { } } + public static void preferenceIoOperation() { + if (INSTANCE.collecting) { + synchronized (INSTANCE) { + INSTANCE.preferenceIoOperations++; + } + } + } + + public static void preferenceIoFailure() { + if (INSTANCE.collecting) { + synchronized (INSTANCE) { + INSTANCE.preferenceIoFailures++; + } + } + } + + public static void preferenceIoQueueDepth(int depth) { + if (INSTANCE.collecting) { + synchronized (INSTANCE) { + INSTANCE.preferenceIoQueueDepthMax = Math.max( + INSTANCE.preferenceIoQueueDepthMax, depth); + } + } + } + + public static void preferenceSqlStatement() { + if (INSTANCE.collecting) { + synchronized (INSTANCE) { + INSTANCE.preferenceSqlStatements++; + } + } + } + + public static void preferenceDatabaseReconnect() { + if (INSTANCE.collecting) { + synchronized (INSTANCE) { + INSTANCE.preferenceDatabaseReconnects++; + } + } + } + @EventHandler public void onServerTickEnd(ServerTickEndEvent event) { if (!collecting) { @@ -371,6 +422,8 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) retainedCullingRegistrations, itemAnimationNanos, droppedItemNanos, blockUpdateChecks, blockUpdateNanos, blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, + preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, + preferenceSqlStatements, preferenceDatabaseReconnects, textCache.requests(), textCache.misses(), textCache.sameRawFastPaths()); } @@ -453,6 +506,11 @@ public record Snapshot( int blockUpdateCoordinatorLanesMax, int blockUpdateDirtyQueueMax, int blockUpdateActiveQueueMax, + long preferenceIoOperations, + long preferenceIoFailures, + int preferenceIoQueueDepthMax, + long preferenceSqlStatements, + long preferenceDatabaseReconnects, long legacyTextCacheRequests, long legacyTextCacheMisses, long legacyTextSameRawFastPaths) { @@ -530,6 +588,11 @@ public String json() { "\"blockUpdateCoordinatorLanesMax\":%d," + "\"blockUpdateDirtyQueueMax\":%d," + "\"blockUpdateActiveQueueMax\":%d," + + "\"preferenceIoOperations\":%d," + + "\"preferenceIoFailures\":%d," + + "\"preferenceIoQueueDepthMax\":%d," + + "\"preferenceSqlStatements\":%d," + + "\"preferenceDatabaseReconnects\":%d," + "\"legacyTextCacheRequests\":%d,\"legacyTextCacheMisses\":%d," + "\"legacyTextCacheHits\":%d,\"legacyTextCacheHitRate\":%.6f," + "\"legacyTextSameRawFastPaths\":%d}", @@ -554,6 +617,8 @@ public String json() { itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D, blockUpdateChecks, blockUpdateNanos / 1_000_000.0D, blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, + preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, + preferenceSqlStatements, preferenceDatabaseReconnects, legacyTextCacheRequests, legacyTextCacheMisses, legacyTextCacheHits(), legacyTextCacheHitRate(), legacyTextSameRawFastPaths); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java index 192e449b..a508b95b 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java @@ -24,7 +24,6 @@ import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; import com.loohp.interactionvisualizer.database.Database; import com.loohp.interactionvisualizer.objectholders.EntryKey; -import com.loohp.interactionvisualizer.objectholders.SynchronizedFilteredCollection; import com.loohp.interactionvisualizer.utils.ArrayUtils; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -35,14 +34,16 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import java.util.AbstractCollection; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.BitSet; import java.util.Collection; import java.util.Collections; +import java.util.EnumMap; import java.util.HashMap; -import java.util.LinkedHashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -56,17 +57,22 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.BooleanSupplier; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; import java.util.logging.Level; public class PreferenceManager implements Listener, AutoCloseable { + private static final UUID SYSTEM_IO_KEY = new UUID(0L, 0L); + @SuppressWarnings({"FieldCanBeLocal", "unused"}) private final InteractionVisualizer plugin; private final List entries; + private volatile Map entryIndexes; + private volatile List registeredEntries; private final Map> preferences; - - private final SynchronizedFilteredCollection backingPlayerList; + private final ViewerGroup backingPlayerList; + private final Map> viewerGroups; private final AtomicBoolean valid; private final PlayerSessionGate playerSessions; @@ -77,17 +83,46 @@ public PreferenceManager(InteractionVisualizer plugin) { this.plugin = plugin; this.valid = new AtomicBoolean(true); this.playerSessions = new PlayerSessionGate(); - this.playerIoExecutor = Executors.newThreadPerTaskExecutor( - Thread.ofVirtual().name("InteractionVisualizer-PlayerIO-", 0).factory()); + this.playerIoExecutor = Executors.newSingleThreadExecutor( + Thread.ofPlatform().name("InteractionVisualizer-PreferenceIO").factory()); this.playerIo = new PlayerIoQueue(playerIoExecutor); - this.entries = Collections.synchronizedList(ArrayUtils.putToArrayList(Database.getBitIndex(), new ArrayList<>())); + AtomicReference> loadedIndex = new AtomicReference<>(); + try { + playerIo.runAndWait(SYSTEM_IO_KEY, () -> loadedIndex.set(Database.getBitIndex())); + } catch (RuntimeException | Error exception) { + playerIoExecutor.shutdownNow(); + Database.close(); + throw exception; + } + this.entries = Collections.synchronizedList( + ArrayUtils.putToArrayList(loadedIndex.get(), new ArrayList<>())); + this.entryIndexes = Map.of(); + this.registeredEntries = List.of(); this.preferences = new ConcurrentHashMap<>(); - this.backingPlayerList = SynchronizedFilteredCollection.from(new LinkedHashSet<>()); - for (Player player : Bukkit.getOnlinePlayers()) { - UUID uuid = player.getUniqueId(); - Object session = playerSessions.open(uuid, valid); - backingPlayerList.add(player); - playerIo.runAndWait(uuid, () -> loadPlayerForSession(uuid, player.getName(), session)); + this.backingPlayerList = new ViewerGroup(); + this.viewerGroups = new ConcurrentHashMap<>(); + rebuildEntryCache(); + try { + for (Player player : Bukkit.getOnlinePlayers()) { + UUID uuid = player.getUniqueId(); + Object session = playerSessions.open(uuid, valid); + backingPlayerList.add(player); + playerIo.runAndWait(uuid, + () -> loadPlayerForSession(uuid, player.getName(), session, player)); + } + } catch (RuntimeException | Error exception) { + playerSessions.close(valid); + try { + playerIo.closeAndWait(); + } catch (RuntimeException | Error closeFailure) { + if (closeFailure != exception) { + exception.addSuppressed(closeFailure); + } + } finally { + playerIoExecutor.shutdownNow(); + Database.close(); + } + throw exception; } Bukkit.getPluginManager().registerEvents(this, plugin); } @@ -97,15 +132,23 @@ public synchronized void close() { if (!playerSessions.close(valid)) { return; } - backingPlayerList.clear(); Map> snapshots = new HashMap<>(); for (Entry> entry : preferences.entrySet()) { snapshots.put(entry.getKey(), copyPreferences(entry.getValue())); } preferences.clear(); + backingPlayerList.clear(); + for (EnumMap groups : viewerGroups.values()) { + groups.values().forEach(ViewerGroup::clear); + } for (Entry> entry : snapshots.entrySet()) { playerIo.submit(entry.getKey(), () -> savePlayerInfo(entry.getKey(), entry.getValue())); } + Map indexSnapshot; + synchronized (entries) { + indexSnapshot = ArrayUtils.putToMap(entries, new HashMap<>()); + } + playerIo.submit(SYSTEM_IO_KEY, () -> Database.setBitIndex(indexSnapshot)); RuntimeException playerIoFailure = null; try { playerIo.closeAndWait(); @@ -113,8 +156,8 @@ public synchronized void close() { playerIoFailure = exception; } finally { playerIoExecutor.shutdown(); + Database.close(); } - saveBitmaskIndex(); if (playerIoFailure != null) { plugin.getLogger().log(Level.SEVERE, "One or more queued player preference operations failed before shutdown", playerIoFailure); @@ -126,11 +169,16 @@ public boolean isValid() { } public void saveBitmaskIndex() { + if (!valid.get()) { + return; + } Bukkit.getConsoleSender().sendMessage(Component.text( "[InteractionVisualizer] Saving player preferences bitmask index, do not halt the server.", NamedTextColor.AQUA)); + Map snapshot; synchronized (entries) { - Database.runExclusive(() -> Database.setBitIndex(ArrayUtils.putToMap(entries, new HashMap<>()))); + snapshot = ArrayUtils.putToMap(entries, new HashMap<>()); } + playerIo.runAndWait(SYSTEM_IO_KEY, () -> Database.setBitIndex(snapshot)); } public void registerEntry(EntryKey entryKey) { @@ -145,25 +193,27 @@ public void registerEntry(EntryKey... entryKeys) { } public void registerEntry(List entryKeys) { - if (!entryKeys.isEmpty()) { - synchronized (entries) { - Database.runExclusive(() -> { - List updatedEntries = ArrayUtils.putToArrayList(Database.getBitIndex(), new ArrayList<>()); - entries.clear(); - entries.addAll(updatedEntries); - boolean changes = false; - for (EntryKey entry : entryKeys) { - if (!entries.contains(entry)) { - changes = true; - entries.add(entry); - } - } - if (changes) { - Database.setBitIndex(ArrayUtils.putToMap(entries, new HashMap<>())); - } - }); + if (entryKeys.isEmpty() || !valid.get()) { + return; + } + boolean changed = false; + Map snapshot = null; + synchronized (entries) { + for (EntryKey entry : entryKeys) { + if (entry != null && !entries.contains(entry)) { + entries.add(entry); + changed = true; + } + } + if (changed) { + rebuildEntryCache(); + snapshot = ArrayUtils.putToMap(entries, new HashMap<>()); } } + if (changed) { + Map indexSnapshot = snapshot; + playerIo.runAndWait(SYSTEM_IO_KEY, () -> Database.setBitIndex(indexSnapshot)); + } } @EventHandler @@ -176,8 +226,8 @@ public void onJoinEvent(PlayerJoinEvent event) { return; } backingPlayerList.add(player); - playerIo.submit(uuid, () -> { - if (loadPlayerForSession(uuid, name, session)) { + submitAsync(uuid, "load player preferences", () -> { + if (loadPlayerForSession(uuid, name, session, player)) { InteractionVisualizer.asyncExecutorManager.runTaskSynchronously(() -> { if (valid.get() && playerSessions.isCurrent(uuid, session) && player.isOnline()) { updatePlayer(player, false); @@ -191,10 +241,11 @@ public void onJoinEvent(PlayerJoinEvent event) { public void onQuitEvent(PlayerQuitEvent event) { Player player = event.getPlayer(); UUID uuid = player.getUniqueId(); - backingPlayerList.remove(player); playerSessions.end(uuid); + backingPlayerList.remove(player); + removeFromViewerGroups(player); Map snapshot = copyPreferences(preferences.remove(uuid)); - playerIo.submit(uuid, () -> savePlayerInfo(uuid, snapshot)); + submitAsync(uuid, "save player preferences", () -> savePlayerInfo(uuid, snapshot)); } public void loadPlayer(UUID uuid, String name, boolean createIfNotFound) { @@ -209,62 +260,57 @@ public void loadPlayer(UUID uuid, String name, boolean createIfNotFound) { }); } - private boolean loadPlayerForSession(UUID uuid, String name, Object session) { + private boolean loadPlayerForSession(UUID uuid, String name, Object session, Player player) { Map info = readPlayerInfo(uuid, name, true); return info != null && playerSessions.publishIfCurrent( - uuid, session, valid, () -> preferences.put(uuid, info)); + uuid, session, valid, () -> { + preferences.put(uuid, info); + if (player != null) { + updateViewerGroups(player, info); + } + }); } private Map readPlayerInfo(UUID uuid, String name, boolean createIfNotFound) { + BitSet defaults = null; if (createIfNotFound) { - boolean newPlayer = false; - if (!Database.playerExists(uuid)) { - Database.createPlayer(uuid, name); - newPlayer = true; - } - Map info = Database.getPlayerInfo(uuid); - if (newPlayer && InteractionVisualizer.defaultDisabledAll) { - disableAll(info); - savePlayerInfo(uuid, info); - } - return info; - } else { - if (Database.playerExists(uuid)) { - return Database.getPlayerInfo(uuid); + defaults = new BitSet(); + if (InteractionVisualizer.defaultDisabledAll) { + defaults.set(0, entryCount(), true); } - return null; } + return Database.loadPlayer(uuid, name, createIfNotFound, defaults); } public void savePlayer(UUID uuid, boolean unload) { + if (!valid.get()) { + return; + } Map info = copyPreferences(unload ? preferences.remove(uuid) : preferences.get(uuid)); + if (unload) { + Player player = backingPlayerList.get(uuid); + if (player != null) { + removeFromViewerGroups(player); + } + } playerIo.runAndWait(uuid, () -> savePlayerInfo(uuid, info)); } private void savePlayerInfo(UUID uuid, Map info) { if (info != null) { - for (Entry entry : info.entrySet()) { - switch (entry.getKey()) { - case HOLOGRAM: - Database.setHologram(uuid, entry.getValue()); - break; - case ITEMDROP: - Database.setItemDrop(uuid, entry.getValue()); - break; - case ITEMSTAND: - Database.setItemStand(uuid, entry.getValue()); - break; - } - } + Database.savePlayer(uuid, info); } } - private void disableAll(Map info) { - synchronized (entries) { - int entryCount = entries.size(); - for (Modules module : Modules.values()) { - info.computeIfAbsent(module, ignored -> new BitSet()).set(0, entryCount, true); - } + private void submitAsync(UUID uuid, String description, Runnable operation) { + CompletableFuture completion = playerIo.submit(uuid, operation); + if (completion != null) { + completion.whenComplete((ignored, failure) -> { + if (failure != null) { + plugin.getLogger().log(Level.SEVERE, + "Unable to " + description + " for " + uuid, failure); + } + }); } } @@ -281,6 +327,10 @@ private static Map copyPreferences(Map source) public void unloadPlayerWithoutSaving(UUID uuid) { preferences.remove(uuid); + Player player = backingPlayerList.get(uuid); + if (player != null) { + removeFromViewerGroups(player); + } } public void updatePlayer(Player player, boolean reset) { @@ -292,22 +342,22 @@ public void updatePlayer(Player player, boolean reset) { } public boolean isRegisteredEntry(EntryKey entry) { - return entries.contains(entry); + return entryIndexes.containsKey(entry); } public List getRegisteredEntries() { - return Collections.unmodifiableList(entries); + return registeredEntries; } public boolean getPlayerPreference(UUID uuid, Modules module, EntryKey entry) { - int i = entries.indexOf(entry); - if (i < 0) { + Integer index = entryIndexes.get(entry); + if (index == null) { return false; } Map info = preferences.get(uuid); if (info != null) { BitSet bitset = info.get(module); - return !bitset.get(i); + return bitset != null && !bitset.get(index); } else { return false; } @@ -326,14 +376,15 @@ public Map> getPlayerPreferences(UUID uuid) { } public void setPlayerPreference(UUID uuid, Modules module, EntryKey entry, boolean enabled, boolean update) { - int i = entries.indexOf(entry); - if (i < 0) { + Integer index = entryIndexes.get(entry); + if (index == null) { return; } Map info = preferences.get(uuid); if (info != null) { - BitSet bitset = info.get(module); - bitset.set(i, !enabled); + BitSet bitset = info.computeIfAbsent(module, ignored -> new BitSet()); + bitset.set(index, !enabled); + updateViewerGroup(uuid, module, entry, enabled); } if (update) { Player player = Bukkit.getPlayer(uuid); @@ -344,8 +395,13 @@ public void setPlayerPreference(UUID uuid, Modules module, EntryKey entry, boole } public void setPlayerAllPreference(UUID uuid, Modules module, boolean enabled, boolean update) { - for (EntryKey entry : getRegisteredEntries()) { - setPlayerPreference(uuid, module, entry, enabled, false); + Map info = preferences.get(uuid); + if (info != null) { + info.computeIfAbsent(module, ignored -> new BitSet()) + .set(0, entryCount(), !enabled); + for (EntryKey entry : registeredEntries) { + updateViewerGroup(uuid, module, entry, enabled); + } } if (update) { Player player = Bukkit.getPlayer(uuid); @@ -356,8 +412,13 @@ public void setPlayerAllPreference(UUID uuid, Modules module, boolean enabled, b } public void setPlayerAllPreference(UUID uuid, EntryKey entry, boolean enabled, boolean update) { - for (Modules module : Modules.values()) { - setPlayerPreference(uuid, module, entry, enabled, false); + Integer index = entryIndexes.get(entry); + Map info = preferences.get(uuid); + if (index != null && info != null) { + for (Modules module : Modules.values()) { + info.computeIfAbsent(module, ignored -> new BitSet()).set(index, !enabled); + updateViewerGroup(uuid, module, entry, enabled); + } } if (update) { Player player = Bukkit.getPlayer(uuid); @@ -368,9 +429,15 @@ public void setPlayerAllPreference(UUID uuid, EntryKey entry, boolean enabled, b } public void setPlayerAllPreference(UUID uuid, boolean enabled, boolean update) { - for (Modules module : Modules.values()) { - for (EntryKey entry : getRegisteredEntries()) { - setPlayerPreference(uuid, module, entry, enabled, false); + Map info = preferences.get(uuid); + if (info != null) { + int entryCount = entryCount(); + for (Modules module : Modules.values()) { + info.computeIfAbsent(module, ignored -> new BitSet()) + .set(0, entryCount, !enabled); + for (EntryKey entry : registeredEntries) { + updateViewerGroup(uuid, module, entry, enabled); + } } } if (update) { @@ -391,14 +458,14 @@ public boolean hasAnyPreferenceDisabled(UUID uuid, Modules module) { } public boolean hasAnyPreferenceDisabled(UUID uuid, EntryKey entry) { - int i = entries.indexOf(entry); - if (i < 0) { + Integer index = entryIndexes.get(entry); + if (index == null) { return false; } Map info = preferences.get(uuid); if (info != null) { for (BitSet bitset : info.values()) { - if (bitset.get(i)) { + if (bitset.get(index)) { return true; } } @@ -422,7 +489,7 @@ public boolean hasAnyPreferenceEnabled(UUID uuid, Modules module) { Map info = preferences.get(uuid); if (info != null) { BitSet bitset = info.get(module); - for (int i = 0; i < entries.size(); i++) { + for (int i = 0; i < entryCount(); i++) { if (!bitset.get(i)) { return true; } @@ -432,14 +499,14 @@ public boolean hasAnyPreferenceEnabled(UUID uuid, Modules module) { } public boolean hasAnyPreferenceEnabled(UUID uuid, EntryKey entry) { - int i = entries.indexOf(entry); - if (i < 0) { + Integer index = entryIndexes.get(entry); + if (index == null) { return false; } Map info = preferences.get(uuid); if (info != null) { for (BitSet bitset : info.values()) { - if (!bitset.get(i)) { + if (!bitset.get(index)) { return true; } } @@ -451,7 +518,7 @@ public boolean hasAnyPreferenceEnabled(UUID uuid) { Map info = preferences.get(uuid); if (info != null) { for (BitSet bitset : info.values()) { - for (int i = 0; i < entries.size(); i++) { + for (int i = 0; i < entryCount(); i++) { if (!bitset.get(i)) { return true; } @@ -465,7 +532,7 @@ public boolean hasAllPreferenceEnabled(UUID uuid, Modules module) { Map info = preferences.get(uuid); if (info != null) { BitSet bitset = info.get(module); - for (int i = 0; i < entries.size(); i++) { + for (int i = 0; i < entryCount(); i++) { if (bitset.get(i)) { return false; } @@ -475,14 +542,14 @@ public boolean hasAllPreferenceEnabled(UUID uuid, Modules module) { } public boolean hasAllPreferenceEnabled(UUID uuid, EntryKey entry) { - int i = entries.indexOf(entry); - if (i < 0) { + Integer index = entryIndexes.get(entry); + if (index == null) { return false; } Map info = preferences.get(uuid); if (info != null) { for (BitSet bitset : info.values()) { - if (bitset.get(i)) { + if (bitset.get(index)) { return false; } } @@ -494,7 +561,7 @@ public boolean hasAllPreferenceEnabled(UUID uuid) { Map info = preferences.get(uuid); if (info != null) { for (BitSet bitset : info.values()) { - for (int i = 0; i < entries.size(); i++) { + for (int i = 0; i < entryCount(); i++) { if (bitset.get(i)) { return false; } @@ -505,43 +572,286 @@ public boolean hasAllPreferenceEnabled(UUID uuid) { } public Collection getPlayerList(Modules module, EntryKey entry) { - BooleanSupplier serverSetting; - switch (module) { - case HOLOGRAM: - serverSetting = () -> InteractionVisualizer.hologramsEnabled && !InteractionVisualizer.hologramsDisabled.contains(entry); - break; - case ITEMDROP: - serverSetting = () -> InteractionVisualizer.itemDropEnabled && !InteractionVisualizer.itemDropDisabled.contains(entry); - break; - case ITEMSTAND: - serverSetting = () -> InteractionVisualizer.itemStandEnabled && !InteractionVisualizer.itemStandDisabled.contains(entry); - break; - default: - serverSetting = () -> true; - break; - } - return SynchronizedFilteredCollection.filter(backingPlayerList, player -> { - if (!serverSetting.getAsBoolean()) { - return false; + return getViewerGroup(module, entry, true); + } + + public Collection getPlayerListIgnoreServerSetting(Modules module, EntryKey entry) { + return getViewerGroup(module, entry, false); + } + + public Collection getPlayerList() { + return backingPlayerList.view(); + } + + private int entryCount() { + synchronized (entries) { + return entries.size(); + } + } + + private void rebuildEntryCache() { + Map indexes = new HashMap<>(); + List registered = new ArrayList<>(); + synchronized (entries) { + for (int index = 0; index < entries.size(); index++) { + EntryKey entry = entries.get(index); + if (entry != null) { + indexes.put(entry, index); + registered.add(entry); + } } - if (!isRegisteredEntry(entry)) { - return false; + } + synchronized (viewerGroups) { + for (EntryKey entry : registered) { + if (viewerGroups.containsKey(entry)) { + continue; + } + int index = indexes.get(entry); + EnumMap groups = new EnumMap<>(Modules.class); + for (Modules module : Modules.values()) { + ViewerGroup group = new ViewerGroup(module, entry); + for (Player player : backingPlayerList) { + if (preferenceEnabled(preferences.get(player.getUniqueId()), module, index)) { + group.add(player); + } + } + groups.put(module, group); + } + viewerGroups.put(entry, groups); } - return getPlayerPreference(player.getUniqueId(), module, entry); - }); + entryIndexes = Collections.unmodifiableMap(indexes); + registeredEntries = Collections.unmodifiableList(registered); + } } - public Collection getPlayerListIgnoreServerSetting(Modules module, EntryKey entry) { - return SynchronizedFilteredCollection.filter(backingPlayerList, player -> { - if (!isRegisteredEntry(entry)) { - return false; + private void updateViewerGroups(Player player, Map info) { + synchronized (viewerGroups) { + for (Entry indexedEntry : entryIndexes.entrySet()) { + EnumMap groups = viewerGroups.get(indexedEntry.getKey()); + if (groups == null) { + continue; + } + for (Modules module : Modules.values()) { + ViewerGroup group = groups.get(module); + if (preferenceEnabled(info, module, indexedEntry.getValue())) { + group.add(player); + } else { + group.remove(player); + } + } } - return getPlayerPreference(player.getUniqueId(), module, entry); - }); + } } - public Collection getPlayerList() { - return Collections.unmodifiableCollection(backingPlayerList); + private void updateViewerGroup(UUID uuid, Modules module, EntryKey entry, boolean enabled) { + EnumMap groups = viewerGroups.get(entry); + ViewerGroup group = groups == null ? null : groups.get(module); + if (group == null) { + return; + } + Player player = backingPlayerList.get(uuid); + if (enabled && player != null) { + group.add(player); + } else { + group.remove(uuid); + } + } + + private void removeFromViewerGroups(Player player) { + for (EnumMap groups : viewerGroups.values()) { + for (ViewerGroup group : groups.values()) { + group.remove(player); + } + } + } + + private Collection getViewerGroup(Modules module, EntryKey entry, + boolean honorServerSetting) { + EnumMap groups = viewerGroups.get(entry); + ViewerGroup group = groups == null ? null : groups.get(module); + if (group == null) { + return List.of(); + } + return honorServerSetting ? group.serverView() : group.view(); + } + + private static boolean preferenceEnabled(Map info, Modules module, int index) { + if (info == null) { + return false; + } + BitSet disabled = info.get(module); + return disabled != null && !disabled.get(index); + } + + private static boolean serverSettingEnabled(Modules module, EntryKey entry) { + return switch (module) { + case HOLOGRAM -> InteractionVisualizer.hologramsEnabled + && !InteractionVisualizer.hologramsDisabled.contains(entry); + case ITEMDROP -> InteractionVisualizer.itemDropEnabled + && !InteractionVisualizer.itemDropDisabled.contains(entry); + case ITEMSTAND -> InteractionVisualizer.itemStandEnabled + && !InteractionVisualizer.itemStandDisabled.contains(entry); + }; + } + + /** Concurrent, snapshot-free membership view updated only on state changes. */ + interface ViewerMembership { + + boolean containsViewer(UUID viewer); + } + + static final class ViewerGroup extends AbstractCollection implements ViewerMembership { + + private final ConcurrentHashMap players = new ConcurrentHashMap<>(); + private final Collection readOnly = Collections.unmodifiableCollection(this); + private final Collection serverView; + + ViewerGroup() { + this.serverView = readOnly; + } + + ViewerGroup(Modules module, EntryKey entry) { + this.serverView = new ServerSettingView(this, module, entry); + } + + @Override + public boolean add(Player player) { + Objects.requireNonNull(player, "player"); + return players.put(player.getUniqueId(), player) != player; + } + + @Override + public boolean remove(Object value) { + if (!(value instanceof Player player)) { + return false; + } + return players.remove(player.getUniqueId(), player); + } + + boolean remove(UUID uuid) { + return players.remove(uuid) != null; + } + + Player get(UUID uuid) { + return players.get(uuid); + } + + Collection view() { + return readOnly; + } + + Collection serverView() { + return serverView; + } + + @Override + public Iterator iterator() { + return players.values().iterator(); + } + + @Override + public int size() { + return players.size(); + } + + @Override + public boolean isEmpty() { + return players.isEmpty(); + } + + @Override + public boolean contains(Object value) { + if (!(value instanceof Player player)) { + return false; + } + Player current = players.get(player.getUniqueId()); + return current == player || player.equals(current); + } + + @Override + public boolean containsViewer(UUID viewer) { + return players.containsKey(viewer); + } + + @Override + public void clear() { + players.clear(); + } + + private static final class ServerSettingView extends AbstractCollection + implements ViewerMembership { + + private final ViewerGroup delegate; + private final Modules module; + private final EntryKey entry; + + private ServerSettingView(ViewerGroup delegate, Modules module, EntryKey entry) { + this.delegate = delegate; + this.module = module; + this.entry = entry; + } + + @Override + public Iterator iterator() { + return serverSettingEnabled(module, entry) + ? delegate.view().iterator() : Collections.emptyIterator(); + } + + @Override + public int size() { + return serverSettingEnabled(module, entry) ? delegate.size() : 0; + } + + @Override + public boolean isEmpty() { + return !serverSettingEnabled(module, entry) || delegate.isEmpty(); + } + + @Override + public boolean contains(Object value) { + return serverSettingEnabled(module, entry) && delegate.contains(value); + } + + @Override + public boolean containsViewer(UUID viewer) { + return serverSettingEnabled(module, entry) && delegate.containsViewer(viewer); + } + + @Override + public boolean add(Player player) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean addAll(Collection collection) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeAll(Collection collection) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retainAll(Collection collection) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeIf(Predicate filter) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + } } static final class PlayerSessionGate { @@ -590,14 +900,13 @@ synchronized boolean close(AtomicBoolean valid) { } } - /** - * Serializes database work for one player without coupling unrelated players to the same queue. - */ + /** One global FIFO matching the single persistent JDBC connection. */ static final class PlayerIoQueue { private final Executor executor; - private final Map queues = new HashMap<>(); + private final Queue operations = new ArrayDeque<>(); private final ThreadLocal activePlayer = new ThreadLocal<>(); + private boolean running; private boolean accepting = true; private CompletableFuture closed; private Throwable failure; @@ -610,34 +919,33 @@ CompletableFuture submit(UUID uuid, Runnable operation) { Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(operation, "operation"); - QueuedOperation queued = new QueuedOperation(operation); - QueueState state; + QueuedOperation queued = new QueuedOperation(uuid, operation); boolean startDrain; synchronized (this) { if (!accepting) { return null; } - state = queues.computeIfAbsent(uuid, ignored -> new QueueState()); - state.operations.add(queued); - startDrain = !state.running; + operations.add(queued); + PerformanceMetrics.preferenceIoQueueDepth(operations.size()); + startDrain = !running; if (startDrain) { - state.running = true; + running = true; } } if (startDrain) { try { - executor.execute(() -> drain(uuid, state)); + executor.execute(this::drain); } catch (RuntimeException | Error throwable) { - reject(uuid, state, throwable); + reject(throwable); } } return queued.completion; } boolean runAndWait(UUID uuid, Runnable operation) { - if (uuid.equals(activePlayer.get())) { - throw new IllegalStateException("Cannot synchronously enqueue player I/O from its own queue"); + if (activePlayer.get() != null) { + throw new IllegalStateException("Cannot synchronously enqueue preference I/O from its own queue"); } CompletableFuture completion = submit(uuid, operation); if (completion == null) { @@ -664,26 +972,25 @@ synchronized CompletableFuture seal() { return closed; } - private void drain(UUID uuid, QueueState state) { + private void drain() { while (true) { QueuedOperation queued; synchronized (this) { - queued = state.operations.poll(); + queued = operations.poll(); if (queued == null) { - state.running = false; - if (queues.get(uuid) == state) { - queues.remove(uuid); - } + running = false; completeCloseIfIdle(); return; } } - activePlayer.set(uuid); + activePlayer.set(queued.uuid); try { + PerformanceMetrics.preferenceIoOperation(); queued.operation.run(); queued.completion.complete(null); } catch (Throwable throwable) { + PerformanceMetrics.preferenceIoFailure(); recordFailure(throwable); queued.completion.completeExceptionally(throwable); } finally { @@ -692,28 +999,28 @@ private void drain(UUID uuid, QueueState state) { } } - private void reject(UUID uuid, QueueState state, Throwable throwable) { + private void reject(Throwable throwable) { List rejected = new ArrayList<>(); synchronized (this) { - if (queues.get(uuid) != state) { + if (!running) { return; } recordFailureLocked(throwable); - state.running = false; + running = false; QueuedOperation queued; - while ((queued = state.operations.poll()) != null) { + while ((queued = operations.poll()) != null) { rejected.add(queued); } - queues.remove(uuid); completeCloseIfIdle(); } + PerformanceMetrics.preferenceIoFailure(); for (QueuedOperation queued : rejected) { queued.completion.completeExceptionally(throwable); } } private void completeCloseIfIdle() { - if (closed != null && queues.isEmpty()) { + if (closed != null && !running && operations.isEmpty()) { if (failure == null) { closed.complete(null); } else { @@ -747,18 +1054,14 @@ private static void await(CompletableFuture completion) { } } - private static final class QueueState { - - private final Queue operations = new ArrayDeque<>(); - private boolean running; - } - private static final class QueuedOperation { + private final UUID uuid; private final Runnable operation; private final CompletableFuture completion = new CompletableFuture<>(); - private QueuedOperation(Runnable operation) { + private QueuedOperation(UUID uuid, Runnable operation) { + this.uuid = uuid; this.operation = operation; } } diff --git a/common/src/test/java/com/loohp/interactionvisualizer/database/DatabaseRetryTest.java b/common/src/test/java/com/loohp/interactionvisualizer/database/DatabaseRetryTest.java new file mode 100644 index 00000000..dae22921 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/database/DatabaseRetryTest.java @@ -0,0 +1,85 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.database; + +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DatabaseRetryTest { + + @Test + void discardsAndRetriesOnceAfterAConnectionFailure() { + AtomicInteger connections = new AtomicInteger(); + AtomicInteger discards = new AtomicInteger(); + AtomicInteger reconnects = new AtomicInteger(); + + String result = Database.retryOnce("test operation", + connections::incrementAndGet, + connection -> { + if (connection == 1) { + throw new SQLException("connection lost"); + } + return "ok-" + connection; + }, discards::incrementAndGet, reconnects::incrementAndGet); + + assertEquals("ok-2", result); + assertEquals(2, connections.get()); + assertEquals(1, discards.get()); + assertEquals(1, reconnects.get()); + } + + @Test + void secondSqlFailureIsPropagatedWithTheFirstFailureAttached() { + SQLException first = new SQLException("first"); + SQLException second = new SQLException("second"); + AtomicInteger attempts = new AtomicInteger(); + AtomicInteger discards = new AtomicInteger(); + AtomicInteger reconnects = new AtomicInteger(); + + Database.DatabaseException failure = assertThrows(Database.DatabaseException.class, + () -> Database.retryOnce("test operation", attempts::incrementAndGet, + connection -> { + throw connection == 1 ? first : second; + }, discards::incrementAndGet, reconnects::incrementAndGet)); + + assertSame(second, failure.getCause()); + assertEquals(1, second.getSuppressed().length); + assertSame(first, second.getSuppressed()[0]); + assertEquals(2, discards.get()); + assertEquals(1, reconnects.get()); + } + + @Test + void applicationFailuresAreNotRetriedAsConnectionFailures() { + AtomicInteger connections = new AtomicInteger(); + AtomicInteger discards = new AtomicInteger(); + AtomicInteger reconnects = new AtomicInteger(); + IllegalStateException applicationFailure = new IllegalStateException("bad data"); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> Database.retryOnce("test operation", connections::incrementAndGet, + connection -> { + throw applicationFailure; + }, discards::incrementAndGet, reconnects::incrementAndGet)); + + assertSame(applicationFailure, thrown); + assertEquals(1, connections.get()); + assertEquals(0, discards.get()); + assertEquals(0, reconnects.get()); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java index 8f397f8e..a39b1898 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java @@ -87,7 +87,8 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0, - 0L, 0L, 7L, 12_345_678L, 0, 0, 0, 100L, 5L, 200L); + 0L, 0L, 7L, 12_345_678L, 0, 0, 0, + 0L, 0L, 0, 0L, 0L, 100L, 5L, 200L); String json = snapshot.json(); @@ -104,6 +105,7 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { assertTrue(json.contains("\"viewerFullReconciles\":0")); assertTrue(json.contains("\"craftEngineCullingRetainedRegistrations\":0")); assertTrue(json.contains("\"blockUpdateDirtyQueueMax\":0")); + assertTrue(json.contains("\"preferenceSqlStatements\":0")); assertTrue(json.contains("\"legacyTextCacheHits\":95")); assertTrue(json.contains("\"legacyTextCacheHitRate\":0.950000")); assertTrue(json.contains("\"legacyTextSameRawFastPaths\":200")); diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java index bc3a83b9..117b62ef 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PreferenceManagerSessionGateTest.java @@ -11,10 +11,17 @@ package com.loohp.interactionvisualizer.managers; +import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.api.InteractionVisualizerAPI.Modules; +import com.loohp.interactionvisualizer.objectholders.EntryKey; +import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; +import java.lang.reflect.Proxy; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -127,35 +134,88 @@ void quitSaveFinishesBeforeTheSamePlayersReconnectLoad() throws Exception { } @Test - void differentPlayersCanUseSeparateDrainsConcurrently() throws Exception { + void differentPlayersShareOneFifoDatabaseDrain() throws Exception { ExecutorService workers = Executors.newFixedThreadPool(2); - CountDownLatch bothStarted = new CountDownLatch(2); - CountDownLatch releaseBoth = new CountDownLatch(1); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + List order = Collections.synchronizedList(new ArrayList<>()); try { PreferenceManager.PlayerIoQueue queue = new PreferenceManager.PlayerIoQueue(workers); CompletableFuture first = queue.submit(UUID.randomUUID(), () -> { - bothStarted.countDown(); - await(releaseBoth); + order.add("first-start"); + firstStarted.countDown(); + await(releaseFirst); + order.add("first-end"); }); CompletableFuture second = queue.submit(UUID.randomUUID(), () -> { - bothStarted.countDown(); - await(releaseBoth); + order.add("second"); + secondStarted.countDown(); }); assertNotNull(first); assertNotNull(second); - assertTrue(bothStarted.await(5, TimeUnit.SECONDS), - "unrelated player queues were serialized behind one drain"); - releaseBoth.countDown(); + assertTrue(firstStarted.await(5, TimeUnit.SECONDS)); + assertFalse(secondStarted.await(100, TimeUnit.MILLISECONDS), + "a second player bypassed the global JDBC FIFO"); + releaseFirst.countDown(); CompletableFuture.allOf(first, second).get(5, TimeUnit.SECONDS); + assertEquals(List.of("first-start", "first-end", "second"), order); queue.closeAndWait(); } finally { - releaseBoth.countDown(); + releaseFirst.countDown(); workers.shutdownNow(); assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); } } + @Test + void viewerGroupReplacesReconnectInstancesWithoutAllocatingNewViews() { + PreferenceManager.ViewerGroup group = new PreferenceManager.ViewerGroup(); + UUID uuid = UUID.randomUUID(); + Player oldPlayer = player(uuid, "old"); + Player reconnectedPlayer = player(uuid, "new"); + + Collection view = group.view(); + assertSame(view, group.view()); + assertTrue(group.add(oldPlayer)); + assertTrue(group.add(reconnectedPlayer)); + assertFalse(group.remove(oldPlayer)); + assertTrue(view.contains(reconnectedPlayer)); + assertEquals(1, view.size()); + assertThrows(UnsupportedOperationException.class, view::clear); + assertTrue(group.remove(reconnectedPlayer)); + assertTrue(view.isEmpty()); + } + + @Test + void cachedServerViewTracksReloadedModuleSettingsWithoutRebuildingTheGroup() { + boolean previousEnabled = InteractionVisualizer.hologramsEnabled; + var previousDisabled = InteractionVisualizer.hologramsDisabled; + try { + InteractionVisualizer.hologramsEnabled = true; + InteractionVisualizer.hologramsDisabled = new HashSet<>(); + EntryKey entry = new EntryKey("preference_cache_test"); + PreferenceManager.ViewerGroup group = new PreferenceManager.ViewerGroup( + Modules.HOLOGRAM, entry); + Player player = player(UUID.randomUUID(), "viewer"); + group.add(player); + + Collection serverView = group.serverView(); + assertSame(serverView, group.serverView()); + assertTrue(serverView.contains(player)); + assertThrows(UnsupportedOperationException.class, serverView::clear); + + InteractionVisualizer.hologramsEnabled = false; + assertTrue(serverView.isEmpty()); + assertEquals(1, group.view().size(), + "server setting changes must not discard the preference membership cache"); + } finally { + InteractionVisualizer.hologramsEnabled = previousEnabled; + InteractionVisualizer.hologramsDisabled = previousDisabled; + } + } + @Test void sealingWaitsForQueuedFinalSaveAndRejectsLateWork() throws Exception { ExecutorService worker = Executors.newSingleThreadExecutor(); @@ -244,4 +304,17 @@ private static void await(CountDownLatch latch) { throw new AssertionError("interrupted while waiting for test latch", exception); } } + + private static Player player(UUID uuid, String name) { + return (Player) Proxy.newProxyInstance( + Player.class.getClassLoader(), new Class[] {Player.class}, (proxy, method, args) -> { + return switch (method.getName()) { + case "getUniqueId" -> uuid; + case "getName", "toString" -> name; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> throw new UnsupportedOperationException(method.getName()); + }; + }); + } } From 70502037153bbb476284dfd1fd7b5c125433a538 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 16:24:40 +0800 Subject: [PATCH 10/12] Enable Paper 26 performance defaults and lifecycle audit --- README.md | 15 ++ .../InteractionVisualizer.java | 82 ++++++++-- .../blocks/EnderchestDisplay.java | 9 ++ .../database/Database.java | 6 + .../debug/PerformanceBlockScene.java | 4 + .../debug/PerformanceScene.java | 4 + .../entities/DroppedItemDisplay.java | 7 +- .../entities/DroppedItemSpatialIndex.java | 15 ++ .../entities/DroppedItemVisibilityPolicy.java | 6 +- .../integration/CustomContentManager.java | 5 + .../craftengine/CraftEngineLightManager.java | 9 ++ .../managers/AsyncExecutorManager.java | 23 +++ .../managers/DisplayManager.java | 18 +++ .../managers/PerformanceMetrics.java | 141 +++++++++++++++++- .../managers/PlayerLocationManager.java | 4 + .../managers/PreferenceManager.java | 53 +++++++ .../managers/TaskManager.java | 11 ++ .../managers/TileEntityManager.java | 7 + .../EnchantmentTableAnimation.java | 8 + .../objectholders/ILightManager.java | 8 + common/src/main/resources/config.yml | 30 ++-- .../config/SparrowConfigurationTest.java | 65 ++++++++ .../entities/DroppedItemSpatialIndexTest.java | 2 + .../DroppedItemVisibilityPolicyTest.java | 10 +- ...erformanceMetricsShutdownSnapshotTest.java | 43 ++++++ .../PerformanceMetricsSlowestTickTest.java | 3 +- 26 files changed, 550 insertions(+), 38 deletions(-) create mode 100644 common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsShutdownSnapshotTest.java diff --git a/README.md b/README.md index 04da294a..7f4b6d87 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,21 @@ The production plugin JAR is written to `build/libs/InteractionVisualizer-` and `/iv perf stop` around a stable sample window. +The resulting `IV_PERF` JSON includes viewer candidates/full reconciles, +dropped-item spatial and full-scan candidates, block queues, preference I/O and +SQL operations, packet operations, and anchor entity operations. Every plugin +disable also emits `IV_PERF_SHUTDOWN`; `totalRetained` should be zero in repeated +enable/disable leak tests. + ## Optional integrations - [CraftEngine](https://github.com/Xiao-MoMi/craft-engine) 26.7.2: optional diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index e17c873e..39fae401 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java @@ -77,6 +77,8 @@ public class InteractionVisualizer extends JavaPlugin { public static final int BSTATS_PLUGIN_ID = 7024; public static final String CONFIG_ID = "config"; public static final Set SUPPORTED_MINECRAFT_VERSIONS = Set.of("26.1.2", "26.2"); + private static final String PERFORMANCE_MIGRATION_MARKER = + ".paper26-performance-defaults-advised"; public static InteractionVisualizer plugin = null; @@ -114,17 +116,17 @@ public class InteractionVisualizer extends JavaPlugin { public static boolean defaultDisabledAll = false; /** A/B switch: virtual item remains authoritative while an invisible tracker stays stationary. */ - public static boolean staticVirtualItemAnchorsDuringAnimation = false; + public static boolean staticVirtualItemAnchorsDuringAnimation = true; /** A/B switch: eligible stationary virtual items are tracked and rendered entirely by packets. */ - public static boolean packetOnlyStaticVirtualItems = false; + public static boolean packetOnlyStaticVirtualItems = true; /** A/B switch: safe animated virtual items use Typewriter-style per-viewer packet tracking. */ public static boolean packetOnlyAnimatedVirtualItems = false; /** A/B switch: smooths visibility recovery bursts; hides are always immediate. */ - public static boolean visibilityRateLimiting = false; + public static boolean visibilityRateLimiting = true; public static int visibilityRateLimitBucketSize = 128; public static int visibilityRateLimitRestorePerTick = 32; /** A/B switch: coalesces block changes and updates tracked blocks from fixed-budget loops. */ - public static boolean eventDrivenBlockUpdates = false; + public static boolean eventDrivenBlockUpdates = true; public static int blockUpdateMaxDirtyPerTick = 64; private boolean blockUpdateModeInitialized; @@ -181,14 +183,17 @@ public void onEnable() { if (!getDataFolder().exists()) { getDataFolder().mkdirs(); } + File configFile = new File(getDataFolder(), "config.yml"); + boolean existingConfiguration = configFile.isFile(); try { - Config.loadConfig(CONFIG_ID, new File(getDataFolder(), "config.yml"), getClass().getClassLoader().getResourceAsStream("config.yml"), getClass().getClassLoader().getResourceAsStream("config.yml"), true); + Config.loadConfig(CONFIG_ID, configFile, getClass().getClassLoader().getResourceAsStream("config.yml"), getClass().getClassLoader().getResourceAsStream("config.yml"), true); } catch (IOException e) { e.printStackTrace(); getServer().getPluginManager().disablePlugin(this); return; } loadConfig(); + advisePerformanceMigrationOnce(existingConfiguration); boolean craftEngineHooked = CustomContentManager.initialize(this).contains("craftengine"); if (isPluginEnabled("CraftEngine")) { @@ -267,25 +272,41 @@ public void onEnable() { @Override public void onDisable() { shutdownPerformanceScenes(); - if (lightManager != null) { - lightManager.shutdown(); + int retainedLightState = 0; + ILightManager disablingLightManager = lightManager; + if (disablingLightManager != null) { + disablingLightManager.shutdown(); + retainedLightState = disablingLightManager.retainedStateCount(); lightManager = ILightManager.DUMMY_INSTANCE; } - viewerCullingManager.shutdown(); + ViewerCullingManager disablingCullingManager = viewerCullingManager; + disablingCullingManager.shutdown(); + int retainedCullingRegistrations = disablingCullingManager.retainedRegistrations(); viewerCullingManager = ViewerCullingManager.DISABLED; CustomContentManager.shutdown(); - if (preferenceManager != null) { - preferenceManager.close(); + int retainedPreferenceState = 0; + PreferenceManager disablingPreferenceManager = preferenceManager; + if (disablingPreferenceManager != null) { + disablingPreferenceManager.close(); + retainedPreferenceState = disablingPreferenceManager.retainedStateCount(); + preferenceManager = null; } Database.close(); TaskManager.shutdown(); DisplayManager.shutdown(); TileEntityManager.shutdown(); PlayerLocationManager.clearCache(); + playerTrackingRange.clear(); LegacyTextComponentCache.invalidateAll(); - if (asyncExecutorManager != null) { - asyncExecutorManager.close(); + int retainedAsyncTasks = 0; + AsyncExecutorManager disablingAsyncExecutorManager = asyncExecutorManager; + if (disablingAsyncExecutorManager != null) { + disablingAsyncExecutorManager.close(); + retainedAsyncTasks = disablingAsyncExecutorManager.retainedTaskCount(); + asyncExecutorManager = null; } + PerformanceMetrics.logShutdownState(this, retainedAsyncTasks, + retainedPreferenceState, retainedLightState, retainedCullingRegistrations); getServer().getConsoleSender().sendMessage(Component.text( "[InteractionVisualizer] Disabled; all display entities removed.", NamedTextColor.RED)); } @@ -321,6 +342,43 @@ public SparrowConfiguration getConfiguration() { return Config.getConfig(CONFIG_ID).getConfiguration(); } + private void advisePerformanceMigrationOnce(boolean existingConfiguration) { + File marker = new File(getDataFolder(), PERFORMANCE_MIGRATION_MARKER); + if (marker.isFile()) { + return; + } + try { + if (!marker.createNewFile()) { + return; + } + } catch (IOException exception) { + getLogger().log(Level.WARNING, + "Unable to persist the Paper 26 performance migration notice marker", exception); + } + if (existingConfiguration && hasPreservedPerformanceOptOut()) { + getLogger().warning("Paper 26 performance defaults are enabled for new installations. " + + "Your explicit false values were preserved; review StaticAnchorDuringAnimation, " + + "PacketOnlyStatic, VisibilityRateLimit, BlockUpdates.EventDriven, and dropped-item " + + "VisibilityCulling/VisibilityRateLimit when you are ready to migrate."); + } + } + + private boolean hasPreservedPerformanceOptOut() { + SparrowConfiguration configuration = getConfiguration(); + return !configuration.getBoolean( + "Settings.Performance.VirtualItems.StaticAnchorDuringAnimation") + || !configuration.getBoolean( + "Settings.Performance.VirtualItems.PacketOnlyStatic") + || !configuration.getBoolean( + "Settings.Performance.VisibilityRateLimit.Enabled") + || !configuration.getBoolean( + "Settings.Performance.BlockUpdates.EventDriven") + || !configuration.getBoolean( + "Entities.Item.Options.VisibilityCulling.Enabled") + || !configuration.getBoolean( + "Entities.Item.Options.VisibilityRateLimit.Enabled"); + } + public void loadConfig() { Config config = Config.getConfig(CONFIG_ID); config.reload(); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java index c4721711..1922a6e9 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/blocks/EnderchestDisplay.java @@ -63,6 +63,15 @@ public class EnderchestDisplay implements Listener, VisualizerDisplay { public static ConcurrentHashMap> link = new ConcurrentHashMap<>(); public static ConcurrentHashMap playermap = new ConcurrentHashMap<>(); + public static void shutdown() { + link.clear(); + playermap.clear(); + } + + public static int retainedStateCount() { + return link.size() + playermap.size(); + } + @Override public EntryKey key() { return KEY; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java b/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java index fcd5a812..178cd6e2 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java @@ -199,6 +199,12 @@ public static void close() { } } + public static int retainedConnectionCount() { + synchronized (DATABASE_LOCK) { + return connection == null ? 0 : 1; + } + } + /** Compatibility hook for older internal callers. */ public static void runExclusive(Runnable operation) { synchronized (DATABASE_LOCK) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceBlockScene.java b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceBlockScene.java index 77b5b049..b0158d5a 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceBlockScene.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceBlockScene.java @@ -462,6 +462,10 @@ public static ShutdownReport shutdown() { restoreFailures, inspectionFailures); } + public static int retainedStateCount() { + return scenes.size(); + } + private static Snapshot mutate(Session session, int requestedOperations, Mode mode) { Objects.requireNonNull(mode, "mode"); int operations = Math.max(0, Math.min(session.entries.size(), requestedOperations)); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java index 1d4052bd..1e0b79a1 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/debug/PerformanceScene.java @@ -142,6 +142,10 @@ public static void shutdown() { } } + public static int retainedStateCount() { + return scenes.size(); + } + private static void expire(UUID ownerId, Set entities) { if (scenes.remove(ownerId, entities)) { removeEntities(entities); diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java index a515352d..df53fde8 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemDisplay.java @@ -321,6 +321,9 @@ private void tickAllInternal() { } update(tracked, itemIndex, viewerIndex, remainingWorldItems); } + if (viewerIndex != null) { + PerformanceMetrics.droppedViewerDistanceChecks(viewerIndex.candidateChecks()); + } if (visibilityPolicy.controlsPerViewerVisibility()) { DroppedItemVisibilityIndex visibilityIndex = null; if (visibilityPolicy.cullingEnabled()) { @@ -549,9 +552,11 @@ private void reconcileLabelVisibility(Collection viewers, List 0) { @@ -333,6 +346,7 @@ private int firstViewerWithin(double x, double y, double z, double rangeSquared) double[] zCoordinates = this.zCoordinates; int viewerCount = size; for (int index = 0; index < viewerCount; index++) { + candidateChecks++; double deltaX = xCoordinates[index] - x; double deltaY = yCoordinates[index] - y; double deltaZ = zCoordinates[index] - z; @@ -352,6 +366,7 @@ private boolean hasViewerWithinGrid(double x, double y, double z, double rangeSq List points = viewerGrid.get(new ViewerGridCell(cellX, cellZ)); if (points != null) { for (Point point : points) { + candidateChecks++; double deltaX = point.x() - x; double deltaY = point.y() - y; double deltaZ = point.z() - z; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicy.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicy.java index c9d987e0..ce6ca84c 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicy.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicy.java @@ -12,9 +12,9 @@ package com.loohp.interactionvisualizer.entities; /** - * Validated configuration for the opt-in dropped-item visibility controls. - * Disabled culling preserves the legacy server-side label lifecycle; - * rate limiting is independently opt-in. + * Validated configuration for the independently reversible dropped-item + * visibility controls. Disabled culling preserves the legacy server-side + * label lifecycle; rate limiting has its own rollback switch. */ record DroppedItemVisibilityPolicy( int viewDistance, diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/CustomContentManager.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/CustomContentManager.java index 3173dddb..15dc0278 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/integration/CustomContentManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/CustomContentManager.java @@ -69,6 +69,11 @@ public static synchronized void shutdown() { owner = null; } + public static synchronized int retainedStateCount() { + return bridges.size() + failedProviders.size() + reportedRuntimeFailures.size() + + retryAfter.size() + (owner == null ? 0 : 1); + } + public static Set providers() { Set providers = new LinkedHashSet<>(); for (CustomContentBridge bridge : bridges) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java index d16757d7..d00643ce 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java @@ -433,6 +433,15 @@ public void shutdown() { } } + @Override + public int retainedStateCount() { + synchronized (lock) { + return desired.size() + applied.size() + externalBaseline.size() + + dirty.size() + waitingForChunk.size() + restoreOnLoad.size() + + trackedByChunk.size() + (flushTask == null ? 0 : 1); + } + } + private void disableAfterLinkageFailure(LinkageError error) { synchronized (lock) { closed = true; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/AsyncExecutorManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/AsyncExecutorManager.java index f4026dec..d6cffe6a 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/AsyncExecutorManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/AsyncExecutorManager.java @@ -26,6 +26,8 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public class AsyncExecutorManager implements AutoCloseable { @@ -72,6 +74,27 @@ public void close() { return; } executor.shutdown(); + boolean interrupted = false; + try { + if (!executor.awaitTermination(2L, TimeUnit.SECONDS)) { + executor.shutdownNow(); + executor.awaitTermination(2L, TimeUnit.SECONDS); + } + } catch (InterruptedException exception) { + interrupted = true; + executor.shutdownNow(); + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + public int retainedTaskCount() { + if (executor instanceof ThreadPoolExecutor threadPool) { + return threadPool.getActiveCount() + threadPool.getQueue().size(); + } + return executor.isTerminated() ? 0 : 1; } } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java index a868fa2e..a168d676 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/DisplayManager.java @@ -275,6 +275,20 @@ public static void shutdown() { } } + /** Number of roots in every display, viewer and pending-work registry. */ + public static int retainedStateCount() { + return active.size() + playerStatus.size() + renderedRevision.size() + + shownViewers.size() + scheduled.size() + forceScheduled.size() + + logicalByActualUuid.size() + actualUuidByLogical.size() + logicalById.size() + + logicalsByChunk.size() + chunkByLogical.size() + viewerChunks.retainedStateCount() + + shownByViewer.size() + itemAnimations.size() + virtualItemIds.size() + + renderedItemStacks.size() + renderedItemLocations.size() + + packetItemPositions.size() + packetOnlyItems.size() + fixedItemDisplays.size() + + virtualTextDisplays.size() + dynamicViewerPositions.size() + + dirtyDynamicViewers.size() + visibilityShowQueues.size() + + pendingViewersByLogical.size() + pendingVisibilityViewers.size(); + } + @SafeVarargs static Set shutdownCleanupCandidates( Collection... candidateGroups) { @@ -2836,6 +2850,10 @@ void clear() { chunksByViewer.clear(); viewersByChunk.clear(); } + + int retainedStateCount() { + return chunksByViewer.size() + viewersByChunk.size(); + } } private record DynamicViewerPosition(double x, double y, double z) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java index 1a112d9b..4d373274 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java @@ -13,6 +13,12 @@ import com.destroystokyo.paper.event.server.ServerTickEndEvent; import com.loohp.interactionvisualizer.InteractionVisualizer; +import com.loohp.interactionvisualizer.blocks.BlockUpdateCoordinator; +import com.loohp.interactionvisualizer.database.Database; +import com.loohp.interactionvisualizer.debug.PerformanceBlockScene; +import com.loohp.interactionvisualizer.debug.PerformanceScene; +import com.loohp.interactionvisualizer.integration.CustomContentManager; +import com.loohp.interactionvisualizer.scheduler.Scheduler; import com.loohp.interactionvisualizer.utils.LegacyTextComponentCache; import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; @@ -77,6 +83,9 @@ public final class PerformanceMetrics implements Listener { private long craftEngineCullingHideDecisions; private long itemAnimationNanos; private long droppedItemNanos; + private long droppedViewerDistanceChecks; + private long droppedSpatialCandidates; + private long droppedFullScanCandidates; private long blockUpdateChecks; private long blockUpdateNanos; private int blockUpdateCoordinatorLanesMax; @@ -149,6 +158,9 @@ public static boolean start(String requestedLabel) { INSTANCE.craftEngineCullingHideDecisions = 0; INSTANCE.itemAnimationNanos = 0; INSTANCE.droppedItemNanos = 0; + INSTANCE.droppedViewerDistanceChecks = 0; + INSTANCE.droppedSpatialCandidates = 0; + INSTANCE.droppedFullScanCandidates = 0; INSTANCE.blockUpdateChecks = 0; INSTANCE.blockUpdateNanos = 0; INSTANCE.blockUpdateCoordinatorLanesMax = 0; @@ -313,6 +325,24 @@ public static void droppedItemNanos(long nanos) { } } + public static void droppedViewerDistanceChecks(long checks) { + if (INSTANCE.collecting) { + INSTANCE.droppedViewerDistanceChecks += checks; + } + } + + public static void droppedSpatialCandidates(long candidates) { + if (INSTANCE.collecting) { + INSTANCE.droppedSpatialCandidates += candidates; + } + } + + public static void droppedFullScanCandidates(long candidates) { + if (INSTANCE.collecting) { + INSTANCE.droppedFullScanCandidates += candidates; + } + } + public static void blockUpdateChecks(int checks, long nanos) { if (INSTANCE.collecting) { INSTANCE.blockUpdateChecks += checks; @@ -371,6 +401,38 @@ public static void preferenceDatabaseReconnect() { } } + /** + * Emits one machine-readable lifecycle audit after all managers have been + * closed. This is intentionally unconditional so repeated enable/disable + * leak tests do not need to keep a performance sample running. + */ + public static ShutdownSnapshot logShutdownState(Plugin plugin, + int asyncTasks, + int preferenceState, + int lightState, + int cullingRegistrations) { + ShutdownSnapshot snapshot = new ShutdownSnapshot( + Scheduler.retainedTaskCount(plugin), asyncTasks, + TaskManager.retainedStateCount(), + BlockUpdateCoordinator.retainedLaneCount(), + BlockUpdateCoordinator.pendingDirtyCount(), + BlockUpdateCoordinator.activeCount(), + InteractionSessionCoordinator.retainedLaneCount(), + InteractionSessionCoordinator.retainedSessionCount(), + DisplayManager.retainedStateCount(), TileEntityManager.retainedStateCount(), + PlayerLocationManager.retainedStateCount(), + InteractionVisualizer.playerTrackingRange.size(), preferenceState, + Database.retainedConnectionCount(), lightState, cullingRegistrations, + CustomContentManager.retainedStateCount(), + PerformanceScene.retainedStateCount() + PerformanceBlockScene.retainedStateCount()); + plugin.getLogger().info("IV_PERF_SHUTDOWN " + snapshot.json()); + if (!snapshot.clean()) { + plugin.getLogger().warning("InteractionVisualizer retained lifecycle state after shutdown: " + + snapshot.summary()); + } + return snapshot; + } + @EventHandler public void onServerTickEnd(ServerTickEndEvent event) { if (!collecting) { @@ -420,7 +482,9 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) viewerFullReconciles, viewerCandidates, craftEngineCullingCandidates, craftEngineCullingShowDecisions, craftEngineCullingHideDecisions, retainedCullingRegistrations, - itemAnimationNanos, droppedItemNanos, blockUpdateChecks, blockUpdateNanos, + itemAnimationNanos, droppedItemNanos, droppedViewerDistanceChecks, + droppedSpatialCandidates, droppedFullScanCandidates, + blockUpdateChecks, blockUpdateNanos, blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, preferenceSqlStatements, preferenceDatabaseReconnects, @@ -451,6 +515,73 @@ public record DroppedLabelVisibilityConfig( int restorePerTick) { } + public record ShutdownSnapshot( + int schedulerTasks, + int asyncTasks, + int taskManagerState, + int blockUpdateLanes, + int blockUpdateDirty, + int blockUpdateActive, + int interactionSessionLanes, + int interactionSessions, + int displayState, + int tileEntityState, + int playerLocationState, + int trackingWorlds, + int preferenceState, + int databaseConnections, + int lightState, + int cullingRegistrations, + int customContentState, + int performanceSceneState) { + + public int totalRetained() { + return schedulerTasks + asyncTasks + taskManagerState + + blockUpdateLanes + blockUpdateDirty + blockUpdateActive + + interactionSessionLanes + interactionSessions + + displayState + tileEntityState + playerLocationState + + trackingWorlds + preferenceState + databaseConnections + + lightState + cullingRegistrations + customContentState + + performanceSceneState; + } + + public boolean clean() { + return totalRetained() == 0; + } + + public String summary() { + return String.format(Locale.ROOT, + "total=%d scheduler=%d async=%d tasks=%d block=%d/%d/%d sessions=%d/%d " + + "display=%d tile=%d playerLocations=%d trackingWorlds=%d " + + "preferences=%d database=%d light=%d culling=%d custom=%d scenes=%d", + totalRetained(), schedulerTasks, asyncTasks, taskManagerState, + blockUpdateLanes, blockUpdateDirty, blockUpdateActive, + interactionSessionLanes, interactionSessions, displayState, + tileEntityState, playerLocationState, trackingWorlds, preferenceState, + databaseConnections, lightState, cullingRegistrations, + customContentState, performanceSceneState); + } + + public String json() { + return String.format(Locale.ROOT, + "{\"schedulerTasks\":%d,\"asyncTasks\":%d,\"taskManagerState\":%d," + + "\"blockUpdateLanes\":%d,\"blockUpdateDirty\":%d," + + "\"blockUpdateActive\":%d,\"interactionSessionLanes\":%d," + + "\"interactionSessions\":%d,\"displayState\":%d," + + "\"tileEntityState\":%d,\"playerLocationState\":%d," + + "\"trackingWorlds\":%d,\"preferenceState\":%d," + + "\"databaseConnections\":%d,\"lightState\":%d," + + "\"cullingRegistrations\":%d,\"customContentState\":%d," + + "\"performanceSceneState\":%d,\"totalRetained\":%d}", + schedulerTasks, asyncTasks, taskManagerState, + blockUpdateLanes, blockUpdateDirty, blockUpdateActive, + interactionSessionLanes, interactionSessions, displayState, + tileEntityState, playerLocationState, trackingWorlds, preferenceState, + databaseConnections, lightState, cullingRegistrations, + customContentState, performanceSceneState, totalRetained()); + } + } + public record Snapshot( String label, boolean staticAnchorDuringAnimation, @@ -501,6 +632,9 @@ public record Snapshot( int craftEngineCullingRetainedRegistrations, long itemAnimationNanos, long droppedItemNanos, + long droppedViewerDistanceChecks, + long droppedSpatialCandidates, + long droppedFullScanCandidates, long blockUpdateChecks, long blockUpdateNanos, int blockUpdateCoordinatorLanesMax, @@ -584,6 +718,9 @@ public String json() { "\"craftEngineCullingHideDecisions\":%d," + "\"craftEngineCullingRetainedRegistrations\":%d," + "\"itemAnimationMs\":%.6f,\"droppedItemMs\":%.6f," + + "\"droppedViewerDistanceChecks\":%d," + + "\"droppedSpatialCandidates\":%d," + + "\"droppedFullScanCandidates\":%d," + "\"blockUpdateChecks\":%d,\"blockUpdateMs\":%.6f," + "\"blockUpdateCoordinatorLanesMax\":%d," + "\"blockUpdateDirtyQueueMax\":%d," + @@ -615,6 +752,8 @@ public String json() { craftEngineCullingShowDecisions, craftEngineCullingHideDecisions, craftEngineCullingRetainedRegistrations, itemAnimationNanos / 1_000_000.0D, droppedItemNanos / 1_000_000.0D, + droppedViewerDistanceChecks, droppedSpatialCandidates, + droppedFullScanCandidates, blockUpdateChecks, blockUpdateNanos / 1_000_000.0D, blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PlayerLocationManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PlayerLocationManager.java index bc1b0f9f..229583f5 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PlayerLocationManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PlayerLocationManager.java @@ -85,6 +85,10 @@ public static void clearCache() { PLAYER_CHUNKS.clear(); } + public static int retainedStateCount() { + return PLAYER_CHUNKS.size(); + } + public static Collection filterOutOfRange(Collection players, VisualizerEntity entity) { return filterOutOfRange(players, entity.getLocation()); } diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java index a508b95b..aea576f8 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/PreferenceManager.java @@ -56,6 +56,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; @@ -149,6 +150,14 @@ public synchronized void close() { indexSnapshot = ArrayUtils.putToMap(entries, new HashMap<>()); } playerIo.submit(SYSTEM_IO_KEY, () -> Database.setBitIndex(indexSnapshot)); + synchronized (viewerGroups) { + viewerGroups.clear(); + } + synchronized (entries) { + entries.clear(); + } + entryIndexes = Map.of(); + registeredEntries = List.of(); RuntimeException playerIoFailure = null; try { playerIo.closeAndWait(); @@ -156,6 +165,7 @@ public synchronized void close() { playerIoFailure = exception; } finally { playerIoExecutor.shutdown(); + awaitExecutorTermination(playerIoExecutor); Database.close(); } if (playerIoFailure != null) { @@ -164,6 +174,41 @@ public synchronized void close() { } } + /** Number of player/session/cache/I/O roots still retained after close. */ + public int retainedStateCount() { + int groupMembers = backingPlayerList.size(); + for (EnumMap groups : viewerGroups.values()) { + for (ViewerGroup group : groups.values()) { + groupMembers += group.size(); + } + } + synchronized (entries) { + return preferences.size() + groupMembers + viewerGroups.size() + + entries.size() + entryIndexes.size() + registeredEntries.size() + + playerSessions.size() + playerIo.retainedOperationCount(); + } + } + + private static void awaitExecutorTermination(ExecutorService executor) { + boolean interrupted = false; + try { + if (!executor.awaitTermination(2L, TimeUnit.SECONDS)) { + executor.shutdownNow(); + if (!executor.awaitTermination(2L, TimeUnit.SECONDS)) { + InteractionVisualizer.plugin.getLogger().warning( + "Preference I/O executor did not terminate cleanly"); + } + } + } catch (InterruptedException exception) { + interrupted = true; + executor.shutdownNow(); + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + public boolean isValid() { return valid.get(); } @@ -898,6 +943,10 @@ synchronized boolean close(AtomicBoolean valid) { sessions.clear(); return true; } + + synchronized int size() { + return sessions.size(); + } } /** One global FIFO matching the single persistent JDBC connection. */ @@ -972,6 +1021,10 @@ synchronized CompletableFuture seal() { return closed; } + synchronized int retainedOperationCount() { + return operations.size() + (running ? 1 : 0); + } + private void drain() { while (true) { QueuedOperation queued; diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java index 06794a60..9e88b916 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java @@ -60,6 +60,7 @@ import com.loohp.interactionvisualizer.entities.DroppedItemDisplay; import com.loohp.interactionvisualizer.entities.VillagerDisplay; import com.loohp.interactionvisualizer.objectholders.EntryKey; +import com.loohp.interactionvisualizer.objectholders.EnchantmentTableAnimation; import com.loohp.interactionvisualizer.updater.Updater; import com.loohp.interactionvisualizer.scheduler.Scheduler; import com.loohp.interactionvisualizer.config.SparrowConfiguration; @@ -478,6 +479,16 @@ static void clearRuntimeState() { } processes.clear(); runnables.clear(); + EnderchestDisplay.shutdown(); + EnchantmentTableAnimation.shutdown(); + } + + /** Number of display registrations or delayed inventory requests retained. */ + public static int retainedStateCount() { + return processes.size() + runnables.size() + + pendingInventoryOpenProcesses.size() + pendingInventoryRefreshes.size() + + EnderchestDisplay.retainedStateCount() + + EnchantmentTableAnimation.retainedStateCount(); } public static void processOpenInventory(Player player) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java index 5928446a..4bfe0f75 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/managers/TileEntityManager.java @@ -280,6 +280,13 @@ private static void clearRuntimeState() { drainingWatcherChanges = false; } + /** Number of block, chunk or viewer-index roots retained by this lifecycle. */ + public synchronized static int retainedStateCount() { + return active.size() + byChunk.size() + lastActiveTypes.size() + + watchedChunksByPlayer.size() + watcherCounts.size() + + dirtyWatcherChunks.size() + unloadingChunks.size(); + } + static boolean reconcileActiveTypeIfCurrent( Map> index, K key, Set indexedValues, T value, TileEntityType currentType, boolean activate, diff --git a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java index 4a4e74e3..70da38f3 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/EnchantmentTableAnimation.java @@ -71,6 +71,14 @@ public class EnchantmentTableAnimation { private static final Map tables = new ConcurrentHashMap<>(); + public static void shutdown() { + tables.clear(); + } + + public static int retainedStateCount() { + return tables.size(); + } + public static EnchantmentTableAnimation getTableAnimation(Block block, Player player) { EnchantmentTableAnimation animation = tables.get(block); if (animation == null) { diff --git a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/ILightManager.java b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/ILightManager.java index 7e4895a6..a6c81484 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/objectholders/ILightManager.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/objectholders/ILightManager.java @@ -55,4 +55,12 @@ default void setUpdatePeriod(long updatePeriod) { default void shutdown() { } + /** + * Number of plugin-owned light bookkeeping entries still reachable from + * this manager. Implementations return zero after a complete shutdown. + */ + default int retainedStateCount() { + return 0; + } + } diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index 3f890114..d6c479d4 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -281,15 +281,15 @@ Entities: #This is in ticks (20 ticks = 1 second) #Setting this too low might impact performance UpdateRate: 20 - #Experimental server-side distance culling for dropped-item labels + #Server-side distance culling for dropped-item labels VisibilityCulling: - #Preserves the legacy label lifecycle unless explicitly enabled after validation - Enabled: false + #Independent rollback switch; existing explicit false values are preserved during migration + Enabled: true ViewDistance: 64 - #Experimental smoothing for label visibility bursts + #Smoothing for dropped-label visibility bursts VisibilityRateLimit: - #Independent from Settings.Performance.VisibilityRateLimit and disabled by default - Enabled: false + #Independent from Settings.Performance.VisibilityRateLimit + Enabled: true BucketSize: 128 RestorePerTick: 32 #Paper does not expose its internal per-item despawn rate. This value @@ -334,24 +334,24 @@ Settings: HideIfViewObstructed: false Performance: VirtualItems: - #A/B switch: keeps invisible tracker anchors still during short item animations. - #The client-side virtual item remains corrected every tick; turn this on only after profiling your server. - StaticAnchorDuringAnimation: false + #Rollback switch: keeps invisible tracker anchors still during short item animations. + #The client-side virtual item remains corrected every tick. + StaticAnchorDuringAnimation: true #A/B switch: eligible stationary virtual items without a visible custom name use no Bukkit tracker entity. - #Requires separate packet-capture and client compatibility validation before enabling in production. - PacketOnlyStatic: false + #Eligible stationary items create no Bukkit tracker entity. + PacketOnlyStatic: true #A/B switch: safe animated items use per-viewer packet entities and create no Bukkit tracker anchor. #Visible custom names and glowing items retain the Paper-owned fallback representation. PacketOnlyAnimated: false VisibilityRateLimit: - #A/B switch: hides remain immediate; newly visible entities are restored through a per-player token bucket. - Enabled: false + #Rollback switch: hides remain immediate; newly visible entities are restored through a per-player token bucket. + Enabled: true BucketSize: 128 RestorePerTick: 32 BlockUpdates: - #A/B switch: coalesces event-driven changes and replaces per-block child tasks with fixed-budget loops. + #Rollback switch: coalesces event-driven changes and replaces per-block child tasks with fixed-budget loops. #Restart the server after changing this option so runnable display tasks are recreated. - EventDriven: false + EventDriven: true #Dirty-update budget per block display type; active rotation and fallback audit have separate bounded work. MaxDirtyPerTick: 64 diff --git a/common/src/test/java/com/loohp/interactionvisualizer/config/SparrowConfigurationTest.java b/common/src/test/java/com/loohp/interactionvisualizer/config/SparrowConfigurationTest.java index 727950c0..073ecb21 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/config/SparrowConfigurationTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/config/SparrowConfigurationTest.java @@ -118,4 +118,69 @@ void defaultConfigKeepsDroppedItemLabelsAboveTheItem() throws Exception { assertEquals(0.8D, configuration.getDouble("Entities.Item.Options.LabelYOffset")); } + + @Test + void newInstallDefaultsEnableThePerformancePaths() throws Exception { + Path file = temporaryDirectory.resolve("new-install.yml"); + Files.writeString(file, "", StandardCharsets.UTF_8); + SparrowConfiguration configuration = new SparrowConfiguration( + file.toFile(), + SparrowConfigurationTest.class.getClassLoader().getResourceAsStream("config.yml"), + false); + + assertTrue(configuration.getBoolean( + "Settings.Performance.VirtualItems.StaticAnchorDuringAnimation")); + assertTrue(configuration.getBoolean( + "Settings.Performance.VirtualItems.PacketOnlyStatic")); + assertFalse(configuration.getBoolean( + "Settings.Performance.VirtualItems.PacketOnlyAnimated")); + assertTrue(configuration.getBoolean( + "Settings.Performance.VisibilityRateLimit.Enabled")); + assertTrue(configuration.getBoolean( + "Settings.Performance.BlockUpdates.EventDriven")); + assertTrue(configuration.getBoolean( + "Entities.Item.Options.VisibilityCulling.Enabled")); + assertTrue(configuration.getBoolean( + "Entities.Item.Options.VisibilityRateLimit.Enabled")); + } + + @Test + void explicitLegacyOptOutsAreNotOverwrittenByNewDefaults() throws Exception { + Path file = temporaryDirectory.resolve("existing-install.yml"); + Files.writeString(file, + "Settings:\n" + + " Performance:\n" + + " VirtualItems:\n" + + " StaticAnchorDuringAnimation: false\n" + + " PacketOnlyStatic: false\n" + + " VisibilityRateLimit:\n" + + " Enabled: false\n" + + " BlockUpdates:\n" + + " EventDriven: false\n" + + "Entities:\n" + + " Item:\n" + + " Options:\n" + + " VisibilityCulling:\n" + + " Enabled: false\n" + + " VisibilityRateLimit:\n" + + " Enabled: false\n", + StandardCharsets.UTF_8); + SparrowConfiguration configuration = new SparrowConfiguration( + file.toFile(), + SparrowConfigurationTest.class.getClassLoader().getResourceAsStream("config.yml"), + true); + + assertFalse(configuration.getBoolean( + "Settings.Performance.VirtualItems.StaticAnchorDuringAnimation")); + assertFalse(configuration.getBoolean( + "Settings.Performance.VirtualItems.PacketOnlyStatic")); + assertFalse(configuration.getBoolean( + "Settings.Performance.VisibilityRateLimit.Enabled")); + assertFalse(configuration.getBoolean( + "Settings.Performance.BlockUpdates.EventDriven")); + assertFalse(configuration.getBoolean( + "Entities.Item.Options.VisibilityCulling.Enabled")); + assertFalse(configuration.getBoolean( + "Entities.Item.Options.VisibilityRateLimit.Enabled")); + } } diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java index dfb79947..8e41cd53 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndexTest.java @@ -15,6 +15,7 @@ import java.util.UUID; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -52,6 +53,7 @@ void findsViewersAcrossChunkBoundariesUsingExactDistance() { assertTrue(viewers.hasViewerWithin(world, 15.9D, 64.0D, 0.0D, 0.3D)); assertFalse(viewers.hasViewerWithin(world, 15.9D, 64.0D, 0.0D, 0.1D)); assertFalse(viewers.hasViewerWithin(UUID.randomUUID(), 15.9D, 64.0D, 0.0D, 1.0D)); + assertEquals(2L, viewers.candidateChecks()); } @Test diff --git a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java index 3b69c000..ba2a9c10 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/entities/DroppedItemVisibilityPolicyTest.java @@ -60,7 +60,7 @@ void clampsDistanceAndRepairsInvalidBucketValues() { } @Test - void bundledConfigurationKeepsBothControlsDisabled() throws IOException { + void bundledConfigurationEnablesControlsForNewInstalls() throws IOException { try (InputStream stream = getClass().getClassLoader().getResourceAsStream("config.yml")) { assertNotNull(stream); String config = new String(stream.readAllBytes(), StandardCharsets.UTF_8) @@ -71,13 +71,13 @@ void bundledConfigurationKeepsBothControlsDisabled() throws IOException { assertTrue(droppedItemOptions.contains( " VisibilityCulling:\n" - + " #Preserves the legacy label lifecycle unless explicitly enabled after validation\n" - + " Enabled: false\n" + + " #Independent rollback switch; existing explicit false values are preserved during migration\n" + + " Enabled: true\n" + " ViewDistance: 64\n")); assertTrue(droppedItemOptions.contains( " VisibilityRateLimit:\n" - + " #Independent from Settings.Performance.VisibilityRateLimit and disabled by default\n" - + " Enabled: false\n")); + + " #Independent from Settings.Performance.VisibilityRateLimit\n" + + " Enabled: true\n")); } } } diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsShutdownSnapshotTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsShutdownSnapshotTest.java new file mode 100644 index 00000000..12336244 --- /dev/null +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsShutdownSnapshotTest.java @@ -0,0 +1,43 @@ +/* + * This file is part of InteractionVisualizer. + * + * Copyright (C) 2026. Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.loohp.interactionvisualizer.managers; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PerformanceMetricsShutdownSnapshotTest { + + @Test + void zeroSnapshotIsCleanAndMachineReadable() { + PerformanceMetrics.ShutdownSnapshot snapshot = new PerformanceMetrics.ShutdownSnapshot( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + + assertTrue(snapshot.clean()); + assertEquals(0, snapshot.totalRetained()); + assertTrue(snapshot.json().contains("\"trackingWorlds\":0")); + assertTrue(snapshot.json().contains("\"totalRetained\":0")); + } + + @Test + void everyRetainedCategoryContributesToTotal() { + PerformanceMetrics.ShutdownSnapshot snapshot = new PerformanceMetrics.ShutdownSnapshot( + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18); + + assertFalse(snapshot.clean()); + assertEquals(171, snapshot.totalRetained()); + assertTrue(snapshot.summary().contains("total=171")); + assertTrue(snapshot.json().contains("\"cullingRegistrations\":16")); + } +} diff --git a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java index a39b1898..f64ab6ed 100644 --- a/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java +++ b/common/src/test/java/com/loohp/interactionvisualizer/managers/PerformanceMetricsSlowestTickTest.java @@ -87,7 +87,7 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0, - 0L, 0L, 7L, 12_345_678L, 0, 0, 0, + 0L, 0L, 0L, 0L, 0L, 7L, 12_345_678L, 0, 0, 0, 0L, 0L, 0, 0L, 0L, 100L, 5L, 200L); String json = snapshot.json(); @@ -104,6 +104,7 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { assertTrue(json.contains("\"droppedLabelVisibilityRestorePerTick\":32")); assertTrue(json.contains("\"viewerFullReconciles\":0")); assertTrue(json.contains("\"craftEngineCullingRetainedRegistrations\":0")); + assertTrue(json.contains("\"droppedSpatialCandidates\":0")); assertTrue(json.contains("\"blockUpdateDirtyQueueMax\":0")); assertTrue(json.contains("\"preferenceSqlStatements\":0")); assertTrue(json.contains("\"legacyTextCacheHits\":95")); From 230f547d36fad8f908c7a84368637ffdac35adcf Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 18:18:11 +0800 Subject: [PATCH 11/12] Fix A-B config generation for enabled defaults --- tools/perf/run-phase2-runtime-once.sh | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index 67e2c352..158e1680 100644 --- a/tools/perf/run-phase2-runtime-once.sh +++ b/tools/perf/run-phase2-runtime-once.sh @@ -185,6 +185,7 @@ unzip -p "$plugin_jar" config.yml > "$run_directory/plugins/InteractionVisualize python3 - "$run_directory/plugins/InteractionVisualizer/config.yml" "$scenario" "$variant" "$ab_factor" <<'PY' from pathlib import Path +import re import sys path = Path(sys.argv[1]) @@ -200,6 +201,16 @@ def replace_once(old: str, new: str) -> None: raise SystemExit(f"Expected one config token {old!r}, found {count}") text = text.replace(old, new, 1) +def replace_boolean_once(prefix: str, value: bool) -> None: + global text + pattern = re.compile(rf"(?m)^{re.escape(prefix)}(?:true|false)$") + replacement = f"{prefix}{str(value).lower()}" + text, count = pattern.subn(replacement, text, count=1) + if count != 1: + raise SystemExit( + f"Expected one boolean config token with prefix {prefix!r}, found {count}" + ) + packet_only = scenario == "visibility-return" or ( scenario in {"static-steady", "static-spawn"} and variant == "B" ) @@ -207,13 +218,9 @@ visibility_limit = scenario.startswith("visibility-") and variant == "B" event_driven = scenario.startswith("block-") and ( ab_factor == "legacy-text-component-cache" or variant == "B" ) -replace_once(" PacketOnlyStatic: false", - f" PacketOnlyStatic: {str(packet_only).lower()}") -replace_once(" Enabled: false\n BucketSize: 128\n RestorePerTick: 32", - f" Enabled: {str(visibility_limit).lower()}\n" - " BucketSize: 128\n RestorePerTick: 32") -replace_once(" EventDriven: false", - f" EventDriven: {str(event_driven).lower()}") +replace_boolean_once(" PacketOnlyStatic: ", packet_only) +replace_boolean_once(" Enabled: ", visibility_limit) +replace_boolean_once(" EventDriven: ", event_driven) replace_once(" Updater: true", " Updater: false") replace_once(" DownloadLanguageFiles: true", " DownloadLanguageFiles: false") path.write_text(text, encoding="utf-8", newline="\n") From c39b196dbdcfa38269ad91c96e4a193377541375 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 18:45:43 +0800 Subject: [PATCH 12/12] Keep formal packet traces within evidence bounds --- tools/perf/run-phase2-runtime-once.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index 158e1680..5d6d3d17 100644 --- a/tools/perf/run-phase2-runtime-once.sh +++ b/tools/perf/run-phase2-runtime-once.sh @@ -38,6 +38,9 @@ measure_seconds="${PHASE2_MEASURE_SECONDS:-180}" capture_enabled="${PHASE2_CAPTURE_ENABLED:-0}" capture_snaplen="${PHASE2_CAPTURE_SNAPLEN:-128}" protocol_trace_enabled="${PHASE2_PROTOCOL_TRACE_ENABLED:-$capture_enabled}" +protocol_trace_max_events="${PHASE2_PROTOCOL_TRACE_MAX_EVENTS:-500000}" +protocol_trace_packet_allowlist="${PHASE2_PROTOCOL_TRACE_PACKET_ALLOWLIST-bundle_delimiter,entity_destroy,spawn_entity}" +protocol_trace_aggregate_packet_allowlist="${PHASE2_PROTOCOL_TRACE_AGGREGATE_PACKET_ALLOWLIST-entity_metadata}" spark_profile_mode="${PHASE2_SPARK_PROFILE_MODE:-none}" ab_factor="${PHASE2_AB_FACTOR:-scenario-config}" @@ -61,7 +64,7 @@ if [[ "$ab_factor" == legacy-text-component-cache && "$scenario" != block-active exit 64 fi for value in "$server_port" "$item_count" "$warmup_seconds" "$settle_seconds" \ - "$measure_seconds" "$capture_snaplen"; do + "$measure_seconds" "$capture_snaplen" "$protocol_trace_max_events"; do [[ "$value" =~ ^[0-9]+$ ]] || { echo "Numeric input is invalid: $value" >&2; exit 64; } done (( server_port >= 1 && server_port <= 65535 )) \ @@ -98,6 +101,12 @@ fi || { echo "PHASE2_CAPTURE_ENABLED must be 0 or 1" >&2; exit 64; } [[ "$protocol_trace_enabled" == 0 || "$protocol_trace_enabled" == 1 ]] \ || { echo "PHASE2_PROTOCOL_TRACE_ENABLED must be 0 or 1" >&2; exit 64; } +(( protocol_trace_max_events >= 1 )) \ + || { echo "PHASE2_PROTOCOL_TRACE_MAX_EVENTS must be positive" >&2; exit 64; } +for allowlist in "$protocol_trace_packet_allowlist" "$protocol_trace_aggregate_packet_allowlist"; do + [[ -z "$allowlist" || "$allowlist" =~ ^[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+)*$ ]] \ + || { echo "Protocol trace allowlist is invalid: $allowlist" >&2; exit 64; } +done case "$spark_profile_mode" in none|cpu|cpu-all|alloc) ;; *) echo "PHASE2_SPARK_PROFILE_MODE must be none, cpu, cpu-all, or alloc" >&2; exit 64 ;; @@ -642,7 +651,12 @@ fi protocol_trace_environment=("PHASE2_PROTOCOL_TRACE_FILE=") if [[ "$protocol_trace_enabled" == 1 ]]; then - protocol_trace_environment=("PHASE2_PROTOCOL_TRACE_FILE=$protocol_trace_path") + protocol_trace_environment=( + "PHASE2_PROTOCOL_TRACE_FILE=$protocol_trace_path" + "PHASE2_PROTOCOL_TRACE_MAX_EVENTS=$protocol_trace_max_events" + "PHASE2_PROTOCOL_TRACE_PACKET_ALLOWLIST=$protocol_trace_packet_allowlist" + "PHASE2_PROTOCOL_TRACE_AGGREGATE_PACKET_ALLOWLIST=$protocol_trace_aggregate_packet_allowlist" + ) fi env \ "PHASE2_MC_PROTOCOL_MODULE=$client_root/node-minecraft-protocol" \