From bdcc1ed8a52ef18fea65e453ae6b3d39b9c2e1bd Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 8 Apr 2026 04:57:23 -0700 Subject: [PATCH 1/5] Add mob-data and mythic-mob custom block types Adds two new phase YAML entry types so admins can summon mobs with richer customization than the existing `mob` entry: - `mob-data`: summons an entity via vanilla `/summon` NBT/component syntax, e.g. glowing breezes with a scale attribute. After summon, MakeSpace runs one tick later so oversized hitboxes clear the surrounding blocks automatically. Closes #488. - `mythic-mob`: spawns a MythicMob via BentoBox's MythicMobsHook with no direct MythicMobs dependency. Uses the new Consumer overload on the hook so MakeSpace can run after the hook's 40-tick spawn delay. Falls back to the 2-arg signature via reflection for compatibility with BentoBox 3.13.0 until a newer release is cut. Closes #303. Co-Authored-By: Claude Opus 4.6 --- README.md | 29 +++ .../oneblocks/OneBlockCustomBlockCreator.java | 4 + .../customblock/MobDataCustomBlock.java | 118 +++++++++++ .../customblock/MythicMobCustomBlock.java | 186 ++++++++++++++++++ .../customblock/MobDataCustomBlockTest.java | 100 ++++++++++ .../customblock/MythicMobCustomBlockTest.java | 98 +++++++++ 6 files changed, 535 insertions(+) create mode 100644 src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java create mode 100644 src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java create mode 100644 src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java create mode 100644 src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlockTest.java diff --git a/README.md b/README.md index 43313a51..0633b37e 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,35 @@ If CHEST is listed in the blocks section, then it will be randomly filled accord Be very careful when editing the chest items and check that the material is a true Bukkit material and spelled correctly. +### Custom block entries + +Phase `blocks:` sections can also be written as a YAML list, which unlocks four +custom entry types: + +```yaml +blocks: + - type: block-data + data: redstone_wire[power=15] + probability: 20 + + # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. + # After spawning, blocks inside the mob's (scaled) bounding box are cleared. + - type: mob-data + data: minecraft:breeze{Glowing:1b,attributes:[{id:"minecraft:generic.scale",base:2}]} + underlying-block: STONE + probability: 15 + + # #303: spawn a MythicMob via BentoBox's MythicMobs hook. Requires the MythicMobs + # plugin to be installed; otherwise the entry is logged and skipped at runtime. + - type: mythic-mob + mob: SkeletalKnight + level: 3 + power: 1.0 + display-name: "Boss" + underlying-block: STONE + probability: 5 +``` + ### Other Add-ons OneBlock is an add-on that uses the BentoBox API. Here are some other ones that you may be interested in: diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java index d46b507c..51dd1a64 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java @@ -10,6 +10,8 @@ import world.bentobox.aoneblock.oneblocks.customblock.BlockDataCustomBlock; import world.bentobox.aoneblock.oneblocks.customblock.MobCustomBlock; +import world.bentobox.aoneblock.oneblocks.customblock.MobDataCustomBlock; +import world.bentobox.aoneblock.oneblocks.customblock.MythicMobCustomBlock; /** * A creator for {@link OneBlockCustomBlock} @@ -23,6 +25,8 @@ public final class OneBlockCustomBlockCreator { static { register("block-data", BlockDataCustomBlock::fromMap); register("mob", MobCustomBlock::fromMap); + register("mob-data", MobDataCustomBlock::fromMap); + register("mythic-mob", MythicMobCustomBlock::fromMap); register("short", map -> { String type = Objects.toString(map.get("data"), null); if (type == null) { diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java new file mode 100644 index 00000000..156857d9 --- /dev/null +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java @@ -0,0 +1,118 @@ +package world.bentobox.aoneblock.oneblocks.customblock; + +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; + +import world.bentobox.aoneblock.AOneBlock; +import world.bentobox.aoneblock.listeners.MakeSpace; +import world.bentobox.aoneblock.oneblocks.OneBlockCustomBlock; +import world.bentobox.bentobox.BentoBox; + +/** + * A custom block that summons an entity using a Minecraft-format NBT data + * string (i.e. the same syntax as the vanilla {@code /summon} command). + *

