diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java index d481efe44..43bc557d1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java @@ -3,6 +3,7 @@ import java.nio.charset.StandardCharsets; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; +import java.util.random.RandomGenerator; import java.util.function.Consumer; import org.bukkit.Bukkit; @@ -38,37 +39,79 @@ private PlayerManager() { } public boolean damageItemInHand(Player player, int damage) { + if (player == null || player.getInventory() == null) { + return false; + } ItemStack itemInHand = player.getInventory().getItemInMainHand(); + if (itemInHand == null || itemInHand.getType() == Material.AIR) { + return false; + } ItemMeta meta = itemInHand.getItemMeta(); + if (!(meta instanceof Damageable)) { + return false; + } boolean isUnbreakable = false; try { isUnbreakable = meta.isUnbreakable(); } catch (NoSuchMethodError e) { // Older versions don't have isUnbreakable(), ignore safely } - if (meta instanceof Damageable && !isUnbreakable) { + if (!isUnbreakable) { Damageable dMeta = (Damageable) meta; - int level = itemInHand.getEnchantmentLevel(MiscUtils.getInstance().getEnchant("UNBREAKING", "DURABILITY")); - int chance = (100 / (level + 1)); - int addedDamage = 0; - for (int i = 0; i < damage; i++) { - if (chance == 100 || ThreadLocalRandom.current().nextInt(100) < chance) { - addedDamage++; - } + if (damage <= 0) { + return true; } + int level = itemInHand.getEnchantmentLevel(MiscUtils.getInstance().getEnchant("UNBREAKING", "DURABILITY")); + int chance = (int) (100L / (Math.max(0, (long) level) + 1)); + long currentDamage = Math.max(0L, dMeta.getDamage()); + long hitsToBreak = Math.max(1L, (long) itemInHand.getType().getMaxDurability() - currentDamage); + int addedDamage = sampleDamage(damage, chance, hitsToBreak, ThreadLocalRandom.current()); if (addedDamage > 0) { - dMeta.setDamage(dMeta.getDamage() + addedDamage); - itemInHand.setItemMeta(dMeta); - if (dMeta.getDamage() > (itemInHand.getType().getMaxDurability())) { + if (addedDamage >= hitsToBreak) { player.getInventory().setItemInMainHand(new ItemStack(Material.AIR)); return false; } + dMeta.setDamage((int) (currentDamage + addedDamage)); + itemInHand.setItemMeta(dMeta); } return true; } return false; } + // The cap is the number of successful hits needed to remove the item. + static int sampleDamage(int attempts, int chance, long cap, RandomGenerator random) { + if (attempts <= 0 || chance <= 0) { + return 0; + } + int limit = (int) Math.min(cap, (long) attempts); + if (chance >= 100) { + return limit; + } + int hits = 0; + if (attempts <= 256) { + for (int i = 0; i < attempts && hits < limit; i++) { + if (random.nextInt(100) < chance) { + hits++; + } + } + return hits; + } + // Geometric gaps count failures before each success. Work is bounded by the + // item's remaining durability rather than the attacker supplied attempts. + double logFailure = Math.log1p(-chance / 100.0); + long remaining = attempts; + while (hits < limit && remaining > 0) { + long failures = (long) Math.floor(Math.log1p(-random.nextDouble()) / logFailure); + if (failures >= remaining) { + break; + } + remaining -= failures + 1; + hits++; + } + return hits; + } + /** * @deprecated Use {@link UuidLookup#getPlayerName(AdvancedCoreUser, String)}. */ diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/misc/PlayerManagerDamageItemTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/misc/PlayerManagerDamageItemTest.java new file mode 100644 index 000000000..a43857c18 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/misc/PlayerManagerDamageItemTest.java @@ -0,0 +1,133 @@ +package com.bencodez.advancedcore.api.misc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTimeout; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.Random; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.Damageable; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class PlayerManagerDamageItemTest { + private final PlayerManager manager = PlayerManager.getInstance(); + + @Test + void missingItemAndMetaAreSafe() { + assertFalse(manager.damageItemInHand(null, 1)); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + assertFalse(manager.damageItemInHand(player, 1)); + ItemStack item = mock(ItemStack.class); + when(inventory.getItemInMainHand()).thenReturn(item); + when(item.getType()).thenReturn(Material.AIR); + assertFalse(manager.damageItemInHand(player, 1)); + when(item.getType()).thenReturn(Material.DIAMOND_SWORD); + assertFalse(manager.damageItemInHand(player, 1)); + } + + @Test + void nonPositiveDamageAndUnbreakableItemDoNotChangeDurability() { + Fixture fixture = new Fixture(3); + assertTrue(manager.damageItemInHand(fixture.player, 0)); + assertTrue(manager.damageItemInHand(fixture.player, -100)); + verify(fixture.meta, never()).setDamage(anyInt()); + when(fixture.meta.isUnbreakable()).thenReturn(true); + assertFalse(manager.damageItemInHand(fixture.player, 100)); + verify(fixture.item, never()).setItemMeta(any()); + } + + @Test + void removesItemWhenDamageReachesMaterialLimit() { + Fixture fixture = new Fixture(Material.LEGACY_WOOD_SWORD.getMaxDurability() - 1); + try (MockedStatic misc = mockStatic(MiscUtils.class)) { + misc.when(MiscUtils::getInstance).thenReturn(mock(MiscUtils.class)); + when(fixture.item.getEnchantmentLevel(null)).thenReturn(0); + assertFalse(manager.damageItemInHand(fixture.player, 1)); + verify(fixture.inventory).setItemInMainHand(argThat(item -> item.getType() == Material.AIR)); + verify(fixture.meta, never()).setDamage(anyInt()); + } + } + + @Test + void appliesDamageAndRemovesItemPastMaximum() { + Fixture fixture = new Fixture(56); + try (MockedStatic misc = mockStatic(MiscUtils.class)) { + MiscUtils utils = mock(MiscUtils.class); + misc.when(MiscUtils::getInstance).thenReturn(utils); + when(fixture.item.getEnchantmentLevel(null)).thenReturn(0); + assertTrue(manager.damageItemInHand(fixture.player, 2)); + verify(fixture.meta).setDamage(58); + verify(fixture.item).setItemMeta(fixture.meta); + Fixture breaking = new Fixture(59); + assertFalse(manager.damageItemInHand(breaking.player, 1)); + verify(breaking.inventory).setItemInMainHand(any(ItemStack.class)); + Fixture overflow = new Fixture(Integer.MAX_VALUE); + assertFalse(manager.damageItemInHand(overflow.player, Integer.MAX_VALUE)); + verify(overflow.inventory).setItemInMainHand(any(ItemStack.class)); + } + } + + @Test + void extremeDamageFinishesPromptlyWithUnbreaking() { + Fixture fixture = new Fixture(0); + try (MockedStatic misc = mockStatic(MiscUtils.class)) { + MiscUtils utils = mock(MiscUtils.class); + misc.when(MiscUtils::getInstance).thenReturn(utils); + when(fixture.item.getEnchantmentLevel(null)).thenReturn(1); + assertTimeout(Duration.ofSeconds(2), () -> + assertFalse(manager.damageItemInHand(fixture.player, Integer.MAX_VALUE))); + verify(fixture.inventory).setItemInMainHand(any(ItemStack.class)); + } + } + + @Test + void boundedSamplerRetainsBinomialMoments() { + Random random = new Random(47291); + int count = 20_000; + double sum = 0; + double squared = 0; + for (int i = 0; i < count; i++) { + int hits = PlayerManager.sampleDamage(1_000, 50, 1_001, random); + sum += hits; + squared += (double) hits * hits; + } + double mean = sum / count; + double variance = squared / count - mean * mean; + assertEquals(500, mean, 1); + assertEquals(250, variance, 10); + assertEquals(0, PlayerManager.sampleDamage(100, 0, 10, random)); + assertEquals(10, PlayerManager.sampleDamage(Integer.MAX_VALUE, 100, 10, random)); + } + + private static final class Fixture { + final Player player = mock(Player.class); + final PlayerInventory inventory = mock(PlayerInventory.class); + final ItemStack item = mock(ItemStack.class); + final Damageable meta = mock(Damageable.class); + + Fixture(int currentDamage) { + when(player.getInventory()).thenReturn(inventory); + when(inventory.getItemInMainHand()).thenReturn(item); + when(item.getType()).thenReturn(Material.LEGACY_WOOD_SWORD); + when(item.getItemMeta()).thenReturn(meta); + when(meta.getDamage()).thenReturn(currentDamage); + } + } +}