From 6a2bd338d15ef7281b8438f56364920c17b04516 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 15:59:57 -0700 Subject: [PATCH 1/2] Forgiving name matching for /island go (#3024) The go/home command previously required an exact, case-sensitive match of the typed destination against island and home names, and dumped the full name list on any miss. New players who type "myisland", "hom" or add a stray space got no teleport. Add IslandGoCommand.resolveName: exact match still wins (unchanged behaviour), then a case/colour/whitespace-insensitive exact match, then a unique case-insensitive prefix. Ambiguous inputs fall through to the existing unknown-home list rather than guessing. The resolved canonical name is used for the home teleport lookup so named-home teleports still target the right home. This is the cheap first slice of the "missing/unmatched argument opens a picker" rule; the openPicker hook on CompositeCommand follows separately. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01G7UmQDCP5MGHEZqFiw44zH --- .../api/commands/island/IslandGoCommand.java | 60 ++++++++++++- .../commands/island/IslandGoCommandTest.java | 90 +++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/api/commands/island/IslandGoCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/island/IslandGoCommand.java index e7afab19e..e9be9258b 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/island/IslandGoCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/island/IslandGoCommand.java @@ -4,8 +4,10 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.bukkit.World; @@ -106,9 +108,11 @@ public boolean execute(User user, String label, List args) { // Check if the name is known if one is given if (!args.isEmpty()) { // Assemble the arguments into one string - final String name = String.join(" ", args); - // If the name is not in the list - if (!names.containsKey(name)) { + final String typed = String.join(" ", args); + // Forgiving lookup: exact, then case/space-insensitive, then unique prefix + final String name = resolveName(typed, names.keySet()); + // If the name could not be resolved to a destination + if (name == null) { // Failed home name check user.sendMessage("commands.island.go.unknown-home"); user.sendMessage("commands.island.sethome.homes-are"); @@ -187,6 +191,56 @@ public record IslandInfo( boolean islandName) { } + /** + * Resolves the text a player typed to one of the valid destination names using + * forgiving matching, so small mistakes still teleport them instead of dumping a + * list. Matching is tried in decreasing order of confidence: + *
    + *
  1. exact match (existing behaviour, always wins);
  2. + *
  3. case-, colour- and whitespace-insensitive exact match;
  4. + *
  5. a unique case-insensitive prefix (e.g. {@code hom} for {@code Home}).
  6. + *
+ * Any step that is ambiguous (more than one candidate) is skipped, so an unclear + * input falls through to the normal unknown-home list rather than guessing. + * + * @param typed the raw string the player typed + * @param names the valid destination names + * @return the canonical destination name to use, or {@code null} if none matched confidently + */ + static String resolveName(String typed, Set names) { + // 1. Exact match wins and preserves the original behaviour + if (names.contains(typed)) { + return typed; + } + String norm = normalize(typed); + if (norm.isEmpty()) { + return null; + } + // 2. Case/colour/whitespace-insensitive exact match, if unambiguous + List exact = names.stream().filter(n -> normalize(n).equals(norm)).toList(); + if (exact.size() == 1) { + return exact.get(0); + } + if (!exact.isEmpty()) { + // Several names collapse to the same normalized form - too ambiguous to guess + return null; + } + // 3. Unique prefix match + List prefix = names.stream().filter(n -> normalize(n).startsWith(norm)).toList(); + return prefix.size() == 1 ? prefix.get(0) : null; + } + + /** + * Normalizes a name for forgiving comparison: strips colour codes, lower-cases and + * collapses runs of whitespace to a single space. + * + * @param s the string to normalize + * @return the normalized form + */ + private static String normalize(String s) { + return Util.stripColor(s).toLowerCase(Locale.ENGLISH).replaceAll("\\s+", " ").trim(); + } + /** * Creates a mapping of valid teleport destination names for a user. * Includes: diff --git a/src/test/java/world/bentobox/bentobox/api/commands/island/IslandGoCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/island/IslandGoCommandTest.java index 56a04814d..7d173a96b 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/island/IslandGoCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/island/IslandGoCommandTest.java @@ -1,6 +1,8 @@ package world.bentobox.bentobox.api.commands.island; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +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.anyString; @@ -111,6 +113,11 @@ public void setUp() throws Exception { BukkitScheduler sch = mock(BukkitScheduler.class); mockedBukkit.when(Bukkit::getScheduler).thenReturn(sch); when(sch.runTaskLater(any(), any(Runnable.class), any(Long.class))).thenReturn(task); + // Run scheduled (zero-delay) teleport tasks synchronously so we can verify them + when(sch.runTask(any(), any(Runnable.class))).thenAnswer((Answer) invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return task; + }); // Event register mockedBukkit.when(Bukkit::getPluginManager).thenReturn(pim); @@ -253,6 +260,89 @@ void testExecuteArgs1MultipleHomes() { checkSpigotMessage("commands.island.sethome.home-list-syntax"); } + /** + * Forgiving matching: an island name typed in the wrong case still teleports. + * Test method for {@link IslandGoCommand#execute(User, String, List)} + */ + @Test + void testExecuteIslandNameWrongCase() { + when(island.getName()).thenReturn("MyIsland"); + assertTrue(igc.execute(user, igc.getLabel(), Collections.singletonList("myisland"))); + // No unknown-home moan; we teleported to the island + verify(mockPlayer, Mockito.never()).sendMessage("commands.island.go.unknown-home"); + verify(im).homeTeleportAsync(island, user); + } + + /** + * Forgiving matching: a unique prefix of a home name teleports to that home. + * Test method for {@link IslandGoCommand#execute(User, String, List)} + */ + @Test + void testExecuteHomeNamePrefix() { + when(island.getHomes()).thenReturn(Map.of("Home", mock(Location.class))); + when(im.homeTeleportAsync(world, mockPlayer, "Home")) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true)); + assertTrue(igc.execute(user, igc.getLabel(), Collections.singletonList("hom"))); + verify(mockPlayer, Mockito.never()).sendMessage("commands.island.go.unknown-home"); + verify(im).homeTeleportAsync(world, mockPlayer, "Home"); + } + + /** + * Test method for {@link IslandGoCommand#resolveName(String, Set)} + */ + @Test + void testResolveNameExactWins() { + assertEquals("Home", IslandGoCommand.resolveName("Home", Set.of("Home", "Hut"))); + } + + /** + * Test method for {@link IslandGoCommand#resolveName(String, Set)} + */ + @Test + void testResolveNameCaseAndSpaceInsensitive() { + assertEquals("My Home", IslandGoCommand.resolveName("my HOME", Set.of("My Home", "Hut"))); + } + + /** + * Test method for {@link IslandGoCommand#resolveName(String, Set)} + */ + @Test + void testResolveNameUniquePrefix() { + assertEquals("Home", IslandGoCommand.resolveName("hom", Set.of("Home", "Base"))); + } + + /** + * Test method for {@link IslandGoCommand#resolveName(String, Set)} + */ + @Test + void testResolveNameAmbiguousPrefixReturnsNull() { + assertNull(IslandGoCommand.resolveName("h", Set.of("Home", "Hut"))); + } + + /** + * Test method for {@link IslandGoCommand#resolveName(String, Set)} + */ + @Test + void testResolveNameAmbiguousNormalizedReturnsNull() { + assertNull(IslandGoCommand.resolveName("home", Set.of("Home", "HOME"))); + } + + /** + * Test method for {@link IslandGoCommand#resolveName(String, Set)} + */ + @Test + void testResolveNameNoMatchReturnsNull() { + assertNull(IslandGoCommand.resolveName("xyzzy", Set.of("Home", "Hut"))); + } + + /** + * Test method for {@link IslandGoCommand#resolveName(String, Set)} + */ + @Test + void testResolveNameBlankReturnsNull() { + assertNull(IslandGoCommand.resolveName(" ", Set.of("Home", "Hut"))); + } + /** * Test method for {@link IslandGoCommand#execute(User, String, List)} */ From 258167e5b4b8042c0539c17fa81eda563b0f1c44 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Jul 2026 16:26:46 -0700 Subject: [PATCH 2/2] Use static import for Mockito.never (SonarCloud S8924, PR #3031) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01G7UmQDCP5MGHEZqFiw44zH --- .../bentobox/api/commands/island/IslandGoCommandTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/world/bentobox/bentobox/api/commands/island/IslandGoCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/island/IslandGoCommandTest.java index 7d173a96b..bb059af19 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/island/IslandGoCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/island/IslandGoCommandTest.java @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -269,7 +270,7 @@ void testExecuteIslandNameWrongCase() { when(island.getName()).thenReturn("MyIsland"); assertTrue(igc.execute(user, igc.getLabel(), Collections.singletonList("myisland"))); // No unknown-home moan; we teleported to the island - verify(mockPlayer, Mockito.never()).sendMessage("commands.island.go.unknown-home"); + verify(mockPlayer, never()).sendMessage("commands.island.go.unknown-home"); verify(im).homeTeleportAsync(island, user); } @@ -283,7 +284,7 @@ void testExecuteHomeNamePrefix() { when(im.homeTeleportAsync(world, mockPlayer, "Home")) .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true)); assertTrue(igc.execute(user, igc.getLabel(), Collections.singletonList("hom"))); - verify(mockPlayer, Mockito.never()).sendMessage("commands.island.go.unknown-home"); + verify(mockPlayer, never()).sendMessage("commands.island.go.unknown-home"); verify(im).homeTeleportAsync(world, mockPlayer, "Home"); }