+ * Example YAML entry: + * + *

+ * - type: mob-data
+ *   data: minecraft:breeze{Glowing:1b,attributes:[{id:"minecraft:generic.scale",base:2}]}
+ *   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 = "execute in " + world + " run summon " + data + " " + x + " " + y + " " + z; + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); + + block.getWorld().playSound(block.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1F, 2F); + + // Defer MakeSpace by one tick so NBT-driven attributes (e.g. + // minecraft:generic.scale) have applied before we measure the bounding box. + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { + Entity spawned = findRecentlySpawned(spawnLoc); + if (spawned != null && addon.getSettings().isClearBlocks()) { + new MakeSpace(addon).makeSpace(spawned, spawnLoc); + } + }, 1L); + } catch (Exception e) { + BentoBox.getInstance().logError("Could not summon mob-data entity '" + data + "': " + e.getMessage()); + } + } + + /** + * Finds the most-recently-spawned living entity near the given location. + * Skips players, then picks the entity with the lowest {@code getTicksLived()} + * (i.e. the newest). + */ + @Nullable + private Entity findRecentlySpawned(Location spawnLoc) { + return spawnLoc.getWorld().getNearbyEntities(spawnLoc, 1.5D, 2D, 1.5D).stream() + .filter(e -> !(e instanceof Player)) + .filter(LivingEntity.class::isInstance) + .min(Comparator.comparingInt(Entity::getTicksLived)) + .orElse(null); + } + + public String getData() { + return data; + } + + public Material getUnderlyingBlock() { + return underlyingBlock; + } +} diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MythicMobCustomBlock.java new file mode 100644 index 00000000..30423168 --- /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/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 00000000..e439d020 --- /dev/null +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java @@ -0,0 +1,100 @@ +package world.bentobox.aoneblock.oneblocks.customblock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.Material; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import world.bentobox.bentobox.BentoBox; + +/** + * Unit tests for {@link MobDataCustomBlock#fromMap(Map)}. + *

