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..7d05668 --- /dev/null +++ b/src/main/java/net/tfminecraft/cooking/husbandry/HusbandryClaimHint.java @@ -0,0 +1,95 @@ +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() {} + + /** True when the chunk-load cleanup would remove this animal, using the same rule. */ + public static boolean needsClaim(Entity entity) { + 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. */ + 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, entity instanceof Tameable tameable && !tameable.isTamed()); + } + + /** + * {@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()); + for (String line : lines(HusbandryEntities.displayName(entity.getType()), + claimable, untamed, owned, HusbandryConfig.maxAnimals(), staff)) { + player.sendMessage(line); + } + } + + public static void forget(UUID playerUuid) { + LAST_SENT.remove(playerUuid); + } + + 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"; + 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 it with an empty hand, then click Remove ownership." + + " 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..4f17136 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,17 @@ 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) { + // The event fires before the animal is tamed, so it cannot report itself as tamed yet. + HusbandryClaimHint.send(player, event.getEntity(), false); + } + } + + @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..c203f3e --- /dev/null +++ b/src/test/java/net/tfminecraft/cooking/husbandry/HusbandryOwnedMountTest.java @@ -0,0 +1,207 @@ +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 { + @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, 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", 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", 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", true, 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); + } + } +}