diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc0193c1a..8d6d6aaf0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: uses: actions/setup-java@v3 with: distribution: temurin - java-version: '16' + java-version: '17' - name: Run Gradle run: ./gradlew && ./gradlew jar - name: Upload build diff --git a/README.md b/README.md index 5bcec2715..fdd0b1e27 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ EN | [中文](README_zh-CN.md) ### Requirements -* Java SE - 16 ([mirror link](https://github.com/adoptium/temurin16-binaries/releases/tag/jdk-16.0.2+7) since Oracle required an account to download old builds) +* Java SE - 17 ([mirror link](https://github.com/adoptium/temurin17-binaries/releases/tag/jdk-17.0.3+7) since Oracle required an account to download old builds) **Note:** If you just want to **run it**, then **jre** is fine @@ -142,7 +142,7 @@ character falling from a very high destination, exact location that you marked. # Quick Troubleshooting -* If compiling wasn't successful, please check your JDK installation (JDK 16 and validated JDK's bin PATH variable) +* If compiling wasn't successful, please check your JDK installation (JDK 17 and validated JDK's bin PATH variable) * My client doesn't connect, doesn't login, 4206, etc... - Mostly your proxy daemon setup is *the issue*, if using Fiddler make sure it running on another port except 8888 * Startup sequence: Mongodb > Grasscutter > Proxy daemon (mitmdump, fiddler, etc.) > Client diff --git a/README_zh-CN.md b/README_zh-CN.md index 075bc11a1..17f7e70f0 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -24,7 +24,7 @@ ### 环境需求 -* Java SE - 16 (当您没有Oracle账户,可以使用[镜像](https://github.com/adoptium/temurin16-binaries/releases/tag/jdk-16.0.2+7)) +* Java SE - 17 (当您没有Oracle账户,可以使用[镜像](https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/)) **注:** 如果您仅仅想要简单地**运行服务端**, 那么使用 **jre** 便足够了 @@ -142,6 +142,6 @@ chmod +x gradlew # 快速排除问题 -* 如果编译未能成功,请检查您的jdk安装 (JDK 16并确认jdk处于环境变量`PATH`中 +* 如果编译未能成功,请检查您的jdk安装 (JDK 17并确认jdk处于环境变量`PATH`中 * 我的客户端无法登录/连接, 4206, 其它... - 大部分情况下这是因为您的代理存在问题.如果使用Fiddler请确认Fiddler监听端口不是`8888` * 启动顺序: MongoDB > Grasscutter > 代理程序 (mitmdump, fiddler等.) > 客户端 diff --git a/build.gradle b/build.gradle index c32d56e1f..bf839316a 100644 --- a/build.gradle +++ b/build.gradle @@ -14,8 +14,8 @@ plugins { id 'application' } -sourceCompatibility = 16 -targetCompatibility = 16 +sourceCompatibility = 17 +targetCompatibility = 17 repositories { mavenCentral() diff --git a/src/main/java/emu/grasscutter/Config.java b/src/main/java/emu/grasscutter/Config.java index 980102957..ee5fe65c8 100644 --- a/src/main/java/emu/grasscutter/Config.java +++ b/src/main/java/emu/grasscutter/Config.java @@ -1,7 +1,5 @@ package emu.grasscutter; -import java.util.ArrayList; - public final class Config { public String DatabaseUrl = "mongodb://localhost:27017"; @@ -12,6 +10,7 @@ public final class Config { public String PACKETS_FOLDER = "./packets/"; public String DUMPS_FOLDER = "./dumps/"; public String KEY_FOLDER = "./keys/"; + public String PLUGINS_FOLDER = "./plugins/"; public String RunMode = "HYBRID"; // HYBRID, DISPATCH_ONLY, GAME_ONLY public GameServerOptions GameServer = new GameServerOptions(); diff --git a/src/main/java/emu/grasscutter/Grasscutter.java b/src/main/java/emu/grasscutter/Grasscutter.java index 5335a2254..8246588ae 100644 --- a/src/main/java/emu/grasscutter/Grasscutter.java +++ b/src/main/java/emu/grasscutter/Grasscutter.java @@ -8,6 +8,7 @@ import java.io.InputStreamReader; import java.net.InetSocketAddress; import emu.grasscutter.command.CommandMap; +import emu.grasscutter.plugin.PluginManager; import emu.grasscutter.utils.Utils; import org.reflections.Reflections; import org.slf4j.LoggerFactory; @@ -33,8 +34,9 @@ public final class Grasscutter { public static RunMode MODE = RunMode.BOTH; private static DispatchServer dispatchServer; 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 { // Declare logback configuration. @@ -52,15 +54,11 @@ public final class Grasscutter { for (String arg : args) { switch (arg.toLowerCase()) { - case "-auth": - MODE = RunMode.AUTH; - break; - case "-game": - MODE = RunMode.GAME; - break; - case "-handbook": - Tools.createGmHandbook(); - return; + case "-auth" -> MODE = RunMode.AUTH; + case "-game" -> MODE = RunMode.GAME; + case "-handbook" -> { + Tools.createGmHandbook(); return; + } } } @@ -71,19 +69,21 @@ public final class Grasscutter { ResourceLoader.loadAll(); // Database 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. if(getConfig().RunMode.equalsIgnoreCase("HYBRID")) { - dispatchServer = new DispatchServer(); dispatchServer.start(); - - gameServer = new GameServer(new InetSocketAddress(getConfig().getGameServerOptions().Ip, getConfig().getGameServerOptions().Port)); gameServer.start(); - } else if(getConfig().RunMode.equalsIgnoreCase("DISPATCH_ONLY")) { - dispatchServer = new DispatchServer(); + } else if (getConfig().RunMode.equalsIgnoreCase("DISPATCH_ONLY")) { dispatchServer.start(); - } else if(getConfig().RunMode.equalsIgnoreCase("GAME_ONLY")) { - gameServer = new GameServer(new InetSocketAddress(getConfig().getGameServerOptions().Ip, getConfig().getGameServerOptions().Port)); + } else if (getConfig().RunMode.equalsIgnoreCase("GAME_ONLY")) { gameServer.start(); } else { getLogger().error("Invalid server run mode. " + getConfig().RunMode); @@ -91,12 +91,23 @@ public final class Grasscutter { getLogger().error("Shutting down..."); System.exit(1); } - - + + // Enable all plugins. + pluginManager.enablePlugins(); // Open console. 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() { try (FileReader file = new FileReader(configFile)) { @@ -112,7 +123,7 @@ public final class Grasscutter { try (FileWriter file = new FileWriter(configFile)) { file.write(gson.toJson(config)); } 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 { while ((input = br.readLine()) != null) { try { 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; } + CommandMap.getInstance().invoke(null, input); } catch (Exception e) { - Grasscutter.getLogger().error("Command error: "); - e.printStackTrace(); + Grasscutter.getLogger().error("Command error:", e); } } } catch (Exception e) { @@ -162,4 +173,8 @@ public final class Grasscutter { public static GameServer getGameServer() { return gameServer; } + + public static PluginManager getPluginManager() { + return pluginManager; + } } diff --git a/src/main/java/emu/grasscutter/command/commands/ClearArtifactsCommand.java b/src/main/java/emu/grasscutter/command/commands/ClearArtifactsCommand.java deleted file mode 100644 index 258e1e1d5..000000000 --- a/src/main/java/emu/grasscutter/command/commands/ClearArtifactsCommand.java +++ /dev/null @@ -1,30 +0,0 @@ -package emu.grasscutter.command.commands; - -import emu.grasscutter.command.Command; -import emu.grasscutter.command.CommandHandler; -import emu.grasscutter.game.GenshinPlayer; -import emu.grasscutter.game.inventory.Inventory; -import emu.grasscutter.game.inventory.ItemType; - -import java.util.List; - -@Command(label = "clearartifacts", usage = "clearartifacts", - description = "Deletes all unequipped and unlocked level 0 artifacts, including yellow rarity ones from your inventory", - aliases = {"clearart"}, permission = "player.clearartifacts") -public final class ClearArtifactsCommand implements CommandHandler { - - @Override - public void execute(GenshinPlayer sender, List args) { - if (sender == null) { - CommandHandler.sendMessage(null, "Run this command in-game."); - return; // TODO: clear player's artifacts from console or other players - } - - Inventory playerInventory = sender.getInventory(); - playerInventory.getItems().values().stream() - .filter(item -> item.getItemType() == ItemType.ITEM_RELIQUARY) - .filter(item -> item.getLevel() == 1 && item.getExp() == 0) - .filter(item -> !item.isLocked() && !item.isEquipped()) - .forEach(item -> playerInventory.removeItem(item, item.getCount())); - } -} diff --git a/src/main/java/emu/grasscutter/command/commands/ClearCommand.java b/src/main/java/emu/grasscutter/command/commands/ClearCommand.java new file mode 100644 index 000000000..8d16e58c1 --- /dev/null +++ b/src/main/java/emu/grasscutter/command/commands/ClearCommand.java @@ -0,0 +1,106 @@ +package emu.grasscutter.command.commands; + +import emu.grasscutter.Grasscutter; +import emu.grasscutter.command.Command; +import emu.grasscutter.command.CommandHandler; +import emu.grasscutter.data.GenshinData; +import emu.grasscutter.data.def.ItemData; +import emu.grasscutter.game.GenshinPlayer; +import emu.grasscutter.game.inventory.GenshinItem; +import emu.grasscutter.game.inventory.Inventory; +import emu.grasscutter.game.inventory.ItemType; + +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +@Command(label = "clear", usage = "clear ", //Merged /clearartifacts and /clearweapons to /clear [uid] + description = "Deletes unequipped unlocked items, including yellow rarity ones from your inventory", + aliases = {"clear"}, permission = "player.clearinv") + +public final class ClearCommand implements CommandHandler { + + @Override + public void execute(GenshinPlayer sender, List args) { + int target; + if (sender == null) { + CommandHandler.sendMessage(null, "Run this command in-game."); + return; + } + String cmdSwitch = args.get(1); + + Inventory playerInventory = sender.getInventory(); + try { + target = Integer.parseInt(args.get(0)); + GenshinPlayer targetPlayer = Grasscutter.getGameServer().getPlayerByUid(target); + if (targetPlayer == null && sender != null) { + target = sender.getUid(); + } else { + switch (cmdSwitch){ + case "wp": + playerInventory.getItems().values().stream() + .filter(item -> item.getItemType() == ItemType.ITEM_WEAPON) + .filter(item -> !item.isLocked() && !item.isEquipped()) + .forEach(item -> playerInventory.removeItem(item, item.getCount())); + sender.dropMessage("Cleared weapons for " + targetPlayer.getNickname() + " ."); + break; + case "art": + playerInventory.getItems().values().stream() + .filter(item -> item.getItemType() == ItemType.ITEM_RELIQUARY) + .filter(item -> item.getLevel() == 1 && item.getExp() == 0) + .filter(item -> !item.isLocked() && !item.isEquipped()) + .forEach(item -> playerInventory.removeItem(item, item.getCount())); + sender.dropMessage("Cleared artifacts for " + targetPlayer.getNickname() + " ."); + break; + case "mat": + playerInventory.getItems().values().stream() + .filter(item -> item.getItemType() == ItemType.ITEM_MATERIAL) + .filter(item -> item.getLevel() == 1 && item.getExp() == 0) + .filter(item -> !item.isLocked() && !item.isEquipped()) + .forEach(item -> playerInventory.removeItem(item, item.getCount())); + sender.dropMessage("Cleared artifacts for " + targetPlayer.getNickname() + " ."); + break; + case "all": + playerInventory.getItems().values().stream() + .filter(item1 -> item1.getItemType() == ItemType.ITEM_RELIQUARY) + .filter(item1 -> item1.getLevel() == 1 && item1.getExp() == 0) + .filter(item1 -> !item1.isLocked() && !item1.isEquipped()) + .forEach(item1 -> playerInventory.removeItem(item1, item1.getCount())); + playerInventory.getItems().values().stream() + .filter(item2 -> item2.getItemType() == ItemType.ITEM_MATERIAL) + .filter(item2 -> !item2.isLocked() && !item2.isEquipped()) + .forEach(item2 -> playerInventory.removeItem(item2, item2.getCount())); + playerInventory.getItems().values().stream() + .filter(item3 -> item3.getItemType() == ItemType.ITEM_WEAPON) + .filter(item3 -> item3.getLevel() == 1 && item3.getExp() == 0) + .filter(item3 -> !item3.isLocked() && !item3.isEquipped()) + .forEach(item3 -> playerInventory.removeItem(item3, item3.getCount())); + playerInventory.getItems().values().stream() + .filter(item4 -> item4.getItemType() == ItemType.ITEM_FURNITURE) + .filter(item4 -> !item4.isLocked() && !item4.isEquipped()) + .forEach(item4 -> playerInventory.removeItem(item4, item4.getCount())); + playerInventory.getItems().values().stream() + .filter(item5 -> item5.getItemType() == ItemType.ITEM_DISPLAY) + .filter(item5 -> !item5.isLocked() && !item5.isEquipped()) + .forEach(item5 -> playerInventory.removeItem(item5, item5.getCount())); + playerInventory.getItems().values().stream() + .filter(item6 -> item6.getItemType() == ItemType.ITEM_VIRTUAL) + .filter(item6 -> !item6.isLocked() && !item6.isEquipped()) + .forEach(item6 -> playerInventory.removeItem(item6, item6.getCount())); + sender.dropMessage("Cleared everything for " + targetPlayer.getNickname() + " ."); + break; + } + } + } catch (NumberFormatException ignored) { + // TODO: Parse from item name using GM Handbook. + CommandHandler.sendMessage(sender, "Invalid playerId."); + return; + } + + GenshinPlayer targetPlayer = Grasscutter.getGameServer().getPlayerByUid(target); + if (targetPlayer == null) { + CommandHandler.sendMessage(sender, "Player not found."); + return; + } + } +} diff --git a/src/main/java/emu/grasscutter/command/commands/ClearWeaponsCommand.java b/src/main/java/emu/grasscutter/command/commands/ClearWeaponsCommand.java deleted file mode 100644 index fbe72db87..000000000 --- a/src/main/java/emu/grasscutter/command/commands/ClearWeaponsCommand.java +++ /dev/null @@ -1,51 +0,0 @@ -package emu.grasscutter.command.commands; - -import emu.grasscutter.Grasscutter; -import emu.grasscutter.command.Command; -import emu.grasscutter.command.CommandHandler; -import emu.grasscutter.game.GenshinPlayer; -import emu.grasscutter.game.inventory.Inventory; -import emu.grasscutter.game.inventory.ItemType; - -import java.util.List; - -@Command(label = "clearweapons", usage = "clearweapons", - description = "Deletes all unequipped and unlocked weapons, including yellow rarity ones from your inventory", - aliases = {"clearwp"}, permission = "player.clearweapons") -public final class ClearWeaponsCommand implements CommandHandler { - - @Override - public void execute(GenshinPlayer sender, List args) { - if (sender == null) { - CommandHandler.sendMessage(null, "Run this command in-game."); - return; - } - - int target; - if (args.size() == 1) { - try { - target = Integer.parseInt(args.get(0)); - if (Grasscutter.getGameServer().getPlayerByUid(target) == null) { - target = sender.getUid(); - } - } catch (NumberFormatException e) { - CommandHandler.sendMessage(sender, "Invalid player id."); - return; - } - } else { - target = sender.getUid(); - } - GenshinPlayer targetPlayer = Grasscutter.getGameServer().getPlayerByUid(target); - if (targetPlayer == null) { - CommandHandler.sendMessage(sender, "Player not found."); - return; - } - - Inventory playerInventory = targetPlayer.getInventory(); - playerInventory.getItems().values().stream() - .filter(item -> item.getItemType() == ItemType.ITEM_WEAPON) - .filter(item -> !item.isLocked() && !item.isEquipped()) - .forEach(item -> playerInventory.removeItem(item, item.getCount())); - sender.dropMessage("Cleared weapons for " + targetPlayer.getNickname() + " ."); - } -} diff --git a/src/main/java/emu/grasscutter/command/commands/KillCharacterCommand.java b/src/main/java/emu/grasscutter/command/commands/KillCharacterCommand.java new file mode 100644 index 000000000..beaa4f959 --- /dev/null +++ b/src/main/java/emu/grasscutter/command/commands/KillCharacterCommand.java @@ -0,0 +1,68 @@ +package emu.grasscutter.command.commands; + +import emu.grasscutter.Grasscutter; +import emu.grasscutter.command.Command; +import emu.grasscutter.command.CommandHandler; +import emu.grasscutter.game.GenshinPlayer; +import emu.grasscutter.game.entity.EntityAvatar; +import emu.grasscutter.game.props.FightProperty; +import emu.grasscutter.game.props.LifeState; +import emu.grasscutter.server.packet.send.PacketEntityFightPropUpdateNotify; +import emu.grasscutter.server.packet.send.PacketLifeStateChangeNotify; + +import java.util.List; + +@Command(label = "killcharacter", usage = "killcharacter [playerId]", aliases = {"suicide", "kill"}, + description = "Kills the players current character", permission = "player.killcharacter") +public final class KillCharacterCommand implements CommandHandler { + + @Override + public void execute(GenshinPlayer sender, List args) { + int target; + if (sender == null) { + // from console + if (args.size() == 1) { + try { + target = Integer.parseInt(args.get(0)); + } catch (NumberFormatException e) { + CommandHandler.sendMessage(null, "Invalid player id."); + return; + } + } else { + CommandHandler.sendMessage(null, "Usage: /killcharacter [playerId]"); + return; + } + } else { + if (args.size() == 1) { + try { + target = Integer.parseInt(args.get(0)); + if (Grasscutter.getGameServer().getPlayerByUid(target) == null) { + target = sender.getUid(); + } + } catch (NumberFormatException e) { + CommandHandler.sendMessage(sender, "Invalid player id."); + return; + } + } else { + target = sender.getUid(); + } + } + + GenshinPlayer targetPlayer = Grasscutter.getGameServer().getPlayerByUid(target); + if (targetPlayer == null) { + CommandHandler.sendMessage(sender, "Player not found or offline."); + return; + } + + EntityAvatar entity = targetPlayer.getTeamManager().getCurrentAvatarEntity(); + entity.setFightProperty(FightProperty.FIGHT_PROP_CUR_HP, 0f); + // Packets + entity.getWorld().broadcastPacket(new PacketEntityFightPropUpdateNotify(entity, FightProperty.FIGHT_PROP_CUR_HP)); + entity.getWorld().broadcastPacket(new PacketLifeStateChangeNotify(0, entity, LifeState.LIFE_DEAD)); + // remove + targetPlayer.getScene().removeEntity(entity); + entity.onDeath(0); + + CommandHandler.sendMessage(sender, "Killed " + targetPlayer.getNickname() + " current character."); + } +} diff --git a/src/main/java/emu/grasscutter/plugin/Plugin.java b/src/main/java/emu/grasscutter/plugin/Plugin.java new file mode 100644 index 000000000..a3160d7c7 --- /dev/null +++ b/src/main/java/emu/grasscutter/plugin/Plugin.java @@ -0,0 +1,67 @@ +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() { } +} diff --git a/src/main/java/emu/grasscutter/plugin/PluginConfig.java b/src/main/java/emu/grasscutter/plugin/PluginConfig.java new file mode 100644 index 000000000..0fb07037c --- /dev/null +++ b/src/main/java/emu/grasscutter/plugin/PluginConfig.java @@ -0,0 +1,18 @@ +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; + } +} diff --git a/src/main/java/emu/grasscutter/plugin/PluginIdentifier.java b/src/main/java/emu/grasscutter/plugin/PluginIdentifier.java new file mode 100644 index 000000000..a467e3949 --- /dev/null +++ b/src/main/java/emu/grasscutter/plugin/PluginIdentifier.java @@ -0,0 +1,29 @@ +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 + ); + } +} diff --git a/src/main/java/emu/grasscutter/plugin/PluginManager.java b/src/main/java/emu/grasscutter/plugin/PluginManager.java new file mode 100644 index 000000000..7b54f460f --- /dev/null +++ b/src/main/java/emu/grasscutter/plugin/PluginManager.java @@ -0,0 +1,152 @@ +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 plugins = new HashMap<>(); + private final Map> 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 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 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 diff --git a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java index 6d8a06e7c..5cb06d5e1 100644 --- a/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java +++ b/src/main/java/emu/grasscutter/server/dispatch/DispatchServer.java @@ -18,6 +18,8 @@ import emu.grasscutter.net.proto.RegionInfoOuterClass.RegionInfo; import emu.grasscutter.net.proto.RegionSimpleInfoOuterClass.RegionSimpleInfo; import emu.grasscutter.server.dispatch.json.*; 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.Utils; @@ -277,7 +279,11 @@ public final class DispatchServer { if (uri.getQuery() != null && uri.getQuery().length() > 0) { response = regionCurrentBase64; } - responseHTML(t, response); + + // Invoke event. + QueryCurrentRegionEvent event = new QueryCurrentRegionEvent(response); event.call(); + // Respond with event result. + responseHTML(t, event.getRegionInfo()); }); } diff --git a/src/main/java/emu/grasscutter/server/event/Cancellable.java b/src/main/java/emu/grasscutter/server/event/Cancellable.java new file mode 100644 index 000000000..0296f0b36 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/Cancellable.java @@ -0,0 +1,8 @@ +package emu.grasscutter.server.event; + +/** + * Implementing this interface marks an event as cancellable. + */ +public interface Cancellable { + void cancel(); +} diff --git a/src/main/java/emu/grasscutter/server/event/Event.java b/src/main/java/emu/grasscutter/server/event/Event.java new file mode 100644 index 000000000..bea7dd66f --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/Event.java @@ -0,0 +1,32 @@ +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); + } +} diff --git a/src/main/java/emu/grasscutter/server/event/EventHandler.java b/src/main/java/emu/grasscutter/server/event/EventHandler.java new file mode 100644 index 000000000..d924933f2 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/EventHandler.java @@ -0,0 +1,11 @@ +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 diff --git a/src/main/java/emu/grasscutter/server/event/Listener.java b/src/main/java/emu/grasscutter/server/event/Listener.java new file mode 100644 index 000000000..2949cfe4a --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/Listener.java @@ -0,0 +1,7 @@ +package emu.grasscutter.server.event; + +/** + * Implementing this interface declares a class as an event listener. + */ +public interface Listener { +} diff --git a/src/main/java/emu/grasscutter/server/event/ServerEvent.java b/src/main/java/emu/grasscutter/server/event/ServerEvent.java new file mode 100644 index 000000000..e87abae0d --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/ServerEvent.java @@ -0,0 +1,17 @@ +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 + } +} diff --git a/src/main/java/emu/grasscutter/server/event/dispatch/QueryAllRegionsEvent.java b/src/main/java/emu/grasscutter/server/event/dispatch/QueryAllRegionsEvent.java new file mode 100644 index 000000000..8595f6221 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/dispatch/QueryAllRegionsEvent.java @@ -0,0 +1,21 @@ +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; + } +} diff --git a/src/main/java/emu/grasscutter/server/event/dispatch/QueryCurrentRegionEvent.java b/src/main/java/emu/grasscutter/server/event/dispatch/QueryCurrentRegionEvent.java new file mode 100644 index 000000000..d6a20b2df --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/dispatch/QueryCurrentRegionEvent.java @@ -0,0 +1,21 @@ +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; + } +} diff --git a/src/main/java/emu/grasscutter/server/event/game/ReceivePacketEvent.java b/src/main/java/emu/grasscutter/server/event/game/ReceivePacketEvent.java new file mode 100644 index 000000000..51109c720 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/game/ReceivePacketEvent.java @@ -0,0 +1,35 @@ +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; + } +} diff --git a/src/main/java/emu/grasscutter/server/event/game/SendPacketEvent.java b/src/main/java/emu/grasscutter/server/event/game/SendPacketEvent.java new file mode 100644 index 000000000..7a25b4e10 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/event/game/SendPacketEvent.java @@ -0,0 +1,30 @@ +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; + } +} diff --git a/src/main/java/emu/grasscutter/server/game/GameServerPacketHandler.java b/src/main/java/emu/grasscutter/server/game/GameServerPacketHandler.java index 62a57df91..50d508bed 100644 --- a/src/main/java/emu/grasscutter/server/game/GameServerPacketHandler.java +++ b/src/main/java/emu/grasscutter/server/game/GameServerPacketHandler.java @@ -2,6 +2,7 @@ package emu.grasscutter.server.game; import java.util.Set; +import emu.grasscutter.server.event.game.ReceivePacketEvent; import org.reflections.Reflections; import emu.grasscutter.Grasscutter; @@ -48,9 +49,7 @@ public class GameServerPacketHandler { } public void handle(GameSession session, int opcode, byte[] header, byte[] payload) { - PacketHandler handler = null; - - handler = this.handlers.get(opcode); + PacketHandler handler = this.handlers.get(opcode); if (handler != null) { try { @@ -77,8 +76,10 @@ public class GameServerPacketHandler { } } - // Handle - handler.handle(session, header, payload); + // Invoke event. + 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) { // TODO Remove this when no more needed ex.printStackTrace(); diff --git a/src/main/java/emu/grasscutter/server/game/GameSession.java b/src/main/java/emu/grasscutter/server/game/GameSession.java index ebd66dc20..53b4f32cc 100644 --- a/src/main/java/emu/grasscutter/server/game/GameSession.java +++ b/src/main/java/emu/grasscutter/server/game/GameSession.java @@ -10,6 +10,7 @@ import emu.grasscutter.game.GenshinPlayer; import emu.grasscutter.net.packet.GenshinPacket; import emu.grasscutter.net.packet.PacketOpcodesUtil; import emu.grasscutter.netty.MihoyoKcpChannel; +import emu.grasscutter.server.event.game.SendPacketEvent; import emu.grasscutter.utils.Crypto; import emu.grasscutter.utils.FileUtils; import emu.grasscutter.utils.Utils; @@ -161,16 +162,15 @@ public class GameSession extends MihoyoKcpChannel { genshinPacket.buildHeader(this.getNextClientSequence()); } - // Build packet - byte[] data = genshinPacket.build(); - // Log if (Grasscutter.getConfig().getGameServerOptions().LOG_PACKETS) { logPacket(genshinPacket); } - - // Send - send(data); + + // Invoke event. + 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) { diff --git a/src/main/java/emu/grasscutter/utils/Utils.java b/src/main/java/emu/grasscutter/utils/Utils.java index c481ffd07..5b7b8c439 100644 --- a/src/main/java/emu/grasscutter/utils/Utils.java +++ b/src/main/java/emu/grasscutter/utils/Utils.java @@ -137,6 +137,15 @@ public final class Utils { 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. */ diff --git a/src/main/java/META-INF/MANIFEST.MF b/src/main/resources/META-INF/MANIFEST.MF similarity index 100% rename from src/main/java/META-INF/MANIFEST.MF rename to src/main/resources/META-INF/MANIFEST.MF