diff --git a/core/src/io/anuke/mindustry/Vars.java b/core/src/io/anuke/mindustry/Vars.java index e186ebce01..8344409142 100644 --- a/core/src/io/anuke/mindustry/Vars.java +++ b/core/src/io/anuke/mindustry/Vars.java @@ -121,6 +121,8 @@ public class Vars implements Loadable{ public static FileHandle tmpDirectory; /** data subdirectory used for saves */ public static FileHandle saveDirectory; + /** data subdirectory used for plugins */ + public static FileHandle pluginDirectory; /** old map file extension, for conversion */ public static final String oldMapExtension = "mmap"; /** map file extension */ @@ -237,6 +239,7 @@ public class Vars implements Loadable{ mapPreviewDirectory = dataDirectory.child("previews/"); saveDirectory = dataDirectory.child("saves/"); tmpDirectory = dataDirectory.child("tmp/"); + pluginDirectory = dataDirectory.child("plugins/"); maps.load(); } diff --git a/core/src/io/anuke/mindustry/core/NetClient.java b/core/src/io/anuke/mindustry/core/NetClient.java index 81c002966b..fa1e4075ec 100644 --- a/core/src/io/anuke/mindustry/core/NetClient.java +++ b/core/src/io/anuke/mindustry/core/NetClient.java @@ -7,6 +7,7 @@ import io.anuke.arc.collection.IntSet; import io.anuke.arc.graphics.Color; import io.anuke.arc.math.RandomXS128; import io.anuke.arc.util.*; +import io.anuke.arc.util.CommandHandler.*; import io.anuke.arc.util.io.ReusableByteInStream; import io.anuke.arc.util.serialization.Base64Coder; import io.anuke.mindustry.Vars; @@ -151,12 +152,35 @@ public class NetClient implements ApplicationListener{ throw new ValidateException(player, "Player has sent a message above the text limit."); } - //server console logging - Log.info("&y{0}: &lb{1}", player.name, message); + //check if it's a command + CommandResponse response = netServer.clientCommands.handleMessage(message, player); + if(response.type == ResponseType.noCommand){ //no command to handle + //server console logging + Log.info("&y{0}: &lb{1}", player.name, message); - //invoke event for all clients but also locally - //this is required so other clients get the correct name even if they don't know who's sending it yet - Call.sendMessage(message, colorizeName(player.id, player.name), player); + //invoke event for all clients but also locally + //this is required so other clients get the correct name even if they don't know who's sending it yet + Call.sendMessage(message, colorizeName(player.id, player.name), player); + }else{ + //log command to console but with brackets + Log.info("<&y{0}: &lm{1}&lg>", player.name, message); + + //a command was sent, now get the output + if(response.type != ResponseType.valid){ + String text; + + //send usage + if(response.type == ResponseType.manyArguments){ + text = "[scarlet]Too many arguments. Usage:[lightgray] " + response.command.text + "[gray] " + response.command.paramText; + }else if(response.type == ResponseType.fewArguments){ + text = "[scarlet]Too few arguments. Usage:[lightgray] " + response.command.text + "[gray] " + response.command.paramText; + }else{ //unknown command + text = "[scarlet]Unknown command. Check [lightgray]/help[scarlet]."; + } + + player.sendMessage(text); + } + } } private static String colorizeName(int id, String name){ diff --git a/core/src/io/anuke/mindustry/core/NetServer.java b/core/src/io/anuke/mindustry/core/NetServer.java index 8d210cb930..e91e2066bc 100644 --- a/core/src/io/anuke/mindustry/core/NetServer.java +++ b/core/src/io/anuke/mindustry/core/NetServer.java @@ -12,6 +12,7 @@ import io.anuke.arc.math.Mathf; import io.anuke.arc.math.geom.Rectangle; import io.anuke.arc.math.geom.Vector2; import io.anuke.arc.util.*; +import io.anuke.arc.util.CommandHandler.*; import io.anuke.arc.util.io.*; import io.anuke.mindustry.content.Blocks; import io.anuke.mindustry.core.GameState.State; @@ -47,6 +48,7 @@ public class NetServer implements ApplicationListener{ private final static float correctDist = 16f; public final Administration admins = new Administration(); + public final CommandHandler clientCommands = new CommandHandler("/"); /** Maps connection IDs to players. */ private IntMap connections = new IntMap<>(); @@ -192,6 +194,34 @@ public class NetServer implements ApplicationListener{ if(player == null) return; RemoteReadServer.readPacket(packet.writeBuffer, packet.type, player); }); + + registerCommands(); + } + + private void registerCommands(){ + clientCommands.register("help", "[page]", "Lists all commands.", (args, player) -> { + if(args.length > 0 && !Strings.canParseInt(args[0])){ + player.sendMessage("[scarlet]'page' must be a number."); + return; + } + int commandsPerPage = 6; + int page = args.length > 0 ? Strings.parseInt(args[0]) : 0; + int pages = Mathf.ceil((float)clientCommands.getCommandList().size / commandsPerPage); + + if(page > pages || page < 0){ + player.sendMessage("[scarlet]'page' must be a number between[orange] 0[] and[orange] " + pages + "[]."); + return; + } + + StringBuilder result = new StringBuilder(); + result.append(Strings.format("[orange]-- Command Page[lightgray] {0}[gray]/[lightgray]{1}[orange] --\n\n", page, commandsPerPage)); + + for(int i = commandsPerPage * page; i < Math.min(commandsPerPage * (page + 1), clientCommands.getCommandList().size); i++){ + Command command = clientCommands.getCommandList().get(i); + result.append("[orange] ").append(command.text).append("[lightgray] ").append(command.paramText).append("[lightgray] - ").append(command.description).append("\n"); + } + player.sendMessage(result.toString()); + }); } public Team assignTeam(Player current, Iterable players){ diff --git a/core/src/io/anuke/mindustry/entities/type/Player.java b/core/src/io/anuke/mindustry/entities/type/Player.java index 6e497b30dd..6642030b8b 100644 --- a/core/src/io/anuke/mindustry/entities/type/Player.java +++ b/core/src/io/anuke/mindustry/entities/type/Player.java @@ -774,6 +774,10 @@ public class Player extends Unit implements BuilderMinerTrait, ShooterTrait{ //region utility methods + public void sendMessage(String text){ + Call.sendMessage(con.id, text, null, null); + } + /** Resets all values of the player. */ public void reset(){ resetNoAdd(); diff --git a/core/src/io/anuke/mindustry/net/Administration.java b/core/src/io/anuke/mindustry/net/Administration.java index c1edec3ecb..bebd0c9115 100644 --- a/core/src/io/anuke/mindustry/net/Administration.java +++ b/core/src/io/anuke/mindustry/net/Administration.java @@ -7,7 +7,6 @@ import io.anuke.arc.collection.*; import static io.anuke.mindustry.Vars.headless; public class Administration{ - /** All player info. Maps UUIDs to info. This persists throughout restarts. */ private ObjectMap playerInfo = new ObjectMap<>(); private Array bannedIPs = new Array<>(); diff --git a/server/src/io/anuke/mindustry/server/MindustryServer.java b/server/src/io/anuke/mindustry/server/MindustryServer.java index 37f049883e..5fc9eb39d5 100644 --- a/server/src/io/anuke/mindustry/server/MindustryServer.java +++ b/server/src/io/anuke/mindustry/server/MindustryServer.java @@ -22,11 +22,10 @@ public class MindustryServer implements ApplicationListener{ Vars.loadSettings(); Vars.init(); content.createContent(); + content.init(); Core.app.addListener(logic = new Logic()); Core.app.addListener(netServer = new NetServer()); Core.app.addListener(new ServerControl(args)); - - content.init(); } } diff --git a/server/src/io/anuke/mindustry/server/ServerControl.java b/server/src/io/anuke/mindustry/server/ServerControl.java index a1633e103c..e25fd2d8a8 100644 --- a/server/src/io/anuke/mindustry/server/ServerControl.java +++ b/server/src/io/anuke/mindustry/server/ServerControl.java @@ -21,6 +21,8 @@ import io.anuke.mindustry.maps.*; import io.anuke.mindustry.net.Administration.*; import io.anuke.mindustry.net.Net; import io.anuke.mindustry.net.Packets.*; +import io.anuke.mindustry.server.plugin.*; +import io.anuke.mindustry.server.plugin.Plugins.*; import io.anuke.mindustry.type.*; import java.io.*; @@ -40,6 +42,7 @@ public class ServerControl implements ApplicationListener{ private final CommandHandler handler = new CommandHandler(""); private final FileHandle logFolder = Core.files.local("logs/"); + private final Plugins plugins = new Plugins(); private FileHandle currentLogFile; private boolean inExtraRound; @@ -107,6 +110,9 @@ public class ServerControl implements ApplicationListener{ Effects.setScreenShakeProvider((a, b) -> {}); Effects.setEffectProvider((a, b, c, d, e, f) -> {}); + //load plugins + plugins.load(); + registerCommands(); Core.app.post(() -> { @@ -118,7 +124,7 @@ public class ServerControl implements ApplicationListener{ } for(String s : commands){ - Response response = handler.handleMessage(s); + CommandResponse response = handler.handleMessage(s); if(response.type != ResponseType.valid){ err("Invalid command argument sent: '{0}': {1}", s, response.type.name()); err("Argument usage: &lc , "); @@ -128,6 +134,7 @@ public class ServerControl implements ApplicationListener{ }); customMapDirectory.mkdirs(); + pluginDirectory.mkdirs(); Thread thread = new Thread(this::readCommands, "Server Controls"); thread.setDaemon(true); @@ -166,6 +173,13 @@ public class ServerControl implements ApplicationListener{ } }); + //initialize plugins + plugins.each(Plugin::init); + + if(!plugins.all().isEmpty()){ + info("&lc{0} plugins loaded.", plugins.all().size); + } + info("&lcServer loaded. Type &ly'help'&lc for help."); System.out.print("> "); @@ -306,6 +320,31 @@ public class ServerControl implements ApplicationListener{ } }); + handler.register("plugins", "Display all loaded plugins.", arg -> { + if(!plugins.all().isEmpty()){ + info("Maps:"); + for(LoadedPlugin plugin : plugins.all()){ + info(" &ly{0} &lcv{1}", plugin.meta.name, plugin.meta.version); + } + }else{ + info("No plugins found."); + } + info("&lyPlugin directory: &lb&fi{0}", pluginDirectory.file().getAbsoluteFile().toString()); + }); + + handler.register("plugin", "", "Display information about a loaded plugin.", arg -> { + LoadedPlugin plugin = plugins.all().find(p -> p.meta.name.equalsIgnoreCase(arg[0])); + if(plugin != null){ + info("Name: &ly{0}", plugin.meta.name); + info("Version: &ly{0}", plugin.meta.version); + info("Author: &ly{0}", plugin.meta.author); + info("Path: &ly{0}", plugin.jarFile.path()); + info("Description: &ly{0}", plugin.meta.description); + }else{ + info("No plugin with name &ly'{0}'&lg found."); + } + }); + handler.register("say", "", "Send a message to all players.", arg -> { if(!state.is(State.playing)){ err("Not hosting. Host a game first."); @@ -644,12 +683,15 @@ public class ServerControl implements ApplicationListener{ } }); - handler.register("gc", "Trigger a grabage collection. Testing onlu.", arg -> { + handler.register("gc", "Trigger a grabage collection. Testing only.", arg -> { int pre = (int)(Core.app.getJavaHeap() / 1024 / 1024); System.gc(); int post = (int)(Core.app.getJavaHeap() / 1024 / 1024); info("&ly{0}&lg MB collected. Memory usage now at &ly{1}&lg MB.", pre - post, post); }); + + plugins.each(p -> p.registerServerCommands(handler)); + plugins.each(p -> p.registerClientCommands(netServer.clientCommands)); } private void readCommands(){ @@ -662,7 +704,7 @@ public class ServerControl implements ApplicationListener{ } private void handleCommandString(String line){ - Response response = handler.handleMessage(line); + CommandResponse response = handler.handleMessage(line); if(response.type == ResponseType.unknownCommand){ diff --git a/server/src/io/anuke/mindustry/server/plugin/Plugin.java b/server/src/io/anuke/mindustry/server/plugin/Plugin.java index 16a04e99e9..813df9fc3e 100644 --- a/server/src/io/anuke/mindustry/server/plugin/Plugin.java +++ b/server/src/io/anuke/mindustry/server/plugin/Plugin.java @@ -1,5 +1,21 @@ package io.anuke.mindustry.server.plugin; +import io.anuke.arc.util.*; + public abstract class Plugin{ - public abstract void init(); + + /** Called after all plugins have been created and commands have been registered.*/ + public void init(){ + + } + + /** Register any commands to be used on the server side, e.g. from the console. */ + public void registerServerCommands(CommandHandler handler){ + + } + + /** Register any commands to be used on the client side, e.g. sent from an in-game player.. */ + public void registerClientCommands(CommandHandler handler){ + + } } diff --git a/server/src/io/anuke/mindustry/server/plugin/Plugins.java b/server/src/io/anuke/mindustry/server/plugin/Plugins.java index 2aa0641a22..ec4bc79e69 100644 --- a/server/src/io/anuke/mindustry/server/plugin/Plugins.java +++ b/server/src/io/anuke/mindustry/server/plugin/Plugins.java @@ -1,13 +1,82 @@ package io.anuke.mindustry.server.plugin; -import io.anuke.arc.collection.*; +import io.anuke.arc.collection.Array; +import io.anuke.arc.files.*; +import io.anuke.arc.function.*; +import io.anuke.arc.util.*; +import io.anuke.mindustry.io.*; + +import java.lang.reflect.*; +import java.net.*; + +import static io.anuke.mindustry.Vars.pluginDirectory; public class Plugins{ - private Array loaded = new Array<>(); + private Array loaded = new Array<>(); + /** Loads all plugins from the folder, but does call any methods on them.*/ public void load(){ + for(FileHandle file : pluginDirectory.list()){ + if(!file.extension().equals("jar")) continue; + try{ + loaded.add(loadPlugin(file)); + }catch(IllegalArgumentException ignored){ + }catch(Exception e){ + Log.err("Failed to load plugin file {0}. Skipping.", file); + e.printStackTrace(); + } + } } + /** @return all loaded plugins. */ + public Array all(){ + return loaded; + } + /** Iterates through each plugin.*/ + public void each(Consumer cons){ + loaded.each(p -> cons.accept(p.plugin)); + } + + private LoadedPlugin loadPlugin(FileHandle jar) throws Exception{ + FileHandle zip = new ZipFileHandle(jar); + + FileHandle metaf = zip.child("plugin.json"); + if(!metaf.exists()){ + Log.warn("Plugin {0} doesn't have a 'plugin.json' file, skipping.", jar); + throw new IllegalArgumentException(); + } + + PluginMeta meta = JsonIO.read(PluginMeta.class, metaf.readString()); + + URLClassLoader classLoader = (URLClassLoader)ClassLoader.getSystemClassLoader(); + Method method = URLClassLoader.class.getDeclaredMethod("addURL", URL.class); + method.setAccessible(true); + method.invoke(classLoader, jar.file().toURI().toURL()); + + Class main = Class.forName(meta.main); + return new LoadedPlugin(jar, zip, (Plugin)main.newInstance(), meta); + } + + /** Represents a plugin that has been loaded from a jar file.*/ + public static class LoadedPlugin{ + public final FileHandle jarFile; + public final FileHandle zipRoot; + public final Plugin plugin; + public final PluginMeta meta; + + public LoadedPlugin(FileHandle jarFile, FileHandle zipRoot, Plugin plugin, PluginMeta meta){ + this.zipRoot = zipRoot; + this.jarFile = jarFile; + this.plugin = plugin; + this.meta = meta; + } + } + + /** Plugin metadata information.*/ + public static class PluginMeta{ + public String name, author, main, description; + public String version; + } }