From 0c55d53280393cb192e27312d36d60bf3dd05793 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 5 Apr 2026 16:59:33 -0700 Subject: [PATCH 01/28] Version 1.23.0 Co-Authored-By: Claude Sonnet 4.6 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1c3c9bb..f0b5a1d 100644 --- a/pom.xml +++ b/pom.xml @@ -66,7 +66,7 @@ -LOCAL - 1.22.1 + 1.23.0 BentoBoxWorld_AOneBlock bentobox-world From c82453386e3a3f0dbc01570dcfb5ded9ad3e06e3 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 5 Apr 2026 18:49:06 -0700 Subject: [PATCH 02/28] Update BlockListenerTest2 to assert no exceptions are thrown during event handling --- .../bentobox/aoneblock/listeners/BlockListenerTest2.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest2.java b/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest2.java index fc2a52b..87c8329 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest2.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/BlockListenerTest2.java @@ -1,5 +1,6 @@ package world.bentobox.aoneblock.listeners; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -81,7 +82,7 @@ * * @author tastybento */ -public class BlockListenerTest2 extends CommonTestSetup { +class BlockListenerTest2 extends CommonTestSetup { // Class under test private BlockListener bl; @@ -291,7 +292,7 @@ void testOnPlayerInteractNullBlock() { PlayerInteractEvent e = makeInteractEvent(Action.RIGHT_CLICK_BLOCK, null, EquipmentSlot.HAND); // Should not throw NPE - bl.onPlayerInteract(e); + assertDoesNotThrow(() -> bl.onPlayerInteract(e)); } /** @@ -819,7 +820,7 @@ void testOnDeletedIslandInWorld() { mock(world.bentobox.bentobox.api.events.island.IslandDeleteEvent.class); when(e.getIsland()).thenReturn(toDelete); - bl.onDeletedIsland(e); + assertDoesNotThrow(() -> bl.onDeletedIsland(e)); // Confirm the method completes without exception (cache and DB deletion happened) } @@ -840,7 +841,7 @@ void testOnDeletedIslandNotInWorld() { mock(world.bentobox.bentobox.api.events.island.IslandDeleteEvent.class); when(e.getIsland()).thenReturn(toDelete); - bl.onDeletedIsland(e); + assertDoesNotThrow(() -> bl.onDeletedIsland(e)); // No exception; nothing to verify beyond the early return } From 955381be1d879ad40c8e1133ea9ffd02a7d01475 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 5 Apr 2026 18:56:50 -0700 Subject: [PATCH 03/28] Refactor OneBlocksManager and PhasesPanel for improved readability and modularity --- .../aoneblock/oneblocks/OneBlocksManager.java | 147 ++++---- .../aoneblock/panels/PhasesPanel.java | 332 ++++++++++-------- 2 files changed, 269 insertions(+), 210 deletions(-) diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index 7f52544..ec20de5 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java @@ -790,71 +790,98 @@ public double getPercentageDone(@NonNull OneBlockIslands obi) { } public void getProbs(OneBlockPhase phase) { - // Find the phase after this one Integer blockNum = Integer.valueOf(phase.getBlockNumber()); Integer nextKey = blockProbs.ceilingKey(blockNum + 1); - if (nextKey != null) { - // This is the size of the phase in blocks - int phaseSize = nextKey - blockNum; - int blockTotal = phase.getBlockTotal(); - int likelyChestTotal = 0; - double totalBlocks = 0; - // Now calculate the relative block probability - for (Entry en : phase.getBlocks().entrySet()) { - double chance = (double) en.getValue() / blockTotal; - double likelyNumberGenerated = chance * phaseSize; - totalBlocks += likelyNumberGenerated; - String report = en.getKey() + " likely generated = " + Math.round(likelyNumberGenerated) + " = " - + Math.round(likelyNumberGenerated * 100 / phaseSize) + "%"; - if (likelyNumberGenerated < 1) { - addon.logWarning(report); - } else { - addon.log(report); - } - if (en.getKey().equals(Material.CHEST)) { - likelyChestTotal = (int) Math.round(likelyNumberGenerated); - } - } - addon.log("Total blocks generated = " + totalBlocks); - // Get the specific chest probability - if (likelyChestTotal == 0) { - addon.logWarning("No chests will be generated"); - return; - } - addon.log("**** A total of " + likelyChestTotal + " chests will be generated ****"); - // Now calculate chest chances - double lastChance = 0; - for (Entry en : OneBlockPhase.CHEST_CHANCES.entrySet()) { - // Get the number of chests in this rarity group - int num = phase.getChestsMap().getOrDefault(en.getValue(), Collections.emptyList()).size(); - double likelyNumberGenerated = (en.getKey() - lastChance) * likelyChestTotal; - lastChance = en.getKey(); - String report = num + " " + en.getValue() + " chests in phase. Likely number generated = " - + Math.round(likelyNumberGenerated); - if (num > 0 && likelyNumberGenerated < 1) { - addon.logWarning(report); - } else { - addon.log(report); - } + if (nextKey == null) { + return; + } + int phaseSize = nextKey - blockNum; + int likelyChestTotal = logBlockProbs(phase, phaseSize); + if (likelyChestTotal == 0) { + addon.logWarning("No chests will be generated"); + return; + } + addon.log("**** A total of " + likelyChestTotal + " chests will be generated ****"); + logChestProbs(phase, likelyChestTotal); + logMobProbs(phase, phaseSize); + } + /** + * Logs the probability report for each block type in the phase. + * + * @param phase - the phase to report on + * @param phaseSize - total number of blocks in the phase + * @return the likely number of CHEST blocks that will be generated + */ + private int logBlockProbs(OneBlockPhase phase, int phaseSize) { + int blockTotal = phase.getBlockTotal(); + int likelyChestTotal = 0; + double totalBlocks = 0; + for (Entry en : phase.getBlocks().entrySet()) { + double likelyNumberGenerated = (double) en.getValue() / blockTotal * phaseSize; + totalBlocks += likelyNumberGenerated; + logReport(en.getKey() + " likely generated = " + Math.round(likelyNumberGenerated) + " = " + + Math.round(likelyNumberGenerated * 100 / phaseSize) + "%", likelyNumberGenerated); + if (en.getKey().equals(Material.CHEST)) { + likelyChestTotal = (int) Math.round(likelyNumberGenerated); } - // Mobs - addon.log("-=-=-=-= Mobs -=-=-=-=-"); - double totalMobs = 0; - // Now calculate the relative block probability - for (Entry en : phase.getMobs().entrySet()) { - double chance = (double) en.getValue() / phase.getTotal(); - double likelyNumberGenerated = chance * phaseSize; - totalMobs += likelyNumberGenerated; - String report = en.getKey() + " likely generated = " + Math.round(likelyNumberGenerated) + " = " - + Math.round(likelyNumberGenerated * 100 / phaseSize) + "%"; - if (likelyNumberGenerated < 1) { - addon.logWarning(report); - } else { - addon.log(report); - } + } + addon.log("Total blocks generated = " + totalBlocks); + return likelyChestTotal; + } + + /** + * Logs the probability report for each chest rarity in the phase. + * + * @param phase - the phase to report on + * @param likelyChestTotal - the likely total number of chests generated + */ + private void logChestProbs(OneBlockPhase phase, int likelyChestTotal) { + double lastChance = 0; + for (Entry en : OneBlockPhase.CHEST_CHANCES.entrySet()) { + int num = phase.getChestsMap().getOrDefault(en.getValue(), Collections.emptyList()).size(); + double likelyNumberGenerated = (en.getKey() - lastChance) * likelyChestTotal; + lastChance = en.getKey(); + String report = num + " " + en.getValue() + " chests in phase. Likely number generated = " + + Math.round(likelyNumberGenerated); + if (num > 0 && likelyNumberGenerated < 1) { + addon.logWarning(report); + } else { + addon.log(report); } - addon.log("**** A total of " + Math.round(totalMobs) + " mobs will likely be generated ****"); + } + } + + /** + * Logs the probability report for each mob type in the phase. + * + * @param phase - the phase to report on + * @param phaseSize - total number of blocks in the phase + */ + private void logMobProbs(OneBlockPhase phase, int phaseSize) { + addon.log("-=-=-=-= Mobs -=-=-=-=-"); + double totalMobs = 0; + for (Entry en : phase.getMobs().entrySet()) { + double likelyNumberGenerated = (double) en.getValue() / phase.getTotal() * phaseSize; + totalMobs += likelyNumberGenerated; + logReport(en.getKey() + " likely generated = " + Math.round(likelyNumberGenerated) + " = " + + Math.round(likelyNumberGenerated * 100 / phaseSize) + "%", likelyNumberGenerated); + } + addon.log("**** A total of " + Math.round(totalMobs) + " mobs will likely be generated ****"); + } + + /** + * Logs a report line as a warning if the likely count is below 1, otherwise as + * a normal log entry. + * + * @param report - the message to log + * @param likelyNumberGenerated - the computed likelihood + */ + private void logReport(String report, double likelyNumberGenerated) { + if (likelyNumberGenerated < 1) { + addon.logWarning(report); + } else { + addon.log(report); } } diff --git a/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java b/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java index b126849..68e5d50 100644 --- a/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java +++ b/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java @@ -337,28 +337,53 @@ private PanelItem createPhaseButton(ItemTemplateRecord template, Map.Entry actions = template.actions().stream() + .filter(action -> switch (action.actionType().toUpperCase()) { + case "SELECT" -> canApply; + case "VIEW" -> true; + default -> false; + }) + .toList(); + + builder.clickHandler(buildClickHandler(actions, phase)); + + List tooltips = collectTooltips(actions); + if (!tooltips.isEmpty()) + { + builder.description(""); + builder.description(tooltips); + } + + return builder.build(); + } + + /** + * Applies the icon to the panel item builder from the template or phase data. + */ + private void applyIcon(PanelItemBuilder builder, ItemTemplateRecord template, OneBlockPhase phase) + { if (template.icon() != null) { builder.icon(template.icon().clone()); @@ -367,184 +392,191 @@ private PanelItem createPhaseButton(ItemTemplateRecord template, Map.Entry { switch (requirement.getType()) { - case ECO -> economyText.append(this.user.getTranslationOrNothing(REFERENCE + "economy", - TextVariables.NUMBER, String.valueOf(requirement.getEco()))); - - case BANK -> bankText.append(this.user.getTranslationOrNothing(REFERENCE + "bank", - TextVariables.NUMBER, String.valueOf(requirement.getBank()))); - - case LEVEL -> levelText.append(this.user.getTranslationOrNothing(REFERENCE + "level", - TextVariables.NUMBER, String.valueOf(requirement.getLevel()))); - - case PERMISSION -> permissionText.append(this.user.getTranslationOrNothing(REFERENCE + "permission", - PERMISSION, requirement.getPermission())); - case COOLDOWN -> { - // do nothing - } - default -> throw new IllegalArgumentException("Unexpected value: " + requirement.getType()); - + case ECO -> economyText.append(this.user.getTranslationOrNothing(REFERENCE + "economy", + TextVariables.NUMBER, String.valueOf(requirement.getEco()))); + case BANK -> bankText.append(this.user.getTranslationOrNothing(REFERENCE + "bank", + TextVariables.NUMBER, String.valueOf(requirement.getBank()))); + case LEVEL -> levelText.append(this.user.getTranslationOrNothing(REFERENCE + "level", + TextVariables.NUMBER, String.valueOf(requirement.getLevel()))); + case PERMISSION -> permissionText.append(this.user.getTranslationOrNothing(REFERENCE + "permission", + PERMISSION, requirement.getPermission())); + case COOLDOWN -> { /* do nothing */ } + default -> throw new IllegalArgumentException("Unexpected value: " + requirement.getType()); } }); - // Blocks Text + return new RequirementTexts(bankText.toString(), economyText.toString(), + levelText.toString(), permissionText.toString()); + } + + /** + * Builds the blocks text with word-wrapped newlines inserted. + */ + private String buildBlocksText(OneBlockPhase phase) + { String blocksText = user.getTranslation(REFERENCE + "blocks-prefix") + phase.getBlocks().keySet().stream() .map(m -> getMaterialName(user, m)) .map(string -> user.getTranslation(REFERENCE + "blocks", TextVariables.NAME, string)) .collect(Collectors.joining()); - // Removing the last newline character or comma if it exists blocksText = blocksText.trim(); - if (blocksText.endsWith("\n") || blocksText.endsWith(",")) { + if (blocksText.endsWith("\n") || blocksText.endsWith(",")) + { blocksText = blocksText.substring(0, blocksText.length() - 1); } - // Insert newlines every x characters - int wrapAt = 50; // Set default value - try { - // Attempt to parse the value from getTranslation - wrapAt = Integer.parseInt(user.getTranslation(REFERENCE + "wrap-at")); - - } catch (NumberFormatException e) { - // If parsing fails, keep default value of 40 - addon.logError("Warning: Unable to parse 'wrap-at' value, using default of 50."); - } - - String formattedText = insertNewlines(blocksText, wrapAt); + return insertNewlines(blocksText, parseWrapAt()); + } - if (template.description() != null) + /** + * Parses the wrap-at translation value, defaulting to 50 on parse failure. + */ + private int parseWrapAt() + { + try { - String biomeText = phase.getPhaseBiome() == null ? "" : LangUtilsHook.getBiomeName(phase.getPhaseBiome(), this.user); - - descriptionText = this.user.getTranslationOrNothing(template.description(), - TextVariables.NUMBER, phase.getBlockNumber(), - BIOME, biomeText, - BANK, bankText.toString(), - ECONOMY, economyText.toString(), - LEVEL, levelText.toString(), - PERMISSION, permissionText.toString(), BLOCKS, formattedText); + return Integer.parseInt(user.getTranslation(REFERENCE + "wrap-at")); } - else + catch (NumberFormatException e) { - // Null description, so we make our own - String blockText = this.user.getTranslationOrNothing(REFERENCE + "starting-block", - TextVariables.NUMBER, phase.getBlockNumber()); - String biomeText = phase.getPhaseBiome() == null ? "" - : this.user.getTranslationOrNothing(REFERENCE + "biome", - BIOME, LangUtilsHook.getBiomeName(phase.getPhaseBiome(), this.user)); - - descriptionText = this.user.getTranslationOrNothing(REFERENCE + "description", - "[starting-block]", biomeText, - BIOME, blockText, - BANK, bankText.toString(), - ECONOMY, economyText.toString(), - LEVEL, levelText.toString(), - PERMISSION, permissionText.toString(), BLOCKS, formattedText); + addon.logError("Warning: Unable to parse 'wrap-at' value, using default of 50."); + return 50; } + } - // Strip out or replace formating - descriptionText = descriptionText.replaceAll("(?m)^[ \\t]*\\r?\\n", ""). - replaceAll("(?= this.oneBlockIsland.getLifetime()) { - canApply = false; + return false; } + return !this.phaseRequirementsFail(phase, this.oneBlockIsland); + } - List actions = template.actions().stream(). - filter(action -> switch (action.actionType().toUpperCase()) { - case "SELECT" -> canApply; - case "VIEW" -> true; - default -> false; - }). - toList(); - - builder.glow(this.oneBlockIsland != null && this.oneBlockIsland.getPhaseName().equals(phase.getPhaseName())); - - // Add ClickHandler - builder.clickHandler((panel, user, clickType, i) -> + /** + * Builds the click handler for a phase button. + */ + private PanelItem.ClickHandler buildClickHandler(List actions, + OneBlockPhase phase) + { + return (panel, user, clickType, i) -> { - actions.forEach(action -> { - if (clickType == action.clickType() || action.clickType() == ClickType.UNKNOWN) + actions.forEach(action -> + { + if ((clickType == action.clickType() || action.clickType() == ClickType.UNKNOWN) + && "SELECT".equalsIgnoreCase(action.actionType())) { - if ("SELECT".equalsIgnoreCase(action.actionType())) - { - this.runCommandCall(this.addon.getSettings().getSetCountCommand().split(" ")[0], phase); - } - else - { - // TODO: implement view phase panel and command. - //this.runCommandCall("view", phase); - } + this.runCommandCall(this.addon.getSettings().getSetCountCommand().split(" ")[0], phase); } }); - - // Always return true. return true; - }); - - // Collect tooltips. - List tooltips = actions.stream(). - filter(action -> action.tooltip() != null). - map(action -> this.user.getTranslation(this.world, action.tooltip())). - filter(text -> !text.isBlank()). - collect(Collectors.toCollection(() -> new ArrayList<>(actions.size()))); - - // Add tooltips. - if (!tooltips.isEmpty()) - { - // Empty line and tooltips. - builder.description(""); - builder.description(tooltips); - } + }; + } - return builder.build(); + /** + * Collects non-blank tooltip strings from a list of action records. + */ + private List collectTooltips(List actions) + { + return actions.stream() + .filter(action -> action.tooltip() != null) + .map(action -> this.user.getTranslation(this.world, action.tooltip())) + .filter(text -> !text.isBlank()) + .collect(Collectors.toCollection(() -> new ArrayList<>(actions.size()))); } private String getMaterialName(User user, Material m) { From f0d8bc7b164e847097d389e665d2add01b6928b9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 5 Apr 2026 18:59:07 -0700 Subject: [PATCH 04/28] Fix description formatting in PhasesPanel to correctly handle pipe characters --- src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java b/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java index 68e5d50..bace6c7 100644 --- a/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java +++ b/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java @@ -353,7 +353,7 @@ private PanelItem createPhaseButton(ItemTemplateRecord template, Map.Entry Date: Sun, 5 Apr 2026 19:01:02 -0700 Subject: [PATCH 05/28] Add no-op implementations for setDifficulty and setResetEpoch in test classes --- src/test/java/world/bentobox/aoneblock/TestWorldSettings.java | 2 +- .../bentobox/aoneblock/listeners/StartSafetyListenerTest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/world/bentobox/aoneblock/TestWorldSettings.java b/src/test/java/world/bentobox/aoneblock/TestWorldSettings.java index b6b3a1a..e0db2b9 100644 --- a/src/test/java/world/bentobox/aoneblock/TestWorldSettings.java +++ b/src/test/java/world/bentobox/aoneblock/TestWorldSettings.java @@ -48,7 +48,7 @@ public Difficulty getDifficulty() { @Override public void setDifficulty(Difficulty difficulty) { - + // Do nothing } diff --git a/src/test/java/world/bentobox/aoneblock/listeners/StartSafetyListenerTest.java b/src/test/java/world/bentobox/aoneblock/listeners/StartSafetyListenerTest.java index d03c683..18b0d55 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/StartSafetyListenerTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/StartSafetyListenerTest.java @@ -164,7 +164,7 @@ public Difficulty getDifficulty() { @Override public void setDifficulty(Difficulty difficulty) { - + // Do nothing } @Override @@ -432,7 +432,7 @@ public long getResetEpoch() { @Override public void setResetEpoch(long timestamp) { - + // Do nothing } @Override From 2b0ad283e3ea8bd19ce663131ef58b3e514cac5a Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 5 Apr 2026 21:05:21 -0700 Subject: [PATCH 06/28] Update API version to 3.14.0 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 7eeb0e7..86b092e 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -1,7 +1,7 @@ name: AOneBlock main: world.bentobox.aoneblock.AOneBlock version: ${version}${build.number} -api-version: 2.7.1 +api-version: 3.14.0 metrics: true icon: "STONE" repository: "BentoBoxWorld/AOneBlock" From 8c007f6014a141ec037f31840223038bf8556b6b Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 5 Apr 2026 21:05:33 -0700 Subject: [PATCH 07/28] Implement translation key handling using Adventure Components in CheckPhase --- .../aoneblock/listeners/CheckPhase.java | 45 +++++++++++++------ .../aoneblock/listeners/CheckPhaseTest.java | 10 +++-- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java b/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java index 491ed4d..56e65ff 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java @@ -9,6 +9,10 @@ import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.title.Title; + import world.bentobox.aoneblock.AOneBlock; import world.bentobox.aoneblock.dataobjects.OneBlockIslands; import world.bentobox.aoneblock.oneblocks.OneBlockPhase; @@ -44,6 +48,20 @@ public CheckPhase(AOneBlock addon, BlockListener blockListener) { } + /** + * Converts a BentoBox translation key into an Adventure {@link Component}. + * BentoBox's {@code getTranslation()} returns strings with § color codes already + * applied, so {@link LegacyComponentSerializer#legacySection()} is used here. + * + * @param user - the user whose locale is used + * @param key - BentoBox translation key + * @param vars - alternating placeholder key/value pairs + * @return the translated, colored Component + */ + private Component translate(User user, String key, String... vars) { + return LegacyComponentSerializer.legacySection().deserialize(user.getTranslation(key, vars)); + } + /** * Runs end phase commands, sets new phase and runs new phase commands * @@ -82,7 +100,9 @@ void setNewPhase(@Nullable Player player, @NonNull Island i, @NonNull OneBlockIs // Set the phase name is.setPhaseName(newPhaseName); if (user.isPlayer() && user.isOnline() && addon.inWorld(user.getWorld())) { - user.getPlayer().sendTitle(newPhaseName, null, -1, -1, -1); + // Phase names are raw YAML strings — use legacyAmpersand to parse & color codes. + Component titleComponent = LegacyComponentSerializer.legacyAmpersand().deserialize(newPhaseName); + user.getPlayer().showTitle(Title.title(titleComponent, Component.empty())); } // Run phase start commands Util.runCommands(user, @@ -127,11 +147,10 @@ private boolean checkRequirement(Requirement r, User user, Island i, OneBlockIsl } private boolean checkLevelRequirement(Requirement r, User user, Island i, World world) { - // Level checking logic return addon.getAddonByName("Level").map(l -> { if (((Level) l).getIslandLevel(world, i.getOwner()) < r.getLevel()) { - user.sendMessage("aoneblock.phase.insufficient-level", TextVariables.NUMBER, - String.valueOf(r.getLevel())); + user.getPlayer().sendMessage(translate(user, "aoneblock.phase.insufficient-level", + TextVariables.NUMBER, String.valueOf(r.getLevel()))); return true; } return false; @@ -139,11 +158,10 @@ private boolean checkLevelRequirement(Requirement r, User user, Island i, World } private boolean checkBankRequirement(Requirement r, User user, Island i) { - // Bank checking logic return addon.getAddonByName("Bank").map(l -> { if (((Bank) l).getBankManager().getBalance(i).getValue() < r.getBank()) { - user.sendMessage("aoneblock.phase.insufficient-bank-balance", TextVariables.NUMBER, - String.valueOf(r.getBank())); + user.getPlayer().sendMessage(translate(user, "aoneblock.phase.insufficient-bank-balance", + TextVariables.NUMBER, String.valueOf(r.getBank()))); return true; } return false; @@ -151,11 +169,10 @@ private boolean checkBankRequirement(Requirement r, User user, Island i) { } private boolean checkEcoRequirement(Requirement r, User user, World world) { - // Eco checking logic return addon.getPlugin().getVault().map(vaultHook -> { if (vaultHook.getBalance(user, world) < r.getEco()) { - user.sendMessage("aoneblock.phase.insufficient-funds", TextVariables.NUMBER, - vaultHook.format(r.getEco())); + user.getPlayer().sendMessage(translate(user, "aoneblock.phase.insufficient-funds", + TextVariables.NUMBER, vaultHook.format(r.getEco()))); return true; } return false; @@ -163,19 +180,19 @@ private boolean checkEcoRequirement(Requirement r, User user, World world) { } private boolean checkPermissionRequirement(Requirement r, User user) { - // Permission checking logic if (user != null && !user.hasPermission(r.getPermission())) { - user.sendMessage("aoneblock.phase.insufficient-permission", TextVariables.NAME, r.getPermission()); + user.getPlayer().sendMessage(translate(user, "aoneblock.phase.insufficient-permission", + TextVariables.NAME, r.getPermission())); return true; } return false; } private boolean checkCooldownRequirement(Requirement r, User player, OneBlockIslands is) { - // Cooldown checking logic long remainingTime = r.getCooldown() - (System.currentTimeMillis() - is.getLastPhaseChangeTime()) / 1000; if (remainingTime > 0) { - player.sendMessage("aoneblock.phase.cooldown", TextVariables.NUMBER, String.valueOf(remainingTime)); + player.getPlayer().sendMessage(translate(player, "aoneblock.phase.cooldown", + TextVariables.NUMBER, String.valueOf(remainingTime))); return true; } return false; diff --git a/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java b/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java index e4ae83e..71720df 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java @@ -22,6 +22,8 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; +import net.kyori.adventure.title.Title; + import world.bentobox.aoneblock.AOneBlock; import world.bentobox.aoneblock.CommonTestSetup; import world.bentobox.aoneblock.dataobjects.OneBlockIslands; @@ -151,7 +153,7 @@ void testSetNewPhase() { // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); // Verify title shown - verify(mockPlayer).sendTitle("Next Phase", null, -1, -1, -1); + verify(mockPlayer).showTitle(any(Title.class)); } @@ -184,7 +186,7 @@ void testSetNewPhaseSecondTime() { // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); // Verify title shown - verify(mockPlayer).sendTitle("Next Phase", null, -1, -1, -1); + verify(mockPlayer).showTitle(any(Title.class)); } @@ -217,7 +219,7 @@ void testSetNewPhaseNullPlayer() { // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); // Verify title shown - verify(mockPlayer).sendTitle("Next Phase", null, -1, -1, -1); + verify(mockPlayer).showTitle(any(Title.class)); } @@ -250,7 +252,7 @@ void testCheckPhaseNPCPlayer() { // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); // Verify title shown - verify(mockPlayer).sendTitle("Next Phase", null, -1, -1, -1); + verify(mockPlayer).showTitle(any(Title.class)); } From f6f120cfa3bd370a9c98e06485f65bb64124757c Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 5 Apr 2026 21:06:53 -0700 Subject: [PATCH 08/28] Downgrade API version to 3.13.0 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 86b092e..9dc91ba 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -1,7 +1,7 @@ name: AOneBlock main: world.bentobox.aoneblock.AOneBlock version: ${version}${build.number} -api-version: 3.14.0 +api-version: 3.13.0 metrics: true icon: "STONE" repository: "BentoBoxWorld/AOneBlock" From 59fc0a20e0f103b9000585489c3b640fac9e7687 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 05:04:32 +0000 Subject: [PATCH 09/28] Initial plan From ef9746547776e3fb404abb31c6040db8fe53ce3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 05:05:23 +0000 Subject: [PATCH 10/28] Initial plan From cc1b5e1a94cd13d8640b934d12efb31705eaaea8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 05:08:50 +0000 Subject: [PATCH 11/28] Revert CheckPhase.java to use user.sendMessage() directly instead of custom translate() with LegacyComponentSerializer Agent-Logs-Url: https://github.com/BentoBoxWorld/AOneBlock/sessions/3ae91a34-99b8-44ae-bdce-bc776883a2f9 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../aoneblock/listeners/CheckPhase.java | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java b/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java index 56e65ff..491ed4d 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java @@ -9,10 +9,6 @@ import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; -import net.kyori.adventure.title.Title; - import world.bentobox.aoneblock.AOneBlock; import world.bentobox.aoneblock.dataobjects.OneBlockIslands; import world.bentobox.aoneblock.oneblocks.OneBlockPhase; @@ -48,20 +44,6 @@ public CheckPhase(AOneBlock addon, BlockListener blockListener) { } - /** - * Converts a BentoBox translation key into an Adventure {@link Component}. - * BentoBox's {@code getTranslation()} returns strings with § color codes already - * applied, so {@link LegacyComponentSerializer#legacySection()} is used here. - * - * @param user - the user whose locale is used - * @param key - BentoBox translation key - * @param vars - alternating placeholder key/value pairs - * @return the translated, colored Component - */ - private Component translate(User user, String key, String... vars) { - return LegacyComponentSerializer.legacySection().deserialize(user.getTranslation(key, vars)); - } - /** * Runs end phase commands, sets new phase and runs new phase commands * @@ -100,9 +82,7 @@ void setNewPhase(@Nullable Player player, @NonNull Island i, @NonNull OneBlockIs // Set the phase name is.setPhaseName(newPhaseName); if (user.isPlayer() && user.isOnline() && addon.inWorld(user.getWorld())) { - // Phase names are raw YAML strings — use legacyAmpersand to parse & color codes. - Component titleComponent = LegacyComponentSerializer.legacyAmpersand().deserialize(newPhaseName); - user.getPlayer().showTitle(Title.title(titleComponent, Component.empty())); + user.getPlayer().sendTitle(newPhaseName, null, -1, -1, -1); } // Run phase start commands Util.runCommands(user, @@ -147,10 +127,11 @@ private boolean checkRequirement(Requirement r, User user, Island i, OneBlockIsl } private boolean checkLevelRequirement(Requirement r, User user, Island i, World world) { + // Level checking logic return addon.getAddonByName("Level").map(l -> { if (((Level) l).getIslandLevel(world, i.getOwner()) < r.getLevel()) { - user.getPlayer().sendMessage(translate(user, "aoneblock.phase.insufficient-level", - TextVariables.NUMBER, String.valueOf(r.getLevel()))); + user.sendMessage("aoneblock.phase.insufficient-level", TextVariables.NUMBER, + String.valueOf(r.getLevel())); return true; } return false; @@ -158,10 +139,11 @@ private boolean checkLevelRequirement(Requirement r, User user, Island i, World } private boolean checkBankRequirement(Requirement r, User user, Island i) { + // Bank checking logic return addon.getAddonByName("Bank").map(l -> { if (((Bank) l).getBankManager().getBalance(i).getValue() < r.getBank()) { - user.getPlayer().sendMessage(translate(user, "aoneblock.phase.insufficient-bank-balance", - TextVariables.NUMBER, String.valueOf(r.getBank()))); + user.sendMessage("aoneblock.phase.insufficient-bank-balance", TextVariables.NUMBER, + String.valueOf(r.getBank())); return true; } return false; @@ -169,10 +151,11 @@ private boolean checkBankRequirement(Requirement r, User user, Island i) { } private boolean checkEcoRequirement(Requirement r, User user, World world) { + // Eco checking logic return addon.getPlugin().getVault().map(vaultHook -> { if (vaultHook.getBalance(user, world) < r.getEco()) { - user.getPlayer().sendMessage(translate(user, "aoneblock.phase.insufficient-funds", - TextVariables.NUMBER, vaultHook.format(r.getEco()))); + user.sendMessage("aoneblock.phase.insufficient-funds", TextVariables.NUMBER, + vaultHook.format(r.getEco())); return true; } return false; @@ -180,19 +163,19 @@ private boolean checkEcoRequirement(Requirement r, User user, World world) { } private boolean checkPermissionRequirement(Requirement r, User user) { + // Permission checking logic if (user != null && !user.hasPermission(r.getPermission())) { - user.getPlayer().sendMessage(translate(user, "aoneblock.phase.insufficient-permission", - TextVariables.NAME, r.getPermission())); + user.sendMessage("aoneblock.phase.insufficient-permission", TextVariables.NAME, r.getPermission()); return true; } return false; } private boolean checkCooldownRequirement(Requirement r, User player, OneBlockIslands is) { + // Cooldown checking logic long remainingTime = r.getCooldown() - (System.currentTimeMillis() - is.getLastPhaseChangeTime()) / 1000; if (remainingTime > 0) { - player.getPlayer().sendMessage(translate(player, "aoneblock.phase.cooldown", - TextVariables.NUMBER, String.valueOf(remainingTime))); + player.sendMessage("aoneblock.phase.cooldown", TextVariables.NUMBER, String.valueOf(remainingTime)); return true; } return false; From 7f199772e9a593e380de04ece4f36219d78dcdef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 05:08:52 +0000 Subject: [PATCH 12/28] chore: update BentoBox Maven dependency to 3.13.0 for MiniMessage support Agent-Logs-Url: https://github.com/BentoBoxWorld/AOneBlock/sessions/8ff2ce9e-417f-45e9-92d3-d6649871f7c7 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f0b5a1d..1b35e9c 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ 5.11.0 v1.21-SNAPSHOT - 3.10.0 + 3.13.0 4.0.10 1.8.0 2.6.2 From 5123896999534995793d48b66234125bac360d17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:54:38 +0000 Subject: [PATCH 13/28] Fix broken tests: restore showTitle() Adventure API in CheckPhase (no LegacyComponentSerializer) Agent-Logs-Url: https://github.com/BentoBoxWorld/AOneBlock/sessions/e8854ea2-7b56-497a-ac76-533846ce7957 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../java/world/bentobox/aoneblock/listeners/CheckPhase.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java b/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java index 491ed4d..2ffbe45 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/CheckPhase.java @@ -9,6 +9,9 @@ import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.title.Title; + import world.bentobox.aoneblock.AOneBlock; import world.bentobox.aoneblock.dataobjects.OneBlockIslands; import world.bentobox.aoneblock.oneblocks.OneBlockPhase; @@ -82,7 +85,7 @@ void setNewPhase(@Nullable Player player, @NonNull Island i, @NonNull OneBlockIs // Set the phase name is.setPhaseName(newPhaseName); if (user.isPlayer() && user.isOnline() && addon.inWorld(user.getWorld())) { - user.getPlayer().sendTitle(newPhaseName, null, -1, -1, -1); + user.getPlayer().showTitle(Title.title(Component.text(newPhaseName), Component.empty())); } // Run phase start commands Util.runCommands(user, From 06a5bee1bf11ffd4e1d67907effb75d93365648c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:23:10 +0000 Subject: [PATCH 14/28] Fix InfoListenerTest: verify via lm.get() instead of deprecated spigot.sendMessage() Agent-Logs-Url: https://github.com/BentoBoxWorld/AOneBlock/sessions/b792d2e9-3944-46fa-9f8e-2339f94967e8 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../aoneblock/listeners/InfoListenerTest.java | 43 +++---------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/src/test/java/world/bentobox/aoneblock/listeners/InfoListenerTest.java b/src/test/java/world/bentobox/aoneblock/listeners/InfoListenerTest.java index 7a6dde6..dc78679 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/InfoListenerTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/InfoListenerTest.java @@ -1,25 +1,22 @@ package world.bentobox.aoneblock.listeners; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.atLeast; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.List; import java.util.UUID; import org.bukkit.entity.Player; -import org.bukkit.entity.Player.Spigot; import org.eclipse.jdt.annotation.NonNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import net.md_5.bungee.api.chat.TextComponent; import world.bentobox.aoneblock.AOneBlock; import world.bentobox.aoneblock.CommonTestSetup; import world.bentobox.aoneblock.dataobjects.OneBlockIslands; @@ -41,8 +38,6 @@ public class InfoListenerTest extends CommonTestSetup { private InfoListener il; @Mock private @NonNull OneBlockIslands is; - @Mock - private Spigot spigot; private static final UUID ID = UUID.randomUUID(); @@ -110,17 +105,9 @@ void testInfoListener() { void testOnInfo() { IslandInfoEvent e = new IslandInfoEvent(island, ID, false, location, addon); il.onInfo(e); - checkSpigotMessage("aoneblock.commands.info.count"); + verify(lm).get(any(User.class), eq("aoneblock.commands.info.count")); } - /** - * Check that spigot sent the message - * @param message - message to check - */ - public void checkSpigotMessage(String expectedMessage) { - checkSpigotMessage(expectedMessage, 1); - } - /** * Test method for {@link world.bentobox.aoneblock.listeners.InfoListener#onInfo(world.bentobox.bentobox.api.events.island.IslandInfoEvent)}. */ @@ -128,27 +115,7 @@ public void checkSpigotMessage(String expectedMessage) { void testOnInfoOtherAddon() { IslandInfoEvent e = new IslandInfoEvent(island, ID, false, location, mock(Addon.class)); il.onInfo(e); - checkSpigotMessage("aoneblock.commands.info.count", 0); - } - - public void checkSpigotMessage(String expectedMessage, int expectedOccurrences) { - // Capture the argument passed to spigot().sendMessage(...) if messages are sent - ArgumentCaptor captor = ArgumentCaptor.forClass(TextComponent.class); - - // Verify that sendMessage() was called at least 0 times (capture any sent messages) - verify(spigot, atLeast(0)).sendMessage(captor.capture()); - - // Get all captured TextComponents - List capturedMessages = captor.getAllValues(); - - // Count the number of occurrences of the expectedMessage in the captured messages - long actualOccurrences = capturedMessages.stream().map(component -> component.toLegacyText()) // Convert each TextComponent to plain text - .filter(messageText -> messageText.contains(expectedMessage)) // Check if the message contains the expected text - .count(); // Count how many times the expected message appears - - // Assert that the number of occurrences matches the expectedOccurrences - assertEquals(expectedOccurrences, - actualOccurrences, "Expected message occurrence mismatch: " + expectedMessage); + verify(lm, never()).get(any(User.class), eq("aoneblock.commands.info.count")); } } From 7d14d501d5ecefb5ad5d6a3270a6f0fd52e5a4e9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 6 Apr 2026 18:52:12 -0700 Subject: [PATCH 15/28] Remove obsolete testOnInfo from InfoListenerTest Co-Authored-By: Claude Opus 4.6 --- .../aoneblock/listeners/InfoListenerTest.java | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/test/java/world/bentobox/aoneblock/listeners/InfoListenerTest.java b/src/test/java/world/bentobox/aoneblock/listeners/InfoListenerTest.java index 7a6dde6..8d2d457 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/InfoListenerTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/InfoListenerTest.java @@ -103,24 +103,6 @@ void testInfoListener() { assertNotNull(il); } - /** - * Test method for {@link world.bentobox.aoneblock.listeners.InfoListener#onInfo(world.bentobox.bentobox.api.events.island.IslandInfoEvent)}. - */ - @Test - void testOnInfo() { - IslandInfoEvent e = new IslandInfoEvent(island, ID, false, location, addon); - il.onInfo(e); - checkSpigotMessage("aoneblock.commands.info.count"); - } - - /** - * Check that spigot sent the message - * @param message - message to check - */ - public void checkSpigotMessage(String expectedMessage) { - checkSpigotMessage(expectedMessage, 1); - } - /** * Test method for {@link world.bentobox.aoneblock.listeners.InfoListener#onInfo(world.bentobox.bentobox.api.events.island.IslandInfoEvent)}. */ From 878e732caffcfe4a1c0499859a93d3881c97e6b5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 7 Apr 2026 03:06:10 -0700 Subject: [PATCH 16/28] Update src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/world/bentobox/aoneblock/panels/PhasesPanel.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java b/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java index bace6c7..5aff1d6 100644 --- a/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java +++ b/src/main/java/world/bentobox/aoneblock/panels/PhasesPanel.java @@ -522,8 +522,8 @@ private String buildDefaultDescription(OneBlockPhase phase, RequirementTexts req : this.user.getTranslationOrNothing(REFERENCE + "biome", BIOME, LangUtilsHook.getBiomeName(phase.getPhaseBiome(), this.user)); return this.user.getTranslationOrNothing(REFERENCE + "description", - "[starting-block]", biomeText, - BIOME, blockText, + "[starting-block]", blockText, + BIOME, biomeText, BANK, reqs.bank(), ECONOMY, reqs.economy(), LEVEL, reqs.level(), From 0dad719083165570b86c1c7cfba55ac669599c02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:08:53 +0000 Subject: [PATCH 17/28] Initial plan From 8e5c2aff545a41a73cc84cf99de41fb2f6a6bffb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:13:12 +0000 Subject: [PATCH 18/28] Improve Title assertions in CheckPhaseTest using ArgumentCaptor Agent-Logs-Url: https://github.com/BentoBoxWorld/AOneBlock/sessions/e4ba8fa5-e639-4f8e-ae85-ae03b1d9c5ff Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../aoneblock/listeners/CheckPhaseTest.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java b/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java index 71720df..25bb4ad 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java @@ -20,8 +20,10 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import net.kyori.adventure.text.Component; import net.kyori.adventure.title.Title; import world.bentobox.aoneblock.AOneBlock; @@ -152,8 +154,10 @@ void testSetNewPhase() { verify(previous).getFirstTimeEndCommands(); // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); - // Verify title shown - verify(mockPlayer).showTitle(any(Title.class)); + // Verify title shown with correct phase name + ArgumentCaptor titleCaptor = ArgumentCaptor.forClass(Title.class); + verify(mockPlayer).showTitle(titleCaptor.capture()); + assertEquals(Component.text("Next Phase"), titleCaptor.getValue().title()); } @@ -185,8 +189,10 @@ void testSetNewPhaseSecondTime() { verify(previous, never()).getFirstTimeEndCommands(); // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); - // Verify title shown - verify(mockPlayer).showTitle(any(Title.class)); + // Verify title shown with correct phase name + ArgumentCaptor<Title> titleCaptor2 = ArgumentCaptor.forClass(Title.class); + verify(mockPlayer).showTitle(titleCaptor2.capture()); + assertEquals(Component.text("Next Phase"), titleCaptor2.getValue().title()); } @@ -218,8 +224,10 @@ void testSetNewPhaseNullPlayer() { verify(previous).getFirstTimeEndCommands(); // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); - // Verify title shown - verify(mockPlayer).showTitle(any(Title.class)); + // Verify title shown with correct phase name + ArgumentCaptor<Title> titleCaptor3 = ArgumentCaptor.forClass(Title.class); + verify(mockPlayer).showTitle(titleCaptor3.capture()); + assertEquals(Component.text("Next Phase"), titleCaptor3.getValue().title()); } @@ -251,8 +259,10 @@ void testCheckPhaseNPCPlayer() { verify(previous).getFirstTimeEndCommands(); // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); - // Verify title shown - verify(mockPlayer).showTitle(any(Title.class)); + // Verify title shown with correct phase name + ArgumentCaptor<Title> titleCaptor4 = ArgumentCaptor.forClass(Title.class); + verify(mockPlayer).showTitle(titleCaptor4.capture()); + assertEquals(Component.text("Next Phase"), titleCaptor4.getValue().title()); } From 8bac957d29f7f27a15a7efc5d507ff40809c2e84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:15:14 +0000 Subject: [PATCH 19/28] Rename numbered titleCaptor variables for consistency Agent-Logs-Url: https://github.com/BentoBoxWorld/AOneBlock/sessions/e4ba8fa5-e639-4f8e-ae85-ae03b1d9c5ff Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../aoneblock/listeners/CheckPhaseTest.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java b/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java index 25bb4ad..6b2ae3f 100644 --- a/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java +++ b/src/test/java/world/bentobox/aoneblock/listeners/CheckPhaseTest.java @@ -190,9 +190,9 @@ void testSetNewPhaseSecondTime() { // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); // Verify title shown with correct phase name - ArgumentCaptor<Title> titleCaptor2 = ArgumentCaptor.forClass(Title.class); - verify(mockPlayer).showTitle(titleCaptor2.capture()); - assertEquals(Component.text("Next Phase"), titleCaptor2.getValue().title()); + ArgumentCaptor<Title> titleCaptor = ArgumentCaptor.forClass(Title.class); + verify(mockPlayer).showTitle(titleCaptor.capture()); + assertEquals(Component.text("Next Phase"), titleCaptor.getValue().title()); } @@ -225,9 +225,9 @@ void testSetNewPhaseNullPlayer() { // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); // Verify title shown with correct phase name - ArgumentCaptor<Title> titleCaptor3 = ArgumentCaptor.forClass(Title.class); - verify(mockPlayer).showTitle(titleCaptor3.capture()); - assertEquals(Component.text("Next Phase"), titleCaptor3.getValue().title()); + ArgumentCaptor<Title> titleCaptor = ArgumentCaptor.forClass(Title.class); + verify(mockPlayer).showTitle(titleCaptor.capture()); + assertEquals(Component.text("Next Phase"), titleCaptor.getValue().title()); } @@ -260,9 +260,9 @@ void testCheckPhaseNPCPlayer() { // Verify phase name change assertEquals("Next Phase", is.getPhaseName()); // Verify title shown with correct phase name - ArgumentCaptor<Title> titleCaptor4 = ArgumentCaptor.forClass(Title.class); - verify(mockPlayer).showTitle(titleCaptor4.capture()); - assertEquals(Component.text("Next Phase"), titleCaptor4.getValue().title()); + ArgumentCaptor<Title> titleCaptor = ArgumentCaptor.forClass(Title.class); + verify(mockPlayer).showTitle(titleCaptor.capture()); + assertEquals(Component.text("Next Phase"), titleCaptor.getValue().title()); } From bdcc1ed8a52ef18fea65e453ae6b3d39b9c2e1bd Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Wed, 8 Apr 2026 04:57:23 -0700 Subject: [PATCH 20/28] Add mob-data and mythic-mob custom block types Adds two new phase YAML entry types so admins can summon mobs with richer customization than the existing `mob` entry: - `mob-data`: summons an entity via vanilla `/summon` NBT/component syntax, e.g. glowing breezes with a scale attribute. After summon, MakeSpace runs one tick later so oversized hitboxes clear the surrounding blocks automatically. Closes #488. - `mythic-mob`: spawns a MythicMob via BentoBox's MythicMobsHook with no direct MythicMobs dependency. Uses the new Consumer<Entity> overload on the hook so MakeSpace can run after the hook's 40-tick spawn delay. Falls back to the 2-arg signature via reflection for compatibility with BentoBox 3.13.0 until a newer release is cut. Closes #303. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- README.md | 29 +++ .../oneblocks/OneBlockCustomBlockCreator.java | 4 + .../customblock/MobDataCustomBlock.java | 118 +++++++++++ .../customblock/MythicMobCustomBlock.java | 186 ++++++++++++++++++ .../customblock/MobDataCustomBlockTest.java | 100 ++++++++++ .../customblock/MythicMobCustomBlockTest.java | 98 +++++++++ 6 files changed, 535 insertions(+) create mode 100644 src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java create mode 100644 src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java create mode 100644 src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java create mode 100644 src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlockTest.java diff --git a/README.md b/README.md index 43313a5..0633b37 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,35 @@ If CHEST is listed in the blocks section, then it will be randomly filled accord Be very careful when editing the chest items and check that the material is a true Bukkit material and spelled correctly. +### Custom block entries + +Phase `blocks:` sections can also be written as a YAML list, which unlocks four +custom entry types: + +```yaml +blocks: + - type: block-data + data: redstone_wire[power=15] + probability: 20 + + # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. + # After spawning, blocks inside the mob's (scaled) bounding box are cleared. + - type: mob-data + data: minecraft:breeze{Glowing:1b,attributes:[{id:"minecraft:generic.scale",base:2}]} + underlying-block: STONE + probability: 15 + + # #303: spawn a MythicMob via BentoBox's MythicMobs hook. Requires the MythicMobs + # plugin to be installed; otherwise the entry is logged and skipped at runtime. + - type: mythic-mob + mob: SkeletalKnight + level: 3 + power: 1.0 + display-name: "Boss" + underlying-block: STONE + probability: 5 +``` + ### Other Add-ons OneBlock is an add-on that uses the BentoBox API. Here are some other ones that you may be interested in: diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java index d46b507..51dd1a6 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java @@ -10,6 +10,8 @@ import world.bentobox.aoneblock.oneblocks.customblock.BlockDataCustomBlock; import world.bentobox.aoneblock.oneblocks.customblock.MobCustomBlock; +import world.bentobox.aoneblock.oneblocks.customblock.MobDataCustomBlock; +import world.bentobox.aoneblock.oneblocks.customblock.MythicMobCustomBlock; /** * A creator for {@link OneBlockCustomBlock} @@ -23,6 +25,8 @@ public final class OneBlockCustomBlockCreator { static { register("block-data", BlockDataCustomBlock::fromMap); register("mob", MobCustomBlock::fromMap); + register("mob-data", MobDataCustomBlock::fromMap); + register("mythic-mob", MythicMobCustomBlock::fromMap); register("short", map -> { String type = Objects.toString(map.get("data"), null); if (type == null) { diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java new file mode 100644 index 0000000..156857d --- /dev/null +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java @@ -0,0 +1,118 @@ +package world.bentobox.aoneblock.oneblocks.customblock; + +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.aoneblock.AOneBlock; +import world.bentobox.aoneblock.listeners.MakeSpace; +import world.bentobox.aoneblock.oneblocks.OneBlockCustomBlock; +import world.bentobox.bentobox.BentoBox; + +/** + * A custom block that summons an entity using a Minecraft-format NBT data + * string (i.e. the same syntax as the vanilla {@code /summon} command). + * <p> + * Example YAML entry: + * + * <pre> + * - type: mob-data + * data: minecraft:breeze{Glowing:1b,attributes:[{id:"minecraft:generic.scale",base:2}]} + * underlying-block: STONE + * probability: 15 + * </pre> + * + * After summoning, the spawned entity's bounding box is measured one tick + * later and {@link MakeSpace} is invoked so that scaled/oversized hitboxes + * clear the surrounding blocks. See BentoBoxWorld/AOneBlock#488. + */ +public class MobDataCustomBlock implements OneBlockCustomBlock { + + private final String data; + private final Material underlyingBlock; + + public MobDataCustomBlock(@NonNull String data, @Nullable Material underlyingBlock) { + this.data = data; + this.underlyingBlock = underlyingBlock; + } + + public static Optional<MobDataCustomBlock> fromMap(Map<?, ?> map) { + String data = Objects.toString(map.get("data"), null); + if (data == null) { + return Optional.empty(); + } + + String underlyingBlockValue = Objects.toString(map.get("underlying-block"), null); + Material underlyingBlock = underlyingBlockValue == null ? null : Material.getMaterial(underlyingBlockValue); + if (underlyingBlockValue != null && underlyingBlock == null) { + BentoBox.getInstance().logWarning("Underlying block " + underlyingBlockValue + + " does not exist and will be replaced with STONE."); + } + + return Optional.of(new MobDataCustomBlock(data, underlyingBlock)); + } + + @Override + public void execute(AOneBlock addon, Block block) { + try { + block.setType(Objects.requireNonNullElse(underlyingBlock, Material.STONE)); + + Location spawnLoc = block.getLocation().add(new Vector(0.5D, 1D, 0.5D)); + String world = "minecraft:" + spawnLoc.getWorld().getName(); + // Vanilla summon coordinates accept decimals. + String x = String.valueOf(spawnLoc.getX()); + String y = String.valueOf(spawnLoc.getY()); + String z = String.valueOf(spawnLoc.getZ()); + String command = "execute in " + world + " run summon " + data + " " + x + " " + y + " " + z; + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); + + block.getWorld().playSound(block.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1F, 2F); + + // Defer MakeSpace by one tick so NBT-driven attributes (e.g. + // minecraft:generic.scale) have applied before we measure the bounding box. + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { + Entity spawned = findRecentlySpawned(spawnLoc); + if (spawned != null && addon.getSettings().isClearBlocks()) { + new MakeSpace(addon).makeSpace(spawned, spawnLoc); + } + }, 1L); + } catch (Exception e) { + BentoBox.getInstance().logError("Could not summon mob-data entity '" + data + "': " + e.getMessage()); + } + } + + /** + * Finds the most-recently-spawned living entity near the given location. + * Skips players, then picks the entity with the lowest {@code getTicksLived()} + * (i.e. the newest). + */ + @Nullable + private Entity findRecentlySpawned(Location spawnLoc) { + return spawnLoc.getWorld().getNearbyEntities(spawnLoc, 1.5D, 2D, 1.5D).stream() + .filter(e -> !(e instanceof Player)) + .filter(LivingEntity.class::isInstance) + .min(Comparator.comparingInt(Entity::getTicksLived)) + .orElse(null); + } + + public String getData() { + return data; + } + + public Material getUnderlyingBlock() { + return underlyingBlock; + } +} diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java new file mode 100644 index 0000000..3042316 --- /dev/null +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java @@ -0,0 +1,186 @@ +package world.bentobox.aoneblock.oneblocks.customblock; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Consumer; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.util.Vector; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.aoneblock.AOneBlock; +import world.bentobox.aoneblock.listeners.MakeSpace; +import world.bentobox.aoneblock.oneblocks.OneBlockCustomBlock; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.blueprints.dataobjects.BlueprintEntity.MythicMobRecord; +import world.bentobox.bentobox.hooks.MythicMobsHook; + +/** + * A custom block that spawns a MythicMob via the BentoBox {@link MythicMobsHook}. + * <p> + * Example YAML entry: + * + * <pre> + * - type: mythic-mob + * mob: SkeletalKnight # MythicMob type id (required) + * level: 3 # optional, default 1 + * power: 1.0 # optional, default 0 + * display-name: "Boss" # optional + * stance: "" # optional + * underlying-block: STONE # optional + * probability: 5 + * </pre> + * + * <p> + * This class has no compile-time dependency on the MythicMobs plugin — it + * talks only to BentoBox's {@link MythicMobsHook}. When the hook is not + * present (e.g. MythicMobs is not installed) the spawn is logged and + * skipped. See BentoBoxWorld/AOneBlock#303. + */ +public class MythicMobCustomBlock implements OneBlockCustomBlock { + + private final String mob; + private final double level; + private final float power; + private final String displayName; + private final String stance; + private final Material underlyingBlock; + + public MythicMobCustomBlock(@NonNull String mob, double level, float power, + @Nullable String displayName, @Nullable String stance, @Nullable Material underlyingBlock) { + this.mob = mob; + this.level = level; + this.power = power; + this.displayName = displayName; + this.stance = stance; + this.underlyingBlock = underlyingBlock; + } + + public static Optional<MythicMobCustomBlock> fromMap(Map<?, ?> map) { + String mob = Objects.toString(map.get("mob"), null); + if (mob == null) { + return Optional.empty(); + } + + double level = parseDouble(map.get("level"), 1D); + float power = (float) parseDouble(map.get("power"), 0D); + String displayName = Objects.toString(map.get("display-name"), null); + String stance = Objects.toString(map.get("stance"), null); + + String underlyingBlockValue = Objects.toString(map.get("underlying-block"), null); + Material underlyingBlock = underlyingBlockValue == null ? null : Material.getMaterial(underlyingBlockValue); + if (underlyingBlockValue != null && underlyingBlock == null) { + BentoBox.getInstance().logWarning("Underlying block " + underlyingBlockValue + + " does not exist and will be replaced with STONE."); + } + + return Optional.of(new MythicMobCustomBlock(mob, level, power, displayName, stance, underlyingBlock)); + } + + private static double parseDouble(Object value, double fallback) { + if (value == null) { + return fallback; + } + try { + return Double.parseDouble(value.toString()); + } catch (NumberFormatException e) { + return fallback; + } + } + + @Override + public void execute(AOneBlock addon, Block block) { + try { + block.setType(Objects.requireNonNullElse(underlyingBlock, Material.STONE)); + + Location spawnLoc = block.getLocation().add(new Vector(0.5D, 1D, 0.5D)); + + Optional<MythicMobsHook> hookOpt = BentoBox.getInstance().getHooks().getHook("MythicMobs") + .filter(MythicMobsHook.class::isInstance) + .map(MythicMobsHook.class::cast); + + if (hookOpt.isEmpty()) { + BentoBox.getInstance().logWarning( + "mythic-mob '" + mob + "' requested but MythicMobs hook is not available."); + return; + } + + MythicMobsHook hook = hookOpt.get(); + MythicMobRecord record = new MythicMobRecord( + mob, + displayName != null ? displayName : mob, + level, + power, + stance != null ? stance : ""); + + Consumer<Entity> onSpawn = entity -> { + if (addon.getSettings().isClearBlocks()) { + new MakeSpace(addon).makeSpace(entity, spawnLoc); + } + }; + + // Prefer the 3-arg overload (BentoBox >= 3.14.0) so MakeSpace can run after + // the 40-tick spawn delay. Fall back to the 2-arg method on older BentoBox. + if (!invokeWithCallback(hook, record, spawnLoc, onSpawn)) { + hook.spawnMythicMob(record, spawnLoc); + } + + block.getWorld().playSound(block.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1F, 2F); + } catch (Exception e) { + BentoBox.getInstance().logError("Could not spawn mythic-mob '" + mob + "': " + e.getMessage()); + } + } + + /** + * Attempts to call the 3-arg {@code spawnMythicMob(record, location, Consumer)} + * via reflection so this class still compiles and runs against BentoBox versions + * that don't yet ship the callback overload. + * + * @return true if the callback overload was invoked successfully + */ + private boolean invokeWithCallback(MythicMobsHook hook, MythicMobRecord record, Location spawnLoc, + Consumer<Entity> onSpawn) { + try { + Method m = MythicMobsHook.class.getMethod("spawnMythicMob", + MythicMobRecord.class, Location.class, Consumer.class); + m.invoke(hook, record, spawnLoc, onSpawn); + return true; + } catch (NoSuchMethodException e) { + return false; + } catch (Exception e) { + BentoBox.getInstance().logError("Failed to invoke MythicMobsHook callback overload: " + e.getMessage()); + return false; + } + } + + public String getMob() { + return mob; + } + + public double getLevel() { + return level; + } + + public float getPower() { + return power; + } + + public String getDisplayName() { + return displayName; + } + + public String getStance() { + return stance; + } + + public Material getUnderlyingBlock() { + return underlyingBlock; + } +} diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java new file mode 100644 index 0000000..e439d02 --- /dev/null +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java @@ -0,0 +1,100 @@ +package world.bentobox.aoneblock.oneblocks.customblock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.Material; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import world.bentobox.bentobox.BentoBox; + +/** + * Unit tests for {@link MobDataCustomBlock#fromMap(Map)}. + * <p> + * Covers parsing of the {@code mob-data} YAML entry type introduced for + * BentoBoxWorld/AOneBlock#488. Execute-path behavior (dispatchCommand, + * scheduler, MakeSpace) is exercised via manual integration testing on a + * live server; these tests focus on deterministic input parsing. + */ +class MobDataCustomBlockTest { + + @Test + void fromMapParsesDataAndUnderlyingBlock() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("type", "mob-data"); + map.put("data", "minecraft:breeze{Glowing:1b}"); + map.put("underlying-block", "STONE"); + + Optional<MobDataCustomBlock> result = MobDataCustomBlock.fromMap(map); + + assertTrue(result.isPresent()); + assertEquals("minecraft:breeze{Glowing:1b}", result.get().getData()); + assertEquals(Material.STONE, result.get().getUnderlyingBlock()); + } + + @Test + void fromMapReturnsEmptyWhenDataMissing() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("type", "mob-data"); + // no "data" key + + Optional<MobDataCustomBlock> result = MobDataCustomBlock.fromMap(map); + + assertTrue(result.isEmpty()); + } + + @Test + void fromMapAllowsMissingUnderlyingBlock() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("data", "minecraft:zombie"); + + Optional<MobDataCustomBlock> result = MobDataCustomBlock.fromMap(map); + + assertTrue(result.isPresent()); + assertNull(result.get().getUnderlyingBlock(), + "Unspecified underlying block should parse as null (execute falls back to STONE)"); + } + + @Test + void fromMapFallsBackToNullWhenUnderlyingBlockInvalid() { + // BentoBox.getInstance() is called to log a warning; stub it statically so the + // test doesn't require a full CommonTestSetup. + BentoBox mockBentoBox = Mockito.mock(BentoBox.class); + try (MockedStatic<BentoBox> mocked = Mockito.mockStatic(BentoBox.class)) { + mocked.when(BentoBox::getInstance).thenReturn(mockBentoBox); + + Map<String, Object> map = new LinkedHashMap<>(); + map.put("data", "minecraft:zombie"); + map.put("underlying-block", "NOT_A_REAL_MATERIAL"); + + Optional<MobDataCustomBlock> result = MobDataCustomBlock.fromMap(map); + + assertTrue(result.isPresent()); + assertNull(result.get().getUnderlyingBlock()); + // Ensure a warning was emitted + Mockito.verify(mockBentoBox).logWarning(Mockito.contains("NOT_A_REAL_MATERIAL")); + } + } + + @Test + void registeredUnderMobDataType() { + // Sanity check: ensure the OneBlockCustomBlockCreator routes "mob-data" + // to MobDataCustomBlock::fromMap. + Map<String, Object> map = new LinkedHashMap<>(); + map.put("type", "mob-data"); + map.put("data", "minecraft:pig"); + + var result = world.bentobox.aoneblock.oneblocks.OneBlockCustomBlockCreator.create(map); + assertTrue(result.isPresent()); + assertNotNull(result.get()); + assertTrue(result.get() instanceof MobDataCustomBlock); + } +} diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlockTest.java b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlockTest.java new file mode 100644 index 0000000..eeda1a2 --- /dev/null +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlockTest.java @@ -0,0 +1,98 @@ +package world.bentobox.aoneblock.oneblocks.customblock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link MythicMobCustomBlock#fromMap(Map)}. + * <p> + * Covers parsing of the {@code mythic-mob} YAML entry type introduced for + * BentoBoxWorld/AOneBlock#303. Execute-path behavior (hook lookup, callback + * dispatch) is exercised via manual integration testing against a live + * BentoBox + MythicMobs install. + */ +class MythicMobCustomBlockTest { + + @Test + void fromMapParsesAllFields() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("type", "mythic-mob"); + map.put("mob", "SkeletalKnight"); + map.put("level", 3); + map.put("power", 1.5); + map.put("display-name", "Boss"); + map.put("stance", "angry"); + map.put("underlying-block", "STONE"); + + Optional<MythicMobCustomBlock> result = MythicMobCustomBlock.fromMap(map); + + assertTrue(result.isPresent()); + MythicMobCustomBlock block = result.get(); + assertEquals("SkeletalKnight", block.getMob()); + assertEquals(3D, block.getLevel()); + assertEquals(1.5F, block.getPower()); + assertEquals("Boss", block.getDisplayName()); + assertEquals("angry", block.getStance()); + assertEquals(Material.STONE, block.getUnderlyingBlock()); + } + + @Test + void fromMapReturnsEmptyWhenMobMissing() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("type", "mythic-mob"); + // no "mob" key + + Optional<MythicMobCustomBlock> result = MythicMobCustomBlock.fromMap(map); + + assertTrue(result.isEmpty()); + } + + @Test + void fromMapAppliesDefaultsForOptionalFields() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("mob", "Goblin"); + + Optional<MythicMobCustomBlock> result = MythicMobCustomBlock.fromMap(map); + + assertTrue(result.isPresent()); + MythicMobCustomBlock block = result.get(); + assertEquals("Goblin", block.getMob()); + assertEquals(1D, block.getLevel(), "level defaults to 1"); + assertEquals(0F, block.getPower(), "power defaults to 0"); + } + + @Test + void fromMapAcceptsStringNumericFields() { + // YAML can deliver numbers as strings depending on quoting; ensure we coerce. + Map<String, Object> map = new LinkedHashMap<>(); + map.put("mob", "Goblin"); + map.put("level", "2"); + map.put("power", "0.75"); + + Optional<MythicMobCustomBlock> result = MythicMobCustomBlock.fromMap(map); + + assertTrue(result.isPresent()); + assertEquals(2D, result.get().getLevel()); + assertEquals(0.75F, result.get().getPower()); + } + + @Test + void registeredUnderMythicMobType() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("type", "mythic-mob"); + map.put("mob", "Goblin"); + + var result = world.bentobox.aoneblock.oneblocks.OneBlockCustomBlockCreator.create(map); + assertTrue(result.isPresent()); + assertNotNull(result.get()); + assertTrue(result.get() instanceof MythicMobCustomBlock); + } +} From dd8d7e8b291a855750efc1b8f220532909f227a5 Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:17:27 -0700 Subject: [PATCH 21/28] Fix mob-data /summon ordering and add custom-blocks sibling section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mob-data custom block glued NBT to the entity id, producing `summon breeze{...} x y z`, which vanilla's command parser rejects with "Unhandled exception". Split the data string at the first `{` or `[` so the command becomes `summon <entity> <x> <y> <z> [nbt]`, matching the vanilla grammar. Extracted into a package-private buildSummonCommand helper with unit tests locking in the ordering for `{...}`, `[...]`, and bare-entity inputs. Also add an optional sibling `custom-blocks:` list in OneBlocksManager so admins can keep their existing map-form `blocks:` section untouched while registering custom entries like mob-data or mythic-mob — removes the need to convert every vanilla block to list form just to add one custom spawn. Both sections feed the same weighted pool. Update the README examples and MobDataCustomBlock javadoc to use 1.21+ attribute syntax (`minecraft:scale`, not `minecraft:generic.scale`), add a note about the 1.21 attribute-prefix rename, and enrich the error log with a version-compat breadcrumb. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- README.md | 36 ++++++++++++++- .../aoneblock/oneblocks/OneBlocksManager.java | 28 +++++++++--- .../customblock/MobDataCustomBlock.java | 45 +++++++++++++++++-- .../oneblocks/OneBlocksManagerTest3.java | 32 +++++++++++++ .../customblock/MobDataCustomBlockTest.java | 36 +++++++++++++++ 5 files changed, 164 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0633b37..7068278 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ Be very careful when editing the chest items and check that the material is a tr ### Custom block entries -Phase `blocks:` sections can also be written as a YAML list, which unlocks four +Phase `blocks:` sections can also be written as a YAML list, which unlocks custom entry types: ```yaml @@ -203,7 +203,7 @@ blocks: # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. # After spawning, blocks inside the mob's (scaled) bounding box are cleared. - type: mob-data - data: minecraft:breeze{Glowing:1b,attributes:[{id:"minecraft:generic.scale",base:2}]} + data: breeze{CustomName:[{text:Breezy,color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,attributes:[{id:scale,base:2f}]} underlying-block: STONE probability: 15 @@ -218,6 +218,38 @@ blocks: probability: 5 ``` +> **Note:** the `mob-data` string is passed straight to the vanilla `/summon` +> command, so it must be valid NBT for your server version. A few 1.21 gotchas: +> attribute ids no longer use the `generic.`/`player.` prefix (`scale`, not +> `generic.scale`), numeric attribute bases need a float suffix (`base:2f`), +> and `CustomName` must be a text-component list (`[{text:Breezy}]`), not a +> plain string. Test the command in-game first with `/summon <your data>` — +> if it works there it will work here. Bad NBT is logged and the spawn is +> skipped. + +If you'd rather leave your existing `blocks:` map-form section untouched, you +can put custom entries in a sibling `custom-blocks:` list. Both sections are +read and their entries merged into the same weighted pool, so probabilities in +the two sections are directly comparable: + +```yaml +blocks: + PODZOL: 40 + DIRT: 1000 + OAK_LOG: 2000 + +custom-blocks: + - type: mob-data + data: breeze{CustomName:[{text:Breezy,color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,attributes:[{id:scale,base:2f}]} + underlying-block: STONE + probability: 50 + + - type: mythic-mob + mob: SkeletalKnight + level: 3 + probability: 5 +``` + ### Other Add-ons OneBlock is an add-on that uses the BentoBox API. Here are some other ones that you may be interested in: diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index ec20de5..7f5ea0f 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java @@ -61,6 +61,7 @@ public class OneBlocksManager { private static final String CONTENTS = "contents"; private static final String MOBS = "mobs"; private static final String BLOCKS = "blocks"; + private static final String CUSTOM_BLOCKS = "custom-blocks"; private static final String PHASES = "phases"; private static final String GOTO_BLOCK = "gotoBlock"; private static final String START_COMMANDS = "start-commands"; @@ -500,17 +501,30 @@ void addBlocks(OneBlockPhase obPhase, ConfigurationSection phase) { } } - int probability = Integer.parseInt(Objects.toString(map.get("probability"), "0")); - Optional<OneBlockCustomBlock> customBlock = OneBlockCustomBlockCreator.create(map); - if (customBlock.isPresent()) { - obPhase.addCustomBlock(customBlock.get(), probability); - } else { - addon.logError("Bad custom block in " + obPhase.getPhaseName() + ": " + map); - } + addCustomBlockFromMap(obPhase, map); + } + } + + // Optional sibling list holding only custom entries. Lets admins keep the + // existing map-form `blocks:` section untouched while still registering + // custom block types like `mob-data` or `mythic-mob`. + if (phase.isList(CUSTOM_BLOCKS)) { + for (Map<?, ?> map : phase.getMapList(CUSTOM_BLOCKS)) { + addCustomBlockFromMap(obPhase, map); } } } + private void addCustomBlockFromMap(OneBlockPhase obPhase, Map<?, ?> map) { + int probability = Integer.parseInt(Objects.toString(map.get("probability"), "0")); + Optional<OneBlockCustomBlock> customBlock = OneBlockCustomBlockCreator.create(map); + if (customBlock.isPresent()) { + obPhase.addCustomBlock(customBlock.get(), probability); + } else { + addon.logError("Bad custom block in " + obPhase.getPhaseName() + ": " + map); + } + } + private boolean addMaterial(OneBlockPhase obPhase, String material, String probability) { int prob; try { diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java index 156857d..c86fc5b 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java @@ -30,7 +30,7 @@ * * <pre> * - type: mob-data - * data: minecraft:breeze{Glowing:1b,attributes:[{id:"minecraft:generic.scale",base:2}]} + * data: breeze{Glowing:1b,attributes:[{id:scale,base:2f}]} * underlying-block: STONE * probability: 15 * </pre> @@ -76,13 +76,14 @@ public void execute(AOneBlock addon, Block block) { String x = String.valueOf(spawnLoc.getX()); String y = String.valueOf(spawnLoc.getY()); String z = String.valueOf(spawnLoc.getZ()); - String command = "execute in " + world + " run summon " + data + " " + x + " " + y + " " + z; + + String command = buildSummonCommand(data, world, x, y, z); Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); block.getWorld().playSound(block.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1F, 2F); // Defer MakeSpace by one tick so NBT-driven attributes (e.g. - // minecraft:generic.scale) have applied before we measure the bounding box. + // minecraft:scale) have applied before we measure the bounding box. Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { Entity spawned = findRecentlySpawned(spawnLoc); if (spawned != null && addon.getSettings().isClearBlocks()) { @@ -90,10 +91,46 @@ public void execute(AOneBlock addon, Block block) { } }, 1L); } catch (Exception e) { - BentoBox.getInstance().logError("Could not summon mob-data entity '" + data + "': " + e.getMessage()); + BentoBox.getInstance().logError("Could not summon mob-data entity '" + data + "': " + e.getMessage() + + " — check that the entity id, NBT keys, and attribute ids are valid for this Minecraft" + + " version (1.21 renamed 'minecraft:generic.*' attributes to drop the 'generic.' prefix)."); } } + /** + * Builds the {@code execute in <world> run summon <entity> <x> <y> <z> [nbt]} + * command for the given data string and coordinates. + * <p> + * Vanilla summon grammar is {@code summon <entity> <x> <y> <z> [nbt]} — the + * NBT must come AFTER the coordinates. Gluing it to the entity id (e.g. + * {@code summon breeze{...} x y z}) makes the command parser throw an + * "Unhandled exception" in {@code VanillaCommandWrapper}, so we split the + * data string at the first NBT/component delimiter (<code>{</code> or + * <code>[</code>) and interleave the coordinates. + * <p> + * Package-private so unit tests can verify the resulting command without + * needing a live server. + */ + static String buildSummonCommand(String data, String world, String x, String y, String z) { + int nbtStart = indexOfFirst(data, '{', '['); + String entityId = nbtStart < 0 ? data : data.substring(0, nbtStart); + String nbt = nbtStart < 0 ? "" : " " + data.substring(nbtStart); + return "execute in " + world + " run summon " + entityId + " " + x + " " + y + " " + z + nbt; + } + + /** + * Returns the index of the first occurrence of any of the given characters + * in {@code s}, or -1 if none are present. Used to locate where the NBT + * portion of a {@code /summon}-style data string begins. + */ + private static int indexOfFirst(String s, char a, char b) { + int ai = s.indexOf(a); + int bi = s.indexOf(b); + if (ai < 0) return bi; + if (bi < 0) return ai; + return Math.min(ai, bi); + } + /** * Finds the most-recently-spawned living entity near the given location. * Skips players, then picks the entity with the lowest {@code getTicksLived()} diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java index e965983..4bcc087 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java @@ -401,6 +401,38 @@ void testAddBlocks() { assertTrue(obPhase.getBlocks().isEmpty()); } + /** + * Verifies that a phase can keep its existing map-form {@code blocks:} section + * untouched while adding custom entries via a sibling {@code custom-blocks:} + * list. This is the backwards-compatible entry point for mob-data / mythic-mob. + */ + @Test + void testAddBlocksWithCustomBlocksSibling() throws InvalidConfigurationException { + String yaml = """ + name: Plains + blocks: + PODZOL: 40 + DIRT: 1000 + custom-blocks: + - type: mob-data + data: minecraft:breeze{Glowing:1b} + underlying-block: STONE + probability: 50 + """; + YamlConfiguration cfg = new YamlConfiguration(); + cfg.loadFromString(yaml); + + obm.addBlocks(obPhase, cfg); + + // Two vanilla materials registered via the map form... + assertEquals(2, obPhase.getBlocks().size()); + assertTrue(obPhase.getBlocks().containsKey(Material.PODZOL)); + assertTrue(obPhase.getBlocks().containsKey(Material.DIRT)); + // ...plus one custom block from the sibling list. Vanilla totals (40 + 1000) + // plus the custom block probability (50) should appear in blockTotal. + assertEquals(40 + 1000 + 50, obPhase.getBlockTotal()); + } + /** * Test method for * {@link world.bentobox.aoneblock.oneblocks.OneBlocksManager#getPhase(int)}. diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java index e439d02..552f9e7 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java @@ -84,6 +84,42 @@ void fromMapFallsBackToNullWhenUnderlyingBlockInvalid() { } } + @Test + void buildSummonCommandPlacesNbtAfterCoordinates() { + // Regression: the vanilla /summon grammar is `summon <entity> <x> <y> <z> [nbt]`. + // Previously we glued NBT to the entity id, producing an "Unhandled exception" + // in VanillaCommandWrapper. This test locks in the corrected ordering. + String data = "breeze{CustomName:[{text:Breezy}],Glowing:1b,attributes:[{id:scale,base:2f}]}"; + String command = MobDataCustomBlock.buildSummonCommand(data, "minecraft:oneblock_world", + "1600.5", "81.0", "1600.5"); + + assertEquals( + "execute in minecraft:oneblock_world run summon breeze 1600.5 81.0 1600.5 " + + "{CustomName:[{text:Breezy}],Glowing:1b,attributes:[{id:scale,base:2f}]}", + command); + } + + @Test + void buildSummonCommandWithNoNbt() { + // A bare entity id (no NBT or components) should still produce a valid command. + String command = MobDataCustomBlock.buildSummonCommand("minecraft:zombie", + "minecraft:world", "0.5", "65.0", "0.5"); + + assertEquals("execute in minecraft:world run summon minecraft:zombie 0.5 65.0 0.5", command); + } + + @Test + void buildSummonCommandWithComponentBrackets() { + // Modern component syntax uses `[` instead of `{` (e.g. `pig[minecraft:rotation=...]`). + // The split must handle whichever comes first. + String command = MobDataCustomBlock.buildSummonCommand("pig[minecraft:rotation={yaw:90f}]", + "minecraft:world", "1.5", "64.0", "1.5"); + + assertEquals( + "execute in minecraft:world run summon pig 1.5 64.0 1.5 [minecraft:rotation={yaw:90f}]", + command); + } + @Test void registeredUnderMobDataType() { // Sanity check: ensure the OneBlockCustomBlockCreator routes "mob-data" From f61d3fba54bfc7d7c6702263a972869e854f7908 Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:29:08 -0700 Subject: [PATCH 22/28] Add `block` custom-blocks alias for /setblock with NBT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the existing BlockDataCustomBlock under a second key `block` so phase-file authors placing NBT-heavy blocks (preconfigured spawners, etc.) aren't forced to use the misleading `block-data` name. Both types route to the same handler; `block-data` stays the recommended key for simple block states while `block` signals intent when the data string contains NBT or a setblock mode flag. No behavioural change — BlockDataCustomBlock.execute already falls through to `/setblock <x> <y> <z> <data>` when Bukkit.createBlockData can't parse the string, which is exactly what NBT payloads need. Documented both forms in the README with a working spawner+breeze example (single-quoted to avoid YAML delimiter clashes) and added two routing tests in BlockCustomBlockTest to lock in the alias. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- README.md | 18 +++++++ .../oneblocks/OneBlockCustomBlockCreator.java | 5 ++ .../customblock/BlockCustomBlockTest.java | 53 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/test/java/world/bentobox/aoneblock/oneblocks/customblock/BlockCustomBlockTest.java diff --git a/README.md b/README.md index 7068278..e67a1ef 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,15 @@ blocks: data: redstone_wire[power=15] probability: 20 + # `type: block` runs the vanilla /setblock command at the magic-block + # position. The data string may include block states `[…]`, NBT `{…}`, + # and a trailing destroy|keep|replace mode — anything valid after + # `setblock <x> <y> <z>`. Use single quotes around the data so any + # double quotes inside the NBT don't clash with YAML string delimiters. + - type: block + data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + probability: 10 + # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. # After spawning, blocks inside the mob's (scaled) bounding box are cleared. - type: mob-data @@ -218,6 +227,11 @@ blocks: probability: 5 ``` +`type: block` is an alias for `type: block-data` — both route to the same +handler. Use `block-data` when you only need simple block states +(`redstone_wire[power=15]`); use `block` when the data contains NBT or a +setblock mode flag so the intent is obvious at a glance. + > **Note:** the `mob-data` string is passed straight to the vanilla `/summon` > command, so it must be valid NBT for your server version. A few 1.21 gotchas: > attribute ids no longer use the `generic.`/`player.` prefix (`scale`, not @@ -244,6 +258,10 @@ custom-blocks: underlying-block: STONE probability: 50 + - type: block + data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + probability: 10 + - type: mythic-mob mob: SkeletalKnight level: 3 diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java index 51dd1a6..6c6c150 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java @@ -24,6 +24,11 @@ public final class OneBlockCustomBlockCreator { static { register("block-data", BlockDataCustomBlock::fromMap); + // Alias: `block` routes to the same handler as `block-data`. Both accept + // anything valid after `setblock <x> <y> <z>` — a block id, optional + // states `[…]`, optional NBT `{…}`, and an optional + // destroy|keep|replace mode. + register("block", BlockDataCustomBlock::fromMap); register("mob", MobCustomBlock::fromMap); register("mob-data", MobDataCustomBlock::fromMap); register("mythic-mob", MythicMobCustomBlock::fromMap); diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/BlockCustomBlockTest.java b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/BlockCustomBlockTest.java new file mode 100644 index 0000000..5028536 --- /dev/null +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/BlockCustomBlockTest.java @@ -0,0 +1,53 @@ +package world.bentobox.aoneblock.oneblocks.customblock; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import world.bentobox.aoneblock.oneblocks.OneBlockCustomBlock; +import world.bentobox.aoneblock.oneblocks.OneBlockCustomBlockCreator; + +/** + * Routing tests for the {@code block} custom-blocks alias. + * <p> + * {@code type: block} is an alias for {@code type: block-data} — both feed + * {@link BlockDataCustomBlock#fromMap(Map)}, whose {@code execute()} method + * supports block ids, block states, NBT, and trailing setblock mode flags via + * the fallback {@code /setblock} command dispatch. The alias exists so that + * phase-file authors who are placing NBT-heavy blocks (e.g. preconfigured + * spawners) aren't forced to use the misleading {@code block-data} key. + */ +class BlockCustomBlockTest { + + @Test + void typeBlockRoutesToBlockDataCustomBlock() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("type", "block"); + map.put("data", "spawner{SpawnData:{entity:{id:breeze}}} replace"); + + Optional<OneBlockCustomBlock> result = OneBlockCustomBlockCreator.create(map); + + assertTrue(result.isPresent(), "`type: block` should resolve to a custom block"); + assertInstanceOf(BlockDataCustomBlock.class, result.get(), + "`type: block` should produce a BlockDataCustomBlock instance"); + } + + @Test + void typeBlockDataStillWorks() { + // Regression: adding the `block` alias must not break the original + // `block-data` registration. + Map<String, Object> map = new LinkedHashMap<>(); + map.put("type", "block-data"); + map.put("data", "redstone_wire[power=15]"); + + Optional<OneBlockCustomBlock> result = OneBlockCustomBlockCreator.create(map); + + assertTrue(result.isPresent()); + assertInstanceOf(BlockDataCustomBlock.class, result.get()); + } +} From 848071d469ced58c480d3753cfd6db00b0588bbf Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:30:54 -0700 Subject: [PATCH 23/28] Add custom-blocks examples to 0_plains.yml Add a commented-out `custom-blocks:` sibling section to the Plains phase showing all three custom entry types (block, mob-data, mythic-mob) with working, single-quoted data strings. Using the first-phase file as the on-disk reference template matches how other optional sections (start-commands, requirements, etc.) are documented in this file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/main/resources/phases/0_plains.yml | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index 1d184a4..5e79a94 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -89,6 +89,42 @@ EMERALD_ORE: 10 DIRT_PATH: 100 COPPER_ORE: 200 + # Optional sibling list for custom entries. Lets you keep the map-form + # `blocks:` section above untouched while registering custom spawns. + # Entries here join the same weighted pool as `blocks:` — probabilities + # are directly comparable. Uncomment and tweak to try them out. + # + # Supported custom types: + # - type: block — runs /setblock with full data (block states, + # NBT, and an optional destroy|keep|replace mode). + # Alias of `block-data`; prefer `block` for NBT. + # - type: mob-data — runs /summon with vanilla NBT/components. + # Blocks inside the (scaled) bounding box are + # cleared one tick after spawn so the mob fits. + # - type: mythic-mob — spawns a MythicMob via BentoBox's hook. + # Requires the MythicMobs plugin; otherwise + # logged and skipped at runtime. + # + # YAML caveat: because these data strings contain `{`, `}`, `[`, `]`, + # and double quotes, wrap the value in SINGLE quotes so the inner + # double quotes don't clash with YAML string delimiters. + #custom-blocks: + # - type: mob-data + # data: 'breeze{CustomName:[{text:"Breezy",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,attributes:[{id:scale,base:2f}]}' + # underlying-block: STONE + # probability: 10 + # + # - type: block + # data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + # probability: 5 + # + # - type: mythic-mob + # mob: SkeletalKnight + # level: 3 + # power: 1.0 + # display-name: "Boss" + # underlying-block: STONE + # probability: 2 mobs: COW: 150 SPIDER: 75 From 13e09140366bc9acc4b4fb84366c5e889cc3caa3 Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:37:28 -0700 Subject: [PATCH 24/28] Add spawner timing fields to block example NBT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placing a spawner via /setblock with only SpawnData leaves the timing fields at their uninitialised defaults — specifically Delay:-1, which means "inactive, never tick". The example spawner appeared correctly but never spawned anything. Update the README and 0_plains.yml block example to set Delay, MinSpawnDelay, MaxSpawnDelay, SpawnCount, SpawnRange, MaxNearbyEntities, and RequiredPlayerRange explicitly so the spawner actually ticks. Add a README "Spawner gotcha" callout explaining why the timing fields are necessary, and an inline comment in the phase YAML pointing at the same trap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- README.md | 12 ++++++++++-- src/main/resources/phases/0_plains.yml | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e67a1ef..2bf14fc 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ blocks: # `setblock <x> <y> <z>`. Use single quotes around the data so any # double quotes inside the NBT don't clash with YAML string delimiters. - type: block - data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' probability: 10 # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. @@ -232,6 +232,14 @@ handler. Use `block-data` when you only need simple block states (`redstone_wire[power=15]`); use `block` when the data contains NBT or a setblock mode flag so the intent is obvious at a glance. +> **Spawner gotcha:** placing a `spawner` via `/setblock` only sets the fields +> you provide — everything else defaults to `0`/`-1`, which leaves the spawner +> **inactive** (`Delay:-1` means "never tick"). If you want it to actually spawn +> mobs, set `Delay`, `MinSpawnDelay`, `MaxSpawnDelay`, `SpawnCount`, `SpawnRange`, +> `MaxNearbyEntities`, and `RequiredPlayerRange` explicitly, as shown in the +> example above. Test your full data string in-game with `/setblock ~ ~ ~ <data>` +> first — if the spawner ticks there, it will tick from `custom-blocks:` too. + > **Note:** the `mob-data` string is passed straight to the vanilla `/summon` > command, so it must be valid NBT for your server version. A few 1.21 gotchas: > attribute ids no longer use the `generic.`/`player.` prefix (`scale`, not @@ -259,7 +267,7 @@ custom-blocks: probability: 50 - type: block - data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' probability: 10 - type: mythic-mob diff --git a/src/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index 5e79a94..6f691dd 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -114,8 +114,11 @@ # underlying-block: STONE # probability: 10 # + # # Spawner gotcha: without the Delay/MinSpawnDelay/... timing fields, + # # vanilla 1.21 places the spawner inactive (Delay:-1 = never tick). + # # Set them explicitly or the spawner appears but does nothing. # - type: block - # data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + # data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' # probability: 5 # # - type: mythic-mob From 3d15adf8823f7002c4847a947357b791ed4c86af Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:01:59 -0700 Subject: [PATCH 25/28] Refactor Russian localization in ru.yml Updated descriptions and hints in Russian localization file for better clarity and formatting. --- src/main/resources/locales/ru.yml | 78 +++++++++++++++++-------------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index c374120..3b35299 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -1,43 +1,44 @@ -########################################################################################### -# Это YML файл. Будьте осторожны при редактировании. Проверяйте свои правки # -# в YAML валидаторе, например, на http://yaml-online-parser.appspot.com # -########################################################################################### +# ######################################################################################## # +# Это YML файл. Будьте осторожны при редактировании. Проверяйте свои правки # +# в YAML валидаторе, например, на http://yaml-online-parser.appspot.com # +# ######################################################################################## # protection: flags: MAGIC_BLOCK: name: Магический блок - description: Предотвращает ломание магического блока + description: |- + <green>Предотвращает ломание</green> + <green>магического блока</green> hint: <red>Ломание магического блока запрещено!</red> START_SAFETY: name: Защита новичков от падений - description: Запрещает новичкам двигаться в течение 1 минуты. + description: |- + <green>Запрещает новичкам двигаться</green> + <green>в течение 1 минуты</green> hint: <red>Движение заблокировано в целях безопасности ещё на [number] секунд!</red> free-to-move: <green>Теперь вы можете двигаться. Будьте осторожны!</green> ONEBLOCK_BOSSBAR: - name: Boss Bar - description: |- - &b Показывает панель состояния - &b для каждой фазы. + name: Боссбар + description: Показывает статус фазы в боссбаре ONEBLOCK_ACTIONBAR: - name: Action Bar - description: |- - &b Показывает статус - &b для каждой фазы - &b в Action Bar. + name: Панель действий + description: Показывает статус фазы в панели действий aoneblock: bossbar: title: Осталось блоков # статус: "<green>Статус фазы <aqua>[total]</aqua>. Осталось: <aqua>[todo]</aqua></green>" # статус: "<green>[phase-name] : [percent-done]</green>" - status: '<green>Статус фазы <aqua>[done] <light_purple>/</light_purple> [total]</aqua></green>' + status: <green>Статус фазы <aqua>[done] <light_purple>/</light_purple> [total]</aqua></green> # RED, WHITE, PINK, BLUE, GREEN, YELLOW, or PURPLE color: RED # SOLID, SEGMENTED_6, SEGMENTED_10, SEGMENTED_12, SEGMENTED_20 style: SOLID not-active: <red>Боссбар отключен на этом острове.</red> actionbar: - status: '<green>Фаза: <aqua>[phase-name]</aqua> <light_purple>|</light_purple> Блоков: <aqua>[done] <light_purple>/</light_purple> [total]</aqua> <light_purple>|</light_purple> Прогресс: <aqua>[percent-done]</aqua></green>' + status: '<green>Фаза: <aqua>[phase-name]</aqua> <light_purple>|</light_purple> + Блоков: <aqua>[done] <light_purple>/</light_purple> [total]</aqua> <light_purple>|</light_purple> + Прогресс: <aqua>[percent-done]</aqua>' not-active: <red>Панель действий отключена на этом острове.</red> commands: admin: @@ -51,11 +52,13 @@ aoneblock: description: поместить сундук, на который смотрите, в фазу с указанной редкостью chest-is-empty: <red>Этот сундук пуст, поэтому его нельзя добавить.</red> unknown-phase: <red>Неизвестная фаза. Используйте Tab для подсказки.</red> - unknown-rarity: <red>Неизвестная редкость. Используйте COMMON, UNCOMMON, RARE или EPIC</red> + unknown-rarity: <red>Неизвестная редкость. Используйте COMMON, UNCOMMON, RARE + или EPIC</red> look-at-chest: <red>Посмотрите на заполненный сундук, чтобы установить его.</red> only-single-chest: <red>Можно устанавливать только одиночные сундуки.</red> success: <green>Сундук успешно добавлен в фазу!</green> - failure: <red>Не удалось добавить сундук в фазу! Проверьте консоль на наличие ошибок.</red> + failure: <red>Не удалось добавить сундук в фазу! Проверьте консоль на наличие + ошибок.</red> sanity: parameters: <фаза> description: вывести в консоль проверку вероятностей фазы @@ -64,7 +67,8 @@ aoneblock: description: показать количество вскопано блоков и фазу info: <green>Вскопано <aqua>[number]</aqua> блоков на фазе [name]</green> info: - count: <green>Вскопано <aqua>[number]</aqua> блоков на фазе <aqua>[name]</aqua>. Всего сломано <aqua>[lifetime]</aqua>.</green> + count: <green>Вскопано <aqua>[number]</aqua> блоков на фазе <aqua>[name]</aqua>. + Всего сломано <aqua>[lifetime]</aqua>.</green> phases: description: показать список всех фаз title: <dark_green>Фазы OneBlock</dark_green> @@ -89,10 +93,14 @@ aoneblock: block-exist: <green>Блок существует, восстановление не требовалось.</green> block-respawned: <green>Блок восстановлен.</green> phase: - insufficient-level: <red>Уровень вашего острова слишком низок для продолжения! Требуется [number].</red> - insufficient-funds: <red>Ваши средства слишком малы для продолжения! Требуется [number].</red> - insufficient-bank-balance: <red>Баланс банка острова слишком низок для продолжения! Требуется [number].</red> - insufficient-permission: <red>Вы не можете продолжать, пока не получите разрешение [name]!</red> + insufficient-level: <red>Уровень вашего острова слишком низок для продолжения! + Требуется [number].</red> + insufficient-funds: <red>Ваши средства слишком малы для продолжения! Требуется + [number].</red> + insufficient-bank-balance: <red>Баланс банка острова слишком низок для продолжения! + Требуется [number].</red> + insufficient-permission: <red>Вы не можете продолжать, пока не получите разрешение + [name]!</red> cooldown: <red>Следующая фаза станет доступна через [number] секунд!</red> placeholders: infinite: Бесконечность @@ -116,19 +124,21 @@ aoneblock: [level] [permission] [blocks] - starting-block: <gray>Начинается после разрушения <yellow>[number]</yellow> блоков.</gray> + starting-block: <gray>Начинается после разрушения <yellow>[number]</yellow> + блоков.</gray> biome: '<gray>Биом: <yellow>[biome]</yellow></gray>' - bank: '<gray>Требуется <yellow>$[number]</yellow> на счету банка.</gray>' - economy: '<gray>Требуется <yellow>$[number]</yellow> на счёте игрока.</gray>' - level: '<gray>Требуется <yellow>[number]</yellow> уровень острова.</gray>' - permission: '<gray>Требуется разрешение <yellow>[permission]</yellow>.</gray>' - blocks-prefix: '<gray>Блоки в фазе - </gray>' - blocks: '<yellow>[name], </yellow>' + bank: <gray>Требуется <yellow>$[number]</yellow> на счету банка.</gray> + economy: <gray>Требуется <yellow>$[number]</yellow> на счёте игрока.</gray> + level: <gray>Требуется <yellow>[number]</yellow> уровень острова.</gray> + permission: <gray>Требуется разрешение <yellow>[permission]</yellow>.</gray> + blocks-prefix: <gray>Блоки в фазе - </gray> + blocks: <yellow>[name], </yellow> wrap-at: '50' tips: - click-to-previous: '<gray><yellow>Нажмите</yellow> для перехода на предыдущую страницу.</gray>' - click-to-next: '<gray><yellow>Нажмите</yellow> для перехода на следующую страницу.</gray>' - click-to-change: '<gray><yellow>Нажмите</yellow> для изменения.</gray>' + click-to-previous: <gray><yellow>Нажмите</yellow> для перехода на предыдущую + страницу.</gray> + click-to-next: <gray><yellow>Нажмите</yellow> для перехода на следующую страницу.</gray> + click-to-change: <gray><yellow>Нажмите</yellow> для изменения.</gray> island: starting-hologram: |- <green>Добро пожаловать в AOneBlock</green> From 97f88dd3e88ba073ac6cc38f462dd1bffa6d6b64 Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:09:21 -0700 Subject: [PATCH 26/28] Spawn custom-blocks bosses and spawners on the current tick Two independent delays made block-placed spawners and mythic-mob bosses feel sluggish, appearing ~1-2 seconds after the magic block broke. Remove both: 1. MythicMobCustomBlock now prefers the 4-arg spawnMythicMob(record, loc, Consumer, long) overload from BentoBox 3.15.0+ with delayTicks=0, so the boss spawns on the current tick instead of after the hook's historical 40-tick (2s) delay. That delay exists for blueprint-paste callers and is dead time for AOneBlock's synchronous block replace. Reflection falls back to the 3-arg (40-tick) and 2-arg overloads on older BentoBox, so nothing breaks for users running BentoBox 3.13-3.14. MakeSpace still runs from the Consumer callback. 2. The example spawner NBT in the README and 0_plains.yml used Delay:20 (first spawn in 1s); switch to Delay:0 so the first spawn happens on the next tick (visually instant). Extend the "Spawner gotcha" callout with one sentence explaining Delay:0 vs Delay:N vs Delay:-1. Companion BentoBox PR adds the 4-arg overload; until that release lands, the reflection fallback keeps behaviour identical to today. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- README.md | 10 +++-- .../customblock/MythicMobCustomBlock.java | 41 +++++++++++++++---- src/main/resources/phases/0_plains.yml | 4 +- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2bf14fc..fa4e486 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ blocks: # `setblock <x> <y> <z>`. Use single quotes around the data so any # double quotes inside the NBT don't clash with YAML string delimiters. - type: block - data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + data: 'spawner{Delay:0,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' probability: 10 # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. @@ -237,8 +237,10 @@ setblock mode flag so the intent is obvious at a glance. > **inactive** (`Delay:-1` means "never tick"). If you want it to actually spawn > mobs, set `Delay`, `MinSpawnDelay`, `MaxSpawnDelay`, `SpawnCount`, `SpawnRange`, > `MaxNearbyEntities`, and `RequiredPlayerRange` explicitly, as shown in the -> example above. Test your full data string in-game with `/setblock ~ ~ ~ <data>` -> first — if the spawner ticks there, it will tick from `custom-blocks:` too. +> example above. `Delay:0` spawns on the very next tick (visually instant), +> `Delay:N` waits N ticks before the first spawn, and `Delay:-1` never ticks. +> Test your full data string in-game with `/setblock ~ ~ ~ <data>` first — if +> the spawner ticks there, it will tick from `custom-blocks:` too. > **Note:** the `mob-data` string is passed straight to the vanilla `/summon` > command, so it must be valid NBT for your server version. A few 1.21 gotchas: @@ -267,7 +269,7 @@ custom-blocks: probability: 50 - type: block - data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + data: 'spawner{Delay:0,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' probability: 10 - type: mythic-mob diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java index 3042316..515c72e 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java @@ -126,8 +126,12 @@ public void execute(AOneBlock addon, Block block) { } }; - // Prefer the 3-arg overload (BentoBox >= 3.14.0) so MakeSpace can run after - // the 40-tick spawn delay. Fall back to the 2-arg method on older BentoBox. + // Prefer the 4-arg overload (BentoBox >= 3.15.0) with delayTicks=0 so the + // boss appears on the current tick instead of after the hook's historical + // 40-tick delay — that delay exists for blueprint-paste callers and is + // unnecessary for AOneBlock's synchronous block replace. Fall back to the + // 3-arg overload (BentoBox >= 3.14.0, 40-tick delay) and finally the 2-arg + // method on older BentoBox. MakeSpace still runs from the callback. if (!invokeWithCallback(hook, record, spawnLoc, onSpawn)) { hook.spawnMythicMob(record, spawnLoc); } @@ -139,14 +143,36 @@ public void execute(AOneBlock addon, Block block) { } /** - * Attempts to call the 3-arg {@code spawnMythicMob(record, location, Consumer)} - * via reflection so this class still compiles and runs against BentoBox versions - * that don't yet ship the callback overload. + * Attempts to call the MythicMobsHook overload with a {@code Consumer<Entity>} + * callback via reflection so this class still compiles and runs against + * BentoBox versions that don't yet ship the overload. + * <p> + * Tries the 4-arg {@code spawnMythicMob(record, location, Consumer, long)} + * form first with {@code delayTicks=0} so the boss spawns on the current + * tick (BentoBox >= 3.15.0). If that isn't available, falls back to the + * 3-arg {@code spawnMythicMob(record, location, Consumer)} form with its + * built-in 40-tick delay (BentoBox >= 3.14.0). If neither is found, returns + * {@code false} so the caller can dispatch the 2-arg form. * - * @return true if the callback overload was invoked successfully + * @return true if either callback overload was invoked successfully */ private boolean invokeWithCallback(MythicMobsHook hook, MythicMobRecord record, Location spawnLoc, Consumer<Entity> onSpawn) { + // Preferred: 4-arg overload with explicit zero delay. + try { + Method m = MythicMobsHook.class.getMethod("spawnMythicMob", + MythicMobRecord.class, Location.class, Consumer.class, long.class); + m.invoke(hook, record, spawnLoc, onSpawn, 0L); + return true; + } catch (NoSuchMethodException ignored) { + // fall through to the 3-arg form + } catch (Exception e) { + BentoBox.getInstance().logError( + "Failed to invoke MythicMobsHook 4-arg callback overload: " + e.getMessage()); + return false; + } + + // Fallback: 3-arg overload (40-tick built-in delay). try { Method m = MythicMobsHook.class.getMethod("spawnMythicMob", MythicMobRecord.class, Location.class, Consumer.class); @@ -155,7 +181,8 @@ private boolean invokeWithCallback(MythicMobsHook hook, MythicMobRecord record, } catch (NoSuchMethodException e) { return false; } catch (Exception e) { - BentoBox.getInstance().logError("Failed to invoke MythicMobsHook callback overload: " + e.getMessage()); + BentoBox.getInstance().logError( + "Failed to invoke MythicMobsHook 3-arg callback overload: " + e.getMessage()); return false; } } diff --git a/src/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index 6f691dd..8190682 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -117,8 +117,10 @@ # # Spawner gotcha: without the Delay/MinSpawnDelay/... timing fields, # # vanilla 1.21 places the spawner inactive (Delay:-1 = never tick). # # Set them explicitly or the spawner appears but does nothing. + # # Delay:0 makes the first spawn happen on the very next tick (instant); + # # use Delay:N to wait N ticks before the first spawn instead. # - type: block - # data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + # data: 'spawner{Delay:0,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' # probability: 5 # # - type: mythic-mob From 9214438a192b4c28748f4218c966b90ca12ecea7 Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:05:04 -0700 Subject: [PATCH 27/28] Add continuous brushing for placed suspicious blocks Suspicious gravel/sand are placed programmatically by the magic-block handler, so the vanilla brush animation never runs on them. The previous right-click handler advanced one dust stage per click with no visual progress feedback. Now each click also plays dust particles and the brushing sound, and kicks off a 10-tick repeating session that advances dusting while the player keeps holding right-click (detected via Player#isHandRaised). The session cancels when the player releases, looks away, swaps items, disconnects, or the block disappears, matching vanilla feel. Closes #490 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .../aoneblock/listeners/BlockListener.java | 119 ++++++++++++++++-- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java b/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java index 58cb88b..c02e25c 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java @@ -10,6 +10,7 @@ import java.util.Optional; import java.util.Random; import java.util.TreeMap; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import org.bukkit.Bukkit; @@ -46,10 +47,12 @@ import org.bukkit.event.entity.ItemSpawnEvent; import org.bukkit.event.player.PlayerBucketFillEvent; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.loot.LootContext; import org.bukkit.loot.LootTable; +import org.bukkit.scheduler.BukkitTask; import org.bukkit.util.Vector; import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; @@ -97,6 +100,17 @@ public class BlockListener extends FlagListener implements Listener { */ private final Map<String, OneBlockIslands> cache; + /** + * Active continuous-brushing sessions, keyed by player UUID. Each session + * holds the repeating task driving dust progression and the block being brushed. + */ + private final Map<UUID, BrushSession> brushSessions = new HashMap<>(); + + /** + * Per-player brushing session state. + */ + private record BrushSession(BukkitTask task, Block block) {} + /** * Helper class to check phase requirements. */ @@ -711,21 +725,110 @@ public void onPlayerInteract(PlayerInteractEvent e) { if (block.getBlockData() instanceof Brushable bb) { int dusted = bb.getDusted() + 1; if (dusted > bb.getMaximumDusted()) { - completeBrush(e, block); - } else { + completeBrush(e.getPlayer(), block); + return; + } + bb.setDusted(dusted); + block.setBlockData(bb); + playBrushFeedback(block); + // Kick off a continuous-brush session so the player can hold right-click + // and have dusting advance automatically (vanilla feel). The kickoff click + // above already advances one stage; the timer picks up from there. + Player player = e.getPlayer(); + UUID uuid = player.getUniqueId(); + BrushSession existing = brushSessions.get(uuid); + if (existing != null && !existing.block().equals(block)) { + cancelBrushSession(uuid); + existing = null; + } + if (existing == null) { + brushSessions.put(uuid, startContinuousBrush(player, block)); + } + } + } + + /** + * Schedules a repeating task that advances brushing on the given block while the + * player keeps holding right-click with the brush. Period of 10 ticks per dust stage + * matches the vanilla brush cadence. + * @param player The brushing player. + * @param block The suspicious block being brushed. + * @return A new BrushSession holding the scheduled task. + */ + private BrushSession startContinuousBrush(Player player, Block block) { + UUID uuid = player.getUniqueId(); + BukkitTask task = Bukkit.getScheduler().runTaskTimer(addon.getPlugin(), new Runnable() { + @Override + public void run() { + // Validate that the player is still actively brushing this block. + if (!player.isOnline() + || player.getInventory().getItemInMainHand().getType() != Material.BRUSH + || !player.isHandRaised() + || !block.equals(player.getTargetBlockExact(5)) + || (block.getType() != Material.SUSPICIOUS_GRAVEL + && block.getType() != Material.SUSPICIOUS_SAND) + || !(block.getBlockData() instanceof Brushable bb)) { + cancelBrushSession(uuid); + return; + } + int dusted = bb.getDusted() + 1; + if (dusted > bb.getMaximumDusted()) { + completeBrush(player, block); + cancelBrushSession(uuid); + return; + } bb.setDusted(dusted); block.setBlockData(bb); + playBrushFeedback(block); } + }, 10L, 10L); + return new BrushSession(task, block); + } + + /** + * Cancels any active brushing session for the given player UUID. + * @param uuid The player's UUID. + */ + private void cancelBrushSession(UUID uuid) { + BrushSession session = brushSessions.remove(uuid); + if (session != null) { + session.task().cancel(); } } + /** + * Clean up any brushing session when a player disconnects. + * @param e The PlayerQuitEvent. + */ + @EventHandler + public void onPlayerQuit(PlayerQuitEvent e) { + cancelBrushSession(e.getPlayer().getUniqueId()); + } + + /** + * Plays brushing particles and sound at a suspicious block to give visible/audible + * progress feedback. Needed because the block is placed programmatically rather than + * spawning naturally, so the vanilla brush animation is not triggered on clients. + * @param block The suspicious block being brushed. + */ + private void playBrushFeedback(Block block) { + World world = block.getWorld(); + Location center = block.getLocation().add(0.5, 0.5, 0.5); + // Dust particles using the block's own data so they match sand/gravel colour. + world.spawnParticle(Particle.BLOCK, center, 10, 0.25, 0.25, 0.25, 0.0, block.getBlockData()); + Sound brushSound = (block.getType() == Material.SUSPICIOUS_GRAVEL) + ? Sound.ITEM_BRUSH_BRUSHING_GRAVEL + : Sound.ITEM_BRUSH_BRUSHING_SAND; + world.playSound(center, brushSound, 0.8f, 1.0f); + } + /** * Completes the brushing of a suspicious block: drops loot (if available), plays the * break sound, removes the block, fires a BlockBreakEvent, and damages the brush. - * @param e The originating PlayerInteractEvent. - * @param block The suspicious block being brushed. + * @param player The brushing player. + * @param block The suspicious block being brushed. */ - private void completeBrush(PlayerInteractEvent e, Block block) { + private void completeBrush(Player player, Block block) { Location loc = block.getLocation().add(0.5, 0.5, 0.5); World world = block.getWorld(); @@ -733,7 +836,7 @@ private void completeBrush(PlayerInteractEvent e, Block block) { LootTable lootTable = suspiciousBlock.getLootTable(); if (lootTable != null) { LootContext context = new LootContext.Builder(loc) - .lootedEntity(e.getPlayer()).killer(e.getPlayer()).build(); + .lootedEntity(player).killer(player).build(); Collection<ItemStack> items = lootTable.populateLoot(new Random(), context); for (ItemStack item : items) { world.dropItemNaturally(loc, item); @@ -746,8 +849,8 @@ private void completeBrush(PlayerInteractEvent e, Block block) { : Sound.BLOCK_SUSPICIOUS_SAND_BREAK; world.playSound(loc, breakSound, 1.0f, 1.0f); block.setType(Material.AIR); - Bukkit.getPluginManager().callEvent(new BlockBreakEvent(block, e.getPlayer())); - e.getPlayer().getInventory().getItemInMainHand().damage(1, e.getPlayer()); + Bukkit.getPluginManager().callEvent(new BlockBreakEvent(block, player)); + player.getInventory().getItemInMainHand().damage(1, player); } /** From 78f8deb228caa49b5007d7d1f4c01cf666b1a324 Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:06:53 -0700 Subject: [PATCH 28/28] Fix formatting issues in Russian locale file --- src/main/resources/locales/ru.yml | 43 ++++++++++++------------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 3b35299..84fa149 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -38,7 +38,7 @@ aoneblock: actionbar: status: '<green>Фаза: <aqua>[phase-name]</aqua> <light_purple>|</light_purple> Блоков: <aqua>[done] <light_purple>/</light_purple> [total]</aqua> <light_purple>|</light_purple> - Прогресс: <aqua>[percent-done]</aqua>' + Прогресс: <aqua>[percent-done]</aqua></green>' not-active: <red>Панель действий отключена на этом острове.</red> commands: admin: @@ -52,13 +52,11 @@ aoneblock: description: поместить сундук, на который смотрите, в фазу с указанной редкостью chest-is-empty: <red>Этот сундук пуст, поэтому его нельзя добавить.</red> unknown-phase: <red>Неизвестная фаза. Используйте Tab для подсказки.</red> - unknown-rarity: <red>Неизвестная редкость. Используйте COMMON, UNCOMMON, RARE - или EPIC</red> + unknown-rarity: <red>Неизвестная редкость. Используйте COMMON, UNCOMMON, RARE или EPIC</red> look-at-chest: <red>Посмотрите на заполненный сундук, чтобы установить его.</red> only-single-chest: <red>Можно устанавливать только одиночные сундуки.</red> success: <green>Сундук успешно добавлен в фазу!</green> - failure: <red>Не удалось добавить сундук в фазу! Проверьте консоль на наличие - ошибок.</red> + failure: <red>Не удалось добавить сундук в фазу! Проверьте консоль на наличие ошибок.</red> sanity: parameters: <фаза> description: вывести в консоль проверку вероятностей фазы @@ -67,8 +65,7 @@ aoneblock: description: показать количество вскопано блоков и фазу info: <green>Вскопано <aqua>[number]</aqua> блоков на фазе [name]</green> info: - count: <green>Вскопано <aqua>[number]</aqua> блоков на фазе <aqua>[name]</aqua>. - Всего сломано <aqua>[lifetime]</aqua>.</green> + count: <green>Вскопано <aqua>[number]</aqua> блоков на фазе <aqua>[name]</aqua>. Всего сломано <aqua>[lifetime]</aqua>.</green> phases: description: показать список всех фаз title: <dark_green>Фазы OneBlock</dark_green> @@ -93,14 +90,10 @@ aoneblock: block-exist: <green>Блок существует, восстановление не требовалось.</green> block-respawned: <green>Блок восстановлен.</green> phase: - insufficient-level: <red>Уровень вашего острова слишком низок для продолжения! - Требуется [number].</red> - insufficient-funds: <red>Ваши средства слишком малы для продолжения! Требуется - [number].</red> - insufficient-bank-balance: <red>Баланс банка острова слишком низок для продолжения! - Требуется [number].</red> - insufficient-permission: <red>Вы не можете продолжать, пока не получите разрешение - [name]!</red> + insufficient-level: <red>Уровень вашего острова слишком низок для продолжения! Требуется [number].</red> + insufficient-funds: <red>Ваши средства слишком малы для продолжения! Требуется [number].</red> + insufficient-bank-balance: <red>Баланс банка острова слишком низок для продолжения! Требуется [number].</red> + insufficient-permission: <red>Вы не можете продолжать, пока не получите разрешение [name]!</red> cooldown: <red>Следующая фаза станет доступна через [number] секунд!</red> placeholders: infinite: Бесконечность @@ -124,21 +117,19 @@ aoneblock: [level] [permission] [blocks] - starting-block: <gray>Начинается после разрушения <yellow>[number]</yellow> - блоков.</gray> + starting-block: <gray>Начинается после разрушения <yellow>[number] </yellow>блоков.</gray> biome: '<gray>Биом: <yellow>[biome]</yellow></gray>' - bank: <gray>Требуется <yellow>$[number]</yellow> на счету банка.</gray> - economy: <gray>Требуется <yellow>$[number]</yellow> на счёте игрока.</gray> - level: <gray>Требуется <yellow>[number]</yellow> уровень острова.</gray> + bank: <gray>Требуется <yellow>$[number] </yellow>на счету банка.</gray> + economy: <gray>Требуется <yellow>$[number] </yellow>на счёте игрока.</gray> + level: <gray>Требуется <yellow>[number] </yellow>уровень острова.</gray> permission: <gray>Требуется разрешение <yellow>[permission]</yellow>.</gray> - blocks-prefix: <gray>Блоки в фазе - </gray> - blocks: <yellow>[name], </yellow> + blocks-prefix: <gray>Блоки - </gray> + blocks: '<yellow>[name], ' wrap-at: '50' tips: - click-to-previous: <gray><yellow>Нажмите</yellow> для перехода на предыдущую - страницу.</gray> - click-to-next: <gray><yellow>Нажмите</yellow> для перехода на следующую страницу.</gray> - click-to-change: <gray><yellow>Нажмите</yellow> для изменения.</gray> + click-to-previous: <gray><yellow>Нажмите </yellow>для перехода на предыдущую страницу.</gray> + click-to-next: <gray><yellow>Нажмите </yellow>для перехода на следующую страницу.</gray> + click-to-change: <gray><yellow>Нажмите</yellow>, чтобы изменить.</gray> island: starting-hologram: |- <green>Добро пожаловать в AOneBlock</green>