diff --git a/src/main/java/net/tfminecraft/archaeo/command/ArchaeoCommand.java b/src/main/java/net/tfminecraft/archaeo/command/ArchaeoCommand.java index 00cc86a..9dd5528 100644 --- a/src/main/java/net/tfminecraft/archaeo/command/ArchaeoCommand.java +++ b/src/main/java/net/tfminecraft/archaeo/command/ArchaeoCommand.java @@ -1340,7 +1340,10 @@ public List onTabComplete(CommandSender sender, Command command, String if (args.length == 3) { if ("tool".equalsIgnoreCase(args[1]) || "pick".equalsIgnoreCase(args[1])) { String typed = args[2].toLowerCase(Locale.ROOT); - return handPick.giveToolTokens().stream() + return java.util.stream.Stream.concat(handPick.giveToolTokens().stream(), + onlineNamesStartingWith(args[2]).stream() + .filter(name -> handPick.toolForGive(name).isEmpty())) + .distinct() .filter(token -> token.toLowerCase(Locale.ROOT).startsWith(typed)) .toList(); } @@ -1446,32 +1449,38 @@ public List onTabComplete(CommandSender sender, Command command, String } if ("info".equalsIgnoreCase(args[1]) || "tp".equalsIgnoreCase(args[1])) { String typed = Arrays.stream(args).skip(2).collect(Collectors.joining(" ")); - List names = new ArrayList<>(sites.namesStartingWith(typed)); - if (args.length == 3) { - String token = args[2].toLowerCase(Locale.ROOT); - for (Site site : sites.all()) { - String serial = "#" + site.getSerial(); - if (serial.startsWith(token) || String.valueOf(site.getSerial()).startsWith(token)) { - names.add(serial); - } - } - } - return names; + return suggestSiteQueries(typed, args.length == 3, site -> true); } if ("camps".equalsIgnoreCase(args[1])) { if (args.length == 3) { - List suggestions = new ArrayList<>(onlineNamesStartingWith(args[2])); + List suggestions = new ArrayList<>(onlineNamesStartingWith(args[2]).stream() + .filter(name -> parseSerial(name).isEmpty() + && net.tfminecraft.archaeo.ownership.CharacterActors.invited(name).isPresent()) + .toList()); String token = args[2].toLowerCase(Locale.ROOT); for (Site site : sites.all()) { if (!site.isCampLocked()) { continue; } + var director = site.getDirector() == null ? null + : net.tfminecraft.tlibs.character.Characters.info(site.getDirector()).orElse(null); + if (director != null) { + String id = director.ref().characterId().toString(); + if (id.startsWith(token)) suggestions.add(id); + String name = director.name(); + if (name != null && !name.isBlank() && name.chars().noneMatch(Character::isWhitespace) + && parseSerial(name).isEmpty() && name.toLowerCase(Locale.ROOT).startsWith(token) + && net.tfminecraft.archaeo.ownership.CharacterActors.invited(name) + .filter(ref -> ref.equals(director.ref())).isPresent()) { + suggestions.add(name); + } + } String serial = "#" + site.getSerial(); if (serial.startsWith(token) || String.valueOf(site.getSerial()).startsWith(token)) { suggestions.add(serial); } } - return suggestions; + return suggestions.stream().distinct().toList(); } if (args.length == 4) { var director = net.tfminecraft.archaeo.ownership.CharacterActors.invited(args[2]).orElse(null); @@ -1512,7 +1521,7 @@ private List suggestSiteQueries(String needle, boolean includeSerials, P } } if (allowed) { - names.add(name); + names.add(name.substring(needle.lastIndexOf(' ') + 1)); } } if (includeSerials) { diff --git a/src/test/java/net/tfminecraft/archaeo/command/ArchaeoCommandTest.java b/src/test/java/net/tfminecraft/archaeo/command/ArchaeoCommandTest.java index 34557fd..c8333e0 100644 --- a/src/test/java/net/tfminecraft/archaeo/command/ArchaeoCommandTest.java +++ b/src/test/java/net/tfminecraft/archaeo/command/ArchaeoCommandTest.java @@ -176,7 +176,7 @@ public void completionUsesPermissionsPlayersWorldsAndEligibleSiteStates() { assertFalse(complete("ruin", "close", "").contains(hidden.getName())); assertTrue(complete("ruin", "delete", "con").contains("confirm")); assertTrue(complete("ruin", "info", "#").containsAll(List.of("#1", "#2"))); - assertTrue(complete("ruin", "tp", "Old", "r").contains("Old ruin")); + assertTrue(complete("ruin", "tp", "Old", "r").contains("ruin")); assertTrue(complete("ruin", "camps", "#").contains("#2")); assertTrue(complete("ruin", "camps", "Archaeologist", "2").contains("#2")); assertTrue(complete("ruin", "delete", "#1", "confirm").isEmpty()); @@ -517,18 +517,18 @@ public void consoleCommandsExplainWorldRequirementsAndCanGiveToNamedPlayers() { new String[]{"ruin", "camps", "Nobody", "#"}, new String[]{"ruin", "camps", "Archaeologist", "#2", "x"})) assertTrue(String.join(" ", extra), complete(extra).isEmpty()); assertEquals(List.of("Archaeologist"), complete("workday", "reset", "arch")); - assertEquals(List.of("Old ruin"), complete("ruin", "set-interest", "high", "Old", "r")); + assertEquals(List.of("ruin"), complete("ruin", "set-interest", "high", "Old", "r")); assertEquals(List.of("confirm"), complete("ruin", "close", "confirm")); assertEquals(List.of("Open camp"), complete("ruin", "close", "Open")); - assertEquals(List.of("Open camp", "confirm"), complete("ruin", "close", "Open", "c")); + assertEquals(List.of("camp", "confirm"), complete("ruin", "close", "Open", "c")); assertEquals(List.of("#2"), complete("ruin", "close", "2")); assertEquals(List.of("confirm"), complete("ruin", "delete", "confirm")); assertEquals(List.of("#1"), complete("ruin", "delete", "1")); assertTrue(complete("ruin", "delete", "z").isEmpty()); - assertEquals(List.of("Old ruin"), complete("ruin", "delete", "Old", "r")); + assertEquals(List.of("ruin"), complete("ruin", "delete", "Old", "r")); assertEquals(List.of("#1"), complete("ruin", "info", "1")); assertTrue(complete("ruin", "info", "z").isEmpty()); - assertEquals(List.of("Old ruin"), complete("ruin", "info", "Old", "r")); + assertEquals(List.of("ruin"), complete("ruin", "info", "Old", "r")); assertEquals(java.util.Set.of("#1", "#2"), new java.util.HashSet<>(complete("ruin", "tp", "#"))); assertEquals(List.of("#2"), complete("ruin", "camps", "2")); assertTrue(complete("ruin", "camps", "z").isEmpty()); @@ -537,6 +537,128 @@ public void consoleCommandsExplainWorldRequirementsAndCanGiveToNamedPlayers() { assertEquals(SiteStatus.HIDDEN, hidden.getStatus()); } + @Test public void acceptedNameCompletionsReplaceOnlyTheCurrentToken() { + Site hidden = dossier(1, "Old ruined city"); + hidden.setSurfaceY(40); + for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) + staff.getWorld().getBlockAt(x, 40, z).setType(Material.STONE); + Site camp = dossier(2, "Open camp"); + net.tfminecraft.archaeo.CharacterTestSupport.establish(camp, staff.getUniqueId(), 1, 0, 20, 4, 4); + for (String action : List.of("info", "tp", "delete", "set-interest high")) { + for (String query : List.of("Ol", "oLD r", "Old ", "Old ruined c", "Old ruined ")) { + String input = "ruin " + action + " " + query; + List suggestions = complete(input.split(" ", -1)).stream() + .filter(value -> !value.equals("confirm")).toList(); + assertEquals(1, suggestions.size()); + String accepted = input.substring(0, input.lastIndexOf(' ') + 1) + suggestions.getFirst(); + assertEquals("ruin " + action + " " + (query.startsWith("oLD") ? "oLD ruined city" : "Old ruined city"), accepted); + assertFalse(run(accepted).stream().anyMatch(message -> message.contains("No site named"))); + } + } + assertEquals(List.of("camp", "confirm"), complete("ruin", "close", "Open", "")); + assertContains(run("ruin close Open " + complete("ruin", "close", "Open", "ca").getFirst()), "Closed Open camp"); + assertEquals(SiteStatus.CLOSED, camp.getStatus()); + assertTrue(complete("ruin", "set-interest", "high", "Open", "").isEmpty()); + assertEquals(List.of("confirm"), complete("ruin", "close", "Old", "")); + } + + @Test public void optionalToolSlotIncludesExecutablePlayerShorthand() { + assertEquals(List.of("Archaeologist"), complete("give", "tool", "aRcH")); + assertEquals(List.of("Archaeologist"), complete("give", "pick", "aRcH")); + assertTrue(complete("give", "tool", "").contains("IRON_PICKAXE")); + assertTrue(complete("give", "tool", "").contains("Archaeologist")); + assertTrue(complete("give", "tool", "absent").isEmpty()); + assertContains(run("give tool " + complete("give", "tool", "Arch").getFirst()), "Gave"); + } + + @Test public void optionalToolSlotOmitsCollidingPlayerAliasesButExplicitRecipientsStillWork() { + PlayerMock recipient = player("bRuSh"); + player("BrushUser"); + assertEquals(List.of("bRuSh", "BrushUser"), complete("give", "tool", "br")); + plugin.getConfig().set("excavation.tools.light.items", List.of("BRUSH")); + plugin.saveConfig(); + assertContains(run("reload"), "Reloaded Archaeo"); + + for (String kind : List.of("tool", "pick")) { + for (String prefix : List.of("", "b", "bRu", "bRuSh")) { + List suggestions = complete("give", kind, prefix); + assertTrue(suggestions.toString(), suggestions.contains("BRUSH")); + assertTrue(suggestions.toString(), suggestions.contains("BrushUser")); + assertFalse(suggestions.toString(), suggestions.contains("bRuSh")); + assertTrue(complete("give", kind, "BRUSH", prefix).contains("bRuSh")); + } + staff.getInventory().clear(); + recipient.getInventory().clear(); + String explicitRecipient = complete("give", kind, "BRUSH", "bRuSh").stream() + .filter(name -> name.equals("bRuSh")).findFirst().orElseThrow(); + assertContains(run("give " + kind + " BRUSH " + explicitRecipient), "to bRuSh"); + assertTrue(recipient.getInventory().contains(Material.BRUSH)); + assertTrue(staff.getInventory().isEmpty()); + + recipient.getInventory().clear(); + assertContains(run("give " + kind + " " + complete("give", kind, "bRuSh").getFirst()), "to Archaeologist"); + assertTrue(staff.getInventory().contains(Material.BRUSH)); + assertTrue(recipient.getInventory().isEmpty()); + } + + plugin.getConfig().set("excavation.tools.light.items", List.of("AIR")); + plugin.saveConfig(); + assertContains(run("reload"), "Reloaded Archaeo"); + assertEquals(List.of("bRuSh", "BrushUser"), complete("give", "tool", "br")); + } + + @Test public void optionalToolSlotKeepsCanonicalToolTokensThatAreAlsoPlayerNames() { + PlayerMock recipient = player("IRON_PICKAXE"); + for (String kind : List.of("tool", "pick")) { + for (String prefix : List.of("", "iRoN_", "IRON_PICKAXE")) { + assertEquals(1L, complete("give", kind, prefix).stream() + .filter(token -> token.equals("IRON_PICKAXE")).count()); + } + staff.getInventory().clear(); + recipient.getInventory().clear(); + String explicitRecipient = complete("give", kind, "IRON_PICKAXE", "iRoN_").getFirst(); + assertContains(run("give " + kind + " IRON_PICKAXE " + explicitRecipient), "to IRON_PICKAXE"); + assertTrue(recipient.getInventory().contains(Material.IRON_PICKAXE)); + assertTrue(staff.getInventory().isEmpty()); + } + } + + @Test public void campDirectorCompletionIncludesOfflineDirectoryIdsAndSafeNames() { + var identity = mock(net.tfminecraft.tlibs.character.CharacterIdentity.class); + UUID multiword = UUID.randomUUID(), single = UUID.randomUUID(), duplicate = UUID.randomUUID(); + var info = new net.tfminecraft.tlibs.character.CharacterInfo( + new net.tfminecraft.tlibs.character.CharacterRef(UUID.randomUUID(), multiword), "Old Owner", + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, null, java.util.Set.of(), 0, null); + var safe = new net.tfminecraft.tlibs.character.CharacterInfo( + new net.tfminecraft.tlibs.character.CharacterRef(UUID.randomUUID(), single), "Director", + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, null, java.util.Set.of(), 0, null); + var repeated = new net.tfminecraft.tlibs.character.CharacterInfo( + new net.tfminecraft.tlibs.character.CharacterRef(UUID.randomUUID(), duplicate), "Duplicate", + net.tfminecraft.tlibs.character.CharacterStatus.ALIVE, null, java.util.Set.of(), 0, null); + when(identity.info(multiword)).thenReturn(java.util.Optional.of(info)); + when(identity.info(single)).thenReturn(java.util.Optional.of(safe)); + when(identity.info(duplicate)).thenReturn(java.util.Optional.of(repeated)); + when(identity.findByName("Director")).thenReturn(List.of(safe)); + when(identity.findByName("Duplicate")).thenReturn(List.of(repeated, safe)); + server.getServicesManager().unregister(net.tfminecraft.tlibs.character.CharacterIdentity.class, + server.getServicesManager().load(net.tfminecraft.tlibs.character.CharacterIdentity.class)); + server.getServicesManager().register(net.tfminecraft.tlibs.character.CharacterIdentity.class, identity, + plugin, org.bukkit.plugin.ServicePriority.Highest); + int serial = 1; + for (UUID id : List.of(multiword, single, duplicate, UUID.randomUUID())) { + Site camp = dossier(serial++, "Camp " + serial); + net.tfminecraft.archaeo.CharacterTestSupport.establish(camp, id, serial, 0, 20, 4, 4); + } + dossier(20, "Hidden"); + var all = complete("ruin", "camps", ""); + assertTrue(all.containsAll(List.of(multiword.toString(), single.toString(), duplicate.toString(), "Director"))); + assertFalse(all.contains("Old Owner")); assertFalse(all.contains("Duplicate")); + assertFalse(all.contains("#20")); + assertEquals(List.of("Director"), complete("ruin", "camps", "dIr")); + assertEquals(List.of(multiword.toString()), complete("ruin", "camps", multiword.toString().substring(0, 8))); + assertContains(run("ruin camps " + multiword), "Camp"); + } + @Test public void worldCompletionOffersUnloadedDossierWorldsAndSkipsDossiersWithoutAWorld() { dossier(1, "Archived").setWorldName("archive"); dossier(2, "Worldless").setWorldName(null); // a dossier file without its world: key @@ -549,7 +671,7 @@ public void consoleCommandsExplainWorldRequirementsAndCanGiveToNamedPlayers() { for (String profile : List.of("hand", "light", "heavy", "super-heavy")) plugin.getConfig().set("excavation.tools." + profile + ".items", List.of("AIR")); plugin.saveConfig(); assertContains(run("reload"), "Reloaded Archaeo"); - assertTrue(complete("give", "tool", "").isEmpty()); + assertEquals(List.of("Archaeologist"), complete("give", "tool", "")); assertContains(run("give tool Missing"), "Player not online: Missing"); staff.getInventory().clear(); assertContains(run("give tool"), "an excavation tool (stone pickaxe)");