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/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 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 diff --git a/build.gradle.kts b/build.gradle.kts index a4630b13c..39e410d02 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 @@ -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/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_]*$" + } + } +} 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/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/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/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/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/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/main/java/world/bentobox/bentobox/util/heads/HeadCache.java b/src/main/java/world/bentobox/bentobox/util/heads/HeadCache.java index d30472a4a..4245159af 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.22.3 + */ + 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()); 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()}. */ 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)); + } } 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(); 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/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/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)); 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 ----