From bc3f054a8a312d7cd0a252b5b22021a6e046371d Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 11:49:23 -0700 Subject: [PATCH 1/3] Add did-you-mean command suggestions (#3027) New players routinely type a subcommand as if it were the whole command (/teams or /team invite instead of /oneblock team) or mistype a subcommand (/island invit). Instead of "Unknown command" or a help dump, BentoBox now matches the input against every node of its command trees and replies with a clickable suggestion the player can also accept by typing "yes". - CommandMatcher: pure matching engine - label/alias matching at any tree depth, edit-distance and prefix heuristics, world-context ranking, permission filtering - SuggestionsManager: presentation and pending-intent tracking; single confident suggestion or a short clickable options list - DidYouMeanListener: UnknownCommandEvent interception (fires only when no plugin owns the command), chat "yes" acceptance, pending cleanup - CompositeCommand dispatch: unmatched subcommand args route through the suggestion engine before falling back to the old error - Config: general.did-you-mean.unknown-commands / .subcommands (default on) - Locales: new general.did-you-mean keys translated into all 22 languages; also fills the pre-existing gap commands.admin.team.setowner.specify-island everywhere Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Jbkc3n7vnNbtMx3s9dbkbc --- .../world/bentobox/bentobox/BentoBox.java | 13 + .../world/bentobox/bentobox/Settings.java | 44 +++ .../api/commands/CompositeCommand.java | 12 + .../listeners/BentoBoxListenerRegistrar.java | 2 + .../bentobox/suggestions/CommandMatcher.java | 276 ++++++++++++++ .../suggestions/DidYouMeanListener.java | 103 ++++++ .../suggestions/SuggestionsManager.java | 157 ++++++++ src/main/resources/config.yml | 11 + src/main/resources/locales/cs.yml | 5 + src/main/resources/locales/de.yml | 5 + src/main/resources/locales/en-US.yml | 5 + src/main/resources/locales/es.yml | 5 + src/main/resources/locales/fr.yml | 5 + src/main/resources/locales/hr.yml | 5 + src/main/resources/locales/hu.yml | 5 + src/main/resources/locales/id.yml | 5 + src/main/resources/locales/it.yml | 5 + src/main/resources/locales/ja.yml | 5 + src/main/resources/locales/ko.yml | 5 + src/main/resources/locales/lv.yml | 5 + src/main/resources/locales/nl.yml | 5 + src/main/resources/locales/pl.yml | 5 + src/main/resources/locales/pt-BR.yml | 5 + src/main/resources/locales/pt.yml | 5 + src/main/resources/locales/ro.yml | 5 + src/main/resources/locales/ru.yml | 5 + src/main/resources/locales/tr.yml | 5 + src/main/resources/locales/uk.yml | 5 + src/main/resources/locales/vi.yml | 5 + src/main/resources/locales/zh-CN.yml | 5 + src/main/resources/locales/zh-HK.yml | 5 + .../suggestions/CommandMatcherTest.java | 215 +++++++++++ .../suggestions/DidYouMeanScenarioTest.java | 342 ++++++++++++++++++ 33 files changed, 1290 insertions(+) create mode 100644 src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java create mode 100644 src/main/java/world/bentobox/bentobox/suggestions/DidYouMeanListener.java create mode 100644 src/main/java/world/bentobox/bentobox/suggestions/SuggestionsManager.java create mode 100644 src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java create mode 100644 src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java 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/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..ed6dcb69b --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java @@ -0,0 +1,276 @@ +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 = maxLength <= 4 ? 1 : maxLength <= 7 ? 2 : 3; + int distance = levenshtein(typed, candidate); + if (distance <= allowed) { + result = Math.max(result, 1.0 - (double) distance / maxLength); + } + return result; + } + + /** 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: + *

    + *
  • replaces the vanilla "Unknown command" message with a clickable + * suggestion when a player types a command no plugin owns (e.g. {@code /teams} + * instead of {@code /oneblock team});
  • + *
  • lets the player accept the last suggestion by typing {@code yes} in + * chat;
  • + *
  • drops a pending suggestion once the player runs any other command or + * leaves.
  • + *
+ * + * @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/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..68e6f02e8 --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java @@ -0,0 +1,215 @@ +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 org.bukkit.World; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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(); + } + + /** + * The headline case: a player types the subcommand as if it were the whole + * command, with a plural on top. {@code /teams} → {@code /oneblock team}. + */ + @Test + void testSubcommandTypedAsRootCommand() { + List matches = CommandMatcher.matchCommandLine(List.of("teams"), List.of(oneblock), null, ALL); + assertFalse(matches.isEmpty()); + assertEquals("/oneblock team", matches.get(0).commandString()); + } + + /** + * A whole subcommand path typed as a root command keeps its arguments: + * {@code /team invite Floris} → {@code /oneblock team invite Floris}. + */ + @Test + void testSubcommandPathTypedAsRootCommandKeepsArgs() { + List matches = CommandMatcher.matchCommandLine(List.of("team", "invite", "Floris"), + List.of(oneblock), null, ALL); + assertFalse(matches.isEmpty()); + assertEquals("/oneblock team invite Floris", matches.get(0).commandString()); + } + + /** + * A typo in the root command itself: {@code /oneblok} → {@code /oneblock}. + */ + @Test + void testTypoInRootCommand() { + List matches = CommandMatcher.matchCommandLine(List.of("oneblok"), List.of(oneblock), null, ALL); + assertFalse(matches.isEmpty()); + assertEquals("/oneblock", matches.get(0).commandString()); + } + + /** + * 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..587769a83 --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java @@ -0,0 +1,342 @@ +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 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); + } + + 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; + } + } +} From 0a0132dab03cbcd57670fbdcb34e54720e6ec754 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 12:06:28 -0700 Subject: [PATCH 2/3] Fix click events dying and literal in suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-game testing showed the clickable suggestions rendering their closing tags as literal text, with the click event dead. Root cause: inline [run_command]/[hover] commands were re-encoded as MiniMessage tags wrapped around the message, but the message text contains a §r from the legacy round-trip (any closed color emits one). §r maps to , which closes the wrapping / tags too - killing the events and orphaning the closers, which MiniMessage renders as literal text. Fix: extract the inline commands as data (Util.extractInlineCommands) and apply ClickEvent/HoverEvent programmatically to the parsed Component in User.parseToComponent. The events now cover the whole message regardless of internal resets. convertInlineCommandsToMiniMessage is kept (refactored over the new extraction) for API compatibility. Regression test asserts no literal tag text leaks and the click covers the message on both the single-suggestion and options-list paths. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Jbkc3n7vnNbtMx3s9dbkbc --- .../bentobox/bentobox/api/user/User.java | 41 ++++++++- .../world/bentobox/bentobox/util/Util.java | 85 ++++++++++++++----- .../suggestions/DidYouMeanScenarioTest.java | 31 +++++++ 3 files changed, 136 insertions(+), 21 deletions(-) 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..68db00dd8 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,48 @@ 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); + } + if (inline.hoverText() != null) { + component = component.hoverEvent(HoverEvent.showText(Util.parseMiniMessageOrLegacy(inline.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/util/Util.java b/src/main/java/world/bentobox/bentobox/util/Util.java index 5b0e7bc87..0ac83c863 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(); @@ -1406,14 +1461,14 @@ public static String convertInlineCommandsToMiniMessage(@NonNull String message) String value = matcher.group(2); switch (action) { case "hover" -> { - if (hoverTag == null) { - // Escape single quotes in hover text - hoverTag = ""; + if (hoverText == null) { + hoverText = value; } } case "run_command", "suggest_command", "copy_to_clipboard", "open_url" -> { - if (clickTag == null) { - clickTag = ""; + if (clickAction == null) { + clickAction = action; + clickValue = value; } } default -> cleanText.append(matcher.group()); // unknown, keep as-is @@ -1421,19 +1476,11 @@ public static String convertInlineCommandsToMiniMessage(@NonNull String message) 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/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java b/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java index 587769a83..99fa899b2 100644 --- a/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java +++ b/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java @@ -30,6 +30,7 @@ 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; @@ -267,6 +268,36 @@ void testConfigToggleOff() { 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")); } From afcafc14bfbd22ec18d7b03354cb9cb7833ece93 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 15:23:59 -0700 Subject: [PATCH 3/3] Fix SonarCloud issues on PR #3029 - User.parseToComponent (S2637): assign nullable record accessor to a local so the null-check narrows it for @NonNull parseMiniMessageOrLegacy - Util.extractInlineCommands (S6916 x2): replace the if statements inside the string-constant switch arms with first-wins ternary assignments - CommandMatcher.quality (S3358): extract the nested ternary into an allowedDistance helper - CommandMatcherTest (S5976): merge three identical matchCommandLine tests into one parameterized test Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01G7UmQDCP5MGHEZqFiw44zH --- .../bentobox/bentobox/api/user/User.java | 5 +- .../bentobox/suggestions/CommandMatcher.java | 13 ++++- .../world/bentobox/bentobox/util/Util.java | 13 ++--- .../suggestions/CommandMatcherTest.java | 48 +++++++++---------- 4 files changed, 41 insertions(+), 38 deletions(-) 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 68db00dd8..c731a53bc 100644 --- a/src/main/java/world/bentobox/bentobox/api/user/User.java +++ b/src/main/java/world/bentobox/bentobox/api/user/User.java @@ -824,8 +824,9 @@ private Component parseToComponent(String text) { if (clickEvent != null) { component = component.clickEvent(clickEvent); } - if (inline.hoverText() != null) { - component = component.hoverEvent(HoverEvent.showText(Util.parseMiniMessageOrLegacy(inline.hoverText()))); + String hoverText = inline.hoverText(); + if (hoverText != null) { + component = component.hoverEvent(HoverEvent.showText(Util.parseMiniMessageOrLegacy(hoverText))); } return component; } diff --git a/src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java b/src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java index ed6dcb69b..8ea7a1629 100644 --- a/src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java +++ b/src/main/java/world/bentobox/bentobox/suggestions/CommandMatcher.java @@ -234,7 +234,7 @@ static double quality(String typed, String candidate) { } // Typos and plurals: "teams" for "team", "tem" for "team" int maxLength = Math.max(typed.length(), candidate.length()); - int allowed = maxLength <= 4 ? 1 : maxLength <= 7 ? 2 : 3; + int allowed = allowedDistance(maxLength); int distance = levenshtein(typed, candidate); if (distance <= allowed) { result = Math.max(result, 1.0 - (double) distance / maxLength); @@ -242,6 +242,17 @@ static double quality(String typed, String candidate) { 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]; diff --git a/src/main/java/world/bentobox/bentobox/util/Util.java b/src/main/java/world/bentobox/bentobox/util/Util.java index 0ac83c863..27bf590b3 100644 --- a/src/main/java/world/bentobox/bentobox/util/Util.java +++ b/src/main/java/world/bentobox/bentobox/util/Util.java @@ -1460,16 +1460,11 @@ public static InlineCommands extractInlineCommands(@NonNull String message) { String action = matcher.group(1).toLowerCase(java.util.Locale.ENGLISH); String value = matcher.group(2); switch (action) { - case "hover" -> { - if (hoverText == null) { - hoverText = value; - } - } + // 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 (clickAction == null) { - clickAction = action; - clickValue = value; - } + clickValue = clickAction == null ? value : clickValue; + clickAction = clickAction == null ? action : clickAction; } default -> cleanText.append(matcher.group()); // unknown, keep as-is } diff --git a/src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java b/src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java index 68e6f02e8..40826ffa0 100644 --- a/src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java +++ b/src/test/java/world/bentobox/bentobox/suggestions/CommandMatcherTest.java @@ -9,11 +9,15 @@ 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; @@ -62,36 +66,28 @@ public void tearDown() throws Exception { } /** - * The headline case: a player types the subcommand as if it were the whole - * command, with a plural on top. {@code /teams} → {@code /oneblock team}. + * A mistyped command line against the root commands resolves to the intended + * command, covering three shapes: + *

    + *
  • subcommand typed as the whole command, with a plural: + * {@code /teams} → {@code /oneblock team};
  • + *
  • a whole subcommand path typed as a root command keeps its arguments: + * {@code /team invite Floris} → {@code /oneblock team invite Floris};
  • + *
  • a typo in the root command itself: {@code /oneblok} → {@code /oneblock}.
  • + *
*/ - @Test - void testSubcommandTypedAsRootCommand() { - List matches = CommandMatcher.matchCommandLine(List.of("teams"), List.of(oneblock), null, ALL); - assertFalse(matches.isEmpty()); - assertEquals("/oneblock team", matches.get(0).commandString()); - } - - /** - * A whole subcommand path typed as a root command keeps its arguments: - * {@code /team invite Floris} → {@code /oneblock team invite Floris}. - */ - @Test - void testSubcommandPathTypedAsRootCommandKeepsArgs() { - List matches = CommandMatcher.matchCommandLine(List.of("team", "invite", "Floris"), - List.of(oneblock), null, ALL); + @ParameterizedTest + @MethodSource("commandLineMatchCases") + void testCommandLineMatch(List input, String expected) { + List matches = CommandMatcher.matchCommandLine(input, List.of(oneblock), null, ALL); assertFalse(matches.isEmpty()); - assertEquals("/oneblock team invite Floris", matches.get(0).commandString()); + assertEquals(expected, matches.get(0).commandString()); } - /** - * A typo in the root command itself: {@code /oneblok} → {@code /oneblock}. - */ - @Test - void testTypoInRootCommand() { - List matches = CommandMatcher.matchCommandLine(List.of("oneblok"), List.of(oneblock), null, ALL); - assertFalse(matches.isEmpty()); - assertEquals("/oneblock", 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")); } /**