From d38d74f82ad3298cef88e08da57c740754e87c44 Mon Sep 17 00:00:00 2001 From: nossr50 Date: Sun, 21 Sep 2025 12:19:55 -0700 Subject: [PATCH] new event McMMOModifyBlockDropItemEvent and mcMMO now modifies the drop list in BlockDropItemEvent instead of spawning items Fixes #5214 --- Changelog.txt | 3 + .../items/McMMOModifyBlockDropItemEvent.java | 226 ++++++++++++++++ .../nossr50/listeners/BlockListener.java | 137 +++++----- .../McMMOModifyBlockDropItemEventTest.java | 252 ++++++++++++++++++ 4 files changed, 556 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/gmail/nossr50/events/items/McMMOModifyBlockDropItemEvent.java create mode 100644 src/test/java/com/gmail/nossr50/events/items/McMMOModifyBlockDropItemEventTest.java diff --git a/Changelog.txt b/Changelog.txt index 7bdb531f5..3364ceaec 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,4 +1,7 @@ Version 2.2.042 + mcMMO now listens to BlockDropItemEvent at LOW priority instead of HIGHEST + Bonus drops from mcMMO now simply modify quantity in BlockDropItemEvent + Added McMMOModifyBlockDropItemEvent event, this event is called when mcMMO modifies the quantity of an ItemStack during a BlockDropItemEvent, it is modifiable and cancellable. You can now define custom sounds to be played in sounds.yml (Thank you JeBobs, see notes) Added a cap to how much Blast Mining PVP damage can do to other players diff --git a/src/main/java/com/gmail/nossr50/events/items/McMMOModifyBlockDropItemEvent.java b/src/main/java/com/gmail/nossr50/events/items/McMMOModifyBlockDropItemEvent.java new file mode 100644 index 000000000..d2fdee530 --- /dev/null +++ b/src/main/java/com/gmail/nossr50/events/items/McMMOModifyBlockDropItemEvent.java @@ -0,0 +1,226 @@ +package com.gmail.nossr50.events.items; + +import static java.util.Objects.requireNonNull; + +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.bukkit.event.block.BlockDropItemEvent; +import org.jetbrains.annotations.NotNull; + +/** + * Called when mcMMO is modifying the amount of bonus drops to add to an Item involved in a {@link BlockDropItemEvent}. + *

+ * This event is called before mcMMO has modified the ItemStack quantity on the {@link Item} entity. + *

+ * This event is called once per Item entity that is involved in the {@link BlockDropItemEvent}. + *

+ * This event is called during mcMMO logic on the {@link BlockDropItemEvent}, and can be used to + * modify the quantity that mcMMO will add to the ItemStack. + *

