From b5881dddeb4b72c8f33871c68450ca81a0404580 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:38:14 +0200 Subject: [PATCH 1/2] fix: keep only Cooking-owned horses and explain claiming Revert the #56 load exemption: vanilla-tamed, named or saddled horses without a Cooking owner bypassed the max-animals cap. Unowned horses are removed on chunk load again, apart from enrolled wild mounts (unchanged). Players now get a hint when they tame or ride an unclaimed animal from the remove-unowned list: it will disappear unless claimed with a named Ownership Token, plus their owned/cap count or how to free a slot. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cooking/husbandry/HusbandryClaimHint.java | 75 ++++++++++++ .../husbandry/HusbandryLifecycleListener.java | 6 +- .../husbandry/HusbandryMountListener.java | 16 +++ .../cooking/husbandry/HusbandryMounts.java | 13 -- .../HusbandryMountPersistenceTest.java | 108 ----------------- .../husbandry/HusbandryOwnedMountTest.java | 114 ++++++++++++++++++ 6 files changed, 206 insertions(+), 126 deletions(-) create mode 100644 src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java delete mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMountPersistenceTest.java create mode 100644 src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java new file mode 100644 index 0000000..7d6aa55 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java @@ -0,0 +1,75 @@ +package net.tfminecraft.cooking.husbandry; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Tameable; + +/** Tells players that animals they tame or ride vanish unless claimed in Cooking. */ +public final class HusbandryClaimHint { + + static final long COOLDOWN_MILLIS = 60_000L; + + private static final Map LAST_SENT = new ConcurrentHashMap<>(); + + private HusbandryClaimHint() {} + + public static boolean needsClaim(Entity entity) { + return entity != null + && HusbandryConfig.isRemoveUnowned(entity.getType()) + && !HusbandryOwnershipService.hasAnyOwner(entity.getUniqueId()); + } + + /** Sends the hint unless this player saw one within the cooldown. */ + public static void remind(Player player, Entity entity) { + long now = System.currentTimeMillis(); + Long last = LAST_SENT.get(player.getUniqueId()); + if (last != null && now - last < COOLDOWN_MILLIS) { + return; + } + send(player, entity); + } + + public static void send(Player player, Entity entity) { + if (player == null || !needsClaim(entity)) { + return; + } + LAST_SENT.put(player.getUniqueId(), System.currentTimeMillis()); + boolean staff = HusbandryOwnershipService.isStaff(player); + HusbandryRepository repository = HusbandryEntities.repository(); + int owned = repository == null ? 0 : repository.countForPlayer(player.getUniqueId()); + boolean untamed = entity instanceof Tameable tameable && !tameable.isTamed(); + for (String line : lines(HusbandryEntities.displayName(entity.getType()), + untamed, owned, HusbandryConfig.maxAnimals(), staff)) { + player.sendMessage(line); + } + } + + public static void forget(UUID playerUuid) { + LAST_SENT.remove(playerUuid); + } + + static List lines(String species, boolean untamed, int owned, int cap, boolean staff) { + String label = species == null || species.isBlank() ? "animal" : species.toLowerCase(Locale.ROOT); + String claim = (untamed ? "Tame it, then right-click" : "Right-click") + + " it with an Ownership Token renamed on an anvil"; + String warning = "§eThis " + label + " is not claimed. Unclaimed animals disappear when their area unloads."; + if (staff) { + return List.of(warning, + "§7" + claim + ". §8(staff: no animal limit)"); + } + if (owned >= cap) { + return List.of(warning, + "§cYou already own " + owned + "/" + cap + " animals, so you cannot claim it.", + "§7Release one first: shift-right-click your animal with an empty hand. See §f/animals§7."); + } + return List.of(warning, + "§7" + claim + " to keep it.", + "§7You own §f" + owned + "/" + cap + "§7 animals. See §f/animals§7."); + } +} diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java index 64bf189..10520c0 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryLifecycleListener.java @@ -125,11 +125,10 @@ static void handleLoad(Entity entity) { } UUID uuid = entity.getUniqueId(); boolean hasRow = repository.exists(uuid); - boolean playerKeptMount = HusbandryMounts.isPlayerKeptMount(entity); if (HusbandryMounts.shouldWipeUnowned( HusbandryConfig.isRemoveUnowned(entity.getType()), - HusbandryOwnershipService.hasAnyOwner(uuid) || playerKeptMount, + HusbandryOwnershipService.hasAnyOwner(uuid), hasRow, HusbandryMounts.hasConfiguredStats(entity))) { if (hasRow) { @@ -140,9 +139,6 @@ static void handleLoad(Entity entity) { return; } if (!hasRow) { - if (playerKeptMount) { - HusbandryEntities.applyPersistFlags(living); - } return; } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java index 3175eee..ac15486 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java @@ -4,8 +4,11 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityMountEvent; +import org.bukkit.event.entity.EntityTameEvent; +import org.bukkit.event.player.PlayerQuitEvent; public final class HusbandryMountListener implements Listener { @@ -25,6 +28,7 @@ public void onMount(EntityMountEvent event) { } } if (!HusbandryOwnershipService.hasAnyOwner(mount.getUniqueId())) { + HusbandryClaimHint.remind(player, mount); return; } if (HusbandryOwnershipService.isOwner(player, mount.getUniqueId()) @@ -34,4 +38,16 @@ public void onMount(EntityMountEvent event) { event.setCancelled(true); player.sendMessage("§cThis is not your animal."); } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTame(EntityTameEvent event) { + if (event.getOwner() instanceof Player player) { + HusbandryClaimHint.send(player, event.getEntity()); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + HusbandryClaimHint.forget(event.getPlayer().getUniqueId()); + } } diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMounts.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMounts.java index c497f10..0a82ea0 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMounts.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMounts.java @@ -8,7 +8,6 @@ import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.ItemStack; public final class HusbandryMounts { @@ -22,18 +21,6 @@ public static boolean hasConfiguredStats(Entity entity) { return entity != null && HusbandryConfig.mountStats(entity.getType()) != null; } - /** Vanilla ownership and equipment survive even when Cooking has no record. */ - public static boolean isPlayerKeptMount(Entity entity) { - if (!(entity instanceof AbstractHorse horse)) { - return false; - } - if (horse.isTamed() || horse.customName() != null) { - return true; - } - ItemStack saddle = horse.getInventory().getSaddle(); - return saddle != null && !saddle.getType().isAir(); - } - public static boolean shouldWipeUnowned( boolean removeUnownedType, boolean hasOwner, boolean hasRow, boolean configuredMount) { return removeUnownedType && !hasOwner && !(hasRow && configuredMount); diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMountPersistenceTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMountPersistenceTest.java deleted file mode 100644 index fdf55b6..0000000 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryMountPersistenceTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package net.tfminecraft.cooking.husbandry; - -import static org.mockito.Mockito.*; - -import java.util.UUID; - -import net.kyori.adventure.text.Component; -import org.bukkit.Material; -import org.bukkit.entity.Horse; -import org.bukkit.entity.Llama; -import org.bukkit.entity.Cow; -import org.bukkit.entity.LivingEntity; -import org.bukkit.inventory.HorseInventory; -import org.bukkit.inventory.ItemStack; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; - -class HusbandryMountPersistenceTest { - @Test - void namedSaddledHorseWithoutCookingRecordSurvivesLoad() { - Horse horse = horse(); - when(horse.customName()).thenReturn(Component.text("Hazel's horse")); - ItemStack saddle = mock(ItemStack.class); - when(saddle.getType()).thenReturn(Material.SADDLE); - when(horse.getInventory().getSaddle()).thenReturn(saddle); - checkLoad(horse, false, false, true); - } - - @Test - void vanillaTamedHorseWithoutCookingOwnerSurvivesLoad() { - Horse horse = horse(); - when(horse.isTamed()).thenReturn(true); - checkLoad(horse, false, false, true); - } - - @Test - void namedHorseAloneSurvivesLoad() { - Horse horse = horse(); - when(horse.customName()).thenReturn(Component.text("Named")); - checkLoad(horse, false, false, true); - } - - @Test - void saddledHorseAloneSurvivesLoad() { - Horse horse = horse(); - ItemStack saddle = mock(ItemStack.class); - when(saddle.getType()).thenReturn(Material.SADDLE); - when(horse.getInventory().getSaddle()).thenReturn(saddle); - checkLoad(horse, false, false, true); - } - - @Test - void wildHorseWithoutRecordIsStillRemoved() { - checkLoad(horse(), false, false, false); - } - - @Test - void enrolledWildHorseIsStillKept() { - checkLoad(horse(), true, true, true); - } - - @Test - void tamedLlamaKeepsItsRowWithoutConfiguredMountStats() { - Llama llama = mock(Llama.class); - when(llama.isTamed()).thenReturn(true); - checkLoad(llama, true, false, true); - } - - @Test - void namedUnownedLivestockStillUsesExistingCleanup() { - Cow cow = mock(Cow.class); - when(cow.customName()).thenReturn(Component.text("Cow")); - checkLoad(cow, true, false, false); - } - - private static Horse horse() { - Horse horse = mock(Horse.class); - when(horse.getInventory()).thenReturn(mock(HorseInventory.class)); - return horse; - } - - private static void checkLoad(LivingEntity entity, boolean hasRow, boolean configuredMount, boolean kept) { - UUID uuid = UUID.randomUUID(); - when(entity.getUniqueId()).thenReturn(uuid); - HusbandryRepository repository = mock(HusbandryRepository.class); - when(repository.exists(uuid)).thenReturn(hasRow); - try (MockedStatic entities = mockStatic(HusbandryEntities.class); - MockedStatic config = mockStatic(HusbandryConfig.class); - MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { - entities.when(HusbandryEntities::repository).thenReturn(repository); - entities.when(() -> HusbandryEntities.applyPersistFlags(entity)).thenCallRealMethod(); - config.when(() -> HusbandryConfig.isRemoveUnowned(entity.getType())).thenReturn(true); - if (configuredMount) { - config.when(() -> HusbandryConfig.mountStats(entity.getType())) - .thenReturn(mock(HusbandryMountStats.class)); - } - - HusbandryLifecycleListener.handleLoad(entity); - - verify(entity, kept ? never() : times(1)).remove(); - verify(repository, !kept && hasRow ? times(1) : never()).deleteAnimal(uuid); - if (kept) { - verify(entity).setPersistent(true); - verify(entity).setRemoveWhenFarAway(false); - } - } - } -} diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java new file mode 100644 index 0000000..9c0259d --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java @@ -0,0 +1,114 @@ +package net.tfminecraft.cooking.husbandry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.UUID; + +import net.kyori.adventure.text.Component; +import org.bukkit.Material; +import org.bukkit.entity.Horse; +import org.bukkit.entity.LivingEntity; +import org.bukkit.inventory.HorseInventory; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class HusbandryOwnedMountTest { + @Test + void tamedNamedSaddledHorseWithoutCookingOwnerIsRemoved() { + Horse horse = keptLookingHorse(); + checkLoad(horse, false, false, false, false); + } + + @Test + void tamedNamedSaddledHorseWithoutOwnerButWithStatsRowIsStillEnrolledWild() { + checkLoad(keptLookingHorse(), true, true, false, true); + } + + @Test + void cookingOwnedHorseIsKept() { + checkLoad(keptLookingHorse(), true, true, true, true); + } + + @Test + void cookingOwnedHorseWithoutMountStatsIsKept() { + checkLoad(keptLookingHorse(), true, false, true, true); + } + + @Test + void wildHorseWithoutRecordIsRemoved() { + checkLoad(horse(), false, false, false, false); + } + + @Test + void hintExplainsHowToClaimAndTheLimit() { + List lines = HusbandryClaimHint.lines("Horse", true, 3, 15, false); + assertEquals(3, lines.size()); + assertTrue(lines.get(0).contains("This horse is not claimed")); + assertTrue(lines.get(1).contains("Tame it, then right-click it with an Ownership Token")); + assertTrue(lines.get(2).contains("3/15")); + } + + @Test + void hintAtCapSaysItCannotBeClaimed() { + List lines = HusbandryClaimHint.lines("Donkey", false, 15, 15, false); + assertTrue(lines.get(1).contains("15/15 animals, so you cannot claim it")); + assertTrue(lines.get(2).contains("/animals")); + } + + @Test + void tamedHintSkipsTheTamingStep() { + List lines = HusbandryClaimHint.lines("Horse", false, 0, 15, false); + assertTrue(lines.get(1).startsWith("§7Right-click it with an Ownership Token")); + } + + @Test + void staffHintHasNoLimit() { + List lines = HusbandryClaimHint.lines("Horse", false, 40, 15, true); + assertEquals(2, lines.size()); + assertTrue(lines.get(1).contains("no animal limit")); + } + + private static Horse keptLookingHorse() { + Horse horse = horse(); + when(horse.isTamed()).thenReturn(true); + when(horse.customName()).thenReturn(Component.text("Hazel's horse")); + ItemStack saddle = mock(ItemStack.class); + when(saddle.getType()).thenReturn(Material.SADDLE); + when(horse.getInventory().getSaddle()).thenReturn(saddle); + return horse; + } + + private static Horse horse() { + Horse horse = mock(Horse.class); + when(horse.getInventory()).thenReturn(mock(HorseInventory.class)); + return horse; + } + + private static void checkLoad( + LivingEntity entity, boolean hasRow, boolean configuredMount, boolean owned, boolean kept) { + UUID uuid = UUID.randomUUID(); + when(entity.getUniqueId()).thenReturn(uuid); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.exists(uuid)).thenReturn(hasRow); + try (MockedStatic entities = mockStatic(HusbandryEntities.class); + MockedStatic config = mockStatic(HusbandryConfig.class); + MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + config.when(() -> HusbandryConfig.isRemoveUnowned(entity.getType())).thenReturn(true); + ownership.when(() -> HusbandryOwnershipService.hasAnyOwner(uuid)).thenReturn(owned); + if (configuredMount) { + config.when(() -> HusbandryConfig.mountStats(entity.getType())) + .thenReturn(mock(HusbandryMountStats.class)); + } + + HusbandryLifecycleListener.handleLoad(entity); + + verify(entity, kept ? never() : times(1)).remove(); + verify(repository, !kept && hasRow ? times(1) : never()).deleteAnimal(uuid); + } + } +} From a130bca898b9870d17a2d6b661943c6fe73ae624 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:40:04 +0000 Subject: [PATCH 2/2] fix: only warn about animals the cleanup would remove Riding enrolls horses, donkeys, mules and camels, and enrolled mounts survive the chunk-load cleanup without an owner, so the mount hint told players a horse would vanish when it would not. needsClaim now uses the cleanup's own rule. The tame hint no longer says to tame an animal that was just tamed (EntityTameEvent fires before taming). Wolves, cats and ocelots cannot be claimed, so they are no longer sent to the Ownership Token. The release instructions name the inspect menu's Remove ownership button, and removal is described as happening when the area next loads. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cooking/husbandry/HusbandryClaimHint.java | 40 +++++-- .../husbandry/HusbandryMountListener.java | 3 +- .../husbandry/HusbandryOwnedMountTest.java | 101 +++++++++++++++++- 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java index 7d6aa55..7d05668 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java @@ -19,10 +19,20 @@ public final class HusbandryClaimHint { private HusbandryClaimHint() {} + /** True when the chunk-load cleanup would remove this animal, using the same rule. */ public static boolean needsClaim(Entity entity) { - return entity != null - && HusbandryConfig.isRemoveUnowned(entity.getType()) - && !HusbandryOwnershipService.hasAnyOwner(entity.getUniqueId()); + if (entity == null || !HusbandryConfig.isRemoveUnowned(entity.getType())) { + return false; + } + UUID uuid = entity.getUniqueId(); + HusbandryRepository repository = HusbandryEntities.repository(); + boolean hasRow = HusbandryEntities.getLoaded(uuid).isPresent() + || (repository != null && repository.exists(uuid)); + return HusbandryMounts.shouldWipeUnowned( + true, + HusbandryOwnershipService.hasAnyOwner(uuid), + hasRow, + HusbandryMounts.hasConfiguredStats(entity)); } /** Sends the hint unless this player saw one within the cooldown. */ @@ -32,20 +42,24 @@ public static void remind(Player player, Entity entity) { if (last != null && now - last < COOLDOWN_MILLIS) { return; } - send(player, entity); + send(player, entity, entity instanceof Tameable tameable && !tameable.isTamed()); } - public static void send(Player player, Entity entity) { + /** + * {@code untamed} comes from the caller: during {@code EntityTameEvent} the animal is not tamed yet, + * so reading it from the entity would tell a player who just tamed it to tame it first. + */ + public static void send(Player player, Entity entity, boolean untamed) { if (player == null || !needsClaim(entity)) { return; } LAST_SENT.put(player.getUniqueId(), System.currentTimeMillis()); + boolean claimable = HusbandryConfig.species(entity.getType()) != null; boolean staff = HusbandryOwnershipService.isStaff(player); HusbandryRepository repository = HusbandryEntities.repository(); int owned = repository == null ? 0 : repository.countForPlayer(player.getUniqueId()); - boolean untamed = entity instanceof Tameable tameable && !tameable.isTamed(); for (String line : lines(HusbandryEntities.displayName(entity.getType()), - untamed, owned, HusbandryConfig.maxAnimals(), staff)) { + claimable, untamed, owned, HusbandryConfig.maxAnimals(), staff)) { player.sendMessage(line); } } @@ -54,11 +68,16 @@ public static void forget(UUID playerUuid) { LAST_SENT.remove(playerUuid); } - static List lines(String species, boolean untamed, int owned, int cap, boolean staff) { + static List lines( + String species, boolean claimable, boolean untamed, int owned, int cap, boolean staff) { String label = species == null || species.isBlank() ? "animal" : species.toLowerCase(Locale.ROOT); + String warning = "§eThis " + label + " is not claimed. Unclaimed animals disappear the next time" + + " their area loads."; + if (!claimable) { + return List.of(warning, "§7This kind of animal cannot be claimed, so it will not stay."); + } String claim = (untamed ? "Tame it, then right-click" : "Right-click") + " it with an Ownership Token renamed on an anvil"; - String warning = "§eThis " + label + " is not claimed. Unclaimed animals disappear when their area unloads."; if (staff) { return List.of(warning, "§7" + claim + ". §8(staff: no animal limit)"); @@ -66,7 +85,8 @@ static List lines(String species, boolean untamed, int owned, int cap, b if (owned >= cap) { return List.of(warning, "§cYou already own " + owned + "/" + cap + " animals, so you cannot claim it.", - "§7Release one first: shift-right-click your animal with an empty hand. See §f/animals§7."); + "§7Release one first: shift-right-click it with an empty hand, then click Remove ownership." + + " See §f/animals§7."); } return List.of(warning, "§7" + claim + " to keep it.", diff --git a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java index ac15486..4f17136 100644 --- a/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryMountListener.java @@ -42,7 +42,8 @@ public void onMount(EntityMountEvent event) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTame(EntityTameEvent event) { if (event.getOwner() instanceof Player player) { - HusbandryClaimHint.send(player, event.getEntity()); + // The event fires before the animal is tamed, so it cannot report itself as tamed yet. + HusbandryClaimHint.send(player, event.getEntity(), false); } } diff --git a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java index 9c0259d..c203f3e 100644 --- a/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java @@ -1,19 +1,28 @@ package net.tfminecraft.cooking.husbandry; 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.*; import java.util.List; +import java.util.Optional; import java.util.UUID; import net.kyori.adventure.text.Component; import org.bukkit.Material; +import org.bukkit.entity.Cow; +import org.bukkit.entity.EntityType; import org.bukkit.entity.Horse; +import org.bukkit.entity.Llama; import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityTameEvent; import org.bukkit.inventory.HorseInventory; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; class HusbandryOwnedMountTest { @@ -45,7 +54,7 @@ void wildHorseWithoutRecordIsRemoved() { @Test void hintExplainsHowToClaimAndTheLimit() { - List lines = HusbandryClaimHint.lines("Horse", true, 3, 15, false); + List lines = HusbandryClaimHint.lines("Horse", true, true, 3, 15, false); assertEquals(3, lines.size()); assertTrue(lines.get(0).contains("This horse is not claimed")); assertTrue(lines.get(1).contains("Tame it, then right-click it with an Ownership Token")); @@ -54,20 +63,104 @@ void hintExplainsHowToClaimAndTheLimit() { @Test void hintAtCapSaysItCannotBeClaimed() { - List lines = HusbandryClaimHint.lines("Donkey", false, 15, 15, false); + List lines = HusbandryClaimHint.lines("Donkey", true, false, 15, 15, false); assertTrue(lines.get(1).contains("15/15 animals, so you cannot claim it")); + assertTrue(lines.get(2).contains("shift-right-click it with an empty hand, then click Remove ownership")); assertTrue(lines.get(2).contains("/animals")); } + @Test + void unclaimableAnimalsAreNotSentToTheToken() { + List lines = HusbandryClaimHint.lines("Wolf", false, false, 0, 15, false); + assertEquals(2, lines.size()); + assertTrue(lines.get(0).contains("the next time their area loads")); + assertTrue(lines.get(1).contains("cannot be claimed")); + assertFalse(String.join(" ", lines).contains("Ownership Token")); + } + + @Test + void onlyAnimalsTheCleanupWouldRemoveNeedAClaim() { + Horse enrolled = horse(); + Llama llama = mock(Llama.class); + Horse owned = horse(); + Cow cow = mock(Cow.class); + UUID enrolledId = UUID.randomUUID(); + UUID llamaId = UUID.randomUUID(); + UUID ownedId = UUID.randomUUID(); + when(enrolled.getUniqueId()).thenReturn(enrolledId); + when(enrolled.getType()).thenReturn(EntityType.HORSE); + when(llama.getUniqueId()).thenReturn(llamaId); + when(llama.getType()).thenReturn(EntityType.LLAMA); + when(owned.getUniqueId()).thenReturn(ownedId); + when(owned.getType()).thenReturn(EntityType.HORSE); + when(cow.getType()).thenReturn(EntityType.COW); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.exists(enrolledId)).thenReturn(true); + try (MockedStatic entities = mockStatic(HusbandryEntities.class); + MockedStatic config = mockStatic(HusbandryConfig.class); + MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + entities.when(() -> HusbandryEntities.getLoaded(any())).thenReturn(Optional.empty()); + config.when(() -> HusbandryConfig.isRemoveUnowned(EntityType.HORSE)).thenReturn(true); + config.when(() -> HusbandryConfig.isRemoveUnowned(EntityType.LLAMA)).thenReturn(true); + config.when(() -> HusbandryConfig.mountStats(EntityType.HORSE)) + .thenReturn(mock(HusbandryMountStats.class)); + ownership.when(() -> HusbandryOwnershipService.hasAnyOwner(ownedId)).thenReturn(true); + + // Riding enrolls a horse, and an enrolled horse survives the cleanup without an owner. + assertFalse(HusbandryClaimHint.needsClaim(enrolled)); + // Llamas have no mount stats, so they are never enrolled and are removed. + assertTrue(HusbandryClaimHint.needsClaim(llama)); + assertFalse(HusbandryClaimHint.needsClaim(owned)); + assertFalse(HusbandryClaimHint.needsClaim(cow)); + assertFalse(HusbandryClaimHint.needsClaim(null)); + } + } + + @Test + void tamingHintDoesNotSayToTameAnAnimalThatWasJustTamed() { + Player player = mock(Player.class); + UUID playerId = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerId); + Llama llama = mock(Llama.class); + UUID llamaId = UUID.randomUUID(); + when(llama.getUniqueId()).thenReturn(llamaId); + when(llama.getType()).thenReturn(EntityType.LLAMA); + // Paper fires EntityTameEvent before taming, so the entity still reports untamed. + when(llama.isTamed()).thenReturn(false); + EntityTameEvent event = mock(EntityTameEvent.class); + when(event.getOwner()).thenReturn(player); + when(event.getEntity()).thenReturn(llama); + HusbandryRepository repository = mock(HusbandryRepository.class); + when(repository.countForPlayer(playerId)).thenReturn(2); + try (MockedStatic entities = mockStatic(HusbandryEntities.class); + MockedStatic config = mockStatic(HusbandryConfig.class); + MockedStatic ownership = mockStatic(HusbandryOwnershipService.class)) { + entities.when(HusbandryEntities::repository).thenReturn(repository); + entities.when(() -> HusbandryEntities.getLoaded(llamaId)).thenReturn(Optional.empty()); + entities.when(() -> HusbandryEntities.displayName(EntityType.LLAMA)).thenReturn("Llama"); + config.when(() -> HusbandryConfig.isRemoveUnowned(EntityType.LLAMA)).thenReturn(true); + config.when(() -> HusbandryConfig.species(EntityType.LLAMA)).thenReturn(mock(HusbandrySpecies.class)); + config.when(HusbandryConfig::maxAnimals).thenReturn(15); + + new HusbandryMountListener().onTame(event); + + ArgumentCaptor sent = ArgumentCaptor.forClass(String.class); + verify(player, times(3)).sendMessage(sent.capture()); + assertTrue(sent.getAllValues().get(1).startsWith("§7Right-click it with an Ownership Token")); + assertTrue(sent.getAllValues().get(2).contains("2/15")); + } + } + @Test void tamedHintSkipsTheTamingStep() { - List lines = HusbandryClaimHint.lines("Horse", false, 0, 15, false); + List lines = HusbandryClaimHint.lines("Horse", true, false, 0, 15, false); assertTrue(lines.get(1).startsWith("§7Right-click it with an Ownership Token")); } @Test void staffHintHasNoLimit() { - List lines = HusbandryClaimHint.lines("Horse", false, 40, 15, true); + List lines = HusbandryClaimHint.lines("Horse", true, false, 40, 15, true); assertEquals(2, lines.size()); assertTrue(lines.get(1).contains("no animal limit")); }