diff --git a/pom.xml b/pom.xml index 8545c43..52a9212 100644 --- a/pom.xml +++ b/pom.xml @@ -35,7 +35,9 @@ 21 1.21.11-R0.1-SNAPSHOT - 3.14.0-SNAPSHOT + + 3.17.0 2.5.0 1.4.0 1.18.0 @@ -50,7 +52,7 @@ ${build.version}-SNAPSHOT - 2.9.0 + 2.10.0 -LOCAL BentoBoxWorld_MagicCobblestoneGenerator diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/database/objects/GeneratorTierObject.java b/src/main/java/world/bentobox/magiccobblestonegenerator/database/objects/GeneratorTierObject.java index 3c2ec40..bbdd111 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/database/objects/GeneratorTierObject.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/database/objects/GeneratorTierObject.java @@ -13,6 +13,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.NavigableMap; import java.util.Set; import java.util.TreeMap; @@ -345,10 +346,12 @@ public void setActivateOnUnlock(boolean activateOnUnlock) /** * Returns the blockChanceMap of this object. * - * @return a {@code TreeMap} where the keys are {@code Double} values representing chances, - * and the values are {@code Material} objects. + * @return a {@code SortedMap} where the keys are {@code Double} values representing chances, + * and the values are block IDs: vanilla {@code Material} names (e.g. {@code COBBLESTONE}) + * or provider-prefixed custom block IDs (e.g. {@code itemsadder:namespace:id}) — see + * {@link world.bentobox.magiccobblestonegenerator.utils.CustomBlocks}. */ - public TreeMap getBlockChanceMap() + public NavigableMap getBlockChanceMap() { return blockChanceMap; } @@ -359,9 +362,9 @@ public TreeMap getBlockChanceMap() * * @param blockChanceMap new value for this object. */ - public void setBlockChanceMap(TreeMap blockChanceMap) + public void setBlockChanceMap(NavigableMap blockChanceMap) { - this.blockChanceMap = blockChanceMap; + this.blockChanceMap = new TreeMap<>(blockChanceMap); } @@ -813,10 +816,11 @@ public boolean includes(GeneratorType type) // Rewards section /** - * Map that stores different blocks and their chance for generating. + * Map that stores different blocks and their chance for generating. Values are vanilla material + * names or provider-prefixed custom block IDs. */ @Expose - private TreeMap blockChanceMap = new TreeMap<>(); + private TreeMap blockChanceMap = new TreeMap<>(); /** * Map that stores different extra treasures and their change for being dropped. @@ -856,10 +860,10 @@ public boolean includes(GeneratorType type) private int maxHeight = 320; /** - * Map that stores min and max heights for each material in the generator. + * Map that stores min and max heights for each block ID in the generator. */ @Expose - private TreeMap materialHeightMap = new TreeMap<>(); + private TreeMap materialHeightMap = new TreeMap<>(); /** * Maximum number of blocks this generator tier is allowed to generate during a single exhaustion period. @@ -872,22 +876,22 @@ public boolean includes(GeneratorType type) * Field to store block height ranges */ @Expose - private Map blockHeightRanges = new HashMap<>(); + private Map blockHeightRanges = new HashMap<>(); /** * Gets the height ranges for each block type - * @return Map of Material to height range array [min, max] + * @return Map of block ID to height range array [min, max] */ - public Map getBlockHeightRanges() + public Map getBlockHeightRanges() { return blockHeightRanges; } /** * Sets the height ranges for each block type - * @param blockHeightRanges Map of Material to height range array [min, max] + * @param blockHeightRanges Map of block ID to height range array [min, max] */ - public void setBlockHeightRanges(Map blockHeightRanges) + public void setBlockHeightRanges(Map blockHeightRanges) { this.blockHeightRanges = blockHeightRanges; } @@ -937,48 +941,48 @@ public void setMaxHeight(int maxHeight) /** - * Gets the map of material-specific height ranges. + * Gets the map of block-specific height ranges, keyed by block ID. * * @return the material height map */ - public TreeMap getMaterialHeightMap() + public NavigableMap getMaterialHeightMap() { return this.materialHeightMap; } /** - * Sets the map of material-specific height ranges. + * Sets the map of block-specific height ranges, keyed by block ID. * * @param materialHeightMap the material height map */ - public void setMaterialHeightMap(TreeMap materialHeightMap) + public void setMaterialHeightMap(NavigableMap materialHeightMap) { - this.materialHeightMap = materialHeightMap; + this.materialHeightMap = new TreeMap<>(materialHeightMap); } /** - * Gets the height range for a specific material. + * Gets the height range for a specific block ID. * - * @param material the material + * @param blockId the block ID * @return an array where index 0 is minHeight and index 1 is maxHeight, or null if not set */ - public int[] getMaterialHeightRange(Material material) + public int[] getMaterialHeightRange(String blockId) { - return this.materialHeightMap.get(material); + return this.materialHeightMap.get(blockId); } /** - * Sets the height range for a specific material. + * Sets the height range for a specific block ID. * - * @param material the material + * @param blockId the block ID * @param minHeight the minimum height * @param maxHeight the maximum height */ - public void setMaterialHeightRange(Material material, int minHeight, int maxHeight) + public void setMaterialHeightRange(String blockId, int minHeight, int maxHeight) { - this.materialHeightMap.put(material, new int[]{minHeight, maxHeight}); + this.materialHeightMap.put(blockId, new int[]{minHeight, maxHeight}); } } diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/GeneratorListener.java b/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/GeneratorListener.java index a1923ce..ae36796 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/GeneratorListener.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/GeneratorListener.java @@ -26,6 +26,8 @@ import world.bentobox.magiccobblestonegenerator.StoneGeneratorAddon; import world.bentobox.magiccobblestonegenerator.database.objects.GeneratorDataObject; import world.bentobox.magiccobblestonegenerator.database.objects.GeneratorTierObject; +import world.bentobox.magiccobblestonegenerator.utils.CustomBlocks; +import world.bentobox.magiccobblestonegenerator.utils.Why; /** @@ -151,13 +153,67 @@ protected void playEffects(Block block) /** - * This method returns material of new block if generator manages to replace cobblestone to a new magic block. + * This method applies a block ID picked by the generator to the given block. Vanilla IDs are set + * immediately; custom (hook-provided) IDs first set a vanilla placeholder and are replaced by the + * hook placement one tick later, because hook APIs set blocks directly rather than through a state. + * + * @param blockId Block ID picked by the generator (vanilla material name or provider-prefixed custom ID). + * @param block Block that must be replaced. + * @param placeholder Vanilla material to leave in place while a custom placement is pending. + * @return {@code true} if a replacement was applied or scheduled. + */ + protected boolean applyBlockId(String blockId, Block block, Material placeholder) + { + Material material = CustomBlocks.matchVanilla(blockId); + + if (material != null) + { + if (!material.isBlock()) + { + return false; + } + + block.setType(material); + return true; + } + + if (CustomBlocks.isCustom(blockId)) + { + block.setType(placeholder); + this.scheduleCustomBlockPlacement(blockId, block.getLocation()); + return true; + } + + return false; + } + + + /** + * This method schedules placement of a custom block via its BentoBox hook on the next tick. + * + * @param blockId Provider-prefixed custom block ID. + * @param location Location where the block must be placed. + */ + protected void scheduleCustomBlockPlacement(String blockId, Location location) + { + Bukkit.getScheduler().runTask(this.addon.getPlugin(), () -> + { + if (!CustomBlocks.place(this.addon, blockId, location)) + { + Why.report(location, "Custom block " + blockId + " could not be placed."); + } + }); + } + + + /** + * This method returns block ID of new block if generator manages to replace cobblestone to a new magic block. * * @param island Island on which block is processed. * @param location Block location that need to be replaced. - * @return Material of replaced block or null, if block was not replaced. + * @return Block ID of replaced block or null, if block was not replaced. */ - protected @Nullable Material generateCobblestoneReplacement(@Nullable Island island, Location location) + protected @Nullable String generateCobblestoneReplacement(@Nullable Island island, Location location) { GeneratorTierObject generatorTier = this.addon.getAddonManager().getGeneratorTier( island, @@ -175,13 +231,13 @@ protected void playEffects(Block block) /** - * This method returns material of new block if generator manages to replace stone to a new magic block. + * This method returns block ID of new block if generator manages to replace stone to a new magic block. * * @param island Island on which block is processed. * @param location Block that need to be replaced. - * @return Material of replaced block or null, if block was not replaced. + * @return Block ID of replaced block or null, if block was not replaced. */ - protected @Nullable Material generateStoneReplacement(@Nullable Island island, Location location) + protected @Nullable String generateStoneReplacement(@Nullable Island island, Location location) { GeneratorTierObject generatorTier = this.addon.getAddonManager().getGeneratorTier( island, @@ -199,13 +255,13 @@ protected void playEffects(Block block) /** - * This method returns material of new block if generator manages to replace basalt to a new magic block. + * This method returns block ID of new block if generator manages to replace basalt to a new magic block. * * @param island Island on which block is processed. * @param location Block that need to be replaced. - * @return Material of replaced block or null, if block was not replaced. + * @return Block ID of replaced block or null, if block was not replaced. */ - protected @Nullable Material generateBasaltReplacement(@Nullable Island island, Location location) + protected @Nullable String generateBasaltReplacement(@Nullable Island island, Location location) { GeneratorTierObject generatorTier = this.addon.getAddonManager().getGeneratorTier( island, diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/MagicGeneratorListener.java b/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/MagicGeneratorListener.java index 3da672e..264e7d3 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/MagicGeneratorListener.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/MagicGeneratorListener.java @@ -123,11 +123,10 @@ public void onBlockFromToEvent(BlockFromToEvent event) { // Return from here at any case. Even if could not manage to replace stone. - Material material = this.generateStoneReplacement(island, eventToBlock.getLocation()); + String blockId = this.generateStoneReplacement(island, eventToBlock.getLocation()); - if (material != null) + if (blockId != null && this.applyBlockId(blockId, eventToBlock, Material.STONE)) { - eventToBlock.setType(material); // sound when lava transforms to cobble this.playEffects(eventToBlock); event.setCancelled(true); @@ -146,12 +145,11 @@ public void onBlockFromToEvent(BlockFromToEvent event) if (liquid.equals(Material.LAVA) && this.canLavaGenerateCobblestone(eventToBlock, event.getFace())) { - Material material = this.generateCobblestoneReplacement(island, eventToBlock.getLocation()); + String blockId = this.generateCobblestoneReplacement(island, eventToBlock.getLocation()); // Lava is generating cobblestone into eventToBlock place - if (material != null) + if (blockId != null && this.applyBlockId(blockId, eventToBlock, Material.COBBLESTONE)) { - eventToBlock.setType(material); // sound when lava transforms to cobble this.playEffects(eventToBlock); event.setCancelled(true); @@ -168,11 +166,10 @@ else if (liquid.equals(Material.WATER)) if (replacedBlock != null) { - Material material = this.generateStoneReplacement(island, replacedBlock.getLocation()); + String blockId = this.generateStoneReplacement(island, replacedBlock.getLocation()); - if (material != null) + if (blockId != null && this.applyBlockId(blockId, replacedBlock, Material.STONE)) { - replacedBlock.setType(material); // sound when lava transforms to cobble this.playEffects(eventToBlock); } diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/VanillaGeneratorListener.java b/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/VanillaGeneratorListener.java index 9874848..6893050 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/VanillaGeneratorListener.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/listeners/VanillaGeneratorListener.java @@ -17,6 +17,7 @@ import world.bentobox.bentobox.database.objects.Island; import world.bentobox.magiccobblestonegenerator.StoneGeneratorAddon; +import world.bentobox.magiccobblestonegenerator.utils.CustomBlocks; import world.bentobox.magiccobblestonegenerator.utils.Why; @@ -97,33 +98,47 @@ public void onBlockFormEvent(BlockFormEvent event) if (event.getNewState().getType() == Material.COBBLESTONE) { - Material material = this.generateCobblestoneReplacement(island, eventSourceBlock.getLocation()); - - if (material != null && material.isBlock()) - { - // Replace new state with a proper material. - event.getNewState().setType(material); - } + this.applyToState(event, this.generateCobblestoneReplacement(island, eventSourceBlock.getLocation())); } else if (event.getNewState().getType() == Material.STONE) { - Material material = this.generateStoneReplacement(island, eventSourceBlock.getLocation()); - - if (material != null && material.isBlock()) - { - // Replace new state with a proper material. - event.getNewState().setType(material); - } + this.applyToState(event, this.generateStoneReplacement(island, eventSourceBlock.getLocation())); } else if (event.getNewState().getType() == Material.BASALT) { - Material material = this.generateBasaltReplacement(island, eventSourceBlock.getLocation()); + this.applyToState(event, this.generateBasaltReplacement(island, eventSourceBlock.getLocation())); + } + } + - if (material != null && material.isBlock()) + /** + * This method applies a picked block ID to the forming block. Vanilla materials replace the new + * block state directly. Custom blocks let the vanilla block form as normal and are placed over it + * via their BentoBox hook one tick later, because hook APIs set blocks directly. + * + * @param event Block form event that is being processed. + * @param blockId Picked block ID or null if no replacement happens. + */ + private void applyToState(BlockFormEvent event, String blockId) + { + if (blockId == null) + { + return; + } + + Material material = CustomBlocks.matchVanilla(blockId); + + if (material != null) + { + if (material.isBlock()) { // Replace new state with a proper material. event.getNewState().setType(material); } } + else if (CustomBlocks.isCustom(blockId)) + { + this.scheduleCustomBlockPlacement(blockId, event.getBlock().getLocation()); + } } } diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/managers/StoneGeneratorImportManager.java b/src/main/java/world/bentobox/magiccobblestonegenerator/managers/StoneGeneratorImportManager.java index 222bf10..d23a20c 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/managers/StoneGeneratorImportManager.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/managers/StoneGeneratorImportManager.java @@ -48,6 +48,7 @@ import world.bentobox.magiccobblestonegenerator.database.objects.GeneratorBundleObject; import world.bentobox.magiccobblestonegenerator.database.objects.GeneratorTierObject; import world.bentobox.magiccobblestonegenerator.utils.Constants; +import world.bentobox.magiccobblestonegenerator.utils.CustomBlocks; import world.bentobox.magiccobblestonegenerator.utils.Utils; /** @@ -315,38 +316,36 @@ private void populateRequirements(GeneratorTierObject generatorTier, Configurati */ private void populateMaterials(GeneratorTierObject generatorTier, ConfigurationSection materials) { if (materials != null) { - TreeMap blockChances = new TreeMap<>(); - TreeMap materialHeightMap = new TreeMap<>(); + TreeMap blockChances = new TreeMap<>(); + TreeMap materialHeightMap = new TreeMap<>(); for (String materialKey : materials.getKeys(false)) { - try { - Material material = Material.valueOf(materialKey.toUpperCase()); - - // Support for both formats - if (materials.isConfigurationSection(materialKey)) { - // New format with chance and height_range - ConfigurationSection materialSection = materials.getConfigurationSection(materialKey); - double chance = materialSection.getDouble("chance", 0); - double lastEntry = blockChances.isEmpty() ? 0D : blockChances.lastKey(); - blockChances.put(lastEntry + chance, material); - - // Get height range if specified - ConfigurationSection heightRange = materialSection.getConfigurationSection("height_range"); - if (heightRange != null) { - int minHeight = heightRange.getInt("min", 0); - int maxHeight = heightRange.getInt("max", 256); - materialHeightMap.put(material, new int[]{minHeight, maxHeight}); - } - } else { - // Old format where the value is directly the probability - double chance = materials.getDouble(materialKey, 0); - double lastEntry = blockChances.isEmpty() ? 0D : blockChances.lastKey(); - blockChances.put(lastEntry + chance, material); + String blockId = this.parseBlockId(generatorTier, materialKey); + + if (blockId == null) { + continue; + } + + // Support for both formats + if (materials.isConfigurationSection(materialKey)) { + // New format with chance and height_range + ConfigurationSection materialSection = materials.getConfigurationSection(materialKey); + double chance = materialSection.getDouble("chance", 0); + double lastEntry = blockChances.isEmpty() ? 0D : blockChances.lastKey(); + blockChances.put(lastEntry + chance, blockId); + + // Get height range if specified + ConfigurationSection heightRange = materialSection.getConfigurationSection("height_range"); + if (heightRange != null) { + int minHeight = heightRange.getInt("min", 0); + int maxHeight = heightRange.getInt("max", 256); + materialHeightMap.put(blockId, new int[]{minHeight, maxHeight}); } - } catch (Exception e) { - this.addon.logWarning( - "Unknown material (" + materialKey + ") in generatorTemplate.yml blocks section for tier " - + generatorTier.getUniqueId() + ". Skipping..."); + } else { + // Old format where the value is directly the probability + double chance = materials.getDouble(materialKey, 0); + double lastEntry = blockChances.isEmpty() ? 0D : blockChances.lastKey(); + blockChances.put(lastEntry + chance, blockId); } } @@ -357,6 +356,41 @@ private void populateMaterials(GeneratorTierObject generatorTier, ConfigurationS } } + /** + * This method parses a template block key into a block ID: either a vanilla material name or a + * provider-prefixed custom block ID (e.g. itemsadder:namespace:id). Unknown vanilla materials are + * rejected; custom blocks that are not currently registered are imported with a warning, so + * templates keep working when the provider plugin is installed later. + * + * @param generatorTier tier that is being populated, for log context. + * @param materialKey raw key from the template file. + * @return normalized block ID or null if the key cannot be used. + */ + private String parseBlockId(GeneratorTierObject generatorTier, String materialKey) { + if (CustomBlocks.isCustom(materialKey)) { + // Custom block IDs are case-sensitive; keep them as written. + if (!CustomBlocks.isRegistered(this.addon, materialKey)) { + this.addon.logWarning("Custom block (" + materialKey + + TIER_TEMPLATE_CONTEXT + generatorTier.getUniqueId() + + " is not registered on this server. It will not generate until its plugin and" + + " BentoBox hook are available."); + } + + return materialKey; + } + + Material material = Material.matchMaterial(materialKey); + + if (material == null) { + this.addon.logWarning( + "Unknown material (" + materialKey + TIER_TEMPLATE_CONTEXT + + generatorTier.getUniqueId() + ". Skipping..."); + return null; + } + + return material.name(); + } + /** * This method populates generatorTier object with treasures from given config * section. @@ -382,7 +416,7 @@ private void populateTreasures(GeneratorTierObject generatorTier, ConfigurationS blockChances.put(lastEntry + materials.getDouble(materialKey, 0), new ItemStack(material)); } catch (Exception e) { this.addon.logWarning("Unknown material (" + materialKey - + ") in generatorTemplate.yml blocks section for tier " + generatorTier.getUniqueId() + + TIER_TEMPLATE_CONTEXT + generatorTier.getUniqueId() + ". Skipping..."); } } @@ -822,4 +856,6 @@ public void setAuthor(String author) { private static final String DESCRIPTION = "description"; + private static final String TIER_TEMPLATE_CONTEXT = ") in generatorTemplate.yml blocks section for tier "; + } diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/panels/CommonPanel.java b/src/main/java/world/bentobox/magiccobblestonegenerator/panels/CommonPanel.java index 0f77088..0077770 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/panels/CommonPanel.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/panels/CommonPanel.java @@ -7,6 +7,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.NavigableMap; import java.util.Objects; import java.util.TreeMap; import java.util.stream.Collectors; @@ -29,6 +30,7 @@ import world.bentobox.magiccobblestonegenerator.database.objects.GeneratorTierObject; import world.bentobox.magiccobblestonegenerator.managers.StoneGeneratorManager; import world.bentobox.magiccobblestonegenerator.utils.Constants; +import world.bentobox.magiccobblestonegenerator.utils.CustomBlocks; import world.bentobox.magiccobblestonegenerator.utils.Utils; @@ -387,7 +389,7 @@ protected String generateHeightRangeDescription(GeneratorTierObject generator) */ private String generateBlockListDescription(GeneratorTierObject generator) { - TreeMap blockChanceMap = generator.getBlockChanceMap(); + NavigableMap blockChanceMap = generator.getBlockChanceMap(); if (blockChanceMap.isEmpty()) { @@ -408,12 +410,12 @@ private String generateBlockListDescription(GeneratorTierObject generator) Double maxValue = blockChanceMap.lastKey(); Double previousValue = 0.0; - List> materialChanceList = + List> materialChanceList = blockChanceMap.entrySet().stream(). sorted(Map.Entry.comparingByKey()). collect(Collectors.toList()); - for (Map.Entry entry : materialChanceList) + for (Map.Entry entry : materialChanceList) { Double value = (entry.getKey() - previousValue) / maxValue * 100.0; @@ -429,7 +431,7 @@ private String generateBlockListDescription(GeneratorTierObject generator) } blocks.append(this.user.getTranslation(reference + "value", - Constants.BLOCK, Utils.prettifyObject(this.user, entry.getValue()), + Constants.BLOCK, CustomBlocks.getDisplayName(this.addon, this.user, entry.getValue()), TextVariables.NUMBER, String.valueOf(value), Constants.TENS, this.tensFormat.format(value), Constants.HUNDREDS, this.hundredsFormat.format(value), diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/panels/admin/GeneratorEditPanel.java b/src/main/java/world/bentobox/magiccobblestonegenerator/panels/admin/GeneratorEditPanel.java index 7153881..bd0e31a 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/panels/admin/GeneratorEditPanel.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/panels/admin/GeneratorEditPanel.java @@ -32,6 +32,7 @@ import world.bentobox.magiccobblestonegenerator.panels.utils.MultiGeneratorSelector; import world.bentobox.magiccobblestonegenerator.panels.utils.SingleBlockSelector; import world.bentobox.magiccobblestonegenerator.utils.Constants; +import world.bentobox.magiccobblestonegenerator.utils.CustomBlocks; import world.bentobox.magiccobblestonegenerator.utils.Pair; import world.bentobox.magiccobblestonegenerator.utils.Utils; @@ -103,6 +104,7 @@ public void build() this.populateBlocks(panelBuilder); } panelBuilder.item(39, this.createButton(Action.ADD_MATERIAL)); + panelBuilder.item(40, this.createButton(Action.ADD_CUSTOM_BLOCK)); panelBuilder.item(41, this.createButton(Action.REMOVE_MATERIAL)); } @@ -265,7 +267,7 @@ else if (this.pageIndex > (this.materialChanceList.size() / MAX_ELEMENTS)) if (!panelBuilder.slotOccupied(index)) { // Get entry from list. - Pair materialEntry = this.materialChanceList.get(materialIndex++); + Pair materialEntry = this.materialChanceList.get(materialIndex++); // Add to panel panelBuilder.item(index, this.createMaterialButton(materialEntry, maxValue)); } @@ -1182,7 +1184,7 @@ private PanelItem createButton(Action button) if (this.activeTab == Tab.BLOCKS) { this.materialChanceList.add( - new Pair<>(material, + new Pair<>(material.name(), number.doubleValue())); this.generatorTier.setBlockChanceMap( @@ -1219,6 +1221,69 @@ else if (this.activeTab == Tab.TREASURES) return true; }; } + case ADD_CUSTOM_BLOCK -> { + description.add(this.user.getTranslationOrNothing(reference + ".description")); + description.add(""); + description.add(this.user.getTranslation(Constants.TIPS + "click-to-add")); + + icon = Material.COMMAND_BLOCK; + clickHandler = (panel, user1, clickType, slot) -> + { + Consumer blockIdConsumer = blockId -> + { + if (blockId == null || blockId.isBlank()) + { + this.build(); + return; + } + + String trimmedId = blockId.trim(); + + if (!CustomBlocks.isCustom(trimmedId)) + { + Utils.sendMessage(this.user, + this.user.getTranslation(Constants.CONVERSATIONS + "custom-block-invalid-prefix", + Constants.BLOCK, trimmedId)); + this.build(); + return; + } + + if (!CustomBlocks.isRegistered(this.addon, trimmedId)) + { + Utils.sendMessage(this.user, + this.user.getTranslation(Constants.CONVERSATIONS + "custom-block-not-found", + Constants.BLOCK, trimmedId)); + this.build(); + return; + } + + Consumer numberConsumer = number -> + { + if (number != null) + { + this.materialChanceList.add(new Pair<>(trimmedId, number.doubleValue())); + this.generatorTier.setBlockChanceMap(Utils.pairList2TreeMap(this.materialChanceList)); + this.save(); + } + + this.build(); + }; + + ConversationUtils.createNumericInput(numberConsumer, + this.user, + this.user.getTranslation(Constants.CONVERSATIONS + "input-number"), + 0.0, + Long.MAX_VALUE); + }; + + ConversationUtils.createStringInput(blockIdConsumer, + this.user, + this.user.getTranslation(Constants.CONVERSATIONS + "input-custom-block"), + null); + + return true; + }; + } case REMOVE_MATERIAL -> { icon = this.selectedMaterial.isEmpty() && this.selectedTreasures.isEmpty() ? Material.BARRIER : Material.LAVA_BUCKET; @@ -1256,7 +1321,7 @@ else if (this.activeTab == Tab.TREASURES) if (!this.selectedMaterial.isEmpty()) { this.selectedMaterial.forEach(pair -> description.add(this.user.getTranslation(reference + ".list-value", - Constants.VALUE, Utils.prettifyObject(this.user, pair.getKey()), + Constants.VALUE, CustomBlocks.getDisplayName(this.addon, this.user, pair.getKey()), Constants.NUMBER, String.valueOf(pair.getValue())))); } @@ -1294,11 +1359,11 @@ else if (this.activeTab == Tab.TREASURES) * @param maxValue Displays maximal value for map. * @return PanelItem for generator tier. */ - private PanelItem createMaterialButton(Pair blockChanceEntry, Double maxValue) + private PanelItem createMaterialButton(Pair blockChanceEntry, Double maxValue) { // Normalize value Double value = blockChanceEntry.getValue() / maxValue * 100.0; - Material material = blockChanceEntry.getKey(); + String material = blockChanceEntry.getKey(); List description = new ArrayList<>(); description.add(this.user.getTranslation(Constants.BUTTON + "block-icon.description", @@ -1343,7 +1408,29 @@ private PanelItem createMaterialButton(Pair blockChanceEntry, description.add(this.user.getTranslation(Constants.TIPS + "right-click-to-deselect")); } - PanelItem.ClickHandler clickHandler = (panel, user1, clickType, slot) -> { + return new PanelItemBuilder(). + name(this.user.getTranslation(Constants.BUTTON + "block-icon.name", + Constants.BLOCK, CustomBlocks.getDisplayName(this.addon, this.user, blockChanceEntry.getKey()))). + description(description). + icon(CustomBlocks.getIcon(this.addon, blockChanceEntry.getKey())). + clickHandler(this.createMaterialButtonClickHandler(blockChanceEntry, material)). + glow(glow). + build(); + } + + + /** + * Creates the click handler for a material button: right-click selects/deselects, shift-left-click + * configures the height range and left-click edits the chance value. + * + * @param blockChanceEntry blockChanceEntry that the button represents. + * @param material the block ID that the button represents. + * @return the click handler for the button. + */ + private PanelItem.ClickHandler createMaterialButtonClickHandler(Pair blockChanceEntry, + String material) + { + return (panel, user1, clickType, slot) -> { if (clickType.isRightClick()) { if (!this.selectedMaterial.remove(blockChanceEntry)) @@ -1380,15 +1467,6 @@ else if (clickType.isLeftClick()) return true; }; - - return new PanelItemBuilder(). - name(this.user.getTranslation(Constants.BUTTON + "block-icon.name", - Constants.BLOCK, Utils.prettifyObject(this.user, blockChanceEntry.getKey()))). - description(description). - icon(blockChanceEntry.getKey()). - clickHandler(clickHandler). - glow(glow). - build(); } @@ -1467,7 +1545,7 @@ private PanelItem createTreasureButton(Pair treasureChanceEnt if (newValue != null) { treasureChanceEntry.setValue(newValue.doubleValue()); - this.generatorTier.setTreasureChanceMap(Utils.pairList2TreeMap(this.materialChanceList)); + this.generatorTier.setTreasureItemChanceMap(Utils.pairList2TreeMap(this.treasureChanceList)); this.save(); } @@ -1693,11 +1771,11 @@ public void setup() /** - * Configure height range for a specific material - * - * @param material The material to configure height range for + * Configure height range for a specific block ID + * + * @param material The block ID to configure height range for */ - private void configureHeightRangeForMaterial(Material material) + private void configureHeightRangeForMaterial(String material) { // Get current height range if it exists int[] currentRange = this.generatorTier.getMaterialHeightRange(material); @@ -1707,116 +1785,144 @@ private void configureHeightRangeForMaterial(Material material) // Create a new panel to configure height range PanelBuilder panelBuilder = new PanelBuilder(). user(this.user). - name(this.user.getTranslation(Constants.TITLE + "height-range-config", - Constants.BLOCK, Utils.prettifyObject(this.user, material))). + name(this.user.getTranslation(Constants.TITLE + "height-range-config", + Constants.BLOCK, CustomBlocks.getDisplayName(this.addon, this.user, material))). size(27); PanelUtils.fillBorder(panelBuilder, Material.MAGENTA_STAINED_GLASS_PANE); // Add min height button - panelBuilder.item(20, new PanelItemBuilder(). + panelBuilder.item(20, this.createMinHeightButton(material, currentMinHeight, currentMaxHeight)); + + // Add max height button + panelBuilder.item(24, this.createMaxHeightButton(material, currentMinHeight, currentMaxHeight)); + + // Add clear height range button + panelBuilder.item(22, new PanelItemBuilder(). + name(this.user.getTranslation(Constants.BUTTON + "clear-height-range.name")). + description(this.user.getTranslation(Constants.BUTTON + "clear-height-range.description")). + icon(Material.BARRIER). + clickHandler((panel, user, clickType, slot) -> { + // Remove the height range for this material + this.generatorTier.getMaterialHeightMap().remove(material); + this.save(); + + // Return to the main panel + this.configureHeightRangeForMaterial(material); + + return true; + }). + build()); + + // Add return button + panelBuilder.item(40, new PanelItemBuilder(). + name(this.user.getTranslation(Constants.BUTTON + "return.name")). + description(this.user.getTranslation(Constants.BUTTON + "return.description")). + icon(Material.OAK_DOOR). + clickHandler((panel, user, clickType, slot) -> { + this.build(); + return true; + }). + build()); + + panelBuilder.build(); + } + + + /** + * Creates the button that prompts for and stores the minimum height of a material's height range. + * + * @param material the block ID whose height range is being configured. + * @param currentMinHeight the currently configured minimum height, shown in the description. + * @param currentMaxHeight the currently configured maximum height, used to validate the new minimum. + * @return the configured min-height PanelItem. + */ + private PanelItem createMinHeightButton(String material, int currentMinHeight, int currentMaxHeight) + { + return new PanelItemBuilder(). name(this.user.getTranslation(Constants.BUTTON + "min-height.name")). - description(this.user.getTranslation(Constants.BUTTON + "min-height.description", + description(this.user.getTranslation(Constants.BUTTON + "min-height.description", Constants.MIN_HEIGHT, String.valueOf(currentMinHeight))). icon(Material.BEDROCK). clickHandler((panel, user, clickType, slot) -> { Consumer numberConsumer = number -> { if (number != null) { int newMinHeight = number.intValue(); - + // Ensure min height is not greater than max height if (newMinHeight > currentMaxHeight) { - this.user.sendMessage("admin.errors.min-height-greater-than-max", + this.user.sendMessage("admin.errors.min-height-greater-than-max", Constants.MIN, String.valueOf(newMinHeight), Constants.MAX, String.valueOf(currentMaxHeight)); return; - + } - + // Set the new height range this.generatorTier.setMaterialHeightRange(material, newMinHeight, currentMaxHeight); this.save(); } - + // Return to the main panel this.configureHeightRangeForMaterial(material); }; - + ConversationUtils.createNumericInput(numberConsumer, this.user, this.user.getTranslation(Constants.CONVERSATIONS + "input-min-height"), Integer.MIN_VALUE, 320); - + return true; }). - build()); - - // Add max height button - panelBuilder.item(24, new PanelItemBuilder(). + build(); + } + + + /** + * Creates the button that prompts for and stores the maximum height of a material's height range. + * + * @param material the block ID whose height range is being configured. + * @param currentMinHeight the currently configured minimum height, used to validate the new maximum. + * @param currentMaxHeight the currently configured maximum height, shown in the description. + * @return the configured max-height PanelItem. + */ + private PanelItem createMaxHeightButton(String material, int currentMinHeight, int currentMaxHeight) + { + return new PanelItemBuilder(). name(this.user.getTranslation(Constants.BUTTON + "max-height.name")). - description(this.user.getTranslation(Constants.BUTTON + "max-height.description", + description(this.user.getTranslation(Constants.BUTTON + "max-height.description", Constants.MAX_HEIGHT, String.valueOf(currentMaxHeight))). icon(Material.GRASS_BLOCK). clickHandler((panel, user, clickType, slot) -> { Consumer numberConsumer = number -> { if (number != null) { int newMaxHeight = number.intValue(); - + // Ensure max height is not less than min height if (newMaxHeight < currentMinHeight) { - this.user.sendMessage("admin.errors.max-height-less-than-min", + this.user.sendMessage("admin.errors.max-height-less-than-min", Constants.MIN, String.valueOf(currentMinHeight), Constants.MAX, String.valueOf(newMaxHeight)); return; } - + // Set the new height range this.generatorTier.setMaterialHeightRange(material, currentMinHeight, newMaxHeight); this.save(); } - + // Return to the main panel this.configureHeightRangeForMaterial(material); }; - + ConversationUtils.createNumericInput(numberConsumer, this.user, this.user.getTranslation(Constants.CONVERSATIONS + "input-max-height"), Integer.MIN_VALUE, 320); - - return true; - }). - build()); - - // Add clear height range button - panelBuilder.item(22, new PanelItemBuilder(). - name(this.user.getTranslation(Constants.BUTTON + "clear-height-range.name")). - description(this.user.getTranslation(Constants.BUTTON + "clear-height-range.description")). - icon(Material.BARRIER). - clickHandler((panel, user, clickType, slot) -> { - // Remove the height range for this material - this.generatorTier.getMaterialHeightMap().remove(material); - this.save(); - - // Return to the main panel - this.configureHeightRangeForMaterial(material); - - return true; - }). - build()); - - // Add return button - panelBuilder.item(40, new PanelItemBuilder(). - name(this.user.getTranslation(Constants.BUTTON + "return.name")). - description(this.user.getTranslation(Constants.BUTTON + "return.description")). - icon(Material.OAK_DOOR). - clickHandler((panel, user, clickType, slot) -> { - this.build(); + return true; }). - build()); - - panelBuilder.build(); + build(); } @@ -1842,6 +1948,10 @@ private enum Action * Allows to add a new material to the block list. */ ADD_MATERIAL, + /** + * Allows to add a custom block (ItemsAdder/CraftEngine/Oraxen/Nexo) to the block list by ID. + */ + ADD_CUSTOM_BLOCK, /** * Allows to remove selected materials from block list */ @@ -1983,7 +2093,7 @@ private enum Button /** * This set is used to detect and delete selected blocks. */ - private final Set> selectedMaterial; + private final Set> selectedMaterial; /** * This set is used to detect and delete selected blocks. @@ -2013,7 +2123,7 @@ private enum Button /** * This list contains elements of tree map that we can edit with a panel. */ - private List> materialChanceList; + private List> materialChanceList; /** * This list contains elements of tree map that we can edit with a panel. diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/panels/player/GeneratorViewPanel.java b/src/main/java/world/bentobox/magiccobblestonegenerator/panels/player/GeneratorViewPanel.java index aa9d0d8..6739efe 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/panels/player/GeneratorViewPanel.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/panels/player/GeneratorViewPanel.java @@ -31,6 +31,7 @@ import world.bentobox.magiccobblestonegenerator.database.objects.GeneratorTierObject; import world.bentobox.magiccobblestonegenerator.panels.CommonPanel; import world.bentobox.magiccobblestonegenerator.utils.Constants; +import world.bentobox.magiccobblestonegenerator.utils.CustomBlocks; import world.bentobox.magiccobblestonegenerator.utils.Utils; @@ -530,7 +531,7 @@ private PanelItem createMaterialButton(ItemTemplateRecord template, TemplatedPan * @return PanelItem for generator tier. */ private PanelItem createMaterialButton(ItemTemplateRecord template, - Map.Entry blockChanceEntry, + Map.Entry blockChanceEntry, Double previousValue, Double maxValue) { @@ -542,13 +543,13 @@ private PanelItem createMaterialButton(ItemTemplateRecord template, } else { - builder.icon(blockChanceEntry.getValue()); + builder.icon(CustomBlocks.getIcon(this.addon, blockChanceEntry.getValue())); } if (template.title() != null) { builder.name(this.user.getTranslation(this.world, template.title(), - Constants.BLOCK, Utils.prettifyObject(this.user, blockChanceEntry.getValue()))); + Constants.BLOCK, CustomBlocks.getDisplayName(this.addon, this.user, blockChanceEntry.getValue()))); } if (template.description() != null) @@ -1363,7 +1364,7 @@ private enum Button /** * This variable stores chance for every block to be spawned. */ - private List> materialChanceList; + private List> materialChanceList; /** * This variable stores chance for every treasure to be spawned. diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/tasks/MagicGenerator.java b/src/main/java/world/bentobox/magiccobblestonegenerator/tasks/MagicGenerator.java index 0978bfd..bc18e16 100644 --- a/src/main/java/world/bentobox/magiccobblestonegenerator/tasks/MagicGenerator.java +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/tasks/MagicGenerator.java @@ -1,12 +1,11 @@ package world.bentobox.magiccobblestonegenerator.tasks; +import java.util.NavigableMap; import java.util.Random; -import java.util.TreeMap; import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.Nullable; @@ -14,6 +13,7 @@ import world.bentobox.magiccobblestonegenerator.StoneGeneratorAddon; import world.bentobox.magiccobblestonegenerator.database.objects.GeneratorTierObject; import world.bentobox.magiccobblestonegenerator.events.GeneratorTreasureDropEvent; +import world.bentobox.magiccobblestonegenerator.utils.CustomBlocks; import world.bentobox.magiccobblestonegenerator.utils.Why; @@ -38,9 +38,9 @@ public MagicGenerator(StoneGeneratorAddon addon) * * @param generatorTier Object that contains all possible chances. * @param location Location of the block that need to be replaced. - * @return the replaced material or null. + * @return the picked block ID (vanilla material name or provider-prefixed custom block ID) or null. */ - public @Nullable Material processBlockReplacement(@Nullable GeneratorTierObject generatorTier, Location location) + public @Nullable String processBlockReplacement(@Nullable GeneratorTierObject generatorTier, Location location) { return this.processBlockReplacement(generatorTier, location, null); } @@ -53,9 +53,9 @@ public MagicGenerator(StoneGeneratorAddon addon) * @param location Location of the block that need to be replaced. * @param island Island on which the block is processed, or null if unknown. Used to provide context for the * {@link GeneratorTreasureDropEvent}. - * @return the replaced material or null. + * @return the picked block ID (vanilla material name or provider-prefixed custom block ID) or null. */ - public @Nullable Material processBlockReplacement(@Nullable GeneratorTierObject generatorTier, Location location, + public @Nullable String processBlockReplacement(@Nullable GeneratorTierObject generatorTier, Location location, @Nullable Island island) { if (generatorTier == null) @@ -69,143 +69,210 @@ public MagicGenerator(StoneGeneratorAddon addon) int blockY = location.getBlockY(); if (blockY < generatorTier.getMinHeight() || blockY > generatorTier.getMaxHeight()) { - Why.report(location, "Block outside global height range: " + blockY + + Why.report(location, "Block outside global height range: " + blockY + " (min: " + generatorTier.getMinHeight() + ", max: " + generatorTier.getMaxHeight() + ")"); return null; } - TreeMap chanceMap = generatorTier.getBlockChanceMap(); + String newBlockId = this.selectBlockId(generatorTier, location, blockY); + + if (newBlockId == null) + { + // No suitable block could be selected; the block is left untouched. + return null; + } + + if (!this.isPlaceable(generatorTier, location, newBlockId)) + { + return null; + } + + Why.report(location, "Replace with " + newBlockId + " by " + generatorTier.getUniqueId()); + + this.processTreasureDrop(generatorTier, location, island); + + return newBlockId; + } + + + /** + * Picks a block ID from the generator's chance map and validates it against the material-specific + * height range, falling back to an alternative material when the picked one cannot spawn at this height. + * + * @param generatorTier Object that contains all possible chances. + * @param location Location of the block that need to be replaced (used for reporting). + * @param blockY The Y coordinate of the block being replaced. + * @return the block ID to place, or null if nothing suitable was found. + */ + private @Nullable String selectBlockId(GeneratorTierObject generatorTier, Location location, int blockY) + { + NavigableMap chanceMap = generatorTier.getBlockChanceMap(); if (chanceMap.isEmpty()) { Why.report(location, "Missing Block Chances in " + generatorTier.getUniqueId()); - // Check if any block has a chance to spawn return null; } - Material newMaterial = this.getMaterialFromMap(chanceMap); + String newBlockId = this.getMaterialFromMap(chanceMap); - if (newMaterial == null) + if (newBlockId == null) { Why.report(location, "Cannot parse material from ChanceMap in " + generatorTier.getUniqueId()); - // Check if a material was found return null; } // Check if this material has specific height restrictions - int[] materialHeightRange = generatorTier.getMaterialHeightRange(newMaterial); - if (materialHeightRange != null) + int[] materialHeightRange = generatorTier.getMaterialHeightRange(newBlockId); + + if (materialHeightRange == null || + (blockY >= materialHeightRange[0] && blockY <= materialHeightRange[1])) { - int materialMinHeight = materialHeightRange[0]; - int materialMaxHeight = materialHeightRange[1]; - - if (blockY < materialMinHeight || blockY > materialMaxHeight) - { - Why.report(location, "Material " + newMaterial + " outside its specific height range: " + blockY + - " (min: " + materialMinHeight + ", max: " + materialMaxHeight + ")"); - - // Try to find another material that can be generated at this height - Material alternativeMaterial = findMaterialForHeight(generatorTier, blockY); - if (alternativeMaterial != null) - { - Why.report(location, "Using alternative material " + alternativeMaterial + " for height " + blockY); - newMaterial = alternativeMaterial; - } - else - { - // If no suitable material found, don't replace the block - return null; - } - } + return newBlockId; } - Why.report(location, "Replace with " + newMaterial + " by " + generatorTier.getUniqueId()); + Why.report(location, "Material " + newBlockId + " outside its specific height range: " + blockY + + " (min: " + materialHeightRange[0] + ", max: " + materialHeightRange[1] + ")"); + + // Try to find another material that can be generated at this height + String alternativeMaterial = findMaterialForHeight(generatorTier, blockY); - if (generatorTier.getMaxTreasureAmount() > 0 && - generatorTier.getTreasureChance() > 0 && - !generatorTier.getTreasureItemChanceMap().isEmpty()) + if (alternativeMaterial != null) + { + Why.report(location, "Using alternative material " + alternativeMaterial + " for height " + blockY); + } + + // If no suitable material was found, alternativeMaterial is null and the block is left untouched. + return alternativeMaterial; + } + + + /** + * Validates that the chosen block ID can actually be placed: custom blocks must be placeable via a + * hook, vanilla blocks must match a known material. + * + * @param generatorTier Object that owns the block (used for reporting). + * @param location Location of the block that need to be replaced (used for reporting). + * @param newBlockId The block ID that has been selected. + * @return true if the block can be placed, false otherwise. + */ + private boolean isPlaceable(GeneratorTierObject generatorTier, Location location, String newBlockId) + { + if (CustomBlocks.isCustom(newBlockId)) { - // Random check on getting treasure. - if (this.random.nextDouble() <= generatorTier.getTreasureChance()) + if (!CustomBlocks.canPlace(this.addon, newBlockId)) { - // Use the same variables for treasures. - TreeMap treasureMap = generatorTier.getTreasureItemChanceMap(); - ItemStack itemStack = this.getMaterialFromMap(treasureMap); - - // Double check, in general it should always be a material. - if (itemStack != null) - { - ItemStack drop = itemStack.clone(); - drop.setAmount(this.random.nextInt(generatorTier.getMaxTreasureAmount() + 1) + 1); - - // Fire a cancellable event so other plugins can intercept, modify or veto the treasure drop. - GeneratorTreasureDropEvent treasureEvent = new GeneratorTreasureDropEvent(generatorTier, - island == null ? null : island.getUniqueId(), - location, - drop); - Bukkit.getPluginManager().callEvent(treasureEvent); - - if (!treasureEvent.isCancelled() && treasureEvent.getItemStack() != null) - { - ItemStack finalDrop = treasureEvent.getItemStack(); - Location dropLocation = treasureEvent.getLocation(); - - // A listener may have nulled the location (or its world); guard against it so we do - // not throw and break block generation. - if (dropLocation == null || dropLocation.getWorld() == null) - { - Why.report(location, "Treasure drop skipped: listener supplied an invalid drop location for " + - generatorTier.getUniqueId()); - } - else - { - Why.report(location, "Dropping treasure " + finalDrop + " by " + generatorTier.getUniqueId()); - - // drop item naturally in the location of the block - dropLocation.getWorld().dropItemNaturally(dropLocation, finalDrop); - } - } - else - { - Why.report(location, "Treasure drop cancelled by " + generatorTier.getUniqueId()); - } - } + Why.report(location, "Custom block " + newBlockId + " cannot be placed (plugin missing, ID not " + + "registered, or BentoBox hook lacks placement support) in " + generatorTier.getUniqueId()); + return false; } } + else if (CustomBlocks.matchVanilla(newBlockId) == null) + { + Why.report(location, "Unknown material " + newBlockId + " in " + generatorTier.getUniqueId()); + return false; + } + + return true; + } + + + /** + * Rolls for a treasure drop and, when successful, fires a cancellable {@link GeneratorTreasureDropEvent} + * before dropping the item naturally at the resulting location. + * + * @param generatorTier Object that contains the treasure chances. + * @param location Location of the block that has been replaced. + * @param island Island on which the block is processed, or null if unknown. + */ + private void processTreasureDrop(GeneratorTierObject generatorTier, Location location, @Nullable Island island) + { + if (generatorTier.getMaxTreasureAmount() <= 0 || + generatorTier.getTreasureChance() <= 0 || + generatorTier.getTreasureItemChanceMap().isEmpty()) + { + return; + } + + // Random check on getting treasure. + if (this.random.nextDouble() > generatorTier.getTreasureChance()) + { + return; + } + + ItemStack itemStack = this.getMaterialFromMap(generatorTier.getTreasureItemChanceMap()); + + // Double check, in general it should always be a material. + if (itemStack == null) + { + return; + } + + ItemStack drop = itemStack.clone(); + drop.setAmount(this.random.nextInt(generatorTier.getMaxTreasureAmount() + 1) + 1); - return newMaterial; + // Fire a cancellable event so other plugins can intercept, modify or veto the treasure drop. + GeneratorTreasureDropEvent treasureEvent = new GeneratorTreasureDropEvent(generatorTier, + island == null ? null : island.getUniqueId(), + location, + drop); + Bukkit.getPluginManager().callEvent(treasureEvent); + + if (treasureEvent.isCancelled() || treasureEvent.getItemStack() == null) + { + Why.report(location, "Treasure drop cancelled by " + generatorTier.getUniqueId()); + return; + } + + ItemStack finalDrop = treasureEvent.getItemStack(); + Location dropLocation = treasureEvent.getLocation(); + + // A listener may have nulled the location (or its world); guard against it so we do + // not throw and break block generation. + if (dropLocation == null || dropLocation.getWorld() == null) + { + Why.report(location, "Treasure drop skipped: listener supplied an invalid drop location for " + + generatorTier.getUniqueId()); + return; + } + + Why.report(location, "Dropping treasure " + finalDrop + " by " + generatorTier.getUniqueId()); + + // drop item naturally in the location of the block + dropLocation.getWorld().dropItemNaturally(dropLocation, finalDrop); } + /** - * Finds a material from the generator's block chance map that can be generated at the specified height. + * Finds a block ID from the generator's block chance map that can be generated at the specified height. * * @param generatorTier The generator tier object containing the material configurations * @param blockY The Y coordinate to check against - * @return A material that can be generated at the specified height, or null if none found + * @return A block ID that can be generated at the specified height, or null if none found */ - private Material findMaterialForHeight(GeneratorTierObject generatorTier, int blockY) + private String findMaterialForHeight(GeneratorTierObject generatorTier, int blockY) { - TreeMap chanceMap = generatorTier.getBlockChanceMap(); - - for (Material material : chanceMap.values()) + NavigableMap chanceMap = generatorTier.getBlockChanceMap(); + + for (String blockId : chanceMap.values()) { - int[] heightRange = generatorTier.getMaterialHeightRange(material); - + int[] heightRange = generatorTier.getMaterialHeightRange(blockId); + // If this material has no specific height range, it can be generated anywhere within the generator's global range if (heightRange == null) { - return material; + return blockId; } - + // Check if the block Y is within this material's height range if (blockY >= heightRange[0] && blockY <= heightRange[1]) { - return material; + return blockId; } } - + return null; } @@ -216,7 +283,7 @@ private Material findMaterialForHeight(GeneratorTierObject generatorTier, int bl * @param chanceMap Map that contains all objects with their chance to drop. * @return from map or null. */ - private T getMaterialFromMap(TreeMap chanceMap) + private T getMaterialFromMap(NavigableMap chanceMap) { if (chanceMap.isEmpty()) { diff --git a/src/main/java/world/bentobox/magiccobblestonegenerator/utils/CustomBlocks.java b/src/main/java/world/bentobox/magiccobblestonegenerator/utils/CustomBlocks.java new file mode 100644 index 0000000..483c4c1 --- /dev/null +++ b/src/main/java/world/bentobox/magiccobblestonegenerator/utils/CustomBlocks.java @@ -0,0 +1,334 @@ +package world.bentobox.magiccobblestonegenerator.utils; + + +import java.util.Optional; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jetbrains.annotations.Nullable; + +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.hooks.CraftEngineHook; +import world.bentobox.bentobox.hooks.ItemsAdderHook; +import world.bentobox.bentobox.hooks.OraxenHook; +import world.bentobox.magiccobblestonegenerator.StoneGeneratorAddon; + + +/** + * Helper for custom blocks provided by third-party plugins (ItemsAdder, CraftEngine, Oraxen, Nexo). + *