+ * This event is considered cancelled if it is either cancelled directly or if bonus drops are 0 or + * less. + */ +public class McMMOModifyBlockDropItemEvent extends Event implements Cancellable { + private final @NotNull BlockDropItemEvent blockDropItemEvent; + private final int originalBonusAmountToAdd; + private int modifiedItemStackQuantity; + private final @NotNull Item itemThatHasBonusDrops; + private boolean isCancelled = false; + private final int originalItemStackQuantity; + + public McMMOModifyBlockDropItemEvent(@NotNull BlockDropItemEvent blockDropItemEvent, + @NotNull Item itemThatHasBonusDrops, int bonusDropsToAdd) { + super(false); + requireNonNull(blockDropItemEvent, "blockDropItemEvent cannot be null"); + requireNonNull(itemThatHasBonusDrops, "itemThatHasBonusDrops cannot be null"); + if (bonusDropsToAdd <= 0) { + throw new IllegalArgumentException("cannot instantiate a new" + + " McMMOModifyBlockDropItemEvent with a bonusDropsToAdd that is <= 0"); + } + this.blockDropItemEvent = blockDropItemEvent; + this.itemThatHasBonusDrops = itemThatHasBonusDrops; + this.originalItemStackQuantity = itemThatHasBonusDrops.getItemStack().getAmount(); + this.originalBonusAmountToAdd = bonusDropsToAdd; + this.modifiedItemStackQuantity = itemThatHasBonusDrops.getItemStack().getAmount() + + bonusDropsToAdd; + } + + @Override + public boolean isCancelled() { + return isCancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.isCancelled = cancel; + } + + /** + * The original BlockDropItemEvent which caused this event to be fired. + * @return the original BlockDropItemEvent + */ + public @NotNull BlockDropItemEvent getBlockDropItemEvent() { + return blockDropItemEvent; + } + + /** + * The original bonus mcMMO would have added before any modifications to this event from + * other plugins. + * @return the original bonus amount to add + */ + public int getOriginalBonusAmountToAdd() { + return originalBonusAmountToAdd; + } + + /** + * The Item entity that is being modified by this event. + * This item returned by this call should not be modified, it is provided as a convenience. + * @return the Item entity that is having bonus drops added to it. + */ + public @NotNull Item getItem() { + return itemThatHasBonusDrops; + } + + + /** + * The modified ItemStack quantity that will be set on the Item entity if this event is not + * cancelled. + * + * @return the modified ItemStack quantity that will be set on the Item entity + */ + public int getModifiedItemStackQuantity() { + return modifiedItemStackQuantity; + } + + /** + * The original ItemStack quantity of the Item entity before any modifications from this event. + * This is a reflection of the state of the Item when mcMMO fired this event. + * It is possible it has modified since then, so do not rely on this value to be the current. + * @return the original ItemStack quantity of the Item entity before any modifications from this event + */ + public int getOriginalItemStackQuantity() { + return originalItemStackQuantity; + } + + /** + * The amount of bonus that will be added to the ItemStack quantity if this event is not + * cancelled. + * @return the amount of bonus that will be added to the ItemStack quantity + */ + public int getBonusAmountToAdd() { + return Math.max(0, modifiedItemStackQuantity - originalItemStackQuantity); + } + + /** + * Set the amount of bonus that will be added to the ItemStack quantity if this event is not + * cancelled. + * @param bonus the amount of bonus that will be added to the ItemStack quantity + * @throws IllegalArgumentException if bonus is less than 0 + */ + public void setBonusAmountToAdd(int bonus) { + if (bonus < 0) throw new IllegalArgumentException("bonus must be >= 0"); + this.modifiedItemStackQuantity = originalItemStackQuantity + bonus; + } + + /** + * Set the modified ItemStack quantity that will be set on the Item entity if this event is not + * cancelled. This CANNOT be lower than the original quantity of the ItemStack. + * @param modifiedItemStackQuantity the modified ItemStack quantity that will be set on the Item entity + * @throws IllegalArgumentException if modifiedItemStackQuantity is less than originalItemStackQuantity + */ + public void setModifiedItemStackQuantity(int modifiedItemStackQuantity) { + if (modifiedItemStackQuantity < originalItemStackQuantity) { + throw new IllegalArgumentException( + "modifiedItemStackQuantity cannot be less than the originalItemStackQuantity"); + } + this.modifiedItemStackQuantity = modifiedItemStackQuantity; + } + + public boolean isEffectivelyNoBonus() { + return modifiedItemStackQuantity == originalItemStackQuantity; + } + + /** + * Delegate method for {@link BlockDropItemEvent}, gets the Player that is breaking the block + * involved in this event. + * + * @return The Player that is breaking the block involved in this event + */ + public @NotNull Player getPlayer() { + return blockDropItemEvent.getPlayer(); + } + + /** + * Delegate method for {@link BlockDropItemEvent#getBlock()}. + * Gets the Block involved in this event. + * + * @return the Block involved in this event + */ + public @NotNull Block getBlock() { + return blockDropItemEvent.getBlock(); + } + + /** + * Delegate method for {@link BlockDropItemEvent#getBlockState()}. + * Gets the BlockState of the block involved in this event. + * + * @return the BlockState of the block involved in this event + */ + public @NotNull BlockState getBlockState() { + return blockDropItemEvent.getBlockState(); + } + + private static final @NotNull HandlerList handlers = new HandlerList(); + + @Override + public @NotNull HandlerList getHandlers() { + return handlers; + } + + public static @NotNull HandlerList getHandlerList() { + return handlers; + } + + @Override + public @NotNull String toString() { + return "McMMOModifyBlockDropItemEvent{" + + "blockDropItemEvent=" + blockDropItemEvent + + ", originalBonusAmountToAdd=" + originalBonusAmountToAdd + + ", modifiedItemStackQuantity=" + modifiedItemStackQuantity + + ", itemThatHasBonusDrops=" + itemThatHasBonusDrops + + ", isCancelled=" + isCancelled + + ", originalItemStackQuantity=" + originalItemStackQuantity + + '}'; + } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof McMMOModifyBlockDropItemEvent that)) { + return false; + } + + return originalBonusAmountToAdd == that.originalBonusAmountToAdd + && modifiedItemStackQuantity == that.modifiedItemStackQuantity + && isCancelled == that.isCancelled + && originalItemStackQuantity == that.originalItemStackQuantity + && blockDropItemEvent.equals(that.blockDropItemEvent) + && itemThatHasBonusDrops.equals( + that.itemThatHasBonusDrops); + } + + @Override + public int hashCode() { + int result = blockDropItemEvent.hashCode(); + result = 31 * result + originalBonusAmountToAdd; + result = 31 * result + modifiedItemStackQuantity; + result = 31 * result + itemThatHasBonusDrops.hashCode(); + result = 31 * result + Boolean.hashCode(isCancelled); + result = 31 * result + originalItemStackQuantity; + return result; + } +} diff --git a/src/main/java/com/gmail/nossr50/listeners/BlockListener.java b/src/main/java/com/gmail/nossr50/listeners/BlockListener.java index 2b7df50ae..4c20170e5 100644 --- a/src/main/java/com/gmail/nossr50/listeners/BlockListener.java +++ b/src/main/java/com/gmail/nossr50/listeners/BlockListener.java @@ -1,5 +1,6 @@ package com.gmail.nossr50.listeners; +import static com.gmail.nossr50.util.MetadataConstants.METADATA_KEY_BONUS_DROPS; import static com.gmail.nossr50.util.Misc.getBlockCenter; import com.gmail.nossr50.api.ItemSpawnReason; @@ -14,6 +15,7 @@ import com.gmail.nossr50.datatypes.skills.ToolType; import com.gmail.nossr50.events.fake.FakeBlockBreakEvent; import com.gmail.nossr50.events.fake.FakeBlockDamageEvent; import com.gmail.nossr50.events.fake.FakeEvent; +import com.gmail.nossr50.events.items.McMMOModifyBlockDropItemEvent; import com.gmail.nossr50.mcMMO; import com.gmail.nossr50.skills.alchemy.Alchemy; import com.gmail.nossr50.skills.excavation.ExcavationManager; @@ -35,6 +37,8 @@ import com.gmail.nossr50.util.sounds.SoundType; import com.gmail.nossr50.worldguard.WorldGuardManager; import com.gmail.nossr50.worldguard.WorldGuardUtils; import java.util.HashSet; +import java.util.List; +import java.util.Set; import org.bukkit.ChatColor; import org.bukkit.GameMode; import org.bukkit.Location; @@ -62,6 +66,7 @@ import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.EntityBlockFormEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.metadata.MetadataValue; public class BlockListener implements Listener { private final mcMMO plugin; @@ -70,88 +75,96 @@ public class BlockListener implements Listener { this.plugin = plugin; } - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false) + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false) public void onBlockDropItemEvent(BlockDropItemEvent event) { //Make sure we clean up metadata on these blocks + final Block block = event.getBlock(); if (event.isCancelled()) { - if (event.getBlock().hasMetadata(MetadataConstants.METADATA_KEY_BONUS_DROPS)) { - event.getBlock().removeMetadata(MetadataConstants.METADATA_KEY_BONUS_DROPS, plugin); + if (block.hasMetadata(METADATA_KEY_BONUS_DROPS)) { + block.removeMetadata(METADATA_KEY_BONUS_DROPS, plugin); } return; } - int tileEntityTolerance = 1; + try { + int tileEntityTolerance = 1; - // beetroot hotfix, potentially other plants may need this fix - if (event.getBlock().getType() == Material.BEETROOTS) { - tileEntityTolerance = 2; - } - - //Track how many "things" are being dropped - HashSet uniqueMaterials = new HashSet<>(); - boolean dontRewardTE = false; //If we suspect TEs are mixed in with other things don't reward bonus drops for anything that isn't a block - int blockCount = 0; - - for (Item item : event.getItems()) { - //Track unique materials - uniqueMaterials.add(item.getItemStack().getType()); - - //Count blocks as a second failsafe - if (item.getItemStack().getType().isBlock()) { - blockCount++; + // beetroot hotfix, potentially other plants may need this fix + final Material blockType = block.getType(); + if (blockType == Material.BEETROOTS) { + tileEntityTolerance = 2; } - } - if (uniqueMaterials.size() > tileEntityTolerance) { - //Too many things are dropping, assume tile entities might be duped - //Technically this would also prevent something like coal from being bonus dropped if you placed a TE above a coal ore when mining it but that's pretty edge case and this is a good solution for now - dontRewardTE = true; - } + //Track how many "things" are being dropped + final Set uniqueMaterials = new HashSet<>(); + boolean dontRewardTE = false; //If we suspect TEs are mixed in with other things don't reward bonus drops for anything that isn't a block + int blockCount = 0; - //If there are more than one block in the item list we can't really trust it and will back out of rewarding bonus drops - if (blockCount <= 1) { - for (Item item : event.getItems()) { - ItemStack is = new ItemStack(item.getItemStack()); + final List eventItems = event.getItems(); + for (Item item : eventItems) { + //Track unique materials + uniqueMaterials.add(item.getItemStack().getType()); - if (is.getAmount() <= 0) { - continue; + //Count blocks as a second failsafe + if (item.getItemStack().getType().isBlock()) { + blockCount++; } + } - //TODO: Ignore this abomination its rewritten in 2.2 - if (!mcMMO.p.getGeneralConfig() - .getDoubleDropsEnabled(PrimarySkillType.MINING, is.getType()) - && !mcMMO.p.getGeneralConfig() - .getDoubleDropsEnabled(PrimarySkillType.HERBALISM, is.getType()) - && !mcMMO.p.getGeneralConfig() - .getDoubleDropsEnabled(PrimarySkillType.WOODCUTTING, is.getType())) { - continue; - } + if (uniqueMaterials.size() > tileEntityTolerance) { + // Too many things are dropping, assume tile entities might be duped + // Technically this would also prevent something like coal from being bonus dropped + // if you placed a TE above a coal ore when mining it but that's pretty edge case + // and this is a good solution for now + dontRewardTE = true; + } - //If we suspect TEs might be duped only reward block - if (dontRewardTE) { - if (!is.getType().isBlock()) { - continue; - } - } + //If there are more than one block in the item list we can't really trust it + // and will back out of rewarding bonus drops + if (!block.getMetadata(METADATA_KEY_BONUS_DROPS).isEmpty()) { + final MetadataValue bonusDropMeta = block + .getMetadata(METADATA_KEY_BONUS_DROPS).get(0); + if (blockCount <= 1) { + for (final Item item : eventItems) { + final ItemStack eventItemStack = item.getItemStack(); + int originalAmount = eventItemStack.getAmount(); - if (event.getBlock().getMetadata(MetadataConstants.METADATA_KEY_BONUS_DROPS).size() - > 0) { - final BonusDropMeta bonusDropMeta = - (BonusDropMeta) event.getBlock().getMetadata( - MetadataConstants.METADATA_KEY_BONUS_DROPS).get(0); - int bonusCount = bonusDropMeta.asInt(); - final Location centeredLocation = getBlockCenter(event.getBlock()); - for (int i = 0; i < bonusCount; i++) { + if (eventItemStack.getAmount() <= 0) { + continue; + } - ItemUtils.spawnItemNaturally(event.getPlayer(), - centeredLocation, is, ItemSpawnReason.BONUS_DROPS); + final Material itemType = eventItemStack.getType(); + if (!mcMMO.p.getGeneralConfig() + .getDoubleDropsEnabled(PrimarySkillType.MINING, itemType) + && !mcMMO.p.getGeneralConfig() + .getDoubleDropsEnabled(PrimarySkillType.HERBALISM, itemType) + && !mcMMO.p.getGeneralConfig() + .getDoubleDropsEnabled(PrimarySkillType.WOODCUTTING, itemType)) { + continue; + } + + //If we suspect TEs might be duped only reward block + if (dontRewardTE) { + if (!itemType.isBlock()) { + continue; + } + } + + int amountToAddFromBonus = bonusDropMeta.asInt(); + final McMMOModifyBlockDropItemEvent modifyBlockDropItemEvent + = new McMMOModifyBlockDropItemEvent(event, item, amountToAddFromBonus); + plugin.getServer().getPluginManager().callEvent(modifyBlockDropItemEvent); + if (!modifyBlockDropItemEvent.isCancelled() + && modifyBlockDropItemEvent.getModifiedItemStackQuantity() > originalAmount) { + eventItemStack.setAmount(modifyBlockDropItemEvent.getModifiedItemStackQuantity()); + } } } } - } - - if (event.getBlock().hasMetadata(MetadataConstants.METADATA_KEY_BONUS_DROPS)) { - event.getBlock().removeMetadata(MetadataConstants.METADATA_KEY_BONUS_DROPS, plugin); + } finally { + if (block.hasMetadata(METADATA_KEY_BONUS_DROPS)) { + block.removeMetadata(METADATA_KEY_BONUS_DROPS, plugin); + } } } diff --git a/src/test/java/com/gmail/nossr50/events/items/McMMOModifyBlockDropItemEventTest.java b/src/test/java/com/gmail/nossr50/events/items/McMMOModifyBlockDropItemEventTest.java new file mode 100644 index 000000000..e0d0814cd --- /dev/null +++ b/src/test/java/com/gmail/nossr50/events/items/McMMOModifyBlockDropItemEventTest.java @@ -0,0 +1,252 @@ +package com.gmail.nossr50.events.items; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.block.BlockDropItemEvent; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class McMMOModifyBlockDropItemEventTest { + private BlockDropItemEvent blockDropItemEvent; + private Item itemEntity; + private ItemStack itemStack; + private Player player; + private Block block; + private BlockState blockState; + + @BeforeEach + void setUp() { + // Mocks for delegate passthroughs + player = mock(Player.class, RETURNS_DEEP_STUBS); + block = mock(Block.class, RETURNS_DEEP_STUBS); + blockState = mock(BlockState.class, RETURNS_DEEP_STUBS); + + // Primary Bukkit event mock + blockDropItemEvent = mock(BlockDropItemEvent.class, RETURNS_DEEP_STUBS); + when(blockDropItemEvent.getPlayer()).thenReturn(player); + when(blockDropItemEvent.getBlock()).thenReturn(block); + when(blockDropItemEvent.getBlockState()).thenReturn(blockState); + + // Item + ItemStack mock + itemStack = mock(ItemStack.class); + when(itemStack.getAmount()).thenReturn(3); // original count + itemEntity = mock(Item.class); + when(itemEntity.getItemStack()).thenReturn(itemStack); + } + + private McMMOModifyBlockDropItemEvent newEvent(int bonus) { + return new McMMOModifyBlockDropItemEvent(blockDropItemEvent, itemEntity, bonus); + } + + @Nested + @DisplayName("Constructor & validation") + class ConstructorValidation { + + @Test + void ctorNullEventThrows() { + assertThrows(NullPointerException.class, + () -> new McMMOModifyBlockDropItemEvent(null, itemEntity, 1)); + } + + @Test + void ctorNullItemThrows() { + assertThrows(NullPointerException.class, + () -> new McMMOModifyBlockDropItemEvent(blockDropItemEvent, null, 1)); + } + + @Test + void ctorZeroBonusThrows() { + assertThrows(IllegalArgumentException.class, + () -> new McMMOModifyBlockDropItemEvent(blockDropItemEvent, itemEntity, 0)); + } + + @Test + void ctorNegativeBonusThrows() { + assertThrows(IllegalArgumentException.class, + () -> new McMMOModifyBlockDropItemEvent(blockDropItemEvent, itemEntity, -5)); + } + + @Test + void ctorSetsOriginalsAndModifiedCorrectly() { + // original amount = 3, bonus = 2 + McMMOModifyBlockDropItemEvent ev = newEvent(2); + assertEquals(3, ev.getOriginalItemStackQuantity()); + assertEquals(2, ev.getOriginalBonusAmountToAdd()); + assertEquals(5, ev.getModifiedItemStackQuantity()); + assertFalse(ev.isCancelled()); + assertFalse(ev.isEffectivelyNoBonus()); + } + } + + @Nested + @DisplayName("Cancellable contract") + class Cancellation { + @Test + void cancelAndUncancel() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); + assertFalse(ev.isCancelled()); + ev.setCancelled(true); + assertTrue(ev.isCancelled()); + ev.setCancelled(false); + assertFalse(ev.isCancelled()); + } + } + + @Nested + @DisplayName("Delta & absolute quantity semantics") + class DeltaAndAbsolute { + + @Test + void getBonusAmountToAddReflectsDifferenceFromOriginal() { + // original 3, bonus 4 => modified 7 + McMMOModifyBlockDropItemEvent ev = newEvent(4); + assertEquals(4, ev.getBonusAmountToAdd()); + assertEquals(7, ev.getModifiedItemStackQuantity()); + } + + @Test + void setBonusAmountToAddUpdatesModifiedQuantity() { + McMMOModifyBlockDropItemEvent ev = newEvent(2); // original 3 -> modified 5 + ev.setBonusAmountToAdd(10); // new modified should be 13 + assertEquals(13, ev.getModifiedItemStackQuantity()); + assertEquals(10, ev.getBonusAmountToAdd()); + assertFalse(ev.isEffectivelyNoBonus()); + } + + @Test + void setBonusAmountToAddNegativeThrows() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); + assertThrows(IllegalArgumentException.class, () -> ev.setBonusAmountToAdd(-1)); + } + + @Test + void setModifiedItemStackQuantityEqualToOriginalIsNoBonus() { + McMMOModifyBlockDropItemEvent ev = newEvent(2); // 3 -> 5 + ev.setModifiedItemStackQuantity(3); // back to original => no bonus + assertEquals(3, ev.getModifiedItemStackQuantity()); + assertEquals(0, ev.getBonusAmountToAdd()); + assertTrue(ev.isEffectivelyNoBonus()); + } + + @Test + void setModifiedItemStackQuantityLessThanOriginalThrows() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); + assertThrows(IllegalArgumentException.class, () -> ev.setModifiedItemStackQuantity(2)); // original is 3 + } + + @Test + void setModifiedItemStackQuantityGreaterThanOriginalUpdatesBonus() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); // original 3 -> modified 4 + ev.setModifiedItemStackQuantity(12); + assertEquals(12, ev.getModifiedItemStackQuantity()); + assertEquals(9, ev.getBonusAmountToAdd()); // 12 - 3 + assertFalse(ev.isEffectivelyNoBonus()); + } + } + + @Nested + @DisplayName("Delegate passthroughs") + class Delegates { + + @Test + void getPlayerPassthrough() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); + assertSame(player, ev.getPlayer()); + verify(blockDropItemEvent, atLeastOnce()).getPlayer(); + } + + @Test + void getBlockPassthrough() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); + assertSame(block, ev.getBlock()); + verify(blockDropItemEvent, atLeastOnce()).getBlock(); + } + + @Test + void getBlockStatePassthrough() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); + assertSame(blockState, ev.getBlockState()); + verify(blockDropItemEvent, atLeastOnce()).getBlockState(); + } + + @Test + void getItemReturnsOriginalItemEntity() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); + assertSame(itemEntity, ev.getItem()); + } + } + + @Nested + @DisplayName("HandlerList plumbing") + class HandlerListTests { + @Test + void handlerList_isNonNull_andShared() { + McMMOModifyBlockDropItemEvent ev = newEvent(1); + HandlerList fromInstance = ev.getHandlers(); + HandlerList fromStatic = McMMOModifyBlockDropItemEvent.getHandlerList(); + assertNotNull(fromInstance); + assertNotNull(fromStatic); + // Bukkit convention: same static instance + assertSame(fromStatic, fromInstance); + } + } + + @Nested + @DisplayName("Object contracts") + class ObjectContracts { + + @Test + void toStringContainsKeyFields() { + McMMOModifyBlockDropItemEvent ev = newEvent(2); + String s = ev.toString(); + assertNotNull(s); + assertTrue(s.contains("originalBonusAmountToAdd=2")); + assertTrue(s.contains("modifiedItemStackQuantity=5")); + } + + @Test + void equalsAndHashCodeReflectState() { + // Same inputs => equal (mocks are same instances) + McMMOModifyBlockDropItemEvent a = newEvent(2); + McMMOModifyBlockDropItemEvent b = newEvent(2); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + + // Change cancellation and modified quantity => not equal + McMMOModifyBlockDropItemEvent c = newEvent(2); + c.setCancelled(true); + assertNotEquals(a, c); + + McMMOModifyBlockDropItemEvent d = newEvent(2); + d.setModifiedItemStackQuantity(99); + assertNotEquals(a, d); + + // Different underlying mocks => not equal + BlockDropItemEvent otherEvent = mock(BlockDropItemEvent.class, RETURNS_DEEP_STUBS); + when(otherEvent.getPlayer()).thenReturn(player); + when(otherEvent.getBlock()).thenReturn(block); + when(otherEvent.getBlockState()).thenReturn(blockState); + + ItemStack otherStack = mock(ItemStack.class); + when(otherStack.getAmount()).thenReturn(3); + Item otherItem = mock(Item.class); + when(otherItem.getItemStack()).thenReturn(otherStack); + + McMMOModifyBlockDropItemEvent e = new McMMOModifyBlockDropItemEvent(otherEvent, otherItem, 2); + assertNotEquals(a, e); + } + } +} \ No newline at end of file