diff --git a/src/main/java/net/tfminecraft/magic/Cache.java b/src/main/java/net/tfminecraft/magic/Cache.java index ba48a9b..a5557ef 100644 --- a/src/main/java/net/tfminecraft/magic/Cache.java +++ b/src/main/java/net/tfminecraft/magic/Cache.java @@ -41,6 +41,9 @@ public final class Cache { public static double castDriftMin = 0.01; /** Seconds between repeat chat lines explaining why a weapon refused a spell. */ public static long refuseChatMillis = 30000L; + public static final int DEFAULT_CAST_WEAR = 1; + /** Vanilla durability a spent cast takes from the held mage weapon. 0 turns cast wear off. */ + public static int castWear = DEFAULT_CAST_WEAR; /** * Extra MythicLib triggers treated as a cast, from {@code runes.keybinds}. * Names are uppercase. {@code CAST} and {@code API} are always included in the check. diff --git a/src/main/java/net/tfminecraft/magic/Magic.java b/src/main/java/net/tfminecraft/magic/Magic.java index 002584a..d3f6f90 100644 --- a/src/main/java/net/tfminecraft/magic/Magic.java +++ b/src/main/java/net/tfminecraft/magic/Magic.java @@ -31,6 +31,7 @@ import net.tfminecraft.magic.listener.ResonanceCastListener; import net.tfminecraft.magic.listener.SacrificeListener; import net.tfminecraft.magic.listener.SealedArtifactListener; +import net.tfminecraft.magic.listener.WeaponWearListener; import net.tfminecraft.magic.loader.ChargesLoader; import net.tfminecraft.magic.loader.ConfigLoader; import net.tfminecraft.magic.loader.ElementsLoader; @@ -120,6 +121,7 @@ public void onEnable() { getServer().getPluginManager().registerEvents(new GearRefreshListener(), this); if (isMmoStackPresent()) { getServer().getPluginManager().registerEvents(new ResonanceCastListener(), this); + getServer().getPluginManager().registerEvents(new WeaponWearListener(), this); getServer().getPluginManager().registerEvents(new CastDriftListener(), this); getServer().getPluginManager().registerEvents(new GearAlignmentListener(), this); } diff --git a/src/main/java/net/tfminecraft/magic/gear/WeaponWear.java b/src/main/java/net/tfminecraft/magic/gear/WeaponWear.java new file mode 100644 index 0000000..d1c43b5 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/WeaponWear.java @@ -0,0 +1,45 @@ +package net.tfminecraft.magic.gear; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; + +/** + * Vanilla durability on mage weapons. Spent casts and wand shots wear it, but the last + * point is never spent: MMOItems deletes a weapon whose vanilla durability runs out. A + * weapon on its last point is worn out and refuses spells and shots until it is repaired. + */ +public final class WeaponWear { + + private WeaponWear() {} + + /** Durability left, or -1 when the stack has no vanilla durability that can wear. */ + public static int remaining(ItemStack stack) { + ItemMeta meta = stack == null ? null : stack.getItemMeta(); + if (!(meta instanceof Damageable damageable) || meta.isUnbreakable()) { + return -1; + } + int max = damageable.hasMaxDamage() ? damageable.getMaxDamage() : stack.getType().getMaxDurability(); + if (max <= 0) { + return -1; + } + return Math.max(0, max - damageable.getDamage()); + } + + /** True when only the last point, or none, is left. */ + public static boolean isWorn(ItemStack stack) { + int left = remaining(stack); + return left >= 0 && left <= 1; + } + + /** Spends up to {@code points} durability, stopping at the last point. */ + public static void wear(ItemStack stack, int points) { + int left = remaining(stack); + if (points <= 0 || left <= 1) { + return; + } + Damageable meta = (Damageable) stack.getItemMeta(); + meta.setDamage(meta.getDamage() + Math.min(points, left - 1)); + stack.setItemMeta(meta); + } +} diff --git a/src/main/java/net/tfminecraft/magic/listener/ResonanceCastListener.java b/src/main/java/net/tfminecraft/magic/listener/ResonanceCastListener.java index b9f2717..8ab3b9b 100644 --- a/src/main/java/net/tfminecraft/magic/listener/ResonanceCastListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/ResonanceCastListener.java @@ -4,19 +4,17 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; +import org.bukkit.GameMode; import org.bukkit.Sound; import org.bukkit.SoundCategory; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; -import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent; -import io.lumine.mythic.lib.api.item.NBTItem; import io.lumine.mythic.lib.skill.Skill; -import net.Indyuce.mmoitems.api.interaction.util.DurabilityItem; import net.tfminecraft.magic.util.MagicText; import net.tfminecraft.magic.Cache; import net.tfminecraft.magic.Magic; @@ -28,6 +26,7 @@ import net.tfminecraft.magic.gear.GearProvenance; import net.tfminecraft.magic.gear.WeaponRequirement; import net.tfminecraft.magic.gear.WeaponRift; +import net.tfminecraft.magic.gear.WeaponWear; import net.tfminecraft.magic.integration.SkillIdResolver; import net.tfminecraft.magic.model.ElementDef; import net.tfminecraft.magic.registry.ElementRegistry; @@ -50,8 +49,9 @@ * is rolled only once refusal and overload have passed, and a failed roll spends mana * and cooldown. * - *

Every spent cast (success, overload whiff, or rift whiff) also costs one MMOItems - * durability on the held weapon. + *

Every spent cast (success, overload whiff, or rift whiff) also costs + * {@code cast.wear_per_cast} vanilla durability on the held weapon, never the last point. + * A weapon on its last point is worn out: it refuses every spell, for free, until repaired. */ public final class ResonanceCastListener implements Listener { @@ -91,6 +91,11 @@ public void onPlayerCastSkill(PlayerCastSkillEvent event) { broken(player, weapon, elementId); return; } + if (WeaponWear.isWorn(weapon)) { + event.setCancelled(true); + worn(player, weapon, elementId); + return; + } if (refuse(player, weapon, elementId, spellTier)) { event.setCancelled(true); return; @@ -114,16 +119,11 @@ public void onPlayerCastSkill(PlayerCastSkillEvent event) { } private static void wearWeapon(Player player, HeldSlot slot, ItemStack weapon) { - NBTItem nbt = NBTItem.get(weapon); - if (nbt.getInteger("MMOITEMS_MAX_DURABILITY") <= 0) { + if (Cache.castWear <= 0 || player.getGameMode() == GameMode.CREATIVE) { return; } - DurabilityItem durability = DurabilityItem.from(player, nbt, - slot == HeldSlot.MAIN_HAND ? EquipmentSlot.HAND : EquipmentSlot.OFF_HAND); - if (durability == null) { - return; - } - GearHand.setHeld(player, slot, durability.decreaseDurability(1).toItem()); + WeaponWear.wear(weapon, Cache.castWear); + GearHand.setHeld(player, slot, weapon); } /** @return true when this spell cannot fire on this weapon for this caster */ @@ -197,6 +197,20 @@ private static void broken(Player player, ItemStack weapon, String elementId) { } } + /** A worn-out weapon refuses everything, so nothing is spent. Shares the refusal rate limiter. */ + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + private static void worn(Player player, ItemStack weapon, String elementId) { + player.sendTitle( + Messages.get("cast.worn_title"), + Messages.get("cast.worn_sub"), + 0, 25, 10); + player.playSound(player.getLocation(), Sound.BLOCK_BEACON_DEACTIVATE, SoundCategory.PLAYERS, 0.7f, 0.6f); + if (claimChat(player, weapon, elementId)) { + player.sendMessage(Messages.get("cast.worn_chat")); + } + } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") private static void whiff(Player player, int rift) { diff --git a/src/main/java/net/tfminecraft/magic/listener/WeaponWearListener.java b/src/main/java/net/tfminecraft/magic/listener/WeaponWearListener.java new file mode 100644 index 0000000..a859118 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/listener/WeaponWearListener.java @@ -0,0 +1,56 @@ +package net.tfminecraft.magic.listener; + +import org.bukkit.Sound; +import org.bukkit.SoundCategory; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerItemDamageEvent; +import org.bukkit.inventory.ItemStack; + +import net.Indyuce.mmoitems.api.event.item.UntargetedWeaponUseEvent; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.gear.GearProvenance; +import net.tfminecraft.magic.gear.WeaponWear; + +/** + * Keeps mage weapon wear to spells and wand shots. + * + *

An ArmourShop skin tags the weapon as an ItemsAdder item, and ItemsAdder then wears it + * on every melee hit through a {@link PlayerItemDamageEvent}. Vanilla never damages a stick + * or paper base, so on those that event is cancelled. A sword base keeps its vanilla wear, + * but wear that would reach the last point is cancelled outright: ItemsAdder only checks + * whether its event was cancelled and then applies its own amount, so lowering the amount + * would not protect the weapon. + * + *

MMOItems wears a wand by one per shot and deletes it when the durability runs out, so + * a worn-out wand refuses the shot instead. + */ +public final class WeaponWearListener implements Listener { + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onItemDamage(PlayerItemDamageEvent event) { + ItemStack item = event.getItem(); + if (!GearProvenance.isGear(item)) { + return; + } + if (item.getType().getMaxDurability() <= 0 || event.getDamage() >= WeaponWear.remaining(item)) { + event.setCancelled(true); + } + } + + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onWeaponShot(UntargetedWeaponUseEvent event) { + ItemStack item = event.getWeapon().getItem(); + if (!GearProvenance.isGear(item) || !WeaponWear.isWorn(item)) { + return; + } + event.setCancelled(true); + Player player = event.getPlayer(); + player.sendTitle(Messages.get("cast.worn_title"), Messages.get("cast.worn_sub"), 0, 25, 10); + player.playSound(player.getLocation(), Sound.BLOCK_BEACON_DEACTIVATE, SoundCategory.PLAYERS, 0.7f, 0.6f); + } +} diff --git a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java index 53bbdc6..c88ec74 100644 --- a/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/ConfigLoader.java @@ -94,6 +94,9 @@ public boolean loadSafe(File configFile) { long seconds = cast.getLong("refuse_chat_seconds", 30L); Cache.refuseChatMillis = Math.max(0L, seconds) * 1000L; } + Cache.castWear = Math.max(0, cast == null + ? Cache.DEFAULT_CAST_WEAR + : cast.getInt("wear_per_cast", Cache.DEFAULT_CAST_WEAR)); loadCastTriggers(config.getStringList("runes.keybinds")); ConfigurationSection artifacts = config.getConfigurationSection("artifacts"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index df3e6ca..7a9251c 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -89,6 +89,9 @@ meditation: # spells out what you are short of, per player, weapon and element. cast: refuse_chat_seconds: 30 + # Vanilla durability every spent cast (success or fumble) takes from the held mage weapon. + # The last point is never spent: a weapon on it refuses spells until repaired. 0 = off. + wear_per_cast: 1 # Drift from spending mana on a gated cast. 4 mana -> 0.04, 10 mana -> 0.10 (mana / mana_divisor). # Surge adds corruption (negative equilibrium). Flow adds tranquility (positive). diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index e588fb0..51e353e 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -89,6 +89,13 @@ cast: broken_chat: "#ff5555This weapon is damaged. Right-click an empty station to reclaim its runes." + # Worn out: the weapon is on its last point of durability. Nothing is spent until it is repaired. + worn_title: "§7*Worn out*" + + worn_sub: "#aaaaaa§oThis weapon needs repair" + + worn_chat: "#ff5555This weapon is worn out. Repair it with a Magical Repair Kit before casting again." + resonance: admin: diff --git a/src/test/java/net/tfminecraft/magic/ConfigurationTest.java b/src/test/java/net/tfminecraft/magic/ConfigurationTest.java index 534b489..7b9c5df 100644 --- a/src/test/java/net/tfminecraft/magic/ConfigurationTest.java +++ b/src/test/java/net/tfminecraft/magic/ConfigurationTest.java @@ -253,6 +253,24 @@ void runtimeConfigValidatesBoundsAndSupportsLegacyFields() throws Exception { loader.load(Path.of("src/main/resources/config.yml").toFile()); } + @Test + void castWearReadsPerCastAmountFloorsAtZeroAndDefaultsToOne() throws Exception { + var loader = new ConfigLoader(); + try { + assertTrue(loader.loadSafe(write("wear.yml", "cast: {wear_per_cast: 3}\n").toFile())); + assertEquals(3, Cache.castWear); + assertTrue(loader.loadSafe(write("wear-negative.yml", "cast: {wear_per_cast: -2}\n").toFile())); + assertEquals(0, Cache.castWear); + assertTrue(loader.loadSafe(write("wear-missing.yml", "cast: {refuse_chat_seconds: 5}\n").toFile())); + assertEquals(1, Cache.castWear); + Cache.castWear = 4; + assertTrue(loader.loadSafe(write("wear-absent.yml", "{}").toFile())); + assertEquals(1, Cache.castWear); + } finally { + Cache.castWear = Cache.DEFAULT_CAST_WEAR; + } + } + @Test void reattuneFeeShareFallsBackToHalfWhenMissingNegativeOrNotANumber() throws Exception { var loader = new ConfigLoader(); diff --git a/src/test/java/net/tfminecraft/magic/ResonanceCastCoverageTest.java b/src/test/java/net/tfminecraft/magic/ResonanceCastCoverageTest.java index a7d5e85..ddb0597 100644 --- a/src/test/java/net/tfminecraft/magic/ResonanceCastCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/ResonanceCastCoverageTest.java @@ -4,11 +4,9 @@ import static org.mockito.Mockito.*; import io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent; -import io.lumine.mythic.lib.api.item.NBTItem; import io.lumine.mythic.lib.skill.*; import java.util.*; import java.util.concurrent.ThreadLocalRandom; -import net.Indyuce.mmoitems.api.interaction.util.DurabilityItem; import net.tfminecraft.magic.charge.TierBands; import net.tfminecraft.magic.gear.*; import net.tfminecraft.magic.integration.SkillIdResolver; @@ -125,7 +123,7 @@ void successfulAndFumbledCastsWearTheHeldSlotExactlyOnce() { var skill = mock(Skill.class); var metadata = mock(SkillMetadata.class); var p = server.addPlayer(); - var weapon = new ItemStack(Material.STICK); + var weapon = new ItemStack(Material.IRON_SWORD); when(event.getCast()).thenReturn(skill); when(event.getPlayer()).thenReturn(p); when(event.getMetadata()).thenReturn(metadata); @@ -138,34 +136,19 @@ void successfulAndFumbledCastsWearTheHeldSlotExactlyOnce() { var session = mock(ResonanceSession.class); when(session.getResonance("fire")).thenReturn(20.); when(plugin.getResonanceGuiManager().getSessionManager().get(p)).thenReturn(session); - var nbt = mock(NBTItem.class); - var durability = mock(DurabilityItem.class); - when(durability.decreaseDurability(1)).thenReturn(durability); - when(durability.toItem()).thenReturn(weapon); try (var ids = mockStatic(SkillIdResolver.class); var hand = mockStatic(GearHand.class); - var nbtApi = mockStatic(NBTItem.class); - var durabilityApi = mockStatic(DurabilityItem.class); var rifts = mockStatic(WeaponRift.class); var random = mockStatic(ThreadLocalRandom.class)) { ids.when(() -> SkillIdResolver.isActiveCast(skill)).thenReturn(true); ids.when(() -> SkillIdResolver.resolveSkillId(skill)).thenReturn("fireball"); hand.when(() -> GearHand.heldSlot(p)).thenReturn(GearHand.HeldSlot.MAIN_HAND); hand.when(() -> GearHand.held(p)).thenReturn(weapon); - nbtApi.when(() -> NBTItem.get(weapon)).thenReturn(nbt); listener.onPlayerCastSkill(event); verify(event, never()).setCancelled(true); - when(nbt.getInteger("MMOITEMS_MAX_DURABILITY")).thenReturn(10); - listener.onPlayerCastSkill(event); - durabilityApi - .when(() -> DurabilityItem.from(p, nbt, EquipmentSlot.HAND)) - .thenReturn(durability); - listener.onPlayerCastSkill(event); + assertEquals(1, damage(weapon)); hand.verify(() -> GearHand.setHeld(p, GearHand.HeldSlot.MAIN_HAND, weapon)); hand.when(() -> GearHand.heldSlot(p)).thenReturn(GearHand.HeldSlot.OFF_HAND); - durabilityApi - .when(() -> DurabilityItem.from(p, nbt, EquipmentSlot.OFF_HAND)) - .thenReturn(durability); hand.when(() -> GearHand.staffCount(p)).thenReturn(2); listener.onPlayerCastSkill(event); verify(skill).whenCast(metadata); @@ -179,10 +162,61 @@ void successfulAndFumbledCastsWearTheHeldSlotExactlyOnce() { listener.onPlayerCastSkill(event); verify(skill, times(2)).whenCast(metadata); verify(event, times(2)).setCancelled(true); - verify(durability, times(4)).decreaseDurability(1); + assertEquals(4, damage(weapon)); + p.setGameMode(GameMode.CREATIVE); + listener.onPlayerCastSkill(event); + assertEquals(4, damage(weapon)); + p.setGameMode(GameMode.SURVIVAL); + Cache.castWear = 0; + listener.onPlayerCastSkill(event); + assertEquals(4, damage(weapon)); + Cache.castWear = 3; + listener.onPlayerCastSkill(event); + assertEquals(7, damage(weapon)); + } finally { + Cache.castWear = Cache.DEFAULT_CAST_WEAR; } } + @Test + void wornOutWeaponRefusesForFreeAndThrottlesTheChatLine() { + var event = mock(PlayerCastSkillEvent.class); + var skill = mock(Skill.class); + var p = server.addPlayer(); + ElementRegistry.register(DomainTest.element("fire")); + var session = new ResonanceSession(); + session.setResonance("fire", 20); + var weapon = new ItemStack(Material.IRON_SWORD); + var meta = (org.bukkit.inventory.meta.Damageable) weapon.getItemMeta(); + meta.setDamage(249); + weapon.setItemMeta(meta); + when(event.getCast()).thenReturn(skill); + when(event.getPlayer()).thenReturn(p); + when(plugin.getResonanceGuiManager().getSessionManager().get(p)).thenReturn(session); + SkillElementRegistry.register("fireball", "fire", 1); + try (var ids = mockStatic(SkillIdResolver.class); + var hand = mockStatic(GearHand.class)) { + ids.when(() -> SkillIdResolver.isActiveCast(skill)).thenReturn(true); + ids.when(() -> SkillIdResolver.resolveSkillId(skill)).thenReturn("fireball"); + hand.when(() -> GearHand.heldSlot(p)).thenReturn(GearHand.HeldSlot.MAIN_HAND); + hand.when(() -> GearHand.held(p)).thenReturn(weapon); + listener.onPlayerCastSkill(event); + listener.onPlayerCastSkill(event); + verify(event, times(2)).setCancelled(true); + verify(skill, never()).whenCast(any()); + assertEquals(249, damage(weapon)); + assertEquals(20, session.getResonance("fire")); + assertNotNull(p.nextTitle()); + assertNotNull(p.nextMessage()); + assertNotNull(p.nextTitle()); + assertNull(p.nextMessage()); + } + } + + static int damage(ItemStack stack) { + return ((org.bukkit.inventory.meta.Damageable) stack.getItemMeta()).getDamage(); + } + @Test void refusalRateLimiterExpiresOldEntriesButKeepsRecentOnes() throws Exception { var field = ResonanceCastListener.class.getDeclaredField("lastRefuseChat"); diff --git a/src/test/java/net/tfminecraft/magic/WeaponWearTest.java b/src/test/java/net/tfminecraft/magic/WeaponWearTest.java new file mode 100644 index 0000000..7e45321 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/WeaponWearTest.java @@ -0,0 +1,154 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import net.Indyuce.mmoitems.api.event.item.UntargetedWeaponUseEvent; +import net.Indyuce.mmoitems.api.interaction.weapon.Weapon; +import net.tfminecraft.magic.gear.GearKeys; +import net.tfminecraft.magic.gear.WeaponWear; +import net.tfminecraft.magic.listener.WeaponWearListener; +import org.bukkit.Material; +import org.bukkit.event.player.PlayerItemDamageEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.*; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class WeaponWearTest { + ServerMock server; + Magic plugin; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + plugin = mock(Magic.class, RETURNS_DEEP_STUBS); + Magic.plugin = plugin; + when(plugin.namespace()).thenReturn("magic"); + when(plugin.getName()).thenReturn("Magic"); + } + + @AfterEach + void cleanup() { + Magic.plugin = null; + MockBukkit.unmock(); + } + + static ItemStack damaged(Material material, int damage) { + var stack = new ItemStack(material); + var meta = (Damageable) stack.getItemMeta(); + meta.setDamage(damage); + stack.setItemMeta(meta); + return stack; + } + + static ItemStack gear(ItemStack stack) { + var meta = stack.getItemMeta(); + meta.getPersistentDataContainer().set(GearKeys.archetype(), PersistentDataType.STRING, "staff"); + stack.setItemMeta(meta); + return stack; + } + + static int damage(ItemStack stack) { + return ((Damageable) stack.getItemMeta()).getDamage(); + } + + @Test + void remainingCountsVanillaOrComponentDurabilityAndSkipsWhatCannotWear() { + assertEquals(-1, WeaponWear.remaining(null)); + assertEquals(-1, WeaponWear.remaining(new ItemStack(Material.AIR))); + assertEquals(-1, WeaponWear.remaining(new ItemStack(Material.STICK))); + var unbreakable = new ItemStack(Material.IRON_SWORD); + var meta = unbreakable.getItemMeta(); + meta.setUnbreakable(true); + unbreakable.setItemMeta(meta); + assertEquals(-1, WeaponWear.remaining(unbreakable)); + assertEquals(250, WeaponWear.remaining(new ItemStack(Material.IRON_SWORD))); + assertEquals(0, WeaponWear.remaining(damaged(Material.IRON_SWORD, 300))); + var stick = new ItemStack(Material.STICK); + var stickMeta = (Damageable) stick.getItemMeta(); + stickMeta.setMaxDamage(500); + stick.setItemMeta(stickMeta); + assertEquals(500, WeaponWear.remaining(stick)); + } + + @Test + void wornMeansTheLastPointOrNoneLeft() { + assertFalse(WeaponWear.isWorn(new ItemStack(Material.STICK))); + assertFalse(WeaponWear.isWorn(damaged(Material.IRON_SWORD, 248))); + assertTrue(WeaponWear.isWorn(damaged(Material.IRON_SWORD, 249))); + assertTrue(WeaponWear.isWorn(damaged(Material.IRON_SWORD, 250))); + } + + @Test + void wearSpendsPointsButNeverTheLastOne() { + var sword = new ItemStack(Material.IRON_SWORD); + WeaponWear.wear(sword, 0); + assertEquals(0, damage(sword)); + WeaponWear.wear(sword, 2); + assertEquals(2, damage(sword)); + WeaponWear.wear(sword, 1000); + assertEquals(249, damage(sword)); + WeaponWear.wear(sword, 1); + assertEquals(249, damage(sword)); + var stick = new ItemStack(Material.STICK); + WeaponWear.wear(stick, 1); + assertFalse(stick.getItemMeta() instanceof Damageable d && d.hasDamage()); + } + + @Test + void itemDamageIsCancelledOnStickAndPaperGearAndWhenItWouldReachTheLastPointOnSwords() { + var listener = new WeaponWearListener(); + PlayerMock player = server.addPlayer(); + var plain = new ItemStack(Material.IRON_SWORD); + var notGear = new PlayerItemDamageEvent(player, plain, 1); + listener.onItemDamage(notGear); + assertFalse(notGear.isCancelled()); + var paperStick = new ItemStack(Material.PAPER); + var paperMeta = (Damageable) paperStick.getItemMeta(); + paperMeta.setMaxDamage(2000); + paperMeta.setDamage(10); + paperStick.setItemMeta(paperMeta); + var skinned = new PlayerItemDamageEvent(player, gear(paperStick), 1); + listener.onItemDamage(skinned); + assertTrue(skinned.isCancelled()); + var worn = new PlayerItemDamageEvent(player, gear(damaged(Material.IRON_SWORD, 249)), 1); + listener.onItemDamage(worn); + assertTrue(worn.isCancelled()); + var tooMuch = new PlayerItemDamageEvent(player, gear(damaged(Material.IRON_SWORD, 240)), 10); + listener.onItemDamage(tooMuch); + assertTrue(tooMuch.isCancelled()); + var justFits = new PlayerItemDamageEvent(player, gear(damaged(Material.IRON_SWORD, 240)), 9); + listener.onItemDamage(justFits); + assertFalse(justFits.isCancelled()); + assertEquals(9, justFits.getDamage()); + var fresh = new PlayerItemDamageEvent(player, gear(new ItemStack(Material.IRON_SWORD)), 1); + listener.onItemDamage(fresh); + assertFalse(fresh.isCancelled()); + assertEquals(1, fresh.getDamage()); + } + + @Test + void wornWandShotIsRefusedWithATitleAndEverythingElsePasses() { + var listener = new WeaponWearListener(); + PlayerMock player = server.addPlayer(); + var weapon = mock(Weapon.class); + var event = mock(UntargetedWeaponUseEvent.class); + when(event.getWeapon()).thenReturn(weapon); + when(event.getPlayer()).thenReturn(player); + var plainWorn = damaged(Material.IRON_SWORD, 249); + var freshGear = gear(new ItemStack(Material.IRON_SWORD)); + var wornGear = gear(damaged(Material.IRON_SWORD, 249)); + when(weapon.getItem()).thenReturn(plainWorn); + listener.onWeaponShot(event); + when(weapon.getItem()).thenReturn(freshGear); + listener.onWeaponShot(event); + verify(event, never()).setCancelled(true); + when(weapon.getItem()).thenReturn(wornGear); + listener.onWeaponShot(event); + verify(event).setCancelled(true); + assertNotNull(player.nextTitle()); + } +}