diff --git a/README.md b/README.md index 92eb233e..7f4b6d87 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,33 @@ 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 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. 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 1aca1792..f940f3f7 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 { @@ -224,15 +222,32 @@ 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 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 + cullingLoaderSource -> match.value == cullingSentinelClass + allowedCraftEngineLightSource, allowedCraftEngineCullingSource -> 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 b7b1844c..00000000 Binary files a/common/lib/LightAPI-fork-3.5.2.jar and /dev/null differ diff --git a/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java b/common/src/main/java/com/loohp/interactionvisualizer/InteractionVisualizer.java index 63c14ae9..39fae401 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; @@ -75,10 +77,15 @@ 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; + /** @deprecated LightAPI is no longer used; retained for binary compatibility. */ + @Deprecated(forRemoval = false) public static Boolean lightapi = false; + public static Boolean craftEngineLight = false; public static Boolean openinv = false; public static Set exemptBlocks = new HashSet<>(); @@ -109,20 +116,25 @@ 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; + private boolean cullingLifecycleInitialized; + private boolean cullingProviderWarningLogged; public static ILightManager lightManager; + public static ViewerCullingManager viewerCullingManager = ViewerCullingManager.DISABLED; public static PreferenceManager preferenceManager; public static AsyncExecutorManager asyncExecutorManager; @@ -160,18 +172,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; @@ -180,16 +183,32 @@ 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(); - - if (CustomContentManager.initialize(this).contains("craftengine")) { + advisePerformanceMigrationOnce(existingConfiguration); + + 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 (configureViewerCulling()) { + craftEngineHooked = true; + } + cullingLifecycleInitialized = true; + if (craftEngineHooked) { hookMessage("CraftEngine"); } @@ -253,18 +272,41 @@ public void onEnable() { @Override public void onDisable() { shutdownPerformanceScenes(); + int retainedLightState = 0; + ILightManager disablingLightManager = lightManager; + if (disablingLightManager != null) { + disablingLightManager.shutdown(); + retainedLightState = disablingLightManager.retainedStateCount(); + lightManager = ILightManager.DUMMY_INSTANCE; + } + 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)); } @@ -300,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(); @@ -326,8 +405,14 @@ 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) { + lightManager.setUpdatePeriod(lightUpdatePeriod); + } updaterEnabled = getConfiguration().getBoolean("Options.Updater"); @@ -341,6 +426,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( @@ -362,4 +449,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/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/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/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/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/database/Database.java b/common/src/main/java/com/loohp/interactionvisualizer/database/Database.java index 87c0734d..178cd6e2 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,451 @@ 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; + })); + } + + /** + * 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; + } - Class.forName("com.mysql.cj.jdbc.Driver"); - setConnection(DriverManager.getConnection("jdbc:mysql://" + host + ":" + port + "/" + database, username, password)); + 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); + } - 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(); - } + 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(); + public static void close() { + synchronized (DATABASE_LOCK) { + configured = false; + closeConnection(); + } + } - 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 int retainedConnectionCount() { + synchronized (DATABASE_LOCK) { + return connection == null ? 0 : 1; + } + } - } 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/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 0c811c79..df53fde8 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()); @@ -319,8 +321,23 @@ private void tickAllInternal() { } update(tracked, itemIndex, viewerIndex, remainingWorldItems); } + if (viewerIndex != null) { + PerformanceMetrics.droppedViewerDistanceChecks(viewerIndex.candidateChecks()); + } 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 +362,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 +506,8 @@ private void switchVisibilityMode(boolean controlled) { state.pending.clear(); } visibilityStates.clear(); - visibilityQueuesPending = false; + viewersByLabel.clear(); + pendingVisibilityViewers.clear(); } private void showToEligibleViewers(TextDisplay label) { @@ -518,7 +537,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 +551,18 @@ 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 +632,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 +657,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 +734,7 @@ private String matchingName(ItemStack stack) { private void remove(UUID itemId) { trackedItems.remove(itemId); + contentCache.remove(itemId); removeLabel(itemId); } @@ -686,17 +744,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 +819,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/DroppedItemSpatialIndex.java b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java index 34807bf2..e6dbad85 100644 --- a/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java +++ b/common/src/main/java/com/loohp/interactionvisualizer/entities/DroppedItemSpatialIndex.java @@ -132,6 +132,7 @@ boolean hasViewerWithin(UUID worldId, double x, double y, double z, double[] zCoordinates = viewers.zCoordinates; int viewerCount = viewers.size; for (int index = 0; index < viewerCount; index++) { + viewers.candidateChecks++; double deltaX = xCoordinates[index] - x; double deltaY = yCoordinates[index] - y; double deltaZ = zCoordinates[index] - z; @@ -195,6 +196,17 @@ boolean hasActiveBounds(UUID worldId) { return viewers != null && viewers.boundsActive; } + long candidateChecks() { + if (viewersByWorld == null) { + return singleWorldViewers == null ? 0L : singleWorldViewers.candidateChecks; + } + long checks = 0L; + for (WorldViewerBucket viewers : viewersByWorld.values()) { + checks += viewers.candidateChecks; + } + return checks; + } + private static final class WorldViewerBucket { private static final int INITIAL_CAPACITY = 8; @@ -225,6 +237,7 @@ private static final class WorldViewerBucket { private int expensiveQueries; private int gridQueriesUntilProbe; private int size; + private long candidateChecks; private WorldViewerBucket(int initialCapacity) { if (initialCapacity > 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/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/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/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/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/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/CraftEngineLightManager.java b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java new file mode 100644 index 00000000..d00643ce --- /dev/null +++ b/common/src/main/java/com/loohp/interactionvisualizer/integration/craftengine/CraftEngineLightManager.java @@ -0,0 +1,523 @@ +/* + * 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(); + } + } + + @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; + 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/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/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/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/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 59b545e9..a168d676 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; @@ -66,6 +68,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; @@ -96,18 +99,24 @@ 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<>(); + 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<>(); 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<>(); 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 +150,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 +164,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) { @@ -163,7 +175,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; @@ -204,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 @@ -227,21 +241,25 @@ public static void shutdown() { forceScheduled.clear(); logicalByActualUuid.clear(); actualUuidByLogical.clear(); + logicalById.clear(); logicalsByChunk.clear(); chunkByLogical.clear(); + viewerChunks.clear(); + shownByViewer.clear(); itemAnimations.clear(); virtualItemIds.clear(); renderedItemStacks.clear(); renderedItemLocations.clear(); + packetItemPositions.clear(); packetOnlyItems.clear(); fixedItemDisplays.clear(); 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; @@ -257,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) { @@ -388,20 +420,30 @@ public static void reset(Player player) { public static void removeAll(Player player) { runSync(() -> { - clearVisibilityShowQueue(player.getUniqueId()); + UUID viewer = player.getUniqueId(); + InteractionVisualizer.viewerCullingManager.clearViewer(viewer); + 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 +457,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 +472,7 @@ public static void sendPlayerPackets(Player player) { } else if (requiresActualEntity(logical)) { schedule(logical, true); } - } + }); }); } @@ -444,12 +490,20 @@ 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) { sync(logical, revision); } + if (!Objects.equals(previousChunk, chunkByLogical.get(logical)) + || !previousRepresentation.equals(representationKey(logical))) { + reconcileViewers(logical); + } } } finally { scheduled.remove(logical); @@ -471,7 +525,6 @@ private static void sync(VisualizerEntity logical, int revision) { syncItemFrame(frame); } renderedRevision.put(logical, revision); - reconcileViewers(logical); } private static void syncDisplay(DisplayEntity logical) { @@ -630,14 +683,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); @@ -648,22 +705,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); @@ -709,9 +787,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); @@ -732,7 +809,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) { @@ -898,7 +975,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; } @@ -963,16 +1040,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() { @@ -1018,43 +1107,63 @@ 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); + updateCullingBounds(logical); 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); @@ -1064,7 +1173,7 @@ private static void tickItemAnimation(Item logical, ItemAnimationState animation reconcileViewers(logical); } } - synchronizeVirtualItemMotion(logical, destination); + synchronizeVirtualItemMotion(logical, world, destinationX, destinationY, destinationZ); } } @@ -1161,7 +1270,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; @@ -1170,7 +1280,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<>()); @@ -1186,7 +1296,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(); @@ -1215,14 +1325,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()); @@ -1463,15 +1593,56 @@ 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); 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; } } @@ -1479,16 +1650,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) { @@ -1504,6 +1679,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) { @@ -1523,6 +1702,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); @@ -1536,6 +1716,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); } @@ -1558,6 +1745,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())); } @@ -1566,6 +1756,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) @@ -1595,14 +1838,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 +1867,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 +1886,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 +1901,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 +1921,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 +1935,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 +1973,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 +1992,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 +2061,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 +2074,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 +2092,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,63 +2273,85 @@ 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); + PerformanceMetrics.viewerReconcile(candidates.size()); + ViewerCullingManager culling = InteractionVisualizer.viewerCullingManager; 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)) { + culling.untrack(viewer, logical.getUniqueId()); + 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); - } + Set retainedCullViewers = usesCraftEngineCulling(logical) + ? new HashSet<>() : Set.of(); + for (UUID viewer : candidates) { + Player player = Bukkit.getPlayer(viewer); + 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) { @@ -2072,6 +2392,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) { @@ -2081,6 +2403,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); } @@ -2144,43 +2467,65 @@ 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 public void onLeave(PlayerQuitEvent event) { UUID uuid = event.getPlayer().getUniqueId(); + InteractionVisualizer.viewerCullingManager.clearViewer(uuid); 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) { + UUID viewer = player.getUniqueId(); + InteractionVisualizer.viewerCullingManager.clearViewer(viewer); + viewerChunks.removeViewer(viewer); + 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 +2545,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; @@ -2207,6 +2553,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) { @@ -2252,6 +2600,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); @@ -2291,8 +2640,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 +2718,12 @@ void clear() { pending.clear(); } + void clear(Consumer removed) { + while (!pending.isEmpty()) { + removed.accept(pending.removeFirst()); + } + } + @FunctionalInterface interface DrainAction { @@ -2427,6 +2782,80 @@ 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(); + } + + int retainedStateCount() { + return chunksByViewer.size() + viewersByChunk.size(); + } + } + private record DynamicViewerPosition(double x, double y, double z) { private static DynamicViewerPosition at(Location location) { @@ -2442,19 +2871,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); } } @@ -2487,6 +2968,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/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/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/managers/PerformanceMetrics.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/PerformanceMetrics.java index 52f3df41..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; @@ -42,6 +48,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,10 +76,26 @@ 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 droppedViewerDistanceChecks; + private long droppedSpatialCandidates; + private long droppedFullScanCandidates; private long blockUpdateChecks; private long blockUpdateNanos; + 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() { } @@ -101,6 +124,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,10 +151,26 @@ 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.droppedViewerDistanceChecks = 0; + INSTANCE.droppedSpatialCandidates = 0; + INSTANCE.droppedFullScanCandidates = 0; INSTANCE.blockUpdateChecks = 0; INSTANCE.blockUpdateNanos = 0; + 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; @@ -249,6 +290,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; @@ -261,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; @@ -269,6 +351,88 @@ 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); + } + } + + 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++; + } + } + } + + /** + * 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) { @@ -299,7 +463,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,7 +479,15 @@ private Snapshot createSnapshot(LegacyTextComponentCache.CacheMetrics textCache) virtualPickupPackets, bukkitEntitySpawns, bukkitEntityRemoves, bukkitEntityTeleports, bukkitShowCalls, bukkitHideCalls, displaySyncs, itemSyncs, packetOnlyItemSyncs, virtualViewerChecks, visibilityShowsQueued, visibilityShowsDrained, - itemAnimationNanos, droppedItemNanos, blockUpdateChecks, blockUpdateNanos, + viewerFullReconciles, viewerCandidates, craftEngineCullingCandidates, + craftEngineCullingShowDecisions, craftEngineCullingHideDecisions, + retainedCullingRegistrations, + itemAnimationNanos, droppedItemNanos, droppedViewerDistanceChecks, + droppedSpatialCandidates, droppedFullScanCandidates, + blockUpdateChecks, blockUpdateNanos, + blockUpdateCoordinatorLanesMax, blockUpdateDirtyQueueMax, blockUpdateActiveQueueMax, + preferenceIoOperations, preferenceIoFailures, preferenceIoQueueDepthMax, + preferenceSqlStatements, preferenceDatabaseReconnects, textCache.requests(), textCache.misses(), textCache.sameRawFastPaths()); } @@ -341,10 +515,78 @@ 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, boolean packetOnlyStatic, + boolean hideIfViewObstructed, boolean visibilityRateLimit, int visibilityBucketSize, int visibilityRestorePerTick, @@ -382,10 +624,27 @@ 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 droppedViewerDistanceChecks, + long droppedSpatialCandidates, + long droppedFullScanCandidates, long blockUpdateChecks, long blockUpdateNanos, + int blockUpdateCoordinatorLanesMax, + int blockUpdateDirtyQueueMax, + int blockUpdateActiveQueueMax, + long preferenceIoOperations, + long preferenceIoFailures, + int preferenceIoQueueDepthMax, + long preferenceSqlStatements, + long preferenceDatabaseReconnects, long legacyTextCacheRequests, long legacyTextCacheMisses, long legacyTextSameRawFastPaths) { @@ -418,8 +677,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 +688,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 +712,29 @@ 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," + + "\"droppedViewerDistanceChecks\":%d," + + "\"droppedSpatialCandidates\":%d," + + "\"droppedFullScanCandidates\":%d," + "\"blockUpdateChecks\":%d,\"blockUpdateMs\":%.6f," + + "\"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}", - label, staticAnchorDuringAnimation, packetOnlyStatic, visibilityRateLimit, + label, staticAnchorDuringAnimation, packetOnlyStatic, hideIfViewObstructed, + visibilityRateLimit, visibilityBucketSize, visibilityRestorePerTick, droppedLabelVisibility.cullingEnabled(), droppedLabelVisibility.viewDistance(), droppedLabelVisibility.rateLimitEnabled(), droppedLabelVisibility.bucketSize(), @@ -470,8 +748,16 @@ 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, + droppedViewerDistanceChecks, droppedSpatialCandidates, + droppedFullScanCandidates, 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/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 192e449b..aea576f8 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; @@ -55,18 +56,24 @@ 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.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 +84,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 +133,31 @@ 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)); + synchronized (viewerGroups) { + viewerGroups.clear(); + } + synchronized (entries) { + entries.clear(); + } + entryIndexes = Map.of(); + registeredEntries = List.of(); RuntimeException playerIoFailure = null; try { playerIo.closeAndWait(); @@ -113,24 +165,65 @@ public synchronized void close() { playerIoFailure = exception; } finally { playerIoExecutor.shutdown(); + awaitExecutorTermination(playerIoExecutor); + Database.close(); } - saveBitmaskIndex(); if (playerIoFailure != null) { plugin.getLogger().log(Level.SEVERE, "One or more queued player preference operations failed before shutdown", playerIoFailure); } } + /** 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(); } 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 +238,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 +271,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 +286,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 +305,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); + defaults = new BitSet(); + if (InteractionVisualizer.defaultDisabledAll) { + defaults.set(0, entryCount(), true); } - return info; - } else { - if (Database.playerExists(uuid)) { - return Database.getPlayerInfo(uuid); - } - 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 +372,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 +387,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 +421,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 +440,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 +457,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 +474,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 +503,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 +534,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 +544,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 +563,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 +577,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 +587,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 +606,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 +617,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 { @@ -588,16 +943,19 @@ synchronized boolean close(AtomicBoolean valid) { sessions.clear(); return true; } + + synchronized int size() { + return sessions.size(); + } } - /** - * 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 +968,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 +1021,29 @@ synchronized CompletableFuture seal() { return closed; } - private void drain(UUID uuid, QueueState state) { + synchronized int retainedOperationCount() { + return operations.size() + (running ? 1 : 0); + } + + 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 +1052,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 +1107,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/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java b/common/src/main/java/com/loohp/interactionvisualizer/managers/TaskManager.java index d1802f1f..9e88b916 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; @@ -59,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; @@ -122,6 +124,8 @@ public class TaskManager { @SuppressWarnings("deprecation") public static void setup() { + BlockUpdateCoordinator.shutdown(); + InteractionSessionCoordinator.shutdown(); pendingInventoryOpenProcesses.clear(); pendingInventoryRefreshes.clear(); anvil = false; @@ -457,9 +461,12 @@ public static void run() { public static void shutdown() { try { if (plugin != null) { + Scheduler.shutdown(plugin); Bukkit.getScheduler().cancelTasks(plugin); } } finally { + BlockUpdateCoordinator.shutdown(); + InteractionSessionCoordinator.shutdown(); clearRuntimeState(); } } @@ -472,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 d9d5b424..a6c81484 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,26 @@ 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() { + } + + /** + * 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/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); } } diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index 6af5491e..d6c479d4 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 @@ -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 @@ -328,27 +328,30 @@ 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: - #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/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/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/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/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/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) { + } +} 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/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/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/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)); 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")); + } +} 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) { + } + } +} 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 aae3795a..f64ab6ed 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,7 +86,9 @@ void snapshotJsonPublishesSlowestTickAttributionInMilliseconds() { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, - 0L, 0L, 7L, 12_345_678L, 100L, 5L, 200L); + 0L, 0L, 0L, 0L, 0L, 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(); @@ -100,6 +102,11 @@ 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("\"droppedSpatialCandidates\":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()); + }; + }); + } } diff --git a/tools/perf/run-phase2-runtime-once.sh b/tools/perf/run-phase2-runtime-once.sh index 67e2c352..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 ;; @@ -185,6 +194,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 +210,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 +227,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") @@ -635,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" \