diff --git a/.github/workflows/build-test-jar.yml b/.github/workflows/build-test-jar.yml index f466867..e3629f6 100644 --- a/.github/workflows/build-test-jar.yml +++ b/.github/workflows/build-test-jar.yml @@ -21,15 +21,18 @@ jobs: - name: Check out repository uses: actions/checkout@v7 - - name: Set up Java 21 + - name: Set up Java 25 uses: actions/setup-java@v6 with: distribution: temurin - java-version: "21" + java-version: "25" cache: maven - name: Build and verify - run: bash ./mvnw -B -ntp verify + run: bash ./mvnw -B -ntp clean verify + + - name: Check optional hologram provider selection + run: java scripts/HologramProviderCheck.java - name: Select distributable JAR shell: bash diff --git a/docs/holograms.md b/docs/holograms.md new file mode 100644 index 0000000..5ed38af --- /dev/null +++ b/docs/holograms.md @@ -0,0 +1,47 @@ +# Generator holograms + +FancyHolograms is optional. DecentHolograms works on its own. To select it, +set this in `config.yml` and restart the server: + +```yaml +holograms: + type: DecentHolograms +``` + +NextGens uses the configured provider when its plugin is installed and enabled. +Otherwise it uses the first available registered provider. Built-in providers +are discovered in this order: DecentHolograms, FancyHolograms, HolographicDisplays. +Selection stops at the first usable provider instead of loading the rest. +Missing or broken providers are skipped. If none is available, NextGens logs a +warning and continues running with generator holograms disabled. Provider +registration never disables NextGens or other server plugins. + +Supported configuration values are `DecentHolograms`, `FancyHologram` (also +`FancyHolograms`), and `HolographicDisplays`. The FancyHolograms integration +uses `de.oliver:FancyHolograms:2.9.1`, the version shown in the +[official API getting-started guide](https://fancyinnovations.com/docs/minecraft-plugins/fancyholograms/api/getting-started) +when checked on October 18, 2026. No dependency upgrade is needed. Only the +selected provider needs to be installed. Diagnose a missing API class using +the actual loaded plugin and API, rather than inferring compatibility from a +plugin version string alone. + +The `NoClassDefFoundError: de/oliver/fancyholograms/api/data/HologramData` +report in issue #16 concerns NextGens 1.3.3. Current source isolates optional +provider classes and skips providers that fail to load. The issue #16 follow-up +also avoids loading unselected providers while the configured one is usable. +Check your server's NextGens version and the selected provider before installing +additional hologram plugins. Current source requires Java 25; upgrading from an +older binary also requires checking server and Java compatibility. + +Run the dependency-free selection regression check with a JDK: + +```sh +java scripts/HologramProviderCheck.java +``` + +This check compiles the real manager and provider interface against small API +substitutes to exercise selection, deterministic fallback, missing dependency +behavior, and nonfatal registration. A clean verification build, the selection +regression checks, and GitHub CI are the acceptance checks for this change; +in-game testing is not required. These checks do not claim live rendering was +tested. diff --git a/scripts/HologramProviderCheck.java b/scripts/HologramProviderCheck.java new file mode 100644 index 0000000..dc193e3 --- /dev/null +++ b/scripts/HologramProviderCheck.java @@ -0,0 +1,206 @@ +import javax.tools.ToolProvider; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class HologramProviderCheck { + + public static void main(String[] args) throws Exception { + Path output = Files.createTempDirectory(Files.createDirectories(Path.of("target")), "hologram-check-"); + List sources = new ArrayList<>(); + Map substitutes = Map.of( + "org.bukkit.Bukkit", """ + package org.bukkit; + public class Bukkit { + public static final org.bukkit.plugin.PluginManager manager = new org.bukkit.plugin.PluginManager(); + public static org.bukkit.plugin.PluginManager getPluginManager() { return manager; } + } + """, + "org.bukkit.plugin.PluginManager", """ + package org.bukkit.plugin; + public class PluginManager { + public final java.util.Set enabled = new java.util.HashSet<>(); + public boolean isPluginEnabled(String name) { return enabled.contains(name); } + public void disablePlugins() { throw new AssertionError("Must not disable other plugins"); } + } + """, + "org.bukkit.Location", "package org.bukkit; public class Location {}", + "org.bukkit.configuration.file.FileConfiguration", """ + package org.bukkit.configuration.file; + public class FileConfiguration { + public String type; + public String getString(String key) { return type; } + } + """, + "com.muhammaddaffa.mdlib.utils.Config", """ + package com.muhammaddaffa.mdlib.utils; + public class Config { + private final org.bukkit.configuration.file.FileConfiguration config = new org.bukkit.configuration.file.FileConfiguration(); + public org.bukkit.configuration.file.FileConfiguration getConfig() { return config; } + } + """, + "com.muhammaddaffa.mdlib.utils.Logger", """ + package com.muhammaddaffa.mdlib.utils; + public class Logger { + public static final java.util.List warnings = new java.util.ArrayList<>(); + public static void info(String message) {} + public static void warning(String message) { warnings.add(message); } + } + """, + "com.muhammaddaffa.nextgens.NextGens", """ + package com.muhammaddaffa.nextgens; + public class NextGens { + public static final com.muhammaddaffa.mdlib.utils.Config DEFAULT_CONFIG = new com.muhammaddaffa.mdlib.utils.Config(); + } + """, + "Check", """ + import com.muhammaddaffa.nextgens.NextGens; + import com.muhammaddaffa.nextgens.hologram.HologramManager; + import com.muhammaddaffa.nextgens.hologram.HologramProvider; + import com.muhammaddaffa.mdlib.utils.Logger; + import org.bukkit.Bukkit; + public class Check { + public static final java.util.List attempted = new java.util.ArrayList<>(); + public static String broken; + public static void main(String[] args) { + NextGens.DEFAULT_CONFIG.getConfig().type = args[0].equals("null") ? null : args[0]; + Bukkit.manager.enabled.addAll(java.util.List.of(args[1].split(","))); + broken = args[2]; + if (args[0].equals("Custom")) { + HologramManager.register(custom("Custom", "Custom", false)); + } + if (args.length > 6) { + switch (args[6]) { + case "invalid" -> { + HologramManager.register(null); + HologramManager.register(custom(null, "Custom", false)); + HologramManager.register(custom("", "Custom", false)); + HologramManager.register(custom("Custom", "Other", false)); + } + case "order" -> { + HologramManager.register(custom("ZProvider", "ZProvider", false)); + HologramManager.register(custom("AProvider", "AProvider", false)); + } + case "reverse" -> { + HologramManager.register(custom("AProvider", "AProvider", false)); + HologramManager.register(custom("ZProvider", "ZProvider", false)); + } + case "broken" -> HologramManager.register(custom("Broken", "Broken", true)); + } + } + HologramManager manager = new HologramManager(); + manager.load(); + String actual = manager.isEnabled() ? manager.getProvider().id() : "none"; + if (!actual.equals(args[3])) { + throw new AssertionError("Expected " + args[3] + ", got " + actual); + } + if (actual.equals("Custom") && !manager.getProvider().pluginName().equals("Custom")) { + throw new AssertionError("Duplicate registration replaced the original provider"); + } + String attempts = String.join(",", attempted); + if (!attempts.equals(args[4])) { + throw new AssertionError("Unexpected provider loading: " + attempts); + } + boolean skipped = Logger.warnings.stream().anyMatch(line -> line.contains("skipping it")); + if (skipped != Boolean.parseBoolean(args[5])) { + throw new AssertionError("Unexpected compatibility warnings: " + Logger.warnings); + } + if (actual.equals("none") && Logger.warnings.stream().noneMatch(line -> line.contains("No supported hologram"))) { + throw new AssertionError("No-provider warning missing"); + } + } + private static HologramProvider custom(String id, String plugin, boolean broken) { + return new HologramProvider() { + public String id() { return id; } + public String pluginName() { return plugin; } + public boolean isAvailable() { + if (broken) { + throw new NoClassDefFoundError("missing/custom/API"); + } + return HologramProvider.super.isAvailable(); + } + public void spawn(String name, org.bukkit.Location location, java.util.List lines) {} + public void destroy(String name, org.bukkit.Location location) {} + }; + } + } + """ + ); + for (var entry : substitutes.entrySet()) { + sources.add(write(output, entry.getKey(), entry.getValue())); + } + for (String[] provider : List.of( + new String[]{"DecentHologramsProvider", "DecentHolograms", "DecentHolograms"}, + new String[]{"FancyHologramProvider", "FancyHologram", "FancyHolograms"}, + new String[]{"HolographicDisplaysProvider", "HolographicDisplays", "HolographicDisplays"})) { + String source = """ + package com.muhammaddaffa.nextgens.hologram.imp; + import com.muhammaddaffa.nextgens.hologram.HologramProvider; + public class %s implements HologramProvider { + public %s() { + try { + Class check = Class.forName("Check"); + ((java.util.List) check.getField("attempted").get(null)).add("%s"); + if ("%s".equals(check.getField("broken").get(null))) { + throw new NoClassDefFoundError("missing/provider/API"); + } + } catch (ReflectiveOperationException ex) { throw new AssertionError(ex); } + } + public String id() { return "%s"; } + public String pluginName() { return "%s"; } + public void spawn(String name, org.bukkit.Location location, java.util.List lines) {} + public void destroy(String name, org.bukkit.Location location) {} + } + """.formatted(provider[0], provider[0], provider[2], provider[2], provider[1], provider[2]); + sources.add(write(output, "com.muhammaddaffa.nextgens.hologram.imp." + provider[0], source)); + } + sources.add("src/main/java/com/muhammaddaffa/nextgens/hologram/HologramManager.java"); + sources.add("src/main/java/com/muhammaddaffa/nextgens/hologram/HologramProvider.java"); + List compilerArgs = new ArrayList<>(List.of("-d", output.toString())); + compilerArgs.addAll(sources); + int result = ToolProvider.getSystemJavaCompiler().run(null, null, null, compilerArgs.toArray(String[]::new)); + if (result != 0) { + throw new AssertionError("Regression check compilation failed: " + result); + } + String[][] cases = { + {"DecentHolograms", "DecentHolograms,FancyHolograms", "FancyHolograms", "DecentHolograms", "DecentHolograms", "false"}, + {"DecentHolograms", "DecentHolograms", "", "DecentHolograms", "DecentHolograms", "false"}, + {"FancyHologram", "FancyHolograms,DecentHolograms", "", "FancyHologram", "FancyHolograms", "false"}, + {"fancyholograms", "FancyHolograms", "", "FancyHologram", "FancyHolograms", "false"}, + {"FancyHolograms", "FancyHolograms,DecentHolograms", "FancyHolograms", "DecentHolograms", "FancyHolograms,DecentHolograms", "true"}, + {"FancyHologram", "DecentHolograms", "", "DecentHolograms", "DecentHolograms", "false"}, + {"null", "DecentHolograms", "", "DecentHolograms", "DecentHolograms", "false"}, + {"", "HolographicDisplays", "", "HolographicDisplays", "HolographicDisplays", "false"}, + {"DecentHolograms", "", "", "none", "", "false"}, + {"FancyHologram", "FancyHolograms", "FancyHolograms", "none", "FancyHolograms", "true"}, + {"Custom", "Custom,DecentHolograms,FancyHolograms", "FancyHolograms", "Custom", "", "false"}, + {"Unknown", "DecentHolograms", "", "DecentHolograms", "DecentHolograms", "false"}, + {"", "DecentHolograms,FancyHolograms,HolographicDisplays", "FancyHolograms", "DecentHolograms", "DecentHolograms", "false"}, + {"Unknown", "FancyHolograms,HolographicDisplays", "", "FancyHologram", "FancyHolograms", "false"}, + {"Unknown", "DecentHolograms,FancyHolograms,HolographicDisplays", "DecentHolograms", "FancyHologram", "DecentHolograms,FancyHolograms", "true"}, + {"null", "", "", "none", "", "false"}, + {"HolographicDisplays", "DecentHolograms,FancyHolograms", "", "DecentHolograms", "DecentHolograms", "false"}, + {"Custom", "Custom,Other,DecentHolograms", "", "Custom", "", "false", "invalid"}, + {"Unknown", "ZProvider,AProvider,DecentHolograms", "", "ZProvider", "", "false", "order"}, + {"Unknown", "ZProvider,AProvider,DecentHolograms", "", "AProvider", "", "false", "reverse"}, + {"Broken", "Broken,DecentHolograms", "", "DecentHolograms", "DecentHolograms", "true", "broken"} + }; + for (String[] scenario : cases) { + try (URLClassLoader loader = new URLClassLoader(new URL[]{output.toUri().toURL()}, ClassLoader.getPlatformClassLoader())) { + loader.loadClass("Check").getMethod("main", String[].class).invoke(null, (Object) scenario); + } + } + System.out.println("Passed " + cases.length + " hologram provider selection checks."); + } + + private static String write(Path output, String name, String source) throws Exception { + Path file = output.resolve(name.replace('.', '/') + ".java"); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + return file.toString(); + } +} diff --git a/src/main/java/com/muhammaddaffa/nextgens/hologram/HologramManager.java b/src/main/java/com/muhammaddaffa/nextgens/hologram/HologramManager.java index ac89d51..8dfd8ba 100644 --- a/src/main/java/com/muhammaddaffa/nextgens/hologram/HologramManager.java +++ b/src/main/java/com/muhammaddaffa/nextgens/hologram/HologramManager.java @@ -8,17 +8,19 @@ import org.bukkit.Bukkit; import org.bukkit.configuration.file.FileConfiguration; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; public class HologramManager { private HologramProvider provider; - private static final Map HOLOGRAM_PROVIDER_LIST = new ConcurrentHashMap<>(); + private static final Map HOLOGRAM_PROVIDER_LIST = + Collections.synchronizedMap(new LinkedHashMap<>()); /** * The providers shipped with the plugin. They are only instantiated once we know their backing @@ -28,31 +30,29 @@ public class HologramManager { * right here in the static initializer, which is exactly what we are avoiding. */ private static final List BUILTIN_PROVIDERS = List.of( - new BuiltinProvider("DecentHolograms", () -> new DecentHologramsProvider()), - new BuiltinProvider("FancyHolograms", () -> new FancyHologramProvider()), - new BuiltinProvider("HolographicDisplays", () -> new HolographicDisplaysProvider()) + new BuiltinProvider("DecentHolograms", "DecentHolograms", () -> new DecentHologramsProvider()), + new BuiltinProvider("FancyHologram", "FancyHolograms", () -> new FancyHologramProvider()), + new BuiltinProvider("HolographicDisplays", "HolographicDisplays", () -> new HolographicDisplaysProvider()) ); - private record BuiltinProvider(String pluginName, Supplier factory) { + private record BuiltinProvider(String id, String pluginName, Supplier factory) { } public static void register(HologramProvider hologramProvider) { if (hologramProvider == null) { - Logger.info("Invalid Holograms Provider! Shutting down the plugin..."); - Bukkit.getPluginManager().disablePlugins(); + Logger.warning("Ignoring a null hologram provider."); return; } String id = hologramProvider.id(); if (id == null || id.isBlank()) { - throw new IllegalArgumentException( - hologramProvider.getClass().getName() + " returned a null or blank id()" - ); + Logger.warning("Ignoring hologram provider " + hologramProvider.getClass().getName() + " with a null or blank id()."); + return; } HologramProvider previous = HOLOGRAM_PROVIDER_LIST.putIfAbsent(id.toLowerCase(Locale.ROOT), hologramProvider); if (previous != null) { - throw new IllegalStateException("A hologram with id " + id + " is already registered by " + previous.getClass().getName()); + Logger.warning("Hologram provider " + id + " is already registered; keeping the existing provider."); } } @@ -62,44 +62,56 @@ public static boolean unregister(String id) { return HOLOGRAM_PROVIDER_LIST.remove(id.toLowerCase(Locale.ROOT)) != null; } - /** - * Registers every built-in provider whose backing plugin is actually installed and enabled. - * A provider that fails to load (e.g. an incompatible plugin version) is skipped instead of - * taking the whole plugin down with it. - */ - private static void registerBuiltinProviders() { - for (BuiltinProvider builtin : BUILTIN_PROVIDERS) { - if (!Bukkit.getPluginManager().isPluginEnabled(builtin.pluginName()) || isRegistered(builtin.pluginName())) { - continue; - } - try { + private static HologramProvider loadBuiltinProvider(BuiltinProvider builtin) { + if (!Bukkit.getPluginManager().isPluginEnabled(builtin.pluginName())) { + return null; + } + try { + HologramProvider candidate = findByConfigId(builtin.pluginName()); + if (candidate == null) { register(builtin.factory().get()); - } catch (Throwable ex) { - Logger.warning("Failed to hook into " + builtin.pluginName() + " (" + - ex.getClass().getSimpleName() + ": " + ex.getMessage() + "), skipping it."); + candidate = findByConfigId(builtin.pluginName()); } + return candidate != null && isAvailable(candidate) ? candidate : null; + } catch (LinkageError | RuntimeException ex) { + Logger.warning("Failed to hook into " + builtin.pluginName() + " (" + + ex.getClass().getSimpleName() + ": " + ex.getMessage() + "), skipping it."); + return null; } } - private static boolean isRegistered(String pluginName) { - for (HologramProvider registered : HOLOGRAM_PROVIDER_LIST.values()) { - if (registered.pluginName().equalsIgnoreCase(pluginName)) { - return true; - } + private static boolean isAvailable(HologramProvider candidate) { + try { + return candidate.isAvailable(); + } catch (LinkageError | RuntimeException ex) { + Logger.warning("Failed to check hologram provider " + candidate.id() + " (" + + ex.getClass().getSimpleName() + ": " + ex.getMessage() + "), skipping it."); + return false; } - return false; } - public void load() { - registerBuiltinProviders(); + private static List registeredProviders() { + synchronized (HOLOGRAM_PROVIDER_LIST) { + return List.copyOf(HOLOGRAM_PROVIDER_LIST.values()); + } + } + public void load() { FileConfiguration config = NextGens.DEFAULT_CONFIG.getConfig(); String configId = config.getString("holograms.type"); // 1. Try to honour the configured provider first. if (configId != null && !configId.isBlank()) { HologramProvider configured = findByConfigId(configId); - if (configured != null && configured.isAvailable()) { + if (configured == null) { + for (BuiltinProvider builtin : BUILTIN_PROVIDERS) { + if (builtin.id().equalsIgnoreCase(configId) || builtin.pluginName().equalsIgnoreCase(configId)) { + configured = loadBuiltinProvider(builtin); + break; + } + } + } + if (configured != null && isAvailable(configured)) { this.provider = configured; Logger.info("Successfully hooked into hologram provider: " + configured.id()); return; @@ -110,8 +122,20 @@ public void load() { } // 2. Fallback: use the first registered provider whose plugin is installed. - for (HologramProvider candidate : HOLOGRAM_PROVIDER_LIST.values()) { - if (candidate.isAvailable()) { + for (HologramProvider candidate : registeredProviders()) { + if (isAvailable(candidate)) { + this.provider = candidate; + Logger.info("Falling back to hologram provider: " + candidate.id()); + return; + } + } + + for (BuiltinProvider builtin : BUILTIN_PROVIDERS) { + if (builtin.id().equalsIgnoreCase(configId) || builtin.pluginName().equalsIgnoreCase(configId)) { + continue; + } + HologramProvider candidate = loadBuiltinProvider(builtin); + if (candidate != null) { this.provider = candidate; Logger.info("Falling back to hologram provider: " + candidate.id()); return; @@ -127,14 +151,14 @@ public void load() { * Finds a provider whose id() or backing plugin name matches the config value. * This lets the config accept either spelling (e.g. "FancyHologram" or "FancyHolograms"). */ - private HologramProvider findByConfigId(String configId) { + private static HologramProvider findByConfigId(String configId) { // fast path: direct match against the registered id HologramProvider byId = HOLOGRAM_PROVIDER_LIST.get(configId.toLowerCase(Locale.ROOT)); if (byId != null) { return byId; } // fallback: match against the backing plugin name - for (HologramProvider candidate : HOLOGRAM_PROVIDER_LIST.values()) { + for (HologramProvider candidate : registeredProviders()) { if (candidate.pluginName().equalsIgnoreCase(configId)) { return candidate; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 849f888..cc4fc29 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -86,8 +86,9 @@ holograms: # - DecentHolograms # - FancyHologram # - HolographicDisplays - # If the chosen plugin isn't installed (or this is left blank), NextGens will - # automatically fall back to any supported hologram plugin it can find. + # If the chosen plugin isn't loaded (or this is left blank), use the first + # available provider: DecentHolograms, FancyHolograms, HolographicDisplays. + # If none is available, warn and keep NextGens running without holograms. type: DecentHolograms # The options below ONLY apply when FancyHolograms is the active provider. fancyholograms: