diff --git a/src/main/java/emu/nebula/Nebula.java b/src/main/java/emu/nebula/Nebula.java index c5a73a3..a31dab0 100644 --- a/src/main/java/emu/nebula/Nebula.java +++ b/src/main/java/emu/nebula/Nebula.java @@ -15,6 +15,7 @@ import emu.nebula.data.ResourceLoader; import emu.nebula.database.DatabaseManager; import emu.nebula.game.GameContext; import emu.nebula.net.PacketHelper; +import emu.nebula.plugin.PluginManager; import emu.nebula.server.HttpServer; import emu.nebula.util.Handbook; import emu.nebula.util.JsonUtils; @@ -38,6 +39,7 @@ public class Nebula { @Getter private static GameContext gameContext; @Getter private static CommandManager commandManager; + @Getter private static PluginManager pluginManager; public static void main(String[] args) { // Start Server @@ -50,24 +52,33 @@ public class Nebula { // Load config + commands Nebula.loadConfig(); + // Load plugin manager + Nebula.pluginManager = new PluginManager(); + + try { + Nebula.getPluginManager().loadPlugins(); + } catch (Exception exception) { + Nebula.getLogger().error("Unable to load plugins.", exception); + } + // Parse arguments for (String arg : args) { switch (arg) { - case "-login": - serverType = ServerType.LOGIN; - break; - case "-game": - serverType = ServerType.GAME; - break; - case "-nohandbook": - case "-skiphandbook": - generateHandbook = false; - break; - case "-database": - // Database only - DatabaseManager.startInternalMongoServer(Nebula.getConfig().getInternalMongoServer()); - Nebula.getLogger().info("Running local Mongo server at " + DatabaseManager.getServer().getConnectionString()); - return; + case "-login": + serverType = ServerType.LOGIN; + break; + case "-game": + serverType = ServerType.GAME; + break; + case "-nohandbook": + case "-skiphandbook": + generateHandbook = false; + break; + case "-database": + // Database only + DatabaseManager.startInternalMongoServer(Nebula.getConfig().getInternalMongoServer()); + Nebula.getLogger().info("Running local Mongo server at " + DatabaseManager.getServer().getConnectionString()); + return; } } @@ -103,6 +114,9 @@ public class Nebula { Nebula.getLogger().error("Unable to start the HTTP server.", exception); } + // Enable plugins + Nebula.getPluginManager().enablePlugins(); + // Start console Nebula.startConsole(); } diff --git a/src/main/java/emu/nebula/plugin/Plugin.java b/src/main/java/emu/nebula/plugin/Plugin.java new file mode 100644 index 0000000..2c619a8 --- /dev/null +++ b/src/main/java/emu/nebula/plugin/Plugin.java @@ -0,0 +1,99 @@ +package emu.nebula.plugin; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; + +import java.io.File; +import java.io.InputStream; +import java.net.URLClassLoader; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class Plugin { + private final Identifier identifier; + private final URLClassLoader classLoader; + private final File dataFolder; + private final Logger logger; + + /* + * Collection of plugin events which are called by the server. + */ + + public void onLoad() {} + public void onEnable() {} + public void onDisable() {} + + /** + * Fetches a resource from the plugin's JAR file. + * + * @param fileName The name of the file to fetch. + * @return An {@link InputStream} of the file. + */ + public final InputStream getResource(String fileName) { + return this.getClassLoader().getResourceAsStream(fileName); + } + + /** Get the plugin's name. */ + public final String getName() { + return this.getIdentifier().name; + } + + /** Get the plugin's description. */ + public final String getDescription() { + return this.getIdentifier().description; + } + + /** Get the plugin's version. */ + public final String getVersion() { + return this.getIdentifier().version; + } + + /** Deserialized plugin config data. */ + public record Config( + String name, + String description, + String version, + String mainClass, + Integer api, + String[] authors, + String[] loadAfter + ) { + /** + * Attempts to validate this config instance. + * + * @return True if the config is valid, false otherwise. + */ + public boolean validate() { + return name != null && description != null && mainClass != null && api != null; + } + } + + /** Loaded plugin data. */ + public record Identifier( + String name, + String description, + String version, + String[] authors + ) { + /** + * Converts a {@link Config} into a {@link Identifier}. + * + * @param config The config to convert. + * @return An instance of {@link Identifier}. + */ + public static Identifier from(Config config) { + if (!config.validate()) + throw new IllegalArgumentException("Invalid plugin config supplied."); + return new Identifier(config.name(), config.description(), config.version(), config.authors()); + } + } + + /** Unloaded plugin data. */ + public record Data( + Plugin instance, + URLClassLoader classLoader, + String[] dependencies + ) {} +} diff --git a/src/main/java/emu/nebula/plugin/PluginManager.java b/src/main/java/emu/nebula/plugin/PluginManager.java new file mode 100644 index 0000000..6cfa972 --- /dev/null +++ b/src/main/java/emu/nebula/plugin/PluginManager.java @@ -0,0 +1,215 @@ +package emu.nebula.plugin; + +import emu.nebula.util.JsonUtils; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.reflect.InvocationTargetException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.*; + +/** + * Manages the server's plugins and the event system. + * @author https://github.com/KingRainbow44 + */ +@Getter +public final class PluginManager { + /* + * This should only be changed when a breaking change is made to the plugin API. + * A 'breaking change' is something which changes the existing logic of the API. + */ + public static final int API_VERSION = 1; + + /** + * The directory where plugins are stored. + */ + public static final File PLUGINS_DIR = new File("plugins"); + + /** Map of loaded plugins; Name -> Instance */ + private final Map plugins = new HashMap<>(); + private final Logger logger = LoggerFactory.getLogger("Plugin Manager"); + + private boolean pluginsLoaded = false; + + /** + * Loads all plugins from the plugins directory. + * This can only be called once. + */ + @SuppressWarnings("resource") + public void loadPlugins() throws IOException { + if (this.pluginsLoaded) { + throw new IllegalStateException("Plugins have already been loaded."); + } + + this.pluginsLoaded = true; + + if (!PLUGINS_DIR.exists() && !PLUGINS_DIR.mkdirs()) { + throw new IOException("Failed to create plugins directory."); + } + + // Read files from the directory. + var files = PLUGINS_DIR.listFiles(); + if (files == null) return; + + var loadingExceptions = new ArrayList(); + + var pluginFiles = Arrays.stream(files) + .filter(file -> file.getName().endsWith(".jar")) + .toList(); + var pluginURLs = pluginFiles.stream() + .map(file -> { + try { + return file.toURI().toURL(); + } catch (IOException e) { + loadingExceptions.add(e); + return null; + } + }) + .filter(Objects::nonNull) + .toArray(URL[]::new); + + loadingExceptions.forEach(e -> this.getLogger().warn("Failed to load plugin: " + e.getMessage())); + + // Begin loading plugins. + var classLoader = new URLClassLoader(pluginURLs); + var dependencies = new ArrayList(); + + pluginFiles.forEach(pluginFile -> { + try { + var pluginUrl = pluginFile.toURI().toURL(); + + // Read the plugin's configuration file. + var jarReader = new URLClassLoader(new URL[] { pluginUrl }); + var pluginConfigFile = jarReader.getResourceAsStream("plugin.json"); + if (pluginConfigFile == null) { + this.getLogger().warn("Plugin {} did not specify a config file.", pluginFile.getName()); + return; + } + + // Deserialize the plugin's configuration file. + var configReader = new InputStreamReader(pluginConfigFile); + var pluginConfig = JsonUtils.loadToClass(configReader, Plugin.Config.class); + if (pluginConfig == null) { + this.getLogger().warn("Plugin {} has an invalid config file.", pluginFile.getName()); + return; + } + jarReader.close(); + + // Validate the plugin's configuration file. + if (pluginConfig.api() == null) { + this.getLogger().warn("Plugin {} did not specify an API version.", pluginFile.getName()); + return; + } else if (pluginConfig.api() != API_VERSION) { + this.getLogger().warn("Plugin {} requires API version {}, but this server is running version {}.", pluginFile.getName(), pluginConfig.api(), API_VERSION); + return; + } else if (!pluginConfig.validate()) { + this.getLogger().warn("Plugin {} has an invalid config file.", pluginFile.getName()); + return; + } + + // Instantiate the plugin. + var pluginClass = classLoader.loadClass(pluginConfig.mainClass()); + var pluginInstance = (Plugin) pluginClass.getDeclaredConstructor( + Plugin.Identifier.class, + URLClassLoader.class, + File.class, + Logger.class + ).newInstance( + Plugin.Identifier.from(pluginConfig), + classLoader, + new File(PLUGINS_DIR, pluginConfig.name()), + LoggerFactory.getLogger(pluginConfig.name()) + ); + + // Check for plugin dependencies. + var loadAfter = pluginConfig.loadAfter(); + if (loadAfter != null && loadAfter.length > 0) { + dependencies.add(new Plugin.Data( + pluginInstance, + classLoader, + loadAfter + )); + } else try { + pluginInstance.onLoad(); + this.plugins.put(pluginInstance.getName(), pluginInstance); + } catch (Throwable exception) { + this.getLogger().warn("Failed to load plugin {}.", pluginFile.getName()); + } + } catch (IOException | ClassNotFoundException e) { + this.getLogger().warn("Failed to load plugin {}.", pluginFile.getName()); + } catch (InvocationTargetException | InstantiationException | IllegalAccessException | NoSuchMethodException e) { + throw new RuntimeException(e); + } + }); + + // Load all plugins with dependencies. + var depth = 0; + final var maxDepth = 30; + while (!dependencies.isEmpty()) { + // Check if the depth is too high. + if (depth >= maxDepth) { + this.getLogger().warn("Failed to load plugins due to circular dependencies."); + break; + } + + try { + // Get the next plugin to load. + var pluginData = dependencies.get(0); + + // Check if the plugin's dependencies are loaded. + if (!this.plugins.keySet().containsAll(List.of(pluginData.dependencies()))) { + depth++; // Increase depth counter. + continue; // Continue to next plugin. + } + + // Remove the plugin from the list of dependencies. + dependencies.remove(pluginData); + + // Load the plugin. + pluginData.instance().onLoad(); + this.plugins.put(pluginData.instance().getName(), pluginData.instance()); + } catch (Throwable exception) { + this.getLogger().warn("Failed to load plugin {}.", exception.getMessage()); + depth++; + } + } + } + + /** + * Enables all plugins. + */ + public void enablePlugins() { + this.getPlugins().forEach((name, plugin) -> { + try { + this.getLogger().info("Enabling plugin {}.", name); + plugin.onEnable(); + return; + } catch (NoSuchMethodError | NoSuchFieldError ignored) { + this.getLogger().warn("Plugin {} is not compatible with this server version.", name); + } catch (Throwable exception) { + this.getLogger().warn("Failed to enable plugin {}.", name); + } + + plugin.onDisable(); + }); + } + + /** + * Disables all plugins. + */ + public void disablePlugins() { + this.getPlugins().forEach((name, plugin) -> { + try { + this.getLogger().info("Disabling plugin {}.", name); + plugin.onDisable(); + } catch (Throwable exception) { + this.getLogger().warn("Failed to disable plugin {}.", name); + } + }); + } +}