diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java index 364c12c6c..e29a9ad86 100644 --- a/src/main/java/world/bentobox/bentobox/BentoBox.java +++ b/src/main/java/world/bentobox/bentobox/BentoBox.java @@ -30,6 +30,7 @@ import world.bentobox.bentobox.managers.AddonsManager; import world.bentobox.bentobox.managers.BlueprintsManager; import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.bentobox.suggestions.SuggestionsManager; import world.bentobox.bentobox.managers.FlagsManager; import world.bentobox.bentobox.managers.HooksManager; import world.bentobox.bentobox.managers.ChunkPregenManager; @@ -65,6 +66,7 @@ public class BentoBox extends JavaPlugin implements Listener { // Managers private CommandsManager commandsManager; + private SuggestionsManager suggestionsManager; private LocalesManager localesManager; private AddonsManager addonsManager; private FlagsManager flagsManager; @@ -164,6 +166,9 @@ public void onEnable(){ // Set up command manager commandsManager = new CommandsManager(); + // Did-you-mean command suggestions + suggestionsManager = new SuggestionsManager(this); + // Load BentoBox commands commandsManager.registerDefaultCommands(); @@ -394,6 +399,14 @@ public CommandsManager getCommandsManager() { return commandsManager; } + /** + * @return the did-you-mean command suggestions manager + * @since 3.20.0 + */ + public SuggestionsManager getSuggestionsManager() { + return suggestionsManager; + } + /** * @return the Locales manager */ diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 6426ad264..5c62e0289 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -152,6 +152,18 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "general.teleport-remove-mobs") private boolean teleportRemoveMobs = false; + @ConfigComment("Did-you-mean command suggestions.") + @ConfigComment("When a player types an unknown command, e.g. /teams instead of /oneblock team,") + @ConfigComment("BentoBox suggests the closest matching BentoBox command. The player can click the") + @ConfigComment("suggestion or type 'yes' to run it.") + @ConfigEntry(path = "general.did-you-mean.unknown-commands", since = "3.20.0") + private boolean didYouMeanUnknownCommands = true; + + @ConfigComment("When a player types an unknown subcommand, e.g. /island invit instead of /island team invite,") + @ConfigComment("BentoBox suggests the closest matching subcommand instead of showing an error.") + @ConfigEntry(path = "general.did-you-mean.subcommands", since = "3.20.0") + private boolean didYouMeanSubcommands = true; + /* PANELS */ @ConfigComment("Panel click cooldown. Value is in milliseconds. Prevents players spamming button presses in GUIs.") @ConfigEntry(path = "panel.click-cooldown-ms") @@ -1787,4 +1799,36 @@ public void setDisabledStructures(List disabledStructures) { this.disabledStructures = disabledStructures; } + /** + * @return whether unknown root commands trigger did-you-mean suggestions + * @since 3.20.0 + */ + public boolean isDidYouMeanUnknownCommands() { + return didYouMeanUnknownCommands; + } + + /** + * @param didYouMeanUnknownCommands the didYouMeanUnknownCommands to set + * @since 3.20.0 + */ + public void setDidYouMeanUnknownCommands(boolean didYouMeanUnknownCommands) { + this.didYouMeanUnknownCommands = didYouMeanUnknownCommands; + } + + /** + * @return whether unknown subcommands trigger did-you-mean suggestions + * @since 3.20.0 + */ + public boolean isDidYouMeanSubcommands() { + return didYouMeanSubcommands; + } + + /** + * @param didYouMeanSubcommands the didYouMeanSubcommands to set + * @since 3.20.0 + */ + public void setDidYouMeanSubcommands(boolean didYouMeanSubcommands) { + this.didYouMeanSubcommands = didYouMeanSubcommands; + } + } diff --git a/src/main/java/world/bentobox/bentobox/api/commands/CompositeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/CompositeCommand.java index 922c9004a..925fad388 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/CompositeCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/CompositeCommand.java @@ -261,6 +261,18 @@ public boolean execute(@NonNull CommandSender sender, @NonNull String label, Str CompositeCommand cmd = getCommandFromArgs(args); String cmdLabel = (cmd.subCommandLevel > 0) ? args[cmd.subCommandLevel - 1] : label; List cmdArgs = Arrays.asList(args).subList(cmd.subCommandLevel, args.length); + // Did-you-mean: the walk stopped at a command that has subcommands but with + // unconsumed args, i.e. the first leftover arg matched none of them. Suggest + // what the player probably meant instead of dead-ending into an error. + if (!cmdArgs.isEmpty() && cmd.hasSubCommands() && user.isPlayer() + && plugin.getSettings().isDidYouMeanSubcommands() && plugin.getSuggestionsManager() != null) { + String typedPrefix = "/" + label + (cmd.subCommandLevel > 0 + ? " " + String.join(" ", Arrays.asList(args).subList(0, cmd.subCommandLevel)) + : ""); + if (plugin.getSuggestionsManager().suggestSubcommand(user, cmd, typedPrefix, cmdArgs)) { + return true; + } + } return cmd.call(user, cmdLabel, cmdArgs); } diff --git a/src/main/java/world/bentobox/bentobox/api/user/User.java b/src/main/java/world/bentobox/bentobox/api/user/User.java index 6804d1e04..c731a53bc 100644 --- a/src/main/java/world/bentobox/bentobox/api/user/User.java +++ b/src/main/java/world/bentobox/bentobox/api/user/User.java @@ -42,6 +42,8 @@ import com.google.common.cache.CacheBuilder; 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.minimessage.tag.resolver.TagResolver; import net.kyori.adventure.title.Title; import world.bentobox.bentobox.BentoBox; @@ -804,13 +806,49 @@ public void sendRawMessage(String message) { /** * Parses a message string into an Adventure Component, handling inline commands and * auto-detecting legacy or MiniMessage format. + *

+ * Inline [run_command]/[hover]-style commands are applied to the parsed Component + * programmatically, not by wrapping the text in MiniMessage click/hover tags: the + * text routinely contains a {@code §r} from the legacy round-trip (any closed color + * or decoration emits one), and the {@code } it maps to would close the + * wrapper tags, killing the events and leaking literal {@code } + * text into chat. * * @param text the message text * @return the parsed Component */ private Component parseToComponent(String text) { - String mmMessage = Util.convertInlineCommandsToMiniMessage(text); - return Util.parseMiniMessageOrLegacy(mmMessage); + Util.InlineCommands inline = Util.extractInlineCommands(text); + Component component = Util.parseMiniMessageOrLegacy(inline.cleanText()); + ClickEvent clickEvent = toClickEvent(inline.clickAction(), inline.clickValue()); + if (clickEvent != null) { + component = component.clickEvent(clickEvent); + } + String hoverText = inline.hoverText(); + if (hoverText != null) { + component = component.hoverEvent(HoverEvent.showText(Util.parseMiniMessageOrLegacy(hoverText))); + } + return component; + } + + /** + * Maps an inline command action to an Adventure {@link ClickEvent}. + * + * @param action the inline command action name, may be null + * @param value the action's value, may be null + * @return the click event, or null if the action is unknown or absent + */ + private static ClickEvent toClickEvent(String action, String value) { + if (action == null || value == null) { + return null; + } + return switch (action) { + case "run_command" -> ClickEvent.runCommand(value); + case "suggest_command" -> ClickEvent.suggestCommand(value); + case "copy_to_clipboard" -> ClickEvent.copyToClipboard(value); + case "open_url" -> ClickEvent.openUrl(value); + default -> null; + }; } /** diff --git a/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java b/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java index 471d16b23..58c56d9cd 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java +++ b/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java @@ -7,6 +7,7 @@ import world.bentobox.bentobox.listeners.teleports.PlayerTeleportListener; import world.bentobox.bentobox.managers.ChunkPregenManager; import world.bentobox.bentobox.managers.IslandDeletionManager; +import world.bentobox.bentobox.suggestions.DidYouMeanListener; /** * Registers all BentoBox event listeners with the Bukkit plugin manager. @@ -35,6 +36,7 @@ public void register() { manager.registerEvents(new BlockEndDragon(plugin), plugin); manager.registerEvents(new BannedCommands(plugin), plugin); manager.registerEvents(new DeathListener(plugin), plugin); + manager.registerEvents(new DidYouMeanListener(plugin), plugin); // Register the plugin itself for any listeners it implements (e.g. MV unregister) manager.registerEvents(plugin, plugin); islandDeletionManager = new IslandDeletionManager(plugin); diff --git a/src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java b/src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java new file mode 100644 index 000000000..8ea7a1629 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java @@ -0,0 +1,287 @@ +package world.bentobox.bentobox.suggestions; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Predicate; + +import org.bukkit.World; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.util.Util; + +/** + * Pure matching engine for the "did you mean?" feature. + *

+ * New players routinely type a subcommand as if it were a whole command + * ({@code /teams} or {@code /team invite} instead of {@code /oneblock team}), + * or mistype a subcommand ({@code /island invit}). This class matches what was + * typed against every node of the given command trees - labels and + * aliases, at any depth - and returns ranked candidate commands. + *

+ * This class is a pure function of its inputs: it sends no messages, reads no + * configuration and holds no state, which keeps it directly unit-testable. + * Presentation and pending-intent handling live in {@link SuggestionsManager}. + * + * @author tastybento + * @since 3.20.0 + */ +public final class CommandMatcher { + + /** Minimum per-token quality for a token to be considered a match at all. */ + private static final double MIN_TOKEN_QUALITY = 0.6; + /** + * Score bonus when the command tree belongs to the world the player is in. + * Deliberately equal to {@link SuggestionsManager#CONFIDENT_GAP} so that being + * in a game mode's world is decisive when several game modes offer the same + * subcommand (e.g. {@code team}). + */ + private static final double WORLD_CONTEXT_BONUS = 0.2; + /** Score penalty per level of implicit path the player did not actually type. */ + private static final double IMPLICIT_DEPTH_PENALTY = 0.05; + /** How deep below each starting point the matcher looks for an entry node. */ + private static final int MAX_IMPLICIT_DEPTH = 2; + /** Longest first token that is worth matching; anything longer is noise. */ + private static final int MAX_TOKEN_LENGTH = 30; + + /** + * A candidate command for what the player probably meant. + * + * @param command the command node the match ends on + * @param commandString the full runnable command, starting with '/', with any + * unmatched trailing tokens re-attached as arguments + * @param score ranking score; higher is more likely + */ + public record Match(@NonNull CompositeCommand command, @NonNull String commandString, double score) { + } + + private CommandMatcher() { + // Static use only + } + + /** + * Matches a complete typed command line (as delivered by an unknown-command + * event, without the leading slash) against a set of command trees. + * + * @param tokens the typed command line, split on whitespace + * @param roots top-level commands to search + * @param contextWorld the world the player is in, for context ranking; may be null + * @param accessible filter for commands the player is allowed to see/run + * @return matches sorted best-first; empty if nothing plausible was found + */ + public static List matchCommandLine(@NonNull List tokens, + @NonNull Collection roots, @Nullable World contextWorld, + @NonNull Predicate accessible) { + List results = new ArrayList<>(); + if (validTokens(tokens)) { + for (CompositeCommand root : roots) { + if (accessible.test(root)) { + double worldBonus = worldBonus(root, contextWorld); + collectEntries(tokens, root, "/" + root.getLabel(), 0, worldBonus, accessible, results); + } + } + } + return rank(results); + } + + /** + * Matches leftover arguments against the subcommand tree below a node. Used + * when command dispatch stopped at {@code node} with arguments that matched + * none of its subcommands (e.g. {@code /island invit Bob} stopping at + * {@code /island}). + * + * @param tokens the unconsumed arguments; the first is the presumed + * mistyped subcommand + * @param node the command the dispatch walk stopped at + * @param typedPrefix what the player actually typed to reach {@code node}, + * starting with '/' (e.g. {@code "/island"}) + * @param accessible filter for commands the player is allowed to see/run + * @return matches sorted best-first; empty if nothing plausible was found + */ + public static List matchSubtree(@NonNull List tokens, @NonNull CompositeCommand node, + @NonNull String typedPrefix, @NonNull Predicate accessible) { + List results = new ArrayList<>(); + if (validTokens(tokens)) { + for (CompositeCommand child : node.getSubCommands().values()) { + if (isCandidate(child) && accessible.test(child)) { + collectEntries(tokens, child, typedPrefix + " " + child.getLabel(), 0, 0, accessible, results); + } + } + } + return rank(results); + } + + private static boolean validTokens(List tokens) { + return !tokens.isEmpty() && !tokens.get(0).isBlank() && tokens.get(0).length() <= MAX_TOKEN_LENGTH; + } + + /** + * Considers {@code node} itself as the entry point the first typed token was + * aiming at, then recurses into its subcommands (up to + * {@link #MAX_IMPLICIT_DEPTH}) so that a subcommand typed as a root command + * is found too. + * + * @param canonicalPath full runnable path up to and including {@code node}'s label + * @param implicitDepth how many path elements the player did not type + */ + private static void collectEntries(List tokens, CompositeCommand node, String canonicalPath, + int implicitDepth, double worldBonus, Predicate accessible, List results) { + tryMatch(tokens, node, canonicalPath, implicitDepth, worldBonus, accessible, results); + if (implicitDepth < MAX_IMPLICIT_DEPTH) { + for (CompositeCommand child : node.getSubCommands().values()) { + if (isCandidate(child) && accessible.test(child)) { + collectEntries(tokens, child, canonicalPath + " " + child.getLabel(), implicitDepth + 1, + worldBonus, accessible, results); + } + } + } + } + + /** + * Attempts to match the token list starting at {@code entry}: the first token + * against the entry node itself, then following tokens greedily down its + * subcommands. Unmatched trailing tokens are kept as command arguments. + */ + private static void tryMatch(List tokens, CompositeCommand entry, String canonicalPath, + int implicitDepth, double worldBonus, Predicate accessible, List results) { + double quality = tokenQuality(tokens.get(0), entry); + if (quality < MIN_TOKEN_QUALITY) { + return; + } + double qualitySum = quality; + int matchedTokens = 1; + CompositeCommand node = entry; + StringBuilder commandString = new StringBuilder(canonicalPath); + int i = 1; + while (i < tokens.size()) { + CompositeCommand best = null; + double bestQuality = MIN_TOKEN_QUALITY; + for (CompositeCommand child : node.getSubCommands().values()) { + if (isCandidate(child) && accessible.test(child)) { + double q = tokenQuality(tokens.get(i), child); + if (q >= bestQuality) { + bestQuality = q; + best = child; + } + } + } + if (best == null) { + break; + } + node = best; + qualitySum += bestQuality; + matchedTokens++; + commandString.append(' ').append(best.getLabel()); + i++; + } + // Any leftover tokens are treated as arguments and re-attached verbatim + while (i < tokens.size()) { + commandString.append(' ').append(tokens.get(i)); + i++; + } + double score = qualitySum / matchedTokens + worldBonus - implicitDepth * IMPLICIT_DEPTH_PENALTY; + results.add(new Match(node, commandString.toString(), score)); + } + + /** + * Commands that should never be suggested: hidden ones, console-only ones and + * the ubiquitous help command. + */ + private static boolean isCandidate(CompositeCommand command) { + return !command.isHidden() && !command.isOnlyConsole() && !"help".equals(command.getLabel()); + } + + private static double worldBonus(CompositeCommand root, @Nullable World contextWorld) { + if (contextWorld == null || root.getWorld() == null) { + return 0; + } + World commandWorld = Util.getWorld(root.getWorld()); + return contextWorld.equals(commandWorld) ? WORLD_CONTEXT_BONUS : 0; + } + + /** + * Best match quality of a typed token against a command's label and aliases. + */ + private static double tokenQuality(String token, CompositeCommand command) { + String typed = token.toLowerCase(Locale.ENGLISH); + double best = quality(typed, command.getLabel().toLowerCase(Locale.ENGLISH)); + for (String alias : command.getAliases()) { + best = Math.max(best, quality(typed, alias.toLowerCase(Locale.ENGLISH))); + } + return best; + } + + /** + * Similarity of a typed token to a candidate label in [0, 1]: 1.0 for an exact + * match, otherwise the better of a prefix match and a length-scaled edit + * distance, or 0 if they are not plausibly the same word. + * Package-private for unit testing. + */ + static double quality(String typed, String candidate) { + if (typed.equals(candidate)) { + return 1.0; + } + double result = 0; + // Prefix typing: "invit" for "invite". Too-short prefixes are ambiguous. + if (typed.length() >= 3 && candidate.startsWith(typed)) { + result = 0.7 + 0.2 * typed.length() / candidate.length(); + } + // Typos and plurals: "teams" for "team", "tem" for "team" + int maxLength = Math.max(typed.length(), candidate.length()); + int allowed = allowedDistance(maxLength); + int distance = levenshtein(typed, candidate); + if (distance <= allowed) { + result = Math.max(result, 1.0 - (double) distance / maxLength); + } + return result; + } + + /** Edit-distance budget scaled to the longer word: stricter for short labels. */ + private static int allowedDistance(int maxLength) { + if (maxLength <= 4) { + return 1; + } + if (maxLength <= 7) { + return 2; + } + return 3; + } + + /** Plain Levenshtein edit distance; inputs are short command labels. */ + private static int levenshtein(String a, String b) { + int[] previous = new int[b.length() + 1]; + int[] current = new int[b.length() + 1]; + for (int j = 0; j <= b.length(); j++) { + previous[j] = j; + } + for (int i = 1; i <= a.length(); i++) { + current[0] = i; + for (int j = 1; j <= b.length(); j++) { + int substitution = previous[j - 1] + (a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1); + current[j] = Math.min(Math.min(current[j - 1] + 1, previous[j] + 1), substitution); + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous[b.length()]; + } + + /** + * Sorts best-first and keeps only the best-scoring match per distinct command + * string. + */ + private static List rank(List results) { + Map best = new LinkedHashMap<>(); + for (Match match : results) { + best.merge(match.commandString(), match, (a, b) -> a.score() >= b.score() ? a : b); + } + return best.values().stream().sorted(Comparator.comparingDouble(Match::score).reversed()).toList(); + } +} diff --git a/src/main/java/world/bentobox/bentobox/suggestions/DidYouMeanListener.java b/src/main/java/world/bentobox/bentobox/suggestions/DidYouMeanListener.java new file mode 100644 index 000000000..7e360c720 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/suggestions/DidYouMeanListener.java @@ -0,0 +1,103 @@ +package world.bentobox.bentobox.suggestions; + +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.command.UnknownCommandEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.user.User; + +/** + * Wires the "did you mean?" suggestion engine into the server: + *

+ * + * @author tastybento + * @since 3.20.0 + */ +public class DidYouMeanListener implements Listener { + + private final BentoBox plugin; + + public DidYouMeanListener(BentoBox plugin) { + this.plugin = plugin; + } + + /** + * Fires only when no plugin owns the typed command, so BentoBox never + * shadows another plugin's command here. + */ + @EventHandler(priority = EventPriority.NORMAL) + public void onUnknownCommand(UnknownCommandEvent e) { + if (!plugin.getSettings().isDidYouMeanUnknownCommands() || !(e.getSender() instanceof Player player)) { + return; + } + User user = User.getInstance(player); + if (plugin.getSuggestionsManager().suggestCommand(user, e.getCommandLine())) { + // Our suggestion replaces the vanilla "Unknown command" message + e.message(null); + } + } + + /** + * A player answering "yes" (or "y") in chat accepts their pending + * suggestion. Anything else is left alone. + */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onChat(AsyncChatEvent e) { + String plain = PlainTextComponentSerializer.plainText().serialize(e.message()).trim(); + if ((plain.equalsIgnoreCase("yes") || plain.equalsIgnoreCase("y")) + && acceptPending(e.getPlayer().getUniqueId())) { + e.setCancelled(true); + } + } + + /** + * Consumes the pending suggestion, if any, and schedules the suggested + * command on the main thread (chat events are async). + * + * @param uuid the player's UUID + * @return true if a pending suggestion was accepted + */ + boolean acceptPending(UUID uuid) { + return plugin.getSuggestionsManager().acceptPending(uuid).map(command -> { + Bukkit.getScheduler().runTask(plugin, () -> { + Player player = Bukkit.getPlayer(uuid); + if (player != null) { + player.performCommand(command.substring(1)); + } + }); + return true; + }).orElse(false); + } + + /** + * The player has moved on to some other command; whatever was suggested is + * stale now. This also covers the player clicking the suggestion itself. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onCommandPreprocess(PlayerCommandPreprocessEvent e) { + plugin.getSuggestionsManager().clearPending(e.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent e) { + plugin.getSuggestionsManager().clearPending(e.getPlayer().getUniqueId()); + } +} diff --git a/src/main/java/world/bentobox/bentobox/suggestions/SuggestionsManager.java b/src/main/java/world/bentobox/bentobox/suggestions/SuggestionsManager.java new file mode 100644 index 000000000..37fea5e7a --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/suggestions/SuggestionsManager.java @@ -0,0 +1,157 @@ +package world.bentobox.bentobox.suggestions; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.World; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.suggestions.CommandMatcher.Match; +import world.bentobox.bentobox.util.Util; + +/** + * Turns {@link CommandMatcher} results into player-facing "did you mean?" + * suggestions and tracks the pending suggestion each player can accept by + * typing {@code yes}. + *

+ * Suggestion messages are sent through the normal locale system and use the + * {@code [run_command: ...]} inline tag, so clicking the message runs the + * suggested command directly - no re-typing needed. + * + * @author tastybento + * @since 3.20.0 + */ +public class SuggestionsManager { + + /** How long a player has to type "yes" after receiving a suggestion. */ + private static final long PENDING_EXPIRY_MS = 30_000; + /** Matches below this score are never shown. */ + private static final double MIN_SCORE = 0.6; + /** + * Lead the best match needs over the runner-up to be presented as a single + * confident suggestion rather than a list of options. + */ + static final double CONFIDENT_GAP = 0.2; + /** Maximum number of options shown when several matches are plausible. */ + private static final int MAX_OPTIONS = 3; + /** Tolerance for floating-point score comparisons. */ + private static final double EPSILON = 1e-9; + /** Longest command line worth analysing. */ + private static final int MAX_COMMAND_LINE_LENGTH = 256; + + private static final String COMMAND_VAR = "[command]"; + + private final BentoBox plugin; + private final Map pending = new ConcurrentHashMap<>(); + + private record PendingSuggestion(String command, long expiry) { + } + + public SuggestionsManager(@NonNull BentoBox plugin) { + this.plugin = plugin; + } + + /** + * Handles a command line no plugin knows, e.g. a player typing + * {@code /teams} when they meant {@code /oneblock team}. Matches it against + * every registered BentoBox command tree. + * + * @param user the player who typed the command + * @param commandLine the full command line without the leading slash + * @return true if a suggestion was sent to the player + */ + public boolean suggestCommand(@NonNull User user, @Nullable String commandLine) { + if (commandLine == null || commandLine.isBlank() || commandLine.length() > MAX_COMMAND_LINE_LENGTH) { + return false; + } + List tokens = Arrays.asList(commandLine.trim().split("\\s+")); + World contextWorld = user.isPlayer() ? Util.getWorld(user.getWorld()) : null; + List matches = CommandMatcher.matchCommandLine(tokens, + plugin.getCommandsManager().getCommands().values(), contextWorld, cmd -> isAccessible(user, cmd)); + return sendSuggestions(user, matches); + } + + /** + * Handles arguments that matched none of a command's subcommands, e.g. + * {@code /island invit Bob}. Matches them against the subcommand tree below + * the command the dispatch walk stopped at. + * + * @param user the player who typed the command + * @param command the command the dispatch walk stopped at + * @param typedPrefix what the player typed to reach {@code command}, + * starting with '/' (e.g. {@code "/island"}) + * @param args the unconsumed arguments + * @return true if a suggestion was sent to the player + */ + public boolean suggestSubcommand(@NonNull User user, @NonNull CompositeCommand command, + @NonNull String typedPrefix, @NonNull List args) { + List matches = CommandMatcher.matchSubtree(args, command, typedPrefix, + cmd -> isAccessible(user, cmd)); + return sendSuggestions(user, matches); + } + + /** + * Consumes the player's pending suggestion if there is one and it has not + * expired. + * + * @param uuid the player's UUID + * @return the suggested command (starting with '/') if one was pending + */ + public Optional acceptPending(@NonNull UUID uuid) { + PendingSuggestion suggestion = pending.remove(uuid); + return suggestion != null && System.currentTimeMillis() <= suggestion.expiry() + ? Optional.of(suggestion.command()) + : Optional.empty(); + } + + /** + * Drops any pending suggestion for this player, e.g. because they ran some + * other command and have moved on. + * + * @param uuid the player's UUID + */ + public void clearPending(@NonNull UUID uuid) { + pending.remove(uuid); + } + + /** + * Never suggest a command the player would not be allowed to run. Walks the + * permission chain up through the parents. + */ + private boolean isAccessible(User user, CompositeCommand command) { + CompositeCommand node = command; + while (node != null) { + if (!user.hasPermission(node.getPermission())) { + return false; + } + node = node.getParent(); + } + return true; + } + + private boolean sendSuggestions(User user, List matches) { + List plausible = matches.stream().filter(m -> m.score() >= MIN_SCORE).limit(MAX_OPTIONS).toList(); + if (plausible.isEmpty()) { + return false; + } + if (plausible.size() == 1 || plausible.get(0).score() - plausible.get(1).score() >= CONFIDENT_GAP - EPSILON) { + // One clear winner: suggest it and let the player accept by typing "yes" + String command = plausible.get(0).commandString(); + user.sendMessage("general.did-you-mean.suggestion", COMMAND_VAR, command); + pending.put(user.getUniqueId(), new PendingSuggestion(command, System.currentTimeMillis() + PENDING_EXPIRY_MS)); + } else { + // Several plausible options: list them, each clickable + user.sendMessage("general.did-you-mean.header"); + plausible.forEach(m -> user.sendMessage("general.did-you-mean.option", COMMAND_VAR, m.commandString())); + } + return true; + } +} diff --git a/src/main/java/world/bentobox/bentobox/util/Util.java b/src/main/java/world/bentobox/bentobox/util/Util.java index 5b0e7bc87..27bf590b3 100644 --- a/src/main/java/world/bentobox/bentobox/util/Util.java +++ b/src/main/java/world/bentobox/bentobox/util/Util.java @@ -1389,13 +1389,68 @@ private static Component computeMiniMessageOrLegacy(@NonNull String text) { */ @NonNull public static String convertInlineCommandsToMiniMessage(@NonNull String message) { - // Handle escaped double brackets first: [[text]] → text (literal) + InlineCommands inline = extractInlineCommands(message); + String clickTag = inline.clickAction() != null + ? "" + : null; + String hoverTag = inline.hoverText() != null + // Escape single quotes in hover text + ? "" + : null; + String result = inline.cleanText(); + + // Wrap the text with click and hover tags + if (clickTag != null || hoverTag != null) { + String prefix = (hoverTag != null ? hoverTag : "") + (clickTag != null ? clickTag : ""); + String suffix = (clickTag != null ? "" : "") + (hoverTag != null ? "" : ""); + result = prefix + result + suffix; + } + + return result; + } + + /** + * The click/hover data carried by a message's inline bracket commands, plus the + * message text with those bracket tags removed. + * + * @param cleanText the message with recognized inline commands stripped and escaped + * double brackets unescaped + * @param clickAction the click action name ({@code run_command}, {@code suggest_command}, + * {@code copy_to_clipboard} or {@code open_url}), or null if none + * @param clickValue the click action's value, or null if none + * @param hoverText the hover text, or null if none + * @since 3.20.0 + */ + public record InlineCommands(@NonNull String cleanText, @Nullable String clickAction, + @Nullable String clickValue, @Nullable String hoverText) { + } + + /** + * Extracts inline command bracket syntax ({@code [run_command: /cmd]}, {@code [hover: text]}, + * etc.) from a message, returning the click/hover actions as data along with the cleaned + * text. + *

+ * Callers that build a {@link Component} should parse {@link InlineCommands#cleanText()} and + * apply the click/hover events programmatically rather than wrapping the text in MiniMessage + * tags (as {@link #convertInlineCommandsToMiniMessage(String)} does): if the text itself + * contains a reset - such as the {@code \u00A7r} the legacy round-trip emits when a color or + * decoration closes - MiniMessage's {@code } closes the wrapping click/hover tags too, + * which kills the events and renders the orphaned closing tags as literal text. + * + * @param message the message with bracket-syntax inline commands + * @return the cleaned text and any click/hover data found + * @since 3.20.0 + */ + @NonNull + public static InlineCommands extractInlineCommands(@NonNull String message) { + // Handle escaped double brackets first: [[text]] -> text (literal) message = message.replace("[[", "\u0000LBRACKET\u0000").replace("]]", "\u0000RBRACKET\u0000"); // Extract all inline commands Matcher matcher = INLINE_CMD_PATTERN.matcher(message); - String clickTag = null; - String hoverTag = null; + String clickAction = null; + String clickValue = null; + String hoverText = null; // Collect commands and strip them from the text StringBuilder cleanText = new StringBuilder(); @@ -1405,35 +1460,22 @@ public static String convertInlineCommandsToMiniMessage(@NonNull String message) String action = matcher.group(1).toLowerCase(java.util.Locale.ENGLISH); String value = matcher.group(2); switch (action) { - case "hover" -> { - if (hoverTag == null) { - // Escape single quotes in hover text - hoverTag = ""; - } - } + // First hover/click wins; ignore later duplicates. + case "hover" -> hoverText = hoverText == null ? value : hoverText; case "run_command", "suggest_command", "copy_to_clipboard", "open_url" -> { - if (clickTag == null) { - clickTag = ""; - } + clickValue = clickAction == null ? value : clickValue; + clickAction = clickAction == null ? action : clickAction; } default -> cleanText.append(matcher.group()); // unknown, keep as-is } lastEnd = matcher.end(); } cleanText.append(message.substring(lastEnd)); - String result = cleanText.toString(); // Restore escaped brackets - result = result.replace("\u0000LBRACKET\u0000", "[").replace("\u0000RBRACKET\u0000", "]"); - - // Wrap the text with click and hover tags - if (clickTag != null || hoverTag != null) { - String prefix = (hoverTag != null ? hoverTag : "") + (clickTag != null ? clickTag : ""); - String suffix = (clickTag != null ? "" : "") + (hoverTag != null ? "" : ""); - result = prefix + result + suffix; - } - - return result; + String result = cleanText.toString().replace("\u0000LBRACKET\u0000", "[") + .replace("\u0000RBRACKET\u0000", "]"); + return new InlineCommands(result, clickAction, clickValue, hoverText); } /** diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 929b8f0d2..12d428316 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -90,6 +90,17 @@ general: flingback: 5.0 # Remove mobs on teleport. teleport-remove-mobs: false + # Did-you-mean command suggestions. + did-you-mean: + # When a player types an unknown command, e.g. /teams instead of /oneblock team, + # BentoBox suggests the closest matching BentoBox command. The player can click the + # suggestion or type 'yes' to run it. + # Added since 3.20.0. + unknown-commands: true + # When a player types an unknown subcommand, e.g. /island invit instead of /island team invite, + # BentoBox suggests the closest matching subcommand instead of showing an error. + # Added since 3.20.0. + subcommands: true panel: # Panel click cooldown. Value is in milliseconds. Prevents players spamming button presses in GUIs. click-cooldown-ms: 250 diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 830d04fdf..687fe3b51 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -11,6 +11,10 @@ prefixes: an-island: ostrov islands: ostrovy general: + did-you-mean: + suggestion: 'Měli jste na mysli [command]? Klikněte sem nebo napište yes pro spuštění.[run_command: [command]][hover: Kliknutím spustíte [command]]' + header: 'Měli jste na mysli něco z tohoto? Klikněte pro spuštění:' + option: ' [command][run_command: [command]][hover: Kliknutím spustíte [command]]' success: 'Povedlo se!' invalid: Neplatné beta: 'Tento příkaz je v beta verzi. Ujisti se, že máš zálohy!' @@ -183,6 +187,7 @@ commands: admin-kicked: 'Admin tě vykopnul z týmu.' success: '[name] byl vykopnut z ostrova hráče [owner].' setowner: + specify-island: 'Postavte se na [prefix_island] a spusťte příkaz ve hře, nebo uveďte současného vlastníka ostrova: setowner ' parameters: description: předat vlastnictví ostrova hráči already-owner: '[name] již je vlastníkem ostrova!' diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 88a7c5489..3653a9947 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -12,6 +12,10 @@ prefixes: an-island: eine Insel islands: Inseln general: + did-you-mean: + suggestion: 'Meintest du [command]? Klicke hier oder tippe yes, um ihn auszuführen.[run_command: [command]][hover: Klicken, um [command] auszuführen]' + header: 'Meintest du einen dieser Befehle? Klicke zum Ausführen:' + option: ' [command][run_command: [command]][hover: Klicken, um [command] auszuführen]' success: 'Erfolg!' invalid: Ungültig beta: 'Dieser Befehl ist in Beta. Stellen Sie sicher, dass Sie Backups haben!' @@ -200,6 +204,7 @@ commands: admin-kicked: 'Der Admin hat dich aus dem Team gekickt.' success: '[name]wurde von der Insel von [owner] gekickt.' setowner: + specify-island: 'Stelle dich auf die [prefix_island] und führe dies im Spiel aus, oder gib den aktuellen Inselbesitzer an: setowner ' parameters: description: überträgt das Insel-Eigentum auf den Spieler already-owner: '[name] ist bereits der Besitzer dieser Insel!' diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 43ff3f608..116e5a211 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -48,6 +48,11 @@ general: must-be-positive-number: '[number] is not a valid positive number.' not-on-island: 'You are not on [prefix_island]!' slow-down: 'Slow down. Click slower.' + did-you-mean: + suggestion: 'Did you mean [command]? Click here + or type yes to run it.[run_command: [command]][hover: Click to run [command]]' + header: 'Did you mean one of these? Click one to run it:' + option: ' [command][run_command: [command]][hover: Click to run [command]]' worlds: overworld: Overworld nether: Nether diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index cb73f9093..ea53bbe67 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -12,6 +12,10 @@ prefixes: an-island: una isla islands: islas general: + did-you-mean: + suggestion: '¿Quisiste decir [command]? Haz clic aquí o escribe yes para ejecutarlo.[run_command: [command]][hover: Haz clic para ejecutar [command]]' + header: '¿Quisiste decir uno de estos? Haz clic en uno para ejecutarlo:' + option: ' [command][run_command: [command]][hover: Haz clic para ejecutar [command]]' success: 'Éxito!' invalid: Inválido beta: 'Este comando está en beta. ¡Asegúrate de tener copias de seguridad!' @@ -189,6 +193,7 @@ commands: admin-kicked: 'El administrador te echó del equipo.' success: '[name] ha sido expulsado de la isla de [owner].' setowner: + specify-island: 'Sitúate en la [prefix_island] y ejecútalo en el juego, o indica el propietario actual de la isla: setowner ' parameters: description: Transfiere la propiedad de la isla al jugador already-owner: '[name] ya es el dueño de esta isla!' diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index ac3d813ac..9a7235a33 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -11,6 +11,10 @@ prefixes: an-island: une île islands: îles general: + did-you-mean: + suggestion: 'Vouliez-vous dire [command] ? Cliquez ici ou tapez yes pour l’exécuter.[run_command: [command]][hover: Cliquez pour exécuter [command]]' + header: 'Vouliez-vous dire l’un de ceux-ci ? Cliquez pour exécuter :' + option: ' [command][run_command: [command]][hover: Cliquez pour exécuter [command]]' success: 'Succès !' invalid: Invalide beta: >- @@ -197,6 +201,7 @@ commands: admin-kicked: 'L''administrateur vous a kické de l''équipe.' success: '[name] a été kické de l''île de [owner] .' setowner: + specify-island: 'Placez-vous sur l’[prefix_island] et exécutez ceci en jeu, ou indiquez le propriétaire actuel de l’île : setowner ' parameters: description: transférer la propriété de l'île au joueur already-owner: '[name] est déjà propriétaire de cette île!' diff --git a/src/main/resources/locales/hr.yml b/src/main/resources/locales/hr.yml index 3e87d0202..ea151873a 100644 --- a/src/main/resources/locales/hr.yml +++ b/src/main/resources/locales/hr.yml @@ -11,6 +11,10 @@ prefixes: an-island: otok islands: ostrva general: + did-you-mean: + suggestion: 'Jeste li mislili [command]? Kliknite ovdje ili upišite yes za pokretanje.[run_command: [command]][hover: Kliknite za pokretanje [command]]' + header: 'Jeste li mislili nešto od ovoga? Kliknite za pokretanje:' + option: ' [command][run_command: [command]][hover: Kliknite za pokretanje [command]]' success: 'Uspjeh!' invalid: Neispravno beta: 'Ova je naredba u beta verziji. Obavezno imate sigurnosne kopije!' @@ -191,6 +195,7 @@ commands: admin-kicked: 'Admin vas je izbacio iz tima.' success: '[name] je izbačen s otoka [owner].' setowner: + specify-island: 'Stanite na [prefix_island] i pokrenite ovo u igri, ili navedite trenutnog vlasnika otoka: setowner ' parameters: description: prenosi vlasništvo nad otokom na igrača already-owner: '[name] je već vlasnik ovog otoka!' diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 745d9b8f9..2656c7bf2 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -11,6 +11,10 @@ prefixes: an-island: egy sziget islands: szigetek general: + did-you-mean: + suggestion: 'Erre gondoltál: [command]? Kattints ide, vagy írd be: yes a futtatáshoz.[run_command: [command]][hover: Kattints a futtatáshoz: [command]]' + header: 'Ezek egyikére gondoltál? Kattints egyre a futtatáshoz:' + option: ' [command][run_command: [command]][hover: Kattints a futtatáshoz: [command]]' success: 'Siker!' invalid: Érvénytelen beta: 'Ez a parancs béta. Győződjön meg róla, hogy van biztonsági másolata!' @@ -201,6 +205,7 @@ commands: admin-kicked: 'Egy adminisztrátor kirúgott téged a csapatból.' success: '[name] ki lett rúgva [owner]szigetéről.' setowner: + specify-island: 'Állj a(z) [prefix_island] szigetre és futtasd a játékban, vagy add meg a sziget jelenlegi tulajdonosát: setowner ' parameters: description: A [prefix_island] tulajdonjogának átruházása a játékosnak. already-owner: '[name] már a szigetnek a tulajdonosa!' diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index f86043aff..3a62e35ac 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -11,6 +11,10 @@ prefixes: an-island: sebuah pulau islands: pulau-pulau general: + did-you-mean: + suggestion: 'Apakah maksud Anda [command]? Klik di sini atau ketik yes untuk menjalankannya.[run_command: [command]][hover: Klik untuk menjalankan [command]]' + header: 'Apakah maksud Anda salah satu dari ini? Klik salah satu untuk menjalankan:' + option: ' [command][run_command: [command]][hover: Klik untuk menjalankan [command]]' success: 'Berhasil!' invalid: Tidak valid beta: 'Perintah ini dalam beta. Pastikan Anda memiliki cadangan!' @@ -192,6 +196,7 @@ commands: admin-kicked: 'Admin mengeluarkan Anda dari tim.' success: '[name] telah diusir dari pulau [owner].' setowner: + specify-island: 'Berdirilah di [prefix_island] dan jalankan ini dalam game, atau sebutkan pemilik pulau saat ini: setowner ' parameters: description: mentransfer kepemilikan pulau kepada pemain already-owner: '[name] sudah menjadi pemilik pulau ini!' diff --git a/src/main/resources/locales/it.yml b/src/main/resources/locales/it.yml index c25bb6288..25a220b9b 100644 --- a/src/main/resources/locales/it.yml +++ b/src/main/resources/locales/it.yml @@ -11,6 +11,10 @@ prefixes: an-island: un'isola islands: isole general: + did-you-mean: + suggestion: 'Intendevi [command]? Clicca qui o scrivi yes per eseguirlo.[run_command: [command]][hover: Clicca per eseguire [command]]' + header: 'Intendevi uno di questi? Cliccane uno per eseguirlo:' + option: ' [command][run_command: [command]][hover: Clicca per eseguire [command]]' success: 'Successo!' invalid: Non valido beta: 'Questo comando è in beta. Assicurati di avere backup!' @@ -195,6 +199,7 @@ commands: admin-kicked: 'Un amministratore ti ha cacciato dal tuo team.' success: '[name] è stato cacciato dall''isola di [owner].' setowner: + specify-island: 'Posizionati sulla [prefix_island] ed eseguilo in gioco, oppure indica il proprietario attuale dell’isola: setowner ' parameters: description: trasferisci il possesso di un'isola ad un player already-owner: '[name] è già il proprietario dell''isola!' diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 69f3a2283..61c19f2c9 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -11,6 +11,10 @@ prefixes: an-island: 島 islands: 島 general: + did-you-mean: + suggestion: 'もしかして [command]?ここをクリックするか、yes と入力すると実行されます。[run_command: [command]][hover: クリックで [command] を実行]' + header: 'もしかして次のいずれかですか?クリックすると実行されます:' + option: ' [command][run_command: [command]][hover: クリックで [command] を実行]' success: '成功!' invalid: 無効 beta: 'このコマンドはベータ版です。バックアップがあることを確認してください!' @@ -169,6 +173,7 @@ commands: admin-kicked: 管理者は、あなたをチームから外しました。 success: '[name][owner]の島から追い出されました。' setowner: + specify-island: '[prefix_island]の上に立ってゲーム内で実行するか、島の現在の所有者を指定してください: setowner ' parameters: <プレーヤー> description: プレーヤーをチームのリーダーにする% already-owner: 'プレイヤーはすでにリーダーです!' diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index a71550a7a..0af8d1149 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -12,6 +12,10 @@ prefixes: an-island: 섬 islands: 섬들 general: + did-you-mean: + suggestion: '혹시 [command] 명령어를 입력하려고 하셨나요? 여기를 클릭하거나 yes를 입력하면 실행됩니다.[run_command: [command]][hover: 클릭하여 [command] 실행]' + header: '다음 중 하나를 입력하려고 하셨나요? 클릭하면 실행됩니다:' + option: ' [command][run_command: [command]][hover: 클릭하여 [command] 실행]' success: '성공!' invalid: 올바르지 않음 beta: '이 명령은 베타입니다. 백업이 있는지 확인하십시오!' @@ -180,6 +184,7 @@ commands: admin-kicked: '관리자가 당신을 소속되있던 섬에서 추방시켰습니다.' success: '[name] 이가 [owner]의 섬에서 추방당했습니다.' setowner: + specify-island: '[prefix_island] 위에 서서 게임 내에서 실행하거나, 섬의 현재 소유자를 지정하세요: setowner ' parameters: <닉네임> description: 섬의 주인권한을 플레이어에게 넘깁니다 already-owner: '[name] 은 이미 이 섬의 주인입니다!' diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index 4b691a4d0..aca0c1328 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -11,6 +11,10 @@ prefixes: an-island: salā islands: saliņas general: + did-you-mean: + suggestion: 'Vai jūs domājāt [command]? Noklikšķiniet šeit vai ierakstiet yes, lai to palaistu.[run_command: [command]][hover: Noklikšķiniet, lai palaistu [command]]' + header: 'Vai jūs domājāt kādu no šiem? Noklikšķiniet, lai palaistu:' + option: ' [command][run_command: [command]][hover: Noklikšķiniet, lai palaistu [command]]' success: 'Darīts!' invalid: Nederīgs! beta: 'Šī komanda ir beta versijā. Pārliecinieties, ka jums ir dublējumi!' @@ -190,6 +194,7 @@ commands: admin-kicked: 'Admins tevi izmenta no komandas.' success: '[name] tika izmests no [owner]salas.' setowner: + specify-island: 'Nostājieties uz [prefix_island] un palaidiet to spēlē, vai norādiet salas pašreizējo īpašnieku: setowner ' parameters: description: iecelt par salas īpašnieku already-owner: '[name] jau ir salas īpašnieks!' diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index 8fcff2e99..2999e0dd6 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -11,6 +11,10 @@ prefixes: an-island: een eiland islands: eilanden general: + did-you-mean: + suggestion: 'Bedoelde je [command]? Klik hier of typ yes om het uit te voeren.[run_command: [command]][hover: Klik om [command] uit te voeren]' + header: 'Bedoelde je een van deze? Klik op één om uit te voeren:' + option: ' [command][run_command: [command]][hover: Klik om [command] uit te voeren]' success: 'Succes!' invalid: Ongeldig beta: 'Deze opdracht is in bèta. Zorg ervoor dat je back -ups hebt!' @@ -197,6 +201,7 @@ commands: admin-kicked: 'De admin heeft je uit het team geschopt.' success: '[name] is geschopt van [owner] ''s eiland.' setowner: + specify-island: 'Ga op het [prefix_island] staan en voer dit in-game uit, of geef de huidige eilandeigenaar op: setowner ' parameters: description: draagt het eilandbezit over aan de speler already-owner: '[name] is al de eigenaar van dit eiland!' diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index f647a060b..c1ffa806d 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -11,6 +11,10 @@ prefixes: an-island: wyspa islands: wyspy general: + did-you-mean: + suggestion: 'Czy chodziło ci o [command]? Kliknij tutaj lub wpisz yes, aby uruchomić.[run_command: [command]][hover: Kliknij, aby uruchomić [command]]' + header: 'Czy chodziło ci o któreś z tych? Kliknij, aby uruchomić:' + option: ' [command][run_command: [command]][hover: Kliknij, aby uruchomić [command]]' success: 'Sukces!' invalid: Błąd beta: 'To polecenie jest w wersji beta. Upewnij się, że masz kopie zapasowe!' @@ -195,6 +199,7 @@ commands: admin-kicked: 'Administrator wyrzucił cię z drużyny.' success: '[name] został wyrzucony z [owner]''s [prefix_island].' setowner: + specify-island: 'Stań na [prefix_island] i uruchom to w grze, lub podaj obecnego właściciela wyspy: setowner ' parameters: description: uczyń gracza liderem wyspy already-owner: 'Gracz jest już liderem!' diff --git a/src/main/resources/locales/pt-BR.yml b/src/main/resources/locales/pt-BR.yml index 1e56268c5..ec7154e56 100644 --- a/src/main/resources/locales/pt-BR.yml +++ b/src/main/resources/locales/pt-BR.yml @@ -10,6 +10,10 @@ prefixes: an-island: uma ilha islands: ilhas general: + did-you-mean: + suggestion: 'Você quis dizer [command]? Clique aqui ou digite yes para executá-lo.[run_command: [command]][hover: Clique para executar [command]]' + header: 'Você quis dizer um destes? Clique em um para executar:' + option: ' [command][run_command: [command]][hover: Clique para executar [command]]' success: 'Sucesso!' invalid: Inválido beta: 'Este comando está na versão beta. Certifique -se de ter backups!' @@ -188,6 +192,7 @@ commands: admin-kicked: 'O administrador expulsou você da equipe.' success: '[name] foi expulso da ilha de [owner].' setowner: + specify-island: 'Fique na [prefix_island] e execute isso no jogo, ou informe o dono atual da ilha: setowner ' parameters: description: transferir posse da ilha para um jogador already-owner: '[name] já é dono desta ilha!' diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index b76df4c81..30adc7edc 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -11,6 +11,10 @@ prefixes: an-island: uma ilha islands: ilhas general: + did-you-mean: + suggestion: 'Queria dizer [command]? Clique aqui ou escreva yes para o executar.[run_command: [command]][hover: Clique para executar [command]]' + header: 'Queria dizer um destes? Clique num para executar:' + option: ' [command][run_command: [command]][hover: Clique para executar [command]]' success: 'Sucesso!' invalid: Invalido beta: 'Este comando está na versão beta. Certifique -se de ter backups!' @@ -195,6 +199,7 @@ commands: admin-kicked: 'O administrador expulsou você do time.' success: '[name] foi expulsa da ilha de [owner].' setowner: + specify-island: 'Fique na [prefix_island] e execute isto no jogo, ou indique o atual dono da ilha: setowner ' parameters: description: transfere o dono da ilha para o jogador already-owner: '[name] já é o dono desta ilha!' diff --git a/src/main/resources/locales/ro.yml b/src/main/resources/locales/ro.yml index 897cd1d78..3e1b039b1 100644 --- a/src/main/resources/locales/ro.yml +++ b/src/main/resources/locales/ro.yml @@ -11,6 +11,10 @@ prefixes: an-island: o insulă islands: insule general: + did-you-mean: + suggestion: 'Ai vrut să spui [command]? Dă clic aici sau tastează yes pentru a-l rula.[run_command: [command]][hover: Dă clic pentru a rula [command]]' + header: 'Ai vrut să spui una dintre acestea? Dă clic pe una pentru a o rula:' + option: ' [command][run_command: [command]][hover: Dă clic pentru a rula [command]]' success: 'Succes!' invalid: Invalid beta: >- @@ -197,6 +201,7 @@ commands: admin-kicked: 'Administratorul te-a dat afară din echipă.' success: '[name] a fost dat afară de pe insula lui [owner] .' setowner: + specify-island: 'Stai pe [prefix_island] și rulează asta în joc, sau numește proprietarul actual al insulei: setowner ' parameters: description: transferă proprietatea insulei către jucător already-owner: '[name] este deja proprietarul acestei insule!' diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 0df53c160..31178f1d7 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -17,6 +17,10 @@ prefixes: an-island: остров islands: острова general: + did-you-mean: + suggestion: 'Возможно, вы имели в виду [command]? Нажмите здесь или введите yes, чтобы выполнить.[run_command: [command]][hover: Нажмите, чтобы выполнить [command]]' + header: 'Возможно, вы имели в виду одну из этих команд? Нажмите, чтобы выполнить:' + option: ' [command][run_command: [command]][hover: Нажмите, чтобы выполнить [command]]' success: Успешно! invalid: Некорректно beta: Эта команда в бета-версии. Убедитесь, что у вас есть резервные @@ -196,6 +200,7 @@ commands: admin-kicked: Администратор выгнал вас из команды. success: [name] был выгнан с острова игрока [owner]. setowner: + specify-island: 'Встаньте на [prefix_island] и выполните команду в игре, либо укажите текущего владельца острова: setowner ' parameters: <игрок> description: передает статус владельца между игроками already-owner: [name] уже является владельцем острова! diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index 24784defe..adb9ff7c0 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -12,6 +12,10 @@ prefixes: an-island: bir ada islands: adalar general: + did-you-mean: + suggestion: 'Şunu mu demek istediniz: [command]? Çalıştırmak için buraya tıklayın veya yes yazın.[run_command: [command]][hover: [command] çalıştırmak için tıklayın]' + header: 'Şunlardan birini mi demek istediniz? Çalıştırmak için birine tıklayın:' + option: ' [command][run_command: [command]][hover: [command] çalıştırmak için tıklayın]' success: 'Başarılı!' invalid: 'Geçersiz!' beta: 'Bu komut beta. Yedeklemeleriniz olduğundan emin olun!' @@ -189,6 +193,7 @@ commands: admin-kicked: 'Admin seni takımından attı!' success: '[name] [owner] adasından atıldı!' setowner: + specify-island: '[prefix_island] üzerinde durup bunu oyun içinde çalıştırın veya adanın mevcut sahibini belirtin: setowner ' parameters: description: Oyuncuya ada liderligini ver! already-owner: '[name] zaten ada sahibi!' diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 60a9cff80..7218f2ac1 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -18,6 +18,10 @@ prefixes: an-island: острів islands: острови general: + did-you-mean: + suggestion: 'Можливо, ви мали на увазі [command]? Натисніть тут або введіть yes, щоб виконати.[run_command: [command]][hover: Натисніть, щоб виконати [command]]' + header: 'Можливо, ви мали на увазі одну з цих команд? Натисніть, щоб виконати:' + option: ' [command][run_command: [command]][hover: Натисніть, щоб виконати [command]]' success: 'Успішно!' invalid: Невірно beta: 'Ця команда в бета-версії. Переконайтеся, що маєте резервні копії!' @@ -178,6 +182,7 @@ commands: admin-kicked: 'Адміністратор вигнав вас із команди.' success: '[name] вигнано з [prefix_island] гравця [owner].' setowner: + specify-island: 'Станьте на [prefix_island] і виконайте це в грі, або вкажіть поточного власника острова: setowner ' parameters: <гравець> description: передати право власності [prefix_island] гравцю already-owner: '[name] уже власник цього [prefix_island]!' diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index f86f60da0..bd7e9211c 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -11,6 +11,10 @@ prefixes: an-island: một hòn đảo islands: đảo general: + did-you-mean: + suggestion: 'Có phải bạn muốn nhập [command]? Nhấp vào đây hoặc gõ yes để chạy lệnh.[run_command: [command]][hover: Nhấp để chạy [command]]' + header: 'Có phải bạn muốn nhập một trong các lệnh sau? Nhấp vào một lệnh để chạy:' + option: ' [command][run_command: [command]][hover: Nhấp để chạy [command]]' success: 'Thành công!' invalid: Không hợp lệ beta: 'Lệnh này là trong phiên bản beta. Hãy chắc chắn rằng bạn có bản sao lưu!' @@ -192,6 +196,7 @@ commands: admin-kicked: 'Quản trị viên đã đuổi bạn khỏi đội.' success: '[name] đã bị đuổi khỏi đội của [owner].' setowner: + specify-island: 'Đứng trên [prefix_island] và chạy lệnh này trong game, hoặc nêu tên chủ đảo hiện tại: setowner ' parameters: description: Chuyển quyền chủ đảo cho người chơi already-owner: '[name] đã là chủ đảo này!' diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 6e0efa4a6..cfc7a04d2 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -15,6 +15,10 @@ prefixes: an-island: 岛屿 islands: 岛屿 general: + did-you-mean: + suggestion: '你是想输入 [command] 吗?点击此处或输入 yes 来执行。[run_command: [command]][hover: 点击执行 [command]]' + header: '你是想输入以下命令之一吗?点击即可执行:' + option: ' [command][run_command: [command]][hover: 点击执行 [command]]' success: '成功!' invalid: 无效 beta: '此命令在Beta中。确保您有备份!' @@ -175,6 +179,7 @@ commands: admin-kicked: '管理员将你踢出了团队.' success: '已将[name][owner]的岛屿里踢出.' setowner: + specify-island: '站在[prefix_island]上并在游戏中执行,或指定岛屿的当前所有者: setowner ' parameters: <玩家> description: 将当前岛屿的岛主设置为指定玩家 already-owner: '[name]已经是当前岛屿的岛主了!' diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index 0df0a7268..601cc0b8f 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -14,6 +14,10 @@ prefixes: an-island: 一個島 islands: 島嶼 general: + did-you-mean: + suggestion: '你是想輸入 [command] 嗎?點擊此處或輸入 yes 來執行。[run_command: [command]][hover: 點擊執行 [command]]' + header: '你是想輸入以下指令之一嗎?點擊即可執行:' + option: ' [command][run_command: [command]][hover: 點擊執行 [command]]' success: '成功!' invalid: 無效 beta: '此命令在Beta中。確保您有備份!' @@ -176,6 +180,7 @@ commands: admin-kicked: '管理員將您從隊伍中踢了出來。' success: '您已將 [name] [owner] 的島嶼裡踢出去。' setowner: + specify-island: '站在[prefix_island]上並在遊戲中執行,或指定島嶼的目前擁有者: setowner ' parameters: description: 將島嶼所有權轉移給指定玩家 already-owner: '[name]本身已經是島主!' diff --git a/src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java b/src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java new file mode 100644 index 000000000..40826ffa0 --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java @@ -0,0 +1,211 @@ +package world.bentobox.bentobox.suggestions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import org.bukkit.World; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.bentobox.suggestions.CommandMatcher.Match; +import world.bentobox.bentobox.util.Util; + +/** + * Tests the pure matching engine behind the did-you-mean feature (#3027). + * + * @author tastybento + */ +class CommandMatcherTest extends CommonTestSetup { + + private static final Predicate ALL = c -> true; + + private GameModeCommand oneblock; + private GameModeCommand island; + @Mock + private World oneblockWorld; + @Mock + private World islandWorld; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + // Command manager + CommandsManager cm = mock(CommandsManager.class); + when(plugin.getCommandsManager()).thenReturn(cm); + // Identity world normalization so distinct mock worlds compare correctly + mockedUtil.when(() -> Util.getWorld(any())).thenAnswer(invocation -> invocation.getArgument(0)); + // Two game modes with the standard command shape + oneblock = new GameModeCommand("oneblock", "ob"); + oneblock.setWorld(oneblockWorld); + island = new GameModeCommand("island", "is"); + island.setWorld(islandWorld); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * A mistyped command line against the root commands resolves to the intended + * command, covering three shapes: + *

+ */ + @ParameterizedTest + @MethodSource("commandLineMatchCases") + void testCommandLineMatch(List input, String expected) { + List matches = CommandMatcher.matchCommandLine(input, List.of(oneblock), null, ALL); + assertFalse(matches.isEmpty()); + assertEquals(expected, matches.get(0).commandString()); + } + + static Stream commandLineMatchCases() { + return Stream.of(Arguments.of(List.of("teams"), "/oneblock team"), + Arguments.of(List.of("team", "invite", "Floris"), "/oneblock team invite Floris"), + Arguments.of(List.of("oneblok"), "/oneblock")); + } + + /** + * In-tree matching: dispatch stopped at /oneblock with args "invit Floris". + * The mistyped grandchild is found: {@code /oneblock team invite Floris}. + */ + @Test + void testMistypedSubcommandInSubtree() { + List matches = CommandMatcher.matchSubtree(List.of("invit", "Floris"), oneblock, "/oneblock", ALL); + assertFalse(matches.isEmpty()); + assertEquals("/oneblock team invite Floris", matches.get(0).commandString()); + } + + /** + * Gibberish must not produce false positives. + */ + @Test + void testGibberishMatchesNothing() { + assertTrue(CommandMatcher.matchCommandLine(List.of("xyzzy"), List.of(oneblock, island), null, ALL).isEmpty()); + assertTrue(CommandMatcher.matchSubtree(List.of("xyzzy"), oneblock, "/oneblock", ALL).isEmpty()); + } + + /** + * When several game modes offer the same subcommand, the world the player is + * standing in is decisive: the gap to the runner-up is at least the + * confident-suggestion threshold. + */ + @Test + void testWorldContextOutranksOtherGameModes() { + List matches = CommandMatcher.matchCommandLine(List.of("teams"), List.of(island, oneblock), + oneblockWorld, ALL); + assertTrue(matches.size() >= 2); + assertEquals("/oneblock team", matches.get(0).commandString()); + assertEquals("/island team", matches.get(1).commandString()); + assertTrue(matches.get(0).score() - matches.get(1).score() >= SuggestionsManager.CONFIDENT_GAP - 1e-9); + } + + /** + * Commands the player cannot access are never suggested. + */ + @Test + void testInaccessibleCommandsAreFiltered() { + Predicate onlyOneblock = c -> { + CompositeCommand root = c; + while (root.getParent() != null) { + root = root.getParent(); + } + return root == oneblock; + }; + List matches = CommandMatcher.matchCommandLine(List.of("teams"), List.of(island, oneblock), null, + onlyOneblock); + assertFalse(matches.isEmpty()); + assertTrue(matches.stream().allMatch(m -> m.commandString().startsWith("/oneblock"))); + } + + /** + * Root aliases are matched too. + */ + @Test + void testRootAliasMatch() { + List matches = CommandMatcher.matchCommandLine(List.of("obb"), List.of(oneblock), null, ALL); + assertFalse(matches.isEmpty()); + assertEquals("/oneblock", matches.get(0).commandString()); + } + + /** + * The token similarity heuristics themselves. + */ + @Test + void testQualityHeuristics() { + assertEquals(1.0, CommandMatcher.quality("team", "team"), 1e-9); + // Plural: one edit away + assertEquals(0.8, CommandMatcher.quality("teams", "team"), 1e-9); + // Prefix typing scores well + assertTrue(CommandMatcher.quality("invit", "invite") > 0.8); + // Unrelated words score zero + assertEquals(0.0, CommandMatcher.quality("xyz", "team"), 1e-9); + assertEquals(0.0, CommandMatcher.quality("spawn", "setname"), 1e-9); + } + + private static class GameModeCommand extends CompositeCommand { + + GameModeCommand(String label, String... aliases) { + super(label, aliases); + } + + @Override + public void setup() { + new SubCommand(this, "go"); + new SubCommand(this, "spawn"); + new SubCommand(this, "create"); + CompositeCommand team = new SubCommand(this, "team"); + new SubCommand(team, "invite"); + new SubCommand(team, "accept"); + new SubCommand(team, "reject"); + new SubCommand(team, "kick"); + } + + @Override + public boolean execute(User user, String label, List args) { + return true; + } + } + + private static class SubCommand extends CompositeCommand { + + SubCommand(CompositeCommand parent, String label, String... aliases) { + super(parent, label, aliases); + } + + @Override + public void setup() { + // Nothing to do + } + + @Override + public boolean execute(User user, String label, List args) { + return true; + } + } +} diff --git a/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java b/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java new file mode 100644 index 000000000..99fa899b2 --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java @@ -0,0 +1,373 @@ +package world.bentobox.bentobox.suggestions; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.event.command.UnknownCommandEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import io.papermc.paper.command.brigadier.CommandSourceStack; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.bentobox.util.Util; + +/** + * Demonstrates the did-you-mean feature (#3027) end to end, following a real + * support transcript from a production server: + * + *
+ * Admin:  type /oneblock team, then invite your friend
+ * Player: /teams
+ * Admin:  not /teams — /oneblock team
+ * Player: /team invite
+ * Admin:  no, type /oneblock team and pick the name from the list
+ * Player: /spawn
+ * 
+ * + * Every one of those wrong inputs now produces a correct, clickable + * suggestion instead of "Unknown command". + * + * @author tastybento + */ +class DidYouMeanScenarioTest extends CommonTestSetup { + + private SuggestionsManager suggestionsManager; + private DidYouMeanListener listener; + private GameModeCommand oneblock; + private Map registeredCommands; + @Mock + private CommandSourceStack commandSourceStack; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + // Command manager with the /oneblock tree registered + CommandsManager cm = mock(CommandsManager.class); + when(plugin.getCommandsManager()).thenReturn(cm); + registeredCommands = new HashMap<>(); + when(cm.getCommands()).thenReturn(registeredCommands); + // Identity world normalization so distinct mock worlds compare correctly + mockedUtil.when(() -> Util.getWorld(any())).thenAnswer(invocation -> invocation.getArgument(0)); + // The OneBlock game mode command tree; the mock player stands in its world + oneblock = new GameModeCommand("oneblock", "ob"); + oneblock.setWorld(world); + registeredCommands.put("oneblock", oneblock); + // The engine under test + suggestionsManager = new SuggestionsManager(plugin); + when(plugin.getSuggestionsManager()).thenReturn(suggestionsManager); + listener = new DidYouMeanListener(plugin); + when(commandSourceStack.getSender()).thenReturn(mockPlayer); + // Real en-US locale strings so the demo shows actual player-facing text + when(lm.get(any(), eq("general.did-you-mean.suggestion"))).thenReturn( + "Did you mean [command]? Click here or type yes to run it.[run_command: [command]][hover: Click to run [command]]"); + when(lm.get(any(), eq("general.did-you-mean.header"))) + .thenReturn("Did you mean one of these? Click one to run it:"); + when(lm.get(any(), eq("general.did-you-mean.option"))) + .thenReturn(" [command][run_command: [command]][hover: Click to run [command]]"); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * Player types {@code /teams}. Instead of "Unknown command" they get a + * clickable "Did you mean /oneblock team?" they can also accept by typing + * yes. + */ + @Test + void testPlayerTypesTeams() { + UnknownCommandEvent e = unknownCommand("teams"); + listener.onUnknownCommand(e); + // The vanilla "Unknown command" message is replaced... + assertNull(e.message()); + // ...by a single confident, clickable suggestion + checkSpigotMessage("Did you mean "); + checkSpigotMessage("/oneblock team"); + assertClickRuns("/oneblock team"); + } + + /** + * Player types {@code /team invite Floris}. The argument survives: + * {@code /oneblock team invite Floris}. + */ + @Test + void testPlayerTypesTeamInvite() { + UnknownCommandEvent e = unknownCommand("team invite Floris"); + listener.onUnknownCommand(e); + assertNull(e.message()); + checkSpigotMessage("/oneblock team invite Floris"); + assertClickRuns("/oneblock team invite Floris"); + } + + /** + * Player types {@code /spawn}. On a server without a /spawn command, the + * game mode's own spawn subcommand is the right guess. + */ + @Test + void testPlayerTypesSpawn() { + UnknownCommandEvent e = unknownCommand("spawn"); + listener.onUnknownCommand(e); + assertNull(e.message()); + checkSpigotMessage("/oneblock spawn"); + assertClickRuns("/oneblock spawn"); + } + + /** + * After a suggestion, the player can just type "yes" in chat and the + * suggested command runs for them. + */ + @Test + void testPlayerAcceptsByTypingYes() { + listener.onUnknownCommand(unknownCommand("teams")); + // Player answers yes (this is what the chat listener calls) + assertTrue(listener.acceptPending(uuid)); + // The suggested command is run for the player on the main thread + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(sch).runTask(eq(plugin), task.capture()); + mockedBukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(mockPlayer); + task.getValue().run(); + verify(mockPlayer).performCommand("oneblock team"); + } + + /** + * A pending suggestion is one-shot and only exists after a suggestion. + */ + @Test + void testYesWithNothingPendingDoesNothing() { + assertFalse(listener.acceptPending(uuid)); + } + + /** + * Running any other command drops the pending suggestion — the player has + * moved on. + */ + @Test + void testRunningAnotherCommandClearsPending() { + listener.onUnknownCommand(unknownCommand("teams")); + listener.onCommandPreprocess(new PlayerCommandPreprocessEvent(mockPlayer, "/somethingelse", new HashSet<>())); + assertFalse(listener.acceptPending(uuid)); + } + + /** + * Gibberish is not force-matched: the vanilla unknown-command message stays + * and nothing is sent. + */ + @Test + void testGibberishLeavesVanillaMessage() { + UnknownCommandEvent e = unknownCommand("xyzzy"); + listener.onUnknownCommand(e); + assertNotNull(e.message()); + verify(mockPlayer, never()).sendMessage(any(Component.class)); + } + + /** + * Two game modes both have a team command. The player is standing in the + * OneBlock world, so that context is decisive: one confident suggestion for + * /oneblock team, not a list. + */ + @Test + void testWorldContextPicksTheRightGameMode() { + GameModeCommand island = new GameModeCommand("island", "is"); + island.setWorld(mock(World.class)); + registeredCommands.put("island", island); + listener.onUnknownCommand(unknownCommand("teams")); + checkSpigotMessage("or type "); + checkSpigotMessage("/oneblock team"); + assertClickRuns("/oneblock team"); + } + + /** + * The same input from a neutral world (e.g. spawn) is genuinely ambiguous, + * so the player gets a short list of clickable options instead. + */ + @Test + void testAmbiguousInputOffersClickableOptions() { + GameModeCommand island = new GameModeCommand("island", "is"); + island.setWorld(mock(World.class)); + registeredCommands.put("island", island); + // Player stands in a world that belongs to neither game mode + when(mockPlayer.getWorld()).thenReturn(mock(World.class)); + listener.onUnknownCommand(unknownCommand("teams")); + checkSpigotMessage("Did you mean one of these"); + checkSpigotMessage("/oneblock team"); + checkSpigotMessage("/island team"); + assertClickRuns("/oneblock team"); + assertClickRuns("/island team"); + } + + /** + * The in-tree interception: {@code /oneblock invit Floris} dispatched + * through the real command executor suggests + * {@code /oneblock team invite Floris} instead of the unknown-command + * error. + */ + @Test + void testMistypedSubcommandThroughDispatch() { + assertTrue(oneblock.execute(mockPlayer, "oneblock", new String[] { "invit", "Floris" })); + checkSpigotMessage("/oneblock team invite Floris"); + assertClickRuns("/oneblock team invite Floris"); + // The unknown-command error was never shown + checkSpigotMessage("general.errors.unknown-command", 0); + } + + /** + * The suggestion respects the alias the player actually typed: + * {@code /ob team invit} suggests {@code /ob team invite}. + */ + @Test + void testMistypedSubcommandKeepsTypedAlias() { + assertTrue(oneblock.execute(mockPlayer, "ob", new String[] { "team", "invit", "Floris" })); + checkSpigotMessage("/ob team invite Floris"); + assertClickRuns("/ob team invite Floris"); + } + + /** + * The whole feature can be turned off in the config. + */ + @Test + void testConfigToggleOff() { + plugin.getSettings().setDidYouMeanUnknownCommands(false); + plugin.getSettings().setDidYouMeanSubcommands(false); + UnknownCommandEvent e = unknownCommand("teams"); + listener.onUnknownCommand(e); + assertNotNull(e.message()); + oneblock.execute(mockPlayer, "oneblock", new String[] { "invit", "Floris" }); + // The old unknown-command error is back, and no suggestion was made + checkSpigotMessage("general.errors.unknown-command"); + checkSpigotMessage("Did you mean", 0); + } + + /** + * Regression test for the in-game report on PR #3029: the locale strings + * close a color tag (e.g. {@code }) right before the inline + * [run_command]/[hover] tags. The legacy round-trip turns that into §r, + * which used to become a MiniMessage {@code } INSIDE the + * click/hover wrapper — killing the click and rendering the literal text + * "</click></hover>" in chat. + */ + @Test + void testNoLiteralClosingTagsAndClickCoversWholeMessage() { + // Single-suggestion path + listener.onUnknownCommand(unknownCommand("teams")); + // Options-list path + GameModeCommand island = new GameModeCommand("island", "is"); + island.setWorld(mock(World.class)); + registeredCommands.put("island", island); + when(mockPlayer.getWorld()).thenReturn(mock(World.class)); + listener.onUnknownCommand(unknownCommand("teams")); + // No message may leak MiniMessage tags as literal text + ArgumentCaptor captor = ArgumentCaptor.forClass(Component.class); + verify(mockPlayer, atLeast(1)).sendMessage(captor.capture()); + for (Component component : captor.getAllValues()) { + String plain = PlainTextComponentSerializer.plainText().serialize(component); + assertFalse(plain.contains(""), "Literal leaked into: " + plain); + assertFalse(plain.contains(""), "Literal leaked into: " + plain); + assertFalse(plain.contains(""), "Literal leaked into: " + plain); + } + assertClickRuns("/oneblock team"); + } + + private UnknownCommandEvent unknownCommand(String commandLine) { + return new UnknownCommandEvent(commandSourceStack, commandLine, Component.text("Unknown command")); + } + + /** + * Asserts that some message sent to the player carries a click event that + * runs the given command. + */ + private void assertClickRuns(String command) { + ArgumentCaptor captor = ArgumentCaptor.forClass(Component.class); + verify(mockPlayer, atLeast(1)).sendMessage(captor.capture()); + assertTrue(captor.getAllValues().stream().anyMatch(c -> hasClickRunning(c, command)), + "No message carries a click event running " + command); + } + + private boolean hasClickRunning(Component component, String command) { + ClickEvent click = component.clickEvent(); + if (click != null && click.action() == ClickEvent.Action.RUN_COMMAND && command.equals(click.value())) { + return true; + } + return component.children().stream().anyMatch(child -> hasClickRunning(child, command)); + } + + /** + * A minimal stand-in for a game mode's player command (e.g. AOneBlock's + * /oneblock): same shape, same unknown-command error on unmatched args. + */ + private static class GameModeCommand extends CompositeCommand { + + GameModeCommand(String label, String... aliases) { + super(label, aliases); + } + + @Override + public void setup() { + new SubCommand(this, "go"); + new SubCommand(this, "spawn"); + new SubCommand(this, "create"); + CompositeCommand team = new SubCommand(this, "team"); + new SubCommand(team, "invite"); + new SubCommand(team, "accept"); + new SubCommand(team, "reject"); + new SubCommand(team, "kick"); + } + + @Override + public boolean execute(User user, String label, List args) { + if (!args.isEmpty()) { + // Mirrors DefaultPlayerCommand's behavior for unmatched arguments + user.sendMessage("general.errors.unknown-command", TextVariables.LABEL, getTopLabel()); + return false; + } + return true; + } + } + + private static class SubCommand extends CompositeCommand { + + SubCommand(CompositeCommand parent, String label, String... aliases) { + super(parent, label, aliases); + } + + @Override + public void setup() { + // Nothing to do + } + + @Override + public boolean execute(User user, String label, List args) { + return true; + } + } +}