More API work for modules

This commit is contained in:
2026-05-19 21:23:56 -04:00
parent cf15f33496
commit 2543c7f19e
6 changed files with 416 additions and 0 deletions
@@ -1,6 +1,7 @@
package dev.plex.api.command; package dev.plex.api.command;
import dev.plex.command.PlexCommand; import dev.plex.command.PlexCommand;
import java.util.List;
/** /**
* Registers and unregisters Plex commands with the running platform. * Registers and unregisters Plex commands with the running platform.
@@ -31,6 +32,13 @@ public interface CommandApi
*/ */
void unregister(PlexCommand command); void unregister(PlexCommand command);
/**
* Returns the commands currently tracked by Plex.
*
* @return registered commands
*/
List<PlexCommand> registeredCommands();
/** /**
* Returns whether command changes are staged for the next Paper command * Returns whether command changes are staged for the next Paper command
* lifecycle rebuild. * lifecycle rebuild.
@@ -1,6 +1,7 @@
package dev.plex.command; package dev.plex.command;
import com.mojang.brigadier.tree.LiteralCommandNode; import com.mojang.brigadier.tree.LiteralCommandNode;
import dev.plex.api.PlexApi;
import dev.plex.command.source.RequiredCommandSource; import dev.plex.command.source.RequiredCommandSource;
import io.papermc.paper.command.brigadier.CommandSourceStack; import io.papermc.paper.command.brigadier.CommandSourceStack;
import java.util.List; import java.util.List;
@@ -24,6 +25,18 @@ public interface PlexCommand
*/ */
LiteralCommandNode<CommandSourceStack> buildCommand(); LiteralCommandNode<CommandSourceStack> buildCommand();
/**
* Supplies the running Plex API to commands that need API helpers.
*
* <p>Most commands do not need to store the API directly, so the default
* implementation is intentionally empty.</p>
*
* @param api running Plex API
*/
default void bindApi(PlexApi api)
{
}
/** /**
* Returns the primary command name. * Returns the primary command name.
* *
@@ -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.
*
* <p>Commands that need a custom Brigadier tree can override
* {@link #configureCommand(LiteralArgumentBuilder)} while keeping the same
* metadata and helper methods.</p>
*/
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<CommandSourceStack> buildCommand()
{
LiteralArgumentBuilder<CommandSourceStack> command = Commands.literal(getName())
.requires(this::canUse);
configureCommand(command);
return command.build();
}
protected void configureCommand(LiteralArgumentBuilder<CommandSourceStack> 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<String> 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<String> 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<CommandSourceStack> 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<Suggestions> suggest(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder)
{
CommandSender sender = context.getSource().getSender();
if (!canUse(context.getSource()))
{
return builder.buildFuture();
}
String remaining = builder.getRemaining();
String[] args = splitSuggestionArgs(remaining);
List<String> completions = suggestions(sender, aliasFromInput(context.getInput()), args);
return suggestLastToken(builder, completions);
}
private CompletableFuture<Suggestions> suggestLastToken(SuggestionsBuilder builder, Collection<String> 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);
}
}
@@ -117,6 +117,7 @@ public abstract class PlexModule
*/ */
public void registerCommand(PlexCommand command) public void registerCommand(PlexCommand command)
{ {
bindCommand(command);
commands.add(command); commands.add(command);
if (api != null) if (api != null)
{ {
@@ -277,6 +278,15 @@ public abstract class PlexModule
public void setApi(PlexApi api) public void setApi(PlexApi api)
{ {
this.api = api; this.api = api;
commands.forEach(this::bindCommand);
}
private void bindCommand(PlexCommand command)
{
if (api != null)
{
command.bindApi(api);
}
} }
/** /**
@@ -4,6 +4,7 @@ import dev.plex.Plex;
import dev.plex.api.command.CommandApi; import dev.plex.api.command.CommandApi;
import dev.plex.command.PlexCommand; import dev.plex.command.PlexCommand;
import dev.plex.util.PlexLog; import dev.plex.util.PlexLog;
import java.util.List;
final class DefaultCommandApi implements CommandApi final class DefaultCommandApi implements CommandApi
{ {
@@ -14,6 +15,7 @@ final class DefaultCommandApi implements CommandApi
@Override @Override
public void register(PlexCommand command) public void register(PlexCommand command)
{ {
command.bindApi(plugin.getApi());
if (plugin.getCommandHandler() == null) if (plugin.getCommandHandler() == null)
{ {
plugin.getPendingCommands().add(command); plugin.getPendingCommands().add(command);
@@ -32,6 +34,16 @@ final class DefaultCommandApi implements CommandApi
} }
} }
@Override
public List<PlexCommand> registeredCommands()
{
if (plugin.getCommandHandler() == null)
{
return List.copyOf(plugin.getPendingCommands());
}
return plugin.getCommandHandler().getCommands();
}
@Override @Override
public boolean requiresLifecycleReload() public boolean requiresLifecycleReload()
{ {
@@ -50,6 +50,11 @@ public class CommandHandler
return lifecycleReloadRequired; return lifecycleReloadRequired;
} }
public List<PlexCommand> getCommands()
{
return List.copyOf(commands);
}
public @Nullable PlexCommand getCommand(String name) public @Nullable PlexCommand getCommand(String name)
{ {
String normalized = name.toLowerCase(Locale.ROOT); String normalized = name.toLowerCase(Locale.ROOT);