diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/NexoItemHandle.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/NexoItemHandle.java index dcc931f6e7..fc8ce74e80 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/NexoItemHandle.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/NexoItemHandle.java @@ -1,29 +1,120 @@ - package com.bencodez.advancedcore.api.item; +import java.lang.ref.WeakReference; import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.WeakHashMap; import org.bukkit.inventory.ItemStack; +import com.bencodez.advancedcore.AdvancedCorePlugin; + +/** + * Reflection-backed Nexo integration kept isolated from the rest of ItemBuilder. + */ public class NexoItemHandle { + private static final Map DEFAULT_CACHES = new HashMap<>(); + private static final Map>> CACHES = new WeakHashMap<>(); + + private final ReflectionCache cache; + public NexoItemHandle() { + this(NexoItemHandle.class.getClassLoader(), "com.nexomc.nexo.api.NexoItems", "com.nexomc.nexo.items.ItemBuilder"); + } + + protected NexoItemHandle(ClassLoader classLoader, String apiClassName, String builderClassName) { + ClassLoader effectiveClassLoader = classLoader == null ? NexoItemHandle.class.getClassLoader() : classLoader; + cache = sharedCache(effectiveClassLoader, apiClassName, builderClassName); } public ItemStack getItem(String item) { + if (item == null || item.isEmpty()) { + return null; + } try { - Class nexoItemsClass = Class.forName("com.nexomc.nexo.api.NexoItems"); - Method itemFromIdMethod = nexoItemsClass.getMethod("itemFromId", String.class); - Object itemBuilder = itemFromIdMethod.invoke(null, item); - if (itemBuilder != null) { - Class itemBuilderClass = Class.forName("com.nexomc.nexo.items.ItemBuilder"); - Method buildMethod = itemBuilderClass.getMethod("build"); - Object builtItem = buildMethod.invoke(itemBuilder); + ensureMethods(); + Object itemBuilder = cache.itemFromIdMethod.invoke(null, item); + if (itemBuilder == null) { + return null; + } + Object builtItem = cache.buildMethod.invoke(itemBuilder); + if (builtItem instanceof ItemStack) { return (ItemStack) builtItem; } - } catch (Exception e) { - e.printStackTrace(); + logFailureOnce("Nexo build() returned an unexpected type: " + + (builtItem == null ? "null" : builtItem.getClass().getName()), null); + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + logFailureOnce("Unable to use the installed Nexo item API", e); } return null; } + private void ensureMethods() throws ReflectiveOperationException { + if (cache.itemFromIdMethod != null && cache.buildMethod != null) { + return; + } + synchronized (cache) { + if (cache.itemFromIdMethod == null) { + Class nexoItemsClass = Class.forName(cache.apiClassName, true, cache.classLoader); + cache.itemFromIdMethod = nexoItemsClass.getMethod("itemFromId", String.class); + } + if (cache.buildMethod == null) { + Class itemBuilderClass = Class.forName(cache.builderClassName, true, cache.classLoader); + cache.buildMethod = itemBuilderClass.getMethod("build"); + } + } + } + + private void logFailureOnce(String message, Throwable throwable) { + synchronized (cache) { + if (cache.compatibilityFailureLogged) { + return; + } + cache.compatibilityFailureLogged = true; + } + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (plugin != null) { + plugin.getLogger().warning(message); + if (throwable != null) { + plugin.debug(throwable); + } + } + } + + private static synchronized ReflectionCache sharedCache(ClassLoader classLoader, String apiClassName, + String builderClassName) { + String key = apiClassName + '\0' + builderClassName; + if (classLoader == NexoItemHandle.class.getClassLoader()) { + return DEFAULT_CACHES.computeIfAbsent(key, + ignored -> new ReflectionCache(classLoader, apiClassName, builderClassName)); + } + + Map> classLoaderCaches = CACHES.computeIfAbsent(classLoader, + ignored -> new HashMap<>()); + WeakReference reference = classLoaderCaches.get(key); + ReflectionCache existing = reference == null ? null : reference.get(); + if (existing != null) { + return existing; + } + + ReflectionCache created = new ReflectionCache(classLoader, apiClassName, builderClassName); + classLoaderCaches.put(key, new WeakReference<>(created)); + return created; + } + + private static final class ReflectionCache { + private final ClassLoader classLoader; + private final String apiClassName; + private final String builderClassName; + private volatile Method itemFromIdMethod; + private volatile Method buildMethod; + private boolean compatibilityFailureLogged; + + private ReflectionCache(ClassLoader classLoader, String apiClassName, String builderClassName) { + this.classLoader = classLoader; + this.apiClassName = apiClassName; + this.builderClassName = builderClassName; + } + } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/effects/BossBar.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/effects/BossBar.java index 861b92f676..ea43811bd5 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/effects/BossBar.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/effects/BossBar.java @@ -4,8 +4,6 @@ import java.util.concurrent.TimeUnit; import org.bukkit.Bukkit; -import org.bukkit.boss.BarColor; -import org.bukkit.boss.BarStyle; import org.bukkit.entity.Player; import com.bencodez.advancedcore.AdvancedCorePlugin; @@ -13,85 +11,54 @@ import lombok.Getter; -// TODO: Auto-generated Javadoc /** - * The Class BossBar. + * Bukkit boss bar wrapper used by rewards and downstream plugins. */ public class BossBar { - /** - * Gets the boss bar. - * - * @return the boss bar - */ @Getter private org.bukkit.boss.BossBar bossBar; - /** - * Instantiates a new boss bar. - * - * @param msg the msg - * @param barColor the bar color - * @param barStyle the bar style - * @param progress the progress - */ public BossBar(String msg, String barColor, String barStyle, double progress) { - bossBar = Bukkit.createBossBar(MessageAPI.colorize(msg), BarColor.valueOf(barColor), - BarStyle.valueOf(barStyle)); - bossBar.setProgress(progress); + bossBar = Bukkit.createBossBar(MessageAPI.colorize(msg), EffectCompatibility.parseBarColor(barColor), + EffectCompatibility.parseBarStyle(barStyle)); + bossBar.setProgress(EffectCompatibility.clampProgress(progress)); } - /** - * Adds a player to the boss bar. - * - * @param player the player to add - */ public void addPlayer(Player player) { - bossBar.addPlayer(player); + if (player != null) { + bossBar.addPlayer(player); + } } - /** - * Adds a player to the boss bar with delay. - * - * @param player the player to add - * @param delay the delay in seconds before removing - */ public void addPlayer(final Player player, int delay) { try { if (player == null) { return; } bossBar.addPlayer(player); - if (delay > 0) { - AdvancedCorePlugin.getInstance().getBukkitScheduler().runTaskLater(AdvancedCorePlugin.getInstance(), - new Runnable() { - - @Override - public void run() { - if (bossBar != null && player != null) { - bossBar.removePlayer(player); - } - } - }, delay * 50 + 60, TimeUnit.MILLISECONDS); + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (plugin != null) { + plugin.getBukkitScheduler().runTaskLater(plugin, () -> { + if (bossBar != null) { + bossBar.removePlayer(player); + } + }, delay * 50L + 60L, TimeUnit.MILLISECONDS); + } } } catch (Exception e) { - AdvancedCorePlugin.getInstance().debug(e); + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (plugin != null) { + plugin.debug(e); + } } } - /** - * Gets the players viewing the boss bar. - * - * @return the list of players - */ public List getPlayers() { return bossBar.getPlayers(); } - /** - * Hides the boss bar. - */ public void hide() { if (bossBar != null) { bossBar.setVisible(false); @@ -100,110 +67,60 @@ public void hide() { } private void hideInDelay(int delay) { - AdvancedCorePlugin.getInstance().getBukkitScheduler().runTaskLater(AdvancedCorePlugin.getInstance(), - new Runnable() { - - @Override - public void run() { - hide(); - } - }, delay * 50, TimeUnit.MILLISECONDS); + AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + if (plugin == null) { + return; + } + plugin.getBukkitScheduler().runTaskLater(plugin, this::hide, delay * 50L, TimeUnit.MILLISECONDS); } - /** - * Removes a player from the boss bar. - * - * @param player the player to remove - */ public void removePlayer(Player player) { - bossBar.removePlayer(player); + if (player != null) { + bossBar.removePlayer(player); + } } - /** - * Sends the boss bar to all players. - */ public void send() { bossBar.setVisible(true); } - /** - * Sends the boss bar with delay. - * - * @param delay the delay in seconds - */ public void send(int delay) { bossBar.setVisible(true); - hideInDelay(delay); } - /** - * Send. - * - * @param player the player - * @param delay the delay - */ public void send(Player player, int delay) { + if (player == null) { + return; + } bossBar.addPlayer(player); bossBar.setVisible(true); hideInDelay(delay); } - /** - * Sets the color of the boss bar. - * - * @param barColor the bar color - */ public void setColor(String barColor) { if (barColor != null) { - bossBar.setColor(BarColor.valueOf(barColor)); + bossBar.setColor(EffectCompatibility.parseBarColor(barColor)); } } - /** - * Sets the progress of the boss bar. - * - * @param progress the progress (0.0 to 1.0) - */ public void setProgress(double progress) { - if (progress > 1) { - progress = 1; - } - if (progress < 0) { - progress = 0; - } - bossBar.setProgress(progress); + bossBar.setProgress(EffectCompatibility.clampProgress(progress)); } - /** - * Sets the style of the boss bar. - * - * @param barStyle the bar style - */ public void setStyle(String barStyle) { if (barStyle != null) { - bossBar.setStyle(BarStyle.valueOf(barStyle)); + bossBar.setStyle(EffectCompatibility.parseBarStyle(barStyle)); } } - /** - * Sets the title of the boss bar. - * - * @param title the title - */ public void setTitle(String title) { if (title != null) { bossBar.setTitle(MessageAPI.colorize(title)); } - } - /** - * Sets the visibility of the boss bar. - * - * @param visible true to show, false to hide - */ public void setVisible(boolean visible) { bossBar.setVisible(visible); } -} \ No newline at end of file +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/effects/EffectCompatibility.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/effects/EffectCompatibility.java new file mode 100644 index 0000000000..61303ba71e --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/effects/EffectCompatibility.java @@ -0,0 +1,32 @@ +package com.bencodez.advancedcore.api.misc.effects; + +import java.util.Locale; + +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; + +/** + * Compatibility parsing shared by player-facing effects. + */ +public final class EffectCompatibility { + + private EffectCompatibility() { + } + + public static BarColor parseBarColor(String value) { + return BarColor.valueOf(normalizeEnum(value, "BLUE")); + } + + public static BarStyle parseBarStyle(String value) { + return BarStyle.valueOf(normalizeEnum(value, "SOLID")); + } + + public static double clampProgress(double progress) { + return Math.max(0D, Math.min(1D, progress)); + } + + private static String normalizeEnum(String value, String defaultValue) { + String effective = value == null || value.trim().isEmpty() ? defaultValue : value.trim(); + return effective.toUpperCase(Locale.ROOT); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/ItemCompatibilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/ItemCompatibilityTest.java new file mode 100644 index 0000000000..b10a436aa1 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/ItemCompatibilityTest.java @@ -0,0 +1,123 @@ +package com.bencodez.advancedcore.tests.item; + +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; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.Material; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.item.NexoItemHandle; +import com.bencodez.advancedcore.api.misc.effects.EffectCompatibility; + +public class ItemCompatibilityTest { + + @Test + public void nexoReflectionBuildsExpectedItemType() { + NexoItemHandle handle = new TestNexoItemHandle(ItemCompatibilityTest.class.getClassLoader()); + + ItemStack item = handle.getItem("test"); + + assertEquals(Material.STONE, item.getType()); + assertNull(handle.getItem("missing")); + } + + @Test + public void nexoReflectionCacheIsSharedAcrossHandles() { + CountingClassLoader loader = new CountingClassLoader(ItemCompatibilityTest.class.getClassLoader()); + NexoItemHandle first = new TestNexoItemHandle(loader); + NexoItemHandle second = new TestNexoItemHandle(loader); + + assertEquals(Material.STONE, first.getItem("test").getType()); + int afterFirst = loader.getNexoLoads(); + assertEquals(Material.STONE, second.getItem("test").getType()); + + assertEquals(2, afterFirst, "the API and builder classes should each be resolved once"); + assertEquals(afterFirst, loader.getNexoLoads(), "a second handle must reuse the shared reflection cache"); + } + + @Test + @SuppressWarnings("unchecked") + public void defaultNexoCacheRetainsReflectionStateBetweenTransientHandles() throws Exception { + NexoItemHandle handle = new TestNexoItemHandle(NexoItemHandle.class.getClassLoader()); + assertEquals(Material.STONE, handle.getItem("test").getType()); + + Field defaultCachesField = NexoItemHandle.class.getDeclaredField("DEFAULT_CACHES"); + defaultCachesField.setAccessible(true); + Map defaultCaches = (Map) defaultCachesField.get(null); + + assertFalse(defaultCaches.isEmpty()); + assertTrue(defaultCaches.values().stream().noneMatch(WeakReference.class::isInstance), + "the production/default loader must retain resolved reflection state between temporary handles"); + } + + @Test + @SuppressWarnings("unchecked") + public void sharedNexoCacheDoesNotStronglyRetainDisposableClassLoaders() throws Exception { + CountingClassLoader loader = new CountingClassLoader(ItemCompatibilityTest.class.getClassLoader()); + new TestNexoItemHandle(loader); + + Field cachesField = NexoItemHandle.class.getDeclaredField("CACHES"); + cachesField.setAccessible(true); + Map> caches = (Map>) cachesField.get(null); + Map loaderCache = caches.get(loader); + + assertTrue(loaderCache.values().stream().allMatch(WeakReference.class::isInstance), + "disposable class-loader keys must not have values that strongly retain reflection state"); + } + + @Test + public void bossBarCompatibilityAcceptsCaseAndClampsProgress() { + assertEquals(BarColor.BLUE, EffectCompatibility.parseBarColor("blue")); + assertEquals(BarStyle.SOLID, EffectCompatibility.parseBarStyle(" solid ")); + assertEquals(1D, EffectCompatibility.clampProgress(2D)); + assertEquals(0D, EffectCompatibility.clampProgress(-1D)); + } + + private static final class TestNexoItemHandle extends NexoItemHandle { + private TestNexoItemHandle(ClassLoader classLoader) { + super(classLoader, FakeNexoItems.class.getName(), FakeBuilder.class.getName()); + } + } + + private static final class CountingClassLoader extends ClassLoader { + private final AtomicInteger nexoLoads = new AtomicInteger(); + + private CountingClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals(FakeNexoItems.class.getName()) || name.equals(FakeBuilder.class.getName())) { + nexoLoads.incrementAndGet(); + } + return super.loadClass(name, resolve); + } + + private int getNexoLoads() { + return nexoLoads.get(); + } + } + + public static final class FakeNexoItems { + public static FakeBuilder itemFromId(String id) { + return "test".equals(id) ? new FakeBuilder() : null; + } + } + + public static final class FakeBuilder { + public ItemStack build() { + return new ItemStack(Material.STONE); + } + } +}