+ * 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 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 00000000..eeda1a2e --- /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); + } +} From dd8d7e8b291a855750efc1b8f220532909f227a5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 8 Apr 2026 07:17:27 -0700 Subject: [PATCH 2/5] Fix mob-data /summon ordering and add custom-blocks sibling section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mob-data custom block glued NBT to the entity id, producing `summon breeze{...} x y z`, which vanilla's command parser rejects with "Unhandled exception". Split the data string at the first `{` or `[` so the command becomes `summon [nbt]`, matching the vanilla grammar. Extracted into a package-private buildSummonCommand helper with unit tests locking in the ordering for `{...}`, `[...]`, and bare-entity inputs. Also add an optional sibling `custom-blocks:` list in OneBlocksManager so admins can keep their existing map-form `blocks:` section untouched while registering custom entries like mob-data or mythic-mob — removes the need to convert every vanilla block to list form just to add one custom spawn. Both sections feed the same weighted pool. Update the README examples and MobDataCustomBlock javadoc to use 1.21+ attribute syntax (`minecraft:scale`, not `minecraft:generic.scale`), add a note about the 1.21 attribute-prefix rename, and enrich the error log with a version-compat breadcrumb. Co-Authored-By: Claude Opus 4.6 --- README.md | 36 ++++++++++++++- .../aoneblock/oneblocks/OneBlocksManager.java | 28 +++++++++--- .../customblock/MobDataCustomBlock.java | 45 +++++++++++++++++-- .../oneblocks/OneBlocksManagerTest3.java | 32 +++++++++++++ .../customblock/MobDataCustomBlockTest.java | 36 +++++++++++++++ 5 files changed, 164 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0633b37e..70682780 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ Be very careful when editing the chest items and check that the material is a tr ### Custom block entries -Phase `blocks:` sections can also be written as a YAML list, which unlocks four +Phase `blocks:` sections can also be written as a YAML list, which unlocks custom entry types: ```yaml @@ -203,7 +203,7 @@ blocks: # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. # After spawning, blocks inside the mob's (scaled) bounding box are cleared. - type: mob-data - data: minecraft:breeze{Glowing:1b,attributes:[{id:"minecraft:generic.scale",base:2}]} + data: breeze{CustomName:[{text:Breezy,color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,attributes:[{id:scale,base:2f}]} underlying-block: STONE probability: 15 @@ -218,6 +218,38 @@ blocks: probability: 5 ``` +> **Note:** the `mob-data` string is passed straight to the vanilla `/summon` +> command, so it must be valid NBT for your server version. A few 1.21 gotchas: +> attribute ids no longer use the `generic.`/`player.` prefix (`scale`, not +> `generic.scale`), numeric attribute bases need a float suffix (`base:2f`), +> and `CustomName` must be a text-component list (`[{text:Breezy}]`), not a +> plain string. Test the command in-game first with `/summon ` — +> if it works there it will work here. Bad NBT is logged and the spawn is +> skipped. + +If you'd rather leave your existing `blocks:` map-form section untouched, you +can put custom entries in a sibling `custom-blocks:` list. Both sections are +read and their entries merged into the same weighted pool, so probabilities in +the two sections are directly comparable: + +```yaml +blocks: + PODZOL: 40 + DIRT: 1000 + OAK_LOG: 2000 + +custom-blocks: + - type: mob-data + data: breeze{CustomName:[{text:Breezy,color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,attributes:[{id:scale,base:2f}]} + underlying-block: STONE + probability: 50 + + - type: mythic-mob + mob: SkeletalKnight + level: 3 + probability: 5 +``` + ### Other Add-ons OneBlock is an add-on that uses the BentoBox API. Here are some other ones that you may be interested in: diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlocksManager.java index ec20de5f..7f5ea0f1 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 index 156857d9..c86fc5b1 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlock.java @@ -30,7 +30,7 @@ * *

  * - type: mob-data
- *   data: minecraft:breeze{Glowing:1b,attributes:[{id:"minecraft:generic.scale",base:2}]}
+ *   data: breeze{Glowing:1b,attributes:[{id:scale,base:2f}]}
  *   underlying-block: STONE
  *   probability: 15
  * 
@@ -76,13 +76,14 @@ public void execute(AOneBlock addon, Block block) { String x = String.valueOf(spawnLoc.getX()); String y = String.valueOf(spawnLoc.getY()); String z = String.valueOf(spawnLoc.getZ()); - String command = "execute in " + world + " run summon " + data + " " + x + " " + y + " " + z; + + String command = buildSummonCommand(data, world, x, y, z); Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); block.getWorld().playSound(block.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1F, 2F); // Defer MakeSpace by one tick so NBT-driven attributes (e.g. - // minecraft:generic.scale) have applied before we measure the bounding box. + // minecraft:scale) have applied before we measure the bounding box. Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> { Entity spawned = findRecentlySpawned(spawnLoc); if (spawned != null && addon.getSettings().isClearBlocks()) { @@ -90,10 +91,46 @@ public void execute(AOneBlock addon, Block block) { } }, 1L); } catch (Exception e) { - BentoBox.getInstance().logError("Could not summon mob-data entity '" + data + "': " + e.getMessage()); + BentoBox.getInstance().logError("Could not summon mob-data entity '" + data + "': " + e.getMessage() + + " — check that the entity id, NBT keys, and attribute ids are valid for this Minecraft" + + " version (1.21 renamed 'minecraft:generic.*' attributes to drop the 'generic.' prefix)."); } } + /** + * Builds the {@code execute in 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()} diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java index e9659837..4bcc087c 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/OneBlocksManagerTest3.java @@ -401,6 +401,38 @@ void testAddBlocks() { assertTrue(obPhase.getBlocks().isEmpty()); } + /** + * Verifies that a phase can keep its existing map-form {@code blocks:} section + * untouched while adding custom entries via a sibling {@code custom-blocks:} + * list. This is the backwards-compatible entry point for mob-data / mythic-mob. + */ + @Test + void testAddBlocksWithCustomBlocksSibling() throws InvalidConfigurationException { + String yaml = """ + name: Plains + blocks: + PODZOL: 40 + DIRT: 1000 + custom-blocks: + - type: mob-data + data: minecraft:breeze{Glowing:1b} + underlying-block: STONE + probability: 50 + """; + YamlConfiguration cfg = new YamlConfiguration(); + cfg.loadFromString(yaml); + + obm.addBlocks(obPhase, cfg); + + // Two vanilla materials registered via the map form... + assertEquals(2, obPhase.getBlocks().size()); + assertTrue(obPhase.getBlocks().containsKey(Material.PODZOL)); + assertTrue(obPhase.getBlocks().containsKey(Material.DIRT)); + // ...plus one custom block from the sibling list. Vanilla totals (40 + 1000) + // plus the custom block probability (50) should appear in blockTotal. + assertEquals(40 + 1000 + 50, obPhase.getBlockTotal()); + } + /** * Test method for * {@link world.bentobox.aoneblock.oneblocks.OneBlocksManager#getPhase(int)}. diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java index e439d020..552f9e7d 100644 --- a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java +++ b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/MobDataCustomBlockTest.java @@ -84,6 +84,42 @@ void fromMapFallsBackToNullWhenUnderlyingBlockInvalid() { } } + @Test + void buildSummonCommandPlacesNbtAfterCoordinates() { + // Regression: the vanilla /summon grammar is `summon [nbt]`. + // Previously we glued NBT to the entity id, producing an "Unhandled exception" + // in VanillaCommandWrapper. This test locks in the corrected ordering. + String data = "breeze{CustomName:[{text:Breezy}],Glowing:1b,attributes:[{id:scale,base:2f}]}"; + String command = MobDataCustomBlock.buildSummonCommand(data, "minecraft:oneblock_world", + "1600.5", "81.0", "1600.5"); + + assertEquals( + "execute in minecraft:oneblock_world run summon breeze 1600.5 81.0 1600.5 " + + "{CustomName:[{text:Breezy}],Glowing:1b,attributes:[{id:scale,base:2f}]}", + command); + } + + @Test + void buildSummonCommandWithNoNbt() { + // A bare entity id (no NBT or components) should still produce a valid command. + String command = MobDataCustomBlock.buildSummonCommand("minecraft:zombie", + "minecraft:world", "0.5", "65.0", "0.5"); + + assertEquals("execute in minecraft:world run summon minecraft:zombie 0.5 65.0 0.5", command); + } + + @Test + void buildSummonCommandWithComponentBrackets() { + // Modern component syntax uses `[` instead of `{` (e.g. `pig[minecraft:rotation=...]`). + // The split must handle whichever comes first. + String command = MobDataCustomBlock.buildSummonCommand("pig[minecraft:rotation={yaw:90f}]", + "minecraft:world", "1.5", "64.0", "1.5"); + + assertEquals( + "execute in minecraft:world run summon pig 1.5 64.0 1.5 [minecraft:rotation={yaw:90f}]", + command); + } + @Test void registeredUnderMobDataType() { // Sanity check: ensure the OneBlockCustomBlockCreator routes "mob-data" From f61d3fba54bfc7d7c6702263a972869e854f7908 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 8 Apr 2026 07:29:08 -0700 Subject: [PATCH 3/5] Add `block` custom-blocks alias for /setblock with NBT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the existing BlockDataCustomBlock under a second key `block` so phase-file authors placing NBT-heavy blocks (preconfigured spawners, etc.) aren't forced to use the misleading `block-data` name. Both types route to the same handler; `block-data` stays the recommended key for simple block states while `block` signals intent when the data string contains NBT or a setblock mode flag. No behavioural change — BlockDataCustomBlock.execute already falls through to `/setblock ` when Bukkit.createBlockData can't parse the string, which is exactly what NBT payloads need. Documented both forms in the README with a working spawner+breeze example (single-quoted to avoid YAML delimiter clashes) and added two routing tests in BlockCustomBlockTest to lock in the alias. Co-Authored-By: Claude Opus 4.6 --- README.md | 18 +++++++ .../oneblocks/OneBlockCustomBlockCreator.java | 5 ++ .../customblock/BlockCustomBlockTest.java | 53 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 src/test/java/world/bentobox/aoneblock/oneblocks/customblock/BlockCustomBlockTest.java diff --git a/README.md b/README.md index 70682780..e67a1ef0 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,15 @@ blocks: data: redstone_wire[power=15] probability: 20 + # `type: block` runs the vanilla /setblock command at the magic-block + # position. The data string may include block states `[…]`, NBT `{…}`, + # and a trailing destroy|keep|replace mode — anything valid after + # `setblock `. Use single quotes around the data so any + # double quotes inside the NBT don't clash with YAML string delimiters. + - type: block + data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + probability: 10 + # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. # After spawning, blocks inside the mob's (scaled) bounding box are cleared. - type: mob-data @@ -218,6 +227,11 @@ blocks: probability: 5 ``` +`type: block` is an alias for `type: block-data` — both route to the same +handler. Use `block-data` when you only need simple block states +(`redstone_wire[power=15]`); use `block` when the data contains NBT or a +setblock mode flag so the intent is obvious at a glance. + > **Note:** the `mob-data` string is passed straight to the vanilla `/summon` > command, so it must be valid NBT for your server version. A few 1.21 gotchas: > attribute ids no longer use the `generic.`/`player.` prefix (`scale`, not @@ -244,6 +258,10 @@ custom-blocks: underlying-block: STONE probability: 50 + - type: block + data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + probability: 10 + - type: mythic-mob mob: SkeletalKnight level: 3 diff --git a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java index 51dd1a64..6c6c1504 100644 --- a/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java +++ b/src/main/java/world/bentobox/aoneblock/oneblocks/OneBlockCustomBlockCreator.java @@ -24,6 +24,11 @@ public final class OneBlockCustomBlockCreator { static { register("block-data", BlockDataCustomBlock::fromMap); + // Alias: `block` routes to the same handler as `block-data`. Both accept + // anything valid after `setblock ` — a block id, optional + // states `[…]`, optional NBT `{…}`, and an optional + // destroy|keep|replace mode. + register("block", BlockDataCustomBlock::fromMap); register("mob", MobCustomBlock::fromMap); register("mob-data", MobDataCustomBlock::fromMap); register("mythic-mob", MythicMobCustomBlock::fromMap); diff --git a/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/BlockCustomBlockTest.java b/src/test/java/world/bentobox/aoneblock/oneblocks/customblock/BlockCustomBlockTest.java new file mode 100644 index 00000000..50285364 --- /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()); + } +} From 848071d469ced58c480d3753cfd6db00b0588bbf Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 8 Apr 2026 07:30:54 -0700 Subject: [PATCH 4/5] Add custom-blocks examples to 0_plains.yml Add a commented-out `custom-blocks:` sibling section to the Plains phase showing all three custom entry types (block, mob-data, mythic-mob) with working, single-quoted data strings. Using the first-phase file as the on-disk reference template matches how other optional sections (start-commands, requirements, etc.) are documented in this file. Co-Authored-By: Claude Opus 4.6 --- src/main/resources/phases/0_plains.yml | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index 1d184a41..5e79a94b 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -89,6 +89,42 @@ EMERALD_ORE: 10 DIRT_PATH: 100 COPPER_ORE: 200 + # Optional sibling list for custom entries. Lets you keep the map-form + # `blocks:` section above untouched while registering custom spawns. + # Entries here join the same weighted pool as `blocks:` — probabilities + # are directly comparable. Uncomment and tweak to try them out. + # + # Supported custom types: + # - type: block — runs /setblock with full data (block states, + # NBT, and an optional destroy|keep|replace mode). + # Alias of `block-data`; prefer `block` for NBT. + # - type: mob-data — runs /summon with vanilla NBT/components. + # Blocks inside the (scaled) bounding box are + # cleared one tick after spawn so the mob fits. + # - type: mythic-mob — spawns a MythicMob via BentoBox's hook. + # Requires the MythicMobs plugin; otherwise + # logged and skipped at runtime. + # + # YAML caveat: because these data strings contain `{`, `}`, `[`, `]`, + # and double quotes, wrap the value in SINGLE quotes so the inner + # double quotes don't clash with YAML string delimiters. + #custom-blocks: + # - type: mob-data + # data: 'breeze{CustomName:[{text:"Breezy",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,attributes:[{id:scale,base:2f}]}' + # underlying-block: STONE + # probability: 10 + # + # - type: block + # data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + # probability: 5 + # + # - type: mythic-mob + # mob: SkeletalKnight + # level: 3 + # power: 1.0 + # display-name: "Boss" + # underlying-block: STONE + # probability: 2 mobs: COW: 150 SPIDER: 75 From 13e09140366bc9acc4b4fb84366c5e889cc3caa3 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 8 Apr 2026 07:37:28 -0700 Subject: [PATCH 5/5] Add spawner timing fields to block example NBT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placing a spawner via /setblock with only SpawnData leaves the timing fields at their uninitialised defaults — specifically Delay:-1, which means "inactive, never tick". The example spawner appeared correctly but never spawned anything. Update the README and 0_plains.yml block example to set Delay, MinSpawnDelay, MaxSpawnDelay, SpawnCount, SpawnRange, MaxNearbyEntities, and RequiredPlayerRange explicitly so the spawner actually ticks. Add a README "Spawner gotcha" callout explaining why the timing fields are necessary, and an inline comment in the phase YAML pointing at the same trap. Co-Authored-By: Claude Opus 4.6 --- README.md | 12 ++++++++++-- src/main/resources/phases/0_plains.yml | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e67a1ef0..2bf14fcd 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ blocks: # `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{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' probability: 10 # #488: summon an entity with vanilla NBT/component data, same syntax as /summon. @@ -232,6 +232,14 @@ handler. Use `block-data` when you only need simple block states (`redstone_wire[power=15]`); use `block` when the data contains NBT or a setblock mode flag so the intent is obvious at a glance. +> **Spawner gotcha:** placing a `spawner` via `/setblock` only sets the fields +> you provide — everything else defaults to `0`/`-1`, which leaves the spawner +> **inactive** (`Delay:-1` means "never tick"). If you want it to actually spawn +> mobs, set `Delay`, `MinSpawnDelay`, `MaxSpawnDelay`, `SpawnCount`, `SpawnRange`, +> `MaxNearbyEntities`, and `RequiredPlayerRange` explicitly, as shown in the +> example above. Test your full data string in-game with `/setblock ~ ~ ~ ` +> first — if the spawner ticks there, it will tick from `custom-blocks:` too. + > **Note:** the `mob-data` string is passed straight to the vanilla `/summon` > command, so it must be valid NBT for your server version. A few 1.21 gotchas: > attribute ids no longer use the `generic.`/`player.` prefix (`scale`, not @@ -259,7 +267,7 @@ custom-blocks: probability: 50 - type: block - data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' probability: 10 - type: mythic-mob diff --git a/src/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index 5e79a94b..6f691dd7 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -114,8 +114,11 @@ # underlying-block: STONE # probability: 10 # + # # Spawner gotcha: without the Delay/MinSpawnDelay/... timing fields, + # # vanilla 1.21 places the spawner inactive (Delay:-1 = never tick). + # # Set them explicitly or the spawner appears but does nothing. # - type: block - # data: 'spawner{SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' + # data: 'spawner{Delay:20,MinSpawnDelay:200,MaxSpawnDelay:800,SpawnCount:1,SpawnRange:4,MaxNearbyEntities:6,RequiredPlayerRange:16,SpawnData:{entity:{id:breeze,CustomName:[{text:"Breezy Generator",color:"#f90606"}],CustomNameVisible:1b,Glowing:1b,active_effects:[{id:unluck,duration:200,ambient:1b,show_particles:1b}],attributes:[{id:scale,base:2f}]}}} replace' # probability: 5 # # - type: mythic-mob