diff --git a/api/src/main/java/dev/plex/api/command/CommandApi.java b/api/src/main/java/dev/plex/api/command/CommandApi.java index d5d2a1d..12db83c 100644 --- a/api/src/main/java/dev/plex/api/command/CommandApi.java +++ b/api/src/main/java/dev/plex/api/command/CommandApi.java @@ -1,6 +1,7 @@ package dev.plex.api.command; import dev.plex.command.PlexCommand; +import java.util.List; /** * Registers and unregisters Plex commands with the running platform. @@ -31,6 +32,13 @@ public interface CommandApi */ void unregister(PlexCommand command); + /** + * Returns the commands currently tracked by Plex. + * + * @return registered commands + */ + List registeredCommands(); + /** * Returns whether command changes are staged for the next Paper command * lifecycle rebuild. diff --git a/api/src/main/java/dev/plex/command/PlexCommand.java b/api/src/main/java/dev/plex/command/PlexCommand.java index 9686c63..083d583 100644 --- a/api/src/main/java/dev/plex/command/PlexCommand.java +++ b/api/src/main/java/dev/plex/command/PlexCommand.java @@ -1,6 +1,7 @@ package dev.plex.command; import com.mojang.brigadier.tree.LiteralCommandNode; +import dev.plex.api.PlexApi; import dev.plex.command.source.RequiredCommandSource; import io.papermc.paper.command.brigadier.CommandSourceStack; import java.util.List; @@ -24,6 +25,18 @@ public interface PlexCommand */ LiteralCommandNode buildCommand(); + /** + * Supplies the running Plex API to commands that need API helpers. + * + *

Most commands do not need to store the API directly, so the default + * implementation is intentionally empty.

+ * + * @param api running Plex API + */ + default void bindApi(PlexApi api) + { + } + /** * Returns the primary command name. * diff --git a/api/src/main/java/dev/plex/command/SimplePlexCommand.java b/api/src/main/java/dev/plex/command/SimplePlexCommand.java new file mode 100644 index 0000000..44283e4 --- /dev/null +++ b/api/src/main/java/dev/plex/command/SimplePlexCommand.java @@ -0,0 +1,368 @@ +package dev.plex.command; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.mojang.brigadier.tree.LiteralCommandNode; +import dev.plex.api.PlexApi; +import dev.plex.command.exception.CommandFailException; +import dev.plex.command.exception.ConsoleMustDefinePlayerException; +import dev.plex.command.exception.ConsoleOnlyException; +import dev.plex.command.exception.PlayerNotBannedException; +import dev.plex.command.exception.PlayerNotFoundException; +import dev.plex.command.source.RequiredCommandSource; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Convenience base for module commands that execute from string-array arguments. + * + *

Commands that need a custom Brigadier tree can override + * {@link #configureCommand(LiteralArgumentBuilder)} while keeping the same + * metadata and helper methods.

+ */ +public abstract class SimplePlexCommand implements PlexCommand +{ + private final CommandSpec commandSpec; + private PlexApi api; + + protected SimplePlexCommand(CommandSpec commandSpec) + { + this.commandSpec = commandSpec; + } + + protected static CommandSpec.Builder command(String name) + { + return CommandSpec.builder(name); + } + + @Override + public final CommandSpec commandSpec() + { + return commandSpec; + } + + @Override + public final void bindApi(PlexApi api) + { + this.api = api; + } + + @Override + public final LiteralCommandNode buildCommand() + { + LiteralArgumentBuilder command = Commands.literal(getName()) + .requires(this::canUse); + configureCommand(command); + return command.build(); + } + + protected void configureCommand(LiteralArgumentBuilder command) + { + command.executes(context -> dispatchCommand(context, new String[0])); + command.then(Commands.argument("args", StringArgumentType.greedyString()) + .suggests(this::suggest) + .executes(context -> dispatchCommand(context, splitExecutionArgs(StringArgumentType.getString(context, "args"))))); + } + + protected abstract Component execute(@NotNull CommandSender sender, @Nullable Player player, @NotNull String[] args); + + protected @NotNull List suggestions(@NotNull CommandSender sender, @NotNull String alias, @NotNull String[] args) throws IllegalArgumentException + { + return List.of(); + } + + protected PlexApi api() + { + if (api == null) + { + throw new IllegalStateException("Command " + getName() + " has not been bound to the Plex API"); + } + return api; + } + + protected void send(Audience audience, String message) + { + audience.sendMessage(componentFromString(message)); + } + + protected void send(Audience audience, Component component) + { + audience.sendMessage(component); + } + + protected void broadcast(String miniMessage) + { + api().messages().broadcast(miniMessage); + } + + protected void broadcast(Component component) + { + api().messages().broadcast(component); + } + + protected boolean checkPermission(CommandSender sender, String permission) + { + if (permission.isEmpty() || isConsole(sender) || sender.hasPermission(permission)) + { + return true; + } + throw new CommandFailException(api().messages().messageString("noPermissionNode", permission)); + } + + protected boolean silentCheckPermission(CommandSender sender, String permission) + { + return permission.isEmpty() || isConsole(sender) || sender.hasPermission(permission); + } + + protected Component permissionMessage() + { + return permissionMessage(getPermission()); + } + + protected Component permissionMessage(String permission) + { + return messageComponent("noPermissionNode", permission); + } + + protected @Nullable UUID getUUID(CommandSender sender) + { + return sender instanceof Player player ? player.getUniqueId() : null; + } + + protected boolean isConsole(CommandSender sender) + { + return !(sender instanceof Player); + } + + protected Component messageComponent(String key, Object... objects) + { + return api().messages().messageComponent(key, objects); + } + + protected Component messageComponent(String key, Component... objects) + { + return api().messages().messageComponent(key, objects); + } + + protected String messageString(String key, Object... objects) + { + return api().messages().messageString(key, objects); + } + + protected Component usage() + { + return usage(getUsage()); + } + + protected Component usage(String usage) + { + return messageComponent("correctUsagePrefix").append(componentFromString(usage).color(NamedTextColor.GRAY)); + } + + protected Player getNonNullPlayer(String name) + { + try + { + UUID uuid = UUID.fromString(name); + Player player = Bukkit.getPlayer(uuid); + if (player != null) + { + return player; + } + } + catch (IllegalArgumentException ignored) + { + } + + Player player = Bukkit.getPlayer(name); + if (player == null) + { + throw new PlayerNotFoundException(); + } + return player; + } + + protected List onlinePlayerNames() + { + return api().players().onlineNames(); + } + + protected Component componentFromString(String value) + { + return LegacyComponentSerializer.legacyAmpersand().deserialize(value).colorIfAbsent(NamedTextColor.GRAY); + } + + protected Component noColorComponentFromString(String value) + { + return LegacyComponentSerializer.legacyAmpersand().deserialize(value); + } + + protected Component mmString(String value) + { + return api().messages().miniMessage(value); + } + + private int dispatchCommand(CommandContext context, String[] args) + { + CommandSender sender = context.getSource().getSender(); + if (!validateSourceAndPermission(sender)) + { + return com.mojang.brigadier.Command.SINGLE_SUCCESS; + } + + try + { + Component component = execute(sender, sender instanceof Player player ? player : null, args); + if (component != null) + { + send(sender, component); + } + } + catch (PlayerNotFoundException | CommandFailException | ConsoleOnlyException | + ConsoleMustDefinePlayerException | PlayerNotBannedException | NumberFormatException ex) + { + send(sender, exceptionComponent(ex)); + } + return com.mojang.brigadier.Command.SINGLE_SUCCESS; + } + + private boolean canUse(CommandSourceStack source) + { + CommandSender sender = source.getSender(); + if (getRequiredSource() == RequiredCommandSource.CONSOLE && sender instanceof Player) + { + return false; + } + + if (getRequiredSource() == RequiredCommandSource.IN_GAME && sender instanceof ConsoleCommandSender) + { + return false; + } + + String permission = getPermission(); + return permission.isEmpty() || sender.hasPermission(permission); + } + + private boolean validateSourceAndPermission(CommandSender sender) + { + if (getRequiredSource() == RequiredCommandSource.CONSOLE && sender instanceof Player) + { + send(sender, messageComponent("noPermissionInGame")); + return false; + } + + if (getRequiredSource() == RequiredCommandSource.IN_GAME && sender instanceof ConsoleCommandSender) + { + send(sender, messageComponent("noPermissionConsole")); + return false; + } + + String permission = getPermission(); + if (!permission.isEmpty() && !sender.hasPermission(permission)) + { + send(sender, messageComponent("noPermissionNode", permission)); + return false; + } + return true; + } + + private CompletableFuture suggest(CommandContext context, SuggestionsBuilder builder) + { + CommandSender sender = context.getSource().getSender(); + if (!canUse(context.getSource())) + { + return builder.buildFuture(); + } + + String remaining = builder.getRemaining(); + String[] args = splitSuggestionArgs(remaining); + List completions = suggestions(sender, aliasFromInput(context.getInput()), args); + return suggestLastToken(builder, completions); + } + + private CompletableFuture suggestLastToken(SuggestionsBuilder builder, Collection suggestions) + { + String remaining = builder.getRemaining(); + int tokenStart = remaining.lastIndexOf(' ') + 1; + String currentToken = remaining.substring(tokenStart).toLowerCase(Locale.ROOT); + SuggestionsBuilder tokenBuilder = tokenStart == 0 ? builder : builder.createOffset(builder.getStart() + tokenStart); + for (String suggestion : suggestions) + { + if (suggestion.toLowerCase(Locale.ROOT).startsWith(currentToken)) + { + tokenBuilder.suggest(suggestion); + } + } + return tokenBuilder.buildFuture(); + } + + private String aliasFromInput(String input) + { + String trimmed = input.trim(); + if (trimmed.isEmpty()) + { + return getName(); + } + + String label = trimmed.split("\\s+", 2)[0]; + return label.startsWith("/") ? label.substring(1) : label; + } + + private String[] splitExecutionArgs(String rawArgs) + { + if (rawArgs.isBlank()) + { + return new String[0]; + } + return rawArgs.trim().split("\\s+"); + } + + private String[] splitSuggestionArgs(String rawArgs) + { + if (rawArgs.isEmpty()) + { + return new String[] {""}; + } + return rawArgs.stripLeading().split("\\s+", -1); + } + + private Component exceptionComponent(RuntimeException ex) + { + if (ex instanceof PlayerNotFoundException && "PlayerNotFoundException".equals(ex.getMessage())) + { + return messageComponent("playerNotFound"); + } + if (ex instanceof PlayerNotBannedException && "PlayerNotBannedException".equals(ex.getMessage())) + { + return messageComponent("playerNotBanned"); + } + if (ex instanceof ConsoleOnlyException && "ConsoleOnlyException".equals(ex.getMessage())) + { + return messageComponent("consoleOnly"); + } + if (ex instanceof ConsoleMustDefinePlayerException && "ConsoleMustDefinePlayerException".equals(ex.getMessage())) + { + return messageComponent("consoleMustDefinePlayer"); + } + String message = ex.getMessage(); + return message == null ? componentFromString(ex.getClass().getSimpleName()) : mmString(message); + } +} diff --git a/api/src/main/java/dev/plex/module/PlexModule.java b/api/src/main/java/dev/plex/module/PlexModule.java index c8bbe7a..7b372f5 100644 --- a/api/src/main/java/dev/plex/module/PlexModule.java +++ b/api/src/main/java/dev/plex/module/PlexModule.java @@ -117,6 +117,7 @@ public abstract class PlexModule */ public void registerCommand(PlexCommand command) { + bindCommand(command); commands.add(command); if (api != null) { @@ -277,6 +278,15 @@ public abstract class PlexModule public void setApi(PlexApi api) { this.api = api; + commands.forEach(this::bindCommand); + } + + private void bindCommand(PlexCommand command) + { + if (api != null) + { + command.bindApi(api); + } } /** diff --git a/server/src/main/java/dev/plex/api/impl/DefaultCommandApi.java b/server/src/main/java/dev/plex/api/impl/DefaultCommandApi.java index d116e4d..fdb7439 100644 --- a/server/src/main/java/dev/plex/api/impl/DefaultCommandApi.java +++ b/server/src/main/java/dev/plex/api/impl/DefaultCommandApi.java @@ -4,6 +4,7 @@ import dev.plex.Plex; import dev.plex.api.command.CommandApi; import dev.plex.command.PlexCommand; import dev.plex.util.PlexLog; +import java.util.List; final class DefaultCommandApi implements CommandApi { @@ -14,6 +15,7 @@ final class DefaultCommandApi implements CommandApi @Override public void register(PlexCommand command) { + command.bindApi(plugin.getApi()); if (plugin.getCommandHandler() == null) { plugin.getPendingCommands().add(command); @@ -32,6 +34,16 @@ final class DefaultCommandApi implements CommandApi } } + @Override + public List registeredCommands() + { + if (plugin.getCommandHandler() == null) + { + return List.copyOf(plugin.getPendingCommands()); + } + return plugin.getCommandHandler().getCommands(); + } + @Override public boolean requiresLifecycleReload() { diff --git a/server/src/main/java/dev/plex/handlers/CommandHandler.java b/server/src/main/java/dev/plex/handlers/CommandHandler.java index 1bb092e..472de82 100644 --- a/server/src/main/java/dev/plex/handlers/CommandHandler.java +++ b/server/src/main/java/dev/plex/handlers/CommandHandler.java @@ -50,6 +50,11 @@ public class CommandHandler return lifecycleReloadRequired; } + public List getCommands() + { + return List.copyOf(commands); + } + public @Nullable PlexCommand getCommand(String name) { String normalized = name.toLowerCase(Locale.ROOT);