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..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 @@ -1,11 +1,14 @@ 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; 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; @@ -111,6 +114,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 +261,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, 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, 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)} */