Commit a505b041 authored by Magix's avatar Magix Committed by GitHub
Browse files

Merge pull request #135 from Grasscutters/plugin-system

Implement a plugin system
parents b6fedcf2 dce13cf6
package emu.grasscutter; package emu.grasscutter;
import java.util.ArrayList;
public final class Config { public final class Config {
public String DatabaseUrl = "mongodb://localhost:27017"; public String DatabaseUrl = "mongodb://localhost:27017";
...@@ -12,6 +10,7 @@ public final class Config { ...@@ -12,6 +10,7 @@ public final class Config {
public String PACKETS_FOLDER = "./packets/"; public String PACKETS_FOLDER = "./packets/";
public String DUMPS_FOLDER = "./dumps/"; public String DUMPS_FOLDER = "./dumps/";
public String KEY_FOLDER = "./keys/"; public String KEY_FOLDER = "./keys/";
public String PLUGINS_FOLDER = "./plugins/";
public String RunMode = "HYBRID"; // HYBRID, DISPATCH_ONLY, GAME_ONLY public String RunMode = "HYBRID"; // HYBRID, DISPATCH_ONLY, GAME_ONLY
public GameServerOptions GameServer = new GameServerOptions(); public GameServerOptions GameServer = new GameServerOptions();
......
...@@ -8,6 +8,7 @@ import java.io.InputStreamReader; ...@@ -8,6 +8,7 @@ import java.io.InputStreamReader;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import emu.grasscutter.command.CommandMap; import emu.grasscutter.command.CommandMap;
import emu.grasscutter.plugin.PluginManager;
import emu.grasscutter.utils.Utils; import emu.grasscutter.utils.Utils;
import org.reflections.Reflections; import org.reflections.Reflections;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
...@@ -33,8 +34,9 @@ public final class Grasscutter { ...@@ -33,8 +34,9 @@ public final class Grasscutter {
public static RunMode MODE = RunMode.BOTH; public static RunMode MODE = RunMode.BOTH;
private static DispatchServer dispatchServer; private static DispatchServer dispatchServer;
private static GameServer gameServer; private static GameServer gameServer;
private static PluginManager pluginManager;
public static final Reflections reflector = new Reflections("emu.grasscutter"); public static final Reflections reflector = new Reflections();
static { static {
// Declare logback configuration. // Declare logback configuration.
...@@ -52,15 +54,11 @@ public final class Grasscutter { ...@@ -52,15 +54,11 @@ public final class Grasscutter {
for (String arg : args) { for (String arg : args) {
switch (arg.toLowerCase()) { switch (arg.toLowerCase()) {
case "-auth": case "-auth" -> MODE = RunMode.AUTH;
MODE = RunMode.AUTH; case "-game" -> MODE = RunMode.GAME;
break; case "-handbook" -> {
case "-game": Tools.createGmHandbook(); return;
MODE = RunMode.GAME; }
break;
case "-handbook":
Tools.createGmHandbook();
return;
} }
} }
...@@ -71,19 +69,21 @@ public final class Grasscutter { ...@@ -71,19 +69,21 @@ public final class Grasscutter {
ResourceLoader.loadAll(); ResourceLoader.loadAll();
// Database // Database
DatabaseManager.initialize(); DatabaseManager.initialize();
// Create server instances.
dispatchServer = new DispatchServer();
gameServer = new GameServer(new InetSocketAddress(getConfig().getGameServerOptions().Ip, getConfig().getGameServerOptions().Port));
// Create plugin manager instance.
pluginManager = new PluginManager();
// Start servers. // Start servers.
if(getConfig().RunMode.equalsIgnoreCase("HYBRID")) { if(getConfig().RunMode.equalsIgnoreCase("HYBRID")) {
dispatchServer = new DispatchServer();
dispatchServer.start(); dispatchServer.start();
gameServer = new GameServer(new InetSocketAddress(getConfig().getGameServerOptions().Ip, getConfig().getGameServerOptions().Port));
gameServer.start(); gameServer.start();
} else if(getConfig().RunMode.equalsIgnoreCase("DISPATCH_ONLY")) { } else if (getConfig().RunMode.equalsIgnoreCase("DISPATCH_ONLY")) {
dispatchServer = new DispatchServer();
dispatchServer.start(); dispatchServer.start();
} else if(getConfig().RunMode.equalsIgnoreCase("GAME_ONLY")) { } else if (getConfig().RunMode.equalsIgnoreCase("GAME_ONLY")) {
gameServer = new GameServer(new InetSocketAddress(getConfig().getGameServerOptions().Ip, getConfig().getGameServerOptions().Port));
gameServer.start(); gameServer.start();
} else { } else {
getLogger().error("Invalid server run mode. " + getConfig().RunMode); getLogger().error("Invalid server run mode. " + getConfig().RunMode);
...@@ -91,12 +91,23 @@ public final class Grasscutter { ...@@ -91,12 +91,23 @@ public final class Grasscutter {
getLogger().error("Shutting down..."); getLogger().error("Shutting down...");
System.exit(1); System.exit(1);
} }
// Enable all plugins.
pluginManager.enablePlugins();
// Open console. // Open console.
startConsole(); startConsole();
// Hook into shutdown event.
Runtime.getRuntime().addShutdownHook(new Thread(Grasscutter::onShutdown));
} }
/**
* Server shutdown event.
*/
private static void onShutdown() {
// Disable all plugins.
pluginManager.disablePlugins();
}
public static void loadConfig() { public static void loadConfig() {
try (FileReader file = new FileReader(configFile)) { try (FileReader file = new FileReader(configFile)) {
...@@ -112,7 +123,7 @@ public final class Grasscutter { ...@@ -112,7 +123,7 @@ public final class Grasscutter {
try (FileWriter file = new FileWriter(configFile)) { try (FileWriter file = new FileWriter(configFile)) {
file.write(gson.toJson(config)); file.write(gson.toJson(config));
} catch (Exception e) { } catch (Exception e) {
Grasscutter.getLogger().error("Config save error"); Grasscutter.getLogger().error("Unable to save config file.");
} }
} }
...@@ -123,13 +134,13 @@ public final class Grasscutter { ...@@ -123,13 +134,13 @@ public final class Grasscutter {
while ((input = br.readLine()) != null) { while ((input = br.readLine()) != null) {
try { try {
if(getConfig().RunMode.equalsIgnoreCase("DISPATCH_ONLY")) { if(getConfig().RunMode.equalsIgnoreCase("DISPATCH_ONLY")) {
getLogger().error("Commands are not supported in dispatch only mode"); getLogger().error("Commands are not supported in dispatch only mode.");
return; return;
} }
CommandMap.getInstance().invoke(null, input); CommandMap.getInstance().invoke(null, input);
} catch (Exception e) { } catch (Exception e) {
Grasscutter.getLogger().error("Command error: "); Grasscutter.getLogger().error("Command error:", e);
e.printStackTrace();
} }
} }
} catch (Exception e) { } catch (Exception e) {
...@@ -162,4 +173,8 @@ public final class Grasscutter { ...@@ -162,4 +173,8 @@ public final class Grasscutter {
public static GameServer getGameServer() { public static GameServer getGameServer() {
return gameServer; return gameServer;
} }
public static PluginManager getPluginManager() {
return pluginManager;
}
} }
package emu.grasscutter.plugin;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.server.game.GameServer;
/**
* The base class for all plugins to extend.
*/
public abstract class Plugin {
private PluginIdentifier identifier;
/**
* This method is reflected into.
*
* Set plugin variables.
* @param identifier The plugin's identifier.
*/
private void initializePlugin(PluginIdentifier identifier) {
if(this.identifier == null)
this.identifier = identifier;
else Grasscutter.getLogger().warn(this.identifier.name + " had a reinitialization attempt.");
}
/**
* The plugin's identifier instance.
* @return An instance of {@link PluginIdentifier}.
*/
public final PluginIdentifier getIdentifier(){
return this.identifier;
}
/**
* Get the plugin's name.
*/
public final String getName() {
return this.identifier.name;
}
/**
* Get the plugin's description.
*/
public final String getDescription() {
return this.identifier.description;
}
/**
* Get the plugin's version.
*/
public final String getVersion() {
return this.identifier.version;
}
/**
* Returns the server that initialized the plugin.
* @return A server instance.
*/
public final GameServer getServer() {
return Grasscutter.getGameServer();
}
/* Called when the plugin is first loaded. */
public void onLoad() { }
/* Called after (most of) the server enables. */
public void onEnable() { }
/* Called before the server disables. */
public void onDisable() { }
}
package emu.grasscutter.plugin;
/**
* The data contained in the plugin's `plugin.json` file.
*/
public final class PluginConfig {
public String name, description, version;
public String mainClass;
public String[] authors;
/**
* 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;
}
}
package emu.grasscutter.plugin;
// TODO: Potentially replace with Lombok?
public final class PluginIdentifier {
public final String name, description, version;
public final String[] authors;
public PluginIdentifier(
String name, String description, String version,
String[] authors
) {
this.name = name;
this.description = description;
this.version = version;
this.authors = authors;
}
/**
* Converts a {@link PluginConfig} into a {@link PluginIdentifier}.
*/
public static PluginIdentifier fromPluginConfig(PluginConfig config) {
if(!config.validate())
throw new IllegalArgumentException("A valid plugin config is required to convert into a plugin identifier.");
return new PluginIdentifier(
config.name, config.description, config.version,
config.authors
);
}
}
package emu.grasscutter.plugin;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.server.event.Event;
import emu.grasscutter.server.event.EventHandler;
import emu.grasscutter.server.event.Listener;
import emu.grasscutter.utils.Utils;
import org.reflections.Reflections;
import java.io.File;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.*;
/**
* Manages the server's plugins & the event system.
*/
public final class PluginManager {
private final Map<String, Plugin> plugins = new HashMap<>();
private final Map<Plugin, List<Listener>> listeners = new HashMap<>();
public PluginManager() {
this.loadPlugins(); // Load all plugins from the plugins directory.
}
/**
* Loads plugins from the config-specified directory.
*/
private void loadPlugins() {
String directory = Grasscutter.getConfig().PLUGINS_FOLDER;
File pluginsDir = new File(Utils.toFilePath(directory));
if(!pluginsDir.exists() && !pluginsDir.mkdirs()) {
Grasscutter.getLogger().error("Failed to create plugins directory: " + pluginsDir.getAbsolutePath());
return;
}
File[] files = pluginsDir.listFiles();
if(files == null) {
// The directory is empty, there aren't any plugins to load.
return;
}
List<File> plugins = Arrays.stream(files)
.filter(file -> file.getName().endsWith(".jar"))
.toList();
plugins.forEach(plugin -> {
try {
URL url = plugin.toURI().toURL();
try (URLClassLoader loader = new URLClassLoader(new URL[]{url})) {
URL configFile = loader.findResource("plugin.json");
InputStreamReader fileReader = new InputStreamReader(configFile.openStream());
PluginConfig pluginConfig = Grasscutter.getGsonFactory().fromJson(fileReader, PluginConfig.class);
if(!pluginConfig.validate()) {
Utils.logObject(pluginConfig);
Grasscutter.getLogger().warn("Plugin " + plugin.getName() + " has an invalid config file.");
return;
}
Class<?> pluginClass = loader.loadClass(pluginConfig.mainClass);
Plugin pluginInstance = (Plugin) pluginClass.getDeclaredConstructor().newInstance();
this.loadPlugin(pluginInstance, PluginIdentifier.fromPluginConfig(pluginConfig));
fileReader.close(); // Close the file reader.
} catch (ClassNotFoundException ignored) {
Grasscutter.getLogger().warn("Plugin " + plugin.getName() + " has an invalid main class.");
}
} catch (Exception exception) {
Grasscutter.getLogger().error("Failed to load plugin: " + plugin.getName(), exception);
}
});
}
/**
* Load the specified plugin.
* @param plugin The plugin instance.
*/
private void loadPlugin(Plugin plugin, PluginIdentifier identifier) {
Grasscutter.getLogger().info("Loading plugin: " + identifier.name);
// Add the plugin's identifier.
try {
Class<Plugin> pluginClass = Plugin.class;
Method method = pluginClass.getDeclaredMethod("initializePlugin", PluginIdentifier.class);
method.setAccessible(true); method.invoke(plugin, identifier); method.setAccessible(false);
} catch (Exception ignored) {
Grasscutter.getLogger().warn("Failed to add plugin identifier: " + identifier.name);
}
// Add the plugin to the list of loaded plugins.
this.plugins.put(identifier.name, plugin);
// Call the plugin's onLoad method.
plugin.onLoad();
}
/**
* Enables all registered plugins.
*/
public void enablePlugins() {
this.plugins.forEach((name, plugin) -> {
Grasscutter.getLogger().info("Enabling plugin: " + name);
plugin.onEnable();
});
}
/**
* Disables all registered plugins.
*/
public void disablePlugins() {
this.plugins.forEach((name, plugin) -> {
Grasscutter.getLogger().info("Disabling plugin: " + name);
plugin.onDisable();
});
}
/**
* Registers a plugin's event listener.
* @param plugin The plugin instance.
* @param listener The event listener.
*/
public void registerListener(Plugin plugin, Listener listener) {
this.listeners.computeIfAbsent(plugin, k -> new ArrayList<>()).add(listener);
}
/**
* Invoke the provided event on all registered event listeners.
* @param event The event to invoke.
*/
public void invokeEvent(Event event) {
this.listeners.values().stream()
.flatMap(Collection::stream)
.forEach(listener -> this.invokeOnListener(listener, event));
}
/**
* Attempts to invoke the event on the provided listener.
*/
private void invokeOnListener(Listener listener, Event event) {
try {
Class<?> listenerClass = listener.getClass();
Method[] methods = listenerClass.getMethods();
for (Method method : methods) {
if(!method.isAnnotationPresent(EventHandler.class)) return;
if(!method.getParameterTypes()[0].isAssignableFrom(event.getClass())) return;
method.invoke(listener, event);
}
} catch (Exception ignored) { }
}
}
\ No newline at end of file
...@@ -18,6 +18,8 @@ import emu.grasscutter.net.proto.RegionInfoOuterClass.RegionInfo; ...@@ -18,6 +18,8 @@ import emu.grasscutter.net.proto.RegionInfoOuterClass.RegionInfo;
import emu.grasscutter.net.proto.RegionSimpleInfoOuterClass.RegionSimpleInfo; import emu.grasscutter.net.proto.RegionSimpleInfoOuterClass.RegionSimpleInfo;
import emu.grasscutter.server.dispatch.json.*; import emu.grasscutter.server.dispatch.json.*;
import emu.grasscutter.server.dispatch.json.ComboTokenReqJson.LoginTokenData; import emu.grasscutter.server.dispatch.json.ComboTokenReqJson.LoginTokenData;
import emu.grasscutter.server.event.dispatch.QueryAllRegionsEvent;
import emu.grasscutter.server.event.dispatch.QueryCurrentRegionEvent;
import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.FileUtils;
import emu.grasscutter.utils.Utils; import emu.grasscutter.utils.Utils;
...@@ -277,7 +279,11 @@ public final class DispatchServer { ...@@ -277,7 +279,11 @@ public final class DispatchServer {
if (uri.getQuery() != null && uri.getQuery().length() > 0) { if (uri.getQuery() != null && uri.getQuery().length() > 0) {
response = regionCurrentBase64; response = regionCurrentBase64;
} }
responseHTML(t, response);
// Invoke event.
QueryCurrentRegionEvent event = new QueryCurrentRegionEvent(response); event.call();
// Respond with event result.
responseHTML(t, event.getRegionInfo());
}); });
} }
......
package emu.grasscutter.server.event;
/**
* Implementing this interface marks an event as cancellable.
*/
public interface Cancellable {
void cancel();
}
package emu.grasscutter.server.event;
import emu.grasscutter.Grasscutter;
/**
* A generic server event.
*/
public abstract class Event {
private boolean cancelled = false;
/**
* Return the cancelled state of the event.
*/
public boolean isCanceled() {
return this.cancelled;
}
/**
* Cancels the event if possible.
*/
public void cancel() {
if(this instanceof Cancellable)
this.cancelled = true;
}
/**
* Pushes this event to all listeners.
*/
public void call() {
Grasscutter.getPluginManager().invokeEvent(this);
}
}
package emu.grasscutter.server.event;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Declares a class as an event listener/handler.
*/
@Retention(RetentionPolicy.RUNTIME)
public @interface EventHandler {
}
\ No newline at end of file
package emu.grasscutter.server.event;
/**
* Implementing this interface declares a class as an event listener.
*/
public interface Listener {
}
package emu.grasscutter.server.event;
/**
* An event that is related to the internals of the server.
*/
public abstract class ServerEvent extends Event {
protected final Type type;
public ServerEvent(Type type) {
this.type = type;
}
public enum Type {
DISPATCH,
GAME
}
}
package emu.grasscutter.server.event.dispatch;
import emu.grasscutter.server.event.ServerEvent;
public final class QueryAllRegionsEvent extends ServerEvent {
private String regionList;
public QueryAllRegionsEvent(String regionList) {
super(Type.DISPATCH);
this.regionList = regionList;
}
public void setRegionList(String regionList) {
this.regionList = regionList;
}
public String getRegionList() {
return this.regionList;
}
}
package emu.grasscutter.server.event.dispatch;
import emu.grasscutter.server.event.ServerEvent;
public final class QueryCurrentRegionEvent extends ServerEvent {
private String regionInfo;
public QueryCurrentRegionEvent(String regionInfo) {
super(Type.DISPATCH);
this.regionInfo = regionInfo;
}
public void setRegionInfo(String regionInfo) {
this.regionInfo = regionInfo;
}
public String getRegionInfo() {
return this.regionInfo;
}
}
package emu.grasscutter.server.event.game;
import emu.grasscutter.server.event.Cancellable;
import emu.grasscutter.server.event.ServerEvent;
import emu.grasscutter.server.game.GameSession;
public final class ReceivePacketEvent extends ServerEvent implements Cancellable {
private final GameSession gameSession;
private final int packetId;
private byte[] packetData;
public ReceivePacketEvent(GameSession gameSession, int packetId, byte[] packetData) {
super(Type.GAME);
this.gameSession = gameSession;
this.packetId = packetId;
this.packetData = packetData;
}
public GameSession getGameSession() {
return this.gameSession;
}
public int getPacketId() {
return this.packetId;
}
public void setPacketData(byte[] packetData) {
this.packetData = packetData;
}
public byte[] getPacketData() {
return this.packetData;
}
}
package emu.grasscutter.server.event.game;
import emu.grasscutter.net.packet.GenshinPacket;
import emu.grasscutter.server.event.Cancellable;
import emu.grasscutter.server.event.ServerEvent;
import emu.grasscutter.server.game.GameSession;
public final class SendPacketEvent extends ServerEvent implements Cancellable {
private final GameSession gameSession;
private GenshinPacket packet;
public SendPacketEvent(GameSession gameSession, GenshinPacket packet) {
super(Type.GAME);
this.gameSession = gameSession;
this.packet = packet;
}
public GameSession getGameSession() {
return this.gameSession;
}
public void setPacket(GenshinPacket packet) {
this.packet = packet;
}
public GenshinPacket getPacket() {
return this.packet;
}
}
...@@ -2,6 +2,7 @@ package emu.grasscutter.server.game; ...@@ -2,6 +2,7 @@ package emu.grasscutter.server.game;
import java.util.Set; import java.util.Set;
import emu.grasscutter.server.event.game.ReceivePacketEvent;
import org.reflections.Reflections; import org.reflections.Reflections;
import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter;
...@@ -48,9 +49,7 @@ public class GameServerPacketHandler { ...@@ -48,9 +49,7 @@ public class GameServerPacketHandler {
} }
public void handle(GameSession session, int opcode, byte[] header, byte[] payload) { public void handle(GameSession session, int opcode, byte[] header, byte[] payload) {
PacketHandler handler = null; PacketHandler handler = this.handlers.get(opcode);
handler = this.handlers.get(opcode);
if (handler != null) { if (handler != null) {
try { try {
...@@ -77,8 +76,10 @@ public class GameServerPacketHandler { ...@@ -77,8 +76,10 @@ public class GameServerPacketHandler {
} }
} }
// Handle // Invoke event.
handler.handle(session, header, payload); ReceivePacketEvent event = new ReceivePacketEvent(session, opcode, payload); event.call();
if(!event.isCanceled()) // If event is not canceled, continue.
handler.handle(session, header, event.getPacketData());
} catch (Exception ex) { } catch (Exception ex) {
// TODO Remove this when no more needed // TODO Remove this when no more needed
ex.printStackTrace(); ex.printStackTrace();
......
...@@ -10,6 +10,7 @@ import emu.grasscutter.game.GenshinPlayer; ...@@ -10,6 +10,7 @@ import emu.grasscutter.game.GenshinPlayer;
import emu.grasscutter.net.packet.GenshinPacket; import emu.grasscutter.net.packet.GenshinPacket;
import emu.grasscutter.net.packet.PacketOpcodesUtil; import emu.grasscutter.net.packet.PacketOpcodesUtil;
import emu.grasscutter.netty.MihoyoKcpChannel; import emu.grasscutter.netty.MihoyoKcpChannel;
import emu.grasscutter.server.event.game.SendPacketEvent;
import emu.grasscutter.utils.Crypto; import emu.grasscutter.utils.Crypto;
import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.FileUtils;
import emu.grasscutter.utils.Utils; import emu.grasscutter.utils.Utils;
...@@ -161,16 +162,15 @@ public class GameSession extends MihoyoKcpChannel { ...@@ -161,16 +162,15 @@ public class GameSession extends MihoyoKcpChannel {
genshinPacket.buildHeader(this.getNextClientSequence()); genshinPacket.buildHeader(this.getNextClientSequence());
} }
// Build packet
byte[] data = genshinPacket.build();
// Log // Log
if (Grasscutter.getConfig().getGameServerOptions().LOG_PACKETS) { if (Grasscutter.getConfig().getGameServerOptions().LOG_PACKETS) {
logPacket(genshinPacket); logPacket(genshinPacket);
} }
// Send // Invoke event.
send(data); SendPacketEvent event = new SendPacketEvent(this, genshinPacket); event.call();
if(!event.isCanceled()) // If event is not cancelled, continue.
this.send(event.getPacket().build());
} }
private void logPacket(int opcode) { private void logPacket(int opcode) {
......
...@@ -137,6 +137,15 @@ public final class Utils { ...@@ -137,6 +137,15 @@ public final class Utils {
return nonNull != null ? nonNull : fallback; return nonNull != null ? nonNull : fallback;
} }
/**
* Logs an object to the console.
* @param object The object to log.
*/
public static void logObject(Object object) {
String asJson = Grasscutter.getGsonFactory().toJson(object);
Grasscutter.getLogger().info(asJson);
}
/** /**
* Checks for required files and folders before startup. * Checks for required files and folders before startup.
*/ */
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment