From 1d7bfdaeffc13a52850496aac673118d63d79fd3 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 5 Sep 2026 11:47:34 -0700 Subject: [PATCH] fix: ignore anvil repair cost when matching required inventory items Items that pass through an anvil (e.g. enchanted books combined for a higher level) gain a repair_cost component. The strict ItemStack#isSimilar check in TryToComplete treated such items as different from the admin-configured requirement, so the challenge could not be completed without enabling "ignore metadata", which then accepts any enchanted book. Add Utils.withoutRepairCost / isSimilarIgnoringRepairCost, which clear the repair cost on a copy of both items before the normal strict comparison, and use them for inventory matching and for the GUI item grouping. Enchantments, names, lore and all other meta are still compared. Fixes #433 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SYkfNqJY8BjLmWTDp32R4Q --- .../challenges/tasks/TryToComplete.java | 8 +- .../bentobox/challenges/utils/Utils.java | 52 ++++++++ .../challenges/tasks/TryToCompleteTest.java | 122 ++++++++++++++++++ 3 files changed, 179 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java index 15fa3da2..40333969 100644 --- a/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java +++ b/src/main/java/world/bentobox/challenges/tasks/TryToComplete.java @@ -1993,7 +1993,8 @@ private boolean hasRequiredTeamPresence() * Checks if two items match, considering the ignore-metadata setting. For potion-like materials * that are in the ignore-metadata set, compares the base potion type while ignoring other metadata. * For non-potion materials in the ignore-metadata set, uses type-only comparison. For materials - * not in the ignore-metadata set, uses full similarity comparison. + * not in the ignore-metadata set, uses full similarity comparison, except that the anvil + * repair cost component is ignored. * * @param candidate candidate item from inventory * @param required required item template @@ -2012,10 +2013,11 @@ private static boolean itemsMatch(ItemStack candidate, ItemStack required, Set 0) { + ((Repairable) meta).setRepairCost(repairCost); + } + book.setItemMeta(meta); + return book; + } + + @Test + void testInventoryChallengeEnchantedBookFromAnvilMatches() { + // Required: Unbreaking III book with no repair cost (as configured by an admin) + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredBook = createEnchantedBook(Enchantment.UNBREAKING, 3, 0); + req.setRequiredItems(Collections.singletonList(requiredBook)); + req.setTakeItems(true); + challenge.setRequirements(req); + + // Player holds an Unbreaking III book that was combined in an anvil (has repair_cost) + ItemStack anvilBook = createEnchantedBook(Enchantment.UNBREAKING, 3, 1); + assertFalse(anvilBook.isSimilar(requiredBook), "Sanity: strict isSimilar must differ on repair cost"); + when(inv.getContents()).thenReturn(new ItemStack[] { anvilBook }); + when(player.getInventory()).thenReturn(inv); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(user, never()).getTranslation(any(World.class), eq("challenges.errors.not-enough-items"), any(), any()); + assertEquals(0, anvilBook.getAmount(), "The anvil-made book should have been taken"); + } + + @Test + void testInventoryChallengeEnchantedBookRequiredHasRepairCost() { + // Required item itself was created from an anvil-made book; player holds a clean one + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredBook = createEnchantedBook(Enchantment.UNBREAKING, 3, 2); + req.setRequiredItems(Collections.singletonList(requiredBook)); + challenge.setRequirements(req); + + ItemStack cleanBook = createEnchantedBook(Enchantment.UNBREAKING, 3, 0); + when(inv.getContents()).thenReturn(new ItemStack[] { cleanBook }); + when(player.getInventory()).thenReturn(inv); + + assertTrue(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + } + + @Test + void testInventoryChallengeEnchantedBookWrongLevelStillFails() { + // Ignoring repair cost must not loosen the enchantment check itself + InventoryRequirements req = new InventoryRequirements(); + ItemStack requiredBook = createEnchantedBook(Enchantment.UNBREAKING, 3, 0); + req.setRequiredItems(Collections.singletonList(requiredBook)); + challenge.setRequirements(req); + + ItemStack lowerBook = createEnchantedBook(Enchantment.UNBREAKING, 2, 1); + when(inv.getContents()).thenReturn(new ItemStack[] { lowerBook }); + when(player.getInventory()).thenReturn(inv); + // The "missing items" message prettifies the enchanted book, which needs vararg translations + Mockito.doAnswer(invocation -> invocation.getArgument(0, String.class)) + .when(user).getTranslationOrNothing(anyString(), Mockito.any(String[].class)); + + assertFalse(TryToComplete.complete(addon, user, challenge, world, topLabel, permissionPrefix)); + verify(user).getTranslation(any(World.class), eq("challenges.errors.not-enough-items"), eq("[items]"), + any()); + } + + @Test + void testUtilsIsSimilarIgnoringRepairCost() { + ItemStack clean = createEnchantedBook(Enchantment.UNBREAKING, 3, 0); + ItemStack anvil = createEnchantedBook(Enchantment.UNBREAKING, 3, 5); + ItemStack other = createEnchantedBook(Enchantment.MENDING, 1, 5); + + assertTrue(Utils.isSimilarIgnoringRepairCost(clean, anvil)); + assertTrue(Utils.isSimilarIgnoringRepairCost(anvil, clean)); + assertTrue(Utils.isSimilarIgnoringRepairCost(anvil, anvil.clone())); + assertFalse(Utils.isSimilarIgnoringRepairCost(anvil, other)); + assertFalse(Utils.isSimilarIgnoringRepairCost(null, clean)); + assertFalse(Utils.isSimilarIgnoringRepairCost(clean, null)); + + // The original item must not be modified + assertEquals(5, ((Repairable) anvil.getItemMeta()).getRepairCost()); + } + + @Test + void testUtilsWithoutRepairCost() { + ItemStack clean = createEnchantedBook(Enchantment.UNBREAKING, 3, 0); + ItemStack anvil = createEnchantedBook(Enchantment.UNBREAKING, 3, 5); + ItemStack plain = new ItemStack(Material.DIRT); + + // Items without a repair cost are returned as-is + assertTrue(clean == Utils.withoutRepairCost(clean)); + assertTrue(plain == Utils.withoutRepairCost(plain)); + assertNull(Utils.withoutRepairCost(null)); + + ItemStack stripped = Utils.withoutRepairCost(anvil); + assertFalse(((Repairable) stripped.getItemMeta()).hasRepairCost()); + assertTrue(stripped.isSimilar(clean)); + assertEquals(5, ((Repairable) anvil.getItemMeta()).getRepairCost(), "Original must be untouched"); + } + + @Test + void testGroupEqualItemsMergesAnvilBooks() { + ItemStack clean = createEnchantedBook(Enchantment.UNBREAKING, 3, 0); + ItemStack anvil = createEnchantedBook(Enchantment.UNBREAKING, 3, 3); + List grouped = Utils.groupEqualItems(Arrays.asList(clean, anvil), Set.of()); + assertEquals(1, grouped.size()); + assertEquals(2, grouped.get(0).getAmount()); + } }