From b99ab416298b33dd51b7bf8963fec9d4f41a5bb4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 10 Aug 2026 07:56:11 -0700 Subject: [PATCH 01/13] Do not put texture-less profiles on player heads A PlayerProfile that has a UUID and a name but no textures property makes the server resolve it against the Mojang session server every time the head is shown. On a top ten panel that is ten lookups per open, which quickly returns HTTP 429 and still renders a default skin. HeadGetter created exactly that profile whenever its own texture fetch failed, and handed it to requesters anyway: the "only if the texture is usable" check tested for a null profile, which createProfile never returns. The failed lookup also overwrote any good cached entry, so a single rate limited call cost a working head for the whole cache period and kept the loop running. - HeadCache gains hasTexture() and only calls setOwnerProfile when a skin is actually known, so a failed lookup yields a plain head instead of one the server keeps trying to resolve. - HeadGetter only notifies requesters when the texture is usable, and no longer lets a failed fetch evict a cached head that has one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014HtnKzNE649pBrCdqMm7nw --- .../bentobox/util/heads/HeadCache.java | 28 +++++++++++++++++-- .../bentobox/util/heads/HeadGetter.java | 16 +++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java b/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java index d30472a4a..9da8a29da 100644 --- a/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java +++ b/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java @@ -82,6 +82,30 @@ public HeadCache(String userName, // --------------------------------------------------------------------- + /** + * Checks if this cache holds a usable skin texture. + *

+ * A profile that has a name and a UUID but no texture property is worse than no + * profile at all: the server resolves it against the Mojang session server every time + * the head is shown, which quickly earns an HTTP 429 and still renders a default skin. + * + * @return {@code true} if the cached profile has a skin texture. + * @since 3.23.0 + */ + public boolean hasTexture() + { + try + { + return this.playerProfile != null && this.playerProfile.getTextures().getSkin() != null; + } + catch (Exception e) + { + // Treat an unreadable profile as having no texture. + return false; + } + } + + /** * Returns a new Player head with a cached texture. Be AWARE, usage does not use clone * method. If for some reason item stack is stored directly, then use clone in return @@ -94,8 +118,8 @@ public ItemStack getPlayerHead() ItemStack item = new ItemStack(Material.PLAYER_HEAD); SkullMeta meta = (SkullMeta) item.getItemMeta(); - // Set correct Skull texture - if (meta != null && this.playerProfile != null) + // Set correct Skull texture. Only if the texture is actually known - see hasTexture. + if (meta != null && this.hasTexture()) { try { meta.setOwnerProfile(this.playerProfile); diff --git a/src/main/java/world/bentobox/bentobox/util/heads/HeadGetter.java b/src/main/java/world/bentobox/bentobox/util/heads/HeadGetter.java index 8e31f8bb1..1bf2fa92a 100644 --- a/src/main/java/world/bentobox/bentobox/util/heads/HeadGetter.java +++ b/src/main/java/world/bentobox/bentobox/util/heads/HeadGetter.java @@ -176,11 +176,21 @@ private void runPlayerHeadGetter() { HeadGetter.createProfile(userName, userId, HeadGetter.getTextureFromUUID(userId))); } - // Save in cache - HeadGetter.cachedHeads.put(userName, cache); + // Save in cache. A failed lookup must not evict a texture we already + // have, otherwise one rate-limited call costs a working head for the + // whole cache period. + HeadCache previous = HeadGetter.cachedHeads.get(userName); + + if (cache.hasTexture() || previous == null || !previous.hasTexture()) { + HeadGetter.cachedHeads.put(userName, cache); + } else { + cache = previous; + } // Tell requesters the head came in, but only if the texture is usable. - if (cache.playerProfile != null && HeadGetter.headRequesters.containsKey(userName)) { + // Handing out a profile with no texture makes the server look it up at + // Mojang every time the head is shown, which ends in HTTP 429s. + if (cache.hasTexture() && HeadGetter.headRequesters.containsKey(userName)) { for (HeadRequester req : HeadGetter.headRequesters.get(userName)) { elementEntry.getValue().setHead(cache.getPlayerHead()); From 0f7002b47e45ba7f5335ba4b26df7c80160e3043 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 10 Aug 2026 08:01:38 -0700 Subject: [PATCH 02/13] Update build version from 3.22.2 to 3.22.3 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index a4630b13c..e2bb12acc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,7 +48,7 @@ paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArt group = "world.bentobox" // From // Base properties from -val buildVersion = "3.22.2" +val buildVersion = "3.22.3" val buildNumberDefault = "-LOCAL" // Local build identifier val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version From 7278da1e7b143da49f5219397353b0ea20df41c9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 10 Aug 2026 08:02:29 -0700 Subject: [PATCH 03/13] Update Javadoc version in HeadCache.java --- src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java b/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java index 9da8a29da..4245159af 100644 --- a/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java +++ b/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java @@ -90,7 +90,7 @@ public HeadCache(String userName, * the head is shown, which quickly earns an HTTP 429 and still renders a default skin. * * @return {@code true} if the cached profile has a skin texture. - * @since 3.23.0 + * @since 3.22.3 */ public boolean hasTexture() { From a7c57a7a56374a1a9b8bf0329878387b11c02599 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 08:59:45 -0700 Subject: [PATCH 04/13] Fix literal color names showing in panels from NamedTextColor concatenation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 07ae99f1d replaced deprecated ChatColor constants with Adventure's NamedTextColor in string concatenations, but NamedTextColor.WHITE.toString() is the word "white", not a color code — so the Management panel showed addon names like "whiteChallenges". Use MiniMessage tags instead, which Util.parseMiniMessageOrLegacy handles correctly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XkVFZoe4hB2Req885z5ea7 --- .../database/yaml/YamlDatabaseHandler.java | 41 ++++++++++++++++-- .../bentobox/managers/BlueprintsManager.java | 5 +-- .../bentobox/panels/CreditsPanel.java | 3 +- .../bentobox/panels/ManagementPanel.java | 5 +-- .../yaml/YamlDatabaseHandlerTest.java | 43 +++++++++++++++++++ 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java b/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java index 9d8a5445d..68fcd5984 100644 --- a/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java +++ b/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java @@ -686,9 +686,17 @@ private Object deserialize(Object value, Class clazz) { if (clazz.equals(value.getClass())) { return value; } - // Integer to Long promotion - if (clazz.equals(Long.class) && value.getClass().equals(Integer.class)) { - return Long.valueOf((Integer) value); + // Numeric widening. YAML types a number by how it is written, so "20" + // loads as Integer and "20.0" as Double - but a config field declared + // double is perfectly entitled to be written without a decimal point. + // Without this, such a value reaches the setter as an Integer; inside a + // collection, generic erasure means nothing complains until the first + // read throws ClassCastException, a long way from the cause. + if (value instanceof Number number) { + Object widened = widen(number, clazz); + if (widened != null) { + return widened; + } } // String-based conversions if (value instanceof String stringValue) { @@ -704,6 +712,33 @@ private Object deserialize(Object value, Class clazz) { return value; } + /** + * Widen a YAML-loaded number to the field's declared numeric type, or null if + * the target is not a numeric type this handles. + *

+ * Widening only. There is deliberately no case for {@code int}: a value written + * with a decimal point against an integer field is a mistake in the config, and + * narrowing it with {@code intValue()} would silently discard the fraction. Left + * alone, it fails visibly instead. + * + * @param number the value as YAML typed it + * @param clazz the declared type + * @return the converted value, or null to leave it alone + */ + @Nullable + private Object widen(Number number, Class clazz) { + if (clazz.equals(Long.class) || clazz.equals(long.class)) { + return number.longValue(); + } + if (clazz.equals(Double.class) || clazz.equals(double.class)) { + return number.doubleValue(); + } + if (clazz.equals(Float.class) || clazz.equals(float.class)) { + return number.floatValue(); + } + return null; + } + /** * Deserialize a string value into the target class type. * Handles numeric types, UUID, Location, and World. diff --git a/src/main/java/world/bentobox/bentobox/managers/BlueprintsManager.java b/src/main/java/world/bentobox/bentobox/managers/BlueprintsManager.java index f9e398f98..412d67691 100644 --- a/src/main/java/world/bentobox/bentobox/managers/BlueprintsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/BlueprintsManager.java @@ -36,7 +36,6 @@ import com.google.gson.GsonBuilder; import com.google.gson.InstanceCreator; -import net.kyori.adventure.text.format.NamedTextColor; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.addons.GameModeAddon; @@ -275,14 +274,14 @@ private BlueprintBundle getDefaultBlueprintBundle() { bb.setIcon(Material.PAPER); bb.setUniqueId(DEFAULT_BUNDLE_NAME); bb.setDisplayName("Default bundle"); - bb.setDescription(Collections.singletonList(NamedTextColor.AQUA + "Default bundle of blueprints")); + bb.setDescription(Collections.singletonList("Default bundle of blueprints")); return bb; } private Blueprint getDefaultBlueprint() { Blueprint defaultBp = new Blueprint(); defaultBp.setName("bedrock"); - defaultBp.setDescription(Collections.singletonList(NamedTextColor.AQUA + "A bedrock block")); + defaultBp.setDescription(Collections.singletonList("A bedrock block")); defaultBp.setBedrock(new Vector(0, 0, 0)); Map map = new HashMap<>(); map.put(new Vector(0, 0, 0), new BlueprintBlock("minecraft:bedrock")); diff --git a/src/main/java/world/bentobox/bentobox/panels/CreditsPanel.java b/src/main/java/world/bentobox/bentobox/panels/CreditsPanel.java index 4adf75842..c49ee58bd 100644 --- a/src/main/java/world/bentobox/bentobox/panels/CreditsPanel.java +++ b/src/main/java/world/bentobox/bentobox/panels/CreditsPanel.java @@ -3,7 +3,6 @@ import org.bukkit.Material; import org.eclipse.jdt.annotation.NonNull; -import net.kyori.adventure.text.format.NamedTextColor; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.localization.TextVariables; @@ -52,7 +51,7 @@ public static void openPanel(User user, String repository) { .description(user.getTranslation(LOCALE_REF + "contributor.description", "[commits]", String.valueOf(contributor.getCommits()))) .clickHandler((panel, user1, clickType, slot1) -> { - user.sendRawMessage(NamedTextColor.GRAY + contributor.getURL()); + user.sendRawMessage("" + contributor.getURL()); return true; }) .build(); diff --git a/src/main/java/world/bentobox/bentobox/panels/ManagementPanel.java b/src/main/java/world/bentobox/bentobox/panels/ManagementPanel.java index c320d3cb0..94a066c57 100644 --- a/src/main/java/world/bentobox/bentobox/panels/ManagementPanel.java +++ b/src/main/java/world/bentobox/bentobox/panels/ManagementPanel.java @@ -6,7 +6,6 @@ import org.bukkit.event.inventory.ClickType; import org.eclipse.jdt.annotation.NonNull; -import net.kyori.adventure.text.format.NamedTextColor; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.addons.GameModeAddon; @@ -98,7 +97,7 @@ public static void openPanel(@NonNull User user, View view) { for (Addon addon : addons) { PanelItem addonItem = new PanelItemBuilder() .icon(addon.getDescription().getIcon()) - .name(NamedTextColor.WHITE + addon.getDescription().getName()) + .name("" + addon.getDescription().getName()) .clickHandler((panel, user1, clickType, slot) -> { if (clickType.equals(ClickType.MIDDLE)) { CreditsPanel.openPanel(user, addon); @@ -122,7 +121,7 @@ public static void openPanel(@NonNull User user, View view) { for (Hook hook : plugin.getHooks().getHooks()) { PanelItem hookItem = new PanelItemBuilder() .icon(hook.getIcon()) - .name(NamedTextColor.WHITE + hook.getPluginName()) + .name("" + hook.getPluginName()) .build(); builder.item(startSlot + i, hookItem); diff --git a/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java b/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java index 418c8dc6c..48c5d7232 100644 --- a/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java +++ b/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java @@ -117,6 +117,49 @@ void testDeserializeSameClassReturnsValue() throws Exception { assertSame(value, deserializeMethod.invoke(handler, value, String.class)); } + @Test + void testDeserializeIntegerToDouble() throws Exception { + // YAML types a number by how it is WRITTEN: "20" loads as Integer even + // where the field is a double. Without widening, that Integer reaches the + // setter, and inside a collection generic erasure hides it until the first + // read throws ClassCastException a long way from the cause. + Object result = deserializeMethod.invoke(handler, 20, Double.class); + assertEquals(20.0, result); + assertEquals(Double.class, result.getClass()); + } + + @Test + void testDeserializeIntegerToFloat() throws Exception { + Object result = deserializeMethod.invoke(handler, 20, Float.class); + assertEquals(20.0f, result); + assertEquals(Float.class, result.getClass()); + } + + @Test + void testDeserializeDoubleToIntegerIsNotNarrowed() throws Exception { + // The other direction is deliberately NOT converted. A decimal written + // against an int field is a config mistake, and intValue() would discard + // the fraction silently - 20.9 becoming 20 with nothing said. Left alone, + // it fails visibly instead. + Object result = deserializeMethod.invoke(handler, 20.9, Integer.class); + assertEquals(20.9, result); + assertEquals(Double.class, result.getClass()); + } + + @Test + void testDeserializeLongToDouble() throws Exception { + Object result = deserializeMethod.invoke(handler, 20L, Double.class); + assertEquals(20.0, result); + assertEquals(Double.class, result.getClass()); + } + + @Test + void testDeserializeNumberToNonNumericTypeIsUntouched() throws Exception { + // Widening must not hijack values whose target is not numeric + Object result = deserializeMethod.invoke(handler, 20, String.class); + assertEquals(20, result); + } + @Test void testDeserializeIntegerToLong() throws Exception { Object result = deserializeMethod.invoke(handler, 42, Long.class); From 1b839b619176408a0d89b9d897305940770c0840 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 09:00:40 -0700 Subject: [PATCH 05/13] Remove YAML handler changes committed by mistake The previous commit accidentally included in-progress work from the fix/yaml-numeric-widening branch; restore those two files to their prior state. That work will arrive via its own PR. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XkVFZoe4hB2Req885z5ea7 --- .../database/yaml/YamlDatabaseHandler.java | 41 ++---------------- .../yaml/YamlDatabaseHandlerTest.java | 43 ------------------- 2 files changed, 3 insertions(+), 81 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java b/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java index 68fcd5984..9d8a5445d 100644 --- a/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java +++ b/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java @@ -686,17 +686,9 @@ private Object deserialize(Object value, Class clazz) { if (clazz.equals(value.getClass())) { return value; } - // Numeric widening. YAML types a number by how it is written, so "20" - // loads as Integer and "20.0" as Double - but a config field declared - // double is perfectly entitled to be written without a decimal point. - // Without this, such a value reaches the setter as an Integer; inside a - // collection, generic erasure means nothing complains until the first - // read throws ClassCastException, a long way from the cause. - if (value instanceof Number number) { - Object widened = widen(number, clazz); - if (widened != null) { - return widened; - } + // Integer to Long promotion + if (clazz.equals(Long.class) && value.getClass().equals(Integer.class)) { + return Long.valueOf((Integer) value); } // String-based conversions if (value instanceof String stringValue) { @@ -712,33 +704,6 @@ private Object deserialize(Object value, Class clazz) { return value; } - /** - * Widen a YAML-loaded number to the field's declared numeric type, or null if - * the target is not a numeric type this handles. - *

- * Widening only. There is deliberately no case for {@code int}: a value written - * with a decimal point against an integer field is a mistake in the config, and - * narrowing it with {@code intValue()} would silently discard the fraction. Left - * alone, it fails visibly instead. - * - * @param number the value as YAML typed it - * @param clazz the declared type - * @return the converted value, or null to leave it alone - */ - @Nullable - private Object widen(Number number, Class clazz) { - if (clazz.equals(Long.class) || clazz.equals(long.class)) { - return number.longValue(); - } - if (clazz.equals(Double.class) || clazz.equals(double.class)) { - return number.doubleValue(); - } - if (clazz.equals(Float.class) || clazz.equals(float.class)) { - return number.floatValue(); - } - return null; - } - /** * Deserialize a string value into the target class type. * Handles numeric types, UUID, Location, and World. diff --git a/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java b/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java index 48c5d7232..418c8dc6c 100644 --- a/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java +++ b/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java @@ -117,49 +117,6 @@ void testDeserializeSameClassReturnsValue() throws Exception { assertSame(value, deserializeMethod.invoke(handler, value, String.class)); } - @Test - void testDeserializeIntegerToDouble() throws Exception { - // YAML types a number by how it is WRITTEN: "20" loads as Integer even - // where the field is a double. Without widening, that Integer reaches the - // setter, and inside a collection generic erasure hides it until the first - // read throws ClassCastException a long way from the cause. - Object result = deserializeMethod.invoke(handler, 20, Double.class); - assertEquals(20.0, result); - assertEquals(Double.class, result.getClass()); - } - - @Test - void testDeserializeIntegerToFloat() throws Exception { - Object result = deserializeMethod.invoke(handler, 20, Float.class); - assertEquals(20.0f, result); - assertEquals(Float.class, result.getClass()); - } - - @Test - void testDeserializeDoubleToIntegerIsNotNarrowed() throws Exception { - // The other direction is deliberately NOT converted. A decimal written - // against an int field is a config mistake, and intValue() would discard - // the fraction silently - 20.9 becoming 20 with nothing said. Left alone, - // it fails visibly instead. - Object result = deserializeMethod.invoke(handler, 20.9, Integer.class); - assertEquals(20.9, result); - assertEquals(Double.class, result.getClass()); - } - - @Test - void testDeserializeLongToDouble() throws Exception { - Object result = deserializeMethod.invoke(handler, 20L, Double.class); - assertEquals(20.0, result); - assertEquals(Double.class, result.getClass()); - } - - @Test - void testDeserializeNumberToNonNumericTypeIsUntouched() throws Exception { - // Widening must not hijack values whose target is not numeric - Object result = deserializeMethod.invoke(handler, 20, String.class); - assertEquals(20, result); - } - @Test void testDeserializeIntegerToLong() throws Exception { Object result = deserializeMethod.invoke(handler, 42, Long.class); From 09cdcaf4929c947bab3383c49d7c232a74f2e1bd Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 15:45:43 -0700 Subject: [PATCH 06/13] Add general.metrics config toggle to opt out of bStats Until now the only way to stop BentoBox submitting bStats data was the global switch in plugins/bStats/config.yml, which disables metrics for every plugin on the server. general.metrics: false (default true) skips BStats registration for BentoBox alone. All metrics call sites already go through the getMetrics() Optional, so nothing is recorded when it is off. Changing the setting requires a restart because bStats offers no unregister. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XkVFZoe4hB2Req885z5ea7 --- .../world/bentobox/bentobox/BentoBox.java | 6 +++-- .../world/bentobox/bentobox/Settings.java | 24 +++++++++++++++++++ src/main/resources/config.yml | 7 ++++++ .../world/bentobox/bentobox/SettingsTest.java | 17 +++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java index d773b1de2..9ef933fec 100644 --- a/src/main/java/world/bentobox/bentobox/BentoBox.java +++ b/src/main/java/world/bentobox/bentobox/BentoBox.java @@ -258,8 +258,10 @@ private void completeSetup(long loadTime) { flagsManager.registerListeners(); // Load metrics - metrics = new BStats(this); - metrics.registerMetrics(); + if (settings.isMetrics()) { + metrics = new BStats(this); + metrics.registerMetrics(); + } // Register Multiverse hook - MV loads AFTER BentoBox // Make sure all worlds are already registered to Multiverse. diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index bb154f801..f4213ba6d 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -44,6 +44,14 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "general.charge-for-blueprint-on-reset") private boolean chargeForBlueprintOnReset = false; + @ConfigComment("Submit anonymous, aggregate usage statistics to bStats (https://bstats.org/plugin/bukkit/BentoBox/3555).") + @ConfigComment("No personal data is ever sent - see https://github.com/BentoBoxWorld/.github/blob/master/PRIVACY.md") + @ConfigComment("Setting this to false disables metrics for BentoBox only; the global switch in") + @ConfigComment("plugins/bStats/config.yml disables bStats for every plugin on the server.") + @ConfigComment("Changing this setting requires a server restart.") + @ConfigEntry(path = "general.metrics", since = "3.22.3") + private boolean metrics = true; + /* COMMANDS */ @ConfigComment("Console commands to run when BentoBox has loaded all worlds and addons.") @ConfigComment("Commands are run as the console.") @@ -609,6 +617,22 @@ public void setChargeForBlueprintOnReset(boolean chargeForBlueprintOnReset) { this.chargeForBlueprintOnReset = chargeForBlueprintOnReset; } + /** + * @return whether anonymous usage statistics are submitted to bStats + * @since 3.22.3 + */ + public boolean isMetrics() { + return metrics; + } + + /** + * @param metrics whether anonymous usage statistics are submitted to bStats + * @since 3.22.3 + */ + public void setMetrics(boolean metrics) { + this.metrics = metrics; + } + public DatabaseType getDatabaseType() { return databaseType; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c17bb1844..87621c71f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -11,6 +11,13 @@ general: # Whether to charge the blueprint bundle cost when a player resets their island. # If false, only island creation will charge the cost. charge-for-blueprint-on-reset: false + # Submit anonymous, aggregate usage statistics to bStats (https://bstats.org/plugin/bukkit/BentoBox/3555). + # No personal data is ever sent - see https://github.com/BentoBoxWorld/.github/blob/master/PRIVACY.md + # Setting this to false disables metrics for BentoBox only; the global switch in + # plugins/bStats/config.yml disables bStats for every plugin on the server. + # Changing this setting requires a server restart. + # Added since 3.22.3. + metrics: true # Console commands to run when BentoBox has loaded all worlds and addons. # Commands are run as the console. # e.g. set aliases for worlds in Multiverse here, or anything you need to diff --git a/src/test/java/world/bentobox/bentobox/SettingsTest.java b/src/test/java/world/bentobox/bentobox/SettingsTest.java index 657503652..a6ffa144b 100644 --- a/src/test/java/world/bentobox/bentobox/SettingsTest.java +++ b/src/test/java/world/bentobox/bentobox/SettingsTest.java @@ -67,6 +67,23 @@ void testSetUseEconomy() { assertFalse(s.isUseEconomy()); } + /** + * Test method for {@link world.bentobox.bentobox.Settings#isMetrics()}. + */ + @Test + void testIsMetrics() { + assertTrue(s.isMetrics()); + } + + /** + * Test method for {@link world.bentobox.bentobox.Settings#setMetrics(boolean)}. + */ + @Test + void testSetMetrics() { + s.setMetrics(false); + assertFalse(s.isMetrics()); + } + /** * Test method for {@link world.bentobox.bentobox.Settings#getDatabaseType()}. */ From 9a484c92231dc5293430ae8a0d44a8be93c24b4a Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 16:04:09 -0700 Subject: [PATCH 07/13] Reference the new org policies from the README Links the newly merged Privacy Policy, AI Policy, and Contributing Guide (BentoBoxWorld/.github) from the contributing section, adds a Policies section, and updates the translations bullet to the native-speakers-only rule (GitLocalize is no longer used). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XkVFZoe4hB2Req885z5ea7 --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b1ffa3df8..6750776db 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,15 @@ You can contribute by: * Donating or sponsoring the developers * Coding new addons * Adopting an Addon and maintaining it -* Translating text for BentoBox and Addons (We use GitLocalize to make this easier) +* Translating text for BentoBox and Addons — native speakers only, please (see the [AI policy](https://github.com/BentoBoxWorld/.github/blob/master/AI_POLICY.md#translations)) * Submitting good bug reports or helpful feature requests * Fixing bugs and submitting Pull Requests for the fixes If you contribute code it **must be in agreement** with: * our [license](https://github.com/BentoBoxWorld/BentoBox/blob/develop/LICENSE) * our [code of conduct](https://github.com/BentoBoxWorld/.github/blob/master/CODE_OF_CONDUCT.md) -* our contribution guidelines +* our [contribution guidelines](https://github.com/BentoBoxWorld/.github/blob/master/CONTRIBUTING.md) +* our [AI policy](https://github.com/BentoBoxWorld/.github/blob/master/AI_POLICY.md) ### Report bugs and suggest features Bugs and feature requests must be filed on our [issue tracker](https://github.com/BentoBoxWorld/BentoBox/issues). @@ -92,6 +93,15 @@ Bugs and feature requests must be filed on our [issue tracker](https://github.co ### Pull requests We consider Pull Requests from non-collaborators that contain actual code improvements or bug fixes. Do not submit PRs that only address code formatting because they will not be accepted. +AI-assisted PRs are welcome — there is no restriction on using AI tools, but you must be able to +stand behind your code, at least until it is accepted. See the [AI policy](https://github.com/BentoBoxWorld/.github/blob/master/AI_POLICY.md). + +## Policies + +* [Privacy Policy](https://github.com/BentoBoxWorld/.github/blob/master/PRIVACY.md) — what anonymous usage data BentoBox submits to bStats, what is never collected, and how to opt out +* [AI Policy](https://github.com/BentoBoxWorld/.github/blob/master/AI_POLICY.md) — how the project uses AI and the rules for AI-assisted contributions +* [Contributing Guide](https://github.com/BentoBoxWorld/.github/blob/master/CONTRIBUTING.md) +* [Code of Conduct](https://github.com/BentoBoxWorld/.github/blob/master/CODE_OF_CONDUCT.md) ## API From b5214f761c050dd285b09ea5362aa73409f9b10c Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 17:03:04 -0700 Subject: [PATCH 08/13] feat: lay dialog buttons out in a grid with columns and button width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DialogBuilder could only produce Paper's default two-wide list of buttons, because it never passed a column count and never passed a button width. Anything grid-shaped — a map, a picker, a calendar — had to skip the API and build a Paper dialog by hand. Adds DialogBuilder#columns(int) for the layout and a DialogButton constructor taking a width, plus withWidth() so a button made by the locale factory can still be sized. Width applies to confirmation buttons too, which the client also sizes. Both keep the current behaviour when untouched: a dialog left at DEFAULT_COLUMNS is built without stating a column count and a button left at DEFAULT_WIDTH without stating a width, so the client goes on deciding and this API does not pin whatever default Paper uses today. Values outside what the client accepts (columns < 1, width outside 1-1024) are rejected here with a clear message rather than deeper in Paper. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017EZEwab2kL4i1FNnBYvSmp --- .../bentobox/api/dialogs/DialogBuilder.java | 62 ++++++++++++++++- .../bentobox/api/dialogs/DialogButton.java | 63 +++++++++++++++++ .../api/dialogs/DialogBuilderTest.java | 67 +++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/bentobox/api/dialogs/DialogBuilder.java b/src/main/java/world/bentobox/bentobox/api/dialogs/DialogBuilder.java index b013a9bcd..53b5f9d96 100644 --- a/src/main/java/world/bentobox/bentobox/api/dialogs/DialogBuilder.java +++ b/src/main/java/world/bentobox/bentobox/api/dialogs/DialogBuilder.java @@ -16,6 +16,7 @@ import io.papermc.paper.registry.data.dialog.body.DialogBody; import io.papermc.paper.registry.data.dialog.body.PlainMessageDialogBody; import io.papermc.paper.registry.data.dialog.type.DialogType; +import io.papermc.paper.registry.data.dialog.type.MultiActionType; import net.kyori.adventure.audience.Audience; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickCallback; @@ -45,6 +46,17 @@ * .build() * .show(user); * } + *

+ * A multi-action dialog can be laid out as a grid rather than the default two-wide + * list, by asking for {@link #columns(int) columns} and giving the buttons a + * {@link DialogButton#DialogButton(Component, Component, int, Consumer) width}: + *

{@code
+ * DialogBuilder grid = new DialogBuilder().title(user, "mygame.map.title").columns(9);
+ * for (Tile tile : tiles) {
+ *     grid.button(new DialogButton(tile.glyph(), tile.tooltip(), 26, u -> select(u, tile)));
+ * }
+ * grid.build().show(user);
+ * }
* * @author tastybento * @since 3.21.0 @@ -54,10 +66,20 @@ public class DialogBuilder { /** How long a button's server-side click callback stays valid after the dialog is shown. */ private static final Duration CALLBACK_LIFETIME = Duration.ofMinutes(10); + /** + * Columns a multi-action dialog falls into when it does not ask for a number. This is + * the client's own default, and a dialog left at it is built without stating a column + * count at all. + * + * @since 3.22.3 + */ + public static final int DEFAULT_COLUMNS = 2; + private Component title = Component.empty(); private final List body = new ArrayList<>(); private boolean escapable = true; private boolean pause = false; + private int columns = DEFAULT_COLUMNS; private DialogButton yesButton; private DialogButton noButton; @@ -160,6 +182,31 @@ public DialogBuilder button(@NonNull DialogButton button) { return this; } + /** + * Lays the buttons of a multi-action dialog out in this many columns, so they form a + * grid rather than the default two-wide list. Defaults to {@link #DEFAULT_COLUMNS}. + *

+ * How wide a grid actually fits depends on how wide its buttons are — see + * {@link DialogButton#DialogButton(Component, Component, int, Consumer) the width + * constructor}. Ask for more than the screen holds and the client squeezes the outer + * columns off it, so a map-like grid wants narrow buttons. + *

+ * Has no effect on a {@link #confirmation(DialogButton, DialogButton) confirmation} + * dialog, whose two buttons are laid out by the client. + * + * @param columns the number of columns, at least 1 + * @return this builder + * @throws IllegalArgumentException if columns is less than 1 + * @since 3.22.3 + */ + public DialogBuilder columns(int columns) { + if (columns < 1) { + throw new IllegalArgumentException("A dialog needs at least one column, not " + columns); + } + this.columns = columns; + return this; + } + /** * Builds the dialog. * @@ -178,12 +225,22 @@ public BBDialog build() { .afterAction(DialogBase.DialogAfterAction.CLOSE).body(bodyLines).build(); DialogType type = confirmation ? DialogType.confirmation(toActionButton(yesButton), toActionButton(noButton)) - : DialogType.multiAction(buttons.stream().map(this::toActionButton).toList()).build(); + : multiAction(); Dialog dialog = Dialog.create(factory -> factory.empty().base(base).type(type)); return new BBDialog(dialog); } + private MultiActionType multiAction() { + MultiActionType.Builder type = DialogType.multiAction(buttons.stream().map(this::toActionButton).toList()); + // A dialog that never asked for a column count is built without one, so the client + // keeps deciding — this API does not pin the default it happens to use today + if (columns != DEFAULT_COLUMNS) { + type.columns(columns); + } + return type.build(); + } + private ActionButton toActionButton(DialogButton button) { ActionButton.Builder b = ActionButton.builder(button.label()) .action(DialogAction.customClick((view, audience) -> runOnMainThread(button.onClick(), audience), @@ -191,6 +248,9 @@ private ActionButton toActionButton(DialogButton button) { if (button.tooltip() != null) { b.tooltip(button.tooltip()); } + if (button.width() != DialogButton.DEFAULT_WIDTH) { + b.width(button.width()); + } return b.build(); } diff --git a/src/main/java/world/bentobox/bentobox/api/dialogs/DialogButton.java b/src/main/java/world/bentobox/bentobox/api/dialogs/DialogButton.java index b6dab6450..1a86c5c91 100644 --- a/src/main/java/world/bentobox/bentobox/api/dialogs/DialogButton.java +++ b/src/main/java/world/bentobox/bentobox/api/dialogs/DialogButton.java @@ -21,8 +21,24 @@ */ public class DialogButton { + /** + * Width of a button that has not asked for one. A button left at this width is built + * without an explicit width at all, so the client lays it out however it normally + * would. + * + * @since 3.22.3 + */ + public static final int DEFAULT_WIDTH = 150; + + /** Narrowest button the client accepts */ + private static final int MIN_WIDTH = 1; + + /** Widest button the client accepts */ + private static final int MAX_WIDTH = 1024; + private final Component label; private final @Nullable Component tooltip; + private final int width; private final @Nullable Consumer onClick; /** @@ -33,8 +49,33 @@ public class DialogButton { * @param onClick the action to run when clicked, or null for a button that just closes the dialog */ public DialogButton(@NonNull Component label, @Nullable Component tooltip, @Nullable Consumer onClick) { + this(label, tooltip, DEFAULT_WIDTH, onClick); + } + + /** + * Creates a button of a given width. + *

+ * Width matters when buttons are laid out in a grid with + * {@link DialogBuilder#columns(int)}: narrow buttons let a row hold more of them + * before the client squeezes the outer columns off the screen. + * + * @param label the button label, not null + * @param tooltip the hover tooltip, or null for none + * @param width the button width, 1 to 1024, or {@link #DEFAULT_WIDTH} to leave it to + * the client + * @param onClick the action to run when clicked, or null for a button that just closes the dialog + * @throws IllegalArgumentException if the width is outside 1 to 1024 + * @since 3.22.3 + */ + public DialogButton(@NonNull Component label, @Nullable Component tooltip, int width, + @Nullable Consumer onClick) { + if (width < MIN_WIDTH || width > MAX_WIDTH) { + throw new IllegalArgumentException( + "Button width must be between " + MIN_WIDTH + " and " + MAX_WIDTH + ", not " + width); + } this.label = label; this.tooltip = tooltip; + this.width = width; this.onClick = onClick; } @@ -63,6 +104,20 @@ public static DialogButton of(@NonNull User user, @NonNull String reference, @Nu return new DialogButton(Util.parseMiniMessageOrLegacy(user.getTranslation(reference)), onClick); } + /** + * Returns a copy of this button at the given width, so a button made by + * {@link #of(User, String, Consumer)} can still be sized. + * + * @param width the button width, 1 to 1024 + * @return a new button, identical but for its width + * @throws IllegalArgumentException if the width is outside 1 to 1024 + * @since 3.22.3 + */ + @NonNull + public DialogButton withWidth(int width) { + return new DialogButton(label, tooltip, width, onClick); + } + /** * @return the button label */ @@ -79,6 +134,14 @@ public Component tooltip() { return tooltip; } + /** + * @return the button width, or {@link #DEFAULT_WIDTH} if none was asked for + * @since 3.22.3 + */ + public int width() { + return width; + } + /** * @return the action to run when the button is clicked, or null if none */ diff --git a/src/test/java/world/bentobox/bentobox/api/dialogs/DialogBuilderTest.java b/src/test/java/world/bentobox/bentobox/api/dialogs/DialogBuilderTest.java index 712dfab09..f289f0826 100644 --- a/src/test/java/world/bentobox/bentobox/api/dialogs/DialogBuilderTest.java +++ b/src/test/java/world/bentobox/bentobox/api/dialogs/DialogBuilderTest.java @@ -1,11 +1,13 @@ package world.bentobox.bentobox.api.dialogs; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -88,4 +90,69 @@ void testBuildNoButtonsThrows() { DialogBuilder builder = new DialogBuilder().title(Component.text("Empty")); assertThrows(IllegalStateException.class, builder::build); } + + /** + * Test method for {@link DialogButton#width()}. A button that never asks for a width + * reports the default, which is the signal to build it without one. + */ + @Test + void testDialogButtonDefaultWidth() { + assertEquals(DialogButton.DEFAULT_WIDTH, new DialogButton(Component.text("Go"), null).width()); + assertEquals(DialogButton.DEFAULT_WIDTH, + DialogButton.of(user, "general.buttons.confirm", null).width()); + } + + /** + * Test method for {@link DialogButton#DialogButton(Component, Component, int, Consumer)}. + */ + @Test + void testDialogButtonKeepsItsWidth() { + DialogButton b = new DialogButton(Component.text("*"), Component.text("tip"), 24, null); + assertEquals(24, b.width()); + assertNotNull(b.tooltip()); + } + + /** + * Test method for {@link DialogButton#withWidth(int)} - a button from the locale + * factory can still be sized, and the copy keeps everything else. + */ + @Test + void testWithWidthCopiesTheRest() { + AtomicReference clicked = new AtomicReference<>(); + DialogButton original = new DialogButton(Component.text("Go"), Component.text("tip"), clicked::set); + DialogButton narrow = original.withWidth(30); + assertEquals(30, narrow.width()); + assertSame(original.label(), narrow.label()); + assertSame(original.tooltip(), narrow.tooltip()); + assertSame(original.onClick(), narrow.onClick()); + // The original is untouched + assertEquals(DialogButton.DEFAULT_WIDTH, original.width()); + narrow.onClick().accept(user); + assertSame(user, clicked.get()); + } + + /** + * Test method for {@link DialogButton} widths outside what the client accepts. + */ + @Test + void testDialogButtonWidthOutOfRangeThrows() { + Component label = Component.text("*"); + assertThrows(IllegalArgumentException.class, () -> new DialogButton(label, null, 0, null)); + assertThrows(IllegalArgumentException.class, () -> new DialogButton(label, null, -5, null)); + assertThrows(IllegalArgumentException.class, () -> new DialogButton(label, null, 1025, null)); + // The ends of the range are fine + assertEquals(1, new DialogButton(label, null, 1, null).width()); + assertEquals(1024, new DialogButton(label, null, 1024, null).width()); + } + + /** + * Test method for {@link DialogBuilder#columns(int)}. + */ + @Test + void testColumnsIsFluentAndValidated() { + DialogBuilder builder = new DialogBuilder().title(Component.text("Grid")); + assertSame(builder, builder.columns(13)); + assertThrows(IllegalArgumentException.class, () -> builder.columns(0)); + assertThrows(IllegalArgumentException.class, () -> builder.columns(-1)); + } } From 34999f68a551c0c757c7a68818fa53c92ebbc642 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 18:21:47 -0700 Subject: [PATCH 09/13] build: compile against Paper 26.2 now MockBukkit supports it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Paper pin sat at the 26.1.2 dev bundle for one reason: MockBukkit had no 26.2 artifact, so the API under test could not match the API compiled against. MockBukkit 4.116.1 ships mockbukkit-v26.2, built against 26.2.build.111-stable, so the reason is gone. 26.2 brings Adventure 5, which needed three test fixes: - ClickEvent is now generic and carries a typed payload, so click.value() becomes click.payload() as ClickEvent.Payload.Text. - Component is sealed, so Mockito can no longer mock it. Two tests passed a mocked Component as a join/quit message; they use a real empty component now. Main code compiled unchanged. Whole suite green at 3462 tests. Note for the release: those two are exactly what an addon will hit if it recompiles. A survey of 59 local addon repos found none affected — 41 never touch Adventure, and of the 18 that do, none call anything Adventure 5 removed — but third-party addons calling ClickEvent.value() or mocking Component in their tests will need the same edits. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017EZEwab2kL4i1FNnBYvSmp --- build.gradle.kts | 17 ++++++++--------- .../bentobox/listeners/BlockEndDragonTest.java | 3 ++- .../listeners/JoinLeaveListenerTest.java | 4 ++-- .../suggestions/DidYouMeanScenarioTest.java | 7 +++++-- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index e2bb12acc..39e410d02 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -89,19 +89,18 @@ val javaVersion = "25" val junitVersion = "5.10.2" val mockitoVersion = "5.11.0" // MockBukkit's modern, per-Minecraft-version artifacts are published to Paper's Maven repo -// under org.mockbukkit.mockbukkit (see the testImplementation coordinate below). The closest -// available to Paper 26.2 is the 26.1.2 line (no 26.2 build exists yet); 4.113.2 is the latest. -val mockBukkitVersion = "4.113.2" +// under org.mockbukkit.mockbukkit (see the testImplementation coordinate below). 4.116.1 is the +// first release with a 26.2 artifact, built against 26.2.build.111-stable. +val mockBukkitVersion = "4.116.1" val mongodbVersion = "3.12.12" val mariadbVersion = "3.0.5" val mysqlVersion = "8.0.27" val postgresqlVersion = "42.2.18" val hikaricpVersion = "5.0.1" -// Compile against the latest stable 26.1.2 dev bundle. This is the newest Paper API that has a -// matching MockBukkit release (mockbukkit-v26.1.2); MockBukkit does not yet support 26.2's new -// registries. Minecraft 26.2 is still fully supported at runtime (see ServerCompatibility and -// the Modrinth game-versions list); 26.2-only blocks/entities are accessed via Enums.getIfPresent. -val paperVersion = "26.1.2.build.72-stable" +// Compile against the 26.2 dev bundle MockBukkit 4.116.1 was built against, so the API under +// test and the API compiled against are the same. Note 26.2 brings Adventure 5, which makes +// ClickEvent generic (payload() rather than value()) and seals Component so it cannot be mocked. +val paperVersion = "26.2.build.111-stable" val bstatsVersion = "3.0.0" val vaultVersion = "1.7.1" val levelVersion = "2.21.3" @@ -257,7 +256,7 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher:$junitVersion") testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion") testImplementation("org.mockito:mockito-core:$mockitoVersion") - testImplementation("org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:$mockBukkitVersion") + testImplementation("org.mockbukkit.mockbukkit:mockbukkit-v26.2:$mockBukkitVersion") testImplementation("org.awaitility:awaitility:$awaitilityVersion") testImplementation("io.papermc.paper:paper-api:$paperVersion") testImplementation("com.github.MilkBowl:VaultAPI:$vaultVersion") diff --git a/src/test/java/world/bentobox/bentobox/listeners/BlockEndDragonTest.java b/src/test/java/world/bentobox/bentobox/listeners/BlockEndDragonTest.java index 0e2106334..ffd2f15c5 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/BlockEndDragonTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/BlockEndDragonTest.java @@ -131,7 +131,8 @@ void testOnPlayerChangeWorldNoFlag() { */ @Test void testOnPlayerJoinWorld() { - Component component = mock(Component.class); + // Adventure 5 seals Component, so it cannot be mocked + Component component = Component.empty(); PlayerJoinEvent event = new PlayerJoinEvent(mockPlayer, component); bed.onPlayerJoinWorld(event); verify(block).setType(Material.END_PORTAL, false); diff --git a/src/test/java/world/bentobox/bentobox/listeners/JoinLeaveListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/JoinLeaveListenerTest.java index 4152e68db..722cddc8a 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/JoinLeaveListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/JoinLeaveListenerTest.java @@ -87,8 +87,8 @@ class JoinLeaveListenerTest extends RanksManagerTestSetup { private AddonDescription desc; - @Mock - private Component component; + // Adventure 5 seals Component, so join/quit messages are real components now + private final Component component = Component.empty(); @Override @BeforeEach diff --git a/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java b/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java index 99fa899b2..3cc119f94 100644 --- a/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java +++ b/src/test/java/world/bentobox/bentobox/suggestions/DidYouMeanScenarioTest.java @@ -314,8 +314,11 @@ private void assertClickRuns(String command) { } private boolean hasClickRunning(Component component, String command) { - ClickEvent click = component.clickEvent(); - if (click != null && click.action() == ClickEvent.Action.RUN_COMMAND && command.equals(click.value())) { + // Adventure 5 made the click event generic: what used to be a bare string value is + // now a typed payload + ClickEvent click = component.clickEvent(); + if (click != null && click.action() == ClickEvent.Action.RUN_COMMAND + && click.payload() instanceof ClickEvent.Payload.Text text && command.equals(text.value())) { return true; } return component.children().stream().anyMatch(child -> hasClickRunning(child, command)); From db17240ff3c349392ee625c0cec613d2d691bf18 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 18:32:48 -0700 Subject: [PATCH 10/13] fix: widen YAML-loaded numbers to the declared numeric field type YAML types a number by how it is written: 20 loads as Integer and 20.0 as Double, regardless of the declared field type. A double field written without a decimal point reached the setter as an Integer; inside a collection, generic erasure hid the mismatch until the first read threw ClassCastException far from the cause. Replace the ad-hoc Integer-to-Long promotion in deserialize() with a widen() helper covering Long, Double and Float targets. Widening only: a decimal written against an int or long field is a config mistake, so it is left alone to fail visibly rather than silently truncated (the long branch is gated on integral sources). The float switch arm in deserializeValue now accepts the boxed Float that widen() produces via Number.floatValue() instead of casting to Double, which would throw ClassCastException for every primitive float field. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XkVFZoe4hB2Req885z5ea7 --- .../database/yaml/YamlDatabaseHandler.java | 58 +++++++++++++-- .../yaml/YamlDatabaseHandlerTest.java | 74 +++++++++++++++++++ 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java b/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java index 9d8a5445d..b12f3f933 100644 --- a/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java +++ b/src/main/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandler.java @@ -222,11 +222,7 @@ private void deserializeValue(Method method, T instance, PropertyDescriptor prop // Floats need special handling because the database returns them as doubles Type setType = propertyDescriptor.getWriteMethod().getGenericParameterTypes()[0]; switch (setType.getTypeName()) { - case "float" -> { - double d = (double) setTo; - float f = (float) d; - method.invoke(instance, f); - } + case "float" -> method.invoke(instance, ((Number) setTo).floatValue()); case "org.bukkit.Sound" -> { Sound s = Registry.SOUNDS .get(NamespacedKey.fromString(((String) setTo).toLowerCase(Locale.ENGLISH))); @@ -686,9 +682,17 @@ private Object deserialize(Object value, Class clazz) { if (clazz.equals(value.getClass())) { return value; } - // Integer to Long promotion - if (clazz.equals(Long.class) && value.getClass().equals(Integer.class)) { - return Long.valueOf((Integer) value); + // Numeric widening. YAML types a number by how it is written, so "20" + // loads as Integer and "20.0" as Double - but a config field declared + // double is perfectly entitled to be written without a decimal point. + // Without this, such a value reaches the setter as an Integer; inside a + // collection, generic erasure means nothing complains until the first + // read throws ClassCastException, a long way from the cause. + if (value instanceof Number number) { + Object widened = widen(number, clazz); + if (widened != null) { + return widened; + } } // String-based conversions if (value instanceof String stringValue) { @@ -704,6 +708,44 @@ private Object deserialize(Object value, Class clazz) { return value; } + /** + * Widen a YAML-loaded number to the field's declared numeric type, or null if + * the target is not a numeric type this handles. + *

+ * Widening only. There is deliberately no case for {@code int}, and the + * {@code long} case only accepts integral sources: a value written with a + * decimal point against an integer field is a mistake in the config, and + * narrowing it would silently discard the fraction. Left alone, it fails + * visibly instead. Double to float is the one accepted narrowing, because + * YAML always types decimals as Double and a float field must still load. + * + * @param number the value as YAML typed it + * @param clazz the declared type + * @return the converted value, or null to leave it alone + */ + @Nullable + private Object widen(Number number, Class clazz) { + if ((clazz.equals(Long.class) || clazz.equals(long.class)) && isIntegral(number)) { + return number.longValue(); + } + if (clazz.equals(Double.class) || clazz.equals(double.class)) { + return number.doubleValue(); + } + if (clazz.equals(Float.class) || clazz.equals(float.class)) { + return number.floatValue(); + } + return null; + } + + /** + * @param number a YAML-loaded number + * @return true if the value carries no fraction to lose, i.e. YAML typed it as an integer + */ + private boolean isIntegral(Number number) { + return number instanceof Byte || number instanceof Short || number instanceof Integer + || number instanceof Long; + } + /** * Deserialize a string value into the target class type. * Handles numeric types, UUID, Location, and World. diff --git a/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java b/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java index 418c8dc6c..d03130448 100644 --- a/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java +++ b/src/test/java/world/bentobox/bentobox/database/yaml/YamlDatabaseHandlerTest.java @@ -50,6 +50,7 @@ public static class TestDataObject implements DataObject { private String uniqueId = "test"; private String name = ""; private int count = 0; + private float speed = 0f; private Map scores = new HashMap<>(); private Set tags = new HashSet<>(); private List items = new java.util.ArrayList<>(); @@ -63,6 +64,8 @@ public static class TestDataObject implements DataObject { public void setName(String name) { this.name = name; } public int getCount() { return count; } public void setCount(int count) { this.count = count; } + public float getSpeed() { return speed; } + public void setSpeed(float speed) { this.speed = speed; } public Map getScores() { return scores; } public void setScores(Map scores) { this.scores = scores; } public Set getTags() { return tags; } @@ -117,6 +120,57 @@ void testDeserializeSameClassReturnsValue() throws Exception { assertSame(value, deserializeMethod.invoke(handler, value, String.class)); } + @Test + void testDeserializeIntegerToDouble() throws Exception { + // YAML types a number by how it is WRITTEN: "20" loads as Integer even + // where the field is a double. Without widening, that Integer reaches the + // setter, and inside a collection generic erasure hides it until the first + // read throws ClassCastException a long way from the cause. + Object result = deserializeMethod.invoke(handler, 20, Double.class); + assertEquals(20.0, result); + assertEquals(Double.class, result.getClass()); + } + + @Test + void testDeserializeIntegerToFloat() throws Exception { + Object result = deserializeMethod.invoke(handler, 20, Float.class); + assertEquals(20.0f, result); + assertEquals(Float.class, result.getClass()); + } + + @Test + void testDeserializeDoubleToIntegerIsNotNarrowed() throws Exception { + // The other direction is deliberately NOT converted. A decimal written + // against an int field is a config mistake, and intValue() would discard + // the fraction silently - 20.9 becoming 20 with nothing said. Left alone, + // it fails visibly instead. + Object result = deserializeMethod.invoke(handler, 20.9, Integer.class); + assertEquals(20.9, result); + assertEquals(Double.class, result.getClass()); + } + + @Test + void testDeserializeDoubleToLongIsNotNarrowed() throws Exception { + // Same principle for long fields: 5.5 must not silently become 5L + Object result = deserializeMethod.invoke(handler, 5.5, Long.class); + assertEquals(5.5, result); + assertEquals(Double.class, result.getClass()); + } + + @Test + void testDeserializeLongToDouble() throws Exception { + Object result = deserializeMethod.invoke(handler, 20L, Double.class); + assertEquals(20.0, result); + assertEquals(Double.class, result.getClass()); + } + + @Test + void testDeserializeNumberToNonNumericTypeIsUntouched() throws Exception { + // Widening must not hijack values whose target is not numeric + Object result = deserializeMethod.invoke(handler, 20, String.class); + assertEquals(20, result); + } + @Test void testDeserializeIntegerToLong() throws Exception { Object result = deserializeMethod.invoke(handler, 42, Long.class); @@ -402,6 +456,26 @@ void testLoadObjectWithCollections() throws Exception { assertEquals(3, result.getItems().size()); } + @Test + void testLoadObjectPrimitiveFloatField() throws Exception { + // A primitive float field must load whether the admin wrote the value + // with a decimal point (YAML types it Double) or without (Integer). + // Exercises the "float" arm in deserializeValue, which deserialize() + // now feeds a boxed Float rather than the raw YAML Double. + YamlConfiguration config = new YamlConfiguration(); + config.set("uniqueId", "float-test"); + config.set("speed", 2.5); + when(connector.loadYamlFile(anyString(), eq("float-test"))).thenReturn(config); + + TestDataObject result = handler.loadObject("float-test"); + assertNotNull(result); + assertEquals(2.5f, result.getSpeed()); + + config.set("speed", 3); + result = handler.loadObject("float-test"); + assertEquals(3.0f, result.getSpeed()); + } + @Test void testLoadObjectMissingFieldUsesDefault() throws Exception { YamlConfiguration config = new YamlConfiguration(); From 5f1e6c6c0c631a5df1e2de4b1eaa418390462288 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 18:35:33 -0700 Subject: [PATCH 11/13] refactor: drop the workarounds for 26.2 symbols missing at compile time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the build compiles against 26.2 (#3067), EntityType.SULFUR_CUBE is a real compile-time symbol, so the code that worked around its absence can go. The three SULFUR_CUBE fields go back to static final. They were left non-final, with a Sonar suppression each, only so tests could reflectively inject a stand-in — the JVM constant-folds static final fields, which defeated that. The tests no longer need the trick: they use EntityType.SULFUR_CUBE directly and the getStaticField / setStaticField helpers and MAGMA_CUBE stand-ins are gone with it. Also corrects .claude/rules/build-toolchain.md, which still described the old 26.1.2 compile target, told readers to use the mockbukkit-v26.1.2 coordinate, and said this work was parked in a draft PR. 3462 tests, no failures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017EZEwab2kL4i1FNnBYvSmp --- .claude/rules/build-toolchain.md | 4 +- .../flags/protection/BucketListener.java | 6 +- .../protection/EntityInteractListener.java | 6 +- .../world/bentobox/bentobox/util/Util.java | 6 +- .../flags/protection/BucketListenerTest.java | 80 ++++++------------- .../EntityInteractListenerTest.java | 74 ++++++----------- .../bentobox/bentobox/util/UtilTest.java | 41 +++------- 7 files changed, 65 insertions(+), 152 deletions(-) diff --git a/.claude/rules/build-toolchain.md b/.claude/rules/build-toolchain.md index 32e57de13..4ea3a1740 100644 --- a/.claude/rules/build-toolchain.md +++ b/.claude/rules/build-toolchain.md @@ -11,5 +11,5 @@ Supporting Minecraft 26.x forced a chain of build changes — keep these in mind - **Java 25.** The 26.x `paper-api` is Java 25 bytecode and its Gradle metadata requires consumers to target Java 25, so BentoBox now compiles to Java 25 (`javaVersion = "25"`, `options.release = 25`). **Addons that compile against BentoBox must also move to Java 25.** - **paperweight `2.0.0-SNAPSHOT`.** All 26.x dev bundles are dev-bundle *data version 8*, which no released paperweight (`<= 2.0.0-beta.21`) can read. The snapshot is resolved via a `pluginManagement` block in `settings.gradle.kts` pointing at Paper's repo. The paperweight tool launcher is pinned to Java 25 (the 26.1+ paperclip patch step requires it). Revisit once a stable paperweight reads data-version-8 bundles. -- **Compile target vs. runtime support.** `paperVersion` is the latest **stable 26.1.2** dev bundle, not 26.2 — because MockBukkit has no 26.2 build and its registry mock throws on 26.2's new `minecraft:sulfur_cube_archetype` registry. Minecraft **26.2 is supported at runtime** (see `ServerCompatibility` and the Modrinth `game-versions` list); 26.2-only blocks/entities are referenced via `Enums.getIfPresent(...)` by name, never a compile-time symbol. The forward "compile against literal 26.2" work is parked in a draft PR until MockBukkit ships a 26.2 build. -- **MockBukkit coordinate.** Tests use `org.mockbukkit.mockbukkit:mockbukkit-v26.1.2:` (from Paper's repo), which **must match `paperVersion`'s MC line** — a mismatched MockBukkit fails every test at init with `InternalDataLoadException` (it validates the live API's registries against its bundled per-version data). When bumping the MC version, bump both together. +- **Compile target.** `paperVersion` is the latest **stable 26.2** dev bundle, so 26.2 symbols (`EntityType.SULFUR_CUBE`, new materials) are available at compile time. This became possible when MockBukkit 4.116.1 shipped its `mockbukkit-v26.2` artifact (earlier MockBukkit threw on 26.2's new `minecraft:sulfur_cube_archetype` registry). +- **MockBukkit coordinate.** Tests use `org.mockbukkit.mockbukkit:mockbukkit-v26.2:` (from Paper's repo), which **must match `paperVersion`'s MC line** — a mismatched MockBukkit fails every test at init with `InternalDataLoadException` (it validates the live API's registries against its bundled per-version data). When bumping the MC version, bump both together. diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/BucketListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/BucketListener.java index d2c954d1f..01ba3a172 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/BucketListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/BucketListener.java @@ -29,11 +29,9 @@ public class BucketListener extends FlagListener { /** * The Sulfur Cube entity type (Minecraft 26.2), resolved at runtime so the code still - * compiles against earlier API versions. {@code null} when absent. Non-final so tests can - * inject a stand-in type (the JVM constant-folds {@code static final} fields). + * compiles against earlier API versions. {@code null} when absent. */ - @SuppressWarnings("java:S3008") // non-final by design; see Javadoc (test injection) - private static EntityType SULFUR_CUBE = Enums.getIfPresent(EntityType.class, "SULFUR_CUBE") + private static final EntityType SULFUR_CUBE = Enums.getIfPresent(EntityType.class, "SULFUR_CUBE") .orNull(); /** diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/EntityInteractListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/EntityInteractListener.java index 55d104c92..8f28c7991 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/EntityInteractListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/EntityInteractListener.java @@ -37,11 +37,9 @@ public class EntityInteractListener extends FlagListener { /** * The Sulfur Cube entity type (Minecraft 26.2), resolved at runtime so the code still - * compiles against earlier API versions. {@code null} when absent. Non-final so tests can - * inject a stand-in type (the JVM constant-folds {@code static final} fields). + * compiles against earlier API versions. {@code null} when absent. */ - @SuppressWarnings("java:S3008") // non-final by design; see Javadoc (test injection) - private static EntityType SULFUR_CUBE = Enums.getIfPresent(EntityType.class, "SULFUR_CUBE") + private static final EntityType SULFUR_CUBE = Enums.getIfPresent(EntityType.class, "SULFUR_CUBE") .orNull(); @EventHandler(priority = EventPriority.LOW, ignoreCancelled=true) diff --git a/src/main/java/world/bentobox/bentobox/util/Util.java b/src/main/java/world/bentobox/bentobox/util/Util.java index 663f579cb..0de0cfd32 100644 --- a/src/main/java/world/bentobox/bentobox/util/Util.java +++ b/src/main/java/world/bentobox/bentobox/util/Util.java @@ -93,12 +93,8 @@ public class Util { * The Sulfur Cube entity type (Minecraft 26.2), resolved at runtime so the code still * compiles against earlier API versions where the constant does not exist. {@code null} * when absent, in which case the {@code ==} comparisons against it are simply false. - * Intentionally non-final: assigned once at class load, but left non-final so tests can - * inject a stand-in type (the JVM constant-folds {@code static final} fields, defeating - * reflective injection). */ - @SuppressWarnings("java:S3008") // non-final by design; see Javadoc (test injection) - private static EntityType SULFUR_CUBE = Enums.getIfPresent(EntityType.class, "SULFUR_CUBE") + private static final EntityType SULFUR_CUBE = Enums.getIfPresent(EntityType.class, "SULFUR_CUBE") .orNull(); /** diff --git a/src/test/java/world/bentobox/bentobox/listeners/flags/protection/BucketListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/flags/protection/BucketListenerTest.java index 6a04f7fcb..8535cf808 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/flags/protection/BucketListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/flags/protection/BucketListenerTest.java @@ -252,68 +252,40 @@ void testOnTropicalFishScoopingFishWaterBucketNotAllowed() { /** * A Sulfur Cube (Minecraft 26.2) is picked up with an empty bucket; this is blocked by the - * BUCKET flag when not allowed. The 26.2 EntityType constant is absent in the test API, so - * MAGMA_CUBE is injected as a stand-in for SULFUR_CUBE. + * BUCKET flag when not allowed. */ @Test - void testOnSulfurCubeBucketingNotAllowed() throws Exception { + void testOnSulfurCubeBucketingNotAllowed() { when(island.isAllowed(any(), any())).thenReturn(false); - EntityType standIn = EntityType.MAGMA_CUBE; - Object previous = getStaticField(BucketListener.class, "SULFUR_CUBE"); - setStaticField(BucketListener.class, "SULFUR_CUBE", standIn); - try { - Entity cube = mock(Entity.class); - when(cube.getLocation()).thenReturn(location); - when(cube.getType()).thenReturn(standIn); - PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(mockPlayer, cube); - PlayerInventory inv = mock(PlayerInventory.class); - ItemStack item = mock(ItemStack.class); - when(item.getType()).thenReturn(Material.BUCKET); - when(inv.getItemInMainHand()).thenReturn(item); - when(mockPlayer.getInventory()).thenReturn(inv); - l.onTropicalFishScooping(e); - assertTrue(e.isCancelled()); - verify(notifier).notify(any(), eq("protection.protected")); - } finally { - setStaticField(BucketListener.class, "SULFUR_CUBE", previous); - } + Entity cube = mock(Entity.class); + when(cube.getLocation()).thenReturn(location); + when(cube.getType()).thenReturn(EntityType.SULFUR_CUBE); + PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(mockPlayer, cube); + PlayerInventory inv = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.BUCKET); + when(inv.getItemInMainHand()).thenReturn(item); + when(mockPlayer.getInventory()).thenReturn(inv); + l.onTropicalFishScooping(e); + assertTrue(e.isCancelled()); + verify(notifier).notify(any(), eq("protection.protected")); } /** * Bucketing a Sulfur Cube is allowed when the island permits the BUCKET flag. */ @Test - void testOnSulfurCubeBucketingAllowed() throws Exception { - EntityType standIn = EntityType.MAGMA_CUBE; - Object previous = getStaticField(BucketListener.class, "SULFUR_CUBE"); - setStaticField(BucketListener.class, "SULFUR_CUBE", standIn); - try { - Entity cube = mock(Entity.class); - when(cube.getLocation()).thenReturn(location); - when(cube.getType()).thenReturn(standIn); - PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(mockPlayer, cube); - PlayerInventory inv = mock(PlayerInventory.class); - ItemStack item = mock(ItemStack.class); - when(item.getType()).thenReturn(Material.BUCKET); - when(inv.getItemInMainHand()).thenReturn(item); - when(mockPlayer.getInventory()).thenReturn(inv); - l.onTropicalFishScooping(e); - assertFalse(e.isCancelled()); - } finally { - setStaticField(BucketListener.class, "SULFUR_CUBE", previous); - } - } - - private static Object getStaticField(Class clazz, String name) throws Exception { - java.lang.reflect.Field f = clazz.getDeclaredField(name); - f.setAccessible(true); - return f.get(null); - } - - @SuppressWarnings("java:S3011") - private static void setStaticField(Class clazz, String name, Object value) throws Exception { - java.lang.reflect.Field f = clazz.getDeclaredField(name); - f.setAccessible(true); - f.set(null, value); + void testOnSulfurCubeBucketingAllowed() { + Entity cube = mock(Entity.class); + when(cube.getLocation()).thenReturn(location); + when(cube.getType()).thenReturn(EntityType.SULFUR_CUBE); + PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(mockPlayer, cube); + PlayerInventory inv = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.BUCKET); + when(inv.getItemInMainHand()).thenReturn(item); + when(mockPlayer.getInventory()).thenReturn(inv); + l.onTropicalFishScooping(e); + assertFalse(e.isCancelled()); } } diff --git a/src/test/java/world/bentobox/bentobox/listeners/flags/protection/EntityInteractListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/flags/protection/EntityInteractListenerTest.java index 2cd33e60b..0c6470887 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/flags/protection/EntityInteractListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/flags/protection/EntityInteractListenerTest.java @@ -399,66 +399,38 @@ void testOnPlayerInteractEntityCopperGolemNameTagNoInteraction() { /** * Giving a block to a Sulfur Cube (Minecraft 26.2) for it to absorb is treated as placing a - * block and must be blocked by the PLACE_BLOCKS flag when not allowed. The 26.2 EntityType - * constant is absent in the test API, so MAGMA_CUBE is injected as a stand-in for SULFUR_CUBE. + * block and must be blocked by the PLACE_BLOCKS flag when not allowed. */ @Test - void testOnPlayerInteractEntitySulfurCubeBlockAbsorptionNotAllowed() throws Exception { - EntityType standIn = EntityType.MAGMA_CUBE; - Object previous = getStaticField(EntityInteractListener.class, "SULFUR_CUBE"); - setStaticField(EntityInteractListener.class, "SULFUR_CUBE", standIn); - try { - clickedEntity = mock(Entity.class); - when(clickedEntity.getLocation()).thenReturn(location); - when(clickedEntity.getType()).thenReturn(standIn); - ItemStack block = mock(ItemStack.class); - when(block.getType()).thenReturn(Material.STONE); - when(inv.getItemInMainHand()).thenReturn(block); - PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(mockPlayer, clickedEntity, hand); - eil.onPlayerInteractEntity(e); - verify(notifier).notify(any(), eq("protection.protected")); - assertTrue(e.isCancelled()); - } finally { - setStaticField(EntityInteractListener.class, "SULFUR_CUBE", previous); - } + void testOnPlayerInteractEntitySulfurCubeBlockAbsorptionNotAllowed() { + clickedEntity = mock(Entity.class); + when(clickedEntity.getLocation()).thenReturn(location); + when(clickedEntity.getType()).thenReturn(EntityType.SULFUR_CUBE); + ItemStack block = mock(ItemStack.class); + when(block.getType()).thenReturn(Material.STONE); + when(inv.getItemInMainHand()).thenReturn(block); + PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(mockPlayer, clickedEntity, hand); + eil.onPlayerInteractEntity(e); + verify(notifier).notify(any(), eq("protection.protected")); + assertTrue(e.isCancelled()); } /** * Giving a block to a Sulfur Cube is allowed when the island permits PLACE_BLOCKS. */ @Test - void testOnPlayerInteractEntitySulfurCubeBlockAbsorptionAllowed() throws Exception { + void testOnPlayerInteractEntitySulfurCubeBlockAbsorptionAllowed() { when(island.isAllowed(any(User.class), any())).thenReturn(true); - EntityType standIn = EntityType.MAGMA_CUBE; - Object previous = getStaticField(EntityInteractListener.class, "SULFUR_CUBE"); - setStaticField(EntityInteractListener.class, "SULFUR_CUBE", standIn); - try { - clickedEntity = mock(Entity.class); - when(clickedEntity.getLocation()).thenReturn(location); - when(clickedEntity.getType()).thenReturn(standIn); - ItemStack block = mock(ItemStack.class); - when(block.getType()).thenReturn(Material.STONE); - when(inv.getItemInMainHand()).thenReturn(block); - PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(mockPlayer, clickedEntity, hand); - eil.onPlayerInteractEntity(e); - verify(notifier, never()).notify(any(), eq("protection.protected")); - assertFalse(e.isCancelled()); - } finally { - setStaticField(EntityInteractListener.class, "SULFUR_CUBE", previous); - } - } - - private static Object getStaticField(Class clazz, String name) throws Exception { - java.lang.reflect.Field f = clazz.getDeclaredField(name); - f.setAccessible(true); - return f.get(null); - } - - @SuppressWarnings("java:S3011") - private static void setStaticField(Class clazz, String name, Object value) throws Exception { - java.lang.reflect.Field f = clazz.getDeclaredField(name); - f.setAccessible(true); - f.set(null, value); + clickedEntity = mock(Entity.class); + when(clickedEntity.getLocation()).thenReturn(location); + when(clickedEntity.getType()).thenReturn(EntityType.SULFUR_CUBE); + ItemStack block = mock(ItemStack.class); + when(block.getType()).thenReturn(Material.STONE); + when(inv.getItemInMainHand()).thenReturn(block); + PlayerInteractEntityEvent e = new PlayerInteractEntityEvent(mockPlayer, clickedEntity, hand); + eil.onPlayerInteractEntity(e); + verify(notifier, never()).notify(any(), eq("protection.protected")); + assertFalse(e.isCancelled()); } } diff --git a/src/test/java/world/bentobox/bentobox/util/UtilTest.java b/src/test/java/world/bentobox/bentobox/util/UtilTest.java index caa7f3708..175d7e918 100644 --- a/src/test/java/world/bentobox/bentobox/util/UtilTest.java +++ b/src/test/java/world/bentobox/bentobox/util/UtilTest.java @@ -63,40 +63,17 @@ void testSameWorldNullSafe() { } /** - * Sulfur Cube (Minecraft 26.2) is slime-like but passive. When the SULFUR_CUBE entity type is - * present, {@link Util#isPassiveEntity(org.bukkit.entity.Entity)} must classify it as passive - * and {@link Util#isHostileEntity(org.bukkit.entity.Entity)} must not treat it as hostile, - * even though it implements {@link Slime}. - *

- * The 26.2 EntityType constant does not exist in the test API, so an existing slime-like type - * (MAGMA_CUBE) is injected into Util's resolved SULFUR_CUBE field as a stand-in. + * Sulfur Cube (Minecraft 26.2) is slime-like but passive: + * {@link Util#isPassiveEntity(org.bukkit.entity.Entity)} must classify it as passive and + * {@link Util#isHostileEntity(org.bukkit.entity.Entity)} must not treat it as hostile, even + * though it implements {@link Slime}. */ @Test - void testSulfurCubeClassifiedAsPassiveNotHostile() throws Exception { - EntityType standIn = EntityType.MAGMA_CUBE; - Object previous = getStaticField(Util.class, "SULFUR_CUBE"); - setStaticField(Util.class, "SULFUR_CUBE", standIn); - try { - Slime sulfurCube = mock(Slime.class); - when(sulfurCube.getType()).thenReturn(standIn); - assertTrue(Util.isPassiveEntity(sulfurCube)); - assertFalse(Util.isHostileEntity(sulfurCube)); - } finally { - setStaticField(Util.class, "SULFUR_CUBE", previous); - } - } - - private static Object getStaticField(Class clazz, String name) throws Exception { - java.lang.reflect.Field f = clazz.getDeclaredField(name); - f.setAccessible(true); - return f.get(null); - } - - @SuppressWarnings("java:S3011") - private static void setStaticField(Class clazz, String name, Object value) throws Exception { - java.lang.reflect.Field f = clazz.getDeclaredField(name); - f.setAccessible(true); - f.set(null, value); + void testSulfurCubeClassifiedAsPassiveNotHostile() { + Slime sulfurCube = mock(Slime.class); + when(sulfurCube.getType()).thenReturn(EntityType.SULFUR_CUBE); + assertTrue(Util.isPassiveEntity(sulfurCube)); + assertFalse(Util.isHostileEntity(sulfurCube)); } // ---- blockFaceToFloat ---- From cb36be7ef105c9af960e65c62a5ea818acd52972 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 20:00:05 -0700 Subject: [PATCH 12/13] docs: publish JSON Schemas for the blueprint file formats Adds draft 2020-12 schemas for .blueprint files and blueprint bundle JSON files, covering blocks, spawners (including trial spawners), entities, display entities, item frames, and the Gson serialization quirks (vector-keyed pair arrays, YAML-encoded ItemStacks). Usable for editor validation and CI. The human-readable specification is on the docs site (Blueprint File Format). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XkVFZoe4hB2Req885z5ea7 --- schemas/README.md | 25 ++ schemas/blueprint-bundle.schema.json | 84 ++++ schemas/blueprint.schema.json | 579 +++++++++++++++++++++++++++ 3 files changed, 688 insertions(+) create mode 100644 schemas/README.md create mode 100644 schemas/blueprint-bundle.schema.json create mode 100644 schemas/blueprint.schema.json diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 000000000..8603d2682 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,25 @@ +# Blueprint JSON Schemas + +Machine-readable [JSON Schema](https://json-schema.org/) (draft 2020-12) definitions of BentoBox's on-disk blueprint formats: + +- **`blueprint.schema.json`** — validates a `.blueprint` file (a single Blueprint object) or a bundle file. Also contains the shared `$defs` for blocks, spawners, entities, and display entities. +- **`blueprint-bundle.schema.json`** — validates a blueprint bundle file (`.json` in a game mode's `blueprints/` folder) on its own. + +The human-readable specification lives in the docs: [Blueprint File Format](https://docs.bentobox.world/en/latest/BentoBox/Blueprint-Format/). + +## Validating a file + +With [ajv](https://ajv.js.org/): + +```bash +ajv validate --spec=draft2020 -s schemas/blueprint.schema.json -d island.blueprint +ajv validate --spec=draft2020 -s schemas/blueprint-bundle.schema.json -d default.json +``` + +Editors that support JSON Schema (VS Code, IntelliJ) can associate `*.blueprint` and bundle files with these schemas for inline validation and completion. + +## Caveats + +- ItemStacks are stored as YAML documents inside JSON strings (Bukkit `ConfigurationSerializable`); the schema treats them as opaque strings, so a schema-valid file can still fail to load if an embedded YAML document is malformed. +- Legacy `.blu` files are ZIP archives whose single entry is JSON that validates against `blueprint.schema.json`. +- The schemas describe what the current serializer emits. Keep them in sync with the `world.bentobox.bentobox.blueprints.dataobjects` classes when `@Expose`d fields change. diff --git a/schemas/blueprint-bundle.schema.json b/schemas/blueprint-bundle.schema.json new file mode 100644 index 000000000..080f0f691 --- /dev/null +++ b/schemas/blueprint-bundle.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bentobox.world/schemas/blueprint-bundle.schema.json", + "title": "BentoBox Blueprint Bundle Format", + "description": "Schema describing the on-disk JSON format of a BentoBox blueprint bundle file.\n\nA bundle groups up to three `.blueprint` files — one per world environment (overworld / nether / end) — into a single option that appears in the island-creation UI. It also controls the cost, permission, GUI slot, usage cap, and post-creation commands for that option.\n\nFILE FORMAT:\n - Plain UTF-8 JSON.\n - One bundle per file. Filename MUST be `.json` (the stem matches the `uniqueId` field).\n - Location: the `blueprints/` folder of a game-mode addon (e.g. `plugins/BentoBox/addons/BSkyBlock/blueprints/default.json`). The referenced blueprints live alongside as `.blueprint` files and are validated by the sibling `blueprint.schema.json`.\n\nSERIALIZATION NOTES (Gson-based):\n - Only fields explicitly tagged `@Expose` in the Java source are emitted; producers must not emit other keys.\n - The `blueprints` map is keyed by the Java `World.Environment` enum and is written as a normal JSON object with the enum NAME (e.g. `NORMAL`) as the key.\n - Pretty-printing is enabled by the writer; consumers must not rely on whitespace.", + "type": "object", + "properties": { + "uniqueId": { + "description": "Unique identifier for this bundle. Must equal the filename stem (e.g. `default` for `default.json`). Used as the permission suffix when `requirePermission` is true: `.island.create.`.", + "type": "string", + "minLength": 1 + }, + "displayName": { + "description": "Human-readable name shown in the selection GUI. May contain legacy `§` colour codes or MiniMessage tags per locale conventions.", + "type": "string" + }, + "icon": { + "description": "Icon material. One of: a plain Bukkit Material enum name (e.g. `DIAMOND`), a vanilla namespaced key (e.g. `minecraft:diamond`), or a resource-pack custom model key (e.g. `myserver:island_tropical`). Default: `PAPER`.", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "Lore lines shown under the icon. One string per line.", + "type": "array", + "items": { "type": "string" } + }, + "blueprints": { + "description": "Map from world environment to the `name` of a Blueprint in the same folder. Environments with no entry are not generated for this bundle; keys correspond to Bukkit `World.Environment` enum names.", + "type": "object", + "propertyNames": { + "enum": ["NORMAL", "NETHER", "THE_END", "CUSTOM"] + }, + "additionalProperties": { + "description": "The `name` field of a sibling Blueprint (i.e. the file's stem without the `.blueprint` extension).", + "type": "string", + "minLength": 1 + } + }, + "requirePermission": { + "description": "If true, a player must hold `.island.create.` to use this bundle.", + "type": "boolean" + }, + "slot": { + "description": "Preferred slot (0-based) in the selection GUI. Runtime clamps to the visible inventory size.", + "type": "integer", + "minimum": 0 + }, + "times": { + "description": "Maximum number of islands a single player may create with this bundle. `0` means unlimited.", + "type": "integer", + "minimum": 0 + }, + "cost": { + "description": "Vault-economy cost to use this bundle. `0` means free. Requires a Vault-compatible economy plugin at runtime.", + "type": "number", + "minimum": 0 + }, + "commands": { + "description": "Commands executed when an island is created with this bundle. Placeholders `[player]` and `[owner]` are substituted. Entries prefixed with `[SUDO]` run as the creating player; others run as console. (Added in BentoBox 2.6.0.)", + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["uniqueId"], + "additionalProperties": false, + "examples": [ + { + "uniqueId": "default", + "displayName": "Default Island", + "icon": "GRASS_BLOCK", + "description": ["A standard island", "with grass and dirt"], + "blueprints": { + "NORMAL": "island", + "NETHER": "nether", + "THE_END": "end" + }, + "requirePermission": false, + "slot": 0, + "times": 0, + "cost": 0.0, + "commands": ["[SUDO] me has arrived!"] + } + ] +} diff --git a/schemas/blueprint.schema.json b/schemas/blueprint.schema.json new file mode 100644 index 000000000..c2dd76386 --- /dev/null +++ b/schemas/blueprint.schema.json @@ -0,0 +1,579 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bentobox.world/schemas/blueprint.schema.json", + "title": "BentoBox Blueprint Format", + "description": "Schema describing the on-disk JSON format used by BentoBox for island blueprints (.blueprint files) and blueprint bundles (bundle .json files).\n\nFILE FORMATS:\n - `.blueprint` files are plain UTF-8 JSON and MUST validate against `#/$defs/Blueprint`.\n - `.blu` files are a legacy format: a ZIP archive containing a single entry whose content is plain JSON that validates against `#/$defs/Blueprint`.\n - Bundle files (e.g. `default.json` inside a game mode's `blueprints/` folder) MUST validate against `#/$defs/BlueprintBundle`.\n\nSERIALIZATION NOTES (Gson-based):\n - Only fields explicitly tagged `@Expose` in the Java source are serialized; producers must not emit other keys.\n - `org.bukkit.util.Vector` values are emitted as a 3-element JSON array of numbers: `[x, y, z]`.\n - Maps keyed by `Vector` use Gson's complex-map-key form: a JSON array of 2-element arrays, i.e. `[[, ], ...]` — NOT a JSON object. This applies to `Blueprint.blocks`, `Blueprint.attached`, and `Blueprint.entities`.\n - Maps keyed by an enum (e.g. `BlueprintBundle.blueprints` keyed by `World.Environment`) ARE emitted as normal JSON objects with the enum NAME as the key.\n - Maps keyed by `Integer` (inventory slot -> ItemStack) are emitted as JSON objects with stringified integer keys.\n - `org.bukkit.inventory.ItemStack` values are serialized to Bukkit YAML (via ConfigurationSerializable) and stored as a JSON string. They cannot be validated structurally by this schema; treat the value as an opaque YAML document.\n - Enum values are serialized by their Java `name()`.\n - `org.bukkit.Color` is serialized as `{\"ALPHA\": int, \"RED\": int, \"GREEN\": int, \"BLUE\": int}` via ConfigurationSerializable.\n - Pretty-printing is enabled by the writer; consumers must not rely on whitespace.", + "type": "object", + "oneOf": [ + { "$ref": "#/$defs/Blueprint" }, + { "$ref": "#/$defs/BlueprintBundle" } + ], + "$defs": { + "Vector": { + "title": "Vector", + "description": "A Bukkit Vector serialized as a 3-element JSON array of doubles: [x, y, z]. For block positions, integer values are customary but the underlying type is double. For entity positions, sub-block fractional components are allowed.", + "type": "array", + "prefixItems": [ + { "type": "number", "description": "x" }, + { "type": "number", "description": "y" }, + { "type": "number", "description": "z" } + ], + "minItems": 3, + "maxItems": 3 + }, + + "ItemStackYaml": { + "title": "ItemStack (YAML-encoded)", + "description": "A Bukkit ItemStack serialized via Bukkit's ConfigurationSerializable as a YAML document and stored as a JSON string. The string SHOULD be parseable by `org.bukkit.inventory.ItemStack#deserialize` or by `YamlConfiguration#loadFromString`. No structural validation is performed by this schema.", + "type": "string" + }, + + "Color": { + "title": "Color", + "description": "An RGBA color, serialized via Bukkit's ConfigurationSerializable.", + "type": "object", + "properties": { + "ALPHA": { "type": "integer", "minimum": 0, "maximum": 255 }, + "RED": { "type": "integer", "minimum": 0, "maximum": 255 }, + "GREEN": { "type": "integer", "minimum": 0, "maximum": 255 }, + "BLUE": { "type": "integer", "minimum": 0, "maximum": 255 } + }, + "required": ["RED", "GREEN", "BLUE"] + }, + + "NamespacedKey": { + "title": "NamespacedKey", + "description": "A Bukkit NamespacedKey such as `minecraft:chests/simple_dungeon`.", + "type": "string", + "pattern": "^[a-z0-9._-]+:[a-z0-9/._-]+$" + }, + + "Icon": { + "title": "Icon", + "description": "Icon material. One of: a plain Bukkit Material enum name (e.g. `DIAMOND`), a vanilla namespaced key (e.g. `minecraft:diamond`), or a resource pack custom model key (e.g. `myserver:island_tropical`). Default: `PAPER`.", + "type": "string", + "minLength": 1 + }, + + "EnvironmentKey": { + "description": "A Bukkit World.Environment name.", + "type": "string", + "enum": ["NORMAL", "NETHER", "THE_END", "CUSTOM"] + }, + + "VectorKeyedBlockMap": { + "title": "Vector-keyed block map", + "description": "Gson complex-map-key form: an array of [VectorKey, BlueprintBlock] pairs.", + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { "$ref": "#/$defs/Vector" }, + { "$ref": "#/$defs/BlueprintBlock" } + ], + "minItems": 2, + "maxItems": 2 + } + }, + + "VectorKeyedEntityListMap": { + "title": "Vector-keyed entity-list map", + "description": "Gson complex-map-key form: an array of [VectorKey, BlueprintEntity[]] pairs. Multiple entities can share the same tile (e.g. a villager and an item frame in one block space).", + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { "$ref": "#/$defs/Vector" }, + { + "type": "array", + "items": { "$ref": "#/$defs/BlueprintEntity" } + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + + "InventoryMap": { + "title": "Inventory slot map", + "description": "Map of zero-based inventory slot to ItemStack (YAML-encoded). Keys are stringified integers; absent slots are empty.", + "type": "object", + "propertyNames": { "pattern": "^[0-9]+$" }, + "additionalProperties": { "$ref": "#/$defs/ItemStackYaml" } + }, + + "Blueprint": { + "title": "Blueprint", + "description": "Top-level object written to a `.blueprint` file. Describes a block volume, attached blocks, and entities relative to an anchor (`bedrock`).", + "type": "object", + "properties": { + "name": { + "description": "Unique identifier for this blueprint; used to look it up from a BlueprintBundle. Conventionally matches the filename stem. Non-null; default empty string.", + "type": "string" + }, + "displayName": { + "description": "Human-readable name shown in UIs. May contain legacy `§` colour codes or MiniMessage tags depending on locale conventions.", + "type": "string" + }, + "icon": { "$ref": "#/$defs/Icon" }, + "description": { + "description": "Lore lines shown under the icon in selection UIs.", + "type": "array", + "items": { "type": "string" } + }, + "bedrock": { + "description": "Anchor point of the blueprint. When pasted, blueprint (0,0,0) is translated so `bedrock` lands on the paste target. If omitted at load-time, BentoBox auto-creates one at `(xSize/2, ySize/2, zSize/2)`.", + "$ref": "#/$defs/Vector" + }, + "xSize": { + "description": "Width of the bounding box in blocks (X axis).", + "type": "integer", + "minimum": 0 + }, + "ySize": { + "description": "Height of the bounding box in blocks (Y axis).", + "type": "integer", + "minimum": 0 + }, + "zSize": { + "description": "Depth of the bounding box in blocks (Z axis).", + "type": "integer", + "minimum": 0 + }, + "sink": { + "description": "If true, at paste time the blueprint will descend until it finds a surface, rather than pasting at the exact Y of the anchor.", + "type": "boolean" + }, + "blocks": { + "description": "Primary blocks of the blueprint, keyed by position relative to the blueprint origin (0..size-1 on each axis).", + "$ref": "#/$defs/VectorKeyedBlockMap" + }, + "attached": { + "description": "Blocks that must be pasted AFTER `blocks` because they attach to a supporting block (torches, ladders, rails, beds, doors, signs, etc.). Same coordinate conventions as `blocks`.", + "$ref": "#/$defs/VectorKeyedBlockMap" + }, + "entities": { + "description": "Entities to spawn at each position. The position key is the block the entity resides in; fine-grained in-block offsets live on BlueprintEntity (x/y/z fields). Multiple entities may share a key.", + "$ref": "#/$defs/VectorKeyedEntityListMap" + } + }, + "additionalProperties": false + }, + + "BlueprintBundle": { + "title": "BlueprintBundle", + "description": "A bundle groups up to three blueprints — one per world environment — into a single selectable option in the island-create UI. Persisted as `.json` inside a game mode's `blueprints/` folder (alongside the `.blueprint` files it references).", + "type": "object", + "properties": { + "uniqueId": { + "description": "Unique identifier for this bundle. Must equal the filename stem (e.g. `default` for `default.json`). Used as a permission suffix when `requirePermission` is true.", + "type": "string", + "minLength": 1 + }, + "displayName": { + "description": "Human-readable name for UI display.", + "type": "string" + }, + "icon": { "$ref": "#/$defs/Icon" }, + "description": { + "description": "Lore shown under the icon in the selection UI.", + "type": "array", + "items": { "type": "string" } + }, + "blueprints": { + "description": "Map from world environment to the `name` of a Blueprint present in the same folder. Environments with no entry are not generated for this bundle.", + "type": "object", + "propertyNames": { "$ref": "#/$defs/EnvironmentKey" }, + "additionalProperties": { "type": "string" } + }, + "requirePermission": { + "description": "If true, using this bundle requires the permission `.island.create.`.", + "type": "boolean" + }, + "slot": { + "description": "Preferred slot in the selection GUI (0-based). Slots are clamped at runtime.", + "type": "integer", + "minimum": 0 + }, + "times": { + "description": "Maximum number of islands a single player may create with this bundle. `0` means unlimited.", + "type": "integer", + "minimum": 0 + }, + "cost": { + "description": "Vault-economy cost a player pays to use this bundle. `0` means free. Requires a Vault-compatible economy plugin at runtime.", + "type": "number", + "minimum": 0 + }, + "commands": { + "description": "Commands executed when an island is created with this bundle. Placeholders `[player]` and `[owner]` are substituted. Entries prefixed with `[SUDO]` run as the player; others run as console.", + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["uniqueId"], + "additionalProperties": false + }, + + "BlueprintBlock": { + "title": "BlueprintBlock", + "description": "One block cell of a blueprint. The `blockData` string is required; all other fields are applied only when the block type supports them.", + "type": "object", + "properties": { + "blockData": { + "description": "Bukkit BlockData string, i.e. the output of `BlockData#getAsString()`. Examples: `minecraft:bedrock`, `minecraft:oak_log[axis=y]`, `minecraft:chest[facing=north,type=single,waterlogged=false]`.", + "type": "string", + "minLength": 1 + }, + "signLines": { + "description": "Front-side sign lines (up to 4). Supports legacy `§` colour codes. DEPRECATED since BentoBox 1.24.0 in favour of side-specific serialisation; still written and read for backwards compatibility.", + "type": "array", + "items": { "type": "string" }, + "maxItems": 4 + }, + "signLines2": { + "description": "Back-side sign lines (up to 4). Added in 1.24.0 when dual-sided signs were introduced.", + "type": "array", + "items": { "type": "string" }, + "maxItems": 4 + }, + "glowingText": { + "description": "Whether the front side of this sign has glowing text.", + "type": "boolean" + }, + "glowingText2": { + "description": "Whether the back side of this sign has glowing text.", + "type": "boolean" + }, + "inventory": { + "description": "Container contents (chests, barrels, hoppers, shulker boxes, furnaces, brewing stands, etc.). Keyed by slot index.", + "$ref": "#/$defs/InventoryMap" + }, + "bannerPatterns": { + "description": "Banner pattern layers, applied in order. Each entry is a Bukkit Pattern serialized via ConfigurationSerializable with keys `pattern` (legacy short code, e.g. `bri`) and `color` (DyeColor name).", + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { "type": "string" }, + "color": { "$ref": "#/$defs/DyeColor" } + } + } + }, + "biome": { + "description": "Biome override for this specific block cell (Bukkit `Biome` enum name). Optional.", + "type": "string" + }, + "creatureSpawner": { + "description": "Present only when `blockData` is a spawner. Describes the spawner configuration.", + "$ref": "#/$defs/BlueprintCreatureSpawner" + }, + "trialSpawner": { + "description": "Present only when `blockData` is a trial spawner (1.21+). Mutually exclusive with `creatureSpawner`.", + "$ref": "#/$defs/BlueprintTrialSpawner" + }, + "itemsAdderBlock": { + "description": "ItemsAdder custom block namespace identifier (e.g. `myserver:custom_ore`). Only meaningful when the ItemsAdder plugin is installed; otherwise the block falls back to `blockData`.", + "type": "string" + } + }, + "required": ["blockData"], + "additionalProperties": false + }, + + "BlueprintCreatureSpawner": { + "title": "BlueprintCreatureSpawner", + "description": "Vanilla (non-trial) mob spawner configuration.", + "type": "object", + "properties": { + "spawnedType": { "$ref": "#/$defs/EntityType" }, + "delay": { + "description": "Current countdown (ticks) until the next spawn attempt.", + "type": "integer" + }, + "maxNearbyEntities": { + "description": "Spawner will stop spawning while at least this many entities of the spawned type exist within its tracking radius.", + "type": "integer", + "minimum": 0 + }, + "maxSpawnDelay": { + "description": "Upper bound (ticks) of the randomised delay picked after a spawn.", + "type": "integer", + "minimum": 0 + }, + "minSpawnDelay": { + "description": "Lower bound (ticks) of the randomised delay picked after a spawn.", + "type": "integer", + "minimum": 0 + }, + "requiredPlayerRange": { + "description": "Maximum distance (blocks) from which a player keeps the spawner active.", + "type": "integer", + "minimum": 0 + }, + "spawnRange": { + "description": "Radius (blocks) around the spawner within which mobs may spawn.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + + "BlueprintTrialSpawner": { + "title": "BlueprintTrialSpawner", + "description": "Trial spawner configuration (1.21+). Added in BentoBox 3.4.2.", + "type": "object", + "properties": { + "ominous": { + "description": "Whether the spawner is in its ominous (cursed) state.", + "type": "boolean" + }, + "spawnedType": { + "description": "Single entity type to spawn. Use this OR `potentialSpawns`.", + "$ref": "#/$defs/EntityType" + }, + "delay": { "type": "integer" }, + "addSimulEnts": { + "description": "Additional simultaneous entities granted per player (scaling factor).", + "type": "number" + }, + "addSpawnsB4Cool": { + "description": "Additional total spawns granted per player before cooldown.", + "type": "number" + }, + "baseSimEnts": { + "description": "Base number of simultaneous entities the spawner will keep alive.", + "type": "number" + }, + "baseSpawnsB4Cool": { + "description": "Base number of total spawns before entering cooldown.", + "type": "number" + }, + "spawnRange": { "type": "integer", "minimum": 0 }, + "requiredPlayerRange": { "type": "integer", "minimum": 0 }, + "playerRange": { "type": "integer", "minimum": 0 }, + "lootTableMap": { + "description": "Candidate reward loot tables with relative weights. Keyed by a loot-table NamespacedKey object.", + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "object", + "properties": { + "nameSpace": { "type": "string" }, + "key": { "type": "string" } + }, + "required": ["nameSpace", "key"], + "additionalProperties": false + }, + { "type": "integer", "minimum": 1 } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "potentialSpawns": { + "description": "Weighted list of possible spawns. Use this OR `spawnedType`.", + "type": "array", + "items": { + "type": "object", + "properties": { + "snapshot": { + "description": "EntitySnapshot serialised as a string (Bukkit `EntitySnapshot#getAsString`). Opaque to this schema.", + "type": "string" + }, + "spawnrule": { + "description": "Bukkit SpawnRule serialised via ConfigurationSerializable. Keys depend on server version; typically includes block-light / sky-light / min/max Y fields.", + "type": "object", + "additionalProperties": true + }, + "spawnWeight": { + "description": "Relative weight of this candidate.", + "type": "integer", + "minimum": 1 + } + }, + "required": ["spawnWeight"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + + "BlueprintEntity": { + "title": "BlueprintEntity", + "description": "One entity to spawn. Only `type` is required; all other fields are applied only when the Bukkit entity class supports them. Nullable wrappers indicate a tri-state: unset means \"do not modify Bukkit default\".", + "type": "object", + "properties": { + "type": { "$ref": "#/$defs/EntityType" }, + "customName": { + "description": "Custom display name; supports legacy `§` colour codes.", + "type": "string" + }, + "x": { + "description": "Fine offset within the position cell (typically 0.0 ≤ x < 1.0 for relative placement, but absolute values are allowed).", + "type": "number" + }, + "y": { "type": "number" }, + "z": { "type": "number" }, + + "glowing": { "type": "boolean", "description": "Glow effect on the entity." }, + "gravity": { "type": "boolean", "description": "Whether gravity applies." }, + "visualFire": { "type": "boolean", "description": "Render a fire effect regardless of `fireTicks`." }, + "silent": { "type": "boolean", "description": "Suppress ambient sounds." }, + "invulnerable": { "type": "boolean", "description": "Immune to all damage sources." }, + "fireTicks": { "type": "integer", "description": "Remaining fire duration (ticks)." }, + + "adult": { "type": "boolean", "description": "Ageable entities only — set false to spawn as baby." }, + "color": { "$ref": "#/$defs/DyeColor", "description": "Colourable entities only (sheep, shulker, collar colour for wolves, etc.)." }, + "tamed": { "type": "boolean", "description": "Tameable entities only. Owner is not restored." }, + "chest": { "type": "boolean", "description": "Chest-carrying horses and llamas only." }, + "domestication": { "type": "integer", "description": "Horse domestication level.", "minimum": 0, "maximum": 100 }, + "inventory": { "$ref": "#/$defs/InventoryMap", "description": "Horse/llama inventory." }, + + "profession": { "type": "string", "description": "Villager profession (enum name or namespaced key string)." }, + "level": { "type": "integer", "description": "Villager level.", "minimum": 1, "maximum": 5 }, + "experience": { "type": "integer", "description": "Villager experience points." }, + "villagerType": { "type": "string", "description": "Villager biome/type variant (enum name or namespaced key string)." }, + "style": { "type": "string", "description": "Horse coat style — Bukkit `Horse.Style` enum name.", "enum": ["WHITE", "WHITEFIELD", "WHITE_DOTS", "BLACK_DOTS", "NONE"] }, + + "npc": { "type": "string", "description": "Citizens plugin NPC id. Only meaningful when Citizens is installed." }, + "MMtype": { "type": "string", "description": "MythicMobs mob type identifier." }, + "MMLevel": { "type": "number", "description": "MythicMobs mob level." }, + "MMpower": { "type": "number", "description": "MythicMobs mob power." }, + "MMStance":{ "type": "string", "description": "MythicMobs mob stance." }, + + "displayRec": { "$ref": "#/$defs/DisplayRec", "description": "Properties common to all DisplayEntity subtypes." }, + "blockDisp": { "$ref": "#/$defs/BlueprintBlock", "description": "BlockDisplay payload — the displayed block." }, + "itemDisp": { "$ref": "#/$defs/ItemDispRec", "description": "ItemDisplay payload." }, + "textDisp": { "$ref": "#/$defs/TextDisplayRec","description": "TextDisplay payload." }, + "itemFrame": { "$ref": "#/$defs/ItemFrameRec", "description": "ItemFrame payload (since BentoBox 3.2.6)." } + }, + "required": ["type"], + "additionalProperties": false + }, + + "DisplayRec": { + "title": "DisplayRec", + "description": "Properties shared by all Display entities (BlockDisplay, ItemDisplay, TextDisplay).", + "type": "object", + "properties": { + "billboard": { + "type": "string", + "enum": ["FIXED", "VERTICAL", "HORIZONTAL", "CENTER"], + "description": "How the display rotates to face the viewer." + }, + "brightness": { + "description": "Bukkit Display.Brightness — typically `{\"block\": int, \"sky\": int}` via ConfigurationSerializable. Opaque.", + "type": "object", + "additionalProperties": true + }, + "height": { "type": "number" }, + "width": { "type": "number" }, + "glowColorOverride": { "$ref": "#/$defs/Color" }, + "interpolationDelay": { "type": "integer" }, + "interpolationDuration": { "type": "integer" }, + "shadowRadius": { "type": "number" }, + "shadowStrength": { "type": "number" }, + "teleportDuration": { "type": "integer" }, + "transformation": { + "description": "Bukkit Transformation (translation, leftRotation, scale, rightRotation) via ConfigurationSerializable. Opaque.", + "type": "object", + "additionalProperties": true + }, + "range": { "type": "number" } + }, + "additionalProperties": false + }, + + "ItemDispRec": { + "type": "object", + "properties": { + "item": { "$ref": "#/$defs/ItemStackYaml" }, + "itemDispTrans": { + "type": "string", + "description": "Bukkit ItemDisplay.ItemDisplayTransform enum name.", + "enum": [ + "NONE", + "THIRDPERSON_LEFTHAND", "THIRDPERSON_RIGHTHAND", + "FIRSTPERSON_LEFTHAND", "FIRSTPERSON_RIGHTHAND", + "HEAD", "GUI", "GROUND", "FIXED" + ] + } + }, + "additionalProperties": false + }, + + "TextDisplayRec": { + "type": "object", + "properties": { + "text": { "type": "string", "description": "Displayed text. Legacy `§` colour codes accepted." }, + "alignment": { + "type": "string", + "enum": ["CENTER", "LEFT", "RIGHT"] + }, + "bgColor": { "$ref": "#/$defs/Color" }, + "face": { "$ref": "#/$defs/BlockFace" }, + "lWidth": { "type": "integer", "description": "Line-wrap width in pixels." }, + "opacity": { "type": "integer", "minimum": -128, "maximum": 127, "description": "Signed byte: -1 renders the default opacity." }, + "isShadowed": { "type": "boolean" }, + "isSeeThrough": { "type": "boolean" }, + "isDefaultBg": { "type": "boolean" } + }, + "additionalProperties": false + }, + + "ItemFrameRec": { + "type": "object", + "properties": { + "item": { "$ref": "#/$defs/ItemStackYaml" }, + "rotation": { + "type": "string", + "description": "Bukkit Rotation enum name.", + "enum": [ + "NONE", + "CLOCKWISE_45", "CLOCKWISE", "CLOCKWISE_135", + "FLIPPED", "FLIPPED_45", + "COUNTER_CLOCKWISE_45", "COUNTER_CLOCKWISE", "COUNTER_CLOCKWISE_135" + ] + }, + "isFixed": { "type": "boolean", "description": "Immovable and unrotatable when true." }, + "isVisible": { "type": "boolean" }, + "dropChance": { "type": "number", "minimum": 0.0, "maximum": 1.0 } + }, + "additionalProperties": false + }, + + "DyeColor": { + "description": "Bukkit DyeColor enum name.", + "type": "string", + "enum": [ + "WHITE", "ORANGE", "MAGENTA", "LIGHT_BLUE", + "YELLOW", "LIME", "PINK", "GRAY", + "LIGHT_GRAY", "CYAN", "PURPLE", "BLUE", + "BROWN", "GREEN", "RED", "BLACK" + ] + }, + + "BlockFace": { + "description": "Bukkit BlockFace enum name.", + "type": "string", + "enum": [ + "NORTH", "EAST", "SOUTH", "WEST", "UP", "DOWN", + "NORTH_EAST", "NORTH_WEST", "SOUTH_EAST", "SOUTH_WEST", + "WEST_NORTH_WEST", "NORTH_NORTH_WEST", "NORTH_NORTH_EAST", "EAST_NORTH_EAST", + "EAST_SOUTH_EAST", "SOUTH_SOUTH_EAST", "SOUTH_SOUTH_WEST", "WEST_SOUTH_WEST", + "SELF" + ] + }, + + "EntityType": { + "description": "Bukkit EntityType enum name (e.g. `VILLAGER`, `ZOMBIE`, `ARMOR_STAND`, `ITEM_FRAME`, `ITEM_DISPLAY`, `BLOCK_DISPLAY`, `TEXT_DISPLAY`, `TRIAL_SPAWNER`). The exact set depends on the server version.", + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + } + } +} From 92fefe2dc606fd79f7824453d6f4833402425337 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 15 Aug 2026 17:41:25 -0700 Subject: [PATCH 13/13] docs: bump the CLAUDE.md version note to 3.22.3 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QDeESyBDbXV4ZLsMMRBZd4 --- CLAUDE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c3fffa64d..c1b2731a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,11 +156,11 @@ A template like `[description]` looks harmless but is a trap. Tra - `plugin.yml` and `config.yml` are filtered for the `${version}` placeholder at build time; locale files are copied without filtering. - Locale translations are produced with Claude, not GitLocalize. When a key is added to `en-US.yml`, translate it into every other `src/main/resources/locales/*.yml` file in the same PR, preserving each file's existing style (e.g. the MiniMessage-tagged names in `zh-CN.yml` / `zh-HK.yml`). - Java preview features are enabled for both compilation and test execution. -- The authoritative version is `buildVersion` in `build.gradle.kts` (current: `3.22.2`). Two related but different strings come out of it: - - **Gradle artifact version** (`project.version`, and so the jar name): `{buildVersion}-SNAPSHOT-LOCAL` locally, `{buildVersion}-SNAPSHOT` on CI (when `BUILD_NUMBER` is set), and the bare `{buildVersion}` when `GIT_BRANCH=origin/master`. So a local build yields `build/libs/BentoBox-3.22.2-SNAPSHOT-LOCAL.jar`. - - **`plugin.yml` version**, the one `/bentobox version` reports: the template is `${project.version}${build.number}`, so CI appends the build number — `3.22.2-SNAPSHOT-b1234`. Locally it matches the artifact version, `3.22.2-SNAPSHOT-LOCAL`. +- The authoritative version is `buildVersion` in `build.gradle.kts` (current: `3.22.3`). Two related but different strings come out of it: + - **Gradle artifact version** (`project.version`, and so the jar name): `{buildVersion}-SNAPSHOT-LOCAL` locally, `{buildVersion}-SNAPSHOT` on CI (when `BUILD_NUMBER` is set), and the bare `{buildVersion}` when `GIT_BRANCH=origin/master`. So a local build yields `build/libs/BentoBox-3.22.3-SNAPSHOT-LOCAL.jar`. + - **`plugin.yml` version**, the one `/bentobox version` reports: the template is `${project.version}${build.number}`, so CI appends the build number — `3.22.3-SNAPSHOT-b1234`. Locally it matches the artifact version, `3.22.3-SNAPSHOT-LOCAL`. - The invariant to preserve when editing this block: **exactly one** of `project.version` and `build.number` carries the build marker. Locally the marker is baked into the revision so the jar filename stays distinguishable from a CI snapshot, which is why `finalBuildNumber` is empty there; setting both is what once stamped `3.22.2-SNAPSHOT-LOCAL-LOCAL` into `plugin.yml`. + The invariant to preserve when editing this block: **exactly one** of `project.version` and `build.number` carries the build marker. Locally the marker is baked into the revision so the jar filename stays distinguishable from a CI snapshot, which is why `finalBuildNumber` is empty there; setting both is what once stamped `3.22.3-SNAPSHOT-LOCAL-LOCAL` into `plugin.yml`. ### Minecraft 26.x / Java 25 toolchain