From 4c15a1b00d2d92756a0bc9a9fc611d5f23a9c86d Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 20 Dec 2024 21:48:03 -0800 Subject: [PATCH 01/22] Update README.md --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5294fdd..6c1e9e2 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,78 @@ [![Discord](https://img.shields.io/discord/272499714048524288.svg?logo=discord)](https://discord.bentobox.world) [![Build Status](https://ci.codemc.org/buildStatus/icon?job=BentoBoxWorld/ControlPanel)](https://ci.codemc.org/job/BentoBoxWorld/job/ControlPanel/) -This is simple ControlPanel for all BentoBox GameMode addons. Allows to customize GUI for users. +# The Control Panel Addon for BentoBox -## How to use +The Control Panel addon for BentoBox provides a customizable interface for players to execute common commands in a convenient menu. Using the provided YAML configuration file, server administrators can define the layout and functionality of the control panel. + +# Installation 1. Place the addon jar in the addons folder of the BentoBox plugin -2. Restart the server -3. Use admin command to import control panels. +1. Restart the server +2. After Control Panel is loaded it will make a default control panel file called `controlPanelTemplate.yml` +3. Open up the file with a text editor and customize it how you like it +1. Use the admin command to import it into the game if you changed it. e.g. `bsb cp import` + 2. If you want to use a file different to the default one, you can name the file name +3. Open the control panel with the `cp` or `controlpanel` sub command ,e.g, `/is cp` + +## How to Use the Configuration File + +1. **Locate the Configuration File**: + The default file, named `controlPanelTemplate.yml`, can be found in the `plugins/BentoBox/addons/ControlPanel` directory. + +2. **Understand the Template Structure**: + The configuration file defines panels, buttons, commands, and permissions. Key elements include: + - **Panel Name**: The title of the control panel. + - **Buttons**: Each button is assigned a slot and has attributes like name, material, description, and command. + - **Permissions**: Control access to different panels based on user permissions. + +3. **Modify Panels and Buttons**: + - **Panel Definition**: + Each panel starts under `panel-list` and includes settings like `defaultPanel`, `panelName`, and `permission`. + ```yaml + defaultPanel: true + panelName: '&1Commands' + permission: 'default' + ``` + - **Button Definition**: + Buttons are assigned slots (numbers) and include attributes: + ```yaml + 0: + name: 'Island' + material: GRASS + description: + - '&1Go to &5 your island' + command: '[label] go' + ``` + - `name`: The button's display name. + - `material`: The item representing the button. + - `description`: Text description with support for color codes (`&`) and placeholders. + - `command`: The command executed when the button is pressed. + +4. **Command Placeholders**: + - `[player]`: Replaced with the player's username. + - `[server]`: Commands executed by the server console. + - `[label]`: GameMode-specific commands. + +5. **Add or Modify Buttons**: + - Assign a slot number (0-53) for each button. + - Update the attributes to match desired functionality. + - Use placeholders and color codes as needed. + +6. **Permissions**: + - Permissions follow the format `[gamemode].controlpanel.panel.[suffix]`. + - Players with multiple panel permissions will open the first panel marked as default. + +7. **Save and Reload**: + After making changes: + - Save the file. + - Reload the controlpanel by importing it using the `/bsb cp import ` command to apply changes. -## Compatibility +## Example Configuration -- [x] BentoBox - 1.7.0 version -- [x] BSkyBlock -- [x] AcidIsland -- [x] SkyGrid -- [x] CaveBlock +Below is an example of a control panel with several commands: +```yaml +panel-list: ## Information From a96d9a33d6b257e015c211001fa73961e843f763 Mon Sep 17 00:00:00 2001 From: BONNe Date: Fri, 7 Mar 2025 21:22:24 +0200 Subject: [PATCH 02/22] Fast implementation for allowing to define icon as item stack. --- pom.xml | 2 +- .../commands/user/PlayerCommand.java | 10 ++- .../database/objects/ControlPanelObject.java | 30 +++++++ .../managers/ControlPanelManager.java | 3 + .../panels/ControlPanelGenerator.java | 3 +- src/main/resources/controlPanelTemplate.yml | 83 +++++++++++-------- 6 files changed, 90 insertions(+), 41 deletions(-) diff --git a/pom.xml b/pom.xml index 6f6f289..abf38ce 100644 --- a/pom.xml +++ b/pom.xml @@ -63,7 +63,7 @@ ${build.version}-SNAPSHOT - 1.14.0 + 1.15.0 -LOCAL BentoBoxWorld_ControlPanel diff --git a/src/main/java/world/bentobox/controlpanel/commands/user/PlayerCommand.java b/src/main/java/world/bentobox/controlpanel/commands/user/PlayerCommand.java index 533a10c..0739ed8 100644 --- a/src/main/java/world/bentobox/controlpanel/commands/user/PlayerCommand.java +++ b/src/main/java/world/bentobox/controlpanel/commands/user/PlayerCommand.java @@ -3,8 +3,10 @@ import java.util.List; +import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; import world.bentobox.controlpanel.ControlPanelAddon; import world.bentobox.controlpanel.database.objects.ControlPanelObject; import world.bentobox.controlpanel.panels.ControlPanelGenerator; @@ -70,9 +72,11 @@ public boolean canExecute(User user, String label, List args) { if (user.isOp()) { - user.sendMessage(Constants.ERRORS + "no-valid-panels-op", - Constants.VARIABLE_ADMIN, - this.getTopLabel()); + this.getIWM().getAddon(this.getWorld()). + flatMap(GameModeAddon::getAdminCommand). + ifPresent(command -> user.sendMessage(Constants.ERRORS + "no-valid-panels-op", + Constants.VARIABLE_ADMIN, + command.getTopLabel())); } else { diff --git a/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java b/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java index c56079b..6c4c8f4 100644 --- a/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java +++ b/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java @@ -10,6 +10,7 @@ import java.util.List; import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; import com.google.gson.annotations.Expose; @@ -235,6 +236,28 @@ public void setMaterial(Material material) } + /** + * Gets icon. + * + * @return the icon + */ + public ItemStack getIcon() + { + return icon; + } + + + /** + * Sets icon. + * + * @param icon the icon + */ + public void setIcon(ItemStack icon) + { + this.icon = icon; + } + + /** * Method ControlPanelButton#getDescription returns the description of this object. * @@ -337,8 +360,15 @@ public void setDescriptionLines(List descriptionLines) * Material icon for button */ @Expose + @Deprecated private Material material; + /** + * ItemStack for icon + */ + @Expose + private ItemStack icon; + /** * Description for the button */ diff --git a/src/main/java/world/bentobox/controlpanel/managers/ControlPanelManager.java b/src/main/java/world/bentobox/controlpanel/managers/ControlPanelManager.java index 3b619f0..5ed4cb1 100644 --- a/src/main/java/world/bentobox/controlpanel/managers/ControlPanelManager.java +++ b/src/main/java/world/bentobox/controlpanel/managers/ControlPanelManager.java @@ -20,12 +20,14 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.Database; +import world.bentobox.bentobox.util.ItemParser; import world.bentobox.controlpanel.ControlPanelAddon; import world.bentobox.controlpanel.database.objects.ControlPanelObject; import world.bentobox.controlpanel.database.objects.ControlPanelObject.ControlPanelButton; @@ -393,6 +395,7 @@ else if (buttonSection.isString("description")) } button.setMaterial(Material.matchMaterial(buttonSection.getString("material", "GRASS"))); + button.setIcon(ItemParser.parse("icon", new ItemStack(Material.PAPER))); buttonList.add(button); } diff --git a/src/main/java/world/bentobox/controlpanel/panels/ControlPanelGenerator.java b/src/main/java/world/bentobox/controlpanel/panels/ControlPanelGenerator.java index 688a00f..0dd7932 100644 --- a/src/main/java/world/bentobox/controlpanel/panels/ControlPanelGenerator.java +++ b/src/main/java/world/bentobox/controlpanel/panels/ControlPanelGenerator.java @@ -8,6 +8,7 @@ import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -116,7 +117,7 @@ private PanelItem generateButton(ControlPanelButton button) return new PanelItemBuilder(). name(buttonName). - icon(button.getMaterial() == null ? Material.PAPER : button.getMaterial()). + icon(button.getIcon() == null ? new ItemStack(button.getMaterial() == null ? Material.PAPER : button.getMaterial()) : button.getIcon()). description(description). clickHandler((panel, user, clickType, slot) -> { final String parsedCommand = button.getCommand(). diff --git a/src/main/resources/controlPanelTemplate.yml b/src/main/resources/controlPanelTemplate.yml index b4aa32f..912e844 100644 --- a/src/main/resources/controlPanelTemplate.yml +++ b/src/main/resources/controlPanelTemplate.yml @@ -5,7 +5,8 @@ # [server] - Command will be run by console, f.e. '[server] op [player]' will result in '/op BONNe1704' # [label] - [label] in command will be replaced with corresponding GameMode user command, # f.e. '[label] challenges' in BSkyblock will result in 'island challenges' -# material is used from Matherial.match +# material is used from Material.match +# icon is used by parsing BentoBox ItemParser. Replacement for material. # permission is a suffix that will be added to the end of "[gamemode].controlpanel.panel.[suffix]". # Adding permission means that user will open control panel defined by permission. # If user will have multiple panel permissions, it will open first encountered with default flag. @@ -17,67 +18,77 @@ panel-list: default: defaultPanel: true - panelName: '&1Commands' + panelName: '&0&l Control Panel' permission: 'default' buttons: 0: - name: 'Island' - material: GRASS - description: - - '&1Go to &5 your island' + name: '&f&l Island' + material: GRASS_BLOCK + icon: minecraft:grass_block + description: |- + &7 Go to your island' command: '[label] go' 8: - name: 'Settings' - material: LAVA_BUCKET - description: - - 'Open your island settings' + name: '&f&l Settings' + material: ANVIL + icon: minecraft:anvil + description: |- + &7 Open your island settings command: '[label] settings' 3: - name: 'Team' - material: SKULL_ITEM - description: - - 'List team members' + name: '&f&l Team' + material: PLAYER_HEAD + icon: minecraft:player_head + description: |- + &7 List team members command: '[label] team' 1: - name: 'Set Home' + name: '&f&l Set Home' material: WHITE_BED - description: - - 'Set your home here' + icon: minecraft:white_bed + description: |- + &7 Set your home here command: '[label] sethome' 9: - name: 'Calculate Level' + name: '&f&l Calculate Level' material: ACACIA_STAIRS - description: - - 'Island level' - - 'Level: %Level_[gamemode]_island_level%' + icon: minecraft:acacia_stairs + description: |- + &7 Island level + &7 Level: &5 %Level_[gamemode]_island_level% command: '[label] level' 10: - name: 'Show top 10' + name: '&f&l Show top 10' material: BOOK - description: - - 'List the Top 10 islands' + icon: minecraft:book + description: |- + &7 Open top 10 islands command: '[label] top' 12: - name: 'Show warps' + name: '&f&l Show warps' material: OAK_SIGN - description: - - 'List warps available' + icon: minecraft:oak_sign + description: |- + &7 List warps available command: '[label] warps' 5: - name: 'Spawn' + name: '&f&l Spawn' material: BEDROCK - description: - - 'Go to world spawn' + icon: minecraft:bedrock + description: |- + &7 Go to world spawn command: '[label] spawn' 14: - name: 'Biomes' + name: '&f&l Biomes' material: OAK_SAPLING - description: - - 'Biomes' + icon: minecraft:oak_sapling + description: |- + &7 Change biome command: '[label] biomes' 15: - name: 'Challenges' + name: '&f&l Challenges' material: ENCHANTING_TABLE - description: - - 'Challenges' + icon: minecraft:enchanting_table + description: |- + &7 Open challenges command: '[label] challenges' \ No newline at end of file From 5cd588aabadb439b907436f8581e3c5e0fa8d25b Mon Sep 17 00:00:00 2001 From: K0bus Date: Mon, 21 Apr 2025 17:00:16 +0200 Subject: [PATCH 03/22] Add ItemsAdder dependencies --- pom.xml | 11 +++++++++++ src/main/resources/addon.yml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index abf38ce..d752710 100644 --- a/pom.xml +++ b/pom.xml @@ -124,6 +124,10 @@ vault-repo http://nexus.hc.to/content/repositories/pub_releases + + matteodev + https://maven.devs.beer/ + @@ -147,6 +151,13 @@ annotations 18.0.0 + + + dev.lone + api-itemsadder + 4.0.10 + provided + UTF-8 UTF-8 - 17 + 21 - - 1.21.3-R0.1-SNAPSHOT + + 1.21.11-R0.1-SNAPSHOT - 2.7.1-SNAPSHOT + 3.10.0 ${build.version}-SNAPSHOT - 1.15.0 + 1.16.0 -LOCAL BentoBoxWorld_ControlPanel @@ -105,12 +105,8 @@ - spigot-repo - https://hub.spigotmc.org/nexus/content/repositories/snapshots - - - spigotmc-public - https://hub.spigotmc.org/nexus/content/groups/public/ + papermc + https://repo.papermc.io/repository/maven-public/ bentoboxworld @@ -130,12 +126,12 @@ - + - org.spigotmc - spigot-api - ${spigot.version} + io.papermc.paper + paper-api + ${paper.version} provided From d94d419dceb27fd4dbc76ba098c90ffd3ea02f23 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 1 Apr 2026 17:50:31 -0700 Subject: [PATCH 15/22] Upgrade GitHub Actions to use latest versions --- .github/workflows/build.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e99f9f3..adeea35 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,22 +10,22 @@ jobs: name: Build and analyze runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up JDK 17 - uses: actions/setup-java@v3 + - name: Set up JDK 21 + uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: 'zulu' # Alternative distribution options are available. - name: Cache SonarCloud packages - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Cache Maven packages - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} @@ -34,4 +34,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=BentoBoxWorld_ControlPanel \ No newline at end of file + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=BentoBoxWorld_ControlPanel From d7a4baf91a97be89c9f4ca08aad811062a46c53d Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 2 Apr 2026 09:31:54 -0700 Subject: [PATCH 16/22] Add JUnit 5 test suite with 97 tests covering all classes - Add test dependencies: JUnit 5, Mockito 5, MockBukkit - Add jitpack.io repo for MockBukkit snapshots - Update surefire to 3.5.4 with Java 21 module opens - Update JaCoCo to 0.8.13 for Java 25 class file support - Add shared test infrastructure: CommonTestSetup, WhiteBox, TestWorldSettings - Add tests for all 11 source classes including addon lifecycle, commands, manager DB operations, GUI generation, and utilities - Add CLAUDE.md for Claude Code guidance Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 62 ++++ pom.xml | 46 ++- .../controlpanel/CommonTestSetup.java | 274 ++++++++++++++ .../controlpanel/ControlPanelAddonTest.java | 220 +++++++++++ .../controlpanel/TestWorldSettings.java | 341 ++++++++++++++++++ .../world/bentobox/controlpanel/WhiteBox.java | 19 + .../commands/admin/AdminCommandTest.java | 51 +++ .../commands/user/PlayerCommandTest.java | 105 ++++++ .../controlpanel/config/SettingsTest.java | 38 ++ .../objects/ControlPanelObjectTest.java | 110 ++++++ .../managers/ControlPanelManagerTest.java | 279 ++++++++++++++ .../panels/ControlPanelGeneratorTest.java | 160 ++++++++ .../controlpanel/panels/GuiUtilsTest.java | 183 ++++++++++ .../controlpanel/utils/ConstantsTest.java | 69 ++++ .../utils/ItemsAdderParseTest.java | 28 ++ .../controlpanel/utils/UtilsTest.java | 155 ++++++++ 16 files changed, 2138 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/test/java/world/bentobox/controlpanel/CommonTestSetup.java create mode 100644 src/test/java/world/bentobox/controlpanel/ControlPanelAddonTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/TestWorldSettings.java create mode 100644 src/test/java/world/bentobox/controlpanel/WhiteBox.java create mode 100644 src/test/java/world/bentobox/controlpanel/commands/admin/AdminCommandTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/commands/user/PlayerCommandTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/config/SettingsTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/database/objects/ControlPanelObjectTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/managers/ControlPanelManagerTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/panels/ControlPanelGeneratorTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/panels/GuiUtilsTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/utils/ConstantsTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/utils/ItemsAdderParseTest.java create mode 100644 src/test/java/world/bentobox/controlpanel/utils/UtilsTest.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f4cfb86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +ControlPanel is a BentoBox addon for Minecraft (Paper) that provides customizable GUI menus for players to execute common commands. It supports multiple gamemodes (AcidIsland, BSkyBlock, CaveBlock, SkyGrid, AOneBlock) and 13 localizations. + +## Build Commands + +```bash +# Build the project +mvn clean package + +# Run tests +mvn clean test + +# Run tests and SonarCloud analysis (as CI does) +mvn -B verify + +# Run a single test class +mvn test -Dtest=UtilsTest + +# Build without tests +mvn clean package -DskipTests +``` + +**Requires Java 21.** The output JAR is placed in `target/`. + +## Testing + +Tests use **JUnit 5 + Mockito 5 + MockBukkit**. All integration tests extend `CommonTestSetup` which provides pre-configured mocks for BentoBox, Bukkit, Player, World, Island, and other framework objects. + +Key test infrastructure in `src/test/java/world/bentobox/controlpanel/`: +- `CommonTestSetup.java` — abstract base class with `@BeforeEach`/`@AfterEach` lifecycle, static mocks for `Bukkit` and `Util` +- `WhiteBox.java` — reflection utility for setting private static fields (e.g., BentoBox singleton) +- `TestWorldSettings.java` — stub `WorldSettings` implementation for tests + +For tests requiring database access, mock `DatabaseSetup` statically and use `WhiteBox.setInternalState(Database.class, "databaseSetup", dbSetup)` to inject the mock handler. + +## Architecture + +This is a **BentoBox Addon** — it extends BentoBox's `Addon` class and follows its lifecycle (`onLoad` → `onEnable` → `onReload` → `onDisable`). + +**Entry point:** `ControlPanelAddon.java` — registers commands, loads settings, initializes the manager. + +**Key flow:** +1. `ControlPanelAddon` hooks `PlayerCommand` and `AdminCommand` into each registered BentoBox gamemode's command tree +2. `ControlPanelManager` loads/saves `ControlPanelObject` data from YAML files and caches them per-world +3. `ControlPanelGenerator` builds the GUI using BentoBox's `PanelBuilder` API, resolving button actions, permissions, and placeholders (`[player]`, `[server]`, `[label]`) + +**Data model:** `ControlPanelObject` (implements `DataObject`) represents a single control panel with its button layout. The manager parses the YAML template (`controlPanelTemplate.yml`) into these objects. + +**Configuration:** `Settings.java` uses BentoBox's `@StoreAt`/`@ConfigEntry` annotations for YAML-backed config. The `config.yml` controls which gamemodes the addon is disabled for. + +**Localization:** 13 locale YAML files in `src/main/resources/locales/`. Translation key constants are centralized in `Constants.java`. + +**Optional integration:** `ItemsAdderParse.java` provides soft-dependency support for the ItemsAdder plugin's custom items. + +## CI + +GitHub Actions on push to `develop` and PRs: builds with Maven + runs SonarCloud analysis (project: `BentoBoxWorld_ControlPanel`). diff --git a/pom.xml b/pom.xml index cd29469..a034c5e 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,9 @@ BentoBoxWorld_ControlPanel bentobox-world https://sonarcloud.io + + 5.10.2 + 5.11.0 @@ -104,6 +107,14 @@ + + + jitpack.io + https://jitpack.io + + true + + papermc https://repo.papermc.io/repository/maven-public/ @@ -128,6 +139,36 @@ + + com.github.MockBukkit + MockBukkit + v1.21-SNAPSHOT + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + io.papermc.paper paper-api @@ -221,11 +262,12 @@ org.apache.maven.plugins maven-surefire-plugin - 3.1.2 + 3.5.4 ${argLine} + -XX:+EnableDynamicAgentLoading --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED @@ -307,7 +349,7 @@ org.jacoco jacoco-maven-plugin - 0.8.10 + 0.8.13 true diff --git a/src/test/java/world/bentobox/controlpanel/CommonTestSetup.java b/src/test/java/world/bentobox/controlpanel/CommonTestSetup.java new file mode 100644 index 0000000..e80d7a0 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/CommonTestSetup.java @@ -0,0 +1,274 @@ +package world.bentobox.controlpanel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.entity.Player.Spigot; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.inventory.ItemFactory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.plugin.PluginManager; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.util.Vector; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.stubbing.Answer; + +import com.google.common.collect.ImmutableSet; + +import net.md_5.bungee.api.chat.TextComponent; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.user.Notifier; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.database.objects.Players; +import world.bentobox.bentobox.managers.BlueprintsManager; +import world.bentobox.bentobox.managers.FlagsManager; +import world.bentobox.bentobox.managers.HooksManager; +import world.bentobox.bentobox.managers.IslandWorldManager; +import world.bentobox.bentobox.managers.IslandsManager; +import world.bentobox.bentobox.managers.LocalesManager; +import world.bentobox.bentobox.managers.PlaceholdersManager; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.util.Util; + +/** + * Common items for testing. Don't forget to use super.setUp()! + *

+ * Sets up BentoBox plugin, pluginManager and ItemFactory. + * Location, world, playersManager and player. + * IWM, Addon and WorldSettings. IslandManager with one + * island with protection and nothing allowed by default. + * Owner of island is player with same UUID. + * Locales, placeholders. + */ +public abstract class CommonTestSetup { + + protected UUID uuid = UUID.randomUUID(); + + @Mock + protected Player mockPlayer; + @Mock + protected PluginManager pim; + @Mock + protected ItemFactory itemFactory; + @Mock + protected Location location; + @Mock + protected World world; + @Mock + protected IslandWorldManager iwm; + @Mock + protected IslandsManager im; + @Mock + protected Island island; + @Mock + protected BentoBox plugin; + @Mock + protected PlayerInventory inv; + @Mock + protected Notifier notifier; + @Mock + protected FlagsManager fm; + @Mock + protected Spigot spigot; + @Mock + protected HooksManager hooksManager; + @Mock + protected BlueprintsManager bm; + + protected ServerMock server; + + protected MockedStatic mockedBukkit; + protected MockedStatic mockedUtil; + + protected AutoCloseable closeable; + + @Mock + protected BukkitScheduler sch; + @Mock + protected LocalesManager lm; + + @Mock + protected PlaceholdersManager phm; + + + @BeforeEach + public void setUp() throws Exception { + closeable = MockitoAnnotations.openMocks(this); + server = MockBukkit.mock(); + // Set up plugin + WhiteBox.setInternalState(BentoBox.class, "instance", plugin); + + // Bukkit static mock + mockedBukkit = Mockito.mockStatic(Bukkit.class, Mockito.RETURNS_DEEP_STUBS); + mockedBukkit.when(Bukkit::getMinecraftVersion).thenReturn("1.21.10"); + mockedBukkit.when(Bukkit::getBukkitVersion).thenReturn(""); + mockedBukkit.when(Bukkit::getPluginManager).thenReturn(pim); + mockedBukkit.when(Bukkit::getItemFactory).thenReturn(itemFactory); + mockedBukkit.when(Bukkit::getServer).thenReturn(server); + // Location + when(location.getWorld()).thenReturn(world); + when(location.getBlockX()).thenReturn(0); + when(location.getBlockY()).thenReturn(0); + when(location.getBlockZ()).thenReturn(0); + when(location.toVector()).thenReturn(new Vector(0, 0, 0)); + when(location.clone()).thenReturn(location); + + // Players Manager and meta data + PlayersManager pm = mock(PlayersManager.class); + when(plugin.getPlayers()).thenReturn(pm); + Players players = mock(Players.class); + when(players.getMetaData()).thenReturn(Optional.empty()); + when(pm.getPlayer(any(UUID.class))).thenReturn(players); + + // Player + when(mockPlayer.getUniqueId()).thenReturn(uuid); + when(mockPlayer.getLocation()).thenReturn(location); + when(mockPlayer.getWorld()).thenReturn(world); + when(mockPlayer.getName()).thenReturn("tastybento"); + when(mockPlayer.getInventory()).thenReturn(inv); + when(mockPlayer.spigot()).thenReturn(spigot); + when(mockPlayer.getType()).thenReturn(EntityType.PLAYER); + + User.setPlugin(plugin); + User.clearUsers(); + User.getInstance(mockPlayer); + + // IWM + when(plugin.getIWM()).thenReturn(iwm); + when(iwm.inWorld(any(Location.class))).thenReturn(true); + when(iwm.inWorld(any(World.class))).thenReturn(true); + when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); + when(iwm.getAddon(any())).thenReturn(Optional.empty()); + + // World Settings + WorldSettings worldSet = new TestWorldSettings(); + when(iwm.getWorldSettings(any())).thenReturn(worldSet); + + // Island Manager + when(plugin.getIslands()).thenReturn(im); + Optional optionalIsland = Optional.of(island); + when(im.getProtectedIslandAt(any())).thenReturn(optionalIsland); + + // Island - nothing is allowed by default + when(island.isAllowed(any())).thenReturn(false); + when(island.isAllowed(any(User.class), any())).thenReturn(false); + when(island.getOwner()).thenReturn(uuid); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid)); + + // Enable reporting from Flags class + MetadataValue mdv = new FixedMetadataValue(plugin, "_why_debug"); + when(mockPlayer.getMetadata(anyString())).thenReturn(Collections.singletonList(mdv)); + + // Locales & Placeholders + when(lm.get(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + when(plugin.getPlaceholdersManager()).thenReturn(phm); + when(phm.replacePlaceholders(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class)); + when(plugin.getLocalesManager()).thenReturn(lm); + // Notifier + when(plugin.getNotifier()).thenReturn(notifier); + + // Fake players + world.bentobox.bentobox.Settings settings = new world.bentobox.bentobox.Settings(); + when(plugin.getSettings()).thenReturn(settings); + + // Util + mockedUtil = Mockito.mockStatic(Util.class, Mockito.CALLS_REAL_METHODS); + mockedUtil.when(() -> Util.getWorld(any())).thenReturn(mock(World.class)); + Util.setPlugin(plugin); + + mockedUtil.when(() -> Util.findFirstMatchingEnum(any(), any())).thenCallRealMethod(); + + // Server & Scheduler + mockedBukkit.when(Bukkit::getScheduler).thenReturn(sch); + + // Hooks + when(hooksManager.getHook(anyString())).thenReturn(Optional.empty()); + when(plugin.getHooks()).thenReturn(hooksManager); + + // Blueprints Manager + when(plugin.getBlueprintsManager()).thenReturn(bm); + } + + @AfterEach + public void tearDown() throws Exception { + mockedBukkit.closeOnDemand(); + mockedUtil.closeOnDemand(); + closeable.close(); + MockBukkit.unmock(); + User.clearUsers(); + Mockito.framework().clearInlineMocks(); + deleteAll(new File("database")); + deleteAll(new File("database_backup")); + } + + protected static void deleteAll(File file) throws IOException { + if (file.exists()) { + Files.walk(file.toPath()).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); + } + } + + public void checkSpigotMessage(String expectedMessage) { + checkSpigotMessage(expectedMessage, 1); + } + + @SuppressWarnings("deprecation") + public void checkSpigotMessage(String expectedMessage, int expectedOccurrences) { + ArgumentCaptor captor = ArgumentCaptor.forClass(TextComponent.class); + verify(spigot, atLeast(0)).sendMessage(captor.capture()); + + List capturedMessages = captor.getAllValues(); + long actualOccurrences = capturedMessages.stream().map(component -> component.toLegacyText()) + .filter(messageText -> messageText.contains(expectedMessage)) + .count(); + + assertEquals(expectedOccurrences, + actualOccurrences, "Expected message occurrence mismatch: " + expectedMessage); + } + + public EntityExplodeEvent getExplodeEvent(Entity entity, Location l, List list) { + return new EntityExplodeEvent(entity, l, list, 0, null); + } + + public PlayerDeathEvent getPlayerDeathEvent(Player player, List drops, int droppedExp, int newExp, + int newTotalExp, int newLevel, @Nullable String deathMessage) { + return new PlayerDeathEvent(player, null, drops, droppedExp, newExp, + newTotalExp, newLevel, deathMessage); + } +} diff --git a/src/test/java/world/bentobox/controlpanel/ControlPanelAddonTest.java b/src/test/java/world/bentobox/controlpanel/ControlPanelAddonTest.java new file mode 100644 index 0000000..1e80600 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/ControlPanelAddonTest.java @@ -0,0 +1,220 @@ +package world.bentobox.controlpanel; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.HashMap; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import world.bentobox.bentobox.api.addons.Addon.State; +import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.configuration.Config; +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.Database; +import world.bentobox.bentobox.database.DatabaseSetup; +import world.bentobox.bentobox.managers.AddonsManager; +import world.bentobox.controlpanel.config.Settings; +import world.bentobox.controlpanel.managers.ControlPanelManager; + +class ControlPanelAddonTest extends CommonTestSetup { + + private ControlPanelAddon addon; + + @Mock + private AddonsManager addonsManager; + + private MockedStatic mockedDbSetup; + + @SuppressWarnings("unchecked") + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + addon = new ControlPanelAddon(); + + // Set up data folder + File dataFolder = new File("addons/ControlPanel"); + dataFolder.mkdirs(); + addon.setDataFolder(dataFolder); + + // Create JAR file with config.yml + File jFile = new File("addon.jar"); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jFile))) { + // Add config.yml + Path configSrc = Paths.get("src/main/resources/config.yml"); + if (Files.exists(configSrc)) { + Path configDest = Paths.get("config.yml"); + Files.copy(configSrc, configDest, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + addToJar(configDest, jos); + Files.deleteIfExists(configDest); + } + // Add controlPanelTemplate.yml + Path templateSrc = Paths.get("src/main/resources/controlPanelTemplate.yml"); + if (Files.exists(templateSrc)) { + Path templateDest = Paths.get("controlPanelTemplate.yml"); + Files.copy(templateSrc, templateDest, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + addToJar(templateDest, jos); + Files.deleteIfExists(templateDest); + } + } + addon.setFile(jFile); + + AddonDescription desc = new AddonDescription.Builder("main", "ControlPanel", "1.16.0") + .description("test").authors("tastybento").build(); + addon.setDescription(desc); + + when(plugin.getAddonsManager()).thenReturn(addonsManager); + when(addonsManager.getGameModeAddons()).thenReturn(Collections.emptyList()); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + when(plugin.getFlagsManager()).thenReturn(fm); + when(fm.getFlags()).thenReturn(Collections.emptyList()); + + // Mock DatabaseSetup for ControlPanelManager creation + AbstractDatabaseHandler handler = mock(AbstractDatabaseHandler.class); + when(handler.loadObjects()).thenReturn(Collections.emptyList()); + when(handler.saveObject(any())).thenReturn(CompletableFuture.completedFuture(true)); + DatabaseSetup dbSetup = mock(DatabaseSetup.class); + when(dbSetup.getHandler(any())).thenReturn(handler); + mockedDbSetup = Mockito.mockStatic(DatabaseSetup.class); + mockedDbSetup.when(() -> DatabaseSetup.getDatabase()).thenReturn(dbSetup); + WhiteBox.setInternalState(Database.class, "databaseSetup", dbSetup); + } + + @AfterEach + @Override + public void tearDown() throws Exception { + mockedDbSetup.closeOnDemand(); + super.tearDown(); + deleteAll(new File("addons")); + Files.deleteIfExists(Paths.get("addon.jar")); + } + + private void addToJar(Path path, JarOutputStream jos) throws IOException { + try (FileInputStream fis = new FileInputStream(path.toFile())) { + byte[] buffer = new byte[1024]; + int bytesRead; + JarEntry entry = new JarEntry(path.toString()); + jos.putNextEntry(entry); + while ((bytesRead = fis.read(buffer)) != -1) { + jos.write(buffer, 0, bytesRead); + } + } + } + + @Test + void testOnLoadSuccess() { + Settings settings = new Settings(); + try (MockedConstruction mockedConfig = Mockito.mockConstruction(Config.class, + (mock, context) -> when(mock.loadConfigObject()).thenReturn(settings))) { + addon.onLoad(); + assertNotNull(addon.getSettings()); + } + } + + @Test + void testOnLoadFailNullSettings() { + try (MockedConstruction mockedConfig = Mockito.mockConstruction(Config.class, + (mock, context) -> when(mock.loadConfigObject()).thenReturn(null))) { + addon.onLoad(); + assertNull(addon.getSettings()); + } + } + + @Test + void testOnEnableWithGameModes() { + Settings settings = new Settings(); + try (MockedConstruction mockedConfig = Mockito.mockConstruction(Config.class, + (mock, context) -> when(mock.loadConfigObject()).thenReturn(settings))) { + addon.onLoad(); + } + + GameModeAddon gma = mock(GameModeAddon.class); + AddonDescription gmaDesc = new AddonDescription.Builder("main", "BSkyBlock", "1.0").build(); + when(gma.getDescription()).thenReturn(gmaDesc); + + CompositeCommand playerCmd = mock(CompositeCommand.class); + when(playerCmd.getSubCommandAliases()).thenReturn(new HashMap<>()); + when(playerCmd.getAddon()).thenReturn(addon); + when(gma.getPlayerCommand()).thenReturn(Optional.of(playerCmd)); + + CompositeCommand adminCmd = mock(CompositeCommand.class); + when(adminCmd.getSubCommandAliases()).thenReturn(new HashMap<>()); + when(adminCmd.getAddon()).thenReturn(addon); + when(gma.getAdminCommand()).thenReturn(Optional.of(adminCmd)); + + when(addonsManager.getGameModeAddons()).thenReturn(Collections.singletonList(gma)); + + addon.setState(State.ENABLED); + addon.onEnable(); + assertNotNull(addon.getAddonManager()); + } + + @Test + void testOnReloadSuccess() { + Settings settings = new Settings(); + try (MockedConstruction mockedConfig = Mockito.mockConstruction(Config.class, + (mock, context) -> when(mock.loadConfigObject()).thenReturn(settings))) { + addon.onLoad(); + } + addon.setState(State.ENABLED); + addon.onEnable(); + + Settings newSettings = new Settings(); + try (MockedConstruction mockedConfig = Mockito.mockConstruction(Config.class, + (mock, context) -> when(mock.loadConfigObject()).thenReturn(newSettings))) { + addon.onReload(); + assertNotNull(addon.getSettings()); + } + } + + @Test + void testOnDisable() { + Settings settings = new Settings(); + try (MockedConstruction mockedConfig = Mockito.mockConstruction(Config.class, + (mock, context) -> { + when(mock.loadConfigObject()).thenReturn(settings); + when(mock.saveConfigObject(any())).thenReturn(true); + })) { + addon.onLoad(); + addon.setState(State.ENABLED); + addon.onEnable(); + addon.onDisable(); + } + } + + @Test + void testGetSettingsDefault() { + assertNull(addon.getSettings()); + } + + @Test + void testGetAddonManagerDefault() { + assertNull(addon.getAddonManager()); + } +} diff --git a/src/test/java/world/bentobox/controlpanel/TestWorldSettings.java b/src/test/java/world/bentobox/controlpanel/TestWorldSettings.java new file mode 100644 index 0000000..4b9e027 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/TestWorldSettings.java @@ -0,0 +1,341 @@ +package world.bentobox.controlpanel; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Difficulty; +import org.bukkit.GameMode; +import org.bukkit.entity.EntityType; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.configuration.WorldSettings; +import world.bentobox.bentobox.api.flags.Flag; + +/** + * Class for tests that require world settings + */ +public class TestWorldSettings implements WorldSettings { + + private long epoch; + + @Override + public GameMode getDefaultGameMode() { + return GameMode.SURVIVAL; + } + + @Override + public Map getDefaultIslandFlags() { + return Collections.emptyMap(); + } + + @Override + public Map getDefaultIslandSettings() { + return Collections.emptyMap(); + } + + @Override + public Difficulty getDifficulty() { + return Difficulty.EASY; + } + + @Override + public void setDifficulty(Difficulty difficulty) { + } + + @Override + public String getFriendlyName() { + return "friendly_name"; + } + + @Override + public int getIslandDistance() { + return 0; + } + + @Override + public int getIslandHeight() { + return 0; + } + + @Override + public int getIslandProtectionRange() { + return 0; + } + + @Override + public int getIslandStartX() { + return 0; + } + + @Override + public int getIslandStartZ() { + return 0; + } + + @Override + public int getIslandXOffset() { + return 0; + } + + @Override + public int getIslandZOffset() { + return 0; + } + + @Override + public List getIvSettings() { + return Collections.emptyList(); + } + + @Override + public int getMaxHomes() { + return 3; + } + + @Override + public int getMaxIslands() { + return 0; + } + + @Override + public int getMaxTeamSize() { + return 4; + } + + @Override + public int getNetherSpawnRadius() { + return 10; + } + + @Override + public String getPermissionPrefix() { + return "perm."; + } + + @Override + public Set getRemoveMobsWhitelist() { + return Collections.emptySet(); + } + + @Override + public int getSeaHeight() { + return 0; + } + + @Override + public List getHiddenFlags() { + return Collections.emptyList(); + } + + @Override + public List getVisitorBannedCommands() { + return Collections.emptyList(); + } + + @Override + public Map getWorldFlags() { + return Collections.emptyMap(); + } + + @Override + public String getWorldName() { + return "world_name"; + } + + @Override + public boolean isDragonSpawn() { + return false; + } + + @Override + public boolean isEndGenerate() { + return true; + } + + @Override + public boolean isEndIslands() { + return true; + } + + @Override + public boolean isNetherGenerate() { + return true; + } + + @Override + public boolean isNetherIslands() { + return true; + } + + @Override + public boolean isOnJoinResetEnderChest() { + return false; + } + + @Override + public boolean isOnJoinResetInventory() { + return false; + } + + @Override + public boolean isOnJoinResetMoney() { + return false; + } + + @Override + public boolean isOnJoinResetHealth() { + return false; + } + + @Override + public boolean isOnJoinResetHunger() { + return false; + } + + @Override + public boolean isOnJoinResetXP() { + return false; + } + + @Override + public @NonNull List getOnJoinCommands() { + return Collections.emptyList(); + } + + @Override + public boolean isOnLeaveResetEnderChest() { + return false; + } + + @Override + public boolean isOnLeaveResetInventory() { + return false; + } + + @Override + public boolean isOnLeaveResetMoney() { + return false; + } + + @Override + public boolean isOnLeaveResetHealth() { + return false; + } + + @Override + public boolean isOnLeaveResetHunger() { + return false; + } + + @Override + public boolean isOnLeaveResetXP() { + return false; + } + + @Override + public @NonNull List getOnLeaveCommands() { + return Collections.emptyList(); + } + + @Override + public boolean isUseOwnGenerator() { + return false; + } + + @Override + public boolean isWaterUnsafe() { + return false; + } + + @Override + public List getGeoLimitSettings() { + return Collections.emptyList(); + } + + @Override + public int getResetLimit() { + return 0; + } + + @Override + public long getResetEpoch() { + return epoch; + } + + @Override + public void setResetEpoch(long timestamp) { + this.epoch = timestamp; + } + + @Override + public boolean isTeamJoinDeathReset() { + return false; + } + + @Override + public int getDeathsMax() { + return 0; + } + + @Override + public boolean isDeathsCounted() { + return true; + } + + @Override + public boolean isDeathsResetOnNewIsland() { + return true; + } + + @Override + public boolean isAllowSetHomeInNether() { + return false; + } + + @Override + public boolean isAllowSetHomeInTheEnd() { + return false; + } + + @Override + public boolean isRequireConfirmationToSetHomeInNether() { + return false; + } + + @Override + public boolean isRequireConfirmationToSetHomeInTheEnd() { + return false; + } + + @Override + public int getBanLimit() { + return 10; + } + + @Override + public boolean isLeaversLoseReset() { + return true; + } + + @Override + public boolean isKickedKeepInventory() { + return true; + } + + @Override + public boolean isCreateIslandOnFirstLoginEnabled() { + return false; + } + + @Override + public int getCreateIslandOnFirstLoginDelay() { + return 0; + } + + @Override + public boolean isCreateIslandOnFirstLoginAbortOnLogout() { + return false; + } +} diff --git a/src/test/java/world/bentobox/controlpanel/WhiteBox.java b/src/test/java/world/bentobox/controlpanel/WhiteBox.java new file mode 100644 index 0000000..36871de --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/WhiteBox.java @@ -0,0 +1,19 @@ +package world.bentobox.controlpanel; + +public class WhiteBox { + /** + * Sets the value of a private static field using Java Reflection. + * @param targetClass The class containing the static field. + * @param fieldName The name of the private static field. + * @param value The value to set the field to. + */ + public static void setInternalState(Class targetClass, String fieldName, Object value) { + try { + java.lang.reflect.Field field = targetClass.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to set static field '" + fieldName + "' on class " + targetClass.getName(), e); + } + } +} diff --git a/src/test/java/world/bentobox/controlpanel/commands/admin/AdminCommandTest.java b/src/test/java/world/bentobox/controlpanel/commands/admin/AdminCommandTest.java new file mode 100644 index 0000000..ca093ab --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/commands/admin/AdminCommandTest.java @@ -0,0 +1,51 @@ +package world.bentobox.controlpanel.commands.admin; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.controlpanel.CommonTestSetup; +import world.bentobox.controlpanel.ControlPanelAddon; +import world.bentobox.controlpanel.managers.ControlPanelManager; + +class AdminCommandTest extends CommonTestSetup { + + private AdminCommand command; + + @Mock + private ControlPanelAddon addon; + @Mock + private CompositeCommand parentCommand; + @Mock + private ControlPanelManager manager; + + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + + when(parentCommand.getSubCommandAliases()).thenReturn(new HashMap<>()); + when(parentCommand.getWorld()).thenReturn(world); + when(parentCommand.getAddon()).thenReturn(addon); + when(parentCommand.getTopLabel()).thenReturn("bsb"); + when(parentCommand.getPermissionPrefix()).thenReturn("bskyblock."); + + when(addon.getAddonManager()).thenReturn(manager); + + command = new AdminCommand(addon, parentCommand); + } + + @Test + void testExecuteShowsHelp() { + User user = User.getInstance(mockPlayer); + assertTrue(command.execute(user, "controlpanel", Collections.emptyList())); + } +} diff --git a/src/test/java/world/bentobox/controlpanel/commands/user/PlayerCommandTest.java b/src/test/java/world/bentobox/controlpanel/commands/user/PlayerCommandTest.java new file mode 100644 index 0000000..52f74bd --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/commands/user/PlayerCommandTest.java @@ -0,0 +1,105 @@ +package world.bentobox.controlpanel.commands.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Optional; + +import org.bukkit.World; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.controlpanel.CommonTestSetup; +import world.bentobox.controlpanel.ControlPanelAddon; +import world.bentobox.controlpanel.database.objects.ControlPanelObject; +import world.bentobox.controlpanel.managers.ControlPanelManager; +import world.bentobox.controlpanel.panels.ControlPanelGenerator; + +class PlayerCommandTest extends CommonTestSetup { + + private PlayerCommand command; + + @Mock + private ControlPanelAddon addon; + @Mock + private CompositeCommand parentCommand; + @Mock + private ControlPanelManager manager; + @Mock + private ControlPanelObject panelObject; + + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + + when(parentCommand.getSubCommandAliases()).thenReturn(new HashMap<>()); + when(parentCommand.getWorld()).thenReturn(world); + when(parentCommand.getAddon()).thenReturn(addon); + when(parentCommand.getTopLabel()).thenReturn("island"); + when(parentCommand.getPermissionPrefix()).thenReturn("bskyblock."); + + when(addon.getAddonManager()).thenReturn(manager); + + command = new PlayerCommand(addon, parentCommand); + } + + @Test + void testCanExecuteNoPanelNonOp() { + User user = User.getInstance(mockPlayer); + when(mockPlayer.isOp()).thenReturn(false); + when(manager.getUserControlPanel(any(), any(World.class), anyString())).thenReturn(null); + + assertFalse(command.canExecute(user, "controlpanel", Collections.emptyList())); + checkSpigotMessage("controlpanel.errors.no-valid-panels"); + } + + @Test + void testCanExecuteNoPanelOp() { + User user = User.getInstance(mockPlayer); + when(mockPlayer.isOp()).thenReturn(true); + when(manager.getUserControlPanel(any(), any(World.class), anyString())).thenReturn(null); + + // Set up IWM to return a GameModeAddon with admin command + GameModeAddon gma = mock(GameModeAddon.class); + CompositeCommand adminCmd = mock(CompositeCommand.class); + when(adminCmd.getTopLabel()).thenReturn("bsb"); + when(gma.getAdminCommand()).thenReturn(Optional.of(adminCmd)); + when(iwm.getAddon(any())).thenReturn(Optional.of(gma)); + + assertFalse(command.canExecute(user, "controlpanel", Collections.emptyList())); + } + + @Test + void testCanExecuteWithPanel() { + User user = User.getInstance(mockPlayer); + when(manager.getUserControlPanel(any(), any(World.class), anyString())).thenReturn(panelObject); + + assertTrue(command.canExecute(user, "controlpanel", Collections.emptyList())); + } + + @Test + void testExecute() { + User user = User.getInstance(mockPlayer); + when(manager.getUserControlPanel(any(), any(World.class), anyString())).thenReturn(panelObject); + command.canExecute(user, "controlpanel", Collections.emptyList()); + + try (MockedStatic mockedGen = Mockito.mockStatic(ControlPanelGenerator.class)) { + assertTrue(command.execute(user, "controlpanel", Collections.emptyList())); + mockedGen.verify(() -> ControlPanelGenerator.open(any(), any(), any(), anyString())); + } + } +} diff --git a/src/test/java/world/bentobox/controlpanel/config/SettingsTest.java b/src/test/java/world/bentobox/controlpanel/config/SettingsTest.java new file mode 100644 index 0000000..fca8b04 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/config/SettingsTest.java @@ -0,0 +1,38 @@ +package world.bentobox.controlpanel.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SettingsTest { + + private Settings settings; + + @BeforeEach + void setUp() { + settings = new Settings(); + } + + @Test + void testDefaultDisabledGameModes() { + assertNotNull(settings.getDisabledGameModes()); + assertTrue(settings.getDisabledGameModes().isEmpty()); + } + + @Test + void testSetDisabledGameModes() { + Set modes = new HashSet<>(); + modes.add("BSkyBlock"); + modes.add("AcidIsland"); + settings.setDisabledGameModes(modes); + assertEquals(2, settings.getDisabledGameModes().size()); + assertTrue(settings.getDisabledGameModes().contains("BSkyBlock")); + assertTrue(settings.getDisabledGameModes().contains("AcidIsland")); + } +} diff --git a/src/test/java/world/bentobox/controlpanel/database/objects/ControlPanelObjectTest.java b/src/test/java/world/bentobox/controlpanel/database/objects/ControlPanelObjectTest.java new file mode 100644 index 0000000..c6beb6c --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/database/objects/ControlPanelObjectTest.java @@ -0,0 +1,110 @@ +package world.bentobox.controlpanel.database.objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.controlpanel.database.objects.ControlPanelObject.ControlPanelButton; + +class ControlPanelObjectTest { + + private ControlPanelObject cpo; + + @BeforeEach + void setUp() { + cpo = new ControlPanelObject(); + } + + @Test + void testUniqueId() { + assertNull(cpo.getUniqueId()); + cpo.setUniqueId("BSkyBlock_default"); + assertEquals("BSkyBlock_default", cpo.getUniqueId()); + } + + @Test + void testGameMode() { + assertNull(cpo.getGameMode()); + cpo.setGameMode("BSkyBlock"); + assertEquals("BSkyBlock", cpo.getGameMode()); + } + + @Test + void testPermissionSuffix() { + assertNull(cpo.getPermissionSuffix()); + cpo.setPermissionSuffix("default"); + assertEquals("default", cpo.getPermissionSuffix()); + } + + @Test + void testPanelName() { + assertNull(cpo.getPanelName()); + cpo.setPanelName("&1Commands"); + assertEquals("&1Commands", cpo.getPanelName()); + } + + @Test + void testDefaultPanel() { + assertFalse(cpo.isDefaultPanel()); + cpo.setDefaultPanel(true); + assertTrue(cpo.isDefaultPanel()); + } + + @Test + void testPanelButtons() { + assertNull(cpo.getPanelButtons()); + List buttons = new ArrayList<>(); + cpo.setPanelButtons(buttons); + assertNotNull(cpo.getPanelButtons()); + assertTrue(cpo.getPanelButtons().isEmpty()); + } + + @Test + void testControlPanelButton() { + ControlPanelButton button = new ControlPanelButton(); + + assertEquals(0, button.getSlot()); + button.setSlot(5); + assertEquals(5, button.getSlot()); + + assertNull(button.getMaterial()); + button.setMaterial(Material.GRASS_BLOCK); + assertEquals(Material.GRASS_BLOCK, button.getMaterial()); + + assertNull(button.getIcon()); + + assertNull(button.getName()); + button.setName("Test Button"); + assertEquals("Test Button", button.getName()); + + assertNull(button.getCommand()); + button.setCommand("island go"); + assertEquals("island go", button.getCommand()); + + assertNull(button.getDescriptionLines()); + List desc = new ArrayList<>(); + desc.add("Line 1"); + button.setDescriptionLines(desc); + assertEquals(1, button.getDescriptionLines().size()); + assertEquals("Line 1", button.getDescriptionLines().get(0)); + } + + @SuppressWarnings("deprecation") + @Test + void testControlPanelButtonDeprecatedDescription() { + ControlPanelButton button = new ControlPanelButton(); + assertNull(button.getDescription()); + button.setDescription("old description"); + assertEquals("old description", button.getDescription()); + } +} diff --git a/src/test/java/world/bentobox/controlpanel/managers/ControlPanelManagerTest.java b/src/test/java/world/bentobox/controlpanel/managers/ControlPanelManagerTest.java new file mode 100644 index 0000000..aaf0186 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/managers/ControlPanelManagerTest.java @@ -0,0 +1,279 @@ +package world.bentobox.controlpanel.managers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; + +import org.bukkit.permissions.PermissionAttachmentInfo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.Database; +import world.bentobox.bentobox.database.DatabaseSetup; +import world.bentobox.controlpanel.CommonTestSetup; +import world.bentobox.controlpanel.ControlPanelAddon; +import world.bentobox.controlpanel.WhiteBox; +import world.bentobox.controlpanel.database.objects.ControlPanelObject; +import world.bentobox.controlpanel.database.objects.ControlPanelObject.ControlPanelButton; +import world.bentobox.controlpanel.utils.Utils; + +@SuppressWarnings("unchecked") +class ControlPanelManagerTest extends CommonTestSetup { + + private ControlPanelManager manager; + + @Mock + private ControlPanelAddon addon; + + private MockedStatic mockedUtils; + private MockedStatic mockedDbSetup; + private AbstractDatabaseHandler handler; + + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + + when(addon.getPlugin()).thenReturn(plugin); + when(addon.getLogger()).thenReturn(Logger.getAnonymousLogger()); + + File dataFolder = new File("addons/ControlPanel"); + dataFolder.mkdirs(); + when(addon.getDataFolder()).thenReturn(dataFolder); + + // Create the template file so the constructor doesn't try to save resources + File templateFile = new File(dataFolder, "controlPanelTemplate.yml"); + if (!templateFile.exists()) { + templateFile.createNewFile(); + } + + // Mock Utils static methods + mockedUtils = Mockito.mockStatic(Utils.class); + mockedUtils.when(() -> Utils.getGameMode(any(org.bukkit.World.class))).thenReturn("BSkyBlock"); + mockedUtils.when(() -> Utils.getGameMode(any(GameModeAddon.class))).thenReturn("BSkyBlock"); + mockedUtils.when(() -> Utils.getPermissionValue(any(), anyString(), any())).thenCallRealMethod(); + mockedUtils.when(() -> Utils.readIntArray(any())).thenCallRealMethod(); + + // Mock DatabaseSetup to return a mock handler + handler = mock(AbstractDatabaseHandler.class); + when(handler.loadObjects()).thenReturn(Collections.emptyList()); + when(handler.saveObject(any())).thenReturn(CompletableFuture.completedFuture(true)); + DatabaseSetup dbSetup = mock(DatabaseSetup.class); + when(dbSetup.getHandler(any())).thenReturn(handler); + mockedDbSetup = Mockito.mockStatic(DatabaseSetup.class); + mockedDbSetup.when(() -> DatabaseSetup.getDatabase()).thenReturn(dbSetup); + // Also set the static field directly since it may have been initialized already + WhiteBox.setInternalState(Database.class, "databaseSetup", dbSetup); + + manager = new ControlPanelManager(addon); + } + + @AfterEach + @Override + public void tearDown() throws Exception { + mockedUtils.closeOnDemand(); + mockedDbSetup.closeOnDemand(); + super.tearDown(); + deleteAll(new File("addons")); + } + + @Test + void testConstructor() { + assertNotNull(manager); + } + + @Test + void testLoadWithObjects() throws Exception { + ControlPanelObject cpo = createDefaultPanel(); + when(handler.loadObjects()).thenReturn(Collections.singletonList(cpo)); + + manager.reload(); + assertTrue(manager.hasAnyControlPanel(world)); + } + + @Test + void testLoadMigratesNullName() throws Exception { + ControlPanelObject cpo = new ControlPanelObject(); + cpo.setUniqueId("BSkyBlock_default"); + cpo.setGameMode("BSkyBlock"); + cpo.setDefaultPanel(true); + + ControlPanelButton button = new ControlPanelButton(); + button.setSlot(0); + button.setName(null); + button.setCommand("test command"); + button.setDescriptionLines(new ArrayList<>()); + cpo.setPanelButtons(new ArrayList<>(List.of(button))); + + when(handler.loadObjects()).thenReturn(Collections.singletonList(cpo)); + + manager.reload(); + assertTrue(manager.hasAnyControlPanel(world)); + } + + @SuppressWarnings("deprecation") + @Test + void testLoadMigratesDeprecatedDescription() throws Exception { + ControlPanelObject cpo = new ControlPanelObject(); + cpo.setUniqueId("BSkyBlock_default"); + cpo.setGameMode("BSkyBlock"); + cpo.setDefaultPanel(true); + + ControlPanelButton button = new ControlPanelButton(); + button.setSlot(0); + button.setName("Test"); + button.setCommand("test"); + button.setDescription("old single description"); + button.setDescriptionLines(null); + cpo.setPanelButtons(new ArrayList<>(List.of(button))); + + when(handler.loadObjects()).thenReturn(Collections.singletonList(cpo)); + + manager.reload(); + assertTrue(manager.hasAnyControlPanel(world)); + } + + @Test + void testHasAnyControlPanelEmpty() { + assertFalse(manager.hasAnyControlPanel(world)); + } + + @Test + void testHasAnyControlPanelEmptyGameMode() { + mockedUtils.when(() -> Utils.getGameMode(any(org.bukkit.World.class))).thenReturn(""); + assertFalse(manager.hasAnyControlPanel(world)); + } + + @Test + void testHasAnyControlPanelGameModeAddon() { + GameModeAddon gma = mock(GameModeAddon.class); + assertFalse(manager.hasAnyControlPanel(gma)); + } + + @Test + void testGetUserControlPanelNoPermission() { + User user = User.getInstance(mockPlayer); + when(mockPlayer.getEffectivePermissions()).thenReturn(Collections.emptySet()); + + ControlPanelObject result = manager.getUserControlPanel(user, world, "bskyblock."); + assertNull(result); + } + + @Test + void testGetUserControlPanelDefaultFallback() throws Exception { + ControlPanelObject cpo = createDefaultPanel(); + when(handler.loadObjects()).thenReturn(Collections.singletonList(cpo)); + manager.reload(); + + User user = User.getInstance(mockPlayer); + when(mockPlayer.getEffectivePermissions()).thenReturn(Collections.emptySet()); + + ControlPanelObject result = manager.getUserControlPanel(user, world, "bskyblock."); + assertNotNull(result); + assertTrue(result.isDefaultPanel()); + } + + @Test + void testGetUserControlPanelWithPermission() throws Exception { + ControlPanelObject cpo = new ControlPanelObject(); + cpo.setUniqueId("BSkyBlock_vip"); + cpo.setGameMode("BSkyBlock"); + cpo.setPermissionSuffix("vip"); + cpo.setDefaultPanel(false); + cpo.setPanelButtons(new ArrayList<>()); + + when(handler.loadObjects()).thenReturn(Collections.singletonList(cpo)); + manager.reload(); + + User user = User.getInstance(mockPlayer); + PermissionAttachmentInfo pai = mock(PermissionAttachmentInfo.class); + when(pai.getPermission()).thenReturn("bskyblock.controlpanel.panel.vip"); + when(mockPlayer.getEffectivePermissions()).thenReturn(Set.of(pai)); + + ControlPanelObject result = manager.getUserControlPanel(user, world, "bskyblock."); + assertNotNull(result); + assertEquals("BSkyBlock_vip", result.getUniqueId()); + } + + @Test + void testSave() { + manager.save(); + } + + @Test + void testWipeDataEmptyGameMode() { + mockedUtils.when(() -> Utils.getGameMode(any(org.bukkit.World.class))).thenReturn(""); + User user = User.getInstance(mockPlayer); + manager.wipeData(world, user); + } + + @Test + void testWipeDataNoExistingPanels() { + User user = User.getInstance(mockPlayer); + manager.wipeData(world, user); + } + + @Test + void testImportControlPanelsNoFile() { + User user = User.getInstance(mockPlayer); + manager.importControlPanels(user, world, "nonexistent.yml"); + checkSpigotMessage("controlpanel.errors.no-file"); + } + + @Test + void testImportControlPanelsEmptyGameMode() { + mockedUtils.when(() -> Utils.getGameMode(any(org.bukkit.World.class))).thenReturn(""); + User user = User.getInstance(mockPlayer); + manager.importControlPanels(user, world, "test.yml"); + checkSpigotMessage("controlpanel.errors.not-a-gamemode-world"); + } + + @Test + void testImportControlPanelsNullUser() { + mockedUtils.when(() -> Utils.getGameMode(any(org.bukkit.World.class))).thenReturn(""); + manager.importControlPanels(null, world, "test.yml"); + } + + @Test + void testReload() { + manager.reload(); + } + + private ControlPanelObject createDefaultPanel() { + ControlPanelObject cpo = new ControlPanelObject(); + cpo.setUniqueId("BSkyBlock_default"); + cpo.setGameMode("BSkyBlock"); + cpo.setPanelName("Commands"); + cpo.setDefaultPanel(true); + + ControlPanelButton button = new ControlPanelButton(); + button.setSlot(0); + button.setName("Test"); + button.setCommand("test"); + button.setDescriptionLines(new ArrayList<>()); + cpo.setPanelButtons(new ArrayList<>(List.of(button))); + + return cpo; + } +} diff --git a/src/test/java/world/bentobox/controlpanel/panels/ControlPanelGeneratorTest.java b/src/test/java/world/bentobox/controlpanel/panels/ControlPanelGeneratorTest.java new file mode 100644 index 0000000..d076a82 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/panels/ControlPanelGeneratorTest.java @@ -0,0 +1,160 @@ +package world.bentobox.controlpanel.panels; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import world.bentobox.bentobox.api.panels.builders.PanelBuilder; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.controlpanel.CommonTestSetup; +import world.bentobox.controlpanel.ControlPanelAddon; +import world.bentobox.controlpanel.database.objects.ControlPanelObject; +import world.bentobox.controlpanel.database.objects.ControlPanelObject.ControlPanelButton; + +class ControlPanelGeneratorTest extends CommonTestSetup { + + @Mock + private ControlPanelAddon addon; + @Mock + private Server mockServer; + @Mock + private ConsoleCommandSender consoleSender; + + private ControlPanelObject controlPanel; + + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + + when(addon.getPlugin()).thenReturn(plugin); + when(addon.getServer()).thenReturn(mockServer); + when(mockServer.getConsoleSender()).thenReturn(consoleSender); + + // Set up a control panel with buttons + controlPanel = new ControlPanelObject(); + controlPanel.setUniqueId("BSkyBlock_default"); + controlPanel.setGameMode("BSkyBlock"); + controlPanel.setPanelName("&1Commands"); + controlPanel.setDefaultPanel(true); + } + + @Test + void testOpenWithEmptyButtons() { + controlPanel.setPanelButtons(Collections.emptyList()); + User user = User.getInstance(mockPlayer); + + try (MockedConstruction mockedPB = Mockito.mockConstruction(PanelBuilder.class, + (mock, context) -> { + when(mock.user(any())).thenReturn(mock); + when(mock.name(anyString())).thenReturn(mock); + })) { + ControlPanelGenerator.open(addon, user, controlPanel, "island"); + PanelBuilder pb = mockedPB.constructed().get(0); + verify(pb).build(); + } + } + + @Test + void testOpenWithButton() { + ControlPanelButton button = new ControlPanelButton(); + button.setSlot(0); + button.setMaterial(Material.GRASS_BLOCK); + button.setName("Go Home"); + button.setCommand("island go"); + button.setDescriptionLines(List.of("Go to your island")); + + List buttons = new ArrayList<>(); + buttons.add(button); + controlPanel.setPanelButtons(buttons); + + User user = User.getInstance(mockPlayer); + + try (MockedConstruction mockedPB = Mockito.mockConstruction(PanelBuilder.class, + (mock, context) -> { + when(mock.user(any())).thenReturn(mock); + when(mock.name(anyString())).thenReturn(mock); + when(mock.item(any(int.class), any())).thenReturn(mock); + when(mock.item(any())).thenReturn(mock); + when(mock.slotOccupied(any(int.class))).thenReturn(false); + })) { + ControlPanelGenerator.open(addon, user, controlPanel, "island"); + PanelBuilder pb = mockedPB.constructed().get(0); + verify(pb).build(); + } + } + + @Test + void testOpenWithButtonWithIcon() { + ControlPanelButton button = new ControlPanelButton(); + button.setSlot(5); + button.setIcon(new ItemStack(Material.DIAMOND)); + button.setName("Special [label]"); + button.setCommand("[label] help"); + button.setDescriptionLines(List.of("Description for [player]")); + + List buttons = new ArrayList<>(); + buttons.add(button); + controlPanel.setPanelButtons(buttons); + + User user = User.getInstance(mockPlayer); + + try (MockedConstruction mockedPB = Mockito.mockConstruction(PanelBuilder.class, + (mock, context) -> { + when(mock.user(any())).thenReturn(mock); + when(mock.name(anyString())).thenReturn(mock); + when(mock.item(any(int.class), any())).thenReturn(mock); + when(mock.item(any())).thenReturn(mock); + when(mock.slotOccupied(any(int.class))).thenReturn(false); + })) { + ControlPanelGenerator.open(addon, user, controlPanel, "island"); + PanelBuilder pb = mockedPB.constructed().get(0); + verify(pb).build(); + } + } + + @Test + void testOpenWithInvalidSlot() { + ControlPanelButton button = new ControlPanelButton(); + button.setSlot(-1); // invalid slot + button.setMaterial(Material.STONE); + button.setName("Invalid"); + button.setCommand("test"); + button.setDescriptionLines(Collections.emptyList()); + + List buttons = new ArrayList<>(); + buttons.add(button); + controlPanel.setPanelButtons(buttons); + + User user = User.getInstance(mockPlayer); + + try (MockedConstruction mockedPB = Mockito.mockConstruction(PanelBuilder.class, + (mock, context) -> { + when(mock.user(any())).thenReturn(mock); + when(mock.name(anyString())).thenReturn(mock); + when(mock.item(any(int.class), any())).thenReturn(mock); + when(mock.item(any())).thenReturn(mock); + when(mock.slotOccupied(any(int.class))).thenReturn(false); + })) { + ControlPanelGenerator.open(addon, user, controlPanel, "island"); + PanelBuilder pb = mockedPB.constructed().get(0); + verify(pb).build(); + } + } +} diff --git a/src/test/java/world/bentobox/controlpanel/panels/GuiUtilsTest.java b/src/test/java/world/bentobox/controlpanel/panels/GuiUtilsTest.java new file mode 100644 index 0000000..28a978d --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/panels/GuiUtilsTest.java @@ -0,0 +1,183 @@ +package world.bentobox.controlpanel.panels; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +import world.bentobox.controlpanel.CommonTestSetup; + +class GuiUtilsTest extends CommonTestSetup { + + @Test + void testStringSplitSimple() { + List result = GuiUtils.stringSplit("Hello world", 999, false); + assertFalse(result.isEmpty()); + assertEquals("Hello world", result.get(0)); + } + + @Test + void testStringSplitWithPipe() { + List result = GuiUtils.stringSplit("Line1|Line2|Line3", 999, false); + assertEquals(3, result.size()); + assertEquals("Line1", result.get(0)); + assertEquals("Line2", result.get(1)); + assertEquals("Line3", result.get(2)); + } + + @Test + void testStringSplitList() { + List input = Arrays.asList("Hello", "World"); + List result = GuiUtils.stringSplit(input, 999); + assertEquals(2, result.size()); + } + + @Test + void testStringSplitEmptyList() { + List input = Collections.emptyList(); + List result = GuiUtils.stringSplit(input, 999); + assertTrue(result.isEmpty()); + } + + @Test + void testGetMaterialItemNormal() { + ItemStack result = GuiUtils.getMaterialItem(Material.STONE); + assertNotNull(result); + assertEquals(Material.STONE, result.getType()); + assertEquals(1, result.getAmount()); + } + + @Test + void testGetMaterialItemWithAmount() { + ItemStack result = GuiUtils.getMaterialItem(Material.STONE, 5); + assertEquals(5, result.getAmount()); + } + + @Test + void testGetMaterialItemWallMaterial() { + ItemStack result = GuiUtils.getMaterialItem(Material.OAK_WALL_SIGN); + assertEquals(Material.OAK_SIGN, result.getType()); + } + + @Test + void testGetMaterialItemPottedPlant() { + ItemStack result = GuiUtils.getMaterialItem(Material.POTTED_OAK_SAPLING); + assertEquals(Material.OAK_SAPLING, result.getType()); + } + + @Test + void testGetMaterialItemMelonStem() { + ItemStack result = GuiUtils.getMaterialItem(Material.MELON_STEM); + assertEquals(Material.MELON_SEEDS, result.getType()); + } + + @Test + void testGetMaterialItemAttachedMelonStem() { + ItemStack result = GuiUtils.getMaterialItem(Material.ATTACHED_MELON_STEM); + assertEquals(Material.MELON_SEEDS, result.getType()); + } + + @Test + void testGetMaterialItemPumpkinStem() { + ItemStack result = GuiUtils.getMaterialItem(Material.PUMPKIN_STEM); + assertEquals(Material.PUMPKIN_SEEDS, result.getType()); + } + + @Test + void testGetMaterialItemCarrots() { + ItemStack result = GuiUtils.getMaterialItem(Material.CARROTS); + assertEquals(Material.CARROT, result.getType()); + } + + @Test + void testGetMaterialItemBeetroots() { + ItemStack result = GuiUtils.getMaterialItem(Material.BEETROOTS); + assertEquals(Material.BEETROOT, result.getType()); + } + + @Test + void testGetMaterialItemPotatoes() { + ItemStack result = GuiUtils.getMaterialItem(Material.POTATOES); + assertEquals(Material.POTATO, result.getType()); + } + + @Test + void testGetMaterialItemCocoa() { + ItemStack result = GuiUtils.getMaterialItem(Material.COCOA); + assertEquals(Material.COCOA_BEANS, result.getType()); + } + + @Test + void testGetMaterialItemKelpPlant() { + ItemStack result = GuiUtils.getMaterialItem(Material.KELP_PLANT); + assertEquals(Material.KELP, result.getType()); + } + + @Test + void testGetMaterialItemRedstoneWire() { + ItemStack result = GuiUtils.getMaterialItem(Material.REDSTONE_WIRE); + assertEquals(Material.REDSTONE, result.getType()); + } + + @Test + void testGetMaterialItemTripwire() { + ItemStack result = GuiUtils.getMaterialItem(Material.TRIPWIRE); + assertEquals(Material.STRING, result.getType()); + } + + @Test + void testGetMaterialItemFrostedIce() { + ItemStack result = GuiUtils.getMaterialItem(Material.FROSTED_ICE); + assertEquals(Material.ICE, result.getType()); + } + + @Test + void testGetMaterialItemEndPortal() { + ItemStack result = GuiUtils.getMaterialItem(Material.END_PORTAL); + assertEquals(Material.PAPER, result.getType()); + } + + @Test + void testGetMaterialItemWater() { + ItemStack result = GuiUtils.getMaterialItem(Material.WATER); + assertEquals(Material.WATER_BUCKET, result.getType()); + } + + @Test + void testGetMaterialItemLava() { + ItemStack result = GuiUtils.getMaterialItem(Material.LAVA); + assertEquals(Material.LAVA_BUCKET, result.getType()); + } + + @Test + void testGetMaterialItemFire() { + ItemStack result = GuiUtils.getMaterialItem(Material.FIRE); + assertEquals(Material.FIRE_CHARGE, result.getType()); + } + + @Test + void testGetMaterialItemAir() { + ItemStack result = GuiUtils.getMaterialItem(Material.AIR); + assertEquals(Material.GLASS_BOTTLE, result.getType()); + } + + @Test + void testGetMaterialItemPistonHead() { + ItemStack result = GuiUtils.getMaterialItem(Material.PISTON_HEAD); + assertEquals(Material.PISTON, result.getType()); + } + + @Test + void testWrapMethod() { + String result = GuiUtils.wrap("short", 100); + assertEquals("short", result); + } +} diff --git a/src/test/java/world/bentobox/controlpanel/utils/ConstantsTest.java b/src/test/java/world/bentobox/controlpanel/utils/ConstantsTest.java new file mode 100644 index 0000000..e8e4be6 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/utils/ConstantsTest.java @@ -0,0 +1,69 @@ +package world.bentobox.controlpanel.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ConstantsTest { + + @Test + void testTitleConstant() { + assertEquals("controlpanel.gui.titles.", Constants.TITLE); + } + + @Test + void testButtonConstant() { + assertEquals("controlpanel.gui.buttons.", Constants.BUTTON); + } + + @Test + void testDescriptionConstant() { + assertEquals("controlpanel.gui.descriptions.", Constants.DESCRIPTION); + } + + @Test + void testMessageConstant() { + assertEquals("controlpanel.messages.", Constants.MESSAGE); + } + + @Test + void testErrorsConstant() { + assertEquals("controlpanel.errors.", Constants.ERRORS); + } + + @Test + void testQuestionsConstant() { + assertEquals("controlpanel.questions.", Constants.QUESTIONS); + } + + @Test + void testCommandsConstant() { + assertEquals("controlpanel.commands.", Constants.COMMANDS); + } + + @Test + void testTypesConstant() { + assertEquals("controlpanel.types.", Constants.TYPES); + } + + @Test + void testVariables() { + assertEquals("[gamemode]", Constants.VARIABLE_GAMEMODE); + assertEquals("[admin]", Constants.VARIABLE_ADMIN); + assertEquals("[file]", Constants.VARIABLE_FILENAME); + assertEquals("[message]", Constants.VARIABLE_MESSAGE); + } + + @Test + void testAllConstantsStartWithControlPanel() { + assertTrue(Constants.TITLE.startsWith("controlpanel.")); + assertTrue(Constants.BUTTON.startsWith("controlpanel.")); + assertTrue(Constants.DESCRIPTION.startsWith("controlpanel.")); + assertTrue(Constants.MESSAGE.startsWith("controlpanel.")); + assertTrue(Constants.ERRORS.startsWith("controlpanel.")); + assertTrue(Constants.QUESTIONS.startsWith("controlpanel.")); + assertTrue(Constants.COMMANDS.startsWith("controlpanel.")); + assertTrue(Constants.TYPES.startsWith("controlpanel.")); + } +} diff --git a/src/test/java/world/bentobox/controlpanel/utils/ItemsAdderParseTest.java b/src/test/java/world/bentobox/controlpanel/utils/ItemsAdderParseTest.java new file mode 100644 index 0000000..7357db8 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/utils/ItemsAdderParseTest.java @@ -0,0 +1,28 @@ +package world.bentobox.controlpanel.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +import world.bentobox.controlpanel.CommonTestSetup; + +class ItemsAdderParseTest extends CommonTestSetup { + + @Test + void testParseWhenItemsAdderNotEnabled() { + when(pim.isPluginEnabled("ItemsAdder")).thenReturn(false); + ItemStack defaultItem = new ItemStack(Material.STONE); + ItemStack result = ItemsAdderParse.parse("custom_item", defaultItem); + assertEquals(defaultItem, result); + } + + @Test + void testParseConvenienceMethodWhenNotEnabled() { + when(pim.isPluginEnabled("ItemsAdder")).thenReturn(false); + ItemStack result = ItemsAdderParse.parse("custom_item"); + assertEquals(Material.PAPER, result.getType()); + } +} diff --git a/src/test/java/world/bentobox/controlpanel/utils/UtilsTest.java b/src/test/java/world/bentobox/controlpanel/utils/UtilsTest.java new file mode 100644 index 0000000..7fab864 --- /dev/null +++ b/src/test/java/world/bentobox/controlpanel/utils/UtilsTest.java @@ -0,0 +1,155 @@ +package world.bentobox.controlpanel.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.bukkit.World; +import org.bukkit.permissions.PermissionAttachmentInfo; +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.controlpanel.CommonTestSetup; + +class UtilsTest extends CommonTestSetup { + + @Test + void testGetPermissionValueWithMatch() { + User user = User.getInstance(mockPlayer); + PermissionAttachmentInfo pai = mock(PermissionAttachmentInfo.class); + when(pai.getPermission()).thenReturn("bskyblock.controlpanel.panel.vip"); + when(mockPlayer.getEffectivePermissions()).thenReturn(Set.of(pai)); + + String result = Utils.getPermissionValue(user, "bskyblock.controlpanel.panel", null); + assertEquals("vip", result); + } + + @Test + void testGetPermissionValueWithWildcard() { + User user = User.getInstance(mockPlayer); + PermissionAttachmentInfo pai = mock(PermissionAttachmentInfo.class); + when(pai.getPermission()).thenReturn("bskyblock.controlpanel.panel.*"); + when(mockPlayer.getEffectivePermissions()).thenReturn(Set.of(pai)); + + String result = Utils.getPermissionValue(user, "bskyblock.controlpanel.panel", "default"); + assertEquals("default", result); + } + + @Test + void testGetPermissionValueNoMatch() { + User user = User.getInstance(mockPlayer); + when(mockPlayer.getEffectivePermissions()).thenReturn(Collections.emptySet()); + + String result = Utils.getPermissionValue(user, "bskyblock.controlpanel.panel", "fallback"); + assertEquals("fallback", result); + } + + @Test + void testGetPermissionValueTrailingDot() { + User user = User.getInstance(mockPlayer); + PermissionAttachmentInfo pai = mock(PermissionAttachmentInfo.class); + when(pai.getPermission()).thenReturn("bskyblock.controlpanel.panel.admin"); + when(mockPlayer.getEffectivePermissions()).thenReturn(Set.of(pai)); + + String result = Utils.getPermissionValue(user, "bskyblock.controlpanel.panel.", null); + assertEquals("admin", result); + } + + @Test + void testGetGameModeFromWorld() { + GameModeAddon gma = mock(GameModeAddon.class); + AddonDescription desc = new AddonDescription.Builder("main", "BSkyBlock", "1.0").build(); + when(gma.getDescription()).thenReturn(desc); + when(iwm.getAddon(any(World.class))).thenReturn(Optional.of(gma)); + + String result = Utils.getGameMode(world); + assertEquals("BSkyBlock", result); + } + + @Test + void testGetGameModeFromWorldNoAddon() { + when(iwm.getAddon(any(World.class))).thenReturn(Optional.empty()); + + String result = Utils.getGameMode(world); + assertEquals("", result); + } + + @Test + void testGetGameModeFromAddon() { + GameModeAddon gma = mock(GameModeAddon.class); + AddonDescription desc = new AddonDescription.Builder("main", "AcidIsland", "1.0").build(); + when(gma.getDescription()).thenReturn(desc); + + String result = Utils.getGameMode(gma); + assertEquals("AcidIsland", result); + } + + @Test + void testGetNextValue() { + String[] values = {"a", "b", "c"}; + assertEquals("b", Utils.getNextValue(values, "a")); + assertEquals("c", Utils.getNextValue(values, "b")); + assertEquals("a", Utils.getNextValue(values, "c")); // wrap around + } + + @Test + void testGetNextValueNotFound() { + String[] values = {"a", "b", "c"}; + assertEquals("d", Utils.getNextValue(values, "d")); // returns current if not found + } + + @Test + void testGetPreviousValue() { + String[] values = {"a", "b", "c"}; + assertEquals("c", Utils.getPreviousValue(values, "a")); // wrap around + assertEquals("a", Utils.getPreviousValue(values, "b")); + assertEquals("b", Utils.getPreviousValue(values, "c")); + } + + @Test + void testGetPreviousValueNotFound() { + String[] values = {"a", "b", "c"}; + assertEquals("d", Utils.getPreviousValue(values, "d")); // returns current if not found + } + + @Test + void testReadIntArrayWithIntegers() { + int[] result = Utils.readIntArray(List.of(1, 3, 5)); + assertArrayEquals(new int[]{1, 3, 5}, result); + } + + @Test + void testReadIntArrayWithRange() { + int[] result = Utils.readIntArray(List.of("1-3")); + assertArrayEquals(new int[]{1, 2, 3}, result); + } + + @Test + void testReadIntArrayWithMixed() { + int[] result = Utils.readIntArray(List.of(0, "2-4", 7)); + assertArrayEquals(new int[]{0, 2, 3, 4, 7}, result); + } + + @Test + void testReadIntArrayEmpty() { + int[] result = Utils.readIntArray(Collections.emptyList()); + assertArrayEquals(new int[]{}, result); + } + + @Test + void testReadIntArraySingleStringNumber() { + int[] result = Utils.readIntArray(List.of("5")); + assertArrayEquals(new int[]{5}, result); + } +} From 8bd395c07a5a9a90bba7b61648efa12207a02fa4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:35:49 +0000 Subject: [PATCH 17/22] Initial plan From b968b07758ec6a84c9059d5ebb3d2f33e64e3320 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:41:08 +0000 Subject: [PATCH 18/22] Add right_click_command and shift_click_command support for panel buttons - Add rightClickCommand and shiftClickCommand fields to ControlPanelButton - Update ControlPanelGenerator to dispatch commands based on ClickType - Update ControlPanelManager to parse new command fields from YAML - Update controlPanelTemplate.yml comments with new command documentation - Update ControlPanelObjectTest to cover new fields Agent-Logs-Url: https://github.com/BentoBoxWorld/ControlPanel/sessions/ae4d379e-4e77-4531-8669-54851ebebea3 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../database/objects/ControlPanelObject.java | 54 +++++++++++++++++++ .../managers/ControlPanelManager.java | 2 + .../panels/ControlPanelGenerator.java | 48 ++++++++++++++++- src/main/resources/controlPanelTemplate.yml | 4 ++ .../objects/ControlPanelObjectTest.java | 8 +++ 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java b/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java index 6c4c8f4..ccf0330 100644 --- a/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java +++ b/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java @@ -304,6 +304,48 @@ public void setCommand(String command) } + /** + * Method ControlPanelButton#getRightClickCommand returns the rightClickCommand of this object. + * + * @return the rightClickCommand (type String) of this object. + */ + public String getRightClickCommand() + { + return rightClickCommand; + } + + + /** + * Method ControlPanelButton#setRightClickCommand sets new value for the rightClickCommand of this object. + * @param rightClickCommand new value for this object. + */ + public void setRightClickCommand(String rightClickCommand) + { + this.rightClickCommand = rightClickCommand; + } + + + /** + * Method ControlPanelButton#getShiftClickCommand returns the shiftClickCommand of this object. + * + * @return the shiftClickCommand (type String) of this object. + */ + public String getShiftClickCommand() + { + return shiftClickCommand; + } + + + /** + * Method ControlPanelButton#setShiftClickCommand sets new value for the shiftClickCommand of this object. + * @param shiftClickCommand new value for this object. + */ + public void setShiftClickCommand(String shiftClickCommand) + { + this.shiftClickCommand = shiftClickCommand; + } + + /** * Method ControlPanelButton#getName returns the name of this object. * @@ -388,6 +430,18 @@ public void setDescriptionLines(List descriptionLines) @Expose private String command; + /** + * Command that will run on right click. + */ + @Expose + private String rightClickCommand; + + /** + * Command that will run on shift click. + */ + @Expose + private String shiftClickCommand; + /** * Name of the Button. */ diff --git a/src/main/java/world/bentobox/controlpanel/managers/ControlPanelManager.java b/src/main/java/world/bentobox/controlpanel/managers/ControlPanelManager.java index af25886..e7cc2f0 100644 --- a/src/main/java/world/bentobox/controlpanel/managers/ControlPanelManager.java +++ b/src/main/java/world/bentobox/controlpanel/managers/ControlPanelManager.java @@ -367,6 +367,8 @@ private void readControlPanel(YamlConfiguration config, @Nullable User user, fin { button.setName(buttonSection.getString("name")); button.setCommand(buttonSection.getString("command", "[user_command]")); + button.setRightClickCommand(buttonSection.getString("right_click_command")); + button.setShiftClickCommand(buttonSection.getString("shift_click_command")); // Create empty list button.setDescriptionLines(new ArrayList<>()); diff --git a/src/main/java/world/bentobox/controlpanel/panels/ControlPanelGenerator.java b/src/main/java/world/bentobox/controlpanel/panels/ControlPanelGenerator.java index 0dd7932..2e0890c 100644 --- a/src/main/java/world/bentobox/controlpanel/panels/ControlPanelGenerator.java +++ b/src/main/java/world/bentobox/controlpanel/panels/ControlPanelGenerator.java @@ -8,6 +8,7 @@ import org.bukkit.Material; +import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; import java.util.ArrayList; import java.util.Comparator; @@ -120,7 +121,14 @@ private PanelItem generateButton(ControlPanelButton button) icon(button.getIcon() == null ? new ItemStack(button.getMaterial() == null ? Material.PAPER : button.getMaterial()) : button.getIcon()). description(description). clickHandler((panel, user, clickType, slot) -> { - final String parsedCommand = button.getCommand(). + final String rawCommand = this.getCommandForClickType(button, clickType); + + if (rawCommand == null || rawCommand.isEmpty()) + { + return true; + } + + final String parsedCommand = rawCommand. replace("[label]", this.topLabel). replace("[server]", ""). replace("[player]", user.getName()). @@ -128,7 +136,7 @@ private PanelItem generateButton(ControlPanelButton button) if (!parsedCommand.isEmpty()) { - if (button.getCommand().startsWith("[server]")) + if (rawCommand.startsWith("[server]")) { if (!this.addon.getServer().dispatchCommand( this.addon.getServer().getConsoleSender(), @@ -154,6 +162,42 @@ private PanelItem generateButton(ControlPanelButton button) } + /** + * This method returns the command string for a given click type. + * If a specific click type command is not defined, falls back to the default command. + * @param button ControlPanelButton that contains command definitions. + * @param clickType The type of click performed. + * @return The command string to execute, or null if no command is defined. + */ + private String getCommandForClickType(ControlPanelButton button, ClickType clickType) + { + switch (clickType) + { + case RIGHT: + case SHIFT_RIGHT: + if (button.getRightClickCommand() != null && !button.getRightClickCommand().isEmpty()) + { + return button.getRightClickCommand(); + } + + break; + + case SHIFT_LEFT: + if (button.getShiftClickCommand() != null && !button.getShiftClickCommand().isEmpty()) + { + return button.getShiftClickCommand(); + } + + break; + + default: + break; + } + + return button.getCommand(); + } + + // --------------------------------------------------------------------- // Section: Variables // --------------------------------------------------------------------- diff --git a/src/main/resources/controlPanelTemplate.yml b/src/main/resources/controlPanelTemplate.yml index 912e844..d8dd75e 100644 --- a/src/main/resources/controlPanelTemplate.yml +++ b/src/main/resources/controlPanelTemplate.yml @@ -5,6 +5,10 @@ # [server] - Command will be run by console, f.e. '[server] op [player]' will result in '/op BONNe1704' # [label] - [label] in command will be replaced with corresponding GameMode user command, # f.e. '[label] challenges' in BSkyblock will result in 'island challenges' +# Command types: +# command - Executed on left click (default click action) +# right_click_command - Executed on right click. Falls back to 'command' if not set. +# shift_click_command - Executed on shift+left click. Falls back to 'command' if not set. # material is used from Material.match # icon is used by parsing BentoBox ItemParser. Replacement for material. # permission is a suffix that will be added to the end of "[gamemode].controlpanel.panel.[suffix]". diff --git a/src/test/java/world/bentobox/controlpanel/database/objects/ControlPanelObjectTest.java b/src/test/java/world/bentobox/controlpanel/database/objects/ControlPanelObjectTest.java index c6beb6c..624f54c 100644 --- a/src/test/java/world/bentobox/controlpanel/database/objects/ControlPanelObjectTest.java +++ b/src/test/java/world/bentobox/controlpanel/database/objects/ControlPanelObjectTest.java @@ -91,6 +91,14 @@ void testControlPanelButton() { button.setCommand("island go"); assertEquals("island go", button.getCommand()); + assertNull(button.getRightClickCommand()); + button.setRightClickCommand("island settings"); + assertEquals("island settings", button.getRightClickCommand()); + + assertNull(button.getShiftClickCommand()); + button.setShiftClickCommand("island team"); + assertEquals("island team", button.getShiftClickCommand()); + assertNull(button.getDescriptionLines()); List desc = new ArrayList<>(); desc.add("Line 1"); From 95e84741043a11c37581042f257280794c08a165 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:42:21 +0000 Subject: [PATCH 19/22] Address code review: clarify click type documentation Agent-Logs-Url: https://github.com/BentoBoxWorld/ControlPanel/sessions/ae4d379e-4e77-4531-8669-54851ebebea3 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../controlpanel/database/objects/ControlPanelObject.java | 2 +- src/main/resources/controlPanelTemplate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java b/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java index ccf0330..08fc904 100644 --- a/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java +++ b/src/main/java/world/bentobox/controlpanel/database/objects/ControlPanelObject.java @@ -437,7 +437,7 @@ public void setDescriptionLines(List descriptionLines) private String rightClickCommand; /** - * Command that will run on shift click. + * Command that will run on shift+left click. */ @Expose private String shiftClickCommand; diff --git a/src/main/resources/controlPanelTemplate.yml b/src/main/resources/controlPanelTemplate.yml index d8dd75e..58347ae 100644 --- a/src/main/resources/controlPanelTemplate.yml +++ b/src/main/resources/controlPanelTemplate.yml @@ -7,7 +7,7 @@ # f.e. '[label] challenges' in BSkyblock will result in 'island challenges' # Command types: # command - Executed on left click (default click action) -# right_click_command - Executed on right click. Falls back to 'command' if not set. +# right_click_command - Executed on right click or shift+right click. Falls back to 'command' if not set. # shift_click_command - Executed on shift+left click. Falls back to 'command' if not set. # material is used from Material.match # icon is used by parsing BentoBox ItemParser. Replacement for material. From 00eb108e5a7243bd344029995f93329aa5fb7331 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 2 Apr 2026 11:47:26 -0700 Subject: [PATCH 20/22] Update README.md to enhance documentation and feature descriptions --- README.md | 452 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 379 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 6c1e9e2..b3e6d47 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,386 @@ -# ControlPanel Addon +# 🎛️ ControlPanel Addon for BentoBox + [![Discord](https://img.shields.io/discord/272499714048524288.svg?logo=discord)](https://discord.bentobox.world) [![Build Status](https://ci.codemc.org/buildStatus/icon?job=BentoBoxWorld/ControlPanel)](https://ci.codemc.org/job/BentoBoxWorld/job/ControlPanel/) -# The Control Panel Addon for BentoBox - -The Control Panel addon for BentoBox provides a customizable interface for players to execute common commands in a convenient menu. Using the provided YAML configuration file, server administrators can define the layout and functionality of the control panel. - -# Installation - -1. Place the addon jar in the addons folder of the BentoBox plugin -1. Restart the server -2. After Control Panel is loaded it will make a default control panel file called `controlPanelTemplate.yml` -3. Open up the file with a text editor and customize it how you like it -1. Use the admin command to import it into the game if you changed it. e.g. `bsb cp import` - 2. If you want to use a file different to the default one, you can name the file name -3. Open the control panel with the `cp` or `controlpanel` sub command ,e.g, `/is cp` - -## How to Use the Configuration File - -1. **Locate the Configuration File**: - The default file, named `controlPanelTemplate.yml`, can be found in the `plugins/BentoBox/addons/ControlPanel` directory. - -2. **Understand the Template Structure**: - The configuration file defines panels, buttons, commands, and permissions. Key elements include: - - **Panel Name**: The title of the control panel. - - **Buttons**: Each button is assigned a slot and has attributes like name, material, description, and command. - - **Permissions**: Control access to different panels based on user permissions. - -3. **Modify Panels and Buttons**: - - **Panel Definition**: - Each panel starts under `panel-list` and includes settings like `defaultPanel`, `panelName`, and `permission`. - ```yaml - defaultPanel: true - panelName: '&1Commands' - permission: 'default' - ``` - - **Button Definition**: - Buttons are assigned slots (numbers) and include attributes: - ```yaml - 0: - name: 'Island' - material: GRASS - description: - - '&1Go to &5 your island' - command: '[label] go' - ``` - - `name`: The button's display name. - - `material`: The item representing the button. - - `description`: Text description with support for color codes (`&`) and placeholders. - - `command`: The command executed when the button is pressed. - -4. **Command Placeholders**: - - `[player]`: Replaced with the player's username. - - `[server]`: Commands executed by the server console. - - `[label]`: GameMode-specific commands. - -5. **Add or Modify Buttons**: - - Assign a slot number (0-53) for each button. - - Update the attributes to match desired functionality. - - Use placeholders and color codes as needed. - -6. **Permissions**: - - Permissions follow the format `[gamemode].controlpanel.panel.[suffix]`. - - Players with multiple panel permissions will open the first panel marked as default. - -7. **Save and Reload**: - After making changes: - - Save the file. - - Reload the controlpanel by importing it using the `/bsb cp import ` command to apply changes. - -## Example Configuration - -Below is an example of a control panel with several commands: +Give your players a slick, clickable GUI menu to run their most-used island commands — no typing required. ControlPanel is a [BentoBox](https://github.com/BentoBoxWorld/BentoBox) addon that lets server admins build fully customizable control panels using a simple YAML file. + +--- + +## ✨ Features + +- 🖱️ **Click-to-run** — players open a GUI and click buttons to execute commands instantly +- 🗂️ **Multiple panels** — define as many panels as you want per gamemode; assign them to players via permissions +- 🎨 **Rich icons** — use any vanilla material, BentoBox `ItemParser` format, or [ItemsAdder](https://github.com/LoneDev6/ItemsAdder) custom items +- 📋 **Live placeholders** — button descriptions support PlaceholderAPI, color codes, and `[gamemode]` substitution +- 📦 **Console commands** — prefix a command with `[server]` to run it as the console +- 🔢 **Slot ranges** — fill a row of slots with a single button definition using `"0-8"` +- 🌍 **13 localisations** — cs, de, en-US, es, fr, id, ko, lv, pl, ru, zh-CN, zh-HK, zh-TW +- 🔌 **Works with** AcidIsland, BSkyBlock, CaveBlock, SkyGrid, AOneBlock + +--- + +## 📦 Installation + +1. Drop the `ControlPanel` JAR into `plugins/BentoBox/addons/`. +2. Restart the server — ControlPanel creates a default `controlPanelTemplate.yml` in `plugins/BentoBox/addons/ControlPanel/`. +3. Edit `controlPanelTemplate.yml` to build your panels (see below). +4. Run the import command in-game to load your panels: + ``` + /bsb cp import + ``` + Supply a custom filename to import from a different file: + ``` + /bsb cp import myCustomPanels + ``` + > ⚠️ Importing when panels already exist will ask for confirmation and replace them. + +--- + +## 🎮 Commands + +### Player command +``` +/is cp +/is controlpanel +``` +Opens the player's assigned control panel. Players without a specific panel permission see the `defaultPanel`. + +### Admin command +``` +/{admin} cp import [filename] +/{admin} cp import +``` +| Command | Description | +|---|---| +| `/{admin} cp import` | Import panels from `controlPanelTemplate.yml` (default) | +| `/{admin} cp import myFile` | Import panels from `myFile.yml` in the addon data folder | + +> Replace `{admin}` with your gamemode's admin command, e.g. `bsb` for BSkyBlock or `oa` for AOneBlock. + +--- + +## 🔑 Permissions + +| Permission | Default | Description | +|---|---|---| +| `[gamemode].controlpanel` | `true` | Open the control panel | +| `[gamemode].controlpanel.admin` | `op` | Use admin import command | +| `[gamemode].controlpanel.panel.default` | `true` | Access the default panel | +| `[gamemode].controlpanel.panel.` | — | Access a custom panel named `` | + +Replace `[gamemode]` with the gamemode name in lowercase, e.g. `bskyblock`, `acidisland`, `aoneblock`. + +--- + +## 🛠️ Configuration File Reference + +The template file lives at `plugins/BentoBox/addons/ControlPanel/controlPanelTemplate.yml`. + +### Top-level structure + +```yaml +panel-list: + : + defaultPanel: true|false + panelName: '' + permission: '<suffix>' + buttons: + <slot>: + name: '<display name>' + material: MATERIAL_NAME + icon: 'namespace:item_id' + itemsadder: 'namespace:item_id' + description: |- + line one + line two + command: '<command>' +``` + +### Panel fields + +| Field | Description | +|---|---| +| `defaultPanel` | `true` = shown to players with no specific panel permission | +| `panelName` | Title of the inventory GUI. Supports `&` color codes. | +| `permission` | Suffix for `[gamemode].controlpanel.panel.<suffix>`. Players with that permission see this panel. | + +### Button fields + +| Field | Description | +|---|---| +| `slot` | Inventory slot (0–53). Also accepts a range like `"0-8"` to fill multiple slots. | +| `name` | Button display name. Supports `&` color codes and command placeholders. | +| `material` | Vanilla Minecraft material (e.g. `GRASS_BLOCK`). Resolved with `Material.match`. | +| `icon` | BentoBox `ItemParser` string (e.g. `minecraft:diamond`). **Takes priority over `material`.** | +| `itemsadder` | ItemsAdder custom item ID (e.g. `iasurvival:ruby`). Requires the ItemsAdder plugin. | +| `description` | Lore lines shown under the button name. Supports `&` color codes, multi-line `\|`, PlaceholderAPI `%placeholders%`, and `[gamemode]`. | +| `command` | Command run when the button is clicked. Supports placeholders (see below). | + +### Command placeholders + +| Placeholder | Replaced with | +|---|---| +| `[label]` | The gamemode's player command label (e.g. `island`, `ob`) | +| `[player]` | The clicking player's username | +| `[server]` | Runs the command as the server console instead of the player | + +### Description placeholders + +| Placeholder | Replaced with | +|---|---| +| `[gamemode]` | Lowercase gamemode name (e.g. `bskyblock`, `aoneblock`) | +| `%PlaceholderAPI_placeholder%` | Any registered PlaceholderAPI placeholder | + +--- + +## 📄 Example Configuration + +A complete, real-world example for an active **BSkyBlock / AOneBlock** server. It shows two panels — a default panel for all players and a VIP panel for donors — along with all button features. + ```yaml panel-list: -## Information + # ── Default panel — shown to all players ────────────────────────────────── + default: + defaultPanel: true + panelName: '&8&l⚙ &r&6&l Island Control Panel' + permission: 'default' + buttons: + + # Row 0: decorative glass pane border + "0-8": + name: ' ' + material: BLACK_STAINED_GLASS_PANE + description: '' + command: '' + + # ── Navigation ──────────────────────────────────────────────────────── + 9: + name: '&a&l 🏝 Go to Island' + icon: minecraft:grass_block + description: |- + &7 Teleport to your island. + &7 Island level: &e%Level_[gamemode]_island_level% + command: '[label] go' + + 10: + name: '&e&l 🏠 Set Home' + icon: minecraft:white_bed + description: |- + &7 Set your island home + &7 to your current location. + command: '[label] sethome' + + 11: + name: '&b&l 👥 Team' + icon: minecraft:player_head + description: |- + &7 View and manage + &7 your island team. + command: '[label] team' + + 12: + name: '&d&l ✉ Invite' + icon: minecraft:paper + description: |- + &7 Invite a player to + &7 your island. + &7 Usage: &f/[label] invite <player> + command: '[label] invite' + + 13: + name: '&6&l ⚙ Settings' + icon: minecraft:anvil + description: |- + &7 Configure your island + &7 protection settings. + command: '[label] settings' + + # ── Stats & Leaderboards ────────────────────────────────────────────── + 18: + name: '&e&l ⭐ Island Level' + icon: minecraft:experience_bottle + description: |- + &7 Calculate your island level. + &7 Current level: &e%Level_[gamemode]_island_level% + &7 Current rank: &6%Level_[gamemode]_island_rank% + command: '[label] level' + + 19: + name: '&6&l 🏆 Top Islands' + icon: minecraft:gold_block + description: |- + &7 View the top 10 islands + &7 on this server. + command: '[label] top' + + 20: + name: '&a&l 🌿 Warps' + icon: minecraft:ender_eye + description: |- + &7 Browse player warps. + command: '[label] warps' + + 21: + name: '&5&l 🔮 Challenges' + icon: minecraft:enchanting_table + description: |- + &7 Complete challenges + &7 for great rewards! + command: '[label] challenges' + + 22: + name: '&b&l 🌊 Biomes' + icon: minecraft:oak_sapling + description: |- + &7 Change the biome + &7 on your island. + command: '[label] biomes' + + # ── World / Utility ─────────────────────────────────────────────────── + 27: + name: '&f&l 🌍 Spawn' + icon: minecraft:bedrock + description: |- + &7 Teleport to world spawn. + command: '[label] spawn' + + 28: + name: '&c&l 🛒 Shop' + icon: minecraft:emerald + description: |- + &7 Open the server shop. + command: 'shop' + + 29: + name: '&e&l 💰 Balance' + icon: minecraft:gold_nugget + description: |- + &7 Check your current balance. + &7 Balance: &6%vault_balance% + command: 'balance' + + # Console command example — runs as server, not player + 35: + name: '&c&l 🔔 Report Bug' + icon: minecraft:writable_book + description: |- + &7 Report an issue to admins. + &7 Opens a support ticket. + command: '[server] ticket create [player] bug-report' + + # Row 5: decorative border + "36-44": + name: ' ' + material: BLACK_STAINED_GLASS_PANE + description: '' + command: '' + + # ── VIP panel — assigned via permission bskyblock.controlpanel.panel.vip ─ + vip: + defaultPanel: false + panelName: '&8&l⚙ &r&d&l VIP Control Panel' + permission: 'vip' + buttons: + + "0-8": + name: ' ' + material: PURPLE_STAINED_GLASS_PANE + description: '' + command: '' + + 9: + name: '&a&l 🏝 Go to Island' + icon: minecraft:grass_block + description: |- + &7 Teleport to your island. + &7 Island level: &e%Level_[gamemode]_island_level% + command: '[label] go' + + 10: + name: '&e&l 🏠 Set Home' + icon: minecraft:white_bed + description: |- + &7 Set your island home. + command: '[label] sethome' + + 11: + name: '&b&l 👥 Team' + icon: minecraft:player_head + description: |- + &7 View and manage your team. + command: '[label] team' + + 12: + name: '&5&l 🔮 Challenges' + icon: minecraft:enchanting_table + description: |- + &7 Complete challenges! + command: '[label] challenges' + + # VIP exclusive — grants a kit via console + 13: + name: '&d&l 🎁 VIP Kit' + icon: minecraft:chest + description: |- + &d VIP exclusive! + &7 Claim your weekly VIP kit. + command: '[server] kit vipweekly [player]' + + # VIP exclusive — ItemsAdder custom icon example + 14: + name: '&6&l ✨ VIP Perks' + itemsadder: 'iasurvival:vip_star' + description: |- + &7 Browse all your VIP perks. + command: 'vipperks' + + 18: + name: '&e&l ⭐ Island Level' + icon: minecraft:experience_bottle + description: |- + &7 Current level: &e%Level_[gamemode]_island_level% + &7 Current rank: &6%Level_[gamemode]_island_rank% + command: '[label] level' + + 19: + name: '&6&l 🏆 Top Islands' + icon: minecraft:gold_block + description: |- + &7 View the top 10 islands. + command: '[label] top' + + 20: + name: '&f&l 🌍 Spawn' + icon: minecraft:bedrock + description: |- + &7 Teleport to world spawn. + command: '[label] spawn' + + "36-44": + name: ' ' + material: PURPLE_STAINED_GLASS_PANE + description: '' + command: '' +``` + +--- + +## 💡 Tips + +- **Slot layout** — a standard chest row is slots 0–8. A 6-row chest (the max) uses slots 0–53. +- **Slot ranges** — use `"0-8"` (quoted) to place the same button across a range of slots. Great for border decorations. +- **Blank buttons** — set `command: ''` and `name: ' '` to create a visual spacer or border. +- **Console commands** — prefix `[server]` to run a command silently as the console, e.g. for kits, economy rewards, or admin actions players wouldn't have permission to run directly. +- **Multiple panels** — give a player the permission `[gamemode].controlpanel.panel.<suffix>` to show them a different panel. Handy for staff, donors, or ranked players. +- **ItemsAdder icons** — use the `itemsadder:` key (instead of `icon:` or `material:`) to display a custom-textured item. Requires ItemsAdder to be installed; falls back gracefully if it isn't. +- **Reloading** — after editing the YAML, run `/{admin} cp import` again to reload. Existing panels will be replaced after confirmation. + +--- + +## 🌐 Information + +More information can be found in the [Wiki Pages](https://github.com/BentoBoxWorld/ControlPanel/wiki). -More information can be found in [Wiki Pages](https://github.com/BentoBoxWorld/ControlPanel/wiki). +Need help? Join the [BentoBox Discord](https://discord.bentobox.world). From 6888a41dfe61ea93d0704a754901a3697ec9e8d1 Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:50:47 -0700 Subject: [PATCH 21/22] Update README.md to clarify command click types and add support for right-click and shift-click commands --- README.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b3e6d47..7a600f2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Give your players a slick, clickable GUI menu to run their most-used island comm ## ✨ Features -- 🖱️ **Click-to-run** — players open a GUI and click buttons to execute commands instantly +- 🖱️ **Click-to-run** — left-click, right-click, and shift-click can each trigger a different command - 🗂️ **Multiple panels** — define as many panels as you want per gamemode; assign them to players via permissions - 🎨 **Rich icons** — use any vanilla material, BentoBox `ItemParser` format, or [ItemsAdder](https://github.com/LoneDev6/ItemsAdder) custom items - 📋 **Live placeholders** — button descriptions support PlaceholderAPI, color codes, and `[gamemode]` substitution @@ -94,7 +94,9 @@ panel-list: description: |- line one line two - command: '<command>' + command: '<left-click command>' + right_click_command: '<right-click command>' + shift_click_command: '<shift+left-click command>' ``` ### Panel fields @@ -115,7 +117,19 @@ panel-list: | `icon` | BentoBox `ItemParser` string (e.g. `minecraft:diamond`). **Takes priority over `material`.** | | `itemsadder` | ItemsAdder custom item ID (e.g. `iasurvival:ruby`). Requires the ItemsAdder plugin. | | `description` | Lore lines shown under the button name. Supports `&` color codes, multi-line `\|`, PlaceholderAPI `%placeholders%`, and `[gamemode]`. | -| `command` | Command run when the button is clicked. Supports placeholders (see below). | +| `command` | Command run on **left-click** (and any click with no specific override). Supports placeholders. | +| `right_click_command` | Command run on **right-click** or **shift+right-click**. Falls back to `command` if omitted. | +| `shift_click_command` | Command run on **shift+left-click**. Falls back to `command` if omitted. | + +### Click type summary + +| Click | Command used | +|---|---| +| Left click | `command` | +| Right click | `right_click_command` → `command` | +| Shift + Right click | `right_click_command` → `command` | +| Shift + Left click | `shift_click_command` → `command` | +| Any other click | `command` | ### Command placeholders @@ -160,9 +174,13 @@ panel-list: name: '&a&l 🏝 Go to Island' icon: minecraft:grass_block description: |- - &7 Teleport to your island. + &7 Left-click: teleport to your island. + &7 Right-click: go to your island nether. + &7 Shift+click: set your home here. &7 Island level: &e%Level_[gamemode]_island_level% command: '[label] go' + right_click_command: '[label] go nether' + shift_click_command: '[label] sethome' 10: name: '&e&l 🏠 Set Home' @@ -369,6 +387,7 @@ panel-list: ## 💡 Tips +- **Alternative clicks** — add `right_click_command` and/or `shift_click_command` to a button to run different commands on right-click and shift+left-click. Omitting them falls back to `command`. - **Slot layout** — a standard chest row is slots 0–8. A 6-row chest (the max) uses slots 0–53. - **Slot ranges** — use `"0-8"` (quoted) to place the same button across a range of slots. Great for border decorations. - **Blank buttons** — set `command: ''` and `name: ' '` to create a visual spacer or border. From e0d0d1051be4d5554a0170cc574eca09769c5665 Mon Sep 17 00:00:00 2001 From: tastybento <tastybento@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:18:09 -0700 Subject: [PATCH 22/22] Revise compatibility and documentation references Updated compatibility list and documentation links. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7a600f2..2a3cd8f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Give your players a slick, clickable GUI menu to run their most-used island comm - 📦 **Console commands** — prefix a command with `[server]` to run it as the console - 🔢 **Slot ranges** — fill a row of slots with a single button definition using `"0-8"` - 🌍 **13 localisations** — cs, de, en-US, es, fr, id, ko, lv, pl, ru, zh-CN, zh-HK, zh-TW -- 🔌 **Works with** AcidIsland, BSkyBlock, CaveBlock, SkyGrid, AOneBlock +- 🔌 **Works with** AcidIsland, BSkyBlock, CaveBlock, SkyGrid, AOneBlock, etc. --- @@ -400,6 +400,6 @@ panel-list: ## 🌐 Information -More information can be found in the [Wiki Pages](https://github.com/BentoBoxWorld/ControlPanel/wiki). +More information can be found in the [BentoBox Docs](https://docs.bentobox.world/en/latest/addons/ControlPanel/). Need help? Join the [BentoBox Discord](https://discord.bentobox.world).