diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..8bb5bd9
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,96 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+**Level** is a BentoBox add-on for Minecraft that calculates island levels based on block types and counts, maintains top-ten leaderboards, and provides competitive metrics for players on game modes like BSkyBlock and AcidIsland.
+
+## Build & Test Commands
+
+```bash
+# Build
+mvn clean package
+
+# Run all tests
+mvn test
+
+# Run a single test class
+mvn test -Dtest=LevelTest
+
+# Run a specific test method
+mvn test -Dtest=LevelTest#testMethodName
+
+# Full build with coverage
+mvn verify
+```
+
+Java 21 is required. The build produces a shaded JAR (includes PanelUtils).
+
+## Architecture
+
+### Entry Points
+- `LevelPladdon` — Bukkit plugin entry point; instantiates `Level` via the `Pladdon` interface
+- `Level` — main addon class; loads config, registers commands/listeners/placeholders, and hooks into optional third-party plugins
+
+### Lifecycle
+`onLoad()` → `onEnable()` → `allLoaded()`
+
+`allLoaded()` is where integrations with other BentoBox add-ons (Warps, Visit) are established, since those may not be loaded yet during `onEnable()`.
+
+### Key Classes
+
+| Class | Role |
+|---|---|
+| `LevelsManager` | Central manager: island level cache, top-ten lists, database reads/writes |
+| `Pipeliner` | Async queue; limits concurrent island calculations (configurable) |
+| `IslandLevelCalculator` | Core chunk-scanning algorithm; supports multiple block stacker plugins |
+| `Results` | Data object returned by a completed calculation |
+| `ConfigSettings` | Main config bound to `config.yml` via BentoBox's `@ConfigEntry` annotations |
+| `BlockConfig` | Block point-value mappings from `blockconfig.yml` |
+| `PlaceholderManager` | Registers PlaceholderAPI placeholders |
+
+### Package Layout
+```
+world/bentobox/level/
+├── calculators/ # IslandLevelCalculator, Pipeliner, Results, EquationEvaluator
+├── commands/ # Player and admin sub-commands
+├── config/ # ConfigSettings, BlockConfig
+├── events/ # IslandPreLevelEvent, IslandLevelCalculatedEvent
+├── listeners/ # Island activity, join/leave, migration listeners
+├── objects/ # IslandLevels, TopTenData (database-persisted objects)
+├── panels/ # GUI panels (top-ten, details, block values)
+├── requests/ # API request handlers for inter-addon queries
+└── util/ # Utils, ConversationUtils, CachedData
+```
+
+### Island Level Calculation Flow
+1. A calculation request enters `Pipeliner` (async queue, default concurrency = 1)
+2. `IslandLevelCalculator` scans island chunks using chunk snapshots (non-blocking)
+3. Block counts are looked up against `BlockConfig` point values
+4. An equation (configurable formula) converts total points → island level
+5. Results are stored via `LevelsManager` and fired as `IslandLevelCalculatedEvent`
+
+### Optional Plugin Integrations
+Level hooks into these plugins when present: WildStacker, RoseStacker, UltimateStacker (block counts), AdvancedChests, ItemsAdder, Oraxen (custom blocks), and the BentoBox Warps/Visit add-ons.
+
+## Testing
+
+Tests live in `src/test/java/world/bentobox/level/`. The framework is JUnit 5 + Mockito 5 + MockBukkit. `CommonTestSetup` is a shared base class that sets up the MockBukkit server and BentoBox mocks — extend it for new test classes.
+
+JaCoCo coverage reports are generated during `mvn verify`.
+
+## Configuration Resources
+
+| File | Location in JAR | Purpose |
+|---|---|---|
+| `config.yml` | `src/main/resources/` | Main settings (level cost formula, world inclusion, etc.) |
+| `blockconfig.yml` | `src/main/resources/` | Points per block type |
+| `locales/` | `src/main/resources/locales/` | Translation strings |
+| `panels/` | `src/main/resources/panels/` | GUI layout definitions |
+
+## Code Conventions
+
+- Null safety via Eclipse JDT annotations (`@NonNull`, `@Nullable`) — honour these on public APIs
+- BentoBox framework patterns: `CompositeCommand` for commands, `@ConfigEntry`/`@ConfigComment` for config, `@StoreAt` for database objects
+- Pre- and post-events (`IslandPreLevelEvent`, `IslandLevelCalculatedEvent`) follow BentoBox's cancellable event pattern — fire both when adding new calculation triggers
diff --git a/pom.xml b/pom.xml
index ee3d307..a2b10dd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -52,10 +52,12 @@
UTF-821
- 2.0.9
+ 5.10.2
+ 5.11.0
+ v1.21-SNAPSHOT
- 1.21.5-R0.1-SNAPSHOT
- 3.7.4
+ 1.21.11-R0.1-SNAPSHOT
+ 3.10.21.12.0
@@ -67,7 +69,7 @@
-LOCAL
- 2.22.0
+ 2.23.0BentoBoxWorld_Levelbentobox-worldhttps://sonarcloud.io
@@ -124,24 +126,24 @@
- spigot-repo
- https://hub.spigotmc.org/nexus/content/repositories/snapshots
+ jitpack.io
+ https://jitpack.io
- codemc
- https://repo.codemc.org/repository/maven-snapshots/
+ codemc-repo
+ https://repo.codemc.org/repository/maven-public
- codemc-repo
- https://repo.codemc.org/repository/maven-public/
+ papermc
+ https://repo.papermc.io/repository/maven-public/
- bentoboxworld
- https://repo.codemc.org/repository/bentoboxworld/
+ codemc
+ https://repo.codemc.org/repository/maven-snapshots/
- jitpack.io
- https://jitpack.io
+ bentoboxworld
+ https://repo.codemc.org/repository/bentoboxworld/
@@ -169,39 +171,58 @@
Oraxen Repositoryhttps://repo.oraxen.com/releases
+
+
+ nexo
+ Nexo Repository
+ https://repo.nexomc.com/releases
+
-
+
- org.spigotmc
- spigot-api
- ${spigot.version}
- provided
-
-
+ com.github.MockBukkit
+ MockBukkit
+ ${mock-bukkit.version}
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.version}
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ ${junit.version}
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.11.0
+ test
+ org.mockitomockito-core
- 3.11.1
+ ${mockito.version}test
+
- org.powermock
- powermock-module-junit4
- ${powermock.version}
- test
-
-
- org.powermock
- powermock-api-mockito2
- ${powermock.version}
- test
+ io.papermc.paper
+ paper-api
+ ${paper.version}
+ providedworld.bentoboxbentobox
- 3.7.4-SNAPSHOT
+ 3.10.0world.bentobox
@@ -262,6 +283,19 @@
4.0.10provided
+
+
+ com.nexomc
+ nexo
+ 1.19.1
+
+
+ dev.triumphteam
+ triumph-gui
+
+
+ provided
+ io.th0rgal
@@ -354,7 +388,7 @@
org.apache.maven.pluginsmaven-compiler-plugin
- 3.13.0
+ 3.14.1${java.version}
@@ -362,10 +396,11 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 3.5.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
@@ -478,7 +513,7 @@
org.jacocojacoco-maven-plugin
- 0.8.10
+ 0.8.13true
diff --git a/src/main/java/world/bentobox/level/Level.java b/src/main/java/world/bentobox/level/Level.java
index b9fe809..ae91392 100644
--- a/src/main/java/world/bentobox/level/Level.java
+++ b/src/main/java/world/bentobox/level/Level.java
@@ -488,4 +488,12 @@ public boolean isItemsAdder() {
return !getSettings().isDisableItemsAdder() && getPlugin().getHooks().getHook("ItemsAdder").isPresent();
}
+ /**
+ * @return true if the Nexo plugin is enabled and not disabled in config
+ */
+ public boolean isNexo() {
+ return !getSettings().getDisabledPluginHooks().contains("Nexo")
+ && Bukkit.getPluginManager().isPluginEnabled("Nexo");
+ }
+
}
diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java
index ac9c46b..d32bced 100644
--- a/src/main/java/world/bentobox/level/LevelsManager.java
+++ b/src/main/java/world/bentobox/level/LevelsManager.java
@@ -57,6 +57,7 @@ public LevelsManager(Level addon) {
// Set up the database handler to store and retrieve data
// Note that these are saved by the BentoBox database
handler = new Database<>(addon, IslandLevels.class);
+
// Initialize the cache
levelsCache = new HashMap<>();
// Initialize top ten lists
@@ -237,7 +238,6 @@ private long getNumBlocks(final long initialLevel) throws ParseException, IOExce
*
* @param world - world where the island is
* @param targetPlayer - target player UUID
- * @param ownerOnly - return level only if the target player is the owner
* @return Level of the player's island or zero if player is unknown or UUID is
* null
*/
@@ -492,7 +492,6 @@ public void setInitialIslandCount(@NonNull Island island, long lv) {
* member
*
* @param world - world
- * @param island - island
* @param lv - level
*/
public void setIslandLevel(@NonNull World world, @NonNull UUID targetPlayer, long lv) {
diff --git a/src/main/java/world/bentobox/level/PlaceholderManager.java b/src/main/java/world/bentobox/level/PlaceholderManager.java
index 668969c..fa84a8e 100644
--- a/src/main/java/world/bentobox/level/PlaceholderManager.java
+++ b/src/main/java/world/bentobox/level/PlaceholderManager.java
@@ -7,11 +7,11 @@
import java.util.UUID;
import java.util.stream.Collectors;
-import org.bukkit.Keyed;
+import org.bukkit.Bukkit;
+import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.World;
-import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.CreatureSpawner;
@@ -19,25 +19,32 @@
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BlockStateMeta;
import org.bukkit.inventory.meta.ItemMeta;
-import org.eclipse.jdt.annotation.Nullable;
-import org.bukkit.Bukkit;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.nexomc.nexo.api.NexoBlocks;
+import com.nexomc.nexo.api.NexoItems;
+import com.nexomc.nexo.mechanics.custom_block.CustomBlockMechanic;
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.api.addons.GameModeAddon;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.hooks.ItemsAdderHook;
+import world.bentobox.bentobox.hooks.OraxenHook;
import world.bentobox.bentobox.managers.PlaceholdersManager;
import world.bentobox.bentobox.managers.RanksManager;
import world.bentobox.level.objects.IslandLevels;
import world.bentobox.level.objects.TopTenData;
/**
- * Handles Level placeholders
- *
- * @author tastybento
+ * Handles registration and resolution of Level placeholders for the Level addon.
+ *
+ * The class implements:
+ * - registering placeholders via the BentoBox PlaceholdersManager
+ * - resolving top-ten and per-island level values
+ * - mapping blocks/items/spawners to the identifier used by IslandLevels
*
*/
public class PlaceholderManager {
@@ -50,6 +57,23 @@ public PlaceholderManager(Level addon) {
this.plugin = addon.getPlugin();
}
+ /**
+ * Register placeholders for a given GameModeAddon.
+ *
+ * This method registers a number of placeholders with BentoBox's PlaceholdersManager:
+ * - island level placeholders (formatted, raw, owner-only)
+ * - points / points-to-next-level placeholders
+ * - top-ten placeholders (name, island name, members, level) for ranks 1..10
+ * - visited island placeholder
+ * - mainhand & looking placeholders (value and count)
+ * - dynamic placeholders for each configured block key from the BlockConfig
+ *
+ * The registered placeholders call into the Level manager and IslandLevels to fetch
+ * values. Safety checks are performed so that missing players, islands or data return "0"
+ * or empty strings rather than throwing exceptions.
+ *
+ * @param gm the GameModeAddon for which placeholders are being registered
+ */
protected void registerPlaceholders(GameModeAddon gm) {
if (plugin.getPlaceholdersManager() == null)
return;
@@ -170,15 +194,17 @@ protected void registerPlaceholders(GameModeAddon gm) {
// Format the key for the placeholder name (e.g., minecraft_stone, pig_spawner)
String placeholderSuffix = configKey.replace(':', '_').replace('.', '_').toLowerCase();
- // Register value placeholder
- bpm.registerPlaceholder(addon, gm.getDescription().getName().toLowerCase() + "_island_value_" + placeholderSuffix,
+ // Register value placeholders
+ String placeholder = gm.getDescription().getName().toLowerCase() + "_island_value_" + placeholderSuffix;
+ bpm.registerPlaceholder(addon, placeholder,
user -> String.valueOf(Objects.requireNonNullElse(
// Use the configKey directly, getValue handles String keys
addon.getBlockConfig().getValue(gm.getOverWorld(), configKey), 0))
);
- // Register count placeholder
- bpm.registerPlaceholder(addon, gm.getDescription().getName().toLowerCase() + "_island_count_" + placeholderSuffix,
+ // Register count placeholders
+ placeholder = gm.getDescription().getName().toLowerCase() + "_island_count_" + placeholderSuffix;
+ bpm.registerPlaceholder(addon, placeholder,
user -> {
// Convert the String configKey back to the expected Object type (EntityType, Material, String)
// for IslandLevels lookup.
@@ -189,15 +215,27 @@ protected void registerPlaceholders(GameModeAddon gm) {
);
});
}
+ // Register limit placeholders
+ addon.getBlockConfig().getBlockLimits().forEach((configKey, configValue) -> {
+ // Format the key for the placeholder name (e.g., minecraft_stone, pig_spawner)
+ String placeholderSuffix = configKey.replace(':', '_').replace('.', '_').toLowerCase();
+ String placeholder = gm.getDescription().getName().toLowerCase() + "_island_limit_" + placeholderSuffix;
+ bpm.registerPlaceholder(addon, placeholder, user -> String.valueOf(configValue));
+ });
}
/**
* Get the name of the owner of the island who holds the rank in this world.
- *
- * @param world world
- * @param rank rank 1 to 10
- * @param weighted if true, then the weighted rank name is returned
- * @return rank name
+ *
+ * Behavior / notes:
+ * - rank is clamped between 1 and Level.TEN
+ * - when weighted == true, the weighted top-ten is used; otherwise the plain top-ten is used
+ * - returns an empty string if a rank is not available or owner is null
+ *
+ * @param world world to look up the ranking in
+ * @param rank 1-based rank (will be clamped)
+ * @param weighted whether to use the weighted top-ten
+ * @return owner name or empty string
*/
String getRankName(World world, int rank, boolean weighted) {
// Ensure rank is within bounds
@@ -216,12 +254,14 @@ String getRankName(World world, int rank, boolean weighted) {
}
/**
- * Get the island name for this rank
- *
- * @param world world
- * @param rank rank 1 to 10
- * @param weighted if true, then the weighted rank name is returned
- * @return name of island or nothing if there isn't one
+ * Get the island name for this rank.
+ *
+ * Similar behavior to getRankName, but returns the island's name (or empty string).
+ *
+ * @param world world to look up the island in
+ * @param rank 1-based rank (clamped)
+ * @param weighted whether to use the weighted list
+ * @return name of island or empty string
*/
String getRankIslandName(World world, int rank, boolean weighted) {
// Ensure rank is within bounds
@@ -237,12 +277,16 @@ String getRankIslandName(World world, int rank, boolean weighted) {
}
/**
- * Gets a comma separated string of island member names
- *
- * @param world world
- * @param rank rank to request
- * @param weighted if true, then the weighted rank name is returned
- * @return comma separated string of island member names
+ * Gets a comma separated string of island member names for a given ranked island.
+ *
+ * - Members are filtered to those at or above RanksManager.MEMBER_RANK.
+ * - Members are sorted by rank descending for consistent ordering.
+ * - If the island is missing or has no members, returns an empty string.
+ *
+ * @param world world to look up
+ * @param rank rank in the top-ten (1..10)
+ * @param weighted whether to use weighted top-ten
+ * @return comma-separated member names, or empty string
*/
String getRankMembers(World world, int rank, boolean weighted) {
// Ensure rank is within bounds
@@ -269,12 +313,15 @@ String getRankMembers(World world, int rank, boolean weighted) {
}
/**
- * Get the level for the rank requested
- *
- * @param world world
- * @param rank rank wanted
- * @param weighted true if weighted (level/number of team members)
- * @return level for the rank requested
+ * Get the level for the rank requested.
+ *
+ * - Returns a formatted level string using the manager's formatLevel helper.
+ * - If a value is missing, manager.formatLevel receives null which should handle the fallback.
+ *
+ * @param world world to query
+ * @param rank rank 1..10 (clamped)
+ * @param weighted whether to fetch weighted level
+ * @return string representation of the level for the rank
*/
String getRankLevel(World world, int rank, boolean weighted) {
// Ensure rank is within bounds
@@ -288,11 +335,11 @@ String getRankLevel(World world, int rank, boolean weighted) {
}
/**
- * Return the rank of the player in a world
- *
+ * Return the rank of the player in a world.
+ *
* @param world world
* @param user player
- * @return rank where 1 is the top rank.
+ * @return rank where 1 is the top rank as a String; returns empty string for null user
*/
private String getRankValue(World world, User user) {
if (user == null) {
@@ -304,6 +351,13 @@ private String getRankValue(World world, User user) {
.values().stream().filter(l -> l > level).count() + 1);
}
+ /**
+ * Return the level for the island the user is currently visiting (if any).
+ *
+ * @param gm the GameModeAddon (used to map to the overworld)
+ * @param user the user to check
+ * @return island level string for the visited island, or empty/ "0" when not applicable
+ */
String getVisitedIslandLevel(GameModeAddon gm, User user) {
if (user == null || !gm.inWorld(user.getWorld()))
return "";
@@ -314,10 +368,16 @@ String getVisitedIslandLevel(GameModeAddon gm, User user) {
/**
* Gets the most specific identifier object for a block.
- * NOTE: Does not currently support getting custom block IDs (e.g., ItemsAdder)
- * directly from the Block object due to hook limitations.
- * @param block The block
- * @return EntityType, Material, or null if air/invalid.
+ *
+ * The identifier is one of:
+ * - EntityType for mob spawners (when the spawner block contains a specific spawned type)
+ * - Material for regular blocks
+ * - null for air or unknown/invalid blocks
+ *
+ * This is used to map the block to the same identifier the BlockConfig and IslandLevels use.
+ *
+ * @param block The block to inspect, null-safe
+ * @return an EntityType or Material, or null for air/unknown
*/
@Nullable
private Object getBlockIdentifier(@Nullable Block block) {
@@ -339,19 +399,52 @@ private Object getBlockIdentifier(@Nullable Block block) {
return Material.SPAWNER; // Return generic spawner material if state invalid
}
+ // Check Oraxen custom blocks (noteblock/stringblock/chorusblock mechanics)
+ if (BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) {
+ String oraxenId = OraxenHook.getOraxenBlockID(block.getLocation());
+ if (oraxenId != null) {
+ return "oraxen:" + oraxenId;
+ }
+ }
+
+ // Check Nexo custom blocks
+ if (addon.isNexo()) {
+ CustomBlockMechanic nexoMechanic = NexoBlocks.customBlockMechanic(block.getLocation());
+ if (nexoMechanic != null) {
+ return "nexo:" + nexoMechanic.getItemID();
+ }
+ }
+
+ // Check ItemsAdder custom blocks
+ if (addon.isItemsAdder()) {
+ String iaId = ItemsAdderHook.getInCustomRegion(block.getLocation());
+ if (iaId != null) {
+ return iaId;
+ }
+ }
+
// Fallback to the Material for regular blocks
return type;
}
/**
* Gets the most specific identifier object for an ItemStack.
- * Prioritizes standard Bukkit methods for spawners.
- * Adds support for reading "spawnermeta:type" NBT tag via PDC.
- * Returns null for spawners if the specific type cannot be determined.
- * Supports ItemsAdder items.
- * @param itemStack The ItemStack
- * @return EntityType, Material (for standard blocks), String (for custom items),
- * or null (if air, invalid, or unidentified spawner).
+ *
+ * This method attempts to:
+ * 1) Resolve a specific EntityType for spawner items via BlockStateMeta or a PersistentDataContainer key.
+ * If the exact spawned mob cannot be determined, it returns null for spawner items so counts
+ * are not incorrectly attributed.
+ * 2) If ItemsAdder is present, check for custom item Namespaced ID and return it (String).
+ * 3) Fallback to returning the Material for block-like items, otherwise null for non-blocks.
+ *
+ * The return type is one of:
+ * - EntityType (specific spawner type)
+ * - Material (normal block-type items)
+ * - String (custom items IDs like ItemsAdder)
+ * - null (air, invalid item, or unidentified spawner item)
+ *
+ * @param itemStack the item to inspect (may be null)
+ * @return EntityType, Material, String, or null
*/
@Nullable
private Object getItemIdentifier(@Nullable ItemStack itemStack) {
@@ -410,20 +503,42 @@ private Object getItemIdentifier(@Nullable ItemStack itemStack) {
} // End of Spawner handling
// 2. Handle potential custom items (e.g., ItemsAdder)
- if (addon.isItemsAdder()) {
- Optional customId = ItemsAdderHook.getNamespacedId(itemStack);
- if (customId.isPresent()) {
- return customId.get(); // Return the String ID from ItemsAdder
- }
- }
-
- // 3. Fallback to Material for regular items that represent blocks
- return type.isBlock() ? type : null;
+ if (addon.isItemsAdder()) {
+ Optional customId = ItemsAdderHook.getNamespacedId(itemStack);
+ if (customId.isPresent()) {
+ return customId.get(); // Return the String ID from ItemsAdder
+ }
+ }
+
+ // 3. Handle Oraxen custom items
+ if (BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) {
+ Optional oraxenId = OraxenHook.getNamespacedId(itemStack);
+ if (oraxenId.isPresent()) {
+ return "oraxen:" + oraxenId.get();
+ }
+ }
+
+ // 4. Handle Nexo custom items
+ if (addon.isNexo()) {
+ String nexoId = NexoItems.idFromItem(itemStack);
+ if (nexoId != null) {
+ return "nexo:" + nexoId;
+ }
+ }
+
+ // 5. Fallback to Material for regular items that represent blocks
+ return type.isBlock() ? type : null;
}
/**
- * Helper method to convert a String key from the config (e.g., "pig_spawner", "minecraft:stone")
- * back into the corresponding Object (EntityType, Material, String) used by IslandLevels.
+ * Convert a configuration key string (from the block config) into the identifier object
+ * used by IslandLevels.
+ *
+ * - Handles "pig_spawner" style keys and resolves them to EntityType where possible.
+ * - Resolves namespaced Material keys using Bukkit's Registry.
+ * - Returns the original string for custom items (ItemsAdder) when present in registry.
+ * - Returns Material.SPAWNER for generic "spawner" key, otherwise null if unresolvable.
+ *
* @param configKey The key string from block config.
* @return EntityType, Material, String identifier, or null if not resolvable.
*/
@@ -468,24 +583,24 @@ private Object getObjectFromConfigKey(String configKey) {
return material;
}
- // Assume it's a custom String key (e.g., ItemsAdder) if not resolved yet
- if (addon.isItemsAdder() && ItemsAdderHook.isInRegistry(configKey)) { // Use original case key for lookup?
- return configKey;
- }
-
// Final check: maybe it's the generic "spawner" key from config?
- if(lowerCaseKey.equals("spawner")) {
+ if (lowerCaseKey.equals("spawner")) {
return Material.SPAWNER;
}
- return null;
+
+ // Return the key as-is for custom blocks (ItemsAdder, Oraxen, Nexo).
+ // These are stored as String keys in mdCount/uwCount.
+ return configKey;
}
/**
* Gets the block count for a specific identifier object in a user's island.
+ * This is a thin wrapper that validates inputs and returns "0" when missing.
+ *
* @param gm GameModeAddon
* @param user User requesting the count
* @param identifier The identifier object (EntityType, Material, String)
- * @return String representation of the count.
+ * @return String representation of the count (zero when not available)
*/
private String getBlockCount(GameModeAddon gm, User user, @Nullable Object identifier) {
if (user == null || identifier == null) {
@@ -496,7 +611,12 @@ private String getBlockCount(GameModeAddon gm, User user, @Nullable Object ident
/**
* Gets the block count for a specific identifier object from IslandLevels.
- * This now correctly uses EntityType or Material as keys based on `DetailsPanel`'s logic.
+ *
+ * - Fetches the Island for the user and then the IslandLevels data.
+ * - IslandLevels stores counts in two maps (mdCount and uwCount) depending on how values
+ * are classified; we add both to provide the complete count.
+ * - Returns "0" if island or data is unavailable.
+ *
* @param gm GameModeAddon
* @param user User to get count for
* @param identifier The identifier object (EntityType, Material, String)
@@ -520,5 +640,4 @@ private String getBlockCountForUser(GameModeAddon gm, User user, Object identifi
return String.valueOf(count);
}
-
}
diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java
index 596f9ce..a7425eb 100644
--- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java
+++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java
@@ -31,15 +31,22 @@
import org.bukkit.block.ShulkerBox;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Slab;
+import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BlockStateMeta;
+import io.th0rgal.oraxen.mechanics.provided.gameplay.furniture.FurnitureMechanic;
+
import com.bgsoftware.wildstacker.api.WildStackerAPI;
import com.bgsoftware.wildstacker.api.objects.StackedBarrel;
import com.google.common.collect.Multiset;
import com.google.common.collect.Multiset.Entry;
import com.google.common.collect.Multisets;
+import com.nexomc.nexo.api.NexoBlocks;
+import com.nexomc.nexo.api.NexoFurniture;
+import com.nexomc.nexo.api.NexoItems;
+import com.nexomc.nexo.mechanics.custom_block.CustomBlockMechanic;
import dev.rosewood.rosestacker.api.RoseStackerAPI;
import us.lynuxcraft.deadsilenceiv.advancedchests.AdvancedChestsAPI;
@@ -73,6 +80,7 @@ public class IslandLevelCalculator {
private final int seaHeight;
private final List stackedBlocks = new ArrayList<>();
private final Set chestBlocks = new HashSet<>();
+ private final Set furnitureChunks = new HashSet<>();
private final Map spawners = new HashMap<>();
/**
@@ -422,7 +430,19 @@ private void countItemStack(ItemStack i) {
}
return;
}
-
+ // Check Nexo
+ if (addon.isNexo() && NexoItems.exists(i)) {
+ String id = NexoItems.idFromItem(i);
+ if (id == null) {
+ return;
+ }
+ id = "nexo:" + id;
+ for (int c = 0; c < i.getAmount(); c++) {
+ checkBlock(id, false);
+ }
+ return;
+ }
+
if (i == null || !i.getType().isBlock())
return;
@@ -473,6 +493,10 @@ record ChunkPair(World world, Chunk chunk, ChunkSnapshot chunkSnapshot) {
}
private void scanAsync(ChunkPair cp) {
+// Track chunks for furniture entity scanning (Oraxen and Nexo are entity-based)
+ if (BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent() || addon.isNexo()) {
+ furnitureChunks.add(cp.chunk);
+ }
// Get the chunk coordinates and island boundaries once per chunk scan
int chunkX = cp.chunk.getX() << 4;
int chunkZ = cp.chunk.getZ() << 4;
@@ -518,10 +542,11 @@ private void processBlock(ChunkPair cp, int x, int y, int z, int globalX, int gl
// Create a Location object only when needed for more complex checks.
Location loc = null;
- // === Custom Block Hooks (ItemsAdder, Oraxen) ===
+ // === Custom Block Hooks (ItemsAdder, Oraxen, Nexo) ===
// These hooks can define custom blocks that override vanilla behavior.
// They must be checked first.
- if (addon.isItemsAdder() || BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) {
+ if (addon.isItemsAdder() || BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()
+ || addon.isNexo()) {
loc = new Location(cp.world, globalX, y, globalZ);
String customBlockId = null;
if (addon.isItemsAdder()) {
@@ -533,6 +558,12 @@ private void processBlock(ChunkPair cp, int x, int y, int z, int globalX, int gl
customBlockId = "oraxen:" + oraxenId; // Make a namespaced ID
}
}
+ if (customBlockId == null && addon.isNexo()) {
+ CustomBlockMechanic nexoMechanic = NexoBlocks.customBlockMechanic(loc);
+ if (nexoMechanic != null) {
+ customBlockId = "nexo:" + nexoMechanic.getItemID();
+ }
+ }
if (customBlockId != null) {
// If a custom block is found, count it and stop further processing for this block.
@@ -741,6 +772,8 @@ public void scanIsland(Pipeliner pipeliner) {
// Chunk finished
// This was the last chunk. Handle stacked blocks, spawners, chests and exit
handleStackedBlocks().thenCompose(v -> handleSpawners()).thenCompose(v -> handleChests())
+ .thenCompose(v -> handleOraxenFurniture())
+ .thenCompose(v -> handleNexoFurniture())
.thenRun(() -> {
this.tidyUp();
this.getR().complete(getResults());
@@ -782,6 +815,91 @@ private CompletableFuture handleChests() {
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
}
+ /**
+ * Scans entities in each island chunk for Oraxen furniture and counts them toward the island level.
+ * Furniture is entity-based in Oraxen (item displays / armor stands), so it is invisible to the
+ * normal block scanner. Only the base entity of each furniture piece is counted to avoid
+ * double-counting multi-entity furniture.
+ *
+ * @return a CompletableFuture that completes when all chunks have been checked
+ */
+ private CompletableFuture handleOraxenFurniture() {
+ if (!BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent() || furnitureChunks.isEmpty()) {
+ return CompletableFuture.completedFuture(null);
+ }
+ int minX = island.getMinProtectedX();
+ int maxX = island.getMaxProtectedX();
+ int minZ = island.getMinProtectedZ();
+ int maxZ = island.getMaxProtectedZ();
+ List> futures = new ArrayList<>();
+ for (Chunk chunk : furnitureChunks) {
+ CompletableFuture future = Util.getChunkAtAsync(chunk.getWorld(), chunk.getX(), chunk.getZ())
+ .thenAccept(c -> {
+ for (Entity entity : c.getEntities()) {
+ // Only count the root/base entity of each furniture piece
+ if (!OraxenHook.isBaseEntity(entity)) {
+ continue;
+ }
+ Location loc = entity.getLocation();
+ // Confirm entity is within the island's protected bounds
+ if (loc.getBlockX() < minX || loc.getBlockX() >= maxX
+ || loc.getBlockZ() < minZ || loc.getBlockZ() >= maxZ) {
+ continue;
+ }
+ var mechanic = OraxenHook.getFurnitureMechanic(entity);
+ if (mechanic == null) {
+ continue;
+ }
+ boolean belowSeaLevel = seaHeight > 0 && loc.getBlockY() <= seaHeight;
+ checkBlock("oraxen:" + mechanic.getItemID(), belowSeaLevel);
+ }
+ });
+ futures.add(future);
+ }
+ return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
+ }
+
+ /**
+ * Scans entities in each island chunk for Nexo furniture and counts them toward the island level.
+ * Nexo furniture is entity-based (ItemDisplay entities), so it is invisible to the normal block
+ * scanner. Only entities for which a FurnitureMechanic can be resolved are counted, which
+ * naturally filters to base furniture entities.
+ *
+ * @return a CompletableFuture that completes when all chunks have been checked
+ */
+ private CompletableFuture handleNexoFurniture() {
+ if (!addon.isNexo() || furnitureChunks.isEmpty()) {
+ return CompletableFuture.completedFuture(null);
+ }
+ int minX = island.getMinProtectedX();
+ int maxX = island.getMaxProtectedX();
+ int minZ = island.getMinProtectedZ();
+ int maxZ = island.getMaxProtectedZ();
+ List> futures = new ArrayList<>();
+ for (Chunk chunk : furnitureChunks) {
+ CompletableFuture future = Util.getChunkAtAsync(chunk.getWorld(), chunk.getX(), chunk.getZ())
+ .thenAccept(c -> {
+ for (Entity entity : c.getEntities()) {
+ Location loc = entity.getLocation();
+ // Confirm entity is within the island's protected bounds
+ if (loc.getBlockX() < minX || loc.getBlockX() >= maxX
+ || loc.getBlockZ() < minZ || loc.getBlockZ() >= maxZ) {
+ continue;
+ }
+ // getFurnitureMechanic returns non-null only for furniture base entities
+ var mechanic = NexoFurniture.furnitureMechanic(entity);
+ if (mechanic == null) {
+ continue;
+ }
+ boolean belowSeaLevel = seaHeight > 0 && loc.getBlockY() <= seaHeight;
+ checkBlock("nexo:" + mechanic.getItemID(), belowSeaLevel);
+ }
+ });
+ futures.add(future);
+ }
+ return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
+ }
+
private CompletableFuture handleStackedBlocks() {
// Deal with any stacked blocks
List> futures = new ArrayList<>();
diff --git a/src/main/java/world/bentobox/level/calculators/Results.java b/src/main/java/world/bentobox/level/calculators/Results.java
index 6dbd2a5..db8124c 100644
--- a/src/main/java/world/bentobox/level/calculators/Results.java
+++ b/src/main/java/world/bentobox/level/calculators/Results.java
@@ -173,7 +173,7 @@ public long getInitialCount() {
}
/**
- * @param long1 the initialCount to set
+ * @param count the initialCount to set
*/
public void setInitialCount(Long count) {
this.initialCount.set(count);
diff --git a/src/main/java/world/bentobox/level/config/BlockConfig.java b/src/main/java/world/bentobox/level/config/BlockConfig.java
index feb7495..2c9793a 100644
--- a/src/main/java/world/bentobox/level/config/BlockConfig.java
+++ b/src/main/java/world/bentobox/level/config/BlockConfig.java
@@ -19,6 +19,8 @@
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.EntityType;
+import com.nexomc.nexo.api.NexoItems;
+
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.hooks.ItemsAdderHook;
import world.bentobox.bentobox.hooks.OraxenHook;
@@ -105,6 +107,9 @@ private boolean isOther(String key) {
if (key.startsWith("oraxen:") && BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent()) {
return OraxenHook.exists(key.substring(7));
}
+ if (key.startsWith("nexo:") && addon.isNexo()) {
+ return NexoItems.exists(key.substring(5));
+ }
// Check ItemsAdder
return addon.isItemsAdder() && ItemsAdderHook.isInRegistry(key);
}
@@ -240,7 +245,7 @@ public Integer getValue(World world, Object obj) {
/**
* Return true if the block should be hidden
- * @param m block material or entity type of spawner
+ * @param obj object that can be a material or string
* @return true if hidden
*/
public boolean isHiddenBlock(Object obj) {
@@ -254,7 +259,7 @@ public boolean isHiddenBlock(Object obj) {
/**
* Return true if the block should not be hidden
- * @param m block material
+ * @param obj object that can be a material or string
* @return false if hidden
*/
public boolean isNotHiddenBlock(Object obj) {
@@ -289,4 +294,11 @@ public Map> getWorldBlockValues() {
return worldBlockValues;
}
+ /**
+ * @return the blockLimits
+ */
+ public Map getBlockLimits() {
+ return blockLimits;
+ }
+
}
diff --git a/src/main/java/world/bentobox/level/util/Utils.java b/src/main/java/world/bentobox/level/util/Utils.java
index dff4a02..5c23c0b 100644
--- a/src/main/java/world/bentobox/level/util/Utils.java
+++ b/src/main/java/world/bentobox/level/util/Utils.java
@@ -161,6 +161,8 @@ public static String prettifyObject(Object object, User user) {
// Remove prefix
if (key.startsWith("oraxen:")) {
key = key.substring(7);
+ } else if (key.startsWith("nexo:")) {
+ key = key.substring(5);
}
}
diff --git a/src/main/resources/blockconfig.yml b/src/main/resources/blockconfig.yml
index 6eebbbc..353f3d6 100644
--- a/src/main/resources/blockconfig.yml
+++ b/src/main/resources/blockconfig.yml
@@ -141,7 +141,6 @@ blocks:
carved_pumpkin: 2
cauldron: 10
cave_air: 0
- chain: 2
chain_command_block: 0
chest: 8
chipped_anvil: 9
diff --git a/src/test/java/world/bentobox/level/CommonTestSetup.java b/src/test/java/world/bentobox/level/CommonTestSetup.java
new file mode 100644
index 0000000..876424a
--- /dev/null
+++ b/src/test/java/world/bentobox/level/CommonTestSetup.java
@@ -0,0 +1,336 @@
+package world.bentobox.level;
+
+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.Settings;
+import world.bentobox.bentobox.api.addons.GameModeAddon;
+import world.bentobox.bentobox.api.commands.CompositeCommand;
+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.
+ * @author tastybento
+ *
+ */
+public abstract class CommonTestSetup {
+
+ protected UUID uuid = UUID.randomUUID();
+
+ @Mock
+ protected GameModeAddon gameModeAddon;
+ @Mock
+ protected Level addon;
+ @Mock
+ protected Player p;
+ @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 CompositeCommand ic;
+
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ MockitoAnnotations.openMocks(this);
+ // Processes the @Mock annotations and initializes the field
+ closeable = MockitoAnnotations.openMocks(this);
+ server = MockBukkit.mock();
+ // Bukkit
+ // Set up plugin
+ WhiteBox.setInternalState(BentoBox.class, "instance", plugin);
+
+ // Register the 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);
+
+ // World
+ when(world.toString()).thenReturn("world");
+ when(world.getName()).thenReturn("BSkyBlock_world");
+
+ // 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); // Paper
+
+ // 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(p.getUniqueId()).thenReturn(uuid);
+ when(p.getLocation()).thenReturn(location);
+ when(p.getWorld()).thenReturn(world);
+ when(p.getName()).thenReturn("tastybento");
+ when(p.getInventory()).thenReturn(inv);
+ when(p.spigot()).thenReturn(spigot);
+ when(p.getType()).thenReturn(EntityType.PLAYER);
+ when(p.getWorld()).thenReturn(world);
+
+ User.setPlugin(plugin);
+ User.clearUsers();
+ User.getInstance(p);
+
+ // 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");
+ // Addon
+ 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(p.getMetadata(anyString())).thenReturn(Collections.singletonList(mdv));
+
+ // Locales & Placeholders
+ when(lm.get(any(), any())).thenAnswer((Answer) invocation -> invocation.getArgument(1, String.class));
+ PlaceholdersManager phm = mock(PlaceholdersManager.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
+ Settings settings = new 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);
+
+ // Util
+ mockedUtil.when(() -> Util.findFirstMatchingEnum(any(), any())).thenCallRealMethod();
+ // Util translate color codes (used in user translate methods)
+ //mockedUtil.when(() -> translateColorCodes(anyString())).thenAnswer((Answer) invocation -> invocation.getArgument(0, String.class));
+
+ // 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);
+
+ // Addon
+ when(addon.getPlugin()).thenReturn(plugin);
+ when(addon.getIslands()).thenReturn(im);
+ when(addon.getIslandsManager()).thenReturn(im);
+
+ // Command
+ when(ic.getAddon()).thenReturn(addon);
+ when(ic.getPermissionPrefix()).thenReturn("bskyblock.");
+ when(ic.getLabel()).thenReturn("island");
+ when(ic.getTopLabel()).thenReturn("island");
+ when(ic.getWorld()).thenReturn(world);
+ when(ic.getTopLabel()).thenReturn("bsb");
+
+ }
+
+ /**
+ * @throws Exception
+ */
+ @AfterEach
+ public void tearDown() throws Exception {
+ // IMPORTANT: Explicitly close the mock to prevent leakage
+ 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);
+ }
+
+ }
+
+ /**
+ * Check that spigot sent the message
+ * @param message - message to check
+ */
+ public void checkSpigotMessage(String expectedMessage) {
+ checkSpigotMessage(expectedMessage, 1);
+ }
+
+ @SuppressWarnings("deprecation")
+ public void checkSpigotMessage(String expectedMessage, int expectedOccurrences) {
+ // Capture the argument passed to spigot().sendMessage(...) if messages are sent
+ ArgumentCaptor captor = ArgumentCaptor.forClass(TextComponent.class);
+
+ // Verify that sendMessage() was called at least 0 times (capture any sent messages)
+ verify(spigot, atLeast(0)).sendMessage(captor.capture());
+
+ // Get all captured TextComponents
+ List capturedMessages = captor.getAllValues();
+
+ // Count the number of occurrences of the expectedMessage in the captured messages
+ long actualOccurrences = capturedMessages.stream().map(component -> component.toLegacyText()) // Convert each TextComponent to plain text
+ .filter(messageText -> messageText.contains(expectedMessage)) // Check if the message contains the expected text
+ .count(); // Count how many times the expected message appears
+
+ // Assert that the number of occurrences matches the expectedOccurrences
+ assertEquals(expectedOccurrences,
+ actualOccurrences, "Expected message occurrence mismatch: " + expectedMessage);
+ }
+
+ /**
+ * Get the explode event
+ * @param entity
+ * @param l
+ * @param list
+ * @return
+ */
+ public EntityExplodeEvent getExplodeEvent(Entity entity, Location l, List list) {
+ //return new EntityExplodeEvent(entity, l, list, 0, null);
+ 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) {
+ //Technically this null is not allowed, but it works right now
+ return new PlayerDeathEvent(player, null, drops, droppedExp, newExp,
+ newTotalExp, newLevel, deathMessage);
+ }
+
+}
diff --git a/src/test/java/world/bentobox/level/LevelTest.java b/src/test/java/world/bentobox/level/LevelTest.java
index 2dd0e87..550509a 100644
--- a/src/test/java/world/bentobox/level/LevelTest.java
+++ b/src/test/java/world/bentobox/level/LevelTest.java
@@ -16,8 +16,8 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
import java.util.Collections;
-import java.util.Comparator;
import java.util.Optional;
import java.util.UUID;
import java.util.jar.JarEntry;
@@ -25,78 +25,50 @@
import java.util.logging.Logger;
import org.bukkit.Bukkit;
-import org.bukkit.Server;
-import org.bukkit.UnsafeValues;
-import org.bukkit.World;
-import org.bukkit.entity.Player;
-import org.bukkit.inventory.ItemFactory;
-import org.bukkit.inventory.meta.ItemMeta;
-import org.bukkit.plugin.PluginManager;
-import org.bukkit.scheduler.BukkitScheduler;
import org.eclipse.jdt.annotation.NonNull;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Test;
-import org.junit.runner.RunWith;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+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 org.mockito.stubbing.Answer;
-import org.powermock.api.mockito.PowerMockito;
-import org.powermock.core.classloader.annotations.PrepareForTest;
-import org.powermock.modules.junit4.PowerMockRunner;
-import org.powermock.reflect.Whitebox;
-import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.Settings;
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.user.User;
import world.bentobox.bentobox.database.DatabaseSetup.DatabaseType;
-import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.hooks.ItemsAdderHook;
import world.bentobox.bentobox.managers.AddonsManager;
import world.bentobox.bentobox.managers.CommandsManager;
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.PlaceholdersManager;
import world.bentobox.bentobox.util.Util;
import world.bentobox.level.config.BlockConfig;
import world.bentobox.level.config.ConfigSettings;
import world.bentobox.level.listeners.IslandActivitiesListeners;
import world.bentobox.level.listeners.JoinLeaveListener;
-import world.bentobox.level.mocks.ServerMocks;
/**
* @author tastybento
*
*/
-@SuppressWarnings("deprecation")
-@RunWith(PowerMockRunner.class)
-@PrepareForTest({ Bukkit.class, BentoBox.class, User.class, Util.class, ItemsAdderHook.class })
-public class LevelTest {
+public class LevelTest extends CommonTestSetup {
private static File jFile;
@Mock
private User user;
@Mock
- private IslandsManager im;
- @Mock
- private Island island;
- @Mock
- private BentoBox plugin;
- @Mock
private FlagsManager fm;
@Mock
private GameModeAddon gameMode;
@Mock
private AddonsManager am;
- @Mock
- private BukkitScheduler scheduler;
@Mock
private Settings pluginSettings;
@@ -111,29 +83,25 @@ public class LevelTest {
private CompositeCommand cmd;
@Mock
private CompositeCommand adminCmd;
- @Mock
- private World world;
- private UUID uuid;
- @Mock
- private PluginManager pim;
@Mock
private BlockConfig blockConfig;
@Mock
private HooksManager hm;
+ private MockedStatic itemsAdderMock;
- @BeforeClass
+ @BeforeAll
public static void beforeClass() throws IOException {
// Make the addon jar
jFile = new File("addon.jar");
// Copy over config file from src folder
Path fromPath = Paths.get("src/main/resources/config.yml");
Path path = Paths.get("config.yml");
- Files.copy(fromPath, path);
+ Files.copy(fromPath, path, StandardCopyOption.REPLACE_EXISTING);
// Copy over block config file from src folder
fromPath = Paths.get("src/main/resources/blockconfig.yml");
path = Paths.get("blockconfig.yml");
- Files.copy(fromPath, path);
+ Files.copy(fromPath, path, StandardCopyOption.REPLACE_EXISTING);
try (JarOutputStream tempJarOutputStream = new JarOutputStream(new FileOutputStream(jFile))) {
// Added the new files to the jar.
try (FileInputStream fis = new FileInputStream(path.toFile())) {
@@ -151,29 +119,25 @@ public static void beforeClass() throws IOException {
/**
* @throws java.lang.Exception
*/
- @Before
+ @Override
+ @BeforeEach
public void setUp() throws Exception {
+ super.setUp();
when(plugin.getHooks()).thenReturn(hm);
- Server server = ServerMocks.newServer();
- // Set up plugin
- Whitebox.setInternalState(BentoBox.class, "instance", plugin);
- when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
-
// The database type has to be created one line before the thenReturn() to work!
DatabaseType value = DatabaseType.JSON;
when(plugin.getSettings()).thenReturn(pluginSettings);
when(pluginSettings.getDatabaseType()).thenReturn(value);
// ItemsAdderHook
- PowerMockito.mockStatic(ItemsAdderHook.class, Mockito.RETURNS_MOCKS);
- when(ItemsAdderHook.isInRegistry(anyString())).thenReturn(true);
+ itemsAdderMock = Mockito.mockStatic(ItemsAdderHook.class, Mockito.RETURNS_MOCKS);
+ itemsAdderMock.when(() -> ItemsAdderHook.isInRegistry(anyString())).thenReturn(true);
// Command manager
CommandsManager cm = mock(CommandsManager.class);
when(plugin.getCommandsManager()).thenReturn(cm);
// Player
- Player p = mock(Player.class);
// Sometimes use Mockito.withSettings().verboseLogging()
when(user.isOp()).thenReturn(false);
uuid = UUID.randomUUID();
@@ -182,29 +146,17 @@ public void setUp() throws Exception {
when(user.getName()).thenReturn("tastybento");
User.setPlugin(plugin);
- // Island World Manager
- IslandWorldManager iwm = mock(IslandWorldManager.class);
- when(plugin.getIWM()).thenReturn(iwm);
-
// Player has island to begin with
when(im.getIsland(Mockito.any(), Mockito.any(UUID.class))).thenReturn(island);
- when(plugin.getIslands()).thenReturn(im);
// Locales
// Return the reference (USE THIS IN THE FUTURE)
when(user.getTranslation(Mockito.anyString()))
.thenAnswer((Answer) invocation -> invocation.getArgument(0, String.class));
- // Server
- PowerMockito.mockStatic(Bukkit.class);
- when(Bukkit.getServer()).thenReturn(server);
- when(Bukkit.getLogger()).thenReturn(Logger.getAnonymousLogger());
- when(Bukkit.getPluginManager()).thenReturn(mock(PluginManager.class));
- when(Bukkit.getBukkitVersion()).thenReturn("");
// Util
- PowerMockito.mockStatic(Util.class, Mockito.RETURNS_MOCKS);
- when(Util.inTest()).thenReturn(true);
+ mockedUtil.when(() -> Util.inTest()).thenReturn(true);
// Addon
addon = new Level();
@@ -236,37 +188,22 @@ public void setUp() throws Exception {
when(plugin.getFlagsManager()).thenReturn(fm);
when(fm.getFlags()).thenReturn(Collections.emptyList());
- // Bukkit
- when(Bukkit.getScheduler()).thenReturn(scheduler);
- ItemMeta meta = mock(ItemMeta.class);
- ItemFactory itemFactory = mock(ItemFactory.class);
- when(itemFactory.getItemMeta(any())).thenReturn(meta);
- when(Bukkit.getItemFactory()).thenReturn(itemFactory);
- UnsafeValues unsafe = mock(UnsafeValues.class);
- when(unsafe.getDataVersion()).thenReturn(777);
- when(Bukkit.getUnsafe()).thenReturn(unsafe);
- when(Bukkit.getPluginManager()).thenReturn(pim);
-
// placeholders
when(plugin.getPlaceholdersManager()).thenReturn(phm);
- // World
- when(world.getName()).thenReturn("bskyblock-world");
- // Island
- when(island.getWorld()).thenReturn(world);
- when(island.getOwner()).thenReturn(uuid);
}
/**
* @throws java.lang.Exception
*/
- @After
+ @Override
+ @AfterEach
public void tearDown() throws Exception {
- ServerMocks.unsetBukkitServer();
+ super.tearDown();
deleteAll(new File("database"));
}
- @AfterClass
+ @AfterAll
public static void cleanUp() throws Exception {
new File("addon.jar").delete();
new File("config.yml").delete();
@@ -274,17 +211,12 @@ public static void cleanUp() throws Exception {
deleteAll(new File("addons"));
}
- private static void deleteAll(File file) throws IOException {
- if (file.exists()) {
- Files.walk(file.toPath()).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
- }
- }
-
/**
* Test method for {@link world.bentobox.level.Level#allLoaded()
*/
@Test
public void testAllLoaded() {
+ mockedBukkit.when(() -> Bukkit.getWorld("acidisland_world")).thenReturn(null);
addon.allLoaded();
verify(plugin).log("[Level] Level hooking into BSkyBlock");
verify(cmd, times(4)).getAddon(); // 4 commands
diff --git a/src/test/java/world/bentobox/level/LevelsManagerTest.java b/src/test/java/world/bentobox/level/LevelsManagerTest.java
index a790604..ab96e75 100644
--- a/src/test/java/world/bentobox/level/LevelsManagerTest.java
+++ b/src/test/java/world/bentobox/level/LevelsManagerTest.java
@@ -12,11 +12,7 @@
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.ArrayList;
-import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -27,36 +23,24 @@
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
-import org.bukkit.inventory.ItemFactory;
-import org.bukkit.inventory.meta.ItemMeta;
-import org.bukkit.plugin.PluginManager;
-import org.bukkit.scheduler.BukkitScheduler;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.BeforeClass;
import org.junit.Test;
-import org.junit.runner.RunWith;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
+import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.stubbing.Answer;
-import org.powermock.api.mockito.PowerMockito;
-import org.powermock.core.classloader.annotations.PrepareForTest;
-import org.powermock.modules.junit4.PowerMockRunner;
-import org.powermock.reflect.Whitebox;
import com.google.common.collect.ImmutableSet;
-import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.Settings;
-import world.bentobox.bentobox.api.panels.builders.PanelBuilder;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.database.AbstractDatabaseHandler;
import world.bentobox.bentobox.database.DatabaseSetup;
import world.bentobox.bentobox.database.DatabaseSetup.DatabaseType;
import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.managers.IslandWorldManager;
-import world.bentobox.bentobox.managers.IslandsManager;
import world.bentobox.bentobox.managers.PlayersManager;
import world.bentobox.level.calculators.Pipeliner;
import world.bentobox.level.calculators.Results;
@@ -68,29 +52,18 @@
* @author tastybento
*
*/
-@RunWith(PowerMockRunner.class)
-@PrepareForTest({ Bukkit.class, BentoBox.class, DatabaseSetup.class, PanelBuilder.class })
-public class LevelsManagerTest {
+public class LevelsManagerTest extends CommonTestSetup {
@Mock
- private static AbstractDatabaseHandler