From 55834d6a936a37dd8f98cd8c7fd809d59cbf9d57 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 13:59:28 +0000 Subject: [PATCH 1/4] feat(core): 5.4.0, opt-in update check, unregisterAll, requirement names - The CommandManager constructor no longer calls Updater.checkUpdates(); -Dcommands.updater=false turns an explicit check off too, and versions are compared as versions so a newer or uncomparable build is never announced as outdated. - CommandManager#unregisterAll() removes every command the manager registered, and only those. - Requirement#name() fills %requirement% when errorMessage() is empty. - Spigot: an unregistered command is removed from the known commands, not just marked; Spigot and Velocity no longer remove a label another plugin holds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se3hcX6fCyfEU3PuabuUyn --- CHANGELOG.md | 32 +++++ README.md | 47 +++++++ .../traqueur/commands/api/CommandManager.java | 30 ++++- .../commands/api/models/CommandInvoker.java | 14 ++- .../api/requirements/Requirement.java | 16 +++ .../commands/api/updater/Updater.java | 22 +++- .../commands/api/updater/UpdaterSettings.java | 30 +++++ .../api/updater/VersionComparator.java | 78 ++++++++++++ .../commands/api/UnregisterAllTest.java | 66 ++++++++++ .../api/models/RequirementMessageTest.java | 91 ++++++++++++++ .../api/updater/UpdaterOptInTest.java | 117 ++++++++++++++++++ gradle.properties | 2 +- .../commands/spigot/SpigotPlatform.java | 68 +++++++++- .../commands/spigot/SpigotUnregisterTest.java | 85 +++++++++++++ .../commands/velocity/VelocityPlatform.java | 9 +- 15 files changed, 697 insertions(+), 10 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 core/src/main/java/fr/traqueur/commands/api/updater/UpdaterSettings.java create mode 100644 core/src/main/java/fr/traqueur/commands/api/updater/VersionComparator.java create mode 100644 core/src/test/java/fr/traqueur/commands/api/UnregisterAllTest.java create mode 100644 core/src/test/java/fr/traqueur/commands/api/models/RequirementMessageTest.java create mode 100644 core/src/test/java/fr/traqueur/commands/api/updater/UpdaterOptInTest.java create mode 100644 spigot/src/test/java/fr/traqueur/commands/spigot/SpigotUnregisterTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7094424 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +Every minor version ships one batch of changes. Nothing changes for code that does not use the +new APIs, except where a section says otherwise. + +## 5.4.0 + +### Changed + +- **No network call when a manager is created.** The `CommandManager` constructor no longer calls + `Updater.checkUpdates()`; call it yourself to check for a newer version. Visible change: the + "CommandsAPI is up to date" line is no longer logged at startup. +- `-Dcommands.updater=false` turns the update check off, even when a plugin calls it. +- The update check compares versions as versions: a local version newer than the published one + (a snapshot, a build named after a commit) is no longer announced as outdated, and a failed + check announces nothing. + +### Added + +- `CommandManager#unregisterAll()` removes every command the manager registered, with their + aliases and subcommands, for the shutdown of a plugin. Commands of other managers stay. +- `Requirement#name()` fills `%requirement%` in the generic requirement message, instead of the + class name. It defaults to the simple class name, so existing messages read the same. A + non-empty `errorMessage()` is still sent as-is. + +### Fixed + +- Spigot: unregistering a command now removes it from the command map's known commands, under + its label, aliases and namespaced forms. It used to be marked as unregistered but kept + answering. +- Spigot and Velocity: unregistering a label that another plugin holds (because this manager did + not get it) no longer removes that plugin's command. diff --git a/README.md b/README.md index a0b3019..a8f5be4 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,53 @@ exactly as the handler produced them. --- +## 🧹 Removing every command of a manager + +When a plugin stops, `unregisterAll()` removes every command its manager registered, with their +aliases and subcommands: + +```java +@Override +public void onDisable() { + manager.unregisterAll(); +} +``` + +Only the commands of **this** manager go: those of other managers, other plugins and the +server stay in place, including a label another plugin held and this manager did not get. + +--- + +## 🚧 Requirement messages + +A requirement that fails sends its `errorMessage()` as-is. When it is empty, the generic +`MessageHandler#getRequirementMessage()` is sent instead, and its `%requirement%` placeholder +is filled with `Requirement#name()` β€” the simple name of the requirement's class by default. +Override `name()` to show something readable: + +```java +@Override +public String name() { + return "Γͺtre dans le Nether"; +} +``` + +--- + +## πŸ”„ Update check + +Creating a `CommandManager` makes **no network call**. To be told about a newer version of the +library, ask for it: + +```java +Updater.checkUpdates(); // asynchronous, logs once +``` + +A failed check, or a version that cannot be compared (a build named after a commit), announces +nothing. A server can turn the check off for every plugin with `-Dcommands.updater=false`. + +--- + ## 🧱 Project Structure ``` diff --git a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java index d1f2d13..df8b1cd 100644 --- a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java +++ b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java @@ -46,6 +46,11 @@ public abstract class CommandManager { */ private final CommandTree commands; + /** + * The commands registered through {@link #registerCommand(Command)}, in registration order. + */ + private final Set> registered; + /** * The argument converters registered in the command manager. */ @@ -82,11 +87,14 @@ public abstract class CommandManager { /** * Create a new command manager. + *

