diff --git a/build.gradle.kts b/build.gradle.kts index 5abde3fd0..2fe7b6a75 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,7 @@ paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArt group = "world.bentobox" // From // Base properties from -val buildVersion = "3.16.2" +val buildVersion = "3.17.0" val buildNumberDefault = "-LOCAL" // Local build identifier val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerBaseEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerBaseEvent.java new file mode 100644 index 000000000..0db6bd9bb --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerBaseEvent.java @@ -0,0 +1,60 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import org.bukkit.event.Cancellable; +import world.bentobox.bentobox.api.events.BentoBoxEvent; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * @author tastybento + */ +public abstract class PlayerBaseEvent extends BentoBoxEvent implements Cancellable { + private boolean cancelled; + + protected final Island island; + protected final UUID playerUUID; + protected final World world; + + public PlayerBaseEvent(UUID playerUUID, Island island, World world) { + super(); + this.playerUUID = playerUUID; + this.world = world; + this.island = island; + } + + /** + * @return the island involved in this event. This may be null if the event is not related to an island + * or if the island has been deleted. + */ + public Island getIsland() { + return island; + } + + /** + * Get the world involved in this event. + * @return world + */ + public World getWorld() { + return world; + } + + /** + * @return the playerUUID + */ + public UUID getPlayerUUID() { + return playerUUID; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } + +} diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerEvent.java new file mode 100644 index 000000000..32b7bec51 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerEvent.java @@ -0,0 +1,89 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fired when a player event happens. + * + * @author tastybento + */ +public class PlayerEvent { + + public enum Reason { + INVENTORY_RESET, + TAMED_REMOVAL, + ENDERCHEST_RESET, + MONEY_RESET, + HEALTH_RESET, + HUNGER_RESET, + EXP_RESET, + UNKNOWN + } + + public static PlayerEventBuilder builder() { + return new PlayerEventBuilder(); + } + + public static class PlayerEventBuilder { + private World world; + private UUID player; + private Island island; + private Reason reason = Reason.UNKNOWN; + + public PlayerEventBuilder world(World world) { + this.world = world; + return this; + } + + public PlayerEventBuilder island(Island island) { + this.island = island; + return this; + } + + /** + * @param reason for the event + * @return PlayerEventBuilder + */ + public PlayerEventBuilder reason(Reason reason) { + this.reason = reason; + return this; + } + + /** + * @param player - the player involved in the event + * @return PlayerEventBuilder + */ + public PlayerEventBuilder involvedPlayer(UUID player) { + this.player = player; + return this; + } + + private PlayerBaseEvent getEvent() { + return switch (reason) { + case INVENTORY_RESET -> new PlayerResetInventoryEvent(world, island, player); + case TAMED_REMOVAL -> new PlayerTamedRemovalEvent(world, island, player); + case ENDERCHEST_RESET -> new PlayerResetEnderChestEvent(world, island, player); + case MONEY_RESET -> new PlayerResetMoneyEvent(world, island, player); + case HEALTH_RESET -> new PlayerResetHealthEvent(world, island, player); + case HUNGER_RESET -> new PlayerResetHungerEvent(world, island, player); + case EXP_RESET -> new PlayerResetExpEvent(world, island, player); + case UNKNOWN -> new PlayerUnknownEvent(world, island, player); + }; + } + + /** + * Build the event and call it + * @return event + */ + public PlayerBaseEvent build() { + // Generate new event + PlayerBaseEvent newEvent = getEvent(); + Bukkit.getPluginManager().callEvent(newEvent); + return newEvent; + } + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetEnderChestEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetEnderChestEvent.java new file mode 100644 index 000000000..c7956cacb --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetEnderChestEvent.java @@ -0,0 +1,29 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fired when a player's ender chest is cleared as part of an island reset. + *

+ * This event is cancellable. If cancelled, the ender chest contents will not be cleared. + *

