Add simple plugin system

This commit is contained in:
Melledy
2025-11-17 09:21:21 -08:00
parent b97f23aa3c
commit 48b1f2f0ce
3 changed files with 343 additions and 15 deletions

View File

@@ -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();
}

View File

@@ -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
) {}
}

View File

@@ -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<String, Plugin> 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<Exception>();
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<Plugin.Data>();
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);
}
});
}
}