diff --git a/README.md b/README.md index 43313a5..2bf14fc 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,93 @@ 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 +custom entry types: + +```yaml +blocks: + - type: block-data + 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 `. 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' + 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 + data: breeze{CustomName:[{text:Breezy,color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,attributes:[{id:scale,base:2f}]} + 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 +``` + +`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. + +> **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 ~ ~ ~ ` +> 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 +> `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 ` — +> 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: 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' + probability: 10 + + - 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/OneBlockCustomBlockCreator.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java index d46b507..6c6c150 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} @@ -22,7 +24,14 @@ 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 ` — 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); register("short", map -> { String type = Objects.toString(map.get("data"), null); if (type == null) { 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 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 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 new file mode 100644 index 0000000..c86fc5b --- /dev/null +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java @@ -0,0 +1,155 @@ +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). + *

+ * Example YAML entry: + * + *

+ * - type: mob-data
+ *   data: breeze{Glowing:1b,attributes:[{id:scale,base:2f}]}
+ *   underlying-block: STONE
+ *   probability: 15
+ * 
+ * + * 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 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 = 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: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() + + " — 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 run summon [nbt]} + * command for the given data string and coordinates. + *

+ * Vanilla summon grammar is {@code summon [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 ({ or + * [) and interleave the coordinates. + *

+ * 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()} + * (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}. + *

+ * Example YAML entry: + * + *

+ * - 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
+ * 
+ * + *

+ * 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 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 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 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 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/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index 1d184a4..6f691dd 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -89,6 +89,45 @@ 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 + # + # # 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{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 + # mob: SkeletalKnight + # level: 3 + # power: 1.0 + # display-name: "Boss" + # underlying-block: STONE + # probability: 2 mobs: COW: 150 SPIDER: 75 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/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. + *

+ * {@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 map = new LinkedHashMap<>(); + map.put("type", "block"); + map.put("data", "spawner{SpawnData:{entity:{id:breeze}}} replace"); + + Optional 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 map = new LinkedHashMap<>(); + map.put("type", "block-data"); + map.put("data", "redstone_wire[power=15]"); + + Optional result = OneBlockCustomBlockCreator.create(map); + + assertTrue(result.isPresent()); + assertInstanceOf(BlockDataCustomBlock.class, result.get()); + } +} 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..552f9e7 --- /dev/null +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java @@ -0,0 +1,136 @@ +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)}. + *

+ * 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 map = new LinkedHashMap<>(); + map.put("type", "mob-data"); + map.put("data", "minecraft:breeze{Glowing:1b}"); + map.put("underlying-block", "STONE"); + + Optional 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 map = new LinkedHashMap<>(); + map.put("type", "mob-data"); + // no "data" key + + Optional result = MobDataCustomBlock.fromMap(map); + + assertTrue(result.isEmpty()); + } + + @Test + void fromMapAllowsMissingUnderlyingBlock() { + Map map = new LinkedHashMap<>(); + map.put("data", "minecraft:zombie"); + + Optional 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 mocked = Mockito.mockStatic(BentoBox.class)) { + mocked.when(BentoBox::getInstance).thenReturn(mockBentoBox); + + Map map = new LinkedHashMap<>(); + map.put("data", "minecraft:zombie"); + map.put("underlying-block", "NOT_A_REAL_MATERIAL"); + + Optional 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 buildSummonCommandPlacesNbtAfterCoordinates() { + // Regression: the vanilla /summon grammar is `summon [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" + // to MobDataCustomBlock::fromMap. + Map 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)}. + *

+ * 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 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 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 map = new LinkedHashMap<>(); + map.put("type", "mythic-mob"); + // no "mob" key + + Optional result = MythicMobCustomBlock.fromMap(map); + + assertTrue(result.isEmpty()); + } + + @Test + void fromMapAppliesDefaultsForOptionalFields() { + Map map = new LinkedHashMap<>(); + map.put("mob", "Goblin"); + + Optional 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 map = new LinkedHashMap<>(); + map.put("mob", "Goblin"); + map.put("level", "2"); + map.put("power", "0.75"); + + Optional result = MythicMobCustomBlock.fromMap(map); + + assertTrue(result.isPresent()); + assertEquals(2D, result.get().getLevel()); + assertEquals(0.75F, result.get().getPower()); + } + + @Test + void registeredUnderMythicMobType() { + Map 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); + } +}