+ * Since 5.4.0, creating a manager makes no network call: call {@link Updater#checkUpdates()} + * to check for a newer version of the library. + *

* * @param platform The platform of the command manager. */ public CommandManager(CommandPlatform platform) { - Updater.checkUpdates(); this.platform = platform; this.platform.injectManager(this); this.messageHandler = new InternalMessageHandler(); @@ -94,6 +102,7 @@ public CommandManager(CommandPlatform platform) { this.logger = new InternalLogger(platform.getLogger()); this.debug = false; this.commands = new CommandTree<>(); + this.registered = new LinkedHashSet<>(); this.typeConverters = new HashMap<>(); this.completers = new HashMap<>(); this.invoker = new CommandInvoker<>(this); @@ -200,6 +209,24 @@ public void registerCommand(Command command) { this.addCommand(command, label); this.registerSubCommands(label, command.getSubcommands()); } + this.registered.add(command); + } + + /** + * Unregister every command this manager registered, with their aliases and subcommands. + *

+ * Meant for the shutdown of a plugin: only the commands registered through this manager + * are removed, the commands of other managers, other plugins and the server stay in place. + * Calling it twice, or on a manager without commands, does nothing. + *

+ * + * @since 5.4.0 + */ + public void unregisterAll() { + for (Command command : new ArrayList<>(this.registered)) { + this.unregisterCommand(command, true); + } + this.registered.clear(); } /** @@ -244,6 +271,7 @@ public void unregisterCommand(Command command) { * @param subcommands If the subcommands must be unregistered. */ public void unregisterCommand(Command command, boolean subcommands) { + this.registered.remove(command); List labels = new ArrayList<>(command.getAllLabels()); for (String label : labels) { this.removeCommand(label, subcommands); diff --git a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java index 044026c..73b6e17 100644 --- a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java @@ -163,10 +163,22 @@ private boolean checkRequirements(S source, Command command) { private String buildRequirementMessage(Requirement req) { return req.errorMessage().isEmpty() ? manager.getMessageHandler().getRequirementMessage() - .replace("%requirement%", req.getClass().getSimpleName()) + .replace("%requirement%", requirementName(req)) : req.errorMessage(); } + /** + * Name a requirement for the generic message, falling back on its class name when + * {@link Requirement#name()} gives nothing usable. + * + * @param req the requirement + * @return the name to show + */ + private String requirementName(Requirement req) { + String name = req.name(); + return name == null || name.isBlank() ? req.getClass().getSimpleName() : name; + } + /** * Check if argument count is valid. * diff --git a/core/src/main/java/fr/traqueur/commands/api/requirements/Requirement.java b/core/src/main/java/fr/traqueur/commands/api/requirements/Requirement.java index eec0c19..16b9838 100644 --- a/core/src/main/java/fr/traqueur/commands/api/requirements/Requirement.java +++ b/core/src/main/java/fr/traqueur/commands/api/requirements/Requirement.java @@ -24,4 +24,20 @@ public interface Requirement { * @return The error message */ String errorMessage(); + + /** + * Get the name of the requirement, shown in the generic requirement message. + *

+ * When {@link #errorMessage()} is empty, the library sends the generic message of the + * {@link fr.traqueur.commands.api.logging.MessageHandler} and fills its + * {@code %requirement%} placeholder with this name. Override it to give the sender + * something more readable than a class name. + *

+ * + * @return The name of the requirement, the simple name of its class by default. + * @since 5.4.0 + */ + default String name() { + return this.getClass().getSimpleName(); + } } diff --git a/core/src/main/java/fr/traqueur/commands/api/updater/Updater.java b/core/src/main/java/fr/traqueur/commands/api/updater/Updater.java index 6b6b54e..ae98aca 100644 --- a/core/src/main/java/fr/traqueur/commands/api/updater/Updater.java +++ b/core/src/main/java/fr/traqueur/commands/api/updater/Updater.java @@ -54,17 +54,35 @@ public static void setLogger(Logger logger) { /* ------------------------------------------------------------ */ /** - * Async update check (non-blocking) + * Async update check (non-blocking). + *

+ * Nothing in the library calls this: since 5.4.0 a command manager no longer checks for + * updates when it is created, so the check only happens when the application asks for it. + * A server can still turn it off for every plugin with {@code -Dcommands.updater=false}, + * in which case this method does nothing. + *

+ *

+ * A version is announced as outdated only when the latest version is known and strictly + * newer than the current one: a failed check, or a version that cannot be compared (a + * build named after a commit), announces nothing. + *

*/ public static void checkUpdates() { + if (!UpdaterSettings.isEnabled()) { + return; + } fetchLatestVersionAsync().thenAccept(latest -> { if (latest == null) { return; } String current = getVersion(); + int comparison = VersionComparator.compare(current, latest); - if (!current.equals(latest)) { + if (comparison == VersionComparator.UNKNOWN) { + return; + } + if (comparison < 0) { LOGGER.warning("⚠ CommandsAPI is not up to date!"); LOGGER.warning("Current: " + current + " | Latest: " + latest); } else { diff --git a/core/src/main/java/fr/traqueur/commands/api/updater/UpdaterSettings.java b/core/src/main/java/fr/traqueur/commands/api/updater/UpdaterSettings.java new file mode 100644 index 0000000..74ab479 --- /dev/null +++ b/core/src/main/java/fr/traqueur/commands/api/updater/UpdaterSettings.java @@ -0,0 +1,30 @@ +package fr.traqueur.commands.api.updater; + +/** + * Reads the server-wide switch of the updater. + *

+ * Kept out of {@link Updater} so that the switch is honoured even when the updater's own + * static methods are replaced, and so that it can be read without touching the network. + *

+ * + * @since 5.4.0 + */ +final class UpdaterSettings { + + /** + * The system property that turns the update check off when set to {@code false}. + */ + static final String PROPERTY = "commands.updater"; + + private UpdaterSettings() { + } + + /** + * Tells whether the update check is allowed to run. + * + * @return false when {@code -Dcommands.updater=false} is set, true otherwise. + */ + static boolean isEnabled() { + return !"false".equalsIgnoreCase(System.getProperty(PROPERTY, "true").trim()); + } +} diff --git a/core/src/main/java/fr/traqueur/commands/api/updater/VersionComparator.java b/core/src/main/java/fr/traqueur/commands/api/updater/VersionComparator.java new file mode 100644 index 0000000..8eacf10 --- /dev/null +++ b/core/src/main/java/fr/traqueur/commands/api/updater/VersionComparator.java @@ -0,0 +1,78 @@ +package fr.traqueur.commands.api.updater; + +import java.util.Locale; + +/** + * Compares two versions of the library, such as {@code 5.3.1} and {@code 5.4.0-SNAPSHOT}. + *

+ * Numeric parts are compared as numbers, so {@code 5.10.0} is newer than {@code 5.9.0}. At + * equal numbers, a {@code -SNAPSHOT} (or any other qualifier) is older than the release. A + * version that does not start with a number, such as a build named after a commit, cannot be + * compared, and the result is {@link #UNKNOWN}. + *

+ * + * @since 5.4.0 + */ +final class VersionComparator { + + /** + * Returned when one of the versions cannot be compared. + */ + static final int UNKNOWN = Integer.MIN_VALUE; + + private VersionComparator() { + } + + /** + * Compares two versions. + * + * @param current The current version. + * @param latest The latest published version. + * @return a negative number when current is older, 0 when equal, a positive number when + * current is newer, or {@link #UNKNOWN} when they cannot be compared. + */ + static int compare(String current, String latest) { + Parsed a = parse(current); + Parsed b = parse(latest); + if (a == null || b == null) { + return UNKNOWN; + } + int length = Math.max(a.numbers.length, b.numbers.length); + for (int i = 0; i < length; i++) { + long x = i < a.numbers.length ? a.numbers[i] : 0; + long y = i < b.numbers.length ? b.numbers[i] : 0; + if (x != y) { + return x < y ? -1 : 1; + } + } + if (a.qualified == b.qualified) { + return 0; + } + return a.qualified ? -1 : 1; + } + + private static Parsed parse(String version) { + if (version == null) { + return null; + } + String trimmed = version.trim().toLowerCase(Locale.ROOT); + int dash = trimmed.indexOf('-'); + String numeric = dash < 0 ? trimmed : trimmed.substring(0, dash); + if (numeric.isEmpty()) { + return null; + } + String[] parts = numeric.split("\\."); + long[] numbers = new long[parts.length]; + for (int i = 0; i < parts.length; i++) { + try { + numbers[i] = Long.parseLong(parts[i]); + } catch (NumberFormatException e) { + return null; + } + } + return new Parsed(numbers, dash >= 0); + } + + private record Parsed(long[] numbers, boolean qualified) { + } +} diff --git a/core/src/test/java/fr/traqueur/commands/api/UnregisterAllTest.java b/core/src/test/java/fr/traqueur/commands/api/UnregisterAllTest.java new file mode 100644 index 0000000..023ec48 --- /dev/null +++ b/core/src/test/java/fr/traqueur/commands/api/UnregisterAllTest.java @@ -0,0 +1,66 @@ +package fr.traqueur.commands.api; + +import fr.traqueur.commands.api.arguments.Arguments; +import fr.traqueur.commands.api.models.Command; +import fr.traqueur.commands.test.mocks.MockCommandManager; +import fr.traqueur.commands.test.mocks.MockSender; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class UnregisterAllTest { + + @Test + void unregisterAll_removesEveryCommandAliasAndSubcommand() { + MockCommandManager manager = new MockCommandManager(); + Command home = command("home"); + home.addAlias("h"); + home.addSubCommand(command("set")); + manager.registerCommand(home); + manager.registerCommand(command("spawn")); + + manager.unregisterAll(); + + assertTrue(manager.getMockPlatform().getRegisteredLabels().isEmpty()); + assertTrue(manager.getCommands().getRoot().getChildren().isEmpty()); + assertTrue(manager.getCompleters().isEmpty()); + assertFalse(manager.getInvoker().invoke(null, "home", new String[0])); + } + + @Test + void unregisterAll_leavesTheCommandsOfAnotherManager() { + MockCommandManager first = new MockCommandManager(); + MockCommandManager second = new MockCommandManager(); + first.registerCommand(command("home")); + Command warp = command("warp"); + warp.addSubCommand(command("list")); + second.registerCommand(warp); + + first.unregisterAll(); + + assertTrue(second.getMockPlatform().hasCommand("warp")); + assertTrue(second.getMockPlatform().hasCommand("warp.list")); + assertTrue(second.getCommands().findNode(new String[]{"warp", "list"}).isPresent()); + } + + @Test + void unregisterAll_skipsCommandsAlreadyUnregistered_andCanBeCalledTwice() { + MockCommandManager manager = new MockCommandManager(); + Command home = command("home"); + manager.registerCommand(home); + manager.registerCommand(command("spawn")); + manager.unregisterCommand(home); + + assertDoesNotThrow(manager::unregisterAll); + assertDoesNotThrow(manager::unregisterAll); + assertTrue(manager.getMockPlatform().getRegisteredLabels().isEmpty()); + } + + private static Command command(String name) { + return new Command<>(null, name) { + @Override + public void execute(MockSender sender, Arguments arguments) { + } + }; + } +} diff --git a/core/src/test/java/fr/traqueur/commands/api/models/RequirementMessageTest.java b/core/src/test/java/fr/traqueur/commands/api/models/RequirementMessageTest.java new file mode 100644 index 0000000..116e391 --- /dev/null +++ b/core/src/test/java/fr/traqueur/commands/api/models/RequirementMessageTest.java @@ -0,0 +1,91 @@ +package fr.traqueur.commands.api.models; + +import fr.traqueur.commands.api.requirements.Requirement; +import fr.traqueur.commands.test.mocks.MockCommandManager; +import fr.traqueur.commands.test.mocks.MockSender; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RequirementMessageTest { + + @Test + void emptyErrorMessage_fillsThePlaceholderWithTheName() { + List received = invokeWith(new Requirement<>() { + @Override + public boolean check(MockSender sender) { + return false; + } + + @Override + public String errorMessage() { + return ""; + } + + @Override + public String name() { + return "Γͺtre dans le Nether"; + } + }); + + assertEquals(List.of("The requirement Γͺtre dans le Nether was not met"), received); + } + + @Test + void defaultName_isTheSimpleClassName() { + assertEquals(List.of("The requirement InNether was not met"), invokeWith(new InNether())); + } + + @Test + void errorMessage_isStillSentAsIs() { + InNether requirement = new InNether() { + @Override + public String errorMessage() { + return "Go to the Nether first."; + } + }; + + assertEquals(List.of("Go to the Nether first."), invokeWith(requirement)); + } + + private static List invokeWith(Requirement requirement) { + MockCommandManager manager = new MockCommandManager(); + Command command = new Command<>(null, "nether") { + @Override + public void execute(MockSender sender, fr.traqueur.commands.api.arguments.Arguments arguments) { + } + }; + command.addRequirements(requirement); + manager.registerCommand(command); + + List received = new ArrayList<>(); + MockSender sender = new MockSender() { + @Override + public void sendMessage(String message) { + received.add(message); + } + + @Override + public boolean hasPermission(String permission) { + return true; + } + }; + manager.getInvoker().invoke(sender, "nether", new String[0]); + return received; + } + + static class InNether implements Requirement { + @Override + public boolean check(MockSender sender) { + return false; + } + + @Override + public String errorMessage() { + return ""; + } + } +} diff --git a/core/src/test/java/fr/traqueur/commands/api/updater/UpdaterOptInTest.java b/core/src/test/java/fr/traqueur/commands/api/updater/UpdaterOptInTest.java new file mode 100644 index 0000000..92bcb45 --- /dev/null +++ b/core/src/test/java/fr/traqueur/commands/api/updater/UpdaterOptInTest.java @@ -0,0 +1,117 @@ +package fr.traqueur.commands.api.updater; + +import fr.traqueur.commands.test.mocks.MockCommandManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; + +class UpdaterOptInTest { + + private Logger logger; + private final List records = new ArrayList<>(); + private final Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + @BeforeEach + void setUp() { + logger = Logger.getLogger("CommandsAPI-optin"); + logger.addHandler(handler); + logger.setLevel(Level.ALL); + Updater.setLogger(logger); + } + + @AfterEach + void tearDown() { + logger.removeHandler(handler); + Updater.setLogger(Logger.getLogger("CommandsAPI")); + System.clearProperty(UpdaterSettings.PROPERTY); + } + + @Test + void creatingAManager_makesNoUpdateCheck() { + try (MockedStatic updater = mockStatic(Updater.class)) { + new MockCommandManager(); + new MockCommandManager(); + + updater.verifyNoInteractions(); + } + } + + @Test + void systemPropertyFalse_skipsAnExplicitCheck() { + System.setProperty(UpdaterSettings.PROPERTY, "false"); + try (MockedStatic updater = mockStatic(Updater.class)) { + updater.when(Updater::checkUpdates).thenCallRealMethod(); + + Updater.checkUpdates(); + + updater.verify(Updater::fetchLatestVersionAsync, org.mockito.Mockito.never()); + } + } + + @Test + void failedCheck_announcesNothing() { + assertTrue(runCheck("5.3.1", null).isEmpty()); + } + + @Test + void newerLocalVersion_isNotAnnouncedAsOutdated() { + assertTrue(runCheck("5.4.0-SNAPSHOT", "5.3.1").stream().noneMatch(r -> r.getLevel() == Level.WARNING)); + } + + @Test + void commitBuild_announcesNothing() { + assertTrue(runCheck("d64670c", "5.3.1").isEmpty()); + } + + @Test + void olderVersion_isAnnouncedAsOutdated() { + assertTrue(runCheck("5.3.1", "5.10.0").stream().anyMatch(r -> r.getLevel() == Level.WARNING)); + } + + @Test + void versionComparison() { + assertTrue(VersionComparator.compare("5.9.0", "5.10.0") < 0); + assertTrue(VersionComparator.compare("5.4.0-SNAPSHOT", "5.4.0") < 0); + assertTrue(VersionComparator.compare("5.4.0", "5.4.0-SNAPSHOT") > 0); + assertEquals(0, VersionComparator.compare("5.4", "5.4.0")); + assertEquals(VersionComparator.UNKNOWN, VersionComparator.compare("abc", "5.4.0")); + assertEquals(VersionComparator.UNKNOWN, VersionComparator.compare(null, "5.4.0")); + } + + private List runCheck(String current, String latest) { + try (MockedStatic updater = mockStatic(Updater.class)) { + updater.when(Updater::checkUpdates).thenCallRealMethod(); + updater.when(Updater::getVersion).thenReturn(current); + updater.when(Updater::fetchLatestVersionAsync).thenReturn(CompletableFuture.completedFuture(latest)); + + Updater.checkUpdates(); + } + return records; + } +} diff --git a/gradle.properties b/gradle.properties index e77a658..5f35ef9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=5.3.1 +version=5.4.0 diff --git a/spigot/src/main/java/fr/traqueur/commands/spigot/SpigotPlatform.java b/spigot/src/main/java/fr/traqueur/commands/spigot/SpigotPlatform.java index ac40a9c..b8d4cbf 100644 --- a/spigot/src/main/java/fr/traqueur/commands/spigot/SpigotPlatform.java +++ b/spigot/src/main/java/fr/traqueur/commands/spigot/SpigotPlatform.java @@ -17,11 +17,12 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Locale; -import java.util.Objects; +import java.util.Map; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -82,6 +83,23 @@ public SpigotPlatform(T plugin) { } + /** + * Constructor for tests, with a command map given instead of the server's. + * + * @param plugin The plugin. + * @param commandMap The command map to register into. + */ + SpigotPlatform(T plugin, CommandMap commandMap) { + this.plugin = plugin; + this.commandMap = commandMap; + try { + pluginConstructor = PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class); + pluginConstructor.setAccessible(true); + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } + /** * {@inheritDoc} */ @@ -288,9 +306,53 @@ private String namespaceOf(org.bukkit.command.Command command, String label) { */ @Override public void removeCommand(String label, boolean subcommand) { - if (subcommand && this.commandMap.getCommand(label) != null) { - Objects.requireNonNull(this.commandMap.getCommand(label)).unregister(commandMap); + if (!subcommand) { + return; + } + org.bukkit.command.Command existing = this.commandMap.getCommand(label); + // Only our own registration goes: on a label we did not get, the command in the map belongs + // to the server or to another plugin, and removing it would break that plugin. + if (!(existing instanceof PluginCommand pluginCommand) || pluginCommand.getExecutor() != spigotExecutor) { + return; + } + // Command#unregister only marks the command as detached: the map keeps dispatching to it + // through its known commands, under the label, the aliases and the namespaced forms. + Map known = this.knownCommands(); + if (known != null) { + known.values().removeIf(registered -> registered == existing); + } + existing.unregister(commandMap); + } + + /** + * Finds the live map of the commands known to the command map. + *

+ * Paper exposes it as {@code getKnownCommands()}; Spigot keeps it in the protected + * {@code knownCommands} field of {@code SimpleCommandMap}. + *

+ * + * @return The known commands, or null when the command map does not expose them. + */ + @SuppressWarnings("unchecked") + private Map knownCommands() { + try { + Method getter = this.commandMap.getClass().getMethod("getKnownCommands"); + return (Map) getter.invoke(this.commandMap); + } catch (ReflectiveOperationException | RuntimeException ignored) { + // Not a Paper command map: look for the Spigot field. + } + for (Class type = this.commandMap.getClass(); type != null; type = type.getSuperclass()) { + try { + Field field = type.getDeclaredField("knownCommands"); + field.setAccessible(true); + return (Map) field.get(this.commandMap); + } catch (NoSuchFieldException ignored) { + // Keep looking up the hierarchy. + } catch (ReflectiveOperationException | RuntimeException e) { + return null; + } } + return null; } @Override diff --git a/spigot/src/test/java/fr/traqueur/commands/spigot/SpigotUnregisterTest.java b/spigot/src/test/java/fr/traqueur/commands/spigot/SpigotUnregisterTest.java new file mode 100644 index 0000000..cb73be3 --- /dev/null +++ b/spigot/src/test/java/fr/traqueur/commands/spigot/SpigotUnregisterTest.java @@ -0,0 +1,85 @@ +package fr.traqueur.commands.spigot; + +import fr.traqueur.commands.api.CommandManager; +import fr.traqueur.commands.api.arguments.Arguments; +import fr.traqueur.commands.api.models.Command; +import org.bukkit.Server; +import org.bukkit.command.CommandSender; +import org.bukkit.command.SimpleCommandMap; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SpigotUnregisterTest { + + private SimpleCommandMap commandMap; + private CommandManager homes; + private CommandManager warps; + + @BeforeEach + void setUp() { + commandMap = new SimpleCommandMap(mock(Server.class)); + homes = manager("Homes"); + warps = manager("Warps"); + } + + @Test + void unregisterAll_removesTheCommandAndItsAliasesFromTheMap() { + Command home = command("home"); + home.addAlias("h"); + homes.registerCommand(home); + assertNotNull(commandMap.getCommand("home")); + assertNotNull(commandMap.getCommand("h")); + assertNotNull(commandMap.getCommand("homes:home")); + + homes.unregisterAll(); + + assertNull(commandMap.getCommand("home")); + assertNull(commandMap.getCommand("h")); + assertNull(commandMap.getCommand("homes:home")); + assertNull(commandMap.getCommand("homes:h")); + } + + @Test + void unregisterAll_leavesTheCommandsOfAnotherManager() { + homes.registerCommand(command("home")); + warps.registerCommand(command("warp")); + + homes.unregisterAll(); + + assertNull(commandMap.getCommand("home")); + assertNotNull(commandMap.getCommand("warp")); + assertNotNull(commandMap.getCommand("warps:warp")); + } + + @Test + void unregisterAll_leavesALabelHeldByAnotherPlugin() { + warps.registerCommand(command("spawn")); + // Homes asks for the same label without overriding: Warps keeps it. + homes.registerCommand(command("spawn")); + org.bukkit.command.Command holder = commandMap.getCommand("spawn"); + + homes.unregisterAll(); + + assertSame(holder, commandMap.getCommand("spawn")); + } + + private CommandManager manager(String name) { + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getName()).thenReturn(name); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + return new CommandManager<>(new SpigotPlatform<>(plugin, commandMap)) { + }; + } + + private static Command command(String name) { + return new Command<>(null, name) { + @Override + public void execute(CommandSender sender, Arguments arguments) { + } + }; + } +} diff --git a/velocity/src/main/java/fr/traqueur/commands/velocity/VelocityPlatform.java b/velocity/src/main/java/fr/traqueur/commands/velocity/VelocityPlatform.java index 223d7da..525c35b 100644 --- a/velocity/src/main/java/fr/traqueur/commands/velocity/VelocityPlatform.java +++ b/velocity/src/main/java/fr/traqueur/commands/velocity/VelocityPlatform.java @@ -163,8 +163,13 @@ public void addCommand(Command command, String label) { */ @Override public void removeCommand(String label, boolean subcommand) { - if (subcommand && this.server.getCommandManager().getCommandMeta(label) != null) { - this.server.getCommandManager().unregister(this.server.getCommandManager().getCommandMeta(label)); + com.velocitypowered.api.command.CommandMeta meta = this.server.getCommandManager().getCommandMeta(label); + // Only our own registration goes: a label we did not get belongs to another plugin. + if (meta == null || !this.plugin.equals(meta.getPlugin())) { + return; + } + if (subcommand) { + this.server.getCommandManager().unregister(meta); } else { this.server.getCommandManager().unregister(label); } From b993294b7e6cddf046726a4bc20b0bb4a42f77c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:03:01 +0000 Subject: [PATCH 2/4] feat(adventure): 5.5.0, library messages as Adventure components A new adventure-addon module adds ComponentMessageFormatter: it receives each message by its MessageKey with its values (arg, requirement, usage) apart, and the component it builds is sent as a component, so click and hover events survive. The Velocity manager exposes setComponentFormatter. The core stays free of Adventure: it gains MessageKey, MessageDispatcher and CommandManager#sendMessage(receiver, key, text, values), which falls back on the unchanged text path when no dispatcher is set or it declines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se3hcX6fCyfEU3PuabuUyn --- CHANGELOG.md | 17 ++ README.md | 41 +++- adventure-addon/build.gradle | 9 + .../adventure/ComponentMessageFormatter.java | 106 +++++++++++ .../commands/adventure/ComponentMessages.java | 89 +++++++++ .../ComponentMessageFormatterTest.java | 176 ++++++++++++++++++ bom/build.gradle | 1 + build.gradle | 2 + .../traqueur/commands/api/CommandManager.java | 54 ++++++ .../api/logging/MessageDispatcher.java | 33 ++++ .../commands/api/logging/MessageHandler.java | 25 +++ .../commands/api/logging/MessageKey.java | 82 ++++++++ .../commands/api/models/CommandInvoker.java | 36 ++-- .../api/logging/MessageDispatcherTest.java | 83 +++++++++ gradle.properties | 2 +- settings.gradle | 3 +- velocity/build.gradle | 1 + .../commands/velocity/CommandManager.java | 28 +++ 18 files changed, 766 insertions(+), 22 deletions(-) create mode 100644 adventure-addon/build.gradle create mode 100644 adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java create mode 100644 adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessages.java create mode 100644 adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java create mode 100644 core/src/main/java/fr/traqueur/commands/api/logging/MessageDispatcher.java create mode 100644 core/src/main/java/fr/traqueur/commands/api/logging/MessageKey.java create mode 100644 core/src/test/java/fr/traqueur/commands/api/logging/MessageDispatcherTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7094424..5726b50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ Every minor version ships one batch of changes. Nothing changes for code that does not use the new APIs, except where a section says otherwise. +## 5.5.0 + +### Added + +- **Messages as `Component`** on Paper and Velocity: `manager.setComponentFormatter(...)` with a + `ComponentMessageFormatter`, which receives the `MessageKey` and the values (`arg`, + `requirement`, `usage`) apart, and whose component is sent as a component, click and hover + events included. `ComponentMessageFormatter.miniMessage(...)` reads MiniMessage templates, from a + function or from a `MessageHandler`. +- New `adventure-addon` module holding it (`ComponentMessages.install` for other Adventure + platforms); the Velocity platform depends on it. +- Core: `MessageKey`, `MessageDispatcher` and `CommandManager#setMessageDispatcher` / + `#sendMessage(receiver, key, text, values)`, the platform-neutral hook the component formatter + uses. `MessageHandler#getMessage(MessageKey)` gives the text of a key. + +Without a component formatter or a dispatcher, messages are sent exactly as in 5.4.0. + ## 5.4.0 ### Changed diff --git a/README.md b/README.md index a8f5be4..952f647 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,41 @@ the JDA platform, and it receives the message **after** the internal placeholder `null` restores the default behaviour: with no formatter registered, messages are sent exactly as the handler produced them. -> ⚠️ Messages are plain `String` in and out, because the `core` module also serves platforms -> without Adventure, such as JDA. A consumer working with `Component` has to serialize it, -> usually to the legacy `Β§` format, which drops `hover` and `click` events β€” command messages -> such as "you do not have permission" do not need them, and honouring them would mean -> pulling Adventure into `core`. +> ℹ️ The text formatter works on plain `String`, because the `core` module also serves +> platforms without Adventure, such as JDA. To keep `click` and `hover` events, use a +> component formatter (below) on Paper and Velocity. + +### Messages as `Component` (Paper, Velocity) + +A **component formatter** builds each message as an Adventure `Component`, which is then sent +as a component β€” click and hover events reach the player intact: + +```java +public interface ComponentMessageFormatter { + Component format(S receiver, MessageKey key, Map placeholders); +} +``` + +The message comes by its `MessageKey` (`NO_PERMISSION`, `ONLY_IN_GAME`, `ARG_NOT_RECOGNIZED`, +`REQUIREMENT`, `DISABLED`, `TOO_LONG`, `INVALID_FORMAT`, `USAGE`, `INTERNAL_ERROR`), and its values +come apart, never substituted in the text: `arg` (the input), `requirement` (the requirement's +`errorMessage()`, or its `name()` when empty) and `usage`. + +```java +// MiniMessage templates from the server's configuration; values become , , +manager.setComponentFormatter(ComponentMessageFormatter.miniMessage( + key -> getConfig().getString("messages." + key.name().toLowerCase()))); + +// Or keep the texts in a MessageHandler written in MiniMessage: %arg% is read as +manager.setComponentFormatter(ComponentMessageFormatter.miniMessage(myHandler)); +``` + +Values are inserted as plain text: an argument typed as `abc` is shown as typed. A formatter +returning `null` for a key sends that message as text, through the `MessageHandler` and the text +formatter, as before. With a component formatter set, the text formatter only sees those messages. + +The formatter lives in the `adventure-addon` module, pulled in by the Paper and Velocity +platforms. On another Adventure platform, install it with `ComponentMessages.install(manager, formatter)`. --- @@ -140,6 +170,7 @@ nothing. A server can turn the check off for every plugin with `-Dcommands.updat traqueur-dev-commandsapi/ β”œβ”€β”€ core/ # Platform-agnostic command logic β”œβ”€β”€ spigot/ # Spigot implementation +β”œβ”€β”€ adventure-addon/ # Component messages, for Adventure platforms β”œβ”€β”€ -test-plugin/ # The test plugin for the specified platform └── velocity/ # Velocity implementation ``` diff --git a/adventure-addon/build.gradle b/adventure-addon/build.gradle new file mode 100644 index 0000000..990b384 --- /dev/null +++ b/adventure-addon/build.gradle @@ -0,0 +1,9 @@ +dependencies { + api project(':core') + compileOnly 'net.kyori:adventure-api:4.17.0' + compileOnly 'net.kyori:adventure-text-minimessage:4.17.0' + testImplementation 'net.kyori:adventure-api:4.17.0' + testImplementation 'net.kyori:adventure-text-minimessage:4.17.0' + testImplementation 'net.kyori:adventure-text-serializer-plain:4.17.0' + testImplementation project(':core').sourceSets.test.output +} diff --git a/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java b/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java new file mode 100644 index 0000000..1da20af --- /dev/null +++ b/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java @@ -0,0 +1,106 @@ +package fr.traqueur.commands.adventure; + +import fr.traqueur.commands.api.logging.MessageHandler; +import fr.traqueur.commands.api.logging.MessageKey; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; + +import java.util.Map; +import java.util.function.Function; + +/** + * Builds the {@link Component} of a library message, on the platforms that speak Adventure. + *

+ * Unlike the text {@link fr.traqueur.commands.api.logging.MessageFormatter}, it receives the + * message by its {@link MessageKey} and its values apart ({@code arg}, {@code requirement}, + * {@code usage}, see {@link MessageKey}), never substituted in a text: a value typed by a + * player cannot inject formatting, and click and hover events reach the sender intact, because + * the component is sent as a component. + *

+ *
{@code
+ * manager.setComponentFormatter(ComponentMessageFormatter.miniMessage(key -> config.getString("messages." + key)));
+ * }
+ * + * @param The type of the sender the message is sent to. + * @since 5.5.0 + */ +@FunctionalInterface +public interface ComponentMessageFormatter { + + /** + * Build a message. + * + * @param receiver The sender the message is sent to. + * @param key The message. + * @param placeholders Its named values, see {@link MessageKey}. + * @return The component to send, or null to send the message as text, as without a + * component formatter. + */ + Component format(S receiver, MessageKey key, Map placeholders); + + /** + * A formatter reading MiniMessage templates, with the values as {@code }, + * {@code } and {@code } tags. + *

+ * The values are inserted as plain text: a value holding MiniMessage tags is shown as typed, + * it is not parsed. A key whose template is null falls back to the text path. + *

+ * + * @param templates The MiniMessage template of each key. + * @param The type of the sender. + * @return The formatter. + */ + static ComponentMessageFormatter miniMessage(Function templates) { + return miniMessage(MiniMessage.miniMessage(), templates); + } + + /** + * A formatter reading MiniMessage templates with a given MiniMessage instance. + * + * @param miniMessage The MiniMessage instance, with its own tags. + * @param templates The MiniMessage template of each key. + * @param The type of the sender. + * @return The formatter. + * @see #miniMessage(Function) + */ + static ComponentMessageFormatter miniMessage(MiniMessage miniMessage, Function templates) { + return (receiver, key, placeholders) -> { + String template = templates.apply(key); + if (template == null) { + return null; + } + TagResolver.Builder resolver = TagResolver.builder(); + placeholders.forEach((name, value) -> resolver.resolver(Placeholder.unparsed(name, value))); + return miniMessage.deserialize(template, resolver.build()); + }; + } + + /** + * A formatter reading MiniMessage templates from a {@link MessageHandler}. + *

+ * The legacy {@code %arg%}, {@code %requirement%} and {@code %usage%} placeholders of the + * handler's texts are read as the {@code }, {@code } and {@code } tags. + * The usage, which the handler does not hold, is sent as plain text. + *

+ * + * @param handler The handler holding MiniMessage texts. + * @param The type of the sender. + * @return The formatter. + */ + static ComponentMessageFormatter miniMessage(MessageHandler handler) { + return miniMessage(key -> { + if (key == MessageKey.USAGE) { + return "<" + MessageKey.USAGE_VALUE + ">"; + } + String text = handler.getMessage(key); + if (text == null) { + return null; + } + return text.replace("%" + MessageKey.ARG + "%", "<" + MessageKey.ARG + ">") + .replace("%" + MessageKey.REQUIREMENT_VALUE + "%", "<" + MessageKey.REQUIREMENT_VALUE + ">") + .replace("%" + MessageKey.USAGE_VALUE + "%", "<" + MessageKey.USAGE_VALUE + ">"); + }); + } +} diff --git a/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessages.java b/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessages.java new file mode 100644 index 0000000..fe6923e --- /dev/null +++ b/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessages.java @@ -0,0 +1,89 @@ +package fr.traqueur.commands.adventure; + +import fr.traqueur.commands.api.CommandManager; +import fr.traqueur.commands.api.logging.MessageDispatcher; +import fr.traqueur.commands.api.logging.MessageKey; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; + +import java.util.Map; +import java.util.function.BiConsumer; + +/** + * Plugs a {@link ComponentMessageFormatter} into a command manager. + *

+ * The Paper and Velocity managers do this in their {@code setComponentFormatter}; use this + * class for another Adventure platform. + *

+ * + * @since 5.5.0 + */ +public final class ComponentMessages { + + private ComponentMessages() { + } + + /** + * Install a component formatter on a manager whose senders are Adventure audiences. + * + * @param manager The manager. + * @param formatter The formatter, or null to send every message as text again. + * @param The type of the sender. + */ + public static void install(CommandManager manager, ComponentMessageFormatter formatter) { + install(manager, formatter, Audience::sendMessage); + } + + /** + * Install a component formatter on a manager, with the way a component reaches a sender. + * + * @param manager The manager. + * @param formatter The formatter, or null to send every message as text again. + * @param sender Sends a component to a sender. + * @param The type of the sender. + */ + public static void install(CommandManager manager, ComponentMessageFormatter formatter, + BiConsumer sender) { + manager.setMessageDispatcher(formatter == null ? null : dispatcher(formatter, sender)); + } + + /** + * Turn a component formatter into the dispatcher of the core. + * + * @param formatter The formatter. + * @param sender Sends a component to a sender. + * @param The type of the sender. + * @return The dispatcher. + */ + public static MessageDispatcher dispatcher(ComponentMessageFormatter formatter, BiConsumer sender) { + return new Dispatcher<>(formatter, sender); + } + + /** + * Get back the component formatter installed on a manager. + * + * @param manager The manager. + * @param The type of the sender. + * @return The formatter, or null when none is installed. + */ + @SuppressWarnings("unchecked") + public static ComponentMessageFormatter formatterOf(CommandManager manager) { + return manager.getMessageDispatcher() instanceof Dispatcher dispatcher + ? (ComponentMessageFormatter) dispatcher.formatter + : null; + } + + private record Dispatcher(ComponentMessageFormatter formatter, + BiConsumer sender) implements MessageDispatcher { + + @Override + public boolean dispatch(S receiver, MessageKey key, Map placeholders) { + Component component = formatter.format(receiver, key, placeholders); + if (component == null) { + return false; + } + sender.accept(receiver, component); + return true; + } + } +} diff --git a/adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java b/adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java new file mode 100644 index 0000000..fc7a34d --- /dev/null +++ b/adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java @@ -0,0 +1,176 @@ +package fr.traqueur.commands.adventure; + +import fr.traqueur.commands.api.arguments.Arguments; +import fr.traqueur.commands.api.logging.MessageKey; +import fr.traqueur.commands.api.models.Command; +import fr.traqueur.commands.api.requirements.Requirement; +import fr.traqueur.commands.test.mocks.MockCommandManager; +import fr.traqueur.commands.test.mocks.MockSender; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ComponentMessageFormatterTest { + + private MockCommandManager manager; + private final List components = new ArrayList<>(); + private final List texts = new ArrayList<>(); + private MockSender sender; + + @BeforeEach + void setUp() { + manager = new MockCommandManager(); + sender = new MockSender() { + @Override + public void sendMessage(String message) { + texts.add(message); + } + + @Override + public boolean hasPermission(String permission) { + return false; + } + }; + } + + @Test + void clickAndHover_reachTheSenderIntact() { + Map templates = new EnumMap<>(MessageKey.class); + templates.put(MessageKey.NO_PERMISSION, + "Interdit. Retour"); + install(ComponentMessageFormatter.miniMessage(templates::get)); + Command command = command("admin"); + command.setPermission("admin.use"); + manager.registerCommand(command); + + manager.getInvoker().invoke(sender, "admin", new String[0]); + + assertTrue(texts.isEmpty(), "the message must not go through the text path"); + assertEquals(1, components.size()); + Component link = find(components.get(0), "Retour"); + assertNotNull(link); + assertEquals(ClickEvent.runCommand("/spawn"), link.clickEvent()); + HoverEvent hover = link.hoverEvent(); + assertNotNull(hover); + assertEquals("Retour au spawn", PlainTextComponentSerializer.plainText().serialize((Component) hover.value())); + } + + @Test + void values_arePassedApart_andInsertedAsPlainText() { + Map> seen = new HashMap<>(); + ComponentMessageFormatter templates = ComponentMessageFormatter.miniMessage( + key -> key == MessageKey.ARG_NOT_RECOGNIZED ? "Inconnu : " : null); + install((receiver, key, placeholders) -> { + seen.put(key, placeholders); + return templates.format(receiver, key, placeholders); + }); + Command command = command("give"); + command.addArgs("amount", Integer.class); + manager.registerCommand(command); + + manager.getInvoker().invoke(sender, "give", new String[]{"dix"}); + + assertEquals(Map.of(MessageKey.ARG, "dix"), seen.get(MessageKey.ARG_NOT_RECOGNIZED)); + assertEquals("Inconnu : dix", PlainTextComponentSerializer.plainText().serialize(components.get(0))); + } + + @Test + void requirementValue_isTheErrorMessage_orTheName() { + Map values = new HashMap<>(); + install((receiver, key, placeholders) -> { + values.putAll(placeholders); + return Component.empty(); + }); + Command command = command("nether"); + command.addRequirements(new Requirement<>() { + @Override + public boolean check(MockSender s) { + return false; + } + + @Override + public String errorMessage() { + return "Va dans le Nether."; + } + }); + manager.registerCommand(command); + + manager.getInvoker().invoke(sender, "nether", new String[0]); + + assertEquals(Map.of(MessageKey.REQUIREMENT_VALUE, "Va dans le Nether."), values); + } + + @Test + void nullComponent_fallsBackOnTheTextPath() { + install((receiver, key, placeholders) -> null); + manager.setMessageFormatter((s, raw) -> "[p] " + raw); + Command command = command("admin"); + command.setPermission("admin.use"); + manager.registerCommand(command); + + manager.getInvoker().invoke(sender, "admin", new String[0]); + + assertTrue(components.isEmpty()); + assertEquals(List.of("[p] &cYou do not have permission to use this command."), texts); + } + + @Test + void removingTheFormatter_restoresTheTextPath() { + install((receiver, key, placeholders) -> Component.text("x")); + assertNotNull(ComponentMessages.formatterOf(manager)); + ComponentMessages.install(manager, null, (s, c) -> components.add(c)); + assertNull(manager.getMessageDispatcher()); + } + + @Test + void handlerTemplates_mapTheLegacyPlaceholders() { + ComponentMessageFormatter formatter = ComponentMessageFormatter.miniMessage( + new fr.traqueur.commands.impl.logging.InternalMessageHandler() { + @Override + public String getArgNotRecognized() { + return "Argument %arg% inconnu"; + } + }); + + Component component = formatter.format(sender, MessageKey.ARG_NOT_RECOGNIZED, Map.of(MessageKey.ARG, "abc")); + + assertEquals("Argument abc inconnu", PlainTextComponentSerializer.plainText().serialize(component)); + assertNull(formatter.format(sender, MessageKey.INTERNAL_ERROR, Map.of())); + } + + private void install(ComponentMessageFormatter formatter) { + ComponentMessages.install(manager, formatter, (receiver, component) -> components.add(component)); + } + + private static Component find(Component root, String content) { + if (root instanceof net.kyori.adventure.text.TextComponent text && text.content().equals(content)) { + return root; + } + for (Component child : root.children()) { + Component found = find(child, content); + if (found != null) { + return found; + } + } + return null; + } + + private static Command command(String name) { + return new Command<>(null, name) { + @Override + public void execute(MockSender s, Arguments arguments) { + } + }; + } +} diff --git a/bom/build.gradle b/bom/build.gradle index cc95d7b..aeb0b36 100644 --- a/bom/build.gradle +++ b/bom/build.gradle @@ -16,6 +16,7 @@ dependencies { api(project(':velocity')) api(project(':jda')) api(project(':annotations-addon')) + api(project(':adventure-addon')) } } diff --git a/build.gradle b/build.gradle index f6300d2..c667de7 100644 --- a/build.gradle +++ b/build.gradle @@ -105,6 +105,8 @@ subprojects { artifactId = 'core' } else if (project.name == 'annotations-addon') { artifactId = 'annotations-addon' + } else if (project.name == 'adventure-addon') { + artifactId = 'adventure-addon' } else { def platform = project.name.replaceFirst(/^platform-/, '') artifactId = "platform-${platform}" diff --git a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java index df8b1cd..bc8d0c6 100644 --- a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java +++ b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java @@ -7,8 +7,10 @@ import fr.traqueur.commands.api.exceptions.ArgumentIncorrectException; import fr.traqueur.commands.api.exceptions.TypeArgumentNotExistException; import fr.traqueur.commands.api.logging.Logger; +import fr.traqueur.commands.api.logging.MessageDispatcher; import fr.traqueur.commands.api.logging.MessageFormatter; import fr.traqueur.commands.api.logging.MessageHandler; +import fr.traqueur.commands.api.logging.MessageKey; import fr.traqueur.commands.api.models.Command; import fr.traqueur.commands.api.models.CommandBuilder; import fr.traqueur.commands.api.models.CommandInvoker; @@ -74,6 +76,11 @@ public abstract class CommandManager { */ private MessageFormatter messageFormatter; + /** + * The dispatcher sending messages by key, or null if every message is sent as text. + */ + private MessageDispatcher messageDispatcher; + /** * The logger of the command manager. */ @@ -181,6 +188,53 @@ public String formatMessage(S sender, String message) { return formatter == null ? message : formatter.format(sender, message); } + /** + * Get the message dispatcher of the command manager. + * + * @return The message dispatcher, or null if every message is sent as text. + * @since 5.5.0 + */ + public MessageDispatcher getMessageDispatcher() { + return this.messageDispatcher; + } + + /** + * Set the message dispatcher of the command manager. + *

+ * A dispatcher receives every message of the library by its {@link MessageKey}, with its + * values apart, before the text path. It is how a platform sends rich messages: on Paper + * and Velocity, prefer {@code setComponentFormatter}, which installs one. + *

+ * + * @param messageDispatcher The dispatcher, or null to send every message as text. + * @since 5.5.0 + */ + public void setMessageDispatcher(MessageDispatcher messageDispatcher) { + this.messageDispatcher = messageDispatcher; + } + + /** + * Send a library message to a sender. + *

+ * The message goes to the {@link MessageDispatcher} first, if one is set. When there is + * none, or it declines, the text is sent through {@link #formatMessage} and the platform, + * exactly as before 5.5.0. + *

+ * + * @param receiver The sender the message is sent to. + * @param key The message. + * @param text The text of the message, with its placeholders already substituted. + * @param placeholders The named values of the message, see {@link MessageKey}. + * @since 5.5.0 + */ + public void sendMessage(S receiver, MessageKey key, String text, Map placeholders) { + MessageDispatcher dispatcher = this.messageDispatcher; + if (dispatcher != null && dispatcher.dispatch(receiver, key, Collections.unmodifiableMap(placeholders))) { + return; + } + this.platform.sendMessage(receiver, this.formatMessage(receiver, text)); + } + /** * Get the debug mode of the command manager. * diff --git a/core/src/main/java/fr/traqueur/commands/api/logging/MessageDispatcher.java b/core/src/main/java/fr/traqueur/commands/api/logging/MessageDispatcher.java new file mode 100644 index 0000000..dbde6f0 --- /dev/null +++ b/core/src/main/java/fr/traqueur/commands/api/logging/MessageDispatcher.java @@ -0,0 +1,33 @@ +package fr.traqueur.commands.api.logging; + +import java.util.Map; + +/** + * Sends a library message by its key, instead of as text. + *

+ * This is the hook a platform uses to send rich messages, such as Adventure components: it + * receives the {@link MessageKey} and the named values of the message, not a string where + * they are already substituted. Most code does not implement it directly, but goes through + * the platform's own API, such as {@code setComponentFormatter} on Paper and Velocity. + *

+ *

+ * Returning false hands the message back to the text path: the {@link MessageHandler} text, + * with its placeholders substituted, through the {@link MessageFormatter}, sent as a string. + *

+ * + * @param The type of the sender the message is sent to. + * @since 5.5.0 + */ +@FunctionalInterface +public interface MessageDispatcher { + + /** + * Send a message to its recipient. + * + * @param receiver The sender the message is sent to. + * @param key The message. + * @param placeholders The named values of the message, see {@link MessageKey}. + * @return true when the message was sent, false to send it as text instead. + */ + boolean dispatch(S receiver, MessageKey key, Map placeholders); +} diff --git a/core/src/main/java/fr/traqueur/commands/api/logging/MessageHandler.java b/core/src/main/java/fr/traqueur/commands/api/logging/MessageHandler.java index 84f972e..920e0b9 100644 --- a/core/src/main/java/fr/traqueur/commands/api/logging/MessageHandler.java +++ b/core/src/main/java/fr/traqueur/commands/api/logging/MessageHandler.java @@ -56,4 +56,29 @@ public interface MessageHandler { * @return The invalid format message. */ String getInvalidFormatMessage(); + + /** + * Get the text of a message by its key. + *

+ * The text still holds its placeholders ({@code %arg%}, {@code %requirement%}). Handy for a + * {@link MessageDispatcher} or a component formatter that keeps its texts in the handler. + *

+ * + * @param key The message. + * @return The text of the message, or null for {@link MessageKey#USAGE} and + * {@link MessageKey#INTERNAL_ERROR}, which the handler does not hold. + * @since 5.5.0 + */ + default String getMessage(MessageKey key) { + return switch (key) { + case NO_PERMISSION -> getNoPermissionMessage(); + case ONLY_IN_GAME -> getOnlyInGameMessage(); + case ARG_NOT_RECOGNIZED -> getArgNotRecognized(); + case REQUIREMENT -> getRequirementMessage(); + case DISABLED -> getCommandDisabledMessage(); + case TOO_LONG -> getArgumentTooLongMessage(); + case INVALID_FORMAT -> getInvalidFormatMessage(); + case USAGE, INTERNAL_ERROR -> null; + }; + } } diff --git a/core/src/main/java/fr/traqueur/commands/api/logging/MessageKey.java b/core/src/main/java/fr/traqueur/commands/api/logging/MessageKey.java new file mode 100644 index 0000000..fd6f45b --- /dev/null +++ b/core/src/main/java/fr/traqueur/commands/api/logging/MessageKey.java @@ -0,0 +1,82 @@ +package fr.traqueur.commands.api.logging; + +/** + * Identifies a message the library sends to a sender. + *

+ * Each key comes with named values, passed separately from the text so that a formatter can + * place them itself (a MiniMessage tag, a translation argument) instead of receiving them + * already substituted: + *

+ *
    + *
  • {@link #ARG_NOT_RECOGNIZED}, {@link #TOO_LONG}, {@link #INVALID_FORMAT}: {@code arg}, the input;
  • + *
  • {@link #REQUIREMENT}: {@code requirement}, the requirement's + * {@link fr.traqueur.commands.api.requirements.Requirement#errorMessage() error message}, or its + * {@link fr.traqueur.commands.api.requirements.Requirement#name() name} when the message is empty;
  • + *
  • {@link #USAGE}: {@code usage}, the usage of the command;
  • + *
  • the others: no value.
  • + *
+ * + * @since 5.5.0 + */ +public enum MessageKey { + + /** + * The sender lacks the permission of the command. + */ + NO_PERMISSION, + + /** + * The command can only be run by a player. + */ + ONLY_IN_GAME, + + /** + * An argument could not be converted. + */ + ARG_NOT_RECOGNIZED, + + /** + * A requirement of the command is not met. + */ + REQUIREMENT, + + /** + * The command is disabled. + */ + DISABLED, + + /** + * An argument exceeds its maximum length. + */ + TOO_LONG, + + /** + * An argument has an invalid format. + */ + INVALID_FORMAT, + + /** + * The command was run with a wrong number of arguments; the message is its usage. + */ + USAGE, + + /** + * An argument has a type no converter is registered for: a bug of the plugin. + */ + INTERNAL_ERROR; + + /** + * The name of the value holding the input of an argument. + */ + public static final String ARG = "arg"; + + /** + * The name of the value holding the reason of a requirement. + */ + public static final String REQUIREMENT_VALUE = "requirement"; + + /** + * The name of the value holding the usage of a command. + */ + public static final String USAGE_VALUE = "usage"; +} diff --git a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java index 73b6e17..0a84840 100644 --- a/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java +++ b/core/src/main/java/fr/traqueur/commands/api/models/CommandInvoker.java @@ -5,6 +5,7 @@ import fr.traqueur.commands.api.arguments.TabCompleter; import fr.traqueur.commands.api.exceptions.ArgumentIncorrectException; import fr.traqueur.commands.api.exceptions.TypeArgumentNotExistException; +import fr.traqueur.commands.api.logging.MessageKey; import fr.traqueur.commands.api.models.collections.CommandTree; import fr.traqueur.commands.api.models.collections.CommandTree.MatchResult; import fr.traqueur.commands.api.requirements.Requirement; @@ -99,7 +100,7 @@ && checkRequirements(source, context.command) */ private boolean checkEnabled(S source, Command command) { if (!command.isEnabled()) { - this.sendMessage(source, manager.getMessageHandler().getCommandDisabledMessage()); + this.sendMessage(source, MessageKey.DISABLED, manager.getMessageHandler().getCommandDisabledMessage(), Map.of()); return false; } return true; @@ -114,7 +115,7 @@ private boolean checkEnabled(S source, Command command) { */ private boolean checkInGameOnly(S source, Command command) { if (command.inGameOnly() && !manager.getPlatform().isPlayer(source)) { - this.sendMessage(source, manager.getMessageHandler().getOnlyInGameMessage()); + this.sendMessage(source, MessageKey.ONLY_IN_GAME, manager.getMessageHandler().getOnlyInGameMessage(), Map.of()); return false; } return true; @@ -130,7 +131,7 @@ private boolean checkInGameOnly(S source, Command command) { private boolean checkPermission(S source, Command command) { String perm = command.getPermission(); if (!perm.isEmpty() && !manager.getPlatform().hasPermission(source, perm)) { - this.sendMessage(source, manager.getMessageHandler().getNoPermissionMessage()); + this.sendMessage(source, MessageKey.NO_PERMISSION, manager.getMessageHandler().getNoPermissionMessage(), Map.of()); return false; } return true; @@ -147,7 +148,8 @@ private boolean checkRequirements(S source, Command command) { for (Requirement req : command.getRequirements()) { if (!req.check(source)) { String msg = buildRequirementMessage(req); - this.sendMessage(source, msg); + String reason = req.errorMessage().isEmpty() ? requirementName(req) : req.errorMessage(); + this.sendMessage(source, MessageKey.REQUIREMENT, msg, Map.of(MessageKey.REQUIREMENT_VALUE, reason)); return false; } } @@ -195,7 +197,7 @@ private boolean checkUsage(S source, CommandContext context) { if (args.length < min || args.length > max) { String usage = buildUsageMessage(source, context); - this.sendMessage(source, usage); + this.sendMessage(source, MessageKey.USAGE, usage, Map.of(MessageKey.USAGE_VALUE, usage)); return false; } return true; @@ -243,7 +245,7 @@ private boolean executeCommand(S source, CommandContext context) { * @return false to indicate internal error */ private boolean handleTypeArgumentError(S source) { - this.sendMessage(source, "&cInternal error: invalid argument type"); + this.sendMessage(source, MessageKey.INTERNAL_ERROR, "&cInternal error: invalid argument type", Map.of()); return false; } @@ -256,23 +258,27 @@ private boolean handleTypeArgumentError(S source) { */ private boolean handleArgumentIncorrectError(S source, ArgumentIncorrectException e) { String msg = manager.getMessageHandler().getArgNotRecognized().replace("%arg%", e.getInput()); - this.sendMessage(source, msg); + this.sendMessage(source, MessageKey.ARG_NOT_RECOGNIZED, msg, Map.of(MessageKey.ARG, Objects.toString(e.getInput(), ""))); return true; } /** - * Sends a message to the sender, through the message formatter of the manager. + * Sends a message to the sender, through the message dispatcher or formatter of the manager. *

- * Every message this invoker emits goes through here, so a formatter registered with - * {@link CommandManager#setMessageFormatter} sees them all, once the internal - * placeholders have been substituted. Without a formatter the message is sent as-is. + * Every message this invoker emits goes through here, so a dispatcher registered with + * {@link CommandManager#setMessageDispatcher} sees them all by key, and a formatter + * registered with {@link CommandManager#setMessageFormatter} sees the text of the others, + * once the internal placeholders have been substituted. Without either, the message is + * sent as-is. *

* - * @param source the command sender - * @param message the message to send + * @param source the command sender + * @param key the message + * @param message the text of the message + * @param placeholders the named values of the message */ - private void sendMessage(S source, String message) { - manager.getPlatform().sendMessage(source, manager.formatMessage(source, message)); + private void sendMessage(S source, MessageKey key, String message, Map placeholders) { + manager.sendMessage(source, key, message, placeholders); } /** diff --git a/core/src/test/java/fr/traqueur/commands/api/logging/MessageDispatcherTest.java b/core/src/test/java/fr/traqueur/commands/api/logging/MessageDispatcherTest.java new file mode 100644 index 0000000..9797951 --- /dev/null +++ b/core/src/test/java/fr/traqueur/commands/api/logging/MessageDispatcherTest.java @@ -0,0 +1,83 @@ +package fr.traqueur.commands.api.logging; + +import fr.traqueur.commands.api.arguments.Arguments; +import fr.traqueur.commands.api.models.Command; +import fr.traqueur.commands.impl.logging.InternalMessageHandler; +import fr.traqueur.commands.test.mocks.MockCommandManager; +import fr.traqueur.commands.test.mocks.MockSender; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class MessageDispatcherTest { + + @Test + void dispatcher_receivesTheKeyAndTheValues_andTheTextPathIsSkipped() { + MockCommandManager manager = new MockCommandManager(); + List texts = new ArrayList<>(); + List dispatched = new ArrayList<>(); + manager.setMessageDispatcher((receiver, key, values) -> dispatched.add(List.of(key, values))); + Command command = new Command<>(null, "pay") { + @Override + public void execute(MockSender sender, Arguments arguments) { + } + }; + command.addArgs("amount", Integer.class); + manager.registerCommand(command); + + manager.getInvoker().invoke(sender(texts), "pay", new String[0]); + manager.getInvoker().invoke(sender(texts), "pay", new String[]{"abc"}); + + assertTrue(texts.isEmpty()); + assertEquals(MessageKey.USAGE, ((List) dispatched.get(0)).get(0)); + assertEquals(List.of(MessageKey.ARG_NOT_RECOGNIZED, Map.of(MessageKey.ARG, "abc")), dispatched.get(1)); + } + + @Test + void decliningDispatcher_leavesTheTextPathUnchanged() { + MockCommandManager manager = new MockCommandManager(); + List texts = new ArrayList<>(); + manager.setMessageDispatcher((receiver, key, values) -> false); + Command command = new Command<>(null, "pay") { + @Override + public void execute(MockSender sender, Arguments arguments) { + } + }; + command.addArgs("amount", Integer.class); + manager.registerCommand(command); + + manager.getInvoker().invoke(sender(texts), "pay", new String[]{"abc"}); + + assertEquals(List.of("&cArgument &eabc &cnot recognized."), texts); + } + + @Test + void handler_givesTheTextOfEveryKey() { + MessageHandler handler = new InternalMessageHandler(); + assertEquals(handler.getNoPermissionMessage(), handler.getMessage(MessageKey.NO_PERMISSION)); + assertEquals(handler.getRequirementMessage(), handler.getMessage(MessageKey.REQUIREMENT)); + assertEquals(handler.getArgumentTooLongMessage(), handler.getMessage(MessageKey.TOO_LONG)); + assertNull(handler.getMessage(MessageKey.USAGE)); + for (MessageKey key : MessageKey.values()) { + assertDoesNotThrow(() -> handler.getMessage(key)); + } + } + + private static MockSender sender(List texts) { + return new MockSender() { + @Override + public void sendMessage(String message) { + texts.add(message); + } + + @Override + public boolean hasPermission(String permission) { + return true; + } + }; + } +} diff --git a/gradle.properties b/gradle.properties index 5f35ef9..27373d4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=5.4.0 +version=5.5.0 diff --git a/settings.gradle b/settings.gradle index 564065b..7ffc137 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,4 +8,5 @@ include 'core' include 'velocity' include 'jda' include 'jda-test-bot' -include 'annotations-addon' \ No newline at end of file +include 'annotations-addon' +include 'adventure-addon' \ No newline at end of file diff --git a/velocity/build.gradle b/velocity/build.gradle index 2dec86f..75f891f 100644 --- a/velocity/build.gradle +++ b/velocity/build.gradle @@ -12,6 +12,7 @@ repositories { dependencies { api project(":core") + api project(":adventure-addon") compileOnly("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT") annotationProcessor("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT") } diff --git a/velocity/src/main/java/fr/traqueur/commands/velocity/CommandManager.java b/velocity/src/main/java/fr/traqueur/commands/velocity/CommandManager.java index d0e93dc..375df43 100644 --- a/velocity/src/main/java/fr/traqueur/commands/velocity/CommandManager.java +++ b/velocity/src/main/java/fr/traqueur/commands/velocity/CommandManager.java @@ -2,6 +2,8 @@ import com.velocitypowered.api.command.CommandSource; import com.velocitypowered.api.proxy.ProxyServer; +import fr.traqueur.commands.adventure.ComponentMessageFormatter; +import fr.traqueur.commands.adventure.ComponentMessages; import java.util.logging.Logger; @@ -22,4 +24,30 @@ public class CommandManager extends fr.traqueur.commands.api.CommandManager(instance, server, logger)); } + + /** + * Set the component formatter of the command manager. + *

+ * Every message of the library is then built as a {@link net.kyori.adventure.text.Component} + * by the formatter and sent as such, click and hover events included. The text + * {@link #setMessageFormatter message formatter} is no longer called, except for the + * messages the component formatter declines by returning null. + *

+ * + * @param formatter The formatter, or null to send messages as text again. + * @since 5.5.0 + */ + public void setComponentFormatter(ComponentMessageFormatter formatter) { + ComponentMessages.install(this, formatter); + } + + /** + * Get the component formatter of the command manager. + * + * @return The formatter, or null when messages are sent as text. + * @since 5.5.0 + */ + public ComponentMessageFormatter getComponentFormatter() { + return ComponentMessages.formatterOf(this); + } } From e28cc37500b11e92f351aa8b28742feabae336c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:15:37 +0000 Subject: [PATCH 3/4] feat(paper): 5.6.0, a Paper platform on Brigadier platform-paper registers commands through LifecycleEvents.COMMANDS: typed arguments become Brigadier nodes, permission and requirements the node's requires, and running a node goes through the usual invoker. BrigadierCommandTree holds the translation, testable with Brigadier alone; paper-test-plugin exercises it on a real server. Tested on Paper 1.21.4 and 26.3: a label registered after startup answers after the next /minecraft:reload, an unregistered command is hidden by its requires and answers as unknown. The README documents it. Also fixes ComponentMessageFormatter.miniMessage leaving the tags of absent values as typed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se3hcX6fCyfEU3PuabuUyn --- CHANGELOG.md | 20 ++ README.md | 51 ++++ .../adventure/ComponentMessageFormatter.java | 11 +- .../ComponentMessageFormatterTest.java | 9 + bom/build.gradle | 1 + .../traqueur/commands/api/CommandManager.java | 12 + gradle.properties | 2 +- paper-test-plugin/build.gradle | 58 ++++ .../traqueur/papertest/PaperTestPlugin.java | 63 ++++ .../src/main/resources/plugin.yml | 4 + paper/build.gradle | 24 ++ .../commands/paper/BrigadierCommandTree.java | 269 ++++++++++++++++++ .../fr/traqueur/commands/paper/Command.java | 23 ++ .../commands/paper/CommandManager.java | 70 +++++ .../commands/paper/PaperPlatform.java | 250 ++++++++++++++++ .../paper/BrigadierCommandTreeTest.java | 255 +++++++++++++++++ .../commands/paper/PaperPlatformTest.java | 108 +++++++ settings.gradle | 2 + 18 files changed, 1229 insertions(+), 3 deletions(-) create mode 100644 paper-test-plugin/build.gradle create mode 100644 paper-test-plugin/src/main/java/fr/traqueur/papertest/PaperTestPlugin.java create mode 100644 paper-test-plugin/src/main/resources/plugin.yml create mode 100644 paper/build.gradle create mode 100644 paper/src/main/java/fr/traqueur/commands/paper/BrigadierCommandTree.java create mode 100644 paper/src/main/java/fr/traqueur/commands/paper/Command.java create mode 100644 paper/src/main/java/fr/traqueur/commands/paper/CommandManager.java create mode 100644 paper/src/main/java/fr/traqueur/commands/paper/PaperPlatform.java create mode 100644 paper/src/test/java/fr/traqueur/commands/paper/BrigadierCommandTreeTest.java create mode 100644 paper/src/test/java/fr/traqueur/commands/paper/PaperPlatformTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 5726b50..46b1a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ Every minor version ships one batch of changes. Nothing changes for code that does not use the new APIs, except where a section says otherwise. +## 5.6.0 + +### Added + +- **`platform-paper`**: registers commands through Paper's Brigadier API (`LifecycleEvents.COMMANDS`). + Typed arguments become Brigadier nodes (numbers, boolean, word, greedy string; a type with a + custom converter becomes a word with its tab completer's suggestions), permission and + requirements become the node's `requires`, and messages can be components + (`setComponentFormatter`). Compiled against the Paper API 1.21.4 (Java 21), tested on Paper + 1.21.4 and 26.3. See the README for what happens to commands added or removed after startup. +- `BrigadierCommandTree`, the platform-neutral translation of a manager's commands into Brigadier + nodes. +- `CommandManager#getConverter(Class)`. +- `paper-test-plugin`, a test plugin for the Paper platform. + +### Fixed + +- `ComponentMessageFormatter.miniMessage(...)`: the ``, `` and `` tags are + known for every key, empty when the key has no such value, instead of being shown as typed. + ## 5.5.0 ### Added diff --git a/README.md b/README.md index 952f647..c713740 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,56 @@ platforms. On another Adventure platform, install it with `ComponentMessages.ins --- +## 🧭 Paper platform (Brigadier) + +`platform-paper` registers commands through Paper's Brigadier API (`LifecycleEvents.COMMANDS`) +instead of the Bukkit command map, so the client knows the shape of each command: arguments are +coloured, a wrong value is flagged before it is sent, and suggestions come from the tab completers. + +```java +public final class MyPlugin extends JavaPlugin { + @Override + public void onEnable() { + CommandManager manager = new fr.traqueur.commands.paper.CommandManager<>(this); + manager.command("give").arg("amount", Integer.class).executor((sender, args) -> { ... }).register(); + } +} +``` + +| CommandsAPI argument | Brigadier node | +|---|---| +| `int`/`Integer`, `long`, `double`, `float`, `byte` (library converter) | the matching number | +| `boolean`/`Boolean` (library converter) | boolean | +| `Infinite` | greedy string, to the end of the line | +| `String`, a type with a custom converter, a number with your own converter | word, with the tab completer's suggestions | + +The permission and the requirements of a command become the `requires` of its node: a player who +cannot run a command does not see it. A command is executable once its required arguments are +given; running it goes through the usual invoker, so conversion, checks and messages are unchanged. +`setComponentFormatter` is available as on Velocity. + +> ℹ️ A word only accepts `0-9 A-Z a-z _ - . +`, as in vanilla: the client refuses a `String` +> argument holding other characters (accents, `<`, `&`). Declare it `Infinite` when it must accept +> any text. + +### When Paper builds the tree + +Paper asks for the commands when it builds its command tree: **once at startup, after the plugins +are enabled, then on every `/minecraft:reload`**. The nodes are built from what the manager holds +at that moment. Tested on Paper 1.21.4 and 26.3: + +| What happens | Result | +|---|---| +| Command registered in `onEnable` | Available at startup. | +| New label registered after startup | Unknown command until the next `/minecraft:reload` (a warning is logged), then available. | +| Command unregistered after startup | Its node stays, hidden by its `requires`: unknown command, and the clients' command lists are refreshed. | +| Same label registered again | Visible again, **with the arguments the node had when it was built**; a changed shape needs a reload. | +| `unregisterAll()` | Every command of the manager becomes unknown; other plugins' commands stay. | + +`platform-spigot` stays available for Spigot, or for a Paper server that prefers the command map. + +--- + ## 🧹 Removing every command of a manager When a plugin stops, `unregisterAll()` removes every command its manager registered, with their @@ -170,6 +220,7 @@ nothing. A server can turn the check off for every plugin with `-Dcommands.updat traqueur-dev-commandsapi/ β”œβ”€β”€ core/ # Platform-agnostic command logic β”œβ”€β”€ spigot/ # Spigot implementation +β”œβ”€β”€ paper/ # Paper implementation, on Brigadier β”œβ”€β”€ adventure-addon/ # Component messages, for Adventure platforms β”œβ”€β”€ -test-plugin/ # The test plugin for the specified platform └── velocity/ # Velocity implementation diff --git a/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java b/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java index 1da20af..694c231 100644 --- a/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java +++ b/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java @@ -45,7 +45,9 @@ public interface ComponentMessageFormatter { * {@code } and {@code } tags. *

* The values are inserted as plain text: a value holding MiniMessage tags is shown as typed, - * it is not parsed. A key whose template is null falls back to the text path. + * it is not parsed. The three tags are known for every key, empty when the key has no such + * value, so one template can serve several keys. A key whose template is null falls back to + * the text path. *

* * @param templates The MiniMessage template of each key. @@ -71,8 +73,13 @@ static ComponentMessageFormatter miniMessage(MiniMessage miniMessage, Fun if (template == null) { return null; } + Map values = new java.util.HashMap<>(); + values.put(MessageKey.ARG, ""); + values.put(MessageKey.REQUIREMENT_VALUE, ""); + values.put(MessageKey.USAGE_VALUE, ""); + values.putAll(placeholders); TagResolver.Builder resolver = TagResolver.builder(); - placeholders.forEach((name, value) -> resolver.resolver(Placeholder.unparsed(name, value))); + values.forEach((name, value) -> resolver.resolver(Placeholder.unparsed(name, value))); return miniMessage.deserialize(template, resolver.build()); }; } diff --git a/adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java b/adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java index fc7a34d..a767374 100644 --- a/adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java +++ b/adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java @@ -149,6 +149,15 @@ public String getArgNotRecognized() { assertNull(formatter.format(sender, MessageKey.INTERNAL_ERROR, Map.of())); } + @Test + void absentValues_areEmpty_soOneTemplateServesEveryKey() { + ComponentMessageFormatter formatter = ComponentMessageFormatter.miniMessage(key -> key + ":"); + + Component usage = formatter.format(sender, MessageKey.USAGE, Map.of(MessageKey.USAGE_VALUE, "/give ")); + + assertEquals("USAGE:/give ", PlainTextComponentSerializer.plainText().serialize(usage)); + } + private void install(ComponentMessageFormatter formatter) { ComponentMessages.install(manager, formatter, (receiver, component) -> components.add(component)); } diff --git a/bom/build.gradle b/bom/build.gradle index aeb0b36..c2de980 100644 --- a/bom/build.gradle +++ b/bom/build.gradle @@ -13,6 +13,7 @@ dependencies { constraints { api(project(':core')) api(project(':spigot')) + api(project(':paper')) api(project(':velocity')) api(project(':jda')) api(project(':annotations-addon')) diff --git a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java index bc8d0c6..4ca46cf 100644 --- a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java +++ b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java @@ -386,6 +386,18 @@ public Map>> getCompleters() { return this.completers; } + /** + * Get the converter registered for a type. + * + * @param type The type. + * @return The converter, or null when none is registered. + * @since 5.6.0 + */ + public ArgumentConverter getConverter(Class type) { + ArgumentConverter.Wrapper wrapper = this.typeConverters.get(type); + return wrapper == null ? null : wrapper.converter(); + } + /** * Check if a TabCompleter exists for the given type. * diff --git a/gradle.properties b/gradle.properties index 27373d4..bf3fc16 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=5.5.0 +version=5.6.0 diff --git a/paper-test-plugin/build.gradle b/paper-test-plugin/build.gradle new file mode 100644 index 0000000..532ed37 --- /dev/null +++ b/paper-test-plugin/build.gradle @@ -0,0 +1,58 @@ +plugins { + id 'java' + id 'io.github.goooler.shadow' version '8.1.8' + id 'xyz.jpenilla.run-paper' version '2.3.1' +} + +group = 'fr.traqueur' +version = '1.0-SNAPSHOT' + +repositories { + mavenCentral() + maven { + name = "papermc-repo" + url = "https://repo.papermc.io/repository/maven-public/" + } +} + +tasks { + runServer { + minecraftVersion("1.21.4") + } +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") + implementation project(":paper") +} + +def targetJavaVersion = 21 +java { + def javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release.set(targetJavaVersion) +} + +processResources { + def props = [version: version] + inputs.properties props + filteringCharset 'UTF-8' + filesMatching('plugin.yml') { + expand props + } +} + +build.dependsOn shadowJar + +shadowJar { + // The spigot module's Spigot API is provided by Paper at runtime. + dependencies { + exclude(dependency('org.spigotmc:spigot-api')) + } + relocate "fr.traqueur.commands", "fr.traqueur.papertest.api.commands" +} diff --git a/paper-test-plugin/src/main/java/fr/traqueur/papertest/PaperTestPlugin.java b/paper-test-plugin/src/main/java/fr/traqueur/papertest/PaperTestPlugin.java new file mode 100644 index 0000000..2389bab --- /dev/null +++ b/paper-test-plugin/src/main/java/fr/traqueur/papertest/PaperTestPlugin.java @@ -0,0 +1,63 @@ +package fr.traqueur.papertest; + +import fr.traqueur.commands.api.arguments.Infinite; +import fr.traqueur.commands.adventure.ComponentMessageFormatter; +import fr.traqueur.commands.paper.CommandManager; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Exercises the Paper platform on a real server: typed arguments, a command added after startup, + * a command removed at runtime, and component messages. + *

+ * From the console: {@code give 3}, {@code give abc} (refused by Brigadier), {@code pt add late}, + * {@code late}, {@code pt remove give}, {@code give 3}, {@code minecraft:reload}, {@code late}. + *

+ */ +public final class PaperTestPlugin extends JavaPlugin { + + private CommandManager manager; + + @Override + public void onEnable() { + manager = new CommandManager<>(this); + manager.setComponentFormatter(ComponentMessageFormatter.miniMessage( + key -> "[PT] " + key + " ")); + + manager.command("give") + .description("Give an amount") + .arg("amount", Integer.class) + .executor((sender, args) -> getLogger().info("give ran with " + args.get("amount"))) + .register(); + + manager.command("echo") + .arg("loud", Boolean.class) + .arg("text", Infinite.class) + .executor((sender, args) -> getLogger().info("echo " + args.get("loud") + " " + args.get("text"))) + .register(); + + manager.command("pt") + .optionalArg("action", String.class) + .optionalArg("label", String.class) + .executor((sender, args) -> control(sender, args.getOptional("action").orElse(""), + args.getOptional("label").orElse(""))) + .register(); + } + + private void control(CommandSender sender, String action, String label) { + switch (action) { + case "add" -> manager.command(label) + .executor((s, a) -> getLogger().info(label + " ran")) + .register(); + case "remove" -> manager.unregisterCommand(label); + case "removeall" -> manager.unregisterAll(); + default -> getLogger().info("pt add|remove|removeall