+ * Generator block entries are stored as plain strings. A vanilla entry is a {@link Material} name + * (e.g. {@code COBBLESTONE}), while a custom entry is prefixed with the provider, e.g. + * {@code itemsadder:iasurvival:ruby_ore}, {@code craftengine:default:topaz_ore} or {@code oraxen:ruby_ore}. + *

+ * All plugin access is routed through BentoBox core hooks, so this addon never depends on the + * third-party plugins directly. Providers whose hook does not support placement yet (Oraxen, Nexo) + * are recognised but will not generate until BentoBox core adds support. + */ +public final class CustomBlocks +{ + private CustomBlocks() + { + } + + + /** + * Custom block providers supported by this addon via BentoBox hooks. + */ + public enum Provider + { + ITEMS_ADDER("itemsadder", "ItemsAdder", true), + CRAFT_ENGINE("craftengine", "CraftEngine", true), + ORAXEN("oraxen", "Oraxen", false), + NEXO("nexo", "Nexo", false); + + + Provider(String prefix, String pluginName, boolean placementSupported) + { + this.prefix = prefix; + this.pluginName = pluginName; + this.placementSupported = placementSupported; + } + + + /** + * @return prefix used in block IDs, e.g. {@code itemsadder}. + */ + public String getPrefix() + { + return this.prefix; + } + + + /** + * @return plugin name used to look up the BentoBox hook. + */ + public String getPluginName() + { + return this.pluginName; + } + + + /** + * @return {@code true} if the BentoBox core hook for this provider can place blocks. + */ + public boolean isPlacementSupported() + { + return this.placementSupported; + } + + + private final String prefix; + + private final String pluginName; + + private final boolean placementSupported; + } + + + /** + * Returns the custom block provider for the given block ID, or {@code null} if the ID does not + * carry a known provider prefix (i.e. it is a vanilla material name). + * + * @param blockId block ID to parse. + * @return provider or {@code null}. + */ + public static @Nullable Provider getProvider(@Nullable String blockId) + { + if (blockId == null) + { + return null; + } + + int separator = blockId.indexOf(':'); + + if (separator <= 0) + { + return null; + } + + String prefix = blockId.substring(0, separator).toLowerCase(); + + for (Provider provider : Provider.values()) + { + if (provider.getPrefix().equals(prefix)) + { + return provider; + } + } + + return null; + } + + + /** + * Returns if given block ID references a custom block from a known provider. + * + * @param blockId block ID to check. + * @return {@code true} if the ID has a known provider prefix. + */ + public static boolean isCustom(@Nullable String blockId) + { + return getProvider(blockId) != null; + } + + + /** + * Strips the provider prefix from a custom block ID, returning the plugin-native ID. + * + * @param blockId provider-prefixed block ID. + * @return the ID without the provider prefix, or the input unchanged if it has none. + */ + public static String getCustomId(String blockId) + { + return getProvider(blockId) == null ? blockId : blockId.substring(blockId.indexOf(':') + 1); + } + + + /** + * Parses a vanilla material from the given block ID. + * + * @param blockId block ID to parse. + * @return the matching {@link Material}, or {@code null} if the ID is a custom block or unknown. + */ + public static @Nullable Material matchVanilla(@Nullable String blockId) + { + if (blockId == null || isCustom(blockId)) + { + return null; + } + + return Material.matchMaterial(blockId); + } + + + /** + * Returns if the BentoBox hook for the given provider is present (i.e. the plugin is installed). + * + * @param addon addon instance used to reach the hook manager. + * @param provider custom block provider. + * @return {@code true} if the hook is registered. + */ + public static boolean isHooked(StoneGeneratorAddon addon, Provider provider) + { + return addon.getPlugin().getHooks().getHook(provider.getPluginName()).isPresent(); + } + + + /** + * Returns if the given custom block ID is registered by its provider plugin on this server. + * + * @param addon addon instance used to reach the hook manager. + * @param blockId provider-prefixed block ID. + * @return {@code true} if the provider plugin is hooked and knows the ID. + */ + public static boolean isRegistered(StoneGeneratorAddon addon, String blockId) + { + Provider provider = getProvider(blockId); + + if (provider == null || !isHooked(addon, provider)) + { + return false; + } + + String customId = getCustomId(blockId); + + return switch (provider) + { + case ITEMS_ADDER -> ItemsAdderHook.isInRegistry(customId); + case CRAFT_ENGINE -> CraftEngineHook.exists(customId); + case ORAXEN -> OraxenHook.exists(customId); + // No BentoBox core hook for Nexo yet. + case NEXO -> false; + }; + } + + + /** + * Returns if the given custom block ID can actually be placed on this server: the provider + * plugin is hooked, the ID is registered, and the BentoBox hook supports block placement. + * + * @param addon addon instance used to reach the hook manager. + * @param blockId provider-prefixed block ID. + * @return {@code true} if {@link #place(StoneGeneratorAddon, String, Location)} can succeed. + */ + public static boolean canPlace(StoneGeneratorAddon addon, String blockId) + { + Provider provider = getProvider(blockId); + + return provider != null && + provider.isPlacementSupported() && + isRegistered(addon, blockId); + } + + + /** + * Places the given custom block through its BentoBox hook. + * + * @param addon addon instance used to reach the hook manager. + * @param blockId provider-prefixed block ID. + * @param location target location. + * @return {@code true} if the block was placed. + */ + public static boolean place(StoneGeneratorAddon addon, String blockId, Location location) + { + if (!canPlace(addon, blockId)) + { + return false; + } + + String customId = getCustomId(blockId); + + return switch (getProvider(blockId)) + { + case ITEMS_ADDER -> + { + ItemsAdderHook.place(customId, location); + yield true; + } + case CRAFT_ENGINE -> CraftEngineHook.placeBlock(location, customId); + // canPlace already filters these out; kept for exhaustiveness. + case ORAXEN, NEXO -> false; + }; + } + + + /** + * Returns an icon ItemStack for the given block ID, for use in GUI panels. Vanilla IDs map to + * their material; custom IDs use the provider's item (correct texture / model data) when the + * hook is available, otherwise a fallback icon. + * + * @param addon addon instance used to reach the hook manager. + * @param blockId block ID. + * @return icon ItemStack, never null. + */ + public static ItemStack getIcon(StoneGeneratorAddon addon, String blockId) + { + Material vanilla = matchVanilla(blockId); + + if (vanilla != null) + { + return new ItemStack(vanilla.isItem() ? vanilla : Material.PAPER); + } + + Provider provider = getProvider(blockId); + + if (provider != null && isHooked(addon, provider)) + { + String customId = getCustomId(blockId); + + Optional icon = switch (provider) + { + case ITEMS_ADDER -> ItemsAdderHook.getItemStack(customId); + case CRAFT_ENGINE -> CraftEngineHook.getItemStack(customId); + // Oraxen hook exposes icons only via Oraxen classes; Nexo has no hook yet. + case ORAXEN, NEXO -> Optional.empty(); + }; + + if (icon.isPresent()) + { + return icon.get().clone(); + } + } + + return new ItemStack(Material.NOTE_BLOCK); + } + + + /** + * Returns a user-facing display name for the given block ID. Vanilla IDs are translated via the + * locale files; custom IDs use the provider item's display name when available, otherwise the + * plugin-native ID. + * + * @param addon addon instance used to reach the hook manager. + * @param user user for translations. + * @param blockId block ID. + * @return display name, never null. + */ + public static String getDisplayName(StoneGeneratorAddon addon, User user, String blockId) + { + Material vanilla = matchVanilla(blockId); + + if (vanilla != null) + { + return Utils.prettifyObject(user, vanilla); + } + + if (isCustom(blockId)) + { + ItemStack icon = getIcon(addon, blockId); + ItemMeta meta = icon.getItemMeta(); + + if (meta != null && meta.hasDisplayName()) + { + return meta.getDisplayName(); + } + + return getCustomId(blockId); + } + + return blockId; + } +} diff --git a/src/main/resources/generatorTemplate.yml b/src/main/resources/generatorTemplate.yml index 465ace5..f2d7831 100644 --- a/src/main/resources/generatorTemplate.yml +++ b/src/main/resources/generatorTemplate.yml @@ -29,6 +29,13 @@ tiers: # Materials and their chances. Use actual blocks please. # Chance supports any positive number. # Everything in the end will be normalized. + # Custom blocks from ItemsAdder, CraftEngine, Oraxen or Nexo can be used by + # prefixing the block ID with the plugin name (quote keys that contain colons): + # "itemsadder:iasurvival:ruby_ore": 1 + # "craftengine:default:topaz_ore": 1 + # ItemsAdder and CraftEngine work out of the box (via BentoBox hooks); + # Oraxen and Nexo entries are accepted but generate only once BentoBox + # core hooks support placing their blocks. blocks: STONE: 10 COBBLESTONE: 86 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 2882c64..d0ccae6 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -431,6 +431,12 @@ stone-generator: description: |- Add new material to the current material list. + add_custom_block: + name: "Add Custom Block" + description: |- + Add a custom block from + ItemsAdder, CraftEngine, + Oraxen or Nexo by its ID. remove_material: name: "Remove Materials" description: |- @@ -1124,6 +1130,12 @@ stone-generator: click-text-to-activate: "You have unlocked [generator]! Click here to activate it now." input-min-height: "Enter the minimum height for this material (between -64 and 320):" input-max-height: "Enter the maximum height for this material (between -64 and 320):" + # Message that appears when admin clicks on the add custom block button. + input-custom-block: "Please enter the custom block ID in chat, prefixed by its plugin: 'itemsadder:namespace:id', 'craftengine:namespace:id', 'oraxen:id' or 'nexo:id'." + # Message that appears when the entered custom block ID has no known plugin prefix. + custom-block-invalid-prefix: "'[block]' is not a valid custom block ID. Use 'itemsadder:', 'craftengine:', 'oraxen:' or 'nexo:' followed by the block ID." + # Message that appears when the entered custom block ID is not registered on this server. + custom-block-not-found: "Custom block '[block]' was not found. Check that the plugin is installed and the ID is correct." # Showcase for manual material translation materials: # Names should be lowercase. diff --git a/src/test/java/world/bentobox/magiccobblestonegenerator/tasks/MagicGeneratorTest.java b/src/test/java/world/bentobox/magiccobblestonegenerator/tasks/MagicGeneratorTest.java index 3591153..d3fba2b 100644 --- a/src/test/java/world/bentobox/magiccobblestonegenerator/tasks/MagicGeneratorTest.java +++ b/src/test/java/world/bentobox/magiccobblestonegenerator/tasks/MagicGeneratorTest.java @@ -1,29 +1,32 @@ package world.bentobox.magiccobblestonegenerator.tasks; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; 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.Optional; import java.util.TreeMap; import org.bukkit.Location; -import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Mockito; +import world.bentobox.bentobox.managers.HooksManager; import world.bentobox.magiccobblestonegenerator.CommonTestSetup; import world.bentobox.magiccobblestonegenerator.StoneGeneratorAddon; import world.bentobox.magiccobblestonegenerator.database.objects.GeneratorTierObject; import world.bentobox.magiccobblestonegenerator.events.GeneratorTreasureDropEvent; /** - * Tests for {@link MagicGenerator}, focused on the treasure drop event (#140). + * Tests for {@link MagicGenerator}, focused on the treasure drop event (#140) and custom block IDs. */ class MagicGeneratorTest extends CommonTestSetup { @@ -33,6 +36,8 @@ class MagicGeneratorTest extends CommonTestSetup { private GeneratorTierObject generatorTier; @Mock private ItemStack treasure; + @Mock + private HooksManager hooksManager; private MagicGenerator generator; @@ -42,8 +47,8 @@ public void setUp() throws Exception { super.setUp(); // Block chance map with a single guaranteed material. - TreeMap blockMap = new TreeMap<>(); - blockMap.put(1.0, Material.COBBLESTONE); + TreeMap blockMap = new TreeMap<>(); + blockMap.put(1.0, "COBBLESTONE"); when(generatorTier.getBlockChanceMap()).thenReturn(blockMap); when(generatorTier.getMinHeight()).thenReturn(-64); when(generatorTier.getMaxHeight()).thenReturn(320); @@ -67,8 +72,8 @@ public void setUp() throws Exception { @Test void testProcessBlockReplacementReturnsMaterial() { - Material result = generator.processBlockReplacement(generatorTier, location, island); - assertEquals(Material.COBBLESTONE, result); + String result = generator.processBlockReplacement(generatorTier, location, island); + assertEquals("COBBLESTONE", result); } @Test @@ -88,10 +93,10 @@ void testCancelledTreasureDropEventPreventsDrop() { return null; }).when(pim).callEvent(any(GeneratorTreasureDropEvent.class)); - Material result = generator.processBlockReplacement(generatorTier, location, island); + String result = generator.processBlockReplacement(generatorTier, location, island); // Block is still generated, but no treasure is dropped. - assertEquals(Material.COBBLESTONE, result); + assertEquals("COBBLESTONE", result); verify(world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); } @@ -106,17 +111,54 @@ void testListenerNullingDropLocationIsGuarded() { return null; }).when(pim).callEvent(any(GeneratorTreasureDropEvent.class)); - Material result = generator.processBlockReplacement(generatorTier, location, island); + String result = generator.processBlockReplacement(generatorTier, location, island); - assertEquals(Material.COBBLESTONE, result); + assertEquals("COBBLESTONE", result); verify(world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); } @Test void testProcessBlockReplacementWithoutIslandOverload() { // The two-argument overload delegates with a null island and still generates the block. - Material result = generator.processBlockReplacement(generatorTier, location); - assertEquals(Material.COBBLESTONE, result); + String result = generator.processBlockReplacement(generatorTier, location); + assertEquals("COBBLESTONE", result); + } + + @Test + void testCustomBlockWithoutHookReturnsNull() { + // A custom block ID whose provider plugin is not hooked must not generate anything. + TreeMap blockMap = new TreeMap<>(); + blockMap.put(1.0, "itemsadder:iasurvival:ruby_ore"); + when(generatorTier.getBlockChanceMap()).thenReturn(blockMap); + + when(addon.getPlugin()).thenReturn(plugin); + when(plugin.getHooks()).thenReturn(hooksManager); + when(hooksManager.getHook(anyString())).thenReturn(Optional.empty()); + + String result = generator.processBlockReplacement(generatorTier, location, island); + + assertNull(result); + // No treasure may drop when the block generation failed. + verify(world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); + } + + @Test + void testUnknownVanillaMaterialReturnsNull() { + TreeMap blockMap = new TreeMap<>(); + blockMap.put(1.0, "NOT_A_REAL_MATERIAL"); + when(generatorTier.getBlockChanceMap()).thenReturn(blockMap); + + assertNull(generator.processBlockReplacement(generatorTier, location, island)); + } + + @Test + void testOraxenBlockReturnsNullUntilCoreSupportsPlacement() { + // Oraxen IDs are recognised but placement is not supported by the BentoBox hook yet. + TreeMap blockMap = new TreeMap<>(); + blockMap.put(1.0, "oraxen:ruby_ore"); + when(generatorTier.getBlockChanceMap()).thenReturn(blockMap); + + assertNull(generator.processBlockReplacement(generatorTier, location, island)); } @Test diff --git a/src/test/java/world/bentobox/magiccobblestonegenerator/utils/CustomBlocksTest.java b/src/test/java/world/bentobox/magiccobblestonegenerator/utils/CustomBlocksTest.java new file mode 100644 index 0000000..4756129 --- /dev/null +++ b/src/test/java/world/bentobox/magiccobblestonegenerator/utils/CustomBlocksTest.java @@ -0,0 +1,126 @@ +package world.bentobox.magiccobblestonegenerator.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.managers.HooksManager; +import world.bentobox.magiccobblestonegenerator.CommonTestSetup; +import world.bentobox.magiccobblestonegenerator.StoneGeneratorAddon; + +/** + * Tests for {@link CustomBlocks} block ID parsing and hook dispatch. + */ +class CustomBlocksTest extends CommonTestSetup { + + @Mock + private StoneGeneratorAddon addon; + @Mock + private HooksManager hooksManager; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + } + + @Test + void testGetProviderParsesKnownPrefixes() { + assertEquals(CustomBlocks.Provider.ITEMS_ADDER, CustomBlocks.getProvider("itemsadder:ns:ruby_ore")); + assertEquals(CustomBlocks.Provider.CRAFT_ENGINE, CustomBlocks.getProvider("craftengine:default:topaz")); + assertEquals(CustomBlocks.Provider.ORAXEN, CustomBlocks.getProvider("oraxen:ruby_ore")); + assertEquals(CustomBlocks.Provider.NEXO, CustomBlocks.getProvider("nexo:ruby_ore")); + // Prefix matching is case-insensitive. + assertEquals(CustomBlocks.Provider.ITEMS_ADDER, CustomBlocks.getProvider("ItemsAdder:ns:ruby_ore")); + } + + @Test + void testGetProviderRejectsVanillaAndUnknown() { + assertNull(CustomBlocks.getProvider("COBBLESTONE")); + assertNull(CustomBlocks.getProvider("minecraft:cobblestone")); + assertNull(CustomBlocks.getProvider("someplugin:block")); + assertNull(CustomBlocks.getProvider(null)); + assertNull(CustomBlocks.getProvider(":oops")); + } + + @Test + void testGetCustomIdStripsOnlyProviderPrefix() { + assertEquals("ns:ruby_ore", CustomBlocks.getCustomId("itemsadder:ns:ruby_ore")); + assertEquals("ruby_ore", CustomBlocks.getCustomId("oraxen:ruby_ore")); + // Non-custom IDs pass through unchanged. + assertEquals("COBBLESTONE", CustomBlocks.getCustomId("COBBLESTONE")); + assertEquals("minecraft:cobblestone", CustomBlocks.getCustomId("minecraft:cobblestone")); + } + + @Test + void testMatchVanilla() { + assertEquals(Material.COBBLESTONE, CustomBlocks.matchVanilla("COBBLESTONE")); + assertEquals(Material.COBBLESTONE, CustomBlocks.matchVanilla("minecraft:cobblestone")); + assertNull(CustomBlocks.matchVanilla("itemsadder:ns:ruby_ore")); + assertNull(CustomBlocks.matchVanilla("NOT_A_REAL_MATERIAL")); + assertNull(CustomBlocks.matchVanilla(null)); + } + + @Test + void testHookAbsentMeansNotRegisteredAndNoPlacement() { + when(addon.getPlugin()).thenReturn(plugin); + when(plugin.getHooks()).thenReturn(hooksManager); + when(hooksManager.getHook(anyString())).thenReturn(Optional.empty()); + + assertFalse(CustomBlocks.isRegistered(addon, "itemsadder:ns:ruby_ore")); + assertFalse(CustomBlocks.canPlace(addon, "itemsadder:ns:ruby_ore")); + assertFalse(CustomBlocks.place(addon, "itemsadder:ns:ruby_ore", location)); + } + + @Test + void testPlacementUnsupportedProvidersCannotPlace() { + // Oraxen and Nexo are rejected before any hook lookup, as core hooks cannot place them yet. + assertFalse(CustomBlocks.canPlace(addon, "oraxen:ruby_ore")); + assertFalse(CustomBlocks.canPlace(addon, "nexo:ruby_ore")); + assertFalse(CustomBlocks.place(addon, "oraxen:ruby_ore", location)); + } + + @Test + void testVanillaIdsAreNeverPlaceableAsCustom() { + assertFalse(CustomBlocks.canPlace(addon, "COBBLESTONE")); + assertFalse(CustomBlocks.isCustom("COBBLESTONE")); + assertTrue(CustomBlocks.isCustom("craftengine:default:topaz")); + } + + @Test + void testGetIconForVanillaBlock() { + ItemStack icon = CustomBlocks.getIcon(addon, "COBBLESTONE"); + assertEquals(Material.COBBLESTONE, icon.getType()); + } + + @Test + void testGetIconFallsBackWhenHookMissing() { + when(addon.getPlugin()).thenReturn(plugin); + when(plugin.getHooks()).thenReturn(hooksManager); + when(hooksManager.getHook(anyString())).thenReturn(Optional.empty()); + + ItemStack icon = CustomBlocks.getIcon(addon, "itemsadder:ns:ruby_ore"); + assertEquals(Material.NOTE_BLOCK, icon.getType()); + } + + @Test + void testGetDisplayNameFallsBackToCustomId() { + when(addon.getPlugin()).thenReturn(plugin); + when(plugin.getHooks()).thenReturn(hooksManager); + when(hooksManager.getHook(anyString())).thenReturn(Optional.empty()); + + // Fallback icon has no display name, so the plugin-native ID is used. + assertEquals("ns:ruby_ore", CustomBlocks.getDisplayName(addon, null, "itemsadder:ns:ruby_ore")); + } +}