diff --git a/pom.xml b/pom.xml index 725f292..70bd7f0 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ -LOCAL - 1.24.0 + 1.25.0 BentoBoxWorld_AOneBlock bentobox-world diff --git a/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java b/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java index c066b98..8cb522c 100644 --- a/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java +++ b/src/main/java/world/bentobox/aoneblock/listeners/BlockListener.java @@ -527,12 +527,18 @@ private void processNextBlock(Cancellable e, Island i, Player player, Block bloc * @param nextBlock - next block object containing entity info */ private void handleEntitySpawn(Cancellable e, Island i, Player player, Block block, OneBlockObject nextBlock) { - if (!(e instanceof EntitySpawnEvent)) { + boolean cancelled = !(e instanceof EntitySpawnEvent); + if (cancelled) { e.setCancelled(true); } spawnEntity(nextBlock, block); + // Cancelling BlockBreakEvent at HIGHEST priority leaves the client with a + // mispredicted "block gone" state; resync the actual block to clear it. + if (cancelled && player != null) { + player.sendBlockChange(block.getLocation(), block.getBlockData()); + } Bukkit.getPluginManager().callEvent(new MagicBlockEntityEvent(i, - player == null ? null : player.getUniqueId(), + player == null ? null : player.getUniqueId(), block, nextBlock.getEntityType())); } @@ -886,7 +892,14 @@ private void fillChest(@NonNull OneBlockObject nextBlock, @NonNull Block block) return; } Color color = addon.getSettings().resolveChestColor(rarity); - Object particleData = Particle.DUST.equals(particle) ? new Particle.DustOptions(color, 1) : null; + Object particleData = null; + if (Particle.DUST.equals(particle)) { + particleData = new Particle.DustOptions(color, 1); + } else if (!Void.class.equals(particle.getDataType())) { + // Particle requires typed data we can't provide — skip rather than crash + addon.logWarning("Chest particle " + particle.name() + " requires extra data and cannot be used. Use DUST or a void-data particle."); + return; + } block.getWorld().spawnParticle(particle, block.getLocation().add(new Vector(0.5, 1.0, 0.5)), 50, 0.5, 0, 0.5, 1, particleData); } diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/CraftEngineCustomBlock.java b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/CraftEngineCustomBlock.java index 0c31d30..bd5b022 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/CraftEngineCustomBlock.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/CraftEngineCustomBlock.java @@ -1,7 +1,6 @@ package world.bentobox.aoneblock.oneblocks.customblock; import java.util.Map; -import java.util.Objects; import java.util.Optional; import org.bukkit.Material; @@ -26,10 +25,29 @@ public static Optional fromId(String id) { return Optional.empty(); } + /** + * Checks whether {@code id} is a syntactically valid namespaced key + * ({@code namespace:key} with both parts non-blank). + */ + static boolean isValidNamespacedKey(String id) { + int colon = id.indexOf(':'); + if (colon <= 0 || colon == id.length() - 1) { + return false; + } + String namespace = id.substring(0, colon); + String key = id.substring(colon + 1); + return !namespace.isBlank() && !key.isBlank(); + } + public static Optional fromMap(Map map) { - return Optional - .ofNullable(Objects.toString(map.get("id"), null)) - .flatMap(CraftEngineCustomBlock::fromId); + Object raw = map.get("id"); + if (!(raw instanceof String id)) { + return Optional.empty(); + } + if (id.isBlank() || !isValidNamespacedKey(id)) { + return Optional.empty(); + } + return Optional.of(new CraftEngineCustomBlock(id)); } @Override diff --git a/src/main/resources/phases/8500_plenty.yml b/src/main/resources/phases/8500_plenty.yml index 758153f..0615f37 100644 --- a/src/main/resources/phases/8500_plenty.yml +++ b/src/main/resources/phases/8500_plenty.yml @@ -45,6 +45,10 @@ DIRT_PATH: 1 LIGHT_GRAY_TERRACOTTA: 1 SUSPICIOUS_SAND: 1 + custom-blocks: + - type: block + data: 'minecraft:bee_nest[honey_level=0,facing=north]{bees:[{entity_data:{id:"minecraft:bee"},min_ticks_in_hive:600,ticks_in_hive:0},{entity_data:{id:"minecraft:bee"},min_ticks_in_hive:600,ticks_in_hive:0},{entity_data:{id:"minecraft:bee"},min_ticks_in_hive:600,ticks_in_hive:0}]}' + probability: 1 mobs: COW: 1 DONKEY: 1 diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/CraftEngineCustomBlockTest.java b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/CraftEngineCustomBlockTest.java index cd35d31..2d91929 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/CraftEngineCustomBlockTest.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/CraftEngineCustomBlockTest.java @@ -1,5 +1,7 @@ package world.bentobox.aoneblock.oneblocks.customblock; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.LinkedHashMap; @@ -27,4 +29,122 @@ void fromMapReturnsEmptyWhenIdMissing() { assertTrue(result.isEmpty(), "Should return empty when 'id' is missing"); } + + @Test + void fromMapReturnsEmptyWhenIdIsNull() { + Map map = new LinkedHashMap<>(); + map.put("type", "craftengine"); + map.put("id", null); + + var result = CraftEngineCustomBlock.fromMap(map); + + assertTrue(result.isEmpty(), "Should return empty when 'id' is null"); + } + + @Test + void fromMapReturnsEmptyWhenIdIsBlank() { + Map map = new LinkedHashMap<>(); + map.put("type", "craftengine"); + map.put("id", " "); + + var result = CraftEngineCustomBlock.fromMap(map); + + assertTrue(result.isEmpty(), "Should return empty when 'id' is blank"); + } + + @Test + void fromMapReturnsEmptyWhenIdIsNonStringValue() { + Map map = new LinkedHashMap<>(); + map.put("type", "craftengine"); + map.put("id", 42); // integer, not a String + + var result = CraftEngineCustomBlock.fromMap(map); + + assertTrue(result.isEmpty(), "Should return empty when 'id' is not a String"); + } + + @Test + void fromMapReturnsEmptyWhenIdHasNoColon() { + Map map = new LinkedHashMap<>(); + map.put("type", "craftengine"); + map.put("id", "nocolon"); + + var result = CraftEngineCustomBlock.fromMap(map); + + assertTrue(result.isEmpty(), "Should return empty when 'id' has no colon"); + } + + @Test + void fromMapReturnsEmptyWhenIdHasEmptyNamespace() { + Map map = new LinkedHashMap<>(); + map.put("type", "craftengine"); + map.put("id", ":key"); + + var result = CraftEngineCustomBlock.fromMap(map); + + assertTrue(result.isEmpty(), "Should return empty when namespace part is empty"); + } + + @Test + void fromMapReturnsEmptyWhenIdHasEmptyKey() { + Map map = new LinkedHashMap<>(); + map.put("type", "craftengine"); + map.put("id", "namespace:"); + + var result = CraftEngineCustomBlock.fromMap(map); + + assertTrue(result.isEmpty(), "Should return empty when key part is empty"); + } + + /** + * {@code fromMap} must succeed even when CraftEngine has not yet loaded its + * block registry (i.e. without calling {@code CraftEngineHook.exists}). + * This prevents false "Bad custom block" errors during the initial server + * start-up phase that occurs before CraftEngine fires its reload event. + */ + @Test + void fromMapReturnsPresentWhenIdProvided() { + Map map = new LinkedHashMap<>(); + map.put("type", "craftengine"); + map.put("id", "oneblock:common_loot_block"); + map.put("probability", 300); + + var result = CraftEngineCustomBlock.fromMap(map); + + assertTrue(result.isPresent(), "Should return a block when 'id' is present, regardless of CraftEngine load state"); + assertInstanceOf(CraftEngineCustomBlock.class, result.get()); + } + + // --- isValidNamespacedKey helper tests --- + + @Test + void isValidNamespacedKeyReturnsTrueForValidKey() { + assertTrue(CraftEngineCustomBlock.isValidNamespacedKey("ns:key")); + assertTrue(CraftEngineCustomBlock.isValidNamespacedKey("oneblock:common_loot_block")); + } + + @Test + void isValidNamespacedKeyReturnsFalseForMissingColon() { + assertFalse(CraftEngineCustomBlock.isValidNamespacedKey("nocolon")); + } + + @Test + void isValidNamespacedKeyReturnsFalseForLeadingColon() { + assertFalse(CraftEngineCustomBlock.isValidNamespacedKey(":key")); + } + + @Test + void isValidNamespacedKeyReturnsFalseForTrailingColon() { + assertFalse(CraftEngineCustomBlock.isValidNamespacedKey("ns:")); + } + + @Test + void isValidNamespacedKeyReturnsFalseForBlankNamespace() { + assertFalse(CraftEngineCustomBlock.isValidNamespacedKey(" :key")); + } + + @Test + void isValidNamespacedKeyReturnsFalseForBlankKey() { + assertFalse(CraftEngineCustomBlock.isValidNamespacedKey("ns: ")); + } }