+ * + * @author tastybento + * @see PlayerBaseEvent + */ +public class PlayerResetEnderChestEvent extends PlayerBaseEvent { + + /** + * Constructs a new PlayerResetEnderChestEvent. + * + * @param world the world in which the reset is occurring + * @param island the island being reset, or {@code null} if not applicable + * @param player the UUID of the player whose ender chest is being cleared + */ + public PlayerResetEnderChestEvent(World world, Island island, UUID player) { + super(player, island, world); + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetExpEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetExpEvent.java new file mode 100644 index 000000000..80fe4a0c1 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetExpEvent.java @@ -0,0 +1,29 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fired when a player's experience (XP) is reset as part of an island reset. + *

+ * This event is cancellable. If cancelled, the player's experience will not be cleared. + *

+ * + * @author tastybento + * @see PlayerBaseEvent + */ +public class PlayerResetExpEvent extends PlayerBaseEvent { + + /** + * Constructs a new PlayerResetExpEvent. + * + * @param world the world in which the reset is occurring + * @param island the island being reset, or {@code null} if not applicable + * @param player the UUID of the player whose experience is being reset + */ + public PlayerResetExpEvent(World world, Island island, UUID player) { + super(player, island, world); + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetHealthEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetHealthEvent.java new file mode 100644 index 000000000..0517aa8ed --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetHealthEvent.java @@ -0,0 +1,29 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fired when a player's health is reset to the maximum value as part of an island reset. + *

+ * This event is cancellable. If cancelled, the player's health will not be reset. + *

+ * + * @author tastybento + * @see PlayerBaseEvent + */ +public class PlayerResetHealthEvent extends PlayerBaseEvent { + + /** + * Constructs a new PlayerResetHealthEvent. + * + * @param world the world in which the reset is occurring + * @param island the island being reset, or {@code null} if not applicable + * @param player the UUID of the player whose health is being reset + */ + public PlayerResetHealthEvent(World world, Island island, UUID player) { + super(player, island, world); + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetHungerEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetHungerEvent.java new file mode 100644 index 000000000..ccf8258e5 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetHungerEvent.java @@ -0,0 +1,29 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fired when a player's hunger (food level) is reset to the maximum value as part of an island reset. + *

+ * This event is cancellable. If cancelled, the player's hunger will not be reset. + *

+ * + * @author tastybento + * @see PlayerBaseEvent + */ +public class PlayerResetHungerEvent extends PlayerBaseEvent { + + /** + * Constructs a new PlayerResetHungerEvent. + * + * @param world the world in which the reset is occurring + * @param island the island being reset, or {@code null} if not applicable + * @param player the UUID of the player whose hunger is being reset + */ + public PlayerResetHungerEvent(World world, Island island, UUID player) { + super(player, island, world); + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetInventoryEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetInventoryEvent.java new file mode 100644 index 000000000..27a2c3e05 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetInventoryEvent.java @@ -0,0 +1,56 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import org.bukkit.event.HandlerList; +import org.eclipse.jdt.annotation.NonNull; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fired when a player's inventory is cleared as part of an island reset. + *

+ * This event is cancellable. If cancelled, the player's inventory will not be cleared. + * This is the only reset event that exposes a {@link HandlerList}, making it the canonical + * example for the reset-event family. + *

+ * + * @author tastybento + * @see PlayerBaseEvent + */ +public class PlayerResetInventoryEvent extends PlayerBaseEvent { + + private static final HandlerList handlers = new HandlerList(); + + /** + * Returns the list of handlers registered for this event type. + * + * @return the handler list + */ + @Override + public @NonNull HandlerList getHandlers() { + return getHandlerList(); + } + + /** + * Returns the static handler list for this event type. + * Required by Bukkit's event system so that handlers can be looked up by class. + * + * @return the static handler list + */ + public static HandlerList getHandlerList() { + return handlers; + } + + /** + * Constructs a new PlayerResetInventoryEvent. + * + * @param world the world in which the reset is occurring + * @param island the island being reset, or {@code null} if not applicable + * @param player the UUID of the player whose inventory is being cleared + */ + public PlayerResetInventoryEvent(World world, Island island, UUID player) { + // Final variables have to be declared in the constructor + super(player, island, world); + } +} \ No newline at end of file diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetMoneyEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetMoneyEvent.java new file mode 100644 index 000000000..56205c197 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerResetMoneyEvent.java @@ -0,0 +1,31 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fired when a player's in-game money balance is reset to zero (or to the configured starting + * amount) as part of an island reset. + *

+ * This event is cancellable. If cancelled, the player's balance will not be reset. + * Requires an economy plugin (e.g. Vault) to be present for the underlying action to take effect. + *

+ * + * @author tastybento + * @see PlayerBaseEvent + */ +public class PlayerResetMoneyEvent extends PlayerBaseEvent { + + /** + * Constructs a new PlayerResetMoneyEvent. + * + * @param world the world in which the reset is occurring + * @param island the island being reset, or {@code null} if not applicable + * @param player the UUID of the player whose balance is being reset + */ + public PlayerResetMoneyEvent(World world, Island island, UUID player) { + super(player, island, world); + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerTamedRemovalEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerTamedRemovalEvent.java new file mode 100644 index 000000000..b467d83f1 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerTamedRemovalEvent.java @@ -0,0 +1,30 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fired when a player's tamed entities (wolves, cats, horses, etc.) are removed as part of an + * island reset. + *

+ * This event is cancellable. If cancelled, the player's tamed entities will not be removed. + *

+ * + * @author tastybento + * @see PlayerBaseEvent + */ +public class PlayerTamedRemovalEvent extends PlayerBaseEvent { + + /** + * Constructs a new PlayerTamedRemovalEvent. + * + * @param world the world in which the removal is occurring + * @param island the island being reset, or {@code null} if not applicable + * @param player the UUID of the player whose tamed entities are being removed + */ + public PlayerTamedRemovalEvent(World world, Island island, UUID player) { + super(player, island, world); + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/events/player/PlayerUnknownEvent.java b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerUnknownEvent.java new file mode 100644 index 000000000..48b1e057b --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/events/player/PlayerUnknownEvent.java @@ -0,0 +1,32 @@ +package world.bentobox.bentobox.api.events.player; + +import org.bukkit.World; +import world.bentobox.bentobox.database.objects.Island; + +import java.util.UUID; + +/** + * Fallback event fired when a player reset action is requested but no more specific reset event + * class exists for it. + *

+ * This event is cancellable. If cancelled, the unknown reset action will not be performed. + * Addon developers should prefer listening to the more specific {@code PlayerReset*} events + * where possible; this event acts as a catch-all for future or custom reset types. + *

+ * + * @author tastybento + * @see PlayerBaseEvent + */ +public class PlayerUnknownEvent extends PlayerBaseEvent { + + /** + * Constructs a new PlayerUnknownEvent. + * + * @param world the world in which the reset is occurring + * @param island the island being reset, or {@code null} if not applicable + * @param player the UUID of the player affected by the unknown reset action + */ + public PlayerUnknownEvent(World world, Island island, UUID player) { + super(player, island, world); + } +} diff --git a/src/main/java/world/bentobox/bentobox/managers/PlayersManager.java b/src/main/java/world/bentobox/bentobox/managers/PlayersManager.java index 3f4ae75be..f319c7d09 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PlayersManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/PlayersManager.java @@ -17,6 +17,7 @@ import org.eclipse.jdt.annotation.Nullable; import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.events.player.PlayerEvent; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.Database; import world.bentobox.bentobox.database.objects.Island; @@ -344,67 +345,113 @@ public void removePlayer(Player player) { } /** - * Cleans the player when leaving an island - * @param world - island world - * @param target - target user - * @param kicked - true if player is being kicked - * @param island - island being left + * Cleans the player when leaving an island. + *

+ * For each configurable reset action a cancellable {@link world.bentobox.bentobox.api.events.player.PlayerBaseEvent} + * is fired via {@link PlayerEvent#builder()} before the action is executed. If the event is + * cancelled by a listener the corresponding action is skipped entirely. The events fired, in order, are: + *

    + *
  1. {@link world.bentobox.bentobox.api.events.player.PlayerTamedRemovalEvent} – before untaming the player's animals
  2. + *
  3. {@link world.bentobox.bentobox.api.events.player.PlayerResetEnderChestEvent} – before clearing the ender chest
  4. + *
  5. {@link world.bentobox.bentobox.api.events.player.PlayerResetInventoryEvent} – before clearing the inventory
  6. + *
  7. {@link world.bentobox.bentobox.api.events.player.PlayerResetMoneyEvent} – before withdrawing the player's balance
  8. + *
  9. {@link world.bentobox.bentobox.api.events.player.PlayerResetHealthEvent} – before resetting health
  10. + *
  11. {@link world.bentobox.bentobox.api.events.player.PlayerResetHungerEvent} – before resetting hunger
  12. + *
  13. {@link world.bentobox.bentobox.api.events.player.PlayerResetExpEvent} – before resetting XP
  14. + *
+ * + * @param world the island world + * @param target the target user + * @param kicked {@code true} if the player is being kicked from the team + * @param island the island being left * @since 1.15.4 */ public void cleanLeavingPlayer(World world, User target, boolean kicked, Island island) { - // Execute commands when leaving + // Execute on-leave commands unconditionally (not a player-state reset, no event needed) String ownerName = this.getName(island.getOwner()); Util.runCommands(target, ownerName, plugin.getIWM().getOnLeaveCommands(world), "leave"); - // Remove any tamed animals - world.getEntitiesByClass(Tameable.class).stream() - .filter(Tameable::isTamed) - .filter(t -> t.getOwner() != null && t.getOwner().getUniqueId().equals(target.getUniqueId())) - .forEach(t -> t.setOwner(null)); + // Remove any tamed animals – skipped if the TAMED_REMOVAL event is cancelled + if (!PlayerEvent.builder() + .world(world).island(island).involvedPlayer(target.getUniqueId()) + .reason(PlayerEvent.Reason.TAMED_REMOVAL).build().isCancelled()) { + world.getEntitiesByClass(Tameable.class).stream() + .filter(Tameable::isTamed) + .filter(t -> t.getOwner() != null && t.getOwner().getUniqueId().equals(target.getUniqueId())) + .forEach(t -> t.setOwner(null)); + } - // Remove money inventory etc. + // Clear ender chest – skipped if the ENDERCHEST_RESET event is cancelled if (plugin.getIWM().isOnLeaveResetEnderChest(world)) { - if (target.isOnline()) { - target.getPlayer().getEnderChest().clear(); - } else { - Players p = getPlayer(target.getUniqueId()); - if (p != null) { - p.addToPendingKick(world); + if (!PlayerEvent.builder() + .world(world).island(island).involvedPlayer(target.getUniqueId()) + .reason(PlayerEvent.Reason.ENDERCHEST_RESET).build().isCancelled()) { + if (target.isOnline()) { + target.getPlayer().getEnderChest().clear(); + } else { + Players p = getPlayer(target.getUniqueId()); + if (p != null) { + p.addToPendingKick(world); + } } } } + + // Clear inventory – skipped if the INVENTORY_RESET event is cancelled if ((kicked && plugin.getIWM().isOnLeaveResetInventory(world) && !plugin.getIWM().isKickedKeepInventory(world)) || (!kicked && plugin.getIWM().isOnLeaveResetInventory(world))) { - if (target.isOnline()) { - target.getPlayer().getInventory().clear(); - } else { - Players p = getPlayer(target.getUniqueId()); - if (p != null) { - p.addToPendingKick(world); + if (!PlayerEvent.builder() + .world(world).island(island).involvedPlayer(target.getUniqueId()) + .reason(PlayerEvent.Reason.INVENTORY_RESET).build().isCancelled()) { + if (target.isOnline()) { + target.getPlayer().getInventory().clear(); + } else { + Players p = getPlayer(target.getUniqueId()); + if (p != null) { + p.addToPendingKick(world); + } } } } + // Withdraw money – skipped if the MONEY_RESET event is cancelled if (plugin.getSettings().isUseEconomy() && plugin.getIWM().isOnLeaveResetMoney(world)) { - plugin.getVault().ifPresent(vault -> vault.withdraw(target, vault.getBalance(target), world)); + if (!PlayerEvent.builder() + .world(world).island(island).involvedPlayer(target.getUniqueId()) + .reason(PlayerEvent.Reason.MONEY_RESET).build().isCancelled()) { + plugin.getVault().ifPresent(vault -> vault.withdraw(target, vault.getBalance(target), world)); + } } - // Reset the health + + // Reset health – skipped if the HEALTH_RESET event is cancelled if (plugin.getIWM().isOnLeaveResetHealth(world) && target.isPlayer()) { - Util.resetHealth(target.getPlayer()); + if (!PlayerEvent.builder() + .world(world).island(island).involvedPlayer(target.getUniqueId()) + .reason(PlayerEvent.Reason.HEALTH_RESET).build().isCancelled()) { + Util.resetHealth(target.getPlayer()); + } } - // Reset the hunger + // Reset hunger – skipped if the HUNGER_RESET event is cancelled if (plugin.getIWM().isOnLeaveResetHunger(world) && target.isPlayer()) { - target.getPlayer().setFoodLevel(20); + if (!PlayerEvent.builder() + .world(world).island(island).involvedPlayer(target.getUniqueId()) + .reason(PlayerEvent.Reason.HUNGER_RESET).build().isCancelled()) { + target.getPlayer().setFoodLevel(20); + } } - // Reset the XP + // Reset XP – skipped if the EXP_RESET event is cancelled if (plugin.getIWM().isOnLeaveResetXP(world) && target.isPlayer()) { - // Player collected XP (displayed) - target.getPlayer().setLevel(0); - target.getPlayer().setExp(0); - // Player total XP (not displayed) - target.getPlayer().setTotalExperience(0); + if (!PlayerEvent.builder() + .world(world).island(island).involvedPlayer(target.getUniqueId()) + .reason(PlayerEvent.Reason.EXP_RESET).build().isCancelled()) { + // Player collected XP (displayed) + target.getPlayer().setLevel(0); + target.getPlayer().setExp(0); + // Player total XP (not displayed) + target.getPlayer().setTotalExperience(0); + } } } diff --git a/src/test/java/world/bentobox/bentobox/api/events/player/PlayerResetEventsTest.java b/src/test/java/world/bentobox/bentobox/api/events/player/PlayerResetEventsTest.java new file mode 100644 index 000000000..c3cd38c0c --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/api/events/player/PlayerResetEventsTest.java @@ -0,0 +1,397 @@ +package world.bentobox.bentobox.api.events.player; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.CommonTestSetup; + +/** + * Tests for the player-reset event family: + * {@link PlayerResetEnderChestEvent}, {@link PlayerResetExpEvent}, + * {@link PlayerResetHealthEvent}, {@link PlayerResetHungerEvent}, + * {@link PlayerResetInventoryEvent}, {@link PlayerResetMoneyEvent}, + * {@link PlayerTamedRemovalEvent}, and {@link PlayerUnknownEvent}. + * + *

Each event is verified for:

+ * + * {@link PlayerResetInventoryEvent} additionally exercises its {@link org.bukkit.event.HandlerList}. + * + * @author tastybento + */ +class PlayerResetEventsTest extends CommonTestSetup { + + /** A fresh UUID that represents the player being reset. */ + private UUID playerUUID; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + playerUUID = UUID.randomUUID(); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + // ----------------------------------------------------------------------- + // PlayerResetEnderChestEvent + // ----------------------------------------------------------------------- + + /** + * Test method for + * {@link PlayerResetEnderChestEvent#PlayerResetEnderChestEvent(org.bukkit.World, world.bentobox.bentobox.database.objects.Island, UUID)}. + * Verifies that the event is created and its accessors return the expected values. + */ + @Test + void testResetEnderChestEventConstruction() { + PlayerResetEnderChestEvent event = new PlayerResetEnderChestEvent(world, island, playerUUID); + assertNotNull(event); + assertSame(island, event.getIsland()); + assertSame(world, event.getWorld()); + assertSame(playerUUID, event.getPlayerUUID()); + } + + /** + * Verifies that a newly created {@link PlayerResetEnderChestEvent} is not cancelled by default. + */ + @Test + void testResetEnderChestEventNotCancelledByDefault() { + PlayerResetEnderChestEvent event = new PlayerResetEnderChestEvent(world, island, playerUUID); + assertFalse(event.isCancelled()); + } + + /** + * Verifies that {@link PlayerResetEnderChestEvent} can be cancelled and then un-cancelled. + */ + @Test + void testResetEnderChestEventCancellation() { + PlayerResetEnderChestEvent event = new PlayerResetEnderChestEvent(world, island, playerUUID); + event.setCancelled(true); + assertTrue(event.isCancelled()); + event.setCancelled(false); + assertFalse(event.isCancelled()); + } + + // ----------------------------------------------------------------------- + // PlayerResetExpEvent + // ----------------------------------------------------------------------- + + /** + * Test method for + * {@link PlayerResetExpEvent#PlayerResetExpEvent(org.bukkit.World, world.bentobox.bentobox.database.objects.Island, UUID)}. + */ + @Test + void testResetExpEventConstruction() { + PlayerResetExpEvent event = new PlayerResetExpEvent(world, island, playerUUID); + assertNotNull(event); + assertSame(island, event.getIsland()); + assertSame(world, event.getWorld()); + assertSame(playerUUID, event.getPlayerUUID()); + } + + /** + * Verifies that a newly created {@link PlayerResetExpEvent} is not cancelled by default. + */ + @Test + void testResetExpEventNotCancelledByDefault() { + PlayerResetExpEvent event = new PlayerResetExpEvent(world, island, playerUUID); + assertFalse(event.isCancelled()); + } + + /** + * Verifies that {@link PlayerResetExpEvent} can be cancelled and then un-cancelled. + */ + @Test + void testResetExpEventCancellation() { + PlayerResetExpEvent event = new PlayerResetExpEvent(world, island, playerUUID); + event.setCancelled(true); + assertTrue(event.isCancelled()); + event.setCancelled(false); + assertFalse(event.isCancelled()); + } + + // ----------------------------------------------------------------------- + // PlayerResetHealthEvent + // ----------------------------------------------------------------------- + + /** + * Test method for + * {@link PlayerResetHealthEvent#PlayerResetHealthEvent(org.bukkit.World, world.bentobox.bentobox.database.objects.Island, UUID)}. + */ + @Test + void testResetHealthEventConstruction() { + PlayerResetHealthEvent event = new PlayerResetHealthEvent(world, island, playerUUID); + assertNotNull(event); + assertSame(island, event.getIsland()); + assertSame(world, event.getWorld()); + assertSame(playerUUID, event.getPlayerUUID()); + } + + /** + * Verifies that a newly created {@link PlayerResetHealthEvent} is not cancelled by default. + */ + @Test + void testResetHealthEventNotCancelledByDefault() { + PlayerResetHealthEvent event = new PlayerResetHealthEvent(world, island, playerUUID); + assertFalse(event.isCancelled()); + } + + /** + * Verifies that {@link PlayerResetHealthEvent} can be cancelled and then un-cancelled. + */ + @Test + void testResetHealthEventCancellation() { + PlayerResetHealthEvent event = new PlayerResetHealthEvent(world, island, playerUUID); + event.setCancelled(true); + assertTrue(event.isCancelled()); + event.setCancelled(false); + assertFalse(event.isCancelled()); + } + + // ----------------------------------------------------------------------- + // PlayerResetHungerEvent + // ----------------------------------------------------------------------- + + /** + * Test method for + * {@link PlayerResetHungerEvent#PlayerResetHungerEvent(org.bukkit.World, world.bentobox.bentobox.database.objects.Island, UUID)}. + */ + @Test + void testResetHungerEventConstruction() { + PlayerResetHungerEvent event = new PlayerResetHungerEvent(world, island, playerUUID); + assertNotNull(event); + assertSame(island, event.getIsland()); + assertSame(world, event.getWorld()); + assertSame(playerUUID, event.getPlayerUUID()); + } + + /** + * Verifies that a newly created {@link PlayerResetHungerEvent} is not cancelled by default. + */ + @Test + void testResetHungerEventNotCancelledByDefault() { + PlayerResetHungerEvent event = new PlayerResetHungerEvent(world, island, playerUUID); + assertFalse(event.isCancelled()); + } + + /** + * Verifies that {@link PlayerResetHungerEvent} can be cancelled and then un-cancelled. + */ + @Test + void testResetHungerEventCancellation() { + PlayerResetHungerEvent event = new PlayerResetHungerEvent(world, island, playerUUID); + event.setCancelled(true); + assertTrue(event.isCancelled()); + event.setCancelled(false); + assertFalse(event.isCancelled()); + } + + // ----------------------------------------------------------------------- + // PlayerResetInventoryEvent + // ----------------------------------------------------------------------- + + /** + * Test method for + * {@link PlayerResetInventoryEvent#PlayerResetInventoryEvent(org.bukkit.World, world.bentobox.bentobox.database.objects.Island, UUID)}. + */ + @Test + void testResetInventoryEventConstruction() { + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + assertNotNull(event); + assertSame(island, event.getIsland()); + assertSame(world, event.getWorld()); + assertSame(playerUUID, event.getPlayerUUID()); + } + + /** + * Verifies that a newly created {@link PlayerResetInventoryEvent} is not cancelled by default. + */ + @Test + void testResetInventoryEventNotCancelledByDefault() { + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + assertFalse(event.isCancelled()); + } + + /** + * Verifies that {@link PlayerResetInventoryEvent} can be cancelled and then un-cancelled. + */ + @Test + void testResetInventoryEventCancellation() { + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + event.setCancelled(true); + assertTrue(event.isCancelled()); + event.setCancelled(false); + assertFalse(event.isCancelled()); + } + + /** + * Test method for {@link PlayerResetInventoryEvent#getHandlers()}. + * Verifies that the instance handler list is non-null. + */ + @Test + void testResetInventoryEventGetHandlers() { + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + assertNotNull(event.getHandlers()); + } + + /** + * Test method for {@link PlayerResetInventoryEvent#getHandlerList()}. + * Verifies that the static handler list is non-null and the same object returned + * by the instance method. + */ + @Test + void testResetInventoryEventGetHandlerList() { + PlayerResetInventoryEvent event = new PlayerResetInventoryEvent(world, island, playerUUID); + assertNotNull(PlayerResetInventoryEvent.getHandlerList()); + assertSame(PlayerResetInventoryEvent.getHandlerList(), event.getHandlers()); + } + + // ----------------------------------------------------------------------- + // PlayerResetMoneyEvent + // ----------------------------------------------------------------------- + + /** + * Test method for + * {@link PlayerResetMoneyEvent#PlayerResetMoneyEvent(org.bukkit.World, world.bentobox.bentobox.database.objects.Island, UUID)}. + */ + @Test + void testResetMoneyEventConstruction() { + PlayerResetMoneyEvent event = new PlayerResetMoneyEvent(world, island, playerUUID); + assertNotNull(event); + assertSame(island, event.getIsland()); + assertSame(world, event.getWorld()); + assertSame(playerUUID, event.getPlayerUUID()); + } + + /** + * Verifies that a newly created {@link PlayerResetMoneyEvent} is not cancelled by default. + */ + @Test + void testResetMoneyEventNotCancelledByDefault() { + PlayerResetMoneyEvent event = new PlayerResetMoneyEvent(world, island, playerUUID); + assertFalse(event.isCancelled()); + } + + /** + * Verifies that {@link PlayerResetMoneyEvent} can be cancelled and then un-cancelled. + */ + @Test + void testResetMoneyEventCancellation() { + PlayerResetMoneyEvent event = new PlayerResetMoneyEvent(world, island, playerUUID); + event.setCancelled(true); + assertTrue(event.isCancelled()); + event.setCancelled(false); + assertFalse(event.isCancelled()); + } + + // ----------------------------------------------------------------------- + // PlayerTamedRemovalEvent + // ----------------------------------------------------------------------- + + /** + * Test method for + * {@link PlayerTamedRemovalEvent#PlayerTamedRemovalEvent(org.bukkit.World, world.bentobox.bentobox.database.objects.Island, UUID)}. + */ + @Test + void testTamedRemovalEventConstruction() { + PlayerTamedRemovalEvent event = new PlayerTamedRemovalEvent(world, island, playerUUID); + assertNotNull(event); + assertSame(island, event.getIsland()); + assertSame(world, event.getWorld()); + assertSame(playerUUID, event.getPlayerUUID()); + } + + /** + * Verifies that a newly created {@link PlayerTamedRemovalEvent} is not cancelled by default. + */ + @Test + void testTamedRemovalEventNotCancelledByDefault() { + PlayerTamedRemovalEvent event = new PlayerTamedRemovalEvent(world, island, playerUUID); + assertFalse(event.isCancelled()); + } + + /** + * Verifies that {@link PlayerTamedRemovalEvent} can be cancelled and then un-cancelled. + */ + @Test + void testTamedRemovalEventCancellation() { + PlayerTamedRemovalEvent event = new PlayerTamedRemovalEvent(world, island, playerUUID); + event.setCancelled(true); + assertTrue(event.isCancelled()); + event.setCancelled(false); + assertFalse(event.isCancelled()); + } + + // ----------------------------------------------------------------------- + // PlayerUnknownEvent + // ----------------------------------------------------------------------- + + /** + * Test method for + * {@link PlayerUnknownEvent#PlayerUnknownEvent(org.bukkit.World, world.bentobox.bentobox.database.objects.Island, UUID)}. + */ + @Test + void testUnknownEventConstruction() { + PlayerUnknownEvent event = new PlayerUnknownEvent(world, island, playerUUID); + assertNotNull(event); + assertSame(island, event.getIsland()); + assertSame(world, event.getWorld()); + assertSame(playerUUID, event.getPlayerUUID()); + } + + /** + * Verifies that a newly created {@link PlayerUnknownEvent} is not cancelled by default. + */ + @Test + void testUnknownEventNotCancelledByDefault() { + PlayerUnknownEvent event = new PlayerUnknownEvent(world, island, playerUUID); + assertFalse(event.isCancelled()); + } + + /** + * Verifies that {@link PlayerUnknownEvent} can be cancelled and then un-cancelled. + */ + @Test + void testUnknownEventCancellation() { + PlayerUnknownEvent event = new PlayerUnknownEvent(world, island, playerUUID); + event.setCancelled(true); + assertTrue(event.isCancelled()); + event.setCancelled(false); + assertFalse(event.isCancelled()); + } + + // ----------------------------------------------------------------------- + // Null island (valid edge case – island may be null on some resets) + // ----------------------------------------------------------------------- + + /** + * Verifies that all events tolerate a {@code null} island without throwing. + */ + @Test + void testEventsAcceptNullIsland() { + assertNotNull(new PlayerResetEnderChestEvent(world, null, playerUUID)); + assertNotNull(new PlayerResetExpEvent(world, null, playerUUID)); + assertNotNull(new PlayerResetHealthEvent(world, null, playerUUID)); + assertNotNull(new PlayerResetHungerEvent(world, null, playerUUID)); + assertNotNull(new PlayerResetInventoryEvent(world, null, playerUUID)); + assertNotNull(new PlayerResetMoneyEvent(world, null, playerUUID)); + assertNotNull(new PlayerTamedRemovalEvent(world, null, playerUUID)); + assertNotNull(new PlayerUnknownEvent(world, null, playerUUID)); + } +} + diff --git a/src/test/java/world/bentobox/bentobox/managers/PlayersManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/PlayersManagerTest.java index 55a02c5d7..db20e7aa7 100644 --- a/src/test/java/world/bentobox/bentobox/managers/PlayersManagerTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/PlayersManagerTest.java @@ -9,11 +9,20 @@ import org.junit.jupiter.api.Assertions; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import world.bentobox.bentobox.api.events.player.PlayerResetEnderChestEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetExpEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetHealthEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetHungerEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetInventoryEvent; +import world.bentobox.bentobox.api.events.player.PlayerResetMoneyEvent; +import world.bentobox.bentobox.api.events.player.PlayerTamedRemovalEvent; + import java.beans.IntrospectionException; import java.io.File; import java.lang.reflect.InvocationTargetException; @@ -684,4 +693,197 @@ void testSavePlayer() { pm.savePlayer(uuid).thenAccept(Assertions::assertTrue); } + // ----------------------------------------------------------------------- + // cleanLeavingPlayer – event-cancellation tests + // ----------------------------------------------------------------------- + + /** + * Helper: arrange pim so that any callEvent() for the given event class will + * cancel that event before returning. + */ + private void cancelEventOfType(Class type) { + doAnswer(inv -> { + Object event = inv.getArgument(0); + if (type.isInstance(event)) { + ((world.bentobox.bentobox.api.events.player.PlayerBaseEvent) event).setCancelled(true); + } + return null; + }).when(pim).callEvent(any()); + } + + /** + * When a {@link PlayerTamedRemovalEvent} is cancelled the player's tamed + * animals must NOT have their owner cleared. + */ + @Test + void testCleanLeavingPlayerTamedRemovalCancelled() { + cancelEventOfType(PlayerTamedRemovalEvent.class); + pm.cleanLeavingPlayer(world, user, false, island); + verify(tamed, never()).setOwner(null); + } + + /** + * When {@link PlayerTamedRemovalEvent} is NOT cancelled tamed animals ARE + * untamed (regression guard for the happy path). + */ + @Test + void testCleanLeavingPlayerTamedRemovalNotCancelled() { + pm.cleanLeavingPlayer(world, user, false, island); + verify(tamed).setOwner(null); + } + + /** + * When a {@link PlayerResetEnderChestEvent} is cancelled the ender chest must + * NOT be cleared. + */ + @Test + void testCleanLeavingPlayerEnderChestCancelled() { + cancelEventOfType(PlayerResetEnderChestEvent.class); + pm.cleanLeavingPlayer(world, user, false, island); + verify(inv, never()).clear(); + } + + /** + * When {@link PlayerResetEnderChestEvent} is NOT cancelled the ender chest IS + * cleared (regression guard). + */ + @Test + void testCleanLeavingPlayerEnderChestNotCancelled() { + pm.cleanLeavingPlayer(world, user, false, island); + verify(inv).clear(); + } + + /** + * When a {@link PlayerResetInventoryEvent} is cancelled the player inventory + * must NOT be cleared. + */ + @Test + void testCleanLeavingPlayerInventoryCancelled() { + when(iwm.isKickedKeepInventory(any())).thenReturn(false); + cancelEventOfType(PlayerResetInventoryEvent.class); + pm.cleanLeavingPlayer(world, user, false, island); + verify(playerInv, never()).clear(); + } + + /** + * When {@link PlayerResetInventoryEvent} is NOT cancelled (and kick-keep is + * off) the inventory IS cleared (regression guard). + */ + @Test + void testCleanLeavingPlayerInventoryNotCancelled() { + when(iwm.isKickedKeepInventory(any())).thenReturn(false); + pm.cleanLeavingPlayer(world, user, false, island); + verify(playerInv).clear(); + } + + /** + * When a {@link PlayerResetMoneyEvent} is cancelled the economy withdraw must + * NOT be called. + */ + @Test + void testCleanLeavingPlayerMoneyCancelled() { + cancelEventOfType(PlayerResetMoneyEvent.class); + pm.cleanLeavingPlayer(world, user, false, island); + verify(vault, never()).withdraw(any(), org.mockito.ArgumentMatchers.anyDouble(), any()); + } + + /** + * When {@link PlayerResetMoneyEvent} is NOT cancelled the balance IS withdrawn + * (regression guard). + */ + @Test + void testCleanLeavingPlayerMoneyNotCancelled() { + pm.cleanLeavingPlayer(world, user, false, island); + verify(vault).withdraw(user, 0D, world); + } + + /** + * When a {@link PlayerResetHealthEvent} is cancelled health must NOT be reset. + */ + @Test + void testCleanLeavingPlayerHealthCancelled() { + cancelEventOfType(PlayerResetHealthEvent.class); + pm.cleanLeavingPlayer(world, user, false, island); + mockedUtil.verify(() -> Util.resetHealth(p), never()); + } + + /** + * When {@link PlayerResetHealthEvent} is NOT cancelled health IS reset + * (regression guard). + */ + @Test + void testCleanLeavingPlayerHealthNotCancelled() { + pm.cleanLeavingPlayer(world, user, false, island); + mockedUtil.verify(() -> Util.resetHealth(p)); + } + + /** + * When a {@link PlayerResetHungerEvent} is cancelled food level must NOT be + * set. + */ + @Test + void testCleanLeavingPlayerHungerCancelled() { + cancelEventOfType(PlayerResetHungerEvent.class); + pm.cleanLeavingPlayer(world, user, false, island); + verify(p, never()).setFoodLevel(20); + } + + /** + * When {@link PlayerResetHungerEvent} is NOT cancelled food level IS reset + * (regression guard). + */ + @Test + void testCleanLeavingPlayerHungerNotCancelled() { + pm.cleanLeavingPlayer(world, user, false, island); + verify(p).setFoodLevel(20); + } + + /** + * When a {@link PlayerResetExpEvent} is cancelled XP must NOT be reset. + */ + @Test + void testCleanLeavingPlayerExpCancelled() { + cancelEventOfType(PlayerResetExpEvent.class); + pm.cleanLeavingPlayer(world, user, false, island); + verify(p, never()).setLevel(0); + verify(p, never()).setExp(0); + verify(p, never()).setTotalExperience(0); + } + + /** + * When {@link PlayerResetExpEvent} is NOT cancelled XP IS reset + * (regression guard). + */ + @Test + void testCleanLeavingPlayerExpNotCancelled() { + pm.cleanLeavingPlayer(world, user, false, island); + verify(p).setLevel(0); + verify(p).setExp(0); + verify(p).setTotalExperience(0); + } + + /** + * Cancelling one event (e.g. ender chest) must not affect other resets + * – only the ender chest clear is skipped; inventory, money, health, hunger + * and XP proceed normally. + */ + @Test + void testCleanLeavingPlayerOnlyEnderChestCancelledOtherActionsStillRun() { + when(iwm.isKickedKeepInventory(any())).thenReturn(false); + cancelEventOfType(PlayerResetEnderChestEvent.class); + pm.cleanLeavingPlayer(world, user, false, island); + // Ender chest NOT cleared + verify(inv, never()).clear(); + // Inventory IS cleared + verify(playerInv).clear(); + // Money IS withdrawn + verify(vault).withdraw(user, 0D, world); + // Health IS reset + mockedUtil.verify(() -> Util.resetHealth(p)); + // Hunger IS reset + verify(p).setFoodLevel(20); + // XP IS reset + verify(p).setTotalExperience(0); + } + }