From 35c4c4c179f2790f62c882dd837c1e720f84f372 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:11:21 +0000 Subject: [PATCH] fix: complete cooking modes and cached animal targets --- .../java/net/tfminecraft/cooking/Cooking.java | 1 + .../husbandry/HusbandryAnimalsCommand.java | 36 +++++++++++-- .../cooking/manager/CommandManager.java | 38 +++++++++++++- .../HusbandryRemainingCoverageTest.java | 37 +++++++++++++ .../manager/CommandManagerCoverageTest.java | 52 +++++++++++++++++++ 5 files changed, 158 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/Cooking.java b/src/main/java/net/tfminecraft/cooking/Cooking.java index e778030..8933b99 100644 --- a/src/main/java/net/tfminecraft/cooking/Cooking.java +++ b/src/main/java/net/tfminecraft/cooking/Cooking.java @@ -198,6 +198,7 @@ public void onEnable() { getCommand("cooking").setTabCompleter(commands); if (husbandryEnabled) { HusbandryAnimalsCommand animals = new HusbandryAnimalsCommand(); + getServer().getPluginManager().registerEvents(animals, this); getCommand("animals").setExecutor(animals); getCommand("animals").setTabCompleter(animals); } else { diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java index 50e9db4..5ab822b 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryAnimalsCommand.java @@ -2,8 +2,11 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.TreeSet; import java.util.UUID; import org.bukkit.Bukkit; @@ -14,12 +17,33 @@ import org.bukkit.command.TabCompleter; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import net.tfminecraft.cooking.Cooking; -public final class HusbandryAnimalsCommand implements CommandExecutor, TabCompleter { +public final class HusbandryAnimalsCommand implements CommandExecutor, TabCompleter, Listener { + + private final Map knownNames = new HashMap<>(); + + public HusbandryAnimalsCommand() { + // Populate outside completion; getOfflinePlayers may read player data. + for (OfflinePlayer player : Bukkit.getOfflinePlayers()) { + String name = player.getName(); + if (name != null) { + knownNames.put(player.getUniqueId(), name); + } + } + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + knownNames.put(player.getUniqueId(), player.getName()); + } @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { @@ -85,13 +109,17 @@ public List onTabComplete(CommandSender sender, Command command, String return Collections.emptyList(); } String prefix = args[0].toLowerCase(Locale.ROOT); - List names = new ArrayList<>(); + TreeSet names = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (String name : knownNames.values()) { + if (name.toLowerCase(Locale.ROOT).startsWith(prefix) && Bukkit.getOfflinePlayerIfCached(name) != null) { + names.add(name); + } + } for (Player player : Bukkit.getOnlinePlayers()) { if (player.getName().toLowerCase(Locale.ROOT).startsWith(prefix)) { names.add(player.getName()); } } - names.sort(String.CASE_INSENSITIVE_ORDER); - return names; + return new ArrayList<>(names); } } diff --git a/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java b/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java index d127a4e..a6314f6 100644 --- a/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java +++ b/src/main/java/net/tfminecraft/cooking/manager/CommandManager.java @@ -146,6 +146,35 @@ public List onTabComplete(CommandSender sender, Command command, String .sorted(String.CASE_INSENSITIVE_ORDER) .collect(Collectors.toList()); } + if (sender instanceof Player && args.length >= 2) { + if (args[0].equalsIgnoreCase("preview")) { + if (args.length == 2) { + return matching(List.of("mixing_bowl"), args[1]); + } + if (args.length == 3 && args[1].equalsIgnoreCase("mixing_bowl")) { + return matching(List.of("flour", "water", "yeast", "dough", "all", "clear"), args[2]); + } + } + if (args[0].equalsIgnoreCase("qualitytest")) { + if (args.length == 2) { + return matching(List.of("pickup", "compose", "compose2"), args[1]); + } + if (args.length >= 4 && (args[1].equalsIgnoreCase("compose") + || args[1].equalsIgnoreCase("compose2"))) { + String expression = String.join(" ", Arrays.copyOfRange(args, 2, args.length - 1)); + String[] foods = args[1].equalsIgnoreCase("compose2") ? expression.split("\\|", -1) + : new String[] { expression }; + for (String food : foods) { + FoodParser.Result parsed = FoodParser.parse(food.trim()); + if (parsed == null || parsed.template == null) { + return Collections.emptyList(); + } + } + return matching(Arrays.stream(CompositionContext.values()).map(CompositionContext::name).toList(), + args[args.length - 1]); + } + } + } if (args.length == 2 && args[0].equalsIgnoreCase("husbandry")) { String prefix = args[1].toLowerCase(Locale.ROOT); List out = new ArrayList<>(); @@ -170,15 +199,20 @@ public List onTabComplete(CommandSender sender, Command command, String } if (args.length == 4 && args[0].equalsIgnoreCase("husbandry") && args[1].equalsIgnoreCase("spawn")) { - return List.of("0", "20", "1000"); + return matching(List.of("0", "20", "1000"), args[3]); } if (args.length == 5 && args[0].equalsIgnoreCase("husbandry") && args[1].equalsIgnoreCase("spawn")) { - return List.of("0", "100", "200"); + return matching(List.of("0", "100", "200"), args[4]); } return Collections.emptyList(); } + private static List matching(List candidates, String token) { + String prefix = token.toLowerCase(Locale.ROOT); + return candidates.stream().filter(value -> value.toLowerCase(Locale.ROOT).startsWith(prefix)).toList(); + } + private void sendUsage(Player player) { player.sendMessage("§e/cooking reload"); player.sendMessage("§e/cooking food "); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java index 44ab1a0..306dccd 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryRemainingCoverageTest.java @@ -148,6 +148,43 @@ boolean attack(HusbandryDamageListener listener, Entity victim, Entity attacker) assertTrue(f.repository.exists(offline.uuid())); } + @Test void animalTargetsUseKnownCachedNamesAndDropExpiredCacheEntriesWithoutRescanning() { + OfflinePlayer offline = mock(OfflinePlayer.class); + when(offline.getUniqueId()).thenReturn(UUID.randomUUID()); + when(offline.getName()).thenReturn("OfflineOwner"); + OfflinePlayer unknown = mock(OfflinePlayer.class); + OfflinePlayer uncached = mock(OfflinePlayer.class); + when(uncached.getName()).thenReturn("Uncached"); + f.player.setOp(true); + try (var bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getOfflinePlayers).thenReturn(new OfflinePlayer[]{offline, unknown, uncached, f.player}); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached("OfflineOwner")).thenReturn(offline); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached("Uncached")).thenReturn(null); + var command = new HusbandryAnimalsCommand(); + assertEquals(List.of("Farmer", "Neighbour", "OfflineOwner"), command.onTabComplete(f.player, null, "animals", new String[]{""})); + assertEquals(List.of("OfflineOwner"), command.onTabComplete(f.player, null, "livestock", new String[]{"oFf"})); + assertTrue(command.onTabComplete(f.player, null, "animals", new String[]{"missing"}).isEmpty()); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached("Uncached")).thenReturn(uncached); + assertEquals(List.of("Uncached"), command.onTabComplete(f.player, null, "animals", new String[]{"un"})); + command.onJoin(new org.bukkit.event.player.PlayerJoinEvent(offlinePlayer(offline.getUniqueId(), "RenamedOwner"), net.kyori.adventure.text.Component.empty())); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached("RenamedOwner")).thenReturn(offline); + assertEquals(List.of("RenamedOwner"), command.onTabComplete(f.player, null, "livestock", new String[]{"re"})); + assertTrue(command.onTabComplete(f.player, null, "animals", new String[]{"off"}).isEmpty()); + bukkit.when(() -> Bukkit.getOfflinePlayerIfCached("RenamedOwner")).thenReturn(null); + assertTrue(command.onTabComplete(f.player, null, "livestock", new String[]{"re"}).isEmpty()); + bukkit.verify(Bukkit::getOfflinePlayers, times(1)); + assertTrue(command.onTabComplete(f.neighbour, null, "animals", new String[]{""}).isEmpty()); + assertTrue(command.onTabComplete(f.player, null, "animals", new String[]{"Farmer", ""}).isEmpty()); + } + } + + private Player offlinePlayer(UUID id, String name) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(id); + when(player.getName()).thenReturn(name); + return player; + } + @Test void itemUsesHandleCreativeDurabilityBreakingStacksAndStoredLabels() { assertFalse(HusbandryItems.matches(null, "feed")); assertFalse(HusbandryItems.matches(new ItemStack(Material.WHEAT), " ")); assertFalse(HusbandryItems.matches(new ItemStack(Material.WHEAT), null)); HusbandryItems.useFromMainHand(null); HusbandryItems.consumeOne(null); ItemStack empty = new ItemStack(Material.AIR); HusbandryItems.consumeOne(empty); assertSame(empty, HusbandryItems.applyUse(empty)); assertNull(HusbandryItems.applyUse(null)); diff --git a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java index b26d14a..bb7595d 100644 --- a/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/cooking/manager/CommandManagerCoverageTest.java @@ -139,6 +139,58 @@ class CommandManagerCoverageTest { assertTrue(tabs("husbandry", "spawn", "cow", "1", "2", "").isEmpty()); } + @Test void previewAndQualityModesCompleteOnlyUsableFiniteArguments() { + assertEquals(List.of("mixing_bowl"), tabs("preview", "")); + assertEquals(List.of("mixing_bowl"), tabs("PREVIEW", "MiX")); + assertTrue(tabs("preview", "unknown").isEmpty()); + assertEquals(List.of("flour", "water", "yeast", "dough", "all", "clear"), tabs("preview", "mixing_bowl", "")); + assertEquals(List.of("water"), tabs("preview", "MIXING_BOWL", "Wa")); + assertTrue(tabs("preview", "other", "").isEmpty()); + assertTrue(tabs("preview", "mixing_bowl", "all", "").isEmpty()); + assertEquals(List.of("pickup", "compose", "compose2"), tabs("qualitytest", "")); + assertEquals(List.of("compose", "compose2"), tabs("QUALITYTEST", "Co")); + assertTrue(tabs("qualitytest", "unknown").isEmpty()); + assertTrue(tabs("qualitytest", "compose", "").isEmpty()); + assertTrue(tabs("qualitytest", "pickup", "food(type=roast)", "").isEmpty()); + assertTrue(commands.onTabComplete(server.getConsoleSender(), command, "cooking", new String[]{"preview", ""}).isEmpty()); + player.setOp(false); + assertTrue(tabs("qualitytest", "").isEmpty()); + } + + @Test void compositionContextsFollowCompleteFoodExpressionsIncludingSpacesAndMultipleFoods() { + org.bukkit.configuration.MemoryConfiguration config = new org.bukkit.configuration.MemoryConfiguration(); + config.set("name", "Roast"); + config.set("update", false); + FoodItem roast = new FoodItem("roast", config); + staticMock(FoodLoader.class).when(() -> FoodLoader.getByString("roast")).thenReturn(roast); + List contexts = List.of("CUTTING_BOARD", "MIXING_BOWL", "SAUCE_SCOOP", "SOUP_SCOOP", "CARVE", + "CHURN", "FRYING_PAN", "SAUSAGE_MAKER", "MILLING", "BAKING"); + assertEquals(contexts, tabs("qualitytest", "compose", "food(type=roast)", "")); + assertEquals(List.of("BAKING"), tabs("qualitytest", "COMPOSE", "food(type=roast)", "bA")); + assertEquals(List.of("SAUCE_SCOOP", "SAUSAGE_MAKER"), tabs("qualitytest", "compose2", "food(type=roast)|food(type=roast)", "sA")); + assertEquals(contexts, tabs("qualitytest", "compose", "food(type=roast;origin=Sunday", "roast)", "")); + assertEquals(contexts, tabs("qualitytest", "compose2", "food(type=roast)", "|", "food(type=roast)", "")); + assertTrue(tabs("qualitytest", "compose", "food(type=roast)").isEmpty()); + assertTrue(tabs("qualitytest", "compose", "food(type=roast", "").isEmpty()); + assertTrue(tabs("qualitytest", "compose", "food(type=missing)", "").isEmpty()); + assertTrue(tabs("qualitytest", "compose2", "food(type=roast)|", "").isEmpty()); + assertTrue(tabs("qualitytest", "compose2", "food(type=roast)|incomplete", "").isEmpty()); + assertTrue(tabs("qualitytest", "compose", "food(type=roast)", "unknown").isEmpty()); + assertTrue(tabs("qualitytest", "compose", "food(type=roast)", "BAKING", "").isEmpty()); + var parser = staticMock(FoodParser.class); + parser.when(() -> FoodParser.parse("empty")).thenReturn(new FoodParser.Result()); + assertTrue(tabs("qualitytest", "compose", "empty", "").isEmpty()); + } + + @Test void numericExamplesMatchTheCurrentToken() { + assertEquals(List.of("1000"), tabs("husbandry", "spawn", "COW", "10")); + assertEquals(List.of("20"), tabs("husbandry", "spawn", "COW", "2")); + assertTrue(tabs("husbandry", "spawn", "COW", "9").isEmpty()); + assertEquals(List.of("200"), tabs("HUSBANDRY", "SPAWN", "COW", "20", "2")); + assertEquals(List.of("100"), tabs("husbandry", "spawn", "COW", "20", "10")); + assertTrue(tabs("husbandry", "spawn", "COW", "20", "9").isEmpty()); + } + @Test void foodCommandValidatesArityTargetCharacterAndFoodRange() { MockedStatic characters = staticMock(RPCharacters.class); run("food");