From 6cc6e0f6414270feb79caf91240df813597dd92c Mon Sep 17 00:00:00 2001 From: Hugo Manrique Date: Wed, 29 Jul 2020 09:43:16 +0200 Subject: [PATCH] New command API (#330) --- .../api/command/BrigadierCommand.java | 48 ++ .../velocitypowered/api/command/Command.java | 83 ++- .../api/command/CommandInvocation.java | 23 + .../api/command/CommandManager.java | 100 ++-- .../api/command/CommandMeta.java | 57 ++ .../api/command/InvocableCommand.java | 54 ++ .../api/command/RawCommand.java | 104 ++-- .../api/command/SimpleCommand.java | 20 + .../api/command/package-info.java | 2 +- build.gradle | 4 +- .../velocitypowered/proxy/VelocityServer.java | 10 +- .../command/AbstractCommandInvocation.java | 31 ++ .../command/CommandInvocationFactory.java | 23 + .../proxy/command/CommandNodeFactory.java | 97 ++++ .../proxy/command/GlistCommand.java | 110 ---- .../proxy/command/ShutdownCommand.java | 31 -- .../proxy/command/VelocityCommandManager.java | 332 +++++------ .../proxy/command/VelocityCommandMeta.java | 70 +++ .../command/VelocityRawCommandInvocation.java | 37 ++ .../VelocitySimpleCommandInvocation.java | 25 + .../{ => builtin}/BuiltinCommandUtil.java | 2 +- .../proxy/command/builtin/GlistCommand.java | 120 ++++ .../command/{ => builtin}/ServerCommand.java | 24 +- .../command/builtin/ShutdownCommand.java | 25 + .../{ => builtin}/VelocityCommand.java | 63 ++- .../proxy/config/VelocityConfiguration.java | 11 + .../backend/BackendPlaySessionHandler.java | 30 +- .../client/ClientPlaySessionHandler.java | 6 +- .../proxy/console/VelocityConsole.java | 7 +- .../protocol/packet/AvailableCommands.java | 67 ++- .../proxy/util/BrigadierUtils.java | 149 +++++ .../src/main/resources/default-velocity.toml | 3 + .../proxy/command/CommandManagerTests.java | 523 ++++++++++++++++++ .../proxy/command/MockCommandSource.java | 20 + .../proxy/plugin/MockEventManager.java | 11 + .../proxy/plugin/MockPluginManager.java | 38 ++ 36 files changed, 1836 insertions(+), 524 deletions(-) create mode 100644 api/src/main/java/com/velocitypowered/api/command/BrigadierCommand.java create mode 100644 api/src/main/java/com/velocitypowered/api/command/CommandInvocation.java create mode 100644 api/src/main/java/com/velocitypowered/api/command/CommandMeta.java create mode 100644 api/src/main/java/com/velocitypowered/api/command/InvocableCommand.java create mode 100644 api/src/main/java/com/velocitypowered/api/command/SimpleCommand.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/AbstractCommandInvocation.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/CommandInvocationFactory.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/CommandNodeFactory.java delete mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/GlistCommand.java delete mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/ShutdownCommand.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/VelocityRawCommandInvocation.java create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/VelocitySimpleCommandInvocation.java rename proxy/src/main/java/com/velocitypowered/proxy/command/{ => builtin}/BuiltinCommandUtil.java (92%) create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/builtin/GlistCommand.java rename proxy/src/main/java/com/velocitypowered/proxy/command/{ => builtin}/ServerCommand.java (84%) create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ShutdownCommand.java rename proxy/src/main/java/com/velocitypowered/proxy/command/{ => builtin}/VelocityCommand.java (80%) create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/util/BrigadierUtils.java create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/command/CommandManagerTests.java create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/command/MockCommandSource.java create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/plugin/MockEventManager.java create mode 100644 proxy/src/test/java/com/velocitypowered/proxy/plugin/MockPluginManager.java diff --git a/api/src/main/java/com/velocitypowered/api/command/BrigadierCommand.java b/api/src/main/java/com/velocitypowered/api/command/BrigadierCommand.java new file mode 100644 index 000000000..edf4d6b2e --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/BrigadierCommand.java @@ -0,0 +1,48 @@ +package com.velocitypowered.api.command; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.tree.LiteralCommandNode; + +/** + * A command that uses Brigadier for parsing the command and + * providing suggestions to the client. + */ +public final class BrigadierCommand implements Command { + + /** + * Return code used by a {@link com.mojang.brigadier.Command} to indicate + * the command execution should be forwarded to the backend server. + */ + public static final int FORWARD = 0xF6287429; + + private final LiteralCommandNode node; + + /** + * Constructs a {@link BrigadierCommand} from the node returned by + * the given builder. + * + * @param builder the {@link LiteralCommandNode} builder + */ + public BrigadierCommand(final LiteralArgumentBuilder builder) { + this(Preconditions.checkNotNull(builder, "builder").build()); + } + + /** + * Constructs a {@link BrigadierCommand} from the given command node. + * + * @param node the command node + */ + public BrigadierCommand(final LiteralCommandNode node) { + this.node = Preconditions.checkNotNull(node, "node"); + } + + /** + * Returns the literal node for this command. + * + * @return the command node + */ + public LiteralCommandNode getNode() { + return node; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/command/Command.java b/api/src/main/java/com/velocitypowered/api/command/Command.java index 87e650125..4b017b9c3 100644 --- a/api/src/main/java/com/velocitypowered/api/command/Command.java +++ b/api/src/main/java/com/velocitypowered/api/command/Command.java @@ -1,59 +1,90 @@ package com.velocitypowered.api.command; import com.google.common.collect.ImmutableList; +import com.velocitypowered.api.proxy.Player; import java.util.List; import java.util.concurrent.CompletableFuture; import org.checkerframework.checker.nullness.qual.NonNull; /** - * Represents a command that can be executed by a {@link CommandSource}, such as a {@link - * com.velocitypowered.api.proxy.Player} or the console. + * Represents a command that can be executed by a {@link CommandSource} + * such as a {@link Player} or the console. + * + *

Velocity 1.1.0 introduces specialized command subinterfaces to separate + * command parsing concerns. These include, in order of preference: + * + *

    + *
  • {@link BrigadierCommand}, which supports parameterized arguments and + * specialized execution, tab complete suggestions and permission-checking logic. + * + *
  • {@link SimpleCommand}, modelled after the convention popularized by + * Bukkit and BungeeCord. Older classes directly implementing {@link Command} + * are suggested to migrate to this interface. + * + *
  • {@link RawCommand}, useful for bolting on external command frameworks + * to Velocity. + * + *
+ * + *

For this reason, the legacy {@code execute}, {@code suggest} and + * {@code hasPermission} methods are deprecated and will be removed + * in Velocity 2.0.0. We suggest implementing one of the more specific + * subinterfaces instead. + * The legacy methods are executed by a {@link CommandManager} if and only if + * the given command directly implements this interface. */ public interface Command { /** - * Executes the command for the specified {@link CommandSource}. + * Executes the command for the specified source. * - * @param source the source of this command - * @param args the arguments for this command + * @param source the source to execute the command for + * @param args the arguments for the command + * @deprecated see {@link Command} */ - void execute(CommandSource source, String @NonNull [] args); + @Deprecated + default void execute(final CommandSource source, final String @NonNull [] args) { + throw new UnsupportedOperationException(); + } /** - * Provides tab complete suggestions for a command for a specified {@link CommandSource}. + * Provides tab complete suggestions for the specified source. * - * @param source the source to run the command for - * @param currentArgs the current, partial arguments for this command - * @return tab complete suggestions + * @param source the source to execute the command for + * @param currentArgs the partial arguments for the command + * @return the tab complete suggestions + * @deprecated see {@link Command} */ - default List suggest(CommandSource source, String @NonNull [] currentArgs) { + @Deprecated + default List suggest(final CommandSource source, final String @NonNull [] currentArgs) { return ImmutableList.of(); } /** - * Provides tab complete suggestions for a command for a specified {@link CommandSource}. + * Provides tab complete suggestions for the specified source. * - * @param source the source to run the command for - * @param currentArgs the current, partial arguments for this command - * @return tab complete suggestions + * @param source the source to execute the command for + * @param currentArgs the partial arguments for the command + * @return the tab complete suggestions + * @deprecated see {@link Command} */ - default CompletableFuture> suggestAsync(CommandSource source, - String @NonNull [] currentArgs) { + @Deprecated + default CompletableFuture> suggestAsync(final CommandSource source, + String @NonNull [] currentArgs) { return CompletableFuture.completedFuture(suggest(source, currentArgs)); } /** - * Tests to check if the {@code source} has permission to use this command with the provided - * {@code args}. + * Tests to check if the source has permission to perform the command with + * the provided arguments. * - *

If this method returns false, the handling will be forwarded onto - * the players current server.

- * - * @param source the source of the command - * @param args the arguments for this command - * @return whether the source has permission + * @param source the source to execute the command for + * @param args the arguments for the command + * @return {@code true} if the source has permission + * @deprecated see {@link Command} */ - default boolean hasPermission(CommandSource source, String @NonNull [] args) { + @Deprecated + default boolean hasPermission(final CommandSource source, final String @NonNull [] args) { return true; } } diff --git a/api/src/main/java/com/velocitypowered/api/command/CommandInvocation.java b/api/src/main/java/com/velocitypowered/api/command/CommandInvocation.java new file mode 100644 index 000000000..20cc923b0 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/CommandInvocation.java @@ -0,0 +1,23 @@ +package com.velocitypowered.api.command; + +/** + * Provides information related to the possible execution of a {@link Command}. + * + * @param the type of the arguments + */ +public interface CommandInvocation { + + /** + * Returns the source to execute the command for. + * + * @return the command source + */ + CommandSource source(); + + /** + * Returns the arguments after the command alias. + * + * @return the command arguments + */ + T arguments(); +} diff --git a/api/src/main/java/com/velocitypowered/api/command/CommandManager.java b/api/src/main/java/com/velocitypowered/api/command/CommandManager.java index 402167151..b688adc63 100644 --- a/api/src/main/java/com/velocitypowered/api/command/CommandManager.java +++ b/api/src/main/java/com/velocitypowered/api/command/CommandManager.java @@ -1,87 +1,125 @@ package com.velocitypowered.api.command; +import com.velocitypowered.api.event.command.CommandExecuteEvent; import java.util.concurrent.CompletableFuture; /** - * Represents an interface to register a command executor with the proxy. + * Handles the registration and execution of commands. */ public interface CommandManager { /** - * Registers the specified command with the manager with the specified aliases. + * Returns a builder to create a {@link CommandMeta} with + * the given alias. + * + * @param alias the first command alias + * @return a {@link CommandMeta} builder + */ + CommandMeta.Builder metaBuilder(String alias); + + /** + * Returns a builder to create a {@link CommandMeta} for + * the given Brigadier command. + * + * @param command the command + * @return a {@link CommandMeta} builder + */ + CommandMeta.Builder metaBuilder(BrigadierCommand command); + + /** + * Registers the specified command with the specified aliases. * * @param command the command to register - * @param aliases the alias to use + * @param aliases the command aliases * + * @throws IllegalArgumentException if one of the given aliases is already registered * @deprecated This method requires at least one alias, but this is only enforced at runtime. - * Prefer {@link #register(String, Command, String...)} instead. + * Prefer {@link #register(String, Command, String...)} */ @Deprecated void register(Command command, String... aliases); /** - * Registers the specified command with the manager with the specified aliases. + * Registers the specified command with the specified aliases. * - * @param alias the first alias to register + * @param alias the first command alias * @param command the command to register - * @param otherAliases the other aliases to use + * @param otherAliases additional aliases + * @throws IllegalArgumentException if one of the given aliases is already registered + * @deprecated Prefer {@link #register(CommandMeta, Command)} instead. */ + @Deprecated void register(String alias, Command command, String... otherAliases); /** - * Unregisters a command. + * Registers the specified Brigadier command. + * + * @param command the command to register + * @throws IllegalArgumentException if the node alias is already registered + */ + void register(BrigadierCommand command); + + /** + * Registers the specified command with the given metadata. + * + * @param meta the command metadata + * @param command the command to register + * @throws IllegalArgumentException if one of the given aliases is already registered + */ + void register(CommandMeta meta, Command command); + + /** + * Unregisters the specified command alias from the manager, if registered. * * @param alias the command alias to unregister */ void unregister(String alias); /** - * Calls CommandExecuteEvent and attempts to execute a command using the specified {@code cmdLine} - * in a blocking fashion. + * Attempts to execute a command from the given {@code cmdLine} in + * a blocking fashion. * - * @param source the command's source + * @param source the source to execute the command for * @param cmdLine the command to run - * @return true if the command was found and executed, false if it was not - * - * @deprecated This method will block current thread during event call and command execution. - * Prefer {@link #executeAsync(CommandSource, String)} instead. + * @return {@code true} if the command was found and executed + * @deprecated this method blocks the current thread during the event call and + * the command execution. Prefer {@link #executeAsync(CommandSource, String)} + * instead. */ @Deprecated boolean execute(CommandSource source, String cmdLine); /** - * Attempts to execute a command using the specified {@code cmdLine} in a blocking fashion without - * calling CommandExecuteEvent. + * Attempts to execute a command from the given {@code cmdLine} without + * firing a {@link CommandExecuteEvent} in a blocking fashion. * - * @param source the command's source + * @param source the source to execute the command for * @param cmdLine the command to run - * @return true if the command was found and executed, false if it was not - * - * @deprecated This method will block current thread during event and command execution. + * @return {@code true} if the command was found and executed + * @deprecated this methods blocks the current thread during the command execution. * Prefer {@link #executeImmediatelyAsync(CommandSource, String)} instead. */ @Deprecated boolean executeImmediately(CommandSource source, String cmdLine); /** - * Calls CommandExecuteEvent and attempts to execute a command from the specified {@code cmdLine} - * async. + * Attempts to asynchronously execute a command from the given {@code cmdLine}. * - * @param source the command's source + * @param source the source to execute the command for * @param cmdLine the command to run - * @return A future that will be completed with the result of the command execution. - * Can be completed exceptionally if exception was thrown during execution. + * @return a future that may be completed with the result of the command execution. + * Can be completed exceptionally if an exception is thrown during execution. */ CompletableFuture executeAsync(CommandSource source, String cmdLine); /** - * Attempts to execute a command from the specified {@code cmdLine} async - * without calling CommandExecuteEvent. + * Attempts to asynchronously execute a command from the given {@code cmdLine} + * without firing a {@link CommandExecuteEvent}. * - * @param source the command's source + * @param source the source to execute the command for * @param cmdLine the command to run - * @return A future that will be completed with the result of the command execution. - * Can be completed exceptionally if exception was thrown during execution. + * @return a future that may be completed with the result of the command execution. + * Can be completed exceptionally if an exception is thrown during execution. */ CompletableFuture executeImmediatelyAsync(CommandSource source, String cmdLine); } diff --git a/api/src/main/java/com/velocitypowered/api/command/CommandMeta.java b/api/src/main/java/com/velocitypowered/api/command/CommandMeta.java new file mode 100644 index 000000000..65965e0d1 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/CommandMeta.java @@ -0,0 +1,57 @@ +package com.velocitypowered.api.command; + +import com.mojang.brigadier.tree.CommandNode; +import java.util.Collection; + +/** + * Contains metadata for a {@link Command}. + */ +public interface CommandMeta { + + /** + * Returns a non-empty collection containing the case-insensitive aliases + * used to execute the command. + * + * @return the command aliases + */ + Collection getAliases(); + + /** + * Returns a collection containing command nodes that provide additional + * argument metadata and tab-complete suggestions. + * Note some {@link Command} implementations may not support hinting. + * + * @return the hinting command nodes + */ + Collection> getHints(); + + /** + * Provides a fluent interface to create {@link CommandMeta}s. + */ + interface Builder { + + /** + * Specifies additional aliases that can be used to execute the command. + * + * @param aliases the command aliases + * @return this builder, for chaining + */ + Builder aliases(String... aliases); + + /** + * Specifies a command node providing additional argument metadata and + * tab-complete suggestions. + * + * @param node the command node + * @return this builder, for chaining + */ + Builder hint(CommandNode node); + + /** + * Returns a newly-created {@link CommandMeta} based on the specified parameters. + * + * @return the built {@link CommandMeta} + */ + CommandMeta build(); + } +} diff --git a/api/src/main/java/com/velocitypowered/api/command/InvocableCommand.java b/api/src/main/java/com/velocitypowered/api/command/InvocableCommand.java new file mode 100644 index 000000000..80125f25b --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/InvocableCommand.java @@ -0,0 +1,54 @@ +package com.velocitypowered.api.command; + +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * A command that can be executed with arbitrary arguments. + * + * @param the type of the command invocation object + */ +public interface InvocableCommand> extends Command { + + /** + * Executes the command for the specified invocation. + * + * @param invocation the invocation context + */ + void execute(I invocation); + + /** + * Provides tab complete suggestions for the specified invocation. + * + * @param invocation the invocation context + * @return the tab complete suggestions + */ + default List suggest(final I invocation) { + return ImmutableList.of(); + } + + /** + * Provides tab complete suggestions for the specified invocation. + * + * @param invocation the invocation context + * @return the tab complete suggestions + * @implSpec defaults to wrapping the value returned by {@link #suggest(CommandInvocation)} + */ + default CompletableFuture> suggestAsync(final I invocation) { + return CompletableFuture.completedFuture(suggest(invocation)); + } + + /** + * Tests to check if the source has permission to perform the specified invocation. + * + *

If the method returns {@code false}, the handling is forwarded onto + * the players current server. + * + * @param invocation the invocation context + * @return {@code true} if the source has permission + */ + default boolean hasPermission(final I invocation) { + return true; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/command/RawCommand.java b/api/src/main/java/com/velocitypowered/api/command/RawCommand.java index 431140c6f..4a2dd58bc 100644 --- a/api/src/main/java/com/velocitypowered/api/command/RawCommand.java +++ b/api/src/main/java/com/velocitypowered/api/command/RawCommand.java @@ -1,67 +1,97 @@ package com.velocitypowered.api.command; -import com.google.common.collect.ImmutableList; import java.util.List; import java.util.concurrent.CompletableFuture; import org.checkerframework.checker.nullness.qual.NonNull; /** - * A specialized sub-interface of {@code Command} which indicates that the proxy should pass a - * raw command to the command. This is useful for bolting on external command frameworks to - * Velocity. + * A specialized sub-interface of {@code Command} which indicates the proxy should pass + * the command and its arguments directly without further processing. + * This is useful for bolting on external command frameworks to Velocity. */ -public interface RawCommand extends Command { - /** - * Executes the command for the specified {@link CommandSource}. - * - * @param source the source of this command - * @param commandLine the full command line after the command name - */ - void execute(CommandSource source, String commandLine); +public interface RawCommand extends InvocableCommand { - default void execute(CommandSource source, String @NonNull [] args) { + /** + * Executes the command for the specified source. + * + * @param source the source to execute the command for + * @param cmdLine the arguments for the command + * @deprecated see {@link Command} + */ + @Deprecated + default void execute(final CommandSource source, final String cmdLine) { + throw new UnsupportedOperationException(); + } + + @Deprecated + @Override + default void execute(final CommandSource source, final String @NonNull [] args) { execute(source, String.join(" ", args)); } - /** - * Provides tab complete suggestions for a command for a specified {@link CommandSource}. - * - * @param source the source to run the command for - * @param currentLine the current, partial command line for this command - * @return tab complete suggestions - */ - default CompletableFuture> suggest(CommandSource source, String currentLine) { - return CompletableFuture.completedFuture(ImmutableList.of()); + @Override + default void execute(Invocation invocation) { + // Guarantees ABI compatibility } + /** + * Provides tab complete suggestions for the specified source. + * + * @param source the source to execute the command for + * @param currentArgs the partial arguments for the command + * @return the tab complete suggestions + * @deprecated see {@link Command} + */ + @Deprecated + default CompletableFuture> suggest(final CommandSource source, + final String currentArgs) { + // This method even has an inconsistent return type + throw new UnsupportedOperationException(); + } + + @Deprecated @Override - default List suggest(CommandSource source, String @NonNull [] currentArgs) { + default List suggest(final CommandSource source, final String @NonNull [] currentArgs) { return suggestAsync(source, currentArgs).join(); } + @Deprecated @Override - default CompletableFuture> suggestAsync(CommandSource source, - String @NonNull [] currentArgs) { + default CompletableFuture> suggestAsync(final CommandSource source, + final String @NonNull [] currentArgs) { return suggest(source, String.join(" ", currentArgs)); } + /** + * Tests to check if the source has permission to perform the command with + * the provided arguments. + * + * @param source the source to execute the command for + * @param cmdLine the arguments for the command + * @return {@code true} if the source has permission + * @deprecated see {@link Command} + */ + @Deprecated + default boolean hasPermission(final CommandSource source, final String cmdLine) { + throw new UnsupportedOperationException(); + } + + @Deprecated @Override - default boolean hasPermission(CommandSource source, String @NonNull [] args) { + default boolean hasPermission(final CommandSource source, final String @NonNull [] args) { return hasPermission(source, String.join(" ", args)); } /** - * Tests to check if the {@code source} has permission to use this command with the provided - * {@code args}. - * - *

If this method returns false, the handling will be forwarded onto - * the players current server.

- * - * @param source the source of the command - * @param commandLine the arguments for this command - * @return whether the source has permission + * Contains the invocation data for a raw command. */ - default boolean hasPermission(CommandSource source, String commandLine) { - return true; + interface Invocation extends CommandInvocation { + + /** + * Returns the used alias to execute the command. + * + * @return the used command alias + */ + String alias(); } } diff --git a/api/src/main/java/com/velocitypowered/api/command/SimpleCommand.java b/api/src/main/java/com/velocitypowered/api/command/SimpleCommand.java new file mode 100644 index 000000000..1150c1cf1 --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/command/SimpleCommand.java @@ -0,0 +1,20 @@ +package com.velocitypowered.api.command; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * A simple command, modelled after the convention popularized by + * Bukkit and BungeeCord. + * + *

Prefer using {@link BrigadierCommand}, which is also + * backwards-compatible with older clients. + */ +public interface SimpleCommand extends InvocableCommand { + + /** + * Contains the invocation data for a simple command. + */ + interface Invocation extends CommandInvocation { + + } +} diff --git a/api/src/main/java/com/velocitypowered/api/command/package-info.java b/api/src/main/java/com/velocitypowered/api/command/package-info.java index 1ce9d9c30..9d323f55a 100644 --- a/api/src/main/java/com/velocitypowered/api/command/package-info.java +++ b/api/src/main/java/com/velocitypowered/api/command/package-info.java @@ -1,4 +1,4 @@ /** - * Provides a simple command framework. + * Provides a command framework. */ package com.velocitypowered.api.command; \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3a1bf1585..fad0011b1 100644 --- a/build.gradle +++ b/build.gradle @@ -47,12 +47,12 @@ allprojects { repositories { mavenLocal() mavenCentral() - + // for kyoripowered dependencies maven { url 'https://oss.sonatype.org/content/groups/public/' } - + // Brigadier maven { url "https://libraries.minecraft.net" diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 07e6dee11..4372e5604 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -21,11 +21,11 @@ import com.velocitypowered.api.util.ProxyVersion; import com.velocitypowered.api.util.bossbar.BossBar; import com.velocitypowered.api.util.bossbar.BossBarColor; import com.velocitypowered.api.util.bossbar.BossBarOverlay; -import com.velocitypowered.proxy.command.GlistCommand; -import com.velocitypowered.proxy.command.ServerCommand; -import com.velocitypowered.proxy.command.ShutdownCommand; -import com.velocitypowered.proxy.command.VelocityCommand; import com.velocitypowered.proxy.command.VelocityCommandManager; +import com.velocitypowered.proxy.command.builtin.GlistCommand; +import com.velocitypowered.proxy.command.builtin.ServerCommand; +import com.velocitypowered.proxy.command.builtin.ShutdownCommand; +import com.velocitypowered.proxy.command.builtin.VelocityCommand; import com.velocitypowered.proxy.config.VelocityConfiguration; import com.velocitypowered.proxy.connection.client.ConnectedPlayer; import com.velocitypowered.proxy.console.VelocityConsole; @@ -194,7 +194,7 @@ public class VelocityServer implements ProxyServer, ForwardingAudience { commandManager.register("velocity", new VelocityCommand(this)); commandManager.register("server", new ServerCommand(this)); commandManager.register("shutdown", new ShutdownCommand(this),"end"); - commandManager.register("glist", new GlistCommand(this)); + new GlistCommand(this).register(); try { Path configPath = Paths.get("velocity.toml"); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/AbstractCommandInvocation.java b/proxy/src/main/java/com/velocitypowered/proxy/command/AbstractCommandInvocation.java new file mode 100644 index 000000000..1a1d30008 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/AbstractCommandInvocation.java @@ -0,0 +1,31 @@ +package com.velocitypowered.proxy.command; + +import com.google.common.base.Preconditions; +import com.velocitypowered.api.command.CommandInvocation; +import com.velocitypowered.api.command.CommandSource; + +/** + * Abstract base class for {@link CommandInvocation} implementations. + * + * @param the type of the arguments + */ +abstract class AbstractCommandInvocation implements CommandInvocation { + + private final CommandSource source; + private final T arguments; + + protected AbstractCommandInvocation(final CommandSource source, final T arguments) { + this.source = Preconditions.checkNotNull(source, "source"); + this.arguments = Preconditions.checkNotNull(arguments, "arguments"); + } + + @Override + public CommandSource source() { + return source; + } + + @Override + public T arguments() { + return arguments; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/CommandInvocationFactory.java b/proxy/src/main/java/com/velocitypowered/proxy/command/CommandInvocationFactory.java new file mode 100644 index 000000000..febfce29b --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/CommandInvocationFactory.java @@ -0,0 +1,23 @@ +package com.velocitypowered.proxy.command; + +import com.mojang.brigadier.context.CommandContext; +import com.velocitypowered.api.command.CommandInvocation; +import com.velocitypowered.api.command.CommandSource; + +/** + * Creates command invocation contexts for the given {@link CommandSource} + * and command line arguments. + * + * @param the type of the built invocation + */ +@FunctionalInterface +public interface CommandInvocationFactory> { + + /** + * Returns an invocation context for the given Brigadier context. + * + * @param context the command context + * @return the built invocation context + */ + I create(final CommandContext context); +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/CommandNodeFactory.java b/proxy/src/main/java/com/velocitypowered/proxy/command/CommandNodeFactory.java new file mode 100644 index 000000000..c55c5c1ce --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/CommandNodeFactory.java @@ -0,0 +1,97 @@ +package com.velocitypowered.proxy.command; + +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.velocitypowered.api.command.BrigadierCommand; +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandInvocation; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.InvocableCommand; +import com.velocitypowered.api.command.RawCommand; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.proxy.util.BrigadierUtils; + +@FunctionalInterface +public interface CommandNodeFactory { + + InvocableCommandNodeFactory SIMPLE = + new InvocableCommandNodeFactory<>() { + @Override + protected SimpleCommand.Invocation createInvocation( + final CommandContext context) { + return VelocitySimpleCommandInvocation.FACTORY.create(context); + } + }; + + InvocableCommandNodeFactory RAW = + new InvocableCommandNodeFactory<>() { + @Override + protected RawCommand.Invocation createInvocation( + final CommandContext context) { + return VelocityRawCommandInvocation.FACTORY.create(context); + } + }; + + CommandNodeFactory FALLBACK = (alias, command) -> + BrigadierUtils.buildRawArgumentsLiteral(alias, + context -> { + CommandSource source = context.getSource(); + String[] args = BrigadierUtils.getSplitArguments(context); + + if (!command.hasPermission(source, args)) { + return BrigadierCommand.FORWARD; + } + command.execute(source, args); + return 1; + }, + (context, builder) -> { + String[] args = BrigadierUtils.getSplitArguments(context); + return command.suggestAsync(context.getSource(), args).thenApply(values -> { + for (String value : values) { + builder.suggest(value); + } + + return builder.build(); + }); + }); + + /** + * Returns a Brigadier node for the execution of the given command. + * + * @param alias the command alias + * @param command the command to execute + * @return the command node + */ + LiteralCommandNode create(String alias, T command); + + abstract class InvocableCommandNodeFactory> + implements CommandNodeFactory> { + + @Override + public LiteralCommandNode create( + final String alias, final InvocableCommand command) { + return BrigadierUtils.buildRawArgumentsLiteral(alias, + context -> { + I invocation = createInvocation(context); + if (!command.hasPermission(invocation)) { + return BrigadierCommand.FORWARD; + } + command.execute(invocation); + return 1; + }, + (context, builder) -> { + I invocation = createInvocation(context); + + return command.suggestAsync(invocation).thenApply(values -> { + for (String value : values) { + builder.suggest(value); + } + + return builder.build(); + }); + }); + } + + protected abstract I createInvocation(final CommandContext context); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/GlistCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/GlistCommand.java deleted file mode 100644 index d63d19b5b..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/GlistCommand.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.velocitypowered.proxy.command; - -import com.google.common.collect.ImmutableList; -import com.velocitypowered.api.command.Command; -import com.velocitypowered.api.command.CommandSource; -import com.velocitypowered.api.permission.Tristate; -import com.velocitypowered.api.proxy.Player; -import com.velocitypowered.api.proxy.ProxyServer; -import com.velocitypowered.api.proxy.server.RegisteredServer; -import java.util.List; -import java.util.Optional; -import net.kyori.adventure.text.TextComponent; -import net.kyori.adventure.text.format.NamedTextColor; -import org.checkerframework.checker.nullness.qual.NonNull; - -public class GlistCommand implements Command { - - private final ProxyServer server; - - public GlistCommand(ProxyServer server) { - this.server = server; - } - - @Override - public void execute(CommandSource source, String @NonNull [] args) { - if (args.length == 0) { - sendTotalProxyCount(source); - source.sendMessage( - TextComponent.builder("To view all players on servers, use ", NamedTextColor.YELLOW) - .append("/glist all", NamedTextColor.DARK_AQUA) - .append(".", NamedTextColor.YELLOW) - .build()); - } else if (args.length == 1) { - String arg = args[0]; - if (arg.equalsIgnoreCase("all")) { - for (RegisteredServer server : BuiltinCommandUtil.sortedServerList(server)) { - sendServerPlayers(source, server, true); - } - sendTotalProxyCount(source); - } else { - Optional registeredServer = server.getServer(arg); - if (!registeredServer.isPresent()) { - source.sendMessage( - TextComponent.of("Server " + arg + " doesn't exist.", NamedTextColor.RED)); - return; - } - sendServerPlayers(source, registeredServer.get(), false); - } - } else { - source.sendMessage(TextComponent.of("Too many arguments.", NamedTextColor.RED)); - } - } - - private void sendTotalProxyCount(CommandSource target) { - target.sendMessage(TextComponent.builder("There are ", NamedTextColor.YELLOW) - .append(Integer.toString(server.getAllPlayers().size()), NamedTextColor.GREEN) - .append(" player(s) online.", NamedTextColor.YELLOW) - .build()); - } - - private void sendServerPlayers(CommandSource target, RegisteredServer server, boolean fromAll) { - List onServer = ImmutableList.copyOf(server.getPlayersConnected()); - if (onServer.isEmpty() && fromAll) { - return; - } - - TextComponent.Builder builder = TextComponent.builder() - .append(TextComponent.of("[" + server.getServerInfo().getName() + "] ", - NamedTextColor.DARK_AQUA)) - .append("(" + onServer.size() + ")", NamedTextColor.GRAY) - .append(": ") - .resetStyle(); - - for (int i = 0; i < onServer.size(); i++) { - Player player = onServer.get(i); - builder.append(player.getUsername()); - - if (i + 1 < onServer.size()) { - builder.append(", "); - } - } - - target.sendMessage(builder.build()); - } - - @Override - public List suggest(CommandSource source, String @NonNull [] currentArgs) { - ImmutableList.Builder options = ImmutableList.builder(); - for (RegisteredServer server : server.getAllServers()) { - options.add(server.getServerInfo().getName()); - } - options.add("all"); - - switch (currentArgs.length) { - case 0: - return options.build(); - case 1: - return options.build().stream() - .filter(o -> o.regionMatches(true, 0, currentArgs[0], 0, currentArgs[0].length())) - .collect(ImmutableList.toImmutableList()); - default: - return ImmutableList.of(); - } - } - - @Override - public boolean hasPermission(CommandSource source, String @NonNull [] args) { - return source.getPermissionValue("velocity.command.glist") == Tristate.TRUE; - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/ShutdownCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/ShutdownCommand.java deleted file mode 100644 index 3667a82a9..000000000 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/ShutdownCommand.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.velocitypowered.proxy.command; - -import com.velocitypowered.api.command.Command; -import com.velocitypowered.api.command.CommandSource; -import com.velocitypowered.proxy.VelocityServer; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; -import org.checkerframework.checker.nullness.qual.NonNull; - -public class ShutdownCommand implements Command { - - private final VelocityServer server; - - public ShutdownCommand(VelocityServer server) { - this.server = server; - } - - @Override - public void execute(CommandSource source, String @NonNull [] args) { - if (args.length == 0) { - server.shutdown(true); - } else { - String reason = String.join(" ", args); - server.shutdown(true, LegacyComponentSerializer.legacy('&').deserialize(reason)); - } - } - - @Override - public boolean hasPermission(CommandSource source, String @NonNull [] args) { - return source == server.getConsoleCommandSource(); - } -} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java index 86d2f75f9..368164e05 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandManager.java @@ -1,273 +1,229 @@ package com.velocitypowered.proxy.command; import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.ParseResults; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.Suggestion; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.velocitypowered.api.command.BrigadierCommand; import com.velocitypowered.api.command.Command; import com.velocitypowered.api.command.CommandManager; +import com.velocitypowered.api.command.CommandMeta; import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.command.RawCommand; +import com.velocitypowered.api.command.SimpleCommand; import com.velocitypowered.api.event.command.CommandExecuteEvent; import com.velocitypowered.api.event.command.CommandExecuteEvent.CommandResult; import com.velocitypowered.proxy.plugin.VelocityEventManager; +import com.velocitypowered.proxy.util.BrigadierUtils; import java.util.Arrays; -import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Locale; -import java.util.Map; -import java.util.Set; import java.util.concurrent.CompletableFuture; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; public class VelocityCommandManager implements CommandManager { - private final Map commands = new HashMap<>(); + private final CommandDispatcher dispatcher; private final VelocityEventManager eventManager; - public VelocityCommandManager(VelocityEventManager eventManager) { - this.eventManager = eventManager; + public VelocityCommandManager(final VelocityEventManager eventManager) { + this.eventManager = Preconditions.checkNotNull(eventManager); + this.dispatcher = new CommandDispatcher<>(); + } + + @Override + public CommandMeta.Builder metaBuilder(final String alias) { + Preconditions.checkNotNull(alias, "alias"); + return new VelocityCommandMeta.Builder(alias); + } + + @Override + public CommandMeta.Builder metaBuilder(final BrigadierCommand command) { + Preconditions.checkNotNull(command, "command"); + return new VelocityCommandMeta.Builder(command.getNode().getName()); } @Override - @Deprecated public void register(final Command command, final String... aliases) { Preconditions.checkArgument(aliases.length > 0, "no aliases provided"); register(aliases[0], command, Arrays.copyOfRange(aliases, 1, aliases.length)); } @Override - public void register(String alias, Command command, String... otherAliases) { + public void register(final String alias, final Command command, final String... otherAliases) { Preconditions.checkNotNull(alias, "alias"); + Preconditions.checkNotNull(command, "command"); Preconditions.checkNotNull(otherAliases, "otherAliases"); - Preconditions.checkNotNull(command, "executor"); + Preconditions.checkArgument(!hasCommand(alias), "alias already registered"); + register(metaBuilder(alias).aliases(otherAliases).build(), command); + } - RawCommand rawCmd = RegularCommandWrapper.wrap(command); - this.commands.put(alias.toLowerCase(Locale.ENGLISH), rawCmd); + @Override + public void register(final BrigadierCommand command) { + Preconditions.checkNotNull(command, "command"); + register(metaBuilder(command).build(), command); + } - for (int i = 0, length = otherAliases.length; i < length; i++) { - final String alias1 = otherAliases[i]; - Preconditions.checkNotNull(alias1, "alias at index %s", i + 1); - this.commands.put(alias1.toLowerCase(Locale.ENGLISH), rawCmd); + @Override + public void register(final CommandMeta meta, final Command command) { + Preconditions.checkNotNull(meta, "meta"); + Preconditions.checkNotNull(command, "command"); + + Iterator aliasIterator = meta.getAliases().iterator(); + String alias = aliasIterator.next(); + + LiteralCommandNode node = null; + if (command instanceof BrigadierCommand) { + node = ((BrigadierCommand) command).getNode(); + } else if (command instanceof SimpleCommand) { + node = CommandNodeFactory.SIMPLE.create(alias, (SimpleCommand) command); + } else if (command instanceof RawCommand) { + // This ugly hack will be removed in Velocity 2.0. Most if not all plugins + // have side-effect free #suggest methods. We rely on the newer RawCommand + // throwing UOE. + RawCommand asRaw = (RawCommand) command; + try { + asRaw.suggest(null, new String[0]); + } catch (final UnsupportedOperationException e) { + node = CommandNodeFactory.RAW.create(alias, asRaw); + } catch (final Exception ignored) { + // The implementation probably relies on a non-null source + } + } + if (node == null) { + node = CommandNodeFactory.FALLBACK.create(alias, command); + } + + if (!(command instanceof BrigadierCommand)) { + for (CommandNode hint : meta.getHints()) { + node.addChild(BrigadierUtils.wrapForHinting(hint, node.getCommand())); + } + } + + dispatcher.getRoot().addChild(node); + while (aliasIterator.hasNext()) { + String otherAlias = aliasIterator.next(); + Preconditions.checkArgument(!hasCommand(otherAlias), + "alias %s is already registered", otherAlias); + dispatcher.getRoot().addChild(BrigadierUtils.buildRedirect(otherAlias, node)); } } @Override public void unregister(final String alias) { - Preconditions.checkNotNull(alias, "name"); - this.commands.remove(alias.toLowerCase(Locale.ENGLISH)); + Preconditions.checkNotNull(alias, "alias"); + CommandNode node = + dispatcher.getRoot().getChild(alias.toLowerCase(Locale.ENGLISH)); + if (node != null) { + dispatcher.getRoot().getChildren().remove(node); + } } /** - * Calls CommandExecuteEvent. - * @param source the command's source - * @param cmd the command - * @return CompletableFuture of event + * Fires a {@link CommandExecuteEvent}. + * + * @param source the source to execute the command for + * @param cmdLine the command to execute + * @return the {@link CompletableFuture} of the event */ - public CompletableFuture callCommandEvent(CommandSource source, String cmd) { + public CompletableFuture callCommandEvent(final CommandSource source, + final String cmdLine) { Preconditions.checkNotNull(source, "source"); - Preconditions.checkNotNull(cmd, "cmd"); - return eventManager.fire(new CommandExecuteEvent(source, cmd)); + Preconditions.checkNotNull(cmdLine, "cmdLine"); + return eventManager.fire(new CommandExecuteEvent(source, cmdLine)); } @Override - public boolean execute(CommandSource source, String cmdLine) { - Preconditions.checkNotNull(source, "source"); - Preconditions.checkNotNull(cmdLine, "cmdLine"); - - CommandExecuteEvent event = callCommandEvent(source, cmdLine).join(); - CommandResult commandResult = event.getResult(); - if (commandResult.isForwardToServer() || !commandResult.isAllowed()) { - return false; - } - cmdLine = commandResult.getCommand().orElse(event.getCommand()); - - return executeImmediately(source, cmdLine); + public boolean execute(final CommandSource source, final String cmdLine) { + return executeAsync(source, cmdLine).join(); } @Override - public boolean executeImmediately(CommandSource source, String cmdLine) { + public boolean executeImmediately(final CommandSource source, final String cmdLine) { Preconditions.checkNotNull(source, "source"); Preconditions.checkNotNull(cmdLine, "cmdLine"); - String alias = cmdLine; - String args = ""; - int firstSpace = cmdLine.indexOf(' '); - if (firstSpace != -1) { - alias = cmdLine.substring(0, firstSpace); - args = cmdLine.substring(firstSpace); - } - RawCommand command = commands.get(alias.toLowerCase(Locale.ENGLISH)); - if (command == null) { - return false; - } - + ParseResults results = parse(cmdLine, source, true); try { - if (!command.hasPermission(source, args)) { - return false; + return dispatcher.execute(results) != BrigadierCommand.FORWARD; + } catch (final CommandSyntaxException e) { + boolean isSyntaxError = !e.getType().equals( + CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownCommand()); + if (isSyntaxError) { + source.sendMessage(TextComponent.of(e.getMessage(), NamedTextColor.RED)); } - command.execute(source, args); - return true; - } catch (Exception e) { + return false; + } catch (final Exception e) { throw new RuntimeException("Unable to invoke command " + cmdLine + " for " + source, e); } } - @Override - public CompletableFuture executeAsync(CommandSource source, String cmdLine) { - CompletableFuture result = new CompletableFuture<>(); - callCommandEvent(source, cmdLine).thenAccept(event -> { + public CompletableFuture executeAsync(final CommandSource source, final String cmdLine) { + Preconditions.checkNotNull(source, "source"); + Preconditions.checkNotNull(cmdLine, "cmdLine"); + + return callCommandEvent(source, cmdLine).thenApply(event -> { CommandResult commandResult = event.getResult(); if (commandResult.isForwardToServer() || !commandResult.isAllowed()) { - result.complete(false); - } - String command = commandResult.getCommand().orElse(event.getCommand()); - try { - result.complete(executeImmediately(source, command)); - } catch (Exception e) { - result.completeExceptionally(e); + return false; } + return executeImmediately(source, commandResult.getCommand().orElse(event.getCommand())); }); - return result; } @Override - public CompletableFuture executeImmediatelyAsync(CommandSource source, String cmdLine) { + public CompletableFuture executeImmediatelyAsync( + final CommandSource source, final String cmdLine) { Preconditions.checkNotNull(source, "source"); Preconditions.checkNotNull(cmdLine, "cmdLine"); - CompletableFuture result = new CompletableFuture<>(); - eventManager.getService().execute(() -> { - try { - result.complete(executeImmediately(source, cmdLine)); - } catch (Exception e) { - result.completeExceptionally(e); - } - }); - return result; - } - public boolean hasCommand(String command) { - return commands.containsKey(command); - } - - public Set getAllRegisteredCommands() { - return ImmutableSet.copyOf(commands.keySet()); + return CompletableFuture.supplyAsync( + () -> executeImmediately(source, cmdLine), eventManager.getService()); } /** - * Offer suggestions to fill in the command. - * @param source the source for the command + * Returns suggestions to fill in the given command. + * + * @param source the source to execute the command for * @param cmdLine the partially completed command - * @return a {@link CompletableFuture} eventually completed with a {@link List}, possibly empty + * @return a {@link CompletableFuture} eventually completed with a {@link List}, + * possibly empty */ - public CompletableFuture> offerSuggestions(CommandSource source, String cmdLine) { + public CompletableFuture> offerSuggestions(final CommandSource source, + final String cmdLine) { Preconditions.checkNotNull(source, "source"); Preconditions.checkNotNull(cmdLine, "cmdLine"); - int firstSpace = cmdLine.indexOf(' '); - if (firstSpace == -1) { - // Offer to fill in commands. - ImmutableList.Builder availableCommands = ImmutableList.builder(); - for (Map.Entry entry : commands.entrySet()) { - if (entry.getKey().regionMatches(true, 0, cmdLine, 0, cmdLine.length()) - && entry.getValue().hasPermission(source, new String[0])) { - availableCommands.add("/" + entry.getKey()); - } - } - return CompletableFuture.completedFuture(availableCommands.build()); - } + ParseResults parse = parse(cmdLine, source, false); + return dispatcher.getCompletionSuggestions(parse) + .thenApply(suggestions -> Lists.transform(suggestions.getList(), Suggestion::getText)); + } - String alias = cmdLine.substring(0, firstSpace); - String args = cmdLine.substring(firstSpace); - RawCommand command = commands.get(alias.toLowerCase(Locale.ENGLISH)); - if (command == null) { - // No such command, so we can't offer any tab complete suggestions. - return CompletableFuture.completedFuture(ImmutableList.of()); - } - - try { - if (!command.hasPermission(source, args)) { - return CompletableFuture.completedFuture(ImmutableList.of()); - } - return command.suggest(source, args) - .thenApply(ImmutableList::copyOf); - } catch (Exception e) { - throw new RuntimeException( - "Unable to invoke suggestions for command " + cmdLine + " for " + source, e); - } + private ParseResults parse(final String cmdLine, final CommandSource source, + final boolean trim) { + String normalized = BrigadierUtils.normalizeInput(cmdLine, trim); + return dispatcher.parse(normalized, source); } /** - * Determines if the {@code source} has permission to run the {@code cmdLine}. - * @param source the source to check against - * @param cmdLine the command to run - * @return {@code true} if the command can be run, otherwise {@code false} + * Returns whether the given alias is registered on this manager. + * + * @param alias the command alias to check + * @return {@code true} if the alias is registered */ - public boolean hasPermission(CommandSource source, String cmdLine) { - Preconditions.checkNotNull(source, "source"); - Preconditions.checkNotNull(cmdLine, "cmdLine"); - - String alias = cmdLine; - String args = ""; - int firstSpace = cmdLine.indexOf(' '); - if (firstSpace != -1) { - alias = cmdLine.substring(0, firstSpace); - args = cmdLine.substring(firstSpace).trim(); - } - RawCommand command = commands.get(alias.toLowerCase(Locale.ENGLISH)); - if (command == null) { - return false; - } - - try { - return command.hasPermission(source, args); - } catch (Exception e) { - throw new RuntimeException( - "Unable to invoke suggestions for command " + alias + " for " + source, e); - } + public boolean hasCommand(final String alias) { + Preconditions.checkNotNull(alias, "alias"); + return dispatcher.getRoot().getChild(alias.toLowerCase(Locale.ENGLISH)) != null; } - private static class RegularCommandWrapper implements RawCommand { - - private final Command delegate; - - private RegularCommandWrapper(Command delegate) { - this.delegate = delegate; - } - - private static String[] split(String line) { - if (line.isEmpty()) { - return new String[0]; - } - - String[] trimmed = line.trim().split(" ", -1); - if (line.endsWith(" ") && !line.trim().isEmpty()) { - // To work around a 1.13+ specific bug we have to inject a space at the end of the arguments - trimmed = Arrays.copyOf(trimmed, trimmed.length + 1); - trimmed[trimmed.length - 1] = ""; - } - return trimmed; - } - - @Override - public void execute(CommandSource source, String commandLine) { - delegate.execute(source, split(commandLine)); - } - - @Override - public CompletableFuture> suggest(CommandSource source, String currentLine) { - return delegate.suggestAsync(source, split(currentLine)); - } - - @Override - public boolean hasPermission(CommandSource source, String commandLine) { - return delegate.hasPermission(source, split(commandLine)); - } - - static RawCommand wrap(Command command) { - if (command instanceof RawCommand) { - return (RawCommand) command; - } - return new RegularCommandWrapper(command); - } + public CommandDispatcher getDispatcher() { + return dispatcher; } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java new file mode 100644 index 000000000..b7a9715c4 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommandMeta.java @@ -0,0 +1,70 @@ +package com.velocitypowered.proxy.command; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.mojang.brigadier.tree.CommandNode; +import com.velocitypowered.api.command.CommandMeta; +import com.velocitypowered.api.command.CommandSource; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +final class VelocityCommandMeta implements CommandMeta { + + static final class Builder implements CommandMeta.Builder { + + private final ImmutableSet.Builder aliases; + private final ImmutableList.Builder> hints; + + public Builder(final String alias) { + Preconditions.checkNotNull(alias, "alias"); + this.aliases = ImmutableSet.builder() + .add(alias.toLowerCase(Locale.ENGLISH)); + this.hints = ImmutableList.builder(); + } + + @Override + public CommandMeta.Builder aliases(final String... aliases) { + Preconditions.checkNotNull(aliases, "aliases"); + for (int i = 0, length = aliases.length; i < length; i++) { + final String alias1 = aliases[i]; + Preconditions.checkNotNull(alias1, "alias at index %s", i); + this.aliases.add(alias1.toLowerCase(Locale.ENGLISH)); + } + return this; + } + + @Override + public CommandMeta.Builder hint(final CommandNode node) { + Preconditions.checkNotNull(node, "node"); + hints.add(node); + return this; + } + + @Override + public CommandMeta build() { + return new VelocityCommandMeta(aliases.build(), hints.build()); + } + } + + private final Set aliases; + private final List> hints; + + private VelocityCommandMeta( + final Set aliases, final List> hints) { + this.aliases = aliases; + this.hints = hints; + } + + @Override + public Collection getAliases() { + return aliases; + } + + @Override + public Collection> getHints() { + return hints; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityRawCommandInvocation.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityRawCommandInvocation.java new file mode 100644 index 000000000..652e71c99 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityRawCommandInvocation.java @@ -0,0 +1,37 @@ +package com.velocitypowered.proxy.command; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.context.CommandContext; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.RawCommand; +import com.velocitypowered.proxy.util.BrigadierUtils; + +final class VelocityRawCommandInvocation extends AbstractCommandInvocation + implements RawCommand.Invocation { + + static final Factory FACTORY = new Factory(); + + static class Factory implements CommandInvocationFactory { + + @Override + public RawCommand.Invocation create(final CommandContext context) { + return new VelocityRawCommandInvocation( + context.getSource(), + BrigadierUtils.getAlias(context), + BrigadierUtils.getRawArguments(context)); + } + } + + private final String alias; + + private VelocityRawCommandInvocation(final CommandSource source, + final String alias, final String arguments) { + super(source, arguments); + this.alias = Preconditions.checkNotNull(alias); + } + + @Override + public String alias() { + return alias; + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocitySimpleCommandInvocation.java b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocitySimpleCommandInvocation.java new file mode 100644 index 000000000..584ca092c --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/VelocitySimpleCommandInvocation.java @@ -0,0 +1,25 @@ +package com.velocitypowered.proxy.command; + +import com.mojang.brigadier.context.CommandContext; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.proxy.util.BrigadierUtils; + +final class VelocitySimpleCommandInvocation extends AbstractCommandInvocation + implements SimpleCommand.Invocation { + + static final Factory FACTORY = new Factory(); + + static class Factory implements CommandInvocationFactory { + + @Override + public SimpleCommand.Invocation create(final CommandContext context) { + final String[] arguments = BrigadierUtils.getSplitArguments(context); + return new VelocitySimpleCommandInvocation(context.getSource(), arguments); + } + } + + VelocitySimpleCommandInvocation(final CommandSource source, final String[] arguments) { + super(source, arguments); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/BuiltinCommandUtil.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/BuiltinCommandUtil.java similarity index 92% rename from proxy/src/main/java/com/velocitypowered/proxy/command/BuiltinCommandUtil.java rename to proxy/src/main/java/com/velocitypowered/proxy/command/builtin/BuiltinCommandUtil.java index 4d5ee1414..14868b84d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/BuiltinCommandUtil.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/BuiltinCommandUtil.java @@ -1,4 +1,4 @@ -package com.velocitypowered.proxy.command; +package com.velocitypowered.proxy.command.builtin; import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.server.RegisteredServer; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/GlistCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/GlistCommand.java new file mode 100644 index 000000000..207ee028f --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/GlistCommand.java @@ -0,0 +1,120 @@ +package com.velocitypowered.proxy.command.builtin; + +import static com.mojang.brigadier.arguments.StringArgumentType.getString; + +import com.google.common.collect.ImmutableList; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.tree.ArgumentCommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.velocitypowered.api.command.BrigadierCommand; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.permission.Tristate; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import java.util.List; +import java.util.Optional; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; + +public class GlistCommand { + + private static final String SERVER_ARG = "server"; + + private final ProxyServer server; + + public GlistCommand(ProxyServer server) { + this.server = server; + } + + /** + * Registers this command. + */ + public void register() { + LiteralCommandNode totalNode = LiteralArgumentBuilder + .literal("glist") + .requires(source -> + source.getPermissionValue("velocity.command.glist") == Tristate.TRUE) + .executes(this::totalCount) + .build(); + ArgumentCommandNode serverNode = RequiredArgumentBuilder + .argument(SERVER_ARG, StringArgumentType.string()) + .suggests((context, builder) -> { + for (RegisteredServer server : server.getAllServers()) { + builder.suggest(server.getServerInfo().getName()); + } + builder.suggest("all"); + return builder.buildFuture(); + }) + .executes(this::serverCount) + .build(); + totalNode.addChild(serverNode); + server.getCommandManager().register(new BrigadierCommand(totalNode)); + } + + private int totalCount(final CommandContext context) { + final CommandSource source = context.getSource(); + sendTotalProxyCount(source); + source.sendMessage( + TextComponent.builder("To view all players on servers, use ", NamedTextColor.YELLOW) + .append("/glist all", NamedTextColor.DARK_AQUA) + .append(".", NamedTextColor.YELLOW) + .build()); + return 1; + } + + private int serverCount(final CommandContext context) { + final CommandSource source = context.getSource(); + final String serverName = getString(context, SERVER_ARG); + if (serverName.equalsIgnoreCase("all")) { + for (RegisteredServer server : BuiltinCommandUtil.sortedServerList(server)) { + sendServerPlayers(source, server, true); + } + sendTotalProxyCount(source); + } else { + Optional registeredServer = server.getServer(serverName); + if (!registeredServer.isPresent()) { + source.sendMessage( + TextComponent.of("Server " + serverName + " doesn't exist.", NamedTextColor.RED)); + return -1; + } + sendServerPlayers(source, registeredServer.get(), false); + } + return 1; + } + + private void sendTotalProxyCount(CommandSource target) { + target.sendMessage(TextComponent.builder("There are ", NamedTextColor.YELLOW) + .append(Integer.toString(server.getAllPlayers().size()), NamedTextColor.GREEN) + .append(" player(s) online.", NamedTextColor.YELLOW) + .build()); + } + + private void sendServerPlayers(CommandSource target, RegisteredServer server, boolean fromAll) { + List onServer = ImmutableList.copyOf(server.getPlayersConnected()); + if (onServer.isEmpty() && fromAll) { + return; + } + + TextComponent.Builder builder = TextComponent.builder() + .append(TextComponent.of("[" + server.getServerInfo().getName() + "] ", + NamedTextColor.DARK_AQUA)) + .append("(" + onServer.size() + ")", NamedTextColor.GRAY) + .append(": ") + .resetStyle(); + + for (int i = 0; i < onServer.size(); i++) { + Player player = onServer.get(i); + builder.append(player.getUsername()); + + if (i + 1 < onServer.size()) { + builder.append(", "); + } + } + + target.sendMessage(builder.build()); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/ServerCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ServerCommand.java similarity index 84% rename from proxy/src/main/java/com/velocitypowered/proxy/command/ServerCommand.java rename to proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ServerCommand.java index 8045cd740..afc9551ed 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/ServerCommand.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ServerCommand.java @@ -1,10 +1,10 @@ -package com.velocitypowered.proxy.command; +package com.velocitypowered.proxy.command.builtin; import static net.kyori.adventure.text.event.HoverEvent.showText; import com.google.common.collect.ImmutableList; -import com.velocitypowered.api.command.Command; import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; import com.velocitypowered.api.permission.Tristate; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; @@ -18,9 +18,8 @@ import java.util.stream.Stream; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; -import org.checkerframework.checker.nullness.qual.NonNull; -public class ServerCommand implements Command { +public class ServerCommand implements SimpleCommand { public static final int MAX_SERVERS_TO_LIST = 50; private final ProxyServer server; @@ -30,7 +29,10 @@ public class ServerCommand implements Command { } @Override - public void execute(CommandSource source, String @NonNull [] args) { + public void execute(final SimpleCommand.Invocation invocation) { + final CommandSource source = invocation.source(); + final String[] args = invocation.arguments(); + if (!(source instanceof Player)) { source.sendMessage(TextComponent.of("Only players may run this command.", NamedTextColor.RED)); @@ -102,9 +104,11 @@ public class ServerCommand implements Command { } @Override - public List suggest(CommandSource source, String @NonNull [] currentArgs) { - Stream possibilities = Stream.concat(Stream.of("all"), server.getAllServers() - .stream().map(rs -> rs.getServerInfo().getName())); + public List suggest(final SimpleCommand.Invocation invocation) { + final String[] currentArgs = invocation.arguments(); + Stream possibilities = server.getAllServers().stream() + .map(rs -> rs.getServerInfo().getName()); + if (currentArgs.length == 0) { return possibilities.collect(Collectors.toList()); } else if (currentArgs.length == 1) { @@ -117,7 +121,7 @@ public class ServerCommand implements Command { } @Override - public boolean hasPermission(CommandSource source, String @NonNull [] args) { - return source.getPermissionValue("velocity.command.server") != Tristate.FALSE; + public boolean hasPermission(final SimpleCommand.Invocation invocation) { + return invocation.source().getPermissionValue("velocity.command.server") != Tristate.FALSE; } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ShutdownCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ShutdownCommand.java new file mode 100644 index 000000000..ff445fd15 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/ShutdownCommand.java @@ -0,0 +1,25 @@ +package com.velocitypowered.proxy.command.builtin; + +import com.velocitypowered.api.command.RawCommand; +import com.velocitypowered.proxy.VelocityServer; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; + +public class ShutdownCommand implements RawCommand { + + private final VelocityServer server; + + public ShutdownCommand(VelocityServer server) { + this.server = server; + } + + @Override + public void execute(final Invocation invocation) { + String reason = invocation.arguments(); + server.shutdown(true, LegacyComponentSerializer.legacy('&').deserialize(reason)); + } + + @Override + public boolean hasPermission(final Invocation invocation) { + return invocation.source() == server.getConsoleCommandSource(); + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java similarity index 80% rename from proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java rename to proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java index 4af5d72fa..1a0466f58 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/VelocityCommand.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java @@ -1,10 +1,10 @@ -package com.velocitypowered.proxy.command; +package com.velocitypowered.proxy.command.builtin; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.velocitypowered.api.command.Command; import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; import com.velocitypowered.api.permission.Tristate; import com.velocitypowered.api.plugin.PluginContainer; import com.velocitypowered.api.plugin.PluginDescription; @@ -25,16 +25,28 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.checkerframework.checker.nullness.qual.NonNull; -public class VelocityCommand implements Command { +public class VelocityCommand implements SimpleCommand { - private final Map subcommands; + private interface SubCommand { + + void execute(final CommandSource source, final String @NonNull [] args); + + default List suggest(final CommandSource source, final String @NonNull [] currentArgs) { + return ImmutableList.of(); + } + + boolean hasPermission(final CommandSource source, final String @NonNull [] args); + } + + private final Map commands; /** * Initializes the command object for /velocity. + * * @param server the Velocity server */ public VelocityCommand(VelocityServer server) { - this.subcommands = ImmutableMap.builder() + this.commands = ImmutableMap.builder() .put("version", new Info(server)) .put("plugins", new Plugins(server)) .put("reload", new Reload(server)) @@ -42,7 +54,7 @@ public class VelocityCommand implements Command { } private void usage(CommandSource source) { - String availableCommands = subcommands.entrySet().stream() + String availableCommands = commands.entrySet().stream() .filter(e -> e.getValue().hasPermission(source, new String[0])) .map(Map.Entry::getKey) .collect(Collectors.joining("|")); @@ -51,13 +63,16 @@ public class VelocityCommand implements Command { } @Override - public void execute(CommandSource source, String @NonNull [] args) { + public void execute(final SimpleCommand.Invocation invocation) { + final CommandSource source = invocation.source(); + final String[] args = invocation.arguments(); + if (args.length == 0) { usage(source); return; } - Command command = subcommands.get(args[0].toLowerCase(Locale.US)); + SubCommand command = commands.get(args[0].toLowerCase(Locale.US)); if (command == null) { usage(source); return; @@ -68,16 +83,19 @@ public class VelocityCommand implements Command { } @Override - public List suggest(CommandSource source, String @NonNull [] currentArgs) { + public List suggest(final SimpleCommand.Invocation invocation) { + final CommandSource source = invocation.source(); + final String[] currentArgs = invocation.arguments(); + if (currentArgs.length == 0) { - return subcommands.entrySet().stream() + return commands.entrySet().stream() .filter(e -> e.getValue().hasPermission(source, new String[0])) .map(Map.Entry::getKey) .collect(ImmutableList.toImmutableList()); } if (currentArgs.length == 1) { - return subcommands.entrySet().stream() + return commands.entrySet().stream() .filter(e -> e.getKey().regionMatches(true, 0, currentArgs[0], 0, currentArgs[0].length())) .filter(e -> e.getValue().hasPermission(source, new String[0])) @@ -85,7 +103,7 @@ public class VelocityCommand implements Command { .collect(ImmutableList.toImmutableList()); } - Command command = subcommands.get(currentArgs[0].toLowerCase(Locale.US)); + SubCommand command = commands.get(currentArgs[0].toLowerCase(Locale.US)); if (command == null) { return ImmutableList.of(); } @@ -95,11 +113,14 @@ public class VelocityCommand implements Command { } @Override - public boolean hasPermission(CommandSource source, String @NonNull [] args) { + public boolean hasPermission(final SimpleCommand.Invocation invocation) { + final CommandSource source = invocation.source(); + final String[] args = invocation.arguments(); + if (args.length == 0) { - return subcommands.values().stream().anyMatch(e -> e.hasPermission(source, args)); + return commands.values().stream().anyMatch(e -> e.hasPermission(source, args)); } - Command command = subcommands.get(args[0].toLowerCase(Locale.US)); + SubCommand command = commands.get(args[0].toLowerCase(Locale.US)); if (command == null) { return true; } @@ -108,7 +129,7 @@ public class VelocityCommand implements Command { return command.hasPermission(source, actualArgs); } - private static class Reload implements Command { + private static class Reload implements SubCommand { private static final Logger logger = LogManager.getLogger(Reload.class); private final VelocityServer server; @@ -136,12 +157,12 @@ public class VelocityCommand implements Command { } @Override - public boolean hasPermission(CommandSource source, String @NonNull [] args) { + public boolean hasPermission(final CommandSource source, final String @NonNull [] args) { return source.getPermissionValue("velocity.command.reload") == Tristate.TRUE; } } - private static class Info implements Command { + private static class Info implements SubCommand { private final ProxyServer server; @@ -189,12 +210,12 @@ public class VelocityCommand implements Command { } @Override - public boolean hasPermission(CommandSource source, String @NonNull [] args) { + public boolean hasPermission(final CommandSource source, final String @NonNull [] args) { return source.getPermissionValue("velocity.command.info") != Tristate.FALSE; } } - private static class Plugins implements Command { + private static class Plugins implements SubCommand { private final ProxyServer server; @@ -260,7 +281,7 @@ public class VelocityCommand implements Command { } @Override - public boolean hasPermission(CommandSource source, String @NonNull [] args) { + public boolean hasPermission(final CommandSource source, final String @NonNull [] args) { return source.getPermissionValue("velocity.command.plugins") == Tristate.TRUE; } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java index 1c84be6d6..904d469b3 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -346,6 +346,10 @@ public class VelocityConfiguration implements ProxyConfig { return advanced.isFailoverOnUnexpectedServerDisconnect(); } + public boolean isAnnounceProxyCommands() { + return advanced.isAnnounceProxyCommands(); + } + public boolean isLogCommandExecutions() { return advanced.isLogCommandExecutions(); } @@ -586,6 +590,7 @@ public class VelocityConfiguration implements ProxyConfig { private boolean bungeePluginMessageChannel = true; private boolean showPingRequests = false; private boolean failoverOnUnexpectedServerDisconnect = true; + private boolean announceProxyCommands = true; private boolean logCommandExecutions = false; private Advanced() { @@ -604,6 +609,7 @@ public class VelocityConfiguration implements ProxyConfig { this.showPingRequests = config.getOrElse("show-ping-requests", false); this.failoverOnUnexpectedServerDisconnect = config .getOrElse("failover-on-unexpected-server-disconnect", true); + this.announceProxyCommands = config.getOrElse("announce-proxy-commands", true); this.logCommandExecutions = config.getOrElse("log-command-executions", false); } } @@ -648,6 +654,10 @@ public class VelocityConfiguration implements ProxyConfig { return failoverOnUnexpectedServerDisconnect; } + public boolean isAnnounceProxyCommands() { + return announceProxyCommands; + } + public boolean isLogCommandExecutions() { return logCommandExecutions; } @@ -665,6 +675,7 @@ public class VelocityConfiguration implements ProxyConfig { + ", bungeePluginMessageChannel=" + bungeePluginMessageChannel + ", showPingRequests=" + showPingRequests + ", failoverOnUnexpectedServerDisconnect=" + failoverOnUnexpectedServerDisconnect + + ", announceProxyCommands=" + announceProxyCommands + ", logCommandExecutions=" + logCommandExecutions + '}'; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java index 7d653234a..b14e67dea 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java @@ -3,10 +3,9 @@ package com.velocitypowered.proxy.connection.backend; import static com.velocitypowered.proxy.connection.backend.BungeeCordMessageResponder.getBungeeCordChannel; import com.google.common.collect.ImmutableList; -import com.mojang.brigadier.arguments.StringArgumentType; -import com.mojang.brigadier.builder.LiteralArgumentBuilder; -import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import com.mojang.brigadier.tree.LiteralCommandNode; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.event.command.PlayerAvailableCommandsEvent; import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.api.network.ProtocolVersion; @@ -18,7 +17,6 @@ import com.velocitypowered.proxy.connection.client.ClientPlaySessionHandler; import com.velocitypowered.proxy.connection.util.ConnectionMessages; import com.velocitypowered.proxy.protocol.MinecraftPacket; import com.velocitypowered.proxy.protocol.packet.AvailableCommands; -import com.velocitypowered.proxy.protocol.packet.AvailableCommands.ProtocolSuggestionProvider; import com.velocitypowered.proxy.protocol.packet.BossBar; import com.velocitypowered.proxy.protocol.packet.Disconnect; import com.velocitypowered.proxy.protocol.packet.KeepAlive; @@ -30,6 +28,7 @@ import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; import io.netty.handler.timeout.ReadTimeoutException; +import java.util.Collection; public class BackendPlaySessionHandler implements MinecraftSessionHandler { @@ -164,23 +163,18 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { @Override public boolean handle(AvailableCommands commands) { - // Inject commands from the proxy. - for (String command : server.getCommandManager().getAllRegisteredCommands()) { - if (!server.getCommandManager().hasPermission(serverConn.getPlayer(), command)) { - continue; + RootCommandNode rootNode = commands.getRootNode(); + if (server.getConfiguration().isAnnounceProxyCommands()) { + // Inject commands from the proxy. + Collection> proxyNodes = server.getCommandManager().getDispatcher() + .getRoot().getChildren(); + for (CommandNode node : proxyNodes) { + rootNode.addChild(node); } - - LiteralCommandNode root = LiteralArgumentBuilder.literal(command) - .then(RequiredArgumentBuilder.argument("args", StringArgumentType.greedyString()) - .suggests(new ProtocolSuggestionProvider("minecraft:ask_server")) - .build()) - .executes((ctx) -> 0) - .build(); - commands.getRootNode().addChild(root); } server.getEventManager().fire( - new PlayerAvailableCommandsEvent(serverConn.getPlayer(), commands.getRootNode())) + new PlayerAvailableCommandsEvent(serverConn.getPlayer(), rootNode)) .thenAcceptAsync(event -> playerConnection.write(commands), playerConnection.eventLoop()); return true; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java index e75873262..d72b0ef46 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java @@ -130,7 +130,7 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { server.getCommandManager().callCommandEvent(player, msg.substring(1)) .thenComposeAsync(event -> processCommandExecuteResult(originalCommand, event.getResult())) - .whenCompleteAsync((ignored, throwable) -> { + .whenComplete((ignored, throwable) -> { if (server.getConfiguration().isLogCommandExecutions()) { logger.info("{} -> executed command /{}", player, originalCommand); } @@ -414,7 +414,6 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { for (String suggestion : suggestions) { offers.add(new Offer(suggestion)); } - int startPos = packet.getCommand().lastIndexOf(' ') + 1; if (startPos > 0) { TabCompleteResponse resp = new TabCompleteResponse(); @@ -460,9 +459,10 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { String command = request.getCommand().substring(1); server.getCommandManager().offerSuggestions(player, command) .thenAcceptAsync(offers -> { + boolean needsSlash = player.getProtocolVersion().compareTo(MINECRAFT_1_13) < 0; try { for (String offer : offers) { - response.getOffers().add(new Offer(offer, null)); + response.getOffers().add(new Offer(needsSlash ? "/" + offer : offer, null)); } response.getOffers().sort(null); player.getConnection().write(response); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/console/VelocityConsole.java b/proxy/src/main/java/com/velocitypowered/proxy/console/VelocityConsole.java index ab4ad3873..931e68c82 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/console/VelocityConsole.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/console/VelocityConsole.java @@ -71,16 +71,11 @@ public final class VelocityConsole extends SimpleTerminalConsole implements Cons .appName("Velocity") .completer((reader, parsedLine, list) -> { try { - boolean isCommand = parsedLine.line().indexOf(' ') == -1; List offers = this.server.getCommandManager() .offerSuggestions(this, parsedLine.line()) .join(); // Console doesn't get harmed much by this... for (String offer : offers) { - if (isCommand) { - list.add(new Candidate(offer.substring(1))); - } else { - list.add(new Candidate(offer)); - } + list.add(new Candidate(offer)); } } catch (Exception e) { logger.error("An error occurred while trying to perform tab completion.", e); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/AvailableCommands.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/AvailableCommands.java index ebf7f5b10..4e72276ef 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/AvailableCommands.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/AvailableCommands.java @@ -16,6 +16,7 @@ import com.mojang.brigadier.tree.ArgumentCommandNode; import com.mojang.brigadier.tree.CommandNode; import com.mojang.brigadier.tree.LiteralCommandNode; import com.mojang.brigadier.tree.RootCommandNode; +import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.network.ProtocolVersion; import com.velocitypowered.proxy.connection.MinecraftSessionHandler; import com.velocitypowered.proxy.protocol.MinecraftPacket; @@ -44,14 +45,13 @@ public class AvailableCommands implements MinecraftPacket { private static final byte FLAG_IS_REDIRECT = 0x08; private static final byte FLAG_HAS_SUGGESTIONS = 0x10; - // Note: Velocity doesn't use Brigadier for command handling. This may change in Velocity 2.0.0. - private @MonotonicNonNull RootCommandNode rootNode; + private @MonotonicNonNull RootCommandNode rootNode; /** * Returns the root node. * @return the root node */ - public RootCommandNode getRootNode() { + public RootCommandNode getRootNode() { if (rootNode == null) { throw new IllegalStateException("Packet not yet deserialized"); } @@ -87,16 +87,16 @@ public class AvailableCommands implements MinecraftPacket { } int rootIdx = ProtocolUtils.readVarInt(buf); - rootNode = (RootCommandNode) wireNodes[rootIdx].built; + rootNode = (RootCommandNode) wireNodes[rootIdx].built; } @Override public void encode(ByteBuf buf, Direction direction, ProtocolVersion protocolVersion) { // Assign all the children an index. - Deque> childrenQueue = new ArrayDeque<>(ImmutableList.of(rootNode)); - Object2IntMap> idMappings = new Object2IntLinkedOpenHashMap<>(); + Deque> childrenQueue = new ArrayDeque<>(ImmutableList.of(rootNode)); + Object2IntMap> idMappings = new Object2IntLinkedOpenHashMap<>(); while (!childrenQueue.isEmpty()) { - CommandNode child = childrenQueue.poll(); + CommandNode child = childrenQueue.poll(); if (!idMappings.containsKey(child)) { idMappings.put(child, idMappings.size()); childrenQueue.addAll(child.getChildren()); @@ -105,14 +105,14 @@ public class AvailableCommands implements MinecraftPacket { // Now serialize the children. ProtocolUtils.writeVarInt(buf, idMappings.size()); - for (CommandNode child : idMappings.keySet()) { + for (CommandNode child : idMappings.keySet()) { serializeNode(child, buf, idMappings); } ProtocolUtils.writeVarInt(buf, idMappings.getInt(rootNode)); } - private static void serializeNode(CommandNode node, ByteBuf buf, - Object2IntMap> idMappings) { + private static void serializeNode(CommandNode node, ByteBuf buf, + Object2IntMap> idMappings) { byte flags = 0; if (node.getRedirect() != null) { flags |= FLAG_IS_REDIRECT; @@ -127,7 +127,7 @@ public class AvailableCommands implements MinecraftPacket { flags |= NODE_TYPE_LITERAL; } else if (node instanceof ArgumentCommandNode) { flags |= NODE_TYPE_ARGUMENT; - if (((ArgumentCommandNode) node).getCustomSuggestions() != null) { + if (((ArgumentCommandNode) node).getCustomSuggestions() != null) { flags |= FLAG_HAS_SUGGESTIONS; } } else { @@ -136,7 +136,7 @@ public class AvailableCommands implements MinecraftPacket { buf.writeByte(flags); ProtocolUtils.writeVarInt(buf, node.getChildren().size()); - for (CommandNode child : node.getChildren()) { + for (CommandNode child : node.getChildren()) { ProtocolUtils.writeVarInt(buf, idMappings.getInt(child)); } if (node.getRedirect() != null) { @@ -145,20 +145,17 @@ public class AvailableCommands implements MinecraftPacket { if (node instanceof ArgumentCommandNode) { ProtocolUtils.writeString(buf, node.getName()); - ArgumentPropertyRegistry.serialize(buf, ((ArgumentCommandNode) node).getType()); + ArgumentPropertyRegistry.serialize(buf, + ((ArgumentCommandNode) node).getType()); - if (((ArgumentCommandNode) node).getCustomSuggestions() != null) { - // The unchecked cast is required, but it is not particularly relevant because we check for - // a more specific type later. (Even then, we only pull out one field.) - @SuppressWarnings("unchecked") - SuggestionProvider provider = ((ArgumentCommandNode) node).getCustomSuggestions(); - - if (!(provider instanceof ProtocolSuggestionProvider)) { - throw new IllegalArgumentException("Suggestion provider " + provider.getClass().getName() - + " is not valid."); + if (((ArgumentCommandNode) node).getCustomSuggestions() != null) { + SuggestionProvider provider = ((ArgumentCommandNode) node) + .getCustomSuggestions(); + String name = "minecraft:ask_server"; + if (provider instanceof ProtocolSuggestionProvider) { + name = ((ProtocolSuggestionProvider) provider).name; } - - ProtocolUtils.writeString(buf, ((ProtocolSuggestionProvider) provider).name); + ProtocolUtils.writeString(buf, name); } } else if (node instanceof LiteralCommandNode) { ProtocolUtils.writeString(buf, node.getName()); @@ -188,7 +185,7 @@ public class AvailableCommands implements MinecraftPacket { String name = ProtocolUtils.readString(buf); ArgumentType argumentType = ArgumentPropertyRegistry.deserialize(buf); - RequiredArgumentBuilder argumentBuilder = RequiredArgumentBuilder + RequiredArgumentBuilder argumentBuilder = RequiredArgumentBuilder .argument(name, argumentType); if ((flags & FLAG_HAS_SUGGESTIONS) != 0) { argumentBuilder.suggests(new ProtocolSuggestionProvider(ProtocolUtils.readString(buf))); @@ -205,11 +202,11 @@ public class AvailableCommands implements MinecraftPacket { private final byte flags; private final int[] children; private final int redirectTo; - private final @Nullable ArgumentBuilder args; - private @MonotonicNonNull CommandNode built; + private final @Nullable ArgumentBuilder args; + private @MonotonicNonNull CommandNode built; private WireNode(int idx, byte flags, int[] children, int redirectTo, - @Nullable ArgumentBuilder args) { + @Nullable ArgumentBuilder args) { this.idx = idx; this.flags = flags; this.children = children; @@ -251,7 +248,7 @@ public class AvailableCommands implements MinecraftPacket { // If executable, add a dummy command if ((flags & FLAG_EXECUTABLE) != 0) { - args.executes((Command) context -> 0); + args.executes((Command) context -> 0); } this.built = args.build(); @@ -267,7 +264,7 @@ public class AvailableCommands implements MinecraftPacket { // Associate children with nodes for (int child : children) { - CommandNode childNode = wireNodes[child].built; + CommandNode childNode = wireNodes[child].built; if (!(childNode instanceof RootCommandNode)) { built.addChild(childNode); } @@ -286,9 +283,11 @@ public class AvailableCommands implements MinecraftPacket { if (args != null) { if (args instanceof LiteralArgumentBuilder) { - helper.add("argsLabel", ((LiteralArgumentBuilder) args).getLiteral()); + helper.add("argsLabel", + ((LiteralArgumentBuilder) args).getLiteral()); } else if (args instanceof RequiredArgumentBuilder) { - helper.add("argsName", ((RequiredArgumentBuilder) args).getName()); + helper.add("argsName", + ((RequiredArgumentBuilder) args).getName()); } } @@ -300,7 +299,7 @@ public class AvailableCommands implements MinecraftPacket { * A placeholder {@link SuggestionProvider} used internally to preserve the suggestion provider * name. */ - public static class ProtocolSuggestionProvider implements SuggestionProvider { + public static class ProtocolSuggestionProvider implements SuggestionProvider { private final String name; @@ -309,7 +308,7 @@ public class AvailableCommands implements MinecraftPacket { } @Override - public CompletableFuture getSuggestions(CommandContext context, + public CompletableFuture getSuggestions(CommandContext context, SuggestionsBuilder builder) throws CommandSyntaxException { return builder.buildFuture(); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/util/BrigadierUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/util/BrigadierUtils.java new file mode 100644 index 000000000..eff2f4267 --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/util/BrigadierUtils.java @@ -0,0 +1,149 @@ +package com.velocitypowered.proxy.util; + +import com.google.common.base.Preconditions; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.velocitypowered.api.command.CommandSource; +import java.util.Locale; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Provides utilities for working with Brigadier commands. + */ +public final class BrigadierUtils { + + /** + * Returns a literal node that redirects its execution to + * the given destination node. + * + * @param alias the command alias + * @param destination the destination node + * @return the built node + */ + public static LiteralCommandNode buildRedirect( + final String alias, final LiteralCommandNode destination) { + // Redirects only work for nodes with children, but break the top argument-less command. + // Manually adding the root command after setting the redirect doesn't fix it. + // See https://github.com/Mojang/brigadier/issues/46). Manually clone the node instead. + LiteralArgumentBuilder builder = LiteralArgumentBuilder + .literal(alias.toLowerCase(Locale.ENGLISH)) + .requires(destination.getRequirement()) + .forward( + destination.getRedirect(), destination.getRedirectModifier(), destination.isFork()) + .executes(destination.getCommand()); + for (CommandNode child : destination.getChildren()) { + builder.then(child); + } + return builder.build(); + } + + /** + * Returns a literal node that optionally accepts arguments + * as a raw {@link String}. + * + * @param alias the literal alias + * @param brigadierCommand the command to execute + * @param suggestionProvider the suggestion provider + * @return the built node + */ + public static LiteralCommandNode buildRawArgumentsLiteral( + final String alias, final Command brigadierCommand, + SuggestionProvider suggestionProvider) { + return LiteralArgumentBuilder + .literal(alias.toLowerCase(Locale.ENGLISH)) + .then(RequiredArgumentBuilder + .argument("arguments", StringArgumentType.greedyString()) + .suggests(suggestionProvider) + .executes(brigadierCommand)) + .executes(brigadierCommand) + .build(); + } + + /** + * Returns the used command alias. + * + * @param context the command context + * @return the parsed command alias + */ + public static String getAlias(final CommandContext context) { + return context.getNodes().get(0).getNode().getName(); + } + + /** + * Returns the raw {@link String} arguments of a command execution. + * + * @param context the command context + * @return the parsed arguments + */ + public static String getRawArguments(final CommandContext context) { + String cmdLine = context.getInput(); + int firstSpace = cmdLine.indexOf(' '); + if (firstSpace == -1) { + return ""; + } + return cmdLine.substring(firstSpace + 1); + } + + /** + * Returns the splitted arguments of a command node built with + * {@link #buildRawArgumentsLiteral(String, Command, SuggestionProvider)}. + * + * @param context the command context + * @return the parsed arguments + */ + public static String[] getSplitArguments(final CommandContext context) { + String line = getRawArguments(context); + if (line.isEmpty()) { + return new String[0]; + } + return line.trim().split(" ", -1); + } + + /** + * Returns the normalized representation of the given command input. + * + * @param cmdLine the command input + * @param trim whether to trim argument-less inputs + * @return the normalized command + */ + public static String normalizeInput(final String cmdLine, final boolean trim) { + // Command aliases are case insensitive, but Brigadier isn't + String command = trim ? cmdLine.trim() : cmdLine; + int firstSpace = command.indexOf(' '); + if (firstSpace != -1) { + return command.substring(0, firstSpace).toLowerCase(Locale.ENGLISH) + + command.substring(firstSpace); + } + return command.toLowerCase(Locale.ENGLISH); + } + + /** + * Prepares the given command node prior for hinting metadata to + * a {@link com.velocitypowered.api.command.Command}. + * + * @param node the command node to be wrapped + * @param command the command to execute + * @return the wrapped command node + */ + public static CommandNode wrapForHinting( + final CommandNode node, final @Nullable Command command) { + Preconditions.checkNotNull(node, "node"); + ArgumentBuilder builder = node.createBuilder(); + builder.executes(command); + for (CommandNode child : node.getChildren()) { + builder.then(wrapForHinting(child, command)); + } + return builder.build(); + } + + private BrigadierUtils() { + throw new AssertionError(); + } +} diff --git a/proxy/src/main/resources/default-velocity.toml b/proxy/src/main/resources/default-velocity.toml index b4e103292..237b15687 100644 --- a/proxy/src/main/resources/default-velocity.toml +++ b/proxy/src/main/resources/default-velocity.toml @@ -123,6 +123,9 @@ show-ping-requests = false # can disable this setting to use the BungeeCord behavior. failover-on-unexpected-server-disconnect = true +# Declares the proxy commands to 1.13+ clients. +announce-proxy-commands = true + # Enables the logging of commands log-command-executions = false diff --git a/proxy/src/test/java/com/velocitypowered/proxy/command/CommandManagerTests.java b/proxy/src/test/java/com/velocitypowered/proxy/command/CommandManagerTests.java new file mode 100644 index 000000000..c97f6dc51 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/command/CommandManagerTests.java @@ -0,0 +1,523 @@ +package com.velocitypowered.proxy.command; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.tree.ArgumentCommandNode; +import com.mojang.brigadier.tree.CommandNode; +import com.mojang.brigadier.tree.LiteralCommandNode; +import com.velocitypowered.api.command.BrigadierCommand; +import com.velocitypowered.api.command.Command; +import com.velocitypowered.api.command.CommandMeta; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.RawCommand; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.proxy.plugin.MockEventManager; +import com.velocitypowered.proxy.plugin.VelocityEventManager; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.junit.jupiter.api.Test; + +public class CommandManagerTests { + + private static final VelocityEventManager EVENT_MANAGER = new MockEventManager(); + + static { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + EVENT_MANAGER.shutdown(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + } + + static VelocityCommandManager createManager() { + return new VelocityCommandManager(EVENT_MANAGER); + } + + @Test + void testConstruction() { + VelocityCommandManager manager = createManager(); + assertFalse(manager.hasCommand("foo")); + assertTrue(manager.getDispatcher().getRoot().getChildren().isEmpty()); + assertFalse(manager.execute(MockCommandSource.INSTANCE, "foo")); + assertFalse(manager.executeImmediately(MockCommandSource.INSTANCE, "bar")); + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "").join().isEmpty()); + } + + @Test + void testBrigadierRegister() { + VelocityCommandManager manager = createManager(); + LiteralCommandNode node = LiteralArgumentBuilder + .literal("foo") + .build(); + BrigadierCommand command = new BrigadierCommand(node); + manager.register(command); + + assertEquals(node, command.getNode()); + assertTrue(manager.hasCommand("fOo")); + + LiteralCommandNode barNode = LiteralArgumentBuilder + .literal("bar") + .build(); + BrigadierCommand aliasesCommand = new BrigadierCommand(barNode); + CommandMeta meta = manager.metaBuilder(aliasesCommand) + .aliases("baZ") + .build(); + + assertEquals(ImmutableSet.of("bar", "baz"), meta.getAliases()); + assertTrue(meta.getHints().isEmpty()); + manager.register(meta, aliasesCommand); + assertTrue(manager.hasCommand("bAr")); + assertTrue(manager.hasCommand("Baz")); + } + + @Test + void testSimpleRegister() { + VelocityCommandManager manager = createManager(); + SimpleCommand command = new NoopSimpleCommand(); + + manager.register("Foo", command); + assertTrue(manager.hasCommand("foO")); + manager.unregister("fOo"); + assertFalse(manager.hasCommand("foo")); + + manager.register("foo", command, "bAr", "BAZ"); + assertTrue(manager.hasCommand("bar")); + assertTrue(manager.hasCommand("bAz")); + } + + @Test + void testRawRegister() { + VelocityCommandManager manager = createManager(); + RawCommand command = new NoopRawCommand(); + + assertThrows(IllegalArgumentException.class, () -> manager.register(command), + "no aliases throws"); + manager.register(command, "foO", "BAR"); + assertTrue(manager.hasCommand("fOo")); + assertTrue(manager.hasCommand("bar")); + } + + @Test + void testDeprecatedRegister() { + VelocityCommandManager manager = createManager(); + Command command = new NoopDeprecatedCommand(); + + manager.register("foo", command); + assertTrue(manager.hasCommand("foO")); + } + + @Test + void testAlreadyRegisteredThrows() { + VelocityCommandManager manager = createManager(); + manager.register("bar", new NoopDeprecatedCommand()); + assertThrows(IllegalArgumentException.class, () -> + manager.register("BAR", new NoopSimpleCommand())); + assertThrows(IllegalArgumentException.class, () -> { + CommandMeta meta = manager.metaBuilder("baz") + .aliases("BAr") + .build(); + manager.register(meta, new NoopRawCommand()); + }); + } + + @Test + void testBrigadierExecute() { + VelocityCommandManager manager = createManager(); + AtomicBoolean executed = new AtomicBoolean(false); + AtomicBoolean checkedRequires = new AtomicBoolean(false); + LiteralCommandNode node = LiteralArgumentBuilder + .literal("buy") + .executes(context -> { + assertEquals(MockCommandSource.INSTANCE, context.getSource()); + assertEquals("buy", context.getInput()); + executed.set(true); + return 1; + }) + .build(); + CommandNode quantityNode = RequiredArgumentBuilder + .argument("quantity", IntegerArgumentType.integer(12, 16)) + .requires(source -> { + assertEquals(MockCommandSource.INSTANCE, source); + checkedRequires.set(true); + return true; + }) + .executes(context -> { + int argument = IntegerArgumentType.getInteger(context, "quantity"); + assertEquals(14, argument); + executed.set(true); + return 1; + }) + .build(); + CommandNode productNode = RequiredArgumentBuilder + .argument("product", StringArgumentType.string()) + .requires(source -> { + checkedRequires.set(true); + return false; + }) + .executes(context -> fail("was executed")) + .build(); + quantityNode.addChild(productNode); + node.addChild(quantityNode); + manager.register(new BrigadierCommand(node)); + + assertTrue(manager.executeAsync(MockCommandSource.INSTANCE, "buy ").join()); + assertTrue(executed.compareAndSet(true, false), "was executed"); + assertTrue(manager.executeImmediatelyAsync(MockCommandSource.INSTANCE, "buy 14").join()); + assertTrue(checkedRequires.compareAndSet(true, false)); + assertTrue(executed.get()); + assertFalse(manager.execute(MockCommandSource.INSTANCE, "buy 9"), + "Invalid arg returns false"); + assertFalse(manager.executeImmediately(MockCommandSource.INSTANCE, "buy 12 bananas")); + assertTrue(checkedRequires.get()); + } + + @Test + void testSimpleExecute() { + VelocityCommandManager manager = createManager(); + AtomicBoolean executed = new AtomicBoolean(false); + SimpleCommand command = invocation -> { + assertEquals(MockCommandSource.INSTANCE, invocation.source()); + assertArrayEquals(new String[] {"bar", "254"}, invocation.arguments()); + executed.set(true); + }; + manager.register("foo", command); + + assertTrue(manager.executeAsync(MockCommandSource.INSTANCE, "foo bar 254").join()); + assertTrue(executed.get()); + + SimpleCommand noPermsCommand = new SimpleCommand() { + @Override + public void execute(final Invocation invocation) { + fail("was executed"); + } + + @Override + public boolean hasPermission(final Invocation invocation) { + return false; + } + }; + + manager.register("dangerous", noPermsCommand, "veryDangerous"); + assertFalse(manager.execute(MockCommandSource.INSTANCE, "dangerous")); + assertFalse(manager.executeImmediately(MockCommandSource.INSTANCE, "verydangerous 123")); + } + + @Test + void testRawExecute() { + VelocityCommandManager manager = createManager(); + AtomicBoolean executed = new AtomicBoolean(false); + RawCommand command = new RawCommand() { + @Override + public void execute(final Invocation invocation) { + assertEquals(MockCommandSource.INSTANCE, invocation.source()); + assertEquals("lobby 23", invocation.arguments()); + executed.set(true); + } + }; + manager.register("sendMe", command); + + assertTrue(manager.executeImmediately(MockCommandSource.INSTANCE, "sendMe lobby 23")); + assertTrue(executed.compareAndSet(true, false)); + + RawCommand noArgsCommand = new RawCommand() { + @Override + public void execute(final Invocation invocation) { + assertEquals("", invocation.arguments()); + executed.set(true); + } + }; + manager.register("noargs", noArgsCommand); + + assertTrue(manager.executeImmediately(MockCommandSource.INSTANCE, "noargs")); + assertTrue(executed.get()); + assertTrue(manager.executeImmediately(MockCommandSource.INSTANCE, "noargs ")); + + RawCommand noPermsCommand = new RawCommand() { + @Override + public void execute(final Invocation invocation) { + fail("was executed"); + } + + @Override + public boolean hasPermission(final Invocation invocation) { + return false; + } + }; + + manager.register("sendThem", noPermsCommand); + assertFalse(manager.executeImmediately(MockCommandSource.INSTANCE, "sendThem foo")); + } + + @Test + void testDeprecatedExecute() { + VelocityCommandManager manager = createManager(); + AtomicBoolean executed = new AtomicBoolean(false); + Command command = new Command() { + @Override + public void execute(final CommandSource source, final String @NonNull [] args) { + assertEquals(MockCommandSource.INSTANCE, source); + assertArrayEquals(new String[] { "boo", "123" }, args); + executed.set(true); + } + }; + manager.register("foo", command); + + assertTrue(manager.execute(MockCommandSource.INSTANCE, "foo boo 123")); + assertTrue(executed.get()); + + Command noPermsCommand = new Command() { + @Override + public boolean hasPermission(final CommandSource source, final String @NonNull [] args) { + return false; + } + }; + + manager.register("oof", noPermsCommand, "veryOof"); + assertFalse(manager.execute(MockCommandSource.INSTANCE, "veryOOF")); + assertFalse(manager.executeImmediately(MockCommandSource.INSTANCE, "ooF boo 54321")); + } + + @Test + void testSuggestions() { + VelocityCommandManager manager = createManager(); + + LiteralCommandNode brigadierNode = LiteralArgumentBuilder + .literal("brigadier") + .build(); + CommandNode nameNode = RequiredArgumentBuilder + .argument("name", StringArgumentType.string()) + .build(); + CommandNode numberNode = RequiredArgumentBuilder + .argument("quantity", IntegerArgumentType.integer()) + .suggests((context, builder) -> builder.suggest(2).suggest(3).buildFuture()) + .build(); + nameNode.addChild(numberNode); + brigadierNode.addChild(nameNode); + manager.register(new BrigadierCommand(brigadierNode)); + + SimpleCommand simpleCommand = new SimpleCommand() { + @Override + public void execute(final Invocation invocation) { + fail(); + } + + @Override + public List suggest(final Invocation invocation) { + switch (invocation.arguments().length) { + case 0: + return ImmutableList.of("foo", "bar"); + case 1: + return ImmutableList.of("123"); + default: + return ImmutableList.of(); + } + } + }; + manager.register("simple", simpleCommand); + + RawCommand rawCommand = new RawCommand() { + @Override + public void execute(final Invocation invocation) { + fail(); + } + + @Override + public List suggest(final Invocation invocation) { + switch (invocation.arguments()) { + case "": + return ImmutableList.of("foo", "baz"); + case "foo ": + return ImmutableList.of("2", "3", "5", "7"); + case "bar ": + return ImmutableList.of("11", "13", "17"); + default: + return ImmutableList.of(); + } + } + }; + manager.register("raw", rawCommand); + + Command deprecatedCommand = new Command() { + @Override + public List suggest( + final CommandSource source, final String @NonNull [] currentArgs) { + switch (currentArgs.length) { + case 0: + return ImmutableList.of("boo", "scary"); + case 1: + return ImmutableList.of("123", "456"); + default: + return ImmutableList.of(); + } + } + }; + manager.register("deprecated", deprecatedCommand); + + assertEquals( + ImmutableList.of("brigadier", "deprecated", "raw", "simple"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "").join(), + "literals are in alphabetical order"); + assertEquals( + ImmutableList.of("brigadier"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "briga").join()); + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "brigadier") + .join().isEmpty()); + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "brigadier ") + .join().isEmpty()); + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "brigadier foo") + .join().isEmpty()); + assertEquals( + ImmutableList.of("2", "3"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "brigadier foo ").join()); + assertEquals( + ImmutableList.of("bar", "foo"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "simple ").join()); + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "simple") + .join().isEmpty()); + assertEquals( + ImmutableList.of("123"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "simPle foo ").join()); + assertEquals( + ImmutableList.of("baz", "foo"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "raw ").join()); + assertEquals( + ImmutableList.of("2", "3", "5", "7"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "raw foo ").join()); + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "raw foo") + .join().isEmpty()); + assertEquals( + ImmutableList.of("11", "13", "17"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "rAW bar ").join()); + assertEquals( + ImmutableList.of("boo", "scary"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "deprecated ").join()); + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "deprecated") + .join().isEmpty()); + assertEquals( + ImmutableList.of("123", "456"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "deprEcated foo ").join()); + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "deprecated foo 789 ") + .join().isEmpty()); + } + + @Test + void testBrigadierSuggestionPermissions() { + VelocityCommandManager manager = createManager(); + LiteralCommandNode manageNode = LiteralArgumentBuilder + .literal("manage") + .requires(source -> false) + .build(); + CommandNode idNode = RequiredArgumentBuilder + .argument("id", IntegerArgumentType.integer(0)) + .suggests((context, builder) -> fail("called suggestion builder")) + .build(); + manageNode.addChild(idNode); + manager.register(new BrigadierCommand(manageNode)); + + // Brigadier doesn't call the children predicate when requesting suggestions. + // However, it won't query children if the source doesn't pass the parent + // #requires predicate. + assertTrue(manager.offerSuggestions(MockCommandSource.INSTANCE, "manage ") + .join().isEmpty()); + } + + @Test + void testHinting() { + VelocityCommandManager manager = createManager(); + AtomicBoolean executed = new AtomicBoolean(false); + AtomicBoolean calledSuggestionProvider = new AtomicBoolean(false); + AtomicReference expectedArgs = new AtomicReference<>(); + RawCommand command = new RawCommand() { + @Override + public void execute(final Invocation invocation) { + assertEquals(expectedArgs.get(), invocation.arguments()); + executed.set(true); + } + + @Override + public List suggest(final Invocation invocation) { + return ImmutableList.of("raw"); + } + }; + + CommandNode barHint = LiteralArgumentBuilder + .literal("bar") + .executes(context -> fail("hints don't get executed")) + .build(); + ArgumentCommandNode numberArg = RequiredArgumentBuilder + .argument("number", IntegerArgumentType.integer()) + .suggests((context, builder) -> { + calledSuggestionProvider.set(true); + return builder.suggest("456").buildFuture(); + }) + .build(); + barHint.addChild(numberArg); + CommandNode bazHint = LiteralArgumentBuilder + .literal("baz") + .build(); + CommandMeta meta = manager.metaBuilder("foo") + .aliases("foo2") + .hint(barHint) + .hint(bazHint) + .build(); + manager.register(meta, command); + + expectedArgs.set("notBarOrBaz"); + assertTrue(manager.execute(MockCommandSource.INSTANCE, "foo notBarOrBaz")); + assertTrue(executed.compareAndSet(true, false)); + expectedArgs.set("anotherArg 123"); + assertTrue(manager.execute(MockCommandSource.INSTANCE, "Foo2 anotherArg 123")); + assertTrue(executed.compareAndSet(true, false)); + expectedArgs.set("bar"); + assertTrue(manager.execute(MockCommandSource.INSTANCE, "foo bar")); + assertTrue(executed.compareAndSet(true, false)); + expectedArgs.set("bar 123"); + assertTrue(manager.execute(MockCommandSource.INSTANCE, "foo2 bar 123")); + assertTrue(executed.compareAndSet(true, false)); + + assertEquals(ImmutableList.of("bar", "baz", "raw"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "foo ").join()); + assertFalse(calledSuggestionProvider.get()); + assertEquals(ImmutableList.of("456"), + manager.offerSuggestions(MockCommandSource.INSTANCE, "foo bar ").join()); + assertTrue(calledSuggestionProvider.compareAndSet(true, false)); + assertEquals(ImmutableList.of(), + manager.offerSuggestions(MockCommandSource.INSTANCE, "foo2 baz ").join()); + } + + static class NoopSimpleCommand implements SimpleCommand { + @Override + public void execute(final Invocation invocation) { + + } + } + + static class NoopRawCommand implements RawCommand { + @Override + public void execute(final Invocation invocation) { + + } + } + + static class NoopDeprecatedCommand implements Command { + @Override + public void execute(final CommandSource source, final String @NonNull [] args) { + + } + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/command/MockCommandSource.java b/proxy/src/test/java/com/velocitypowered/proxy/command/MockCommandSource.java new file mode 100644 index 000000000..1e946429d --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/command/MockCommandSource.java @@ -0,0 +1,20 @@ +package com.velocitypowered.proxy.command; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.permission.Tristate; +import net.kyori.text.Component; + +public class MockCommandSource implements CommandSource { + + public static CommandSource INSTANCE = new MockCommandSource(); + + @Override + public void sendMessage(final Component component) { + + } + + @Override + public Tristate getPermissionValue(final String permission) { + return Tristate.UNDEFINED; + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/plugin/MockEventManager.java b/proxy/src/test/java/com/velocitypowered/proxy/plugin/MockEventManager.java new file mode 100644 index 000000000..20be3a079 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/plugin/MockEventManager.java @@ -0,0 +1,11 @@ +package com.velocitypowered.proxy.plugin; + +/** + * A mock {@link VelocityEventManager}. Must be shutdown after use! + */ +public class MockEventManager extends VelocityEventManager { + + public MockEventManager() { + super(MockPluginManager.INSTANCE); + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/plugin/MockPluginManager.java b/proxy/src/test/java/com/velocitypowered/proxy/plugin/MockPluginManager.java new file mode 100644 index 000000000..2620fcec5 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/plugin/MockPluginManager.java @@ -0,0 +1,38 @@ +package com.velocitypowered.proxy.plugin; + +import com.google.common.collect.ImmutableList; +import com.velocitypowered.api.plugin.PluginContainer; +import com.velocitypowered.api.plugin.PluginManager; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Optional; + +public class MockPluginManager implements PluginManager { + + public static PluginManager INSTANCE = new MockPluginManager(); + + @Override + public Optional fromInstance(final Object instance) { + return Optional.empty(); + } + + @Override + public Optional getPlugin(final String id) { + return Optional.empty(); + } + + @Override + public Collection getPlugins() { + return ImmutableList.of(); + } + + @Override + public boolean isLoaded(final String id) { + return false; + } + + @Override + public void addToClasspath(final Object plugin, final Path path) { + + } +}