From d728b22cd00922534731811efcb564cdbf11058c Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 3 May 2026 22:10:00 -0700 Subject: [PATCH 01/17] feat: add /island donate inv command to donate full inventory Adds a confirmable "inv" subcommand that scans the player's inventory for donatable blocks, lists each material with its point value plus a total in the confirmation prompt, and on confirm donates all of them in one go. Items with no value or that are not donatable blocks remain in the inventory. Also fixes the donate tab-complete: the args list BentoBox passes to a subcommand includes the leaf label at index 0, so the previous size-1 check never fired in real use and only the auto "help" suggestion appeared. Tests now exercise the realistic args shape. Bumps build version to 2.27.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- pom.xml | 2 +- .../level/commands/IslandDonateCommand.java | 116 +++++++++++++++++- src/main/resources/locales/en-US.yml | 7 +- .../commands/IslandDonateCommandTest.java | 89 +++++++++++++- 4 files changed, 206 insertions(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index 8d7473b..5ebe28a 100644 --- a/pom.xml +++ b/pom.xml @@ -69,7 +69,7 @@ -LOCAL - 2.26.0 + 2.27.0 BentoBoxWorld_Level bentobox-world https://sonarcloud.io diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index fa76d70..8eae877 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -1,10 +1,13 @@ package world.bentobox.level.commands; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.Optional; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.commands.ConfirmableCommand; @@ -17,8 +20,9 @@ import world.bentobox.level.util.Utils; /** - * Command: /island donate [hand [amount]] - * Opens a donation GUI or donates blocks from hand. + * Command: /island donate [hand [amount]] [inv] + * Opens a donation GUI, or donates blocks from the player's hand, or + * donates every donatable block from the player's inventory. * * @author tastybento */ @@ -65,6 +69,11 @@ public boolean execute(User user, String label, List args) { return handleHandDonation(user, island, args); } + // Handle "inv" subcommand (accepts English "inv" or the localized keyword) + if (!args.isEmpty() && isInvKeyword(user, args.get(0))) { + return handleInvDonation(user, island); + } + // No args - open GUI DonationPanel.openPanel(addon, getWorld(), user, island); return true; @@ -142,14 +151,106 @@ private void performHandDonation(User user, Island island, Material material, in "[points]", Utils.formatNumber(user, points)); } + /** + * Handle the /island donate inv subcommand. Scans the player's inventory for + * blocks with a positive donation value, shows a per-material breakdown plus + * the total, and asks for confirmation. Items with no value or that aren't + * donatable blocks remain in the inventory. + */ + private boolean handleInvDonation(User user, Island island) { + Map totals = collectDonatableTotals(user.getPlayer().getInventory()); + + if (totals.isEmpty()) { + user.sendMessage("island.donate.empty"); + return false; + } + + long totalPoints = 0L; + StringBuilder prompt = new StringBuilder( + user.getTranslation("island.donate.inv.confirm-header")); + for (Map.Entry e : totals.entrySet()) { + int value = addon.getBlockConfig().getValue(getWorld(), e.getKey()); + long points = (long) value * e.getValue(); + totalPoints += points; + prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-line", + TextVariables.NUMBER, String.valueOf(e.getValue()), + "[material]", Utils.prettifyObject(e.getKey(), user), + "[points]", Utils.formatNumber(user, points))); + } + prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-total", + "[points]", Utils.formatNumber(user, totalPoints))); + + askConfirmation(user, prompt.toString(), () -> performInvDonation(user, island)); + return true; + } + + private void performInvDonation(User user, Island island) { + PlayerInventory pInv = user.getPlayer().getInventory(); + ItemStack[] contents = pInv.getStorageContents(); + Map donated = new EnumMap<>(Material.class); + long totalPoints = 0L; + + for (int i = 0; i < contents.length; i++) { + ItemStack item = contents[i]; + Integer value = donationValue(item); + if (value == null) { + continue; + } + int amount = item.getAmount(); + long points = (long) value * amount; + donated.merge(item.getType(), amount, Integer::sum); + totalPoints += points; + addon.getManager().donateBlocks(island, user.getUniqueId(), item.getType().name(), amount, points); + contents[i] = null; + } + pInv.setStorageContents(contents); + + if (donated.isEmpty()) { + user.sendMessage("island.donate.empty"); + return; + } + int totalBlocks = donated.values().stream().mapToInt(Integer::intValue).sum(); + user.sendMessage("island.donate.success", + "[points]", Utils.formatNumber(user, totalPoints), + TextVariables.NUMBER, String.valueOf(totalBlocks)); + addon.getManager().recalculateAfterDonation(island); + } + + private Map collectDonatableTotals(PlayerInventory pInv) { + Map totals = new EnumMap<>(Material.class); + for (ItemStack item : pInv.getStorageContents()) { + if (donationValue(item) != null) { + totals.merge(item.getType(), item.getAmount(), Integer::sum); + } + } + return totals; + } + + /** + * @return the per-block donation value if the item is a donatable block with a + * positive configured value, or null otherwise + */ + private Integer donationValue(ItemStack item) { + if (item == null || item.getType().isAir() || !item.getType().isBlock()) { + return null; + } + Integer value = addon.getBlockConfig().getValue(getWorld(), item.getType()); + return (value != null && value > 0) ? value : null; + } + @Override public Optional> tabComplete(User user, String alias, List args) { + // BentoBox includes the command label as args.get(0); the user-typed args start at index 1. String lastArg = !args.isEmpty() ? args.get(args.size() - 1) : ""; String handKeyword = user.getTranslation("island.donate.hand.keyword"); - if (args.size() <= 1) { - return Optional.of(Util.tabLimit(List.of(handKeyword), lastArg)); + String invKeyword = user.getTranslation("island.donate.inv.keyword"); + + // First user-arg slot: suggest "hand" and "inv". + if (args.size() <= 2) { + return Optional.of(Util.tabLimit(List.of(handKeyword, invKeyword), lastArg)); } - if (args.size() == 2 && isHandKeyword(user, args.get(0)) && user.isPlayer()) { + // Second user-arg slot after "hand": suggest the held count. + if (args.size() == 3 && isHandKeyword(user, args.get(1)) && user.isPlayer()) { int held = user.getPlayer().getInventory().getItemInMainHand().getAmount(); if (held > 0) { return Optional.of(Util.tabLimit(List.of(String.valueOf(held)), lastArg)); @@ -162,4 +263,9 @@ private boolean isHandKeyword(User user, String arg) { String localized = user.getTranslation("island.donate.hand.keyword"); return "hand".equalsIgnoreCase(arg) || localized.equalsIgnoreCase(arg); } + + private boolean isInvKeyword(User user, String arg) { + String localized = user.getTranslation("island.donate.inv.keyword"); + return "inv".equalsIgnoreCase(arg) || localized.equalsIgnoreCase(arg); + } } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index ff81550..e18ba4a 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -52,7 +52,7 @@ island: in-progress: "Island level calculation is in progress..." time-out: "The level calculation took too long. Please try again later." donate: - parameters: "[hand [amount]]" + parameters: "[hand [amount]] [inv]" description: "donate blocks to permanently raise island level" must-be-on-island: "You must be on your island to donate blocks." no-permission: "You do not have permission to donate blocks on this island." @@ -72,6 +72,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "About to DESTROY these blocks from your inventory:" + confirm-line: "[number] x [material] = [points] points" + confirm-total: "Total: [points] permanent points." detail: description: "shows detail of your island blocks" top: diff --git a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java index 7ba90bd..40771de 100644 --- a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java +++ b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java @@ -4,14 +4,19 @@ import static org.junit.jupiter.api.Assertions.assertFalse; 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.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; 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.util.Collections; import java.util.List; +import java.util.Locale; import java.util.UUID; import org.bukkit.Location; @@ -65,6 +70,10 @@ protected void setUp() throws Exception { when(user.getTranslation(anyString(), anyString(), anyString())).thenAnswer(i -> i.getArgument(0, String.class)); when(user.getTranslation(anyString(), anyString(), anyString(), anyString(), anyString())).thenAnswer(i -> i.getArgument(0, String.class)); when(user.getTranslation("island.donate.hand.keyword")).thenReturn("hand"); + when(user.getTranslation("island.donate.inv.keyword")).thenReturn("inv"); + when(user.getTranslationOrNothing(anyString())).thenReturn(""); + when(user.getTranslationOrNothing(anyString(), anyString(), anyString())).thenReturn(""); + when(user.getLocale()).thenReturn(Locale.US); when(user.getLocation()).thenReturn(location); when(player.getInventory()).thenReturn(inventory); @@ -156,9 +165,87 @@ void testExecuteHandBlockNoValue() { @Test void testTabCompleteNoArgs() { - // When no args, should suggest "hand" + // When no args, should suggest "hand" and "inv" var result = cmd.tabComplete(user, "donate", Collections.emptyList()); assertTrue(result.isPresent()); assertTrue(result.get().contains("hand")); + assertTrue(result.get().contains("inv")); + } + + @Test + void testTabCompleteFirstArgFromBentoBoxFlow() { + // BentoBox passes the leaf command label as args.get(0); the partial first + // user arg sits in args.get(1). Empty string = bare "/island donate ". + var result = cmd.tabComplete(user, "donate", List.of("donate", "")); + assertTrue(result.isPresent()); + assertTrue(result.get().contains("hand")); + assertTrue(result.get().contains("inv")); + } + + @Test + void testTabCompleteSecondArgAfterHandSuggestsHeldAmount() { + ItemStack stack = mock(ItemStack.class); + when(stack.getAmount()).thenReturn(7); + when(inventory.getItemInMainHand()).thenReturn(stack); + + var result = cmd.tabComplete(user, "donate", List.of("donate", "hand", "")); + assertTrue(result.isPresent()); + assertTrue(result.get().contains("7")); + } + + @Test + void testTabCompleteAfterInvSuggestsNothing() { + var result = cmd.tabComplete(user, "donate", List.of("donate", "inv", "")); + assertTrue(result.isPresent()); + assertTrue(result.get().isEmpty()); + } + + @Test + void testExecuteInvEmptyInventory() { + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { null, null, null }); + + assertFalse(cmd.execute(user, "donate", List.of("inv"))); + verify(user).sendMessage("island.donate.empty"); + verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); + } + + @Test + void testExecuteInvNoValuableBlocks() { + // Stone with no value, sword (not a block) + ItemStack stone = mock(ItemStack.class); + when(stone.getType()).thenReturn(Material.STONE); + when(stone.getAmount()).thenReturn(5); + ItemStack sword = mock(ItemStack.class); + when(sword.getType()).thenReturn(Material.DIAMOND_SWORD); + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { stone, sword }); + when(blockConfig.getValue(any(), eq(Material.STONE))).thenReturn(null); + + assertFalse(cmd.execute(user, "donate", List.of("inv"))); + verify(user).sendMessage("island.donate.empty"); + verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); + } + + @Test + void testExecuteInvShowsConfirmationPrompt() { + ItemStack diamond = mock(ItemStack.class); + when(diamond.getType()).thenReturn(Material.DIAMOND_BLOCK); + when(diamond.getAmount()).thenReturn(2); + ItemStack gold = mock(ItemStack.class); + when(gold.getType()).thenReturn(Material.GOLD_BLOCK); + when(gold.getAmount()).thenReturn(3); + // Non-donatable item is ignored, not destroyed + ItemStack sword = mock(ItemStack.class); + when(sword.getType()).thenReturn(Material.DIAMOND_SWORD); + + when(inventory.getStorageContents()) + .thenReturn(new ItemStack[] { diamond, sword, gold }); + when(blockConfig.getValue(any(), eq(Material.DIAMOND_BLOCK))).thenReturn(100); + when(blockConfig.getValue(any(), eq(Material.GOLD_BLOCK))).thenReturn(50); + + assertTrue(cmd.execute(user, "donate", List.of("inv"))); + // The confirmation header should have been requested via getTranslation + verify(user).getTranslation("island.donate.inv.confirm-header"); + // No donation yet — only confirmation requested + verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); } } From bb9cdaf73dfe69d89ea2c3a70319671b6cd1d2ba Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 4 May 2026 08:16:18 -0700 Subject: [PATCH 02/17] i18n: add donate.inv translations across all locales Adds the four new island.donate.inv keys (keyword, confirm-header, confirm-line, confirm-total) to every non-English locale file. MiniMessage tags and placeholders are preserved verbatim; only the human-readable text was translated. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/resources/locales/cs.yml | 5 +++++ src/main/resources/locales/de.yml | 5 +++++ src/main/resources/locales/es.yml | 5 +++++ src/main/resources/locales/fr.yml | 5 +++++ src/main/resources/locales/hu.yml | 5 +++++ src/main/resources/locales/id.yml | 5 +++++ src/main/resources/locales/ko.yml | 5 +++++ src/main/resources/locales/lv.yml | 5 +++++ src/main/resources/locales/nl.yml | 5 +++++ src/main/resources/locales/pl.yml | 5 +++++ src/main/resources/locales/pt.yml | 5 +++++ src/main/resources/locales/ru.yml | 5 +++++ src/main/resources/locales/tr.yml | 5 +++++ src/main/resources/locales/uk.yml | 5 +++++ src/main/resources/locales/vi.yml | 5 +++++ src/main/resources/locales/zh-CN.yml | 5 +++++ 16 files changed, 80 insertions(+) diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 35ec78b..7c1da5b 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -67,6 +67,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Tyto bloky budou ZNIČENY z tvého inventáře:" + confirm-line: "[number] x [material] = [points] bodů" + confirm-total: "Celkem: [points] trvalých bodů." detail: description: "zobrazit podrobnosti o blocích vašeho ostrova" top: diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index b05dab9..75ebec9 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -68,6 +68,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Diese Blöcke werden aus deinem Inventar ZERSTÖRT:" + confirm-line: "[number] x [material] = [points] Punkte" + confirm-total: "Gesamt: [points] permanente Punkte." detail: description: "zeigt Details der Blöcke deiner Insel" top: diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 0d93e3f..8571223 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -65,6 +65,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Estos bloques serán DESTRUIDOS de tu inventario:" + confirm-line: "[number] x [material] = [points] puntos" + confirm-total: "Total: [points] puntos permanentes." detail: description: "muestra el detalle de los bloques de tu isla" top: diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index e1265d1..d939b6b 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -67,6 +67,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Ces blocs vont être DÉTRUITS de votre inventaire :" + confirm-line: "[number] x [material] = [points] points" + confirm-total: "Total : [points] points permanents." top: description: affiche le top 10 gui-title: "Top 10" diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 7be400a..e9113e5 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -68,6 +68,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Ezeket a blokkokat MEGSEMMISÍTI a leltáradból:" + confirm-line: "[number] x [material] = [points] pont" + confirm-total: "Összesen: [points] állandó pont." detail: description: "megmutatja a szigeted blokkjainak részleteit" top: diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index 539666b..ff360f0 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -65,6 +65,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Akan MENGHANCURKAN blok-blok ini dari inventaris Anda:" + confirm-line: "[number] x [material] = [points] poin" + confirm-total: "Total: [points] poin permanen." top: description: menunjukkan Sepuluh Besar gui-title: " Sepuluh Besar" diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index f846737..073e26c 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -68,6 +68,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "인벤토리에서 다음 블록을 파괴합니다:" + confirm-line: "[number] x [material] = [points] 점" + confirm-total: "총: [points] 영구 점수." detail: description: "섬 블록의 세부 정보를 표시합니다" top: diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index 7ec2474..a9f6feb 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -68,6 +68,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Šie bloki tiks IZNĪCINĀTI no tavas somas:" + confirm-line: "[number] x [material] = [points] punkti" + confirm-total: "Kopā: [points] pastāvīgi punkti." detail: description: "rāda tavas salas bloku detaļas" top: diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index 324b896..9ddd410 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -65,6 +65,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Deze blokken worden VERNIETIGD uit je inventaris:" + confirm-line: "[number] x [material] = [points] punten" + confirm-total: "Totaal: [points] permanente punten." top: description: Toon de Top tien gui-title: " Top tien" diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index da6ad80..67b6940 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -65,6 +65,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Te bloki zostaną ZNISZCZONE z twojego ekwipunku:" + confirm-line: "[number] x [material] = [points] punktów" + confirm-total: "Łącznie: [points] punktów stałych." top: description: pokauje Top 10 wysp gui-title: "Top 10" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 57fbabe..ffb289c 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -68,6 +68,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Estes blocos serão DESTRUÍDOS do seu inventário:" + confirm-line: "[number] x [material] = [points] pontos" + confirm-total: "Total: [points] pontos permanentes." detail: description: "mostra os detalhes dos blocos da sua ilha" top: diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 2dc53da..e483b21 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -68,6 +68,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Эти блоки будут УНИЧТОЖЕНЫ из вашего инвентаря:" + confirm-line: "[number] x [material] = [points] очков" + confirm-total: "Всего: [points] постоянных очков." detail: description: показать информацию о блоках на вашем острове top: diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index 689f9b8..2f8ae2b 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -72,6 +72,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Envanterinizden bu bloklar YOK EDİLECEK:" + confirm-line: "[number] x [material] = [points] puan" + confirm-total: "Toplam: [points] kalıcı puan." detail: description: "adanın blok ayrıntılarını gösterir" top: diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 9ac33d3..d8286b3 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -65,6 +65,11 @@ island: success: "Пожертвовано [number] x [material] за [points] постійних очок!" not-block: "Ви повинні тримати блок, який можна розмістити." confirm-prompt: "Буде ЗНИЩЕНО [number] x [material] за [points] постійних очок." + inv: + keyword: "inv" + confirm-header: "Ці блоки будуть ЗНИЩЕНІ з вашого інвентарю:" + confirm-line: "[number] x [material] = [points] очок" + confirm-total: "Всього: [points] постійних очок." top: description: показати першу десятку gui-title: "& Десятка Кращих" diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 6d27ce2..9c19d5d 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -71,6 +71,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "Sắp PHÁ HỦY những khối này từ kho đồ của bạn:" + confirm-line: "[number] x [material] = [points] điểm" + confirm-total: "Tổng: [points] điểm vĩnh viễn." detail: description: "hiển thị chi tiết các khối trên đảo của bạn" top: diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 1901208..6dd71c6 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -63,6 +63,11 @@ island: success: "Donated [number] x [material] for [points] permanent points!" not-block: "You must be holding a placeable block to donate." confirm-prompt: "About to DESTROY [number] x [material] for [points] permanent points." + inv: + keyword: "inv" + confirm-header: "即将从你的物品栏销毁以下方块:" + confirm-line: "[number] x [material] = [points] 点" + confirm-total: "共计: [points] 永久点数。" top: description: 显示前十名 From d0c8b85b8e72a733702296169eb53ca94943791d Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 4 May 2026 08:18:07 -0700 Subject: [PATCH 03/17] refactor: extract [material] and [points] placeholders to constants Addresses SonarCloud findings about duplicated string literals in IslandDonateCommand. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../level/commands/IslandDonateCommand.java | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index 8eae877..d4d603e 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -28,6 +28,9 @@ */ public class IslandDonateCommand extends ConfirmableCommand { + private static final String MATERIAL_PLACEHOLDER = "[material]"; + private static final String POINTS_PLACEHOLDER = "[points]"; + private final Level addon; public IslandDonateCommand(Level addon, CompositeCommand parent) { @@ -120,8 +123,8 @@ private boolean handleHandDonation(User user, Island island, List args) String prompt = user.getTranslation("island.donate.hand.confirm-prompt", TextVariables.NUMBER, String.valueOf(previewAmount), - "[material]", Utils.prettifyObject(material, user), - "[points]", Utils.formatNumber(user, previewPoints)); + MATERIAL_PLACEHOLDER, Utils.prettifyObject(material, user), + POINTS_PLACEHOLDER, Utils.formatNumber(user, previewPoints)); askConfirmation(user, prompt, () -> performHandDonation(user, island, material, blockValue, finalRequested)); return true; @@ -147,8 +150,8 @@ private void performHandDonation(User user, Island island, Material material, in user.sendMessage("island.donate.hand.success", TextVariables.NUMBER, String.valueOf(amount), - "[material]", Utils.prettifyObject(material, user), - "[points]", Utils.formatNumber(user, points)); + MATERIAL_PLACEHOLDER, Utils.prettifyObject(material, user), + POINTS_PLACEHOLDER, Utils.formatNumber(user, points)); } /** @@ -174,11 +177,11 @@ private boolean handleInvDonation(User user, Island island) { totalPoints += points; prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-line", TextVariables.NUMBER, String.valueOf(e.getValue()), - "[material]", Utils.prettifyObject(e.getKey(), user), - "[points]", Utils.formatNumber(user, points))); + MATERIAL_PLACEHOLDER, Utils.prettifyObject(e.getKey(), user), + POINTS_PLACEHOLDER, Utils.formatNumber(user, points))); } prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-total", - "[points]", Utils.formatNumber(user, totalPoints))); + POINTS_PLACEHOLDER, Utils.formatNumber(user, totalPoints))); askConfirmation(user, prompt.toString(), () -> performInvDonation(user, island)); return true; @@ -211,7 +214,7 @@ private void performInvDonation(User user, Island island) { } int totalBlocks = donated.values().stream().mapToInt(Integer::intValue).sum(); user.sendMessage("island.donate.success", - "[points]", Utils.formatNumber(user, totalPoints), + POINTS_PLACEHOLDER, Utils.formatNumber(user, totalPoints), TextVariables.NUMBER, String.valueOf(totalBlocks)); addon.getManager().recalculateAfterDonation(island); } From c531317fef0a0e76148167af7b5fdeef990edd8b Mon Sep 17 00:00:00 2001 From: msmith-codes Date: Thu, 7 May 2026 03:43:28 -0400 Subject: [PATCH 04/17] FIXED: Negative values in progression while using a non-linear function. --- .../calculators/IslandLevelCalculator.java | 21 +++++++++++++++-- .../bentobox/level/calculators/Results.java | 23 +++++++++++++++++++ .../level/commands/IslandLevelCommand.java | 14 ++++++++--- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 146540d..b632aca 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -751,13 +751,30 @@ public void tidyUp() { this.results.level.set(calculateLevel(blockAndDeathPoints)); // Calculate how many points are required to get to the next level - long nextLevel = this.results.level.get(); + long currentLevel = this.results.level.get(); + long nextLevel = currentLevel; long blocks = blockAndDeathPoints; - while (nextLevel < this.results.level.get() + 1 && blocks - blockAndDeathPoints < MAX_AMOUNT) { + while (nextLevel < currentLevel + 1 && blocks - blockAndDeathPoints < MAX_AMOUNT) { nextLevel = calculateLevel(++blocks); } this.results.pointsToNextLevel.set(blocks - blockAndDeathPoints); + // Calculate how many points have been accumulated within the current level by + // walking back to the smallest block count that still yields the current level. + // For non-linear level formulas the per-level interval is not equal to the + // configured level cost, so we derive the actual interval here. + // Floor the probe at initialCount when zeroing new island levels: calculateLevel + // subtracts initialCount internally, so probing below it produces negative + // modifiedPoints which yield NaN (sqrt, log) or negative fractions that truncate + // to 0 — both compare >= to a level-0 island and would walk the loop to MAX_AMOUNT. + long minBlocks = addon.getSettings().isZeroNewIslandLevels() ? results.initialCount.get() : 0; + long lowerBlocks = blockAndDeathPoints; + while (lowerBlocks > minBlocks && blockAndDeathPoints - lowerBlocks < MAX_AMOUNT + && calculateLevel(lowerBlocks - 1) >= currentLevel) { + lowerBlocks--; + } + this.results.pointsFromCurrentLevel.set(blockAndDeathPoints - lowerBlocks); + // Report results.report = getReport(); // Set the duration diff --git a/src/main/java/world/bentobox/level/calculators/Results.java b/src/main/java/world/bentobox/level/calculators/Results.java index 01c0c0a..5deaae6 100644 --- a/src/main/java/world/bentobox/level/calculators/Results.java +++ b/src/main/java/world/bentobox/level/calculators/Results.java @@ -48,6 +48,14 @@ public enum Result { AtomicLong level = new AtomicLong(0); AtomicInteger deathHandicap = new AtomicInteger(0); AtomicLong pointsToNextLevel = new AtomicLong(0); + /** + * Points already accumulated within the current level (i.e. how far past the + * start-of-current-level threshold the island has progressed). Combined with + * {@link #pointsToNextLevel} this gives the actual size of the current level + * interval — which is what should be shown to players when the configured + * level formula is non-linear. + */ + AtomicLong pointsFromCurrentLevel = new AtomicLong(0); //AtomicLong initialLevel = new AtomicLong(0); AtomicLong initialCount = new AtomicLong(0); /** @@ -115,6 +123,21 @@ public void setPointsToNextLevel(long points) { pointsToNextLevel.set(points); } + /** + * @return the points already accumulated within the current level + */ + public long getPointsFromCurrentLevel() { + return pointsFromCurrentLevel.get(); + } + + /** + * Set the points already accumulated within the current level + * @param points + */ + public void setPointsFromCurrentLevel(long points) { + pointsFromCurrentLevel.set(points); + } + /** * @return the totalPoints */ diff --git a/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java b/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java index 240df2e..5e4cd3d 100644 --- a/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java @@ -110,12 +110,20 @@ private void showResult(User user, UUID playerUUID, Island island, long oldLevel if (addon.getSettings().getDeathPenalty() != 0) { user.sendMessage("island.level.deaths", "[number]", String.valueOf(results.getDeathHandicap())); } - // Send player how many points are required to reach next island level + // Send player how many points are required to reach next island level. + // Use the actual interval between the current and next level so the progress + // ratio is correct for non-linear level formulas (log/sqrt/etc.) — the + // configured level_cost only matches the interval for the default linear + // formula and would otherwise yield negative or misleading values. if (results.getPointsToNextLevel() >= 0) { + long interval = results.getPointsFromCurrentLevel() + results.getPointsToNextLevel(); + if (interval <= 0) { + interval = this.addon.getSettings().getLevelCost(); + } user.sendMessage("island.level.required-points-to-next-level", "[points]", Utils.formatNumber(user, results.getPointsToNextLevel()), - "[progress]", Utils.formatNumber(user, this.addon.getSettings().getLevelCost() - results.getPointsToNextLevel()), - "[levelcost]", Utils.formatNumber(user, this.addon.getSettings().getLevelCost()) + "[progress]", Utils.formatNumber(user, results.getPointsFromCurrentLevel()), + "[levelcost]", Utils.formatNumber(user, interval) ); } // Tell other team members From e9886e6352fd18db7bbb2dce18fa0eb1bf680315 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 18:47:45 +0000 Subject: [PATCH 05/17] Initial plan From 1c218a354e9047beb9f78d486d980bb9860dfd24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 19:07:13 +0000 Subject: [PATCH 06/17] Add custom block support (Oraxen/Nexo/ItemsAdder/CraftEngine) to value, detail, and donate menus Agent-Logs-Url: https://github.com/BentoBoxWorld/Level/sessions/daf183a6-549f-4946-900c-5719c0a27e81 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- src/main/java/world/bentobox/level/Level.java | 44 +++++++++++ .../level/commands/IslandDonateCommand.java | 74 ++++++++++++++----- .../level/commands/IslandValueCommand.java | 23 ++---- .../bentobox/level/panels/DetailsPanel.java | 41 ++++++++-- .../bentobox/level/panels/DonationPanel.java | 34 +++++++-- .../bentobox/level/panels/ValuePanel.java | 11 +++ 6 files changed, 176 insertions(+), 51 deletions(-) diff --git a/src/main/java/world/bentobox/level/Level.java b/src/main/java/world/bentobox/level/Level.java index da58a37..bfb89d3 100644 --- a/src/main/java/world/bentobox/level/Level.java +++ b/src/main/java/world/bentobox/level/Level.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.bukkit.Bukkit; @@ -11,16 +12,21 @@ import org.bukkit.World; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; +import com.nexomc.nexo.api.NexoItems; + import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.configuration.Config; import world.bentobox.bentobox.api.flags.Flag; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.hooks.ItemsAdderHook; +import world.bentobox.bentobox.hooks.OraxenHook; import world.bentobox.bentobox.managers.RanksManager; import world.bentobox.bentobox.util.Util; import world.bentobox.level.calculators.Pipeliner; @@ -508,6 +514,44 @@ public boolean isItemsAdder() { return !getSettings().isDisableItemsAdder() && getPlugin().getHooks().getHook("ItemsAdder").isPresent(); } + /** + * Returns the custom-block plugin ID for an ItemStack, checking Oraxen, Nexo, + * and ItemsAdder in that order. Returns {@code null} when the item is not + * recognized as a custom block by any supported plugin. + * + * @param item the ItemStack to check (may be null) + * @return a namespaced custom-block ID such as {@code "oraxen:my_block"}, + * {@code "nexo:my_block"}, or an ItemsAdder ID, or {@code null} + */ + @Nullable + public String getCustomBlockId(ItemStack item) { + if (item == null || item.getType().isAir()) { + return null; + } + // Check Oraxen + if (getPlugin().getHooks().getHook("Oraxen").isPresent()) { + String id = OraxenHook.getIdByItem(item); + if (id != null) { + return "oraxen:" + id; + } + } + // Check Nexo + if (isNexo()) { + String id = NexoItems.idFromItem(item); + if (id != null) { + return "nexo:" + id; + } + } + // Check ItemsAdder + if (isItemsAdder()) { + Optional id = ItemsAdderHook.getNamespacedId(item); + if (id.isPresent()) { + return id.get(); + } + } + return null; + } + /** * @return true if the Nexo plugin is enabled and not disabled in config */ diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index d4d603e..cd3b682 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -1,6 +1,6 @@ package world.bentobox.level.commands; -import java.util.EnumMap; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -87,13 +87,20 @@ public boolean execute(User user, String label, List args) { */ private boolean handleHandDonation(User user, Island island, List args) { ItemStack hand = user.getPlayer().getInventory().getItemInMainHand(); - if (hand.getType().isAir() || !hand.getType().isBlock()) { + + // Check for a custom block (Oraxen, Nexo, ItemsAdder) first + final String customId = addon.getCustomBlockId(hand); + + // If not a custom block, require a vanilla block + if (customId == null && (hand.getType().isAir() || !hand.getType().isBlock())) { user.sendMessage("island.donate.hand.not-block"); return false; } final Material material = hand.getType(); - final Integer blockValue = addon.getBlockConfig().getValue(getWorld(), material); + final Integer blockValue = customId != null + ? addon.getBlockConfig().getValue(getWorld(), customId) + : addon.getBlockConfig().getValue(getWorld(), material); if (blockValue == null || blockValue <= 0) { user.sendMessage("island.donate.no-value"); return false; @@ -121,18 +128,25 @@ private boolean handleHandDonation(User user, Island island, List args) final long previewPoints = (long) previewAmount * blockValue; final int finalRequested = requested; + Object displayKey = customId != null ? customId : material; String prompt = user.getTranslation("island.donate.hand.confirm-prompt", TextVariables.NUMBER, String.valueOf(previewAmount), - MATERIAL_PLACEHOLDER, Utils.prettifyObject(material, user), + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, previewPoints)); - askConfirmation(user, prompt, () -> performHandDonation(user, island, material, blockValue, finalRequested)); + askConfirmation(user, prompt, () -> performHandDonation(user, island, material, customId, blockValue, finalRequested)); return true; } - private void performHandDonation(User user, Island island, Material material, int blockValue, int requested) { + private void performHandDonation(User user, Island island, Material material, String customId, int blockValue, int requested) { ItemStack currentHand = user.getPlayer().getInventory().getItemInMainHand(); - if (currentHand.getType() != material || currentHand.getAmount() == 0) { + // Verify the item in hand is still the same + if (customId != null) { + if (!customId.equals(addon.getCustomBlockId(currentHand)) || currentHand.getAmount() == 0) { + user.sendMessage("island.donate.hand.not-block"); + return; + } + } else if (currentHand.getType() != material || currentHand.getAmount() == 0) { user.sendMessage("island.donate.hand.not-block"); return; } @@ -145,12 +159,14 @@ private void performHandDonation(User user, Island island, Material material, in currentHand.setAmount(currentHand.getAmount() - amount); } - addon.getManager().donateBlocks(island, user.getUniqueId(), material.name(), amount, points); + String donationId = customId != null ? customId : material.name(); + addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, amount, points); addon.getManager().recalculateAfterDonation(island); + Object displayKey = customId != null ? customId : material; user.sendMessage("island.donate.hand.success", TextVariables.NUMBER, String.valueOf(amount), - MATERIAL_PLACEHOLDER, Utils.prettifyObject(material, user), + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, points)); } @@ -161,7 +177,7 @@ private void performHandDonation(User user, Island island, Material material, in * donatable blocks remain in the inventory. */ private boolean handleInvDonation(User user, Island island) { - Map totals = collectDonatableTotals(user.getPlayer().getInventory()); + Map totals = collectDonatableTotals(user.getPlayer().getInventory()); if (totals.isEmpty()) { user.sendMessage("island.donate.empty"); @@ -171,13 +187,17 @@ private boolean handleInvDonation(User user, Island island) { long totalPoints = 0L; StringBuilder prompt = new StringBuilder( user.getTranslation("island.donate.inv.confirm-header")); - for (Map.Entry e : totals.entrySet()) { - int value = addon.getBlockConfig().getValue(getWorld(), e.getKey()); + for (Map.Entry e : totals.entrySet()) { + // Resolve back to Material when possible for a nicer display label + Object displayKey; + Material mat = Material.matchMaterial(e.getKey()); + displayKey = mat != null ? mat : e.getKey(); + int value = addon.getBlockConfig().getValue(getWorld(), displayKey); long points = (long) value * e.getValue(); totalPoints += points; prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-line", TextVariables.NUMBER, String.valueOf(e.getValue()), - MATERIAL_PLACEHOLDER, Utils.prettifyObject(e.getKey(), user), + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, points))); } prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-total", @@ -190,7 +210,7 @@ private boolean handleInvDonation(User user, Island island) { private void performInvDonation(User user, Island island) { PlayerInventory pInv = user.getPlayer().getInventory(); ItemStack[] contents = pInv.getStorageContents(); - Map donated = new EnumMap<>(Material.class); + Map donated = new HashMap<>(); long totalPoints = 0L; for (int i = 0; i < contents.length; i++) { @@ -201,9 +221,11 @@ private void performInvDonation(User user, Island island) { } int amount = item.getAmount(); long points = (long) value * amount; - donated.merge(item.getType(), amount, Integer::sum); + String customId = addon.getCustomBlockId(item); + String donationId = customId != null ? customId : item.getType().name(); + donated.merge(donationId, amount, Integer::sum); totalPoints += points; - addon.getManager().donateBlocks(island, user.getUniqueId(), item.getType().name(), amount, points); + addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, amount, points); contents[i] = null; } pInv.setStorageContents(contents); @@ -219,11 +241,13 @@ private void performInvDonation(User user, Island island) { addon.getManager().recalculateAfterDonation(island); } - private Map collectDonatableTotals(PlayerInventory pInv) { - Map totals = new EnumMap<>(Material.class); + private Map collectDonatableTotals(PlayerInventory pInv) { + Map totals = new HashMap<>(); for (ItemStack item : pInv.getStorageContents()) { if (donationValue(item) != null) { - totals.merge(item.getType(), item.getAmount(), Integer::sum); + String customId = addon.getCustomBlockId(item); + String key = customId != null ? customId : item.getType().name(); + totals.merge(key, item.getAmount(), Integer::sum); } } return totals; @@ -234,7 +258,17 @@ private Map collectDonatableTotals(PlayerInventory pInv) { * positive configured value, or null otherwise */ private Integer donationValue(ItemStack item) { - if (item == null || item.getType().isAir() || !item.getType().isBlock()) { + if (item == null || item.getType().isAir()) { + return null; + } + // Check custom block plugins first (Oraxen, Nexo, ItemsAdder) + String customId = addon.getCustomBlockId(item); + if (customId != null) { + Integer value = addon.getBlockConfig().getValue(getWorld(), customId); + return (value != null && value > 0) ? value : null; + } + // Fall back to vanilla block check + if (!item.getType().isBlock()) { return null; } Integer value = addon.getBlockConfig().getValue(getWorld(), item.getType()); diff --git a/src/main/java/world/bentobox/level/commands/IslandValueCommand.java b/src/main/java/world/bentobox/level/commands/IslandValueCommand.java index 92141ff..3501911 100644 --- a/src/main/java/world/bentobox/level/commands/IslandValueCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandValueCommand.java @@ -11,12 +11,9 @@ import org.bukkit.inventory.PlayerInventory; import org.eclipse.jdt.annotation.NonNull; -import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.hooks.ItemsAdderHook; -import world.bentobox.bentobox.hooks.OraxenHook; import world.bentobox.bentobox.util.Util; import world.bentobox.level.Level; import world.bentobox.level.objects.IslandLevels; @@ -80,21 +77,11 @@ private void executeHandCommand(User user) { return; } - // Oraxen - if (BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) { - String id = OraxenHook.getIdByItem(mainHandItem); - if (id != null) { - printValue(user, "oraxen:" + id); - return; - } - } - // ItemsAdder - if (addon.isItemsAdder()) { - Optional id = ItemsAdderHook.getNamespacedId(mainHandItem); - if (id.isPresent()) { - printValue(user, id.get()); - return; - } + // Check custom block plugins first (Oraxen, Nexo, ItemsAdder) + String customId = addon.getCustomBlockId(mainHandItem); + if (customId != null) { + printValue(user, customId); + return; } printValue(user, mainHandItem.getType()); diff --git a/src/main/java/world/bentobox/level/panels/DetailsPanel.java b/src/main/java/world/bentobox/level/panels/DetailsPanel.java index d19769b..3a27485 100644 --- a/src/main/java/world/bentobox/level/panels/DetailsPanel.java +++ b/src/main/java/world/bentobox/level/panels/DetailsPanel.java @@ -12,8 +12,10 @@ import org.bukkit.inventory.ItemStack; import com.google.common.base.Enums; +import com.nexomc.nexo.api.NexoItems; import lv.id.bonne.panelutils.PanelUtils; +import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.panels.PanelItem; import world.bentobox.bentobox.api.panels.TemplatedPanel; @@ -23,6 +25,7 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.hooks.ItemsAdderHook; +import world.bentobox.bentobox.hooks.OraxenHook; import world.bentobox.level.Level; import world.bentobox.level.objects.IslandLevels; import world.bentobox.level.util.Utils; @@ -738,14 +741,38 @@ private BlockDataRec getBlockData(Object key) { Objects.requireNonNullElse(this.addon.getBlockConfig().getLimit(e), 0), Utils.prettifyObject(key, this.user), this.user.getTranslation(this.world, "level.gui.buttons.spawner.block-name")); - } else if (key instanceof String s && addon.isItemsAdder()) { - Optional opt = ItemsAdderHook.getItemStack(s); - ItemStack icon = opt.orElse(new ItemStack(Material.PAPER)); - String disp = opt.filter(is -> is.getItemMeta().hasDisplayName()) - .map(is -> is.getItemMeta().getDisplayName()).orElse(Utils.prettifyObject(key, this.user)); - return new BlockDataRec(icon, this.user.getTranslationOrNothing(ref + "id", "[id]", s), + } else if (key instanceof String s) { + ItemStack icon = new ItemStack(Material.PAPER); + String disp = Utils.prettifyObject(s, this.user); + + if (s.startsWith("oraxen:") && BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) { + Optional opt = OraxenHook.getOptionalItemById(s.substring(7)); + if (opt.isPresent()) { + icon = opt.get().build(); + if (icon.getItemMeta() != null && icon.getItemMeta().hasDisplayName()) { + disp = icon.getItemMeta().getDisplayName(); + } + } + } else if (s.startsWith("nexo:") && addon.isNexo()) { + com.nexomc.nexo.items.ItemBuilder nexoItem = NexoItems.itemFromId(s.substring(5)); + if (nexoItem != null) { + icon = nexoItem.build(); + if (icon.getItemMeta() != null && icon.getItemMeta().hasDisplayName()) { + disp = icon.getItemMeta().getDisplayName(); + } + } + } else if (addon.isItemsAdder() && ItemsAdderHook.isInRegistry(s)) { + Optional opt = ItemsAdderHook.getItemStack(s); + icon = opt.orElse(new ItemStack(Material.PAPER)); + disp = opt.filter(is -> is.getItemMeta() != null && is.getItemMeta().hasDisplayName()) + .map(is -> is.getItemMeta().getDisplayName()).orElse(Utils.prettifyObject(s, this.user)); + } + + return new BlockDataRec(icon, + this.user.getTranslationOrNothing(ref + "id", "[id]", s), this.addon.getBlockConfig().getBlockValues().getOrDefault(s, 0), - Objects.requireNonNullElse(this.addon.getBlockConfig().getLimit(s), 0), disp, ""); + Objects.requireNonNullElse(this.addon.getBlockConfig().getLimit(s), 0), + disp, ""); } return new BlockDataRec(new ItemStack(Material.PAPER), "", 0, 0, Utils.prettifyObject(key, this.user), ""); } diff --git a/src/main/java/world/bentobox/level/panels/DonationPanel.java b/src/main/java/world/bentobox/level/panels/DonationPanel.java index ccb5ae7..760611e 100644 --- a/src/main/java/world/bentobox/level/panels/DonationPanel.java +++ b/src/main/java/world/bentobox/level/panels/DonationPanel.java @@ -137,7 +137,10 @@ private long calculateDonationValue() { for (int slot : layout.donationSlots) { ItemStack item = inventory.getItem(slot); if (item != null && !item.getType().isAir()) { - Integer value = addon.getBlockConfig().getValue(world, item.getType()); + String customId = addon.getCustomBlockId(item); + Integer value = customId != null + ? addon.getBlockConfig().getValue(world, customId) + : addon.getBlockConfig().getValue(world, item.getType()); if (value != null && value > 0) { total += (long) value * item.getAmount(); } @@ -176,15 +179,24 @@ private void processDonation() { for (int slot : layout.donationSlots) { ItemStack item = inventory.getItem(slot); if (item != null && !item.getType().isAir()) { - Material mat = item.getType(); - Integer value = addon.getBlockConfig().getValue(world, mat); + String customId = addon.getCustomBlockId(item); + String donationId; + Integer value; + if (customId != null) { + value = addon.getBlockConfig().getValue(world, customId); + donationId = customId; + } else { + Material mat = item.getType(); + value = addon.getBlockConfig().getValue(world, mat); + donationId = mat.name(); + } if (value != null && value > 0) { int count = item.getAmount(); long points = (long) value * count; - donations.merge(mat.name(), count, Integer::sum); + donations.merge(donationId, count, Integer::sum); totalPoints += points; // Record each material type as a separate donation log entry - addon.getManager().donateBlocks(island, user.getUniqueId(), mat.name(), count, points); + addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, count, points); // Clear the slot - items are consumed inventory.setItem(slot, null); } else { @@ -337,7 +349,17 @@ private void handleDonationSlotClick(int slot, Player player) { } private boolean isValidDonationItem(ItemStack item) { - if (item == null || item.getType().isAir() || !item.getType().isBlock()) { + if (item == null || item.getType().isAir()) { + return false; + } + // Check custom block plugins first (Oraxen, Nexo, ItemsAdder) + String customId = addon.getCustomBlockId(item); + if (customId != null) { + Integer value = addon.getBlockConfig().getValue(world, customId); + return value != null && value > 0; + } + // Fall back to vanilla block check + if (!item.getType().isBlock()) { return false; } Integer value = addon.getBlockConfig().getValue(world, item.getType()); diff --git a/src/main/java/world/bentobox/level/panels/ValuePanel.java b/src/main/java/world/bentobox/level/panels/ValuePanel.java index 70a0bf1..23361ed 100644 --- a/src/main/java/world/bentobox/level/panels/ValuePanel.java +++ b/src/main/java/world/bentobox/level/panels/ValuePanel.java @@ -28,6 +28,7 @@ import world.bentobox.bentobox.api.panels.builders.TemplatedPanelBuilder; import world.bentobox.bentobox.api.panels.reader.ItemTemplateRecord; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.hooks.CraftEngineHook; import world.bentobox.bentobox.hooks.ItemsAdderHook; import world.bentobox.bentobox.util.Util; import world.bentobox.level.Level; @@ -735,6 +736,14 @@ private Material getIcon(String key) { if (key.startsWith("oraxen:") && BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) { return Material.PAPER; } + // Try Nexo + if (key.startsWith("nexo:") && addon.isNexo()) { + return Material.PAPER; + } + // Try CraftEngine — IDs are already namespaced (e.g. "mynamespace:my_block") + if (addon.isCraftEngine() && CraftEngineHook.exists(key)) { + return Material.PAPER; + } return null; } @@ -769,6 +778,8 @@ private PanelItem createMaterialButton(ItemTemplateRecord template, BlockRecord builder.icon((icon == null || icon == Material.AIR) ? Material.PAPER : icon); if (key.startsWith("oraxen:")) { key = key.substring(7); + } else if (key.startsWith("nexo:")) { + key = key.substring(5); } String displayMaterial = (icon == null) ? Util.prettifyText(key) : Utils.prettifyObject(key, user); // Special handling for spawn eggs From 9c3e38a38da1865c183f9f6d0476fc4f952c0588 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 19:11:25 +0000 Subject: [PATCH 07/17] Refactor: extract getCustomBlockItemStack helper, add null guard for inv donation value Agent-Logs-Url: https://github.com/BentoBoxWorld/Level/sessions/daf183a6-549f-4946-900c-5719c0a27e81 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../level/commands/IslandDonateCommand.java | 12 +++-- .../bentobox/level/panels/DetailsPanel.java | 53 ++++++++++--------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index cd3b682..520298c 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -188,12 +188,14 @@ private boolean handleInvDonation(User user, Island island) { StringBuilder prompt = new StringBuilder( user.getTranslation("island.donate.inv.confirm-header")); for (Map.Entry e : totals.entrySet()) { - // Resolve back to Material when possible for a nicer display label - Object displayKey; + // Resolve back to Material when possible for correct key-casing in getValue() + // (vanilla keys are stored uppercase by material.name(), but blockValues uses lowercase). + // For custom blocks the key is already the right namespaced string. Material mat = Material.matchMaterial(e.getKey()); - displayKey = mat != null ? mat : e.getKey(); - int value = addon.getBlockConfig().getValue(getWorld(), displayKey); - long points = (long) value * e.getValue(); + Object displayKey = mat != null ? mat : e.getKey(); + Integer rawValue = addon.getBlockConfig().getValue(getWorld(), displayKey); + if (rawValue == null) continue; + long points = (long) rawValue * e.getValue(); totalPoints += points; prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-line", TextVariables.NUMBER, String.valueOf(e.getValue()), diff --git a/src/main/java/world/bentobox/level/panels/DetailsPanel.java b/src/main/java/world/bentobox/level/panels/DetailsPanel.java index 3a27485..6e345b7 100644 --- a/src/main/java/world/bentobox/level/panels/DetailsPanel.java +++ b/src/main/java/world/bentobox/level/panels/DetailsPanel.java @@ -742,31 +742,12 @@ private BlockDataRec getBlockData(Object key) { Utils.prettifyObject(key, this.user), this.user.getTranslation(this.world, "level.gui.buttons.spawner.block-name")); } else if (key instanceof String s) { - ItemStack icon = new ItemStack(Material.PAPER); - String disp = Utils.prettifyObject(s, this.user); - - if (s.startsWith("oraxen:") && BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) { - Optional opt = OraxenHook.getOptionalItemById(s.substring(7)); - if (opt.isPresent()) { - icon = opt.get().build(); - if (icon.getItemMeta() != null && icon.getItemMeta().hasDisplayName()) { - disp = icon.getItemMeta().getDisplayName(); - } - } - } else if (s.startsWith("nexo:") && addon.isNexo()) { - com.nexomc.nexo.items.ItemBuilder nexoItem = NexoItems.itemFromId(s.substring(5)); - if (nexoItem != null) { - icon = nexoItem.build(); - if (icon.getItemMeta() != null && icon.getItemMeta().hasDisplayName()) { - disp = icon.getItemMeta().getDisplayName(); - } - } - } else if (addon.isItemsAdder() && ItemsAdderHook.isInRegistry(s)) { - Optional opt = ItemsAdderHook.getItemStack(s); - icon = opt.orElse(new ItemStack(Material.PAPER)); - disp = opt.filter(is -> is.getItemMeta() != null && is.getItemMeta().hasDisplayName()) - .map(is -> is.getItemMeta().getDisplayName()).orElse(Utils.prettifyObject(s, this.user)); - } + Optional optItem = getCustomBlockItemStack(s); + ItemStack icon = optItem.orElse(new ItemStack(Material.PAPER)); + String disp = optItem + .filter(is -> is.getItemMeta() != null && is.getItemMeta().hasDisplayName()) + .map(is -> is.getItemMeta().getDisplayName()) + .orElse(Utils.prettifyObject(s, this.user)); return new BlockDataRec(icon, this.user.getTranslationOrNothing(ref + "id", "[id]", s), @@ -777,6 +758,28 @@ private BlockDataRec getBlockData(Object key) { return new BlockDataRec(new ItemStack(Material.PAPER), "", 0, 0, Utils.prettifyObject(key, this.user), ""); } + /** + * Returns the best available ItemStack for a custom-block string ID. + * Checks Oraxen, Nexo, and ItemsAdder in order; returns empty when none matches. + * + * @param id the custom block ID (e.g. "oraxen:my_block", "nexo:my_block", or an ItemsAdder ID) + * @return an Optional containing the representative ItemStack, or empty + */ + private Optional getCustomBlockItemStack(String id) { + if (id.startsWith("oraxen:") && BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) { + return OraxenHook.getOptionalItemById(id.substring(7)) + .map(ib -> ib.build()); + } + if (id.startsWith("nexo:") && addon.isNexo()) { + com.nexomc.nexo.items.ItemBuilder nexoItem = NexoItems.itemFromId(id.substring(5)); + return nexoItem != null ? Optional.of(nexoItem.build()) : Optional.empty(); + } + if (addon.isItemsAdder() && ItemsAdderHook.isInRegistry(id)) { + return ItemsAdderHook.getItemStack(id); + } + return Optional.empty(); + } + // --------------------------------------------------------------------- // Section: Other Methods From 7d5b8dbb5e94a292b49e78676da8888af0711f40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 19:13:56 +0000 Subject: [PATCH 08/17] Polish: improve clarity of comments and variable names from code review Agent-Logs-Url: https://github.com/BentoBoxWorld/Level/sessions/daf183a6-549f-4946-900c-5719c0a27e81 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../bentobox/level/commands/IslandDonateCommand.java | 8 +++++--- .../java/world/bentobox/level/panels/DetailsPanel.java | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index 520298c..80af368 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -188,9 +188,11 @@ private boolean handleInvDonation(User user, Island island) { StringBuilder prompt = new StringBuilder( user.getTranslation("island.donate.inv.confirm-header")); for (Map.Entry e : totals.entrySet()) { - // Resolve back to Material when possible for correct key-casing in getValue() - // (vanilla keys are stored uppercase by material.name(), but blockValues uses lowercase). - // For custom blocks the key is already the right namespaced string. + // Vanilla keys are stored as Material.name() (e.g. "STONE"), but blockValues uses + // the lowercase namespaced key (e.g. "stone"). Resolving to a Material first lets + // getValue() derive the correct lowercase key via material.getKey().getKey(). + // Custom-block keys (e.g. "oraxen:my_block") do not match any Material, so they + // are passed through as Strings and match blockValues directly. Material mat = Material.matchMaterial(e.getKey()); Object displayKey = mat != null ? mat : e.getKey(); Integer rawValue = addon.getBlockConfig().getValue(getWorld(), displayKey); diff --git a/src/main/java/world/bentobox/level/panels/DetailsPanel.java b/src/main/java/world/bentobox/level/panels/DetailsPanel.java index 6e345b7..2445cc9 100644 --- a/src/main/java/world/bentobox/level/panels/DetailsPanel.java +++ b/src/main/java/world/bentobox/level/panels/DetailsPanel.java @@ -768,11 +768,11 @@ private BlockDataRec getBlockData(Object key) { private Optional getCustomBlockItemStack(String id) { if (id.startsWith("oraxen:") && BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) { return OraxenHook.getOptionalItemById(id.substring(7)) - .map(ib -> ib.build()); + .map(itemBuilder -> itemBuilder.build()); } if (id.startsWith("nexo:") && addon.isNexo()) { - com.nexomc.nexo.items.ItemBuilder nexoItem = NexoItems.itemFromId(id.substring(5)); - return nexoItem != null ? Optional.of(nexoItem.build()) : Optional.empty(); + com.nexomc.nexo.items.ItemBuilder nexoBuilder = NexoItems.itemFromId(id.substring(5)); + return nexoBuilder != null ? Optional.of(nexoBuilder.build()) : Optional.empty(); } if (addon.isItemsAdder() && ItemsAdderHook.isInRegistry(id)) { return ItemsAdderHook.getItemStack(id); From 60abaf93d8629bf7b3d8ebfb3f023a98bee07c4a Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 7 May 2026 17:47:54 -0700 Subject: [PATCH 09/17] perf: replace O(N) linear walks with binary search in tidyUp() The forward and backward point-boundary scans could each iterate up to 10M times calling calculateLevel() (string parse + eval) on the primary thread. Binary search reduces this to ~23 iterations. Also adds missing ResultsTest coverage for pointsFromCurrentLevel. Co-Authored-By: Claude Opus 4.6 --- .../calculators/IslandLevelCalculator.java | 46 ++++++++++--------- .../level/commands/IslandLevelCommand.java | 6 +-- .../level/calculators/ResultsTest.java | 13 ++++++ 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index b632aca..8ef95dc 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -750,30 +750,34 @@ public void tidyUp() { } this.results.level.set(calculateLevel(blockAndDeathPoints)); - // Calculate how many points are required to get to the next level + // Binary search for points to next level (first point count that exceeds currentLevel) long currentLevel = this.results.level.get(); - long nextLevel = currentLevel; - long blocks = blockAndDeathPoints; - while (nextLevel < currentLevel + 1 && blocks - blockAndDeathPoints < MAX_AMOUNT) { - nextLevel = calculateLevel(++blocks); - } - this.results.pointsToNextLevel.set(blocks - blockAndDeathPoints); - - // Calculate how many points have been accumulated within the current level by - // walking back to the smallest block count that still yields the current level. - // For non-linear level formulas the per-level interval is not equal to the - // configured level cost, so we derive the actual interval here. - // Floor the probe at initialCount when zeroing new island levels: calculateLevel - // subtracts initialCount internally, so probing below it produces negative - // modifiedPoints which yield NaN (sqrt, log) or negative fractions that truncate - // to 0 — both compare >= to a level-0 island and would walk the loop to MAX_AMOUNT. + long lo = blockAndDeathPoints + 1; + long hi = blockAndDeathPoints + MAX_AMOUNT; + while (lo < hi) { + long mid = lo + (hi - lo) / 2; + if (calculateLevel(mid) > currentLevel) { + hi = mid; + } else { + lo = mid + 1; + } + } + this.results.pointsToNextLevel.set(lo - blockAndDeathPoints); + + // Binary search for points accumulated within the current level. + // Floor at initialCount when zeroing new islands to avoid negative/NaN in non-linear formulas. long minBlocks = addon.getSettings().isZeroNewIslandLevels() ? results.initialCount.get() : 0; - long lowerBlocks = blockAndDeathPoints; - while (lowerBlocks > minBlocks && blockAndDeathPoints - lowerBlocks < MAX_AMOUNT - && calculateLevel(lowerBlocks - 1) >= currentLevel) { - lowerBlocks--; + lo = Math.max(minBlocks, blockAndDeathPoints - MAX_AMOUNT); + hi = blockAndDeathPoints; + while (lo < hi) { + long mid = lo + (hi - lo) / 2; + if (calculateLevel(mid) >= currentLevel) { + hi = mid; + } else { + lo = mid + 1; + } } - this.results.pointsFromCurrentLevel.set(blockAndDeathPoints - lowerBlocks); + this.results.pointsFromCurrentLevel.set(blockAndDeathPoints - lo); // Report results.report = getReport(); diff --git a/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java b/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java index 5e4cd3d..1d4e0af 100644 --- a/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java @@ -110,11 +110,7 @@ private void showResult(User user, UUID playerUUID, Island island, long oldLevel if (addon.getSettings().getDeathPenalty() != 0) { user.sendMessage("island.level.deaths", "[number]", String.valueOf(results.getDeathHandicap())); } - // Send player how many points are required to reach next island level. - // Use the actual interval between the current and next level so the progress - // ratio is correct for non-linear level formulas (log/sqrt/etc.) — the - // configured level_cost only matches the interval for the default linear - // formula and would otherwise yield negative or misleading values. + // Send player how many points are required to reach next island level if (results.getPointsToNextLevel() >= 0) { long interval = results.getPointsFromCurrentLevel() + results.getPointsToNextLevel(); if (interval <= 0) { diff --git a/src/test/java/world/bentobox/level/calculators/ResultsTest.java b/src/test/java/world/bentobox/level/calculators/ResultsTest.java index 66cf47d..7045ff8 100644 --- a/src/test/java/world/bentobox/level/calculators/ResultsTest.java +++ b/src/test/java/world/bentobox/level/calculators/ResultsTest.java @@ -94,6 +94,19 @@ void testDefaultDeathHandicapIsZero() { assertEquals(0, results.getDeathHandicap()); } + // --- Points from current level --- + + @Test + void testSetAndGetPointsFromCurrentLevel() { + results.setPointsFromCurrentLevel(75L); + assertEquals(75L, results.getPointsFromCurrentLevel()); + } + + @Test + void testDefaultPointsFromCurrentLevelIsZero() { + assertEquals(0L, results.getPointsFromCurrentLevel()); + } + // --- Donated points --- @Test From 58e35bb3dddbf8e99901ceb61562ae54ba1311bd Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 8 May 2026 15:25:02 -0700 Subject: [PATCH 10/17] fix: show custom-block icon and display name in value panel (#426) The /is value panel rendered Oraxen/Nexo/ItemsAdder blocks as paper with the prettified config key as the name. Look up the real ItemStack from the plugin's registry and use it as the icon, plus the item meta display name when present. Lookup helper is extracted to Utils so DetailsPanel and ValuePanel share it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../bentobox/level/panels/DetailsPanel.java | 34 +------------- .../bentobox/level/panels/ValuePanel.java | 38 ++++++++++----- .../java/world/bentobox/level/util/Utils.java | 47 +++++++++++++++++++ 3 files changed, 75 insertions(+), 44 deletions(-) diff --git a/src/main/java/world/bentobox/level/panels/DetailsPanel.java b/src/main/java/world/bentobox/level/panels/DetailsPanel.java index 2445cc9..3bd84fd 100644 --- a/src/main/java/world/bentobox/level/panels/DetailsPanel.java +++ b/src/main/java/world/bentobox/level/panels/DetailsPanel.java @@ -12,10 +12,8 @@ import org.bukkit.inventory.ItemStack; import com.google.common.base.Enums; -import com.nexomc.nexo.api.NexoItems; import lv.id.bonne.panelutils.PanelUtils; -import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.panels.PanelItem; import world.bentobox.bentobox.api.panels.TemplatedPanel; @@ -24,8 +22,6 @@ import world.bentobox.bentobox.api.panels.reader.ItemTemplateRecord; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.bentobox.hooks.ItemsAdderHook; -import world.bentobox.bentobox.hooks.OraxenHook; import world.bentobox.level.Level; import world.bentobox.level.objects.IslandLevels; import world.bentobox.level.util.Utils; @@ -742,12 +738,9 @@ private BlockDataRec getBlockData(Object key) { Utils.prettifyObject(key, this.user), this.user.getTranslation(this.world, "level.gui.buttons.spawner.block-name")); } else if (key instanceof String s) { - Optional optItem = getCustomBlockItemStack(s); + Optional optItem = Utils.getCustomBlockItemStack(addon, s); ItemStack icon = optItem.orElse(new ItemStack(Material.PAPER)); - String disp = optItem - .filter(is -> is.getItemMeta() != null && is.getItemMeta().hasDisplayName()) - .map(is -> is.getItemMeta().getDisplayName()) - .orElse(Utils.prettifyObject(s, this.user)); + String disp = Utils.getCustomBlockDisplayName(optItem, s, this.user); return new BlockDataRec(icon, this.user.getTranslationOrNothing(ref + "id", "[id]", s), @@ -758,29 +751,6 @@ private BlockDataRec getBlockData(Object key) { return new BlockDataRec(new ItemStack(Material.PAPER), "", 0, 0, Utils.prettifyObject(key, this.user), ""); } - /** - * Returns the best available ItemStack for a custom-block string ID. - * Checks Oraxen, Nexo, and ItemsAdder in order; returns empty when none matches. - * - * @param id the custom block ID (e.g. "oraxen:my_block", "nexo:my_block", or an ItemsAdder ID) - * @return an Optional containing the representative ItemStack, or empty - */ - private Optional getCustomBlockItemStack(String id) { - if (id.startsWith("oraxen:") && BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) { - return OraxenHook.getOptionalItemById(id.substring(7)) - .map(itemBuilder -> itemBuilder.build()); - } - if (id.startsWith("nexo:") && addon.isNexo()) { - com.nexomc.nexo.items.ItemBuilder nexoBuilder = NexoItems.itemFromId(id.substring(5)); - return nexoBuilder != null ? Optional.of(nexoBuilder.build()) : Optional.empty(); - } - if (addon.isItemsAdder() && ItemsAdderHook.isInRegistry(id)) { - return ItemsAdderHook.getItemStack(id); - } - return Optional.empty(); - } - - // --------------------------------------------------------------------- // Section: Other Methods // --------------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/level/panels/ValuePanel.java b/src/main/java/world/bentobox/level/panels/ValuePanel.java index 23361ed..7a7695f 100644 --- a/src/main/java/world/bentobox/level/panels/ValuePanel.java +++ b/src/main/java/world/bentobox/level/panels/ValuePanel.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -668,6 +669,16 @@ private PanelItem createMaterialButton(ItemTemplateRecord template, TemplatedPan return this.createMaterialButton(template, this.elementList.get(index)); } + private static String stripCustomPrefix(String key) { + if (key.startsWith("oraxen:")) { + return key.substring(7); + } + if (key.startsWith("nexo:")) { + return key.substring(5); + } + return key; + } + private Material getIcon(String key) { // Filter out some names key = key.replaceAll("wall_", ""); @@ -773,18 +784,21 @@ private PanelItem createMaterialButton(ItemTemplateRecord template, BlockRecord ? this.user.getTranslationOrNothing(baseKey + "limit", TextVariables.NUMBER, String.valueOf(blockLimit)) : ""; - // Determine icon and display material text - Material icon = getIcon(key); - builder.icon((icon == null || icon == Material.AIR) ? Material.PAPER : icon); - if (key.startsWith("oraxen:")) { - key = key.substring(7); - } else if (key.startsWith("nexo:")) { - key = key.substring(5); - } - String displayMaterial = (icon == null) ? Util.prettifyText(key) : Utils.prettifyObject(key, user); - // Special handling for spawn eggs - if (icon != null && icon.name().endsWith("_SPAWN_EGG")) { - displayMaterial = Util.prettifyText(key); + // Prefer the actual custom-block ItemStack (real texture + display name) over a fallback Material icon. + Optional customStack = Utils.getCustomBlockItemStack(addon, key); + String displayMaterial; + if (customStack.isPresent()) { + builder.icon(customStack.get().clone()); + displayMaterial = Utils.getCustomBlockDisplayName(customStack, stripCustomPrefix(key), user); + } else { + Material icon = getIcon(key); + builder.icon((icon == null || icon == Material.AIR) ? Material.PAPER : icon); + String stripped = stripCustomPrefix(key); + displayMaterial = (icon == null) ? Util.prettifyText(stripped) : Utils.prettifyObject(stripped, user); + // Special handling for spawn eggs + if (icon != null && icon.name().endsWith("_SPAWN_EGG")) { + displayMaterial = Util.prettifyText(stripped); + } } // Set button title if available diff --git a/src/main/java/world/bentobox/level/util/Utils.java b/src/main/java/world/bentobox/level/util/Utils.java index b807cb9..2a40434 100644 --- a/src/main/java/world/bentobox/level/util/Utils.java +++ b/src/main/java/world/bentobox/level/util/Utils.java @@ -9,13 +9,21 @@ import java.text.NumberFormat; import java.util.List; +import java.util.Optional; import org.bukkit.Material; import org.bukkit.entity.EntityType; +import org.bukkit.inventory.ItemStack; import org.bukkit.permissions.PermissionAttachmentInfo; +import com.nexomc.nexo.api.NexoItems; + +import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.hooks.ItemsAdderHook; import world.bentobox.bentobox.hooks.LangUtilsHook; +import world.bentobox.bentobox.hooks.OraxenHook; +import world.bentobox.level.Level; public class Utils @@ -216,6 +224,45 @@ public static String prettifyObject(Object object, User user) { return ""; } + /** + * Returns the best available ItemStack for a custom-block string ID. + * Checks Oraxen, Nexo, and ItemsAdder in order; returns empty when none matches. + * + * @param addon the Level addon + * @param id the custom block ID (e.g. "oraxen:my_block", "nexo:my_block", or an ItemsAdder ID) + * @return an Optional containing the representative ItemStack, or empty + */ + public static Optional getCustomBlockItemStack(Level addon, String id) { + if (id == null) { + return Optional.empty(); + } + if (id.startsWith("oraxen:") && BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) { + return OraxenHook.getOptionalItemById(id.substring(7)).map(itemBuilder -> itemBuilder.build()); + } + if (id.startsWith("nexo:") && addon.isNexo()) { + com.nexomc.nexo.items.ItemBuilder nexoBuilder = NexoItems.itemFromId(id.substring(5)); + return nexoBuilder != null ? Optional.of(nexoBuilder.build()) : Optional.empty(); + } + if (addon.isItemsAdder() && ItemsAdderHook.isInRegistry(id)) { + return ItemsAdderHook.getItemStack(id); + } + return Optional.empty(); + } + + /** + * Returns the display name from an ItemStack's meta when present, otherwise falls back to + * {@link #prettifyObject(Object, User)} on the original key. + * + * @param itemStack the optional ItemStack (typically from a custom-block plugin) + * @param key the raw key used as a fallback for prettification + * @param user the user for translation lookups + * @return the human-readable display name + */ + public static String getCustomBlockDisplayName(Optional itemStack, String key, User user) { + return itemStack.filter(is -> is.getItemMeta() != null && is.getItemMeta().hasDisplayName()) + .map(is -> is.getItemMeta().getDisplayName()).orElse(prettifyObject(key, user)); + } + public static String prettifyDescription(Object object, User user) { if (object instanceof String key) { String translation = user.getTranslationOrNothing(LEVEL_MATERIALS + key + DESCRIPTION); From 9708fc07085c2577c303935a715262b84789ee5f Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 9 May 2026 14:19:55 -0700 Subject: [PATCH 11/17] fix: recognize CraftEngine items in value lookups (#428) Three call sites were missing CraftEngine support, so /is value hand returned "no value" for held CraftEngine items, the value panel showed a paper icon with the prettified namespaced key, and the chat output used the raw key instead of the configured display name: - Level.getCustomBlockId now checks CraftEngineHook.getItemId, so a held custom item is mapped to its namespaced ID. - Utils.getCustomBlockItemStack now delegates to CraftEngineHook.getItemStack so the panel renders the real icon. - Utils.getCustomBlockDisplayName now also reads the modern minecraft:item_name component (hasItemName/getItemName) used by CraftEngine, in addition to the legacy display.Name used by Oraxen, Nexo, and ItemsAdder. - IslandValueCommand.printValue routes string IDs through the same getCustomBlockItemStack/getCustomBlockDisplayName path used by the panel, so the chat message shows the configured display name. Requires BentoBox >= 3.15.1-SNAPSHOT (BentoBoxWorld/BentoBox#2973 for the new getItemId helper). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/world/bentobox/level/Level.java | 15 ++++++++--- .../level/commands/IslandValueCommand.java | 8 ++++-- .../java/world/bentobox/level/util/Utils.java | 25 ++++++++++++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/main/java/world/bentobox/level/Level.java b/src/main/java/world/bentobox/level/Level.java index bfb89d3..96a2cf6 100644 --- a/src/main/java/world/bentobox/level/Level.java +++ b/src/main/java/world/bentobox/level/Level.java @@ -25,6 +25,7 @@ import world.bentobox.bentobox.api.flags.Flag; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.hooks.CraftEngineHook; import world.bentobox.bentobox.hooks.ItemsAdderHook; import world.bentobox.bentobox.hooks.OraxenHook; import world.bentobox.bentobox.managers.RanksManager; @@ -516,12 +517,13 @@ public boolean isItemsAdder() { /** * Returns the custom-block plugin ID for an ItemStack, checking Oraxen, Nexo, - * and ItemsAdder in that order. Returns {@code null} when the item is not - * recognized as a custom block by any supported plugin. + * ItemsAdder, and CraftEngine in that order. Returns {@code null} when the item + * is not recognized as a custom block by any supported plugin. * * @param item the ItemStack to check (may be null) * @return a namespaced custom-block ID such as {@code "oraxen:my_block"}, - * {@code "nexo:my_block"}, or an ItemsAdder ID, or {@code null} + * {@code "nexo:my_block"}, an ItemsAdder ID, or a CraftEngine ID + * (e.g. {@code "default:my_block"}), or {@code null} */ @Nullable public String getCustomBlockId(ItemStack item) { @@ -549,6 +551,13 @@ public String getCustomBlockId(ItemStack item) { return id.get(); } } + // Check CraftEngine — IDs are already namespaced (e.g. "mynamespace:my_block") + if (isCraftEngine()) { + String id = CraftEngineHook.getItemId(item); + if (id != null) { + return id; + } + } return null; } diff --git a/src/main/java/world/bentobox/level/commands/IslandValueCommand.java b/src/main/java/world/bentobox/level/commands/IslandValueCommand.java index 3501911..098d0f2 100644 --- a/src/main/java/world/bentobox/level/commands/IslandValueCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandValueCommand.java @@ -107,14 +107,18 @@ private void printValue(User user, Object material) if (value != null) { + String displayName = (material instanceof String id) + ? Utils.getCustomBlockDisplayName(Utils.getCustomBlockItemStack(addon, id), id, user) + : Utils.prettifyObject(material, user); + Utils.sendMessage(user, user.getTranslation(this.getWorld(), "level.conversations.value", "[value]", - String.valueOf(value), MATERIAL, Utils.prettifyObject(material, user))); + String.valueOf(value), MATERIAL, displayName)); double underWater = this.addon.getSettings().getUnderWaterMultiplier(); if (underWater > 1.0) { Utils.sendMessage(user, user.getTranslation(this.getWorld(), "level.conversations.success-underwater", - "[value]", (underWater * value) + ""), MATERIAL, Utils.prettifyObject(material, user)); + "[value]", (underWater * value) + ""), MATERIAL, displayName); } // Show how many have been placed and how many are allowed diff --git a/src/main/java/world/bentobox/level/util/Utils.java b/src/main/java/world/bentobox/level/util/Utils.java index 2a40434..e585e2d 100644 --- a/src/main/java/world/bentobox/level/util/Utils.java +++ b/src/main/java/world/bentobox/level/util/Utils.java @@ -20,6 +20,7 @@ import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.hooks.CraftEngineHook; import world.bentobox.bentobox.hooks.ItemsAdderHook; import world.bentobox.bentobox.hooks.LangUtilsHook; import world.bentobox.bentobox.hooks.OraxenHook; @@ -226,10 +227,11 @@ public static String prettifyObject(Object object, User user) { /** * Returns the best available ItemStack for a custom-block string ID. - * Checks Oraxen, Nexo, and ItemsAdder in order; returns empty when none matches. + * Checks Oraxen, Nexo, ItemsAdder, and CraftEngine in order; returns empty when none matches. * * @param addon the Level addon - * @param id the custom block ID (e.g. "oraxen:my_block", "nexo:my_block", or an ItemsAdder ID) + * @param id the custom block ID (e.g. "oraxen:my_block", "nexo:my_block", an ItemsAdder ID, + * or a CraftEngine ID such as "default:my_block") * @return an Optional containing the representative ItemStack, or empty */ public static Optional getCustomBlockItemStack(Level addon, String id) { @@ -246,12 +248,18 @@ public static Optional getCustomBlockItemStack(Level addon, String id if (addon.isItemsAdder() && ItemsAdderHook.isInRegistry(id)) { return ItemsAdderHook.getItemStack(id); } + if (addon.isCraftEngine()) { + return CraftEngineHook.getItemStack(id); + } return Optional.empty(); } /** * Returns the display name from an ItemStack's meta when present, otherwise falls back to * {@link #prettifyObject(Object, User)} on the original key. + *

+ * Checks the legacy {@code display.Name} (used by Oraxen, Nexo, ItemsAdder) and then the + * modern {@code minecraft:item_name} component (used by CraftEngine and other 1.20.5+ items). * * @param itemStack the optional ItemStack (typically from a custom-block plugin) * @param key the raw key used as a fallback for prettification @@ -259,8 +267,17 @@ public static Optional getCustomBlockItemStack(Level addon, String id * @return the human-readable display name */ public static String getCustomBlockDisplayName(Optional itemStack, String key, User user) { - return itemStack.filter(is -> is.getItemMeta() != null && is.getItemMeta().hasDisplayName()) - .map(is -> is.getItemMeta().getDisplayName()).orElse(prettifyObject(key, user)); + if (itemStack.isEmpty() || itemStack.get().getItemMeta() == null) { + return prettifyObject(key, user); + } + org.bukkit.inventory.meta.ItemMeta meta = itemStack.get().getItemMeta(); + if (meta.hasDisplayName()) { + return meta.getDisplayName(); + } + if (meta.hasItemName()) { + return meta.getItemName(); + } + return prettifyObject(key, user); } public static String prettifyDescription(Object object, User user) { From dc8f70e6ff862be2d0ccfb9d75b130b508f220a1 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 9 May 2026 15:24:24 -0700 Subject: [PATCH 12/17] fix: update BentoBox version to 3.15.1-SNAPSHOT --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5ebe28a..c18e98c 100644 --- a/pom.xml +++ b/pom.xml @@ -57,7 +57,7 @@ v1.21-SNAPSHOT 1.21.11-R0.1-SNAPSHOT - 3.15.0-SNAPSHOT + 3.15.1-SNAPSHOT 1.12.0 From 44009aaece66634a452b52b6287a1d12e13202ed Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 11 May 2026 20:05:15 -0700 Subject: [PATCH 13/17] Update API version in addon.yml --- src/main/resources/addon.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index e99df00..61e4c89 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -2,7 +2,7 @@ name: Level main: world.bentobox.level.Level version: ${version}${build.number} icon: DIAMOND -api-version: 3.14.0 +api-version: 3.16.0 authors: tastybento From 241017ff1584c3960e6bfd4733b77be75bae6c57 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 11 May 2026 20:38:17 -0700 Subject: [PATCH 14/17] feat: add donations-only mode to skip block scan (#430) Adds a `donations-only` config option that bypasses the island chunk scan entirely. The island level is computed only from blocks donated via `/island donate`, removing the per-recalculation CPU cost of scanning. When the option is enabled: - IslandLevelCalculator.scanIsland short-circuits to tidyUp(), which adds the donated points (`LevelsManager.getDonatedPoints`) and runs the configured level-calc formula on those points alone. - The /island detail command is not registered, since there are no scanned blocks to break down per the issue thread. - /island level, /island top, /island value, and /island donate continue to work; /island level reports the level based on donations only. Closes #430. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/world/bentobox/level/Level.java | 6 ++++- .../calculators/IslandLevelCalculator.java | 8 ++++++ .../bentobox/level/config/ConfigSettings.java | 26 +++++++++++++++++++ src/main/resources/config.yml | 13 ++++++++-- .../java/world/bentobox/level/LevelTest.java | 15 +++++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/level/Level.java b/src/main/java/world/bentobox/level/Level.java index 96a2cf6..82f6702 100644 --- a/src/main/java/world/bentobox/level/Level.java +++ b/src/main/java/world/bentobox/level/Level.java @@ -275,7 +275,11 @@ private void registerCommands(GameModeAddon gm) { new IslandLevelCommand(this, playerCmd); new IslandTopCommand(this, playerCmd); new IslandValueCommand(this, playerCmd); - new IslandDetailCommand(this, playerCmd); + // In donations-only mode, there are no scanned blocks to break down, + // so the detail command is not registered. + if (!getSettings().isDonationsOnly()) { + new IslandDetailCommand(this, playerCmd); + } new IslandDonateCommand(this, playerCmd); }); } diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 8ef95dc..0ec2c29 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -794,6 +794,14 @@ boolean isNotZeroIsland() { } public void scanIsland(Pipeliner pipeliner) { + // In donations-only mode, skip the chunk scan entirely. tidyUp() will add + // the donated points and compute the level from those alone. + if (addon.getSettings().isDonationsOnly()) { + pipeliner.getInProcessQueue().remove(this); + this.tidyUp(); + this.getR().complete(getResults()); + return; + } // Scan the next chunk scanNextChunk().thenAccept(result -> { if (!Bukkit.isPrimaryThread()) { diff --git a/src/main/java/world/bentobox/level/config/ConfigSettings.java b/src/main/java/world/bentobox/level/config/ConfigSettings.java index f7879a3..ef47003 100644 --- a/src/main/java/world/bentobox/level/config/ConfigSettings.java +++ b/src/main/java/world/bentobox/level/config/ConfigSettings.java @@ -54,6 +54,17 @@ public class ConfigSettings implements ConfigObject { @ConfigEntry(path = "zero-new-island-levels") private boolean zeroNewIslandLevels = true; + @ConfigComment("") + @ConfigComment("Donations-only mode") + @ConfigComment("If true, the island block scan is skipped entirely and the island level") + @ConfigComment("is computed only from blocks donated via /island donate. This removes the") + @ConfigComment("per-recalculation CPU cost of scanning the island.") + @ConfigComment("The /island detail command is not registered in this mode, since there") + @ConfigComment("are no scanned blocks to break down. /island level still works and reports") + @ConfigComment("the level based on donated blocks.") + @ConfigEntry(path = "donations-only") + private boolean donationsOnly = false; + @ConfigComment("") @ConfigComment("Calculate island level on login") @@ -509,4 +520,19 @@ public boolean isDisableItemsAdder() { public void setDisableItemsAdder(boolean disableItemsAdder) { this.disableItemsAdder = disableItemsAdder; } + + /** + * @return true if donations-only mode is enabled (block scan skipped, level + * computed from donations only, /island detail disabled) + */ + public boolean isDonationsOnly() { + return donationsOnly; + } + + /** + * @param donationsOnly the donationsOnly to set + */ + public void setDonationsOnly(boolean donationsOnly) { + this.donationsOnly = donationsOnly; + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 11aacec..c101ad8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -20,13 +20,22 @@ concurrent-island-calcs: 1 # If an island takes longer that this time to calculate, then the calculation will abort. # Generally, calculation should only take a few seconds, so if this ever triggers then something is not right. calculation-timeout: 5 -# +# # Zero island levels on new island or island reset # If true, Level will calculate the starter island's level and remove it from any future level calculations. # If false, the player's starter island blocks will count towards their level. # This will reduce CPU if false. zero-new-island-levels: true -# +# +# Donations-only mode +# If true, the island block scan is skipped entirely and the island level +# is computed only from blocks donated via /island donate. This removes the +# per-recalculation CPU cost of scanning the island. +# The /island detail command is not registered in this mode, since there +# are no scanned blocks to break down. /island level still works and reports +# the level based on donated blocks. +donations-only: false +# # Calculate island level on login # This silently calculates the player's island level when they login # This applies to all islands the player has on the server, e.g., BSkyBlock, AcidIsland diff --git a/src/test/java/world/bentobox/level/LevelTest.java b/src/test/java/world/bentobox/level/LevelTest.java index 288fec9..6323bf6 100644 --- a/src/test/java/world/bentobox/level/LevelTest.java +++ b/src/test/java/world/bentobox/level/LevelTest.java @@ -247,4 +247,19 @@ void testGetSettings() { assertEquals(100, s.getLevelCost()); } + /** + * Donations-only mode must not register the IslandDetailCommand, so we expect + * four player commands instead of the usual five. + */ + @Test + void testAllLoadedDonationsOnlySkipsDetailCommand() { + mockedBukkit.when(() -> Bukkit.getWorld("acidisland_world")).thenReturn(null); + addon.getSettings().setDonationsOnly(true); + addon.allLoaded(); + // 4 player commands (level, top, value, donate) — no detail + verify(cmd, times(4)).getAddon(); + // Admin command count is unchanged + verify(adminCmd, times(5)).getAddon(); + } + } From f17c70e5e419d07ac6e1737c93da23251997d80f Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 12 May 2026 07:17:22 -0700 Subject: [PATCH 15/17] fix: ignore initialCount in donations-only mode (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an admin enables donations-only mid-game, existing islands already have an `initialCount` recorded from the original starter-island scan. The level formula subtracts `initialCount` from raw points when `zero-new-island-levels` is on — but under donations-only, raw points are just the donated total, which is typically much smaller than the recorded initial count. The result was a huge negative `modifiedPoints` and a wildly negative level. Ignore `initialCount` (treat as 0) whenever donations-only is enabled: - `calculateLevel(rawPoints)` no longer subtracts it - the `pointsFromCurrentLevel` binary search no longer floors at it - the report no longer prints it (would be misleading, since it isn't applied to the level math) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../level/calculators/IslandLevelCalculator.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 0ec2c29..9bf12f7 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -133,9 +133,14 @@ public IslandLevelCalculator(Level addon, Island island, CompletableFuture getReport() { if (addon.getSettings().isZeroNewIslandLevels()) { reportLines.add("Initial island level = " + (0L - addon.getManager().getInitialLevel(island))); }*/ - if (addon.getSettings().isZeroNewIslandLevels()) { + if (addon.getSettings().isZeroNewIslandLevels() && !addon.getSettings().isDonationsOnly()) { reportLines.add("Initial island count = " + (0L - addon.getManager().getInitialCount(island))); } reportLines.add("Previous level = " + addon.getManager().getIslandLevel(island.getWorld(), island.getOwner())); @@ -766,7 +771,9 @@ public void tidyUp() { // Binary search for points accumulated within the current level. // Floor at initialCount when zeroing new islands to avoid negative/NaN in non-linear formulas. - long minBlocks = addon.getSettings().isZeroNewIslandLevels() ? results.initialCount.get() : 0; + // In donations-only mode, the initial count is ignored (see calculateLevel). + long minBlocks = addon.getSettings().isZeroNewIslandLevels() && !addon.getSettings().isDonationsOnly() + ? results.initialCount.get() : 0; lo = Math.max(minBlocks, blockAndDeathPoints - MAX_AMOUNT); hi = blockAndDeathPoints; while (lo < hi) { From 8b8b2de27de4315b39f237889680c6299b8f542a Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 12 May 2026 07:20:03 -0700 Subject: [PATCH 16/17] fix: still run zero-island scan under donations-only (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous short-circuit in scanIsland skipped every scan when donations-only was enabled — including the zero-island scan that fires on IslandCreatedEvent / IslandResettedEvent. That meant new islands created during a donations-only window got initialCount = 0, so if an admin later disabled donations-only the player's entire current block total would count toward their level (no handicap subtracted). Restrict the short-circuit to non-zero-island scans. The zero-island scan still runs and records the real handicap into initialCount; it is just ignored by calculateLevel while donations-only is on (per the existing fix in this PR). When donations-only is later disabled, the stored initialCount is correctly subtracted by calculateLevel as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../level/calculators/IslandLevelCalculator.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 9bf12f7..dc1ddcc 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -801,9 +801,14 @@ boolean isNotZeroIsland() { } public void scanIsland(Pipeliner pipeliner) { - // In donations-only mode, skip the chunk scan entirely. tidyUp() will add - // the donated points and compute the level from those alone. - if (addon.getSettings().isDonationsOnly()) { + // In donations-only mode, skip the chunk scan for regular level calcs: + // tidyUp() will add the donated points and compute the level from those + // alone. Zero-island scans (run on island create/reset when + // zero-new-island-levels is true) still run the full scan so the + // initial-count handicap is recorded — this lets an admin later + // disable donations-only without losing the handicap that would + // otherwise need to be subtracted from the scanned block total. + if (addon.getSettings().isDonationsOnly() && !zeroIsland) { pipeliner.getInProcessQueue().remove(this); this.tidyUp(); this.getR().complete(getResults()); From 13ab09bf62bfabebebdfc7bbc72fddf757034537 Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 12 May 2026 07:31:09 -0700 Subject: [PATCH 17/17] fix: disable VIEW action in top panel under donations-only (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer button at the bottom of the top-ten panel rendered with a "Click to view" tooltip and opened the details panel on click. That panel breaks down scanned blocks, which is meaningless in donations-only mode (and /island detail is not registered there either). Add a donations-only check to the VIEW action filter so it's removed from activeActions for that button when donations-only is enabled. Filtering it out also drops the "Click to view" tooltip — no template or locale change needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/world/bentobox/level/panels/TopLevelPanel.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/level/panels/TopLevelPanel.java b/src/main/java/world/bentobox/level/panels/TopLevelPanel.java index 81910b8..3dfa68c 100644 --- a/src/main/java/world/bentobox/level/panels/TopLevelPanel.java +++ b/src/main/java/world/bentobox/level/panels/TopLevelPanel.java @@ -180,7 +180,11 @@ private PanelItem createIslandIcon(ItemTemplateRecord template, IslandTopRecord || !this.addon.getVisitHook().getAddonManager().preprocessTeleportation(this.user, island, true); } case "VIEW" -> { - return island.getOwner() == null + // In donations-only mode there is no detail panel to view, so this + // action is filtered out — that also drops the "click to view" + // tooltip from the rendered button. + return this.addon.getSettings().isDonationsOnly() + || island.getOwner() == null || !island.getMemberSet(RanksManager.MEMBER_RANK).contains(this.user.getUniqueId()); } default -> {