diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6752afe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# 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.7.0 + +### Added + +- Annotations: `@Default("value")` makes an argument optional with a value used when it is left + out, converted by the parameter type's converter (on an `Optional` parameter, the value is + wrapped). An invalid default on a primitive parameter is refused at registration. + `Optional` parameters, already supported, are now documented. +- `CommandManager#describe()` returns a public tree of `CommandDescription` / `ArgumentDescription` + records (name, path, aliases, description, usage, permission, in-game only, required and optional + arguments, subcommands) to generate a plugin's command documentation. + +## 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 + +- **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 + +- **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..fe6c6b0 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,158 @@ 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)`. + +--- + +## 🧭 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. + +--- + +## 📖 Describing the commands + +`manager.describe()` returns a public, read-only tree of the commands registered through the +manager, to generate a plugin's documentation without touching the internal structures: + +```java +for (CommandDescription command : manager.describe()) { + System.out.println(command.usage() + " - " + command.description() + + (command.permission().isEmpty() ? "" : " (" + command.permission() + ")")); + command.requiredArguments(); // List: name, type, optional, infinite + command.optionalArguments(); + command.subcommands(); // List, with their full path ("home set") +} +``` + +The usage is the one set on the command, or the one the library generates, listing every +subcommand. + +--- + +## 🧹 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`. --- @@ -93,6 +240,8 @@ exactly as the handler produced them. 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 ``` @@ -181,6 +330,21 @@ tasks.withType().configureEach { > 💡 Without this flag, parameter names default to `arg0`, `arg1`, etc. You can always use `@Arg("name")` explicitly to avoid this requirement. +### Optional arguments and default values + +| Parameter | Left out by the sender | +|---|---| +| `@Arg("amount") int amount` | required: the usage is sent | +| `@Arg("amount") Optional amount` | `Optional.empty()` | +| `@Arg("amount") @Default("1") int amount` | `1` | +| `@Arg("amount") @Default("1") Optional amount` | `Optional.of(1)` | +| `@Arg("text") @Infinite @Default("hello") String text` | `"hello"` | + +The `@Default` value is written as the sender would type it and goes through the converter of the +parameter's type when the command runs, custom converters included. On a primitive parameter, an +invalid value (`@Default("lots") int`) is refused when the commands are registered. Optional +parameters must come after the required ones: required arguments are always read first. + --- ## 💡 Example (Spigot) 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..694c231 --- /dev/null +++ b/adventure-addon/src/main/java/fr/traqueur/commands/adventure/ComponentMessageFormatter.java @@ -0,0 +1,113 @@ +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. 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. + * @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; + } + 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(); + values.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..a767374 --- /dev/null +++ b/adventure-addon/src/test/java/fr/traqueur/commands/adventure/ComponentMessageFormatterTest.java @@ -0,0 +1,185 @@ +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())); + } + + @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)); + } + + 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/annotations-addon/src/main/java/fr/traqueur/commands/annotations/AnnotationCommandProcessor.java b/annotations-addon/src/main/java/fr/traqueur/commands/annotations/AnnotationCommandProcessor.java index 84b9c42..2b41eea 100644 --- a/annotations-addon/src/main/java/fr/traqueur/commands/annotations/AnnotationCommandProcessor.java +++ b/annotations-addon/src/main/java/fr/traqueur/commands/annotations/AnnotationCommandProcessor.java @@ -212,9 +212,22 @@ private boolean isSenderParameter(Parameter param) { private void registerArgument(CommandBuilder builder, Parameter param, String commandPath) { String argName = getArgumentName(param); Class argType = resolveArgumentType(param); - boolean isOptional = param.getType() == Optional.class; + boolean isOptional = param.getType() == Optional.class || param.isAnnotationPresent(Default.class); TabCompleter completer = getTabCompleter(commandPath, argName); + if (param.isAnnotationPresent(Default.class) + && argType != fr.traqueur.commands.api.arguments.Infinite.class + && manager.getConverter(argType) == null) { + throw new IllegalArgumentException("No converter for the @Default value of parameter '" + argName + + "' of type " + argType.getName() + " in " + commandPath); + } + // A primitive cannot receive null: its value is checked now rather than when the command runs. + if (param.isAnnotationPresent(Default.class) && param.getType().isPrimitive() + && convertDefault(param, param.getAnnotation(Default.class).value()) == null) { + throw new IllegalArgumentException("The @Default value '" + param.getAnnotation(Default.class).value() + + "' of parameter '" + argName + "' is not a valid " + argType.getName() + " in " + commandPath); + } + if (isOptional) { builder.optionalArg(argName, argType, completer); } else { @@ -329,9 +342,27 @@ private Object resolveSender(Parameter param, S sender) { private Object resolveArgument(Parameter param, Arguments args) { String argName = getArgumentName(param); boolean isOptional = param.getType() == Optional.class; + Default defaultValue = param.getAnnotation(Default.class); + if (defaultValue != null) { + Optional given = args.getOptional(argName); + Object value = given.isPresent() ? given.get() : convertDefault(param, defaultValue.value()); + return isOptional ? Optional.ofNullable(value) : value; + } return isOptional ? args.getOptional(argName) : args.get(argName); } + /** + * Convert the {@link Default} value of a parameter with the converter of its type. + */ + private Object convertDefault(Parameter param, String raw) { + Class type = resolveArgumentType(param); + if (type == fr.traqueur.commands.api.arguments.Infinite.class) { + return raw; + } + fr.traqueur.commands.api.arguments.ArgumentConverter converter = manager.getConverter(type); + return converter == null ? null : converter.apply(raw); + } + private record CommandMethodInfo(Object handler, Method method, String name) {} private record TabCompleterMethod(Object handler, Method method) {} } \ No newline at end of file diff --git a/annotations-addon/src/main/java/fr/traqueur/commands/annotations/Default.java b/annotations-addon/src/main/java/fr/traqueur/commands/annotations/Default.java new file mode 100644 index 0000000..b871f4d --- /dev/null +++ b/annotations-addon/src/main/java/fr/traqueur/commands/annotations/Default.java @@ -0,0 +1,37 @@ +package fr.traqueur.commands.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Makes an argument optional, with a value used when the sender leaves it out. + *

+ * The value is written as the sender would type it, and converted by the converter of the + * parameter's type when the command runs, so {@code @Default("1") int amount} receives + * {@code 1}, and {@code @Default("spawn") Warp warp} goes through the {@code Warp} converter. + * On an {@code Optional} parameter, the value is wrapped: the optional is then never empty + * unless the converter rejects the value. + *

+ *
{@code
+ * @Command(name = "give")
+ * public void give(Player sender, @Arg("item") String item, @Arg("amount") @Default("1") int amount) { ... }
+ * }
+ *

+ * As every optional argument, it must come after the required ones. + *

+ * + * @since 5.7.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface Default { + + /** + * The value used when the argument is left out, as the sender would type it. + * + * @return The default value. + */ + String value(); +} diff --git a/annotations-addon/src/test/java/fr/traqueur/commands/annotations/DefaultArgsTest.java b/annotations-addon/src/test/java/fr/traqueur/commands/annotations/DefaultArgsTest.java new file mode 100644 index 0000000..0583291 --- /dev/null +++ b/annotations-addon/src/test/java/fr/traqueur/commands/annotations/DefaultArgsTest.java @@ -0,0 +1,76 @@ +package fr.traqueur.commands.annotations; + +import fr.traqueur.commands.annotations.commands.DefaultArgsTestCommands; +import fr.traqueur.commands.annotations.commands.InvalidDefaultTestCommands; +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.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class DefaultArgsTest { + + private MockCommandManager manager; + private DefaultArgsTestCommands commands; + private final MockSender sender = new MockSender() { + @Override + public void sendMessage(String message) { + } + + @Override + public boolean hasPermission(String permission) { + return true; + } + }; + + @BeforeEach + void setUp() { + manager = new MockCommandManager(); + commands = new DefaultArgsTestCommands(); + new AnnotationCommandProcessor<>(manager).register(commands); + } + + @Test + void defaultArgument_isOptional() { + Command give = manager.getMockPlatform().getCommand("give"); + assertEquals(1, give.getArgs().size()); + assertEquals("amount", give.getOptionalArgs().get(0).name()); + } + + @Test + void leftOut_receivesTheConvertedDefault_givenWins() { + manager.getInvoker().invoke(sender, "give", new String[]{"apple"}); + manager.getInvoker().invoke(sender, "give", new String[]{"apple", "5"}); + + assertEquals(List.of("applex1", "applex5"), commands.received); + } + + @Test + void optionalWithDefault_isNeverEmpty() { + manager.getInvoker().invoke(sender, "heal", new String[0]); + manager.getInvoker().invoke(sender, "heal", new String[]{"3.5"}); + + assertEquals(List.of(Optional.of(20.0), Optional.of(3.5)), commands.received); + } + + @Test + void infiniteWithDefault_receivesTheText() { + manager.getInvoker().invoke(sender, "say", new String[0]); + manager.getInvoker().invoke(sender, "say", new String[]{"good", "bye"}); + + assertEquals(List.of("hello there", "good bye"), commands.received); + } + + @Test + void invalidPrimitiveDefault_isRefusedAtRegistration() { + AnnotationCommandProcessor processor = new AnnotationCommandProcessor<>(new MockCommandManager()); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> processor.register(new InvalidDefaultTestCommands())); + assertTrue(error.getMessage().contains("lots")); + } +} diff --git a/annotations-addon/src/test/java/fr/traqueur/commands/annotations/commands/DefaultArgsTestCommands.java b/annotations-addon/src/test/java/fr/traqueur/commands/annotations/commands/DefaultArgsTestCommands.java new file mode 100644 index 0000000..e58a743 --- /dev/null +++ b/annotations-addon/src/test/java/fr/traqueur/commands/annotations/commands/DefaultArgsTestCommands.java @@ -0,0 +1,33 @@ +package fr.traqueur.commands.annotations.commands; + +import fr.traqueur.commands.annotations.Arg; +import fr.traqueur.commands.annotations.Command; +import fr.traqueur.commands.annotations.CommandContainer; +import fr.traqueur.commands.annotations.Default; +import fr.traqueur.commands.annotations.Infinite; +import fr.traqueur.commands.test.mocks.MockSender; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@CommandContainer +public class DefaultArgsTestCommands { + + public final List received = new ArrayList<>(); + + @Command(name = "give") + public void give(MockSender sender, @Arg("item") String item, @Arg("amount") @Default("1") int amount) { + received.add(item + "x" + amount); + } + + @Command(name = "heal") + public void heal(MockSender sender, @Arg("amount") @Default("20") Optional amount) { + received.add(amount); + } + + @Command(name = "say") + public void say(MockSender sender, @Arg("text") @Infinite @Default("hello there") String text) { + received.add(text); + } +} diff --git a/annotations-addon/src/test/java/fr/traqueur/commands/annotations/commands/InvalidDefaultTestCommands.java b/annotations-addon/src/test/java/fr/traqueur/commands/annotations/commands/InvalidDefaultTestCommands.java new file mode 100644 index 0000000..01fe99f --- /dev/null +++ b/annotations-addon/src/test/java/fr/traqueur/commands/annotations/commands/InvalidDefaultTestCommands.java @@ -0,0 +1,15 @@ +package fr.traqueur.commands.annotations.commands; + +import fr.traqueur.commands.annotations.Arg; +import fr.traqueur.commands.annotations.Command; +import fr.traqueur.commands.annotations.CommandContainer; +import fr.traqueur.commands.annotations.Default; +import fr.traqueur.commands.test.mocks.MockSender; + +@CommandContainer +public class InvalidDefaultTestCommands { + + @Command(name = "broken") + public void broken(MockSender sender, @Arg("amount") @Default("lots") int amount) { + } +} diff --git a/bom/build.gradle b/bom/build.gradle index cc95d7b..c2de980 100644 --- a/bom/build.gradle +++ b/bom/build.gradle @@ -13,9 +13,11 @@ dependencies { constraints { api(project(':core')) api(project(':spigot')) + api(project(':paper')) 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 d1f2d13..350dd79 100644 --- a/core/src/main/java/fr/traqueur/commands/api/CommandManager.java +++ b/core/src/main/java/fr/traqueur/commands/api/CommandManager.java @@ -4,11 +4,15 @@ import fr.traqueur.commands.api.arguments.ArgumentConverter; import fr.traqueur.commands.api.arguments.Arguments; import fr.traqueur.commands.api.arguments.TabCompleter; +import fr.traqueur.commands.api.description.ArgumentDescription; +import fr.traqueur.commands.api.description.CommandDescription; 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; @@ -46,6 +50,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. */ @@ -69,6 +78,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. */ @@ -82,11 +96,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 +111,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); @@ -172,6 +190,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. * @@ -200,6 +265,82 @@ 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(); + } + + /** + * Describe the commands registered through this manager. + *

+ * The result is a public, read-only tree, in registration order, meant to generate the + * documentation of a plugin's commands: name, aliases, description, usage, permission, + * required and optional arguments, and subcommands. It is a snapshot: later registrations + * do not change it. + *

+ * + * @return The description of every root command. + * @since 5.7.0 + */ + public List describe() { + List descriptions = new ArrayList<>(); + for (Command command : this.registered) { + descriptions.add(this.describe(command, command.getName().replace('.', ' '))); + } + return descriptions; + } + + private CommandDescription describe(Command command, String path) { + List arguments = new ArrayList<>(); + for (Argument argument : command.getArgs()) { + arguments.add(new ArgumentDescription(argument.name(), argument.type().key(), false, argument.isInfinite())); + } + for (Argument argument : command.getOptionalArgs()) { + arguments.add(new ArgumentDescription(argument.name(), argument.type().key(), true, argument.isInfinite())); + } + List subcommands = new ArrayList<>(); + for (Command subcommand : command.getSubcommands()) { + subcommands.add(this.describe(subcommand, path + " " + subcommand.getName().replace('.', ' '))); + } + String usage = command.getUsage().isEmpty() ? this.usageOf(command, path) : command.getUsage(); + return new CommandDescription(command.getName(), path, command.getAliases(), command.getDescription(), + usage, command.getPermission(), command.inGameOnly(), arguments, subcommands); + } + + /** + * The usage the library generates, in the format of {@link Command#generateDefaultUsage}, but + * listing every subcommand, whatever the permission of the reader. + */ + private String usageOf(Command command, String path) { + StringBuilder usage = new StringBuilder("/").append(path); + List subs = command.getSubcommands().stream() + .map(sub -> Patterns.DOT.split(sub.getName())[0]) + .toList(); + if (!subs.isEmpty()) { + usage.append(" <").append(String.join("|", subs)).append(">"); + } + List args = new ArrayList<>(); + command.getArgs().forEach(argument -> args.add("<" + argument.canonicalName() + ">")); + command.getOptionalArgs().forEach(argument -> args.add("[" + argument.canonicalName() + "]")); + if (!args.isEmpty()) { + usage.append(subs.isEmpty() ? " " : "|").append(String.join(" ", args)); + } + return usage.toString(); } /** @@ -244,6 +385,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); @@ -304,6 +446,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/core/src/main/java/fr/traqueur/commands/api/description/ArgumentDescription.java b/core/src/main/java/fr/traqueur/commands/api/description/ArgumentDescription.java new file mode 100644 index 0000000..3758839 --- /dev/null +++ b/core/src/main/java/fr/traqueur/commands/api/description/ArgumentDescription.java @@ -0,0 +1,23 @@ +package fr.traqueur.commands.api.description; + +/** + * A public, read-only description of a command argument. + * + * @param name The name of the argument. + * @param type The type the argument is converted to; for an infinite argument, + * {@link fr.traqueur.commands.api.arguments.Infinite}. + * @param optional Whether the sender can leave it out. + * @param infinite Whether it takes the rest of the line. + * @since 5.7.0 + */ +public record ArgumentDescription(String name, Class type, boolean optional, boolean infinite) { + + /** + * The simple, lower-case name of the type, as shown in the generated usages ({@code integer}). + * + * @return The type name. + */ + public String typeName() { + return this.type.getSimpleName().toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/core/src/main/java/fr/traqueur/commands/api/description/CommandDescription.java b/core/src/main/java/fr/traqueur/commands/api/description/CommandDescription.java new file mode 100644 index 0000000..38862bd --- /dev/null +++ b/core/src/main/java/fr/traqueur/commands/api/description/CommandDescription.java @@ -0,0 +1,56 @@ +package fr.traqueur.commands.api.description; + +import java.util.List; + +/** + * A public, read-only description of a command, to generate the documentation of a plugin's + * commands without going through the internal structures of the library. + * + * @param name The name of the command, as registered. + * @param path The words typed to reach it, the parents of a subcommand included ({@code home set}). + * @param aliases Its aliases. + * @param description Its description, empty when none is set. + * @param usage Its usage: the one set on the command, or the one the library generates. + * @param permission Its permission, empty when none is required. + * @param inGameOnly Whether only a player can run it. + * @param arguments Its arguments, the required ones first, in order. + * @param subcommands Its subcommands. + * @since 5.7.0 + */ +public record CommandDescription(String name, + String path, + List aliases, + String description, + String usage, + String permission, + boolean inGameOnly, + List arguments, + List subcommands) { + + /** + * Create a description. + */ + public CommandDescription { + aliases = List.copyOf(aliases); + arguments = List.copyOf(arguments); + subcommands = List.copyOf(subcommands); + } + + /** + * The required arguments of the command. + * + * @return The required arguments, in order. + */ + public List requiredArguments() { + return this.arguments.stream().filter(argument -> !argument.optional()).toList(); + } + + /** + * The optional arguments of the command. + * + * @return The optional arguments, in order. + */ + public List optionalArguments() { + return this.arguments.stream().filter(ArgumentDescription::optional).toList(); + } +} 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 044026c..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; } } @@ -163,10 +165,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. * @@ -183,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; @@ -231,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; } @@ -244,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/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/DescribeTest.java b/core/src/test/java/fr/traqueur/commands/api/DescribeTest.java new file mode 100644 index 0000000..e637a12 --- /dev/null +++ b/core/src/test/java/fr/traqueur/commands/api/DescribeTest.java @@ -0,0 +1,80 @@ +package fr.traqueur.commands.api; + +import fr.traqueur.commands.api.arguments.Arguments; +import fr.traqueur.commands.api.arguments.Infinite; +import fr.traqueur.commands.api.description.ArgumentDescription; +import fr.traqueur.commands.api.description.CommandDescription; +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 java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DescribeTest { + + @Test + void describe_givesTheTreeOfTheRegisteredCommands() { + MockCommandManager manager = new MockCommandManager(); + Command home = command("home"); + home.addAlias("h"); + home.setDescription("Teleport home"); + home.setPermission("valkn.home"); + home.addOptionalArgs("name", String.class); + Command set = command("set"); + set.setGameOnly(true); + set.addArgs("name", String.class); + set.addOptionalArgs("note", Infinite.class); + home.addSubCommand(set); + manager.registerCommand(home); + Command spawn = command("spawn"); + spawn.setUsage("/spawn, simply"); + manager.registerCommand(spawn); + + List described = manager.describe(); + + assertEquals(List.of("home", "spawn"), described.stream().map(CommandDescription::name).toList()); + CommandDescription homeDescription = described.get(0); + assertEquals(List.of("h"), homeDescription.aliases()); + assertEquals("Teleport home", homeDescription.description()); + assertEquals("valkn.home", homeDescription.permission()); + assertEquals("/home |[name:string]", homeDescription.usage()); + assertEquals(List.of(new ArgumentDescription("name", String.class, true, false)), homeDescription.optionalArguments()); + assertTrue(homeDescription.requiredArguments().isEmpty()); + + CommandDescription setDescription = homeDescription.subcommands().get(0); + assertEquals("home set", setDescription.path()); + assertTrue(setDescription.inGameOnly()); + assertEquals(List.of("name"), setDescription.requiredArguments().stream().map(ArgumentDescription::name).toList()); + ArgumentDescription note = setDescription.optionalArguments().get(0); + assertTrue(note.infinite()); + assertEquals("infinite", note.typeName()); + assertEquals("/home set [note:infinite]", setDescription.usage()); + + assertEquals("/spawn, simply", described.get(1).usage()); + } + + @Test + void describe_followsUnregistration_andIsReadOnly() { + MockCommandManager manager = new MockCommandManager(); + Command home = command("home"); + manager.registerCommand(home); + manager.registerCommand(command("spawn")); + manager.unregisterCommand(home); + + List described = manager.describe(); + + assertEquals(List.of("spawn"), described.stream().map(CommandDescription::name).toList()); + assertThrows(UnsupportedOperationException.class, () -> described.get(0).arguments().add(null)); + } + + 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/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/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/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..61e845f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=5.3.1 +version=5.7.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