diff --git a/CLAUDE.md b/CLAUDE.md
index 8bb5bd9..27ee7c6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -89,8 +89,100 @@ JaCoCo coverage reports are generated during `mvn verify`.
| `locales/` | `src/main/resources/locales/` | Translation strings |
| `panels/` | `src/main/resources/panels/` | GUI layout definitions |
+**Panel template upgrades:** Files under `panels/` are copied to the addon's data folder (`plugins/BentoBox/addons/Level/panels/`) on first run and are **not** overwritten on upgrade. If a release modifies a panel template (new tabs, buttons, slots, etc.), the release notes/changelog must explicitly instruct users to delete the affected on-disk panel file so it regenerates — otherwise existing servers will silently keep the old layout.
+
+**Current upgrade-sensitive example:** If `src/main/resources/panels/detail_panel.yml` changes (for example by adding a new `DONATED` tab), existing servers must delete/regenerate `plugins/BentoBox/addons/Level/panels/detail_panel.yml` after upgrading or they will continue using the old panel definition and the new tab will not appear.
## 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
+
+## Dependency Source Lookup
+
+When you need to inspect source code for a dependency (e.g., BentoBox, addons):
+
+1. **Check local Maven repo first**: `~/.m2/repository/` — sources jars are named `*-sources.jar`
+2. **Check the workspace**: Look for sibling directories or Git submodules that may contain the dependency as a local project (e.g., `../bentoBox`, `../addon-*`)
+3. **Check Maven local cache for already-extracted sources** before downloading anything
+4. Only download a jar or fetch from the internet if the above steps yield nothing useful
+
+Prefer reading `.java` source files directly from a local Git clone over decompiling or extracting a jar.
+
+In general, the latest version of BentoBox should be targeted.
+
+## Project Layout
+
+Related projects are checked out as siblings under `~/git/`:
+
+**Core:**
+- `bentobox/` — core BentoBox framework
+
+**Game modes:**
+- `addon-acidisland/` — AcidIsland game mode
+- `addon-bskyblock/` — BSkyBlock game mode
+- `Boxed/` — Boxed game mode (expandable box area)
+- `CaveBlock/` — CaveBlock game mode
+- `OneBlock/` — AOneBlock game mode
+- `SkyGrid/` — SkyGrid game mode
+- `RaftMode/` — Raft survival game mode
+- `StrangerRealms/` — StrangerRealms game mode
+- `Brix/` — plot game mode
+- `parkour/` — Parkour game mode
+- `poseidon/` — Poseidon game mode
+- `gg/` — gg game mode
+
+**Addons:**
+- `addon-level/` — island level calculation
+- `addon-challenges/` — challenges system
+- `addon-welcomewarpsigns/` — warp signs
+- `addon-limits/` — block/entity limits
+- `addon-invSwitcher/` / `invSwitcher/` — inventory switcher
+- `addon-biomes/` / `Biomes/` — biomes management
+- `Bank/` — island bank
+- `Border/` — world border for islands
+- `Chat/` — island chat
+- `CheckMeOut/` — island submission/voting
+- `ControlPanel/` — game mode control panel
+- `Converter/` — ASkyBlock to BSkyBlock converter
+- `DimensionalTrees/` — dimension-specific trees
+- `discordwebhook/` — Discord integration
+- `Downloads/` — BentoBox downloads site
+- `DragonFights/` — per-island ender dragon fights
+- `ExtraMobs/` — additional mob spawning rules
+- `FarmersDance/` — twerking crop growth
+- `GravityFlux/` — gravity addon
+- `Greenhouses-addon/` — greenhouse biomes
+- `IslandFly/` — island flight permission
+- `IslandRankup/` — island rankup system
+- `Likes/` — island likes/dislikes
+- `Limits/` — block/entity limits
+- `lost-sheep/` — lost sheep adventure
+- `MagicCobblestoneGenerator/` — custom cobblestone generator
+- `PortalStart/` — portal-based island start
+- `pp/` — pp addon
+- `Regionerator/` — region management
+- `Residence/` — residence addon
+- `TopBlock/` — top ten for OneBlock
+- `TwerkingForTrees/` — twerking tree growth
+- `Upgrades/` — island upgrades (Vault)
+- `Visit/` — island visiting
+- `weblink/` — web link addon
+- `CrowdBound/` — CrowdBound addon
+
+**Data packs:**
+- `BoxedDataPack/` — advancement datapack for Boxed
+
+**Documentation & tools:**
+- `docs/` — main documentation site
+- `docs-chinese/` — Chinese documentation
+- `docs-french/` — French documentation
+- `BentoBoxWorld.github.io/` — GitHub Pages site
+- `website/` — website
+- `translation-tool/` — translation tool
+
+Check these for source before any network fetch.
+
+## Key Dependencies (source locations)
+
+- `world.bentobox:bentobox` → `~/git/bentobox/src/`
diff --git a/pom.xml b/pom.xml
index f1207c4..86aebeb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -57,7 +57,7 @@
v1.21-SNAPSHOT
1.21.11-R0.1-SNAPSHOT
- 3.10.2
+ 3.14.1-SNAPSHOT
1.12.0
@@ -222,7 +222,7 @@
world.bentobox
bentobox
- 3.10.0
+ ${bentobox.version}
world.bentobox
@@ -378,25 +378,26 @@
org.apache.maven.plugins
maven-clean-plugin
- 3.4.0
+ 3.5.0
org.apache.maven.plugins
maven-resources-plugin
- 3.3.1
+ 3.5.0
org.apache.maven.plugins
maven-compiler-plugin
- 3.14.1
+ 3.15.0
${java.version}
+ true
org.apache.maven.plugins
maven-surefire-plugin
- 3.5.4
+ 3.5.5
${argLine}
@@ -434,12 +435,12 @@
org.apache.maven.plugins
maven-jar-plugin
- 3.4.2
+ 3.5.0
org.apache.maven.plugins
maven-javadoc-plugin
- 3.11.1
+ 3.12.0
none
false
@@ -458,7 +459,7 @@
org.apache.maven.plugins
maven-source-plugin
- 3.3.1
+ 3.4.0
attach-sources
@@ -471,17 +472,17 @@
org.apache.maven.plugins
maven-install-plugin
- 3.1.3
+ 3.1.4
org.apache.maven.plugins
maven-deploy-plugin
- 3.1.3
+ 3.1.4
org.apache.maven.plugins
maven-shade-plugin
- 3.6.0
+ 3.6.2
true
@@ -513,7 +514,7 @@
org.jacoco
jacoco-maven-plugin
- 0.8.13
+ 0.8.14
true
diff --git a/src/main/java/world/bentobox/level/Level.java b/src/main/java/world/bentobox/level/Level.java
index ae91392..20f2094 100644
--- a/src/main/java/world/bentobox/level/Level.java
+++ b/src/main/java/world/bentobox/level/Level.java
@@ -7,6 +7,7 @@
import java.util.UUID;
import org.bukkit.Bukkit;
+import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
@@ -17,8 +18,10 @@
import world.bentobox.bentobox.api.addons.Addon;
import world.bentobox.bentobox.api.addons.GameModeAddon;
import world.bentobox.bentobox.api.configuration.Config;
+import world.bentobox.bentobox.api.flags.Flag;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.database.objects.Island;
+import world.bentobox.bentobox.managers.RanksManager;
import world.bentobox.bentobox.util.Util;
import world.bentobox.level.calculators.Pipeliner;
import world.bentobox.level.commands.AdminLevelCommand;
@@ -27,6 +30,7 @@
import world.bentobox.level.commands.AdminStatsCommand;
import world.bentobox.level.commands.AdminTopCommand;
import world.bentobox.level.commands.IslandDetailCommand;
+import world.bentobox.level.commands.IslandDonateCommand;
import world.bentobox.level.commands.IslandLevelCommand;
import world.bentobox.level.commands.IslandTopCommand;
import world.bentobox.level.commands.IslandValueCommand;
@@ -49,6 +53,17 @@ public class Level extends Addon {
// The 10 in top ten
public static final int TEN = 10;
+ /**
+ * Flag to control who can donate blocks to raise island level.
+ * Default: OWNER only. Can be extended down to MEMBER rank.
+ */
+ public static final Flag BLOCK_DONATION = new Flag.Builder("ISLAND_BLOCK_DONATION", Material.HOPPER)
+ .type(Flag.Type.PROTECTION)
+ .defaultRank(RanksManager.OWNER_RANK)
+ .minimumRank(RanksManager.MEMBER_RANK)
+ .mode(Flag.Mode.BASIC)
+ .build();
+
// Settings
private ConfigSettings settings;
private Config configObject = new Config<>(this, ConfigSettings.class);
@@ -114,6 +129,9 @@ public void allLoaded() {
hookPlugin("RoseStacker", this::hookRoseStackers);
hookPlugin("UltimateStacker", this::hookUltimateStacker);
+ // Register the block donation flag
+ getPlugin().getFlagsManager().registerFlag(this, BLOCK_DONATION);
+
if (this.isEnabled()) {
hookExtensions();
}
@@ -250,6 +268,7 @@ private void registerCommands(GameModeAddon gm) {
new IslandTopCommand(this, playerCmd);
new IslandValueCommand(this, playerCmd);
new IslandDetailCommand(this, playerCmd);
+ new IslandDonateCommand(this, playerCmd);
});
}
diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java
index 9b6d612..3816947 100644
--- a/src/main/java/world/bentobox/level/LevelsManager.java
+++ b/src/main/java/world/bentobox/level/LevelsManager.java
@@ -542,4 +542,54 @@ public void deleteIsland(String uniqueId) {
handler.deleteID(uniqueId);
}
+ // ---- Block Donation Methods ----
+
+ /**
+ * Record a block donation for an island. Items should already be removed from the player's inventory.
+ *
+ * @param island the island receiving the donation
+ * @param donorUUID UUID of the donating player
+ * @param material the material name being donated
+ * @param count how many blocks
+ * @param points the point value of this donation
+ */
+ public void donateBlocks(@NonNull Island island, @NonNull UUID donorUUID, @NonNull String material, int count, long points) {
+ IslandLevels ld = getLevelsData(island);
+ ld.addDonation(donorUUID.toString(), material, count, points);
+ handler.saveObjectAsync(ld);
+ }
+
+ /**
+ * Queue a full level recalculation for the island. Call this after donations
+ * so that the level/top-ten update immediately.
+ *
+ * @param island the island to recalculate
+ */
+ public void recalculateAfterDonation(@NonNull Island island) {
+ UUID owner = island.getOwner();
+ if (owner != null) {
+ calculateLevel(owner, island);
+ }
+ }
+
+ /**
+ * Get the total donated points for an island.
+ *
+ * @param island the island
+ * @return total donated points
+ */
+ public long getDonatedPoints(@NonNull Island island) {
+ return getLevelsData(island).getDonatedPoints();
+ }
+
+ /**
+ * Get the donated blocks map for an island.
+ *
+ * @param island the island
+ * @return map of material name to count
+ */
+ public Map getDonatedBlocks(@NonNull Island island) {
+ return getLevelsData(island).getDonatedBlocks();
+ }
+
}
diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java
index a7425eb..d669703 100644
--- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java
+++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java
@@ -287,6 +287,22 @@ private List getReport() {
+ " blocks (max " + limit + explain);
}
reportLines.add(LINE_BREAK);
+ // Donated blocks section
+ if (results.donatedPoints.get() > 0) {
+ reportLines.add("Donated blocks (permanent contributions):");
+ reportLines.add("Total donated points = " + String.format("%,d", results.donatedPoints.get()));
+ Map donatedBlocks = addon.getManager().getDonatedBlocks(island);
+ donatedBlocks.entrySet().stream()
+ .sorted(Map.Entry.comparingByValue().reversed())
+ .forEach(entry -> {
+ Integer value = addon.getBlockConfig().getBlockValues().getOrDefault(entry.getKey().toLowerCase(java.util.Locale.ENGLISH), 0);
+ long totalValue = (long) value * entry.getValue();
+ reportLines.add(" " + Util.prettifyText(entry.getKey()) + " x "
+ + String.format("%,d", entry.getValue())
+ + " = " + String.format("%,d", totalValue) + " points");
+ });
+ reportLines.add(LINE_BREAK);
+ }
return reportLines;
}
@@ -701,6 +717,11 @@ public void tidyUp() {
results.rawBlockCount
.addAndGet((long) (results.underWaterBlockCount.get() * addon.getSettings().getUnderWaterMultiplier()));
+ // Add donated block points (permanent contributions that persist across recalculations)
+ long donatedPoints = addon.getManager().getDonatedPoints(island);
+ results.rawBlockCount.addAndGet(donatedPoints);
+ results.donatedPoints.set(donatedPoints);
+
// Set the death penalty
if (this.addon.getSettings().isSumTeamDeaths()) {
for (UUID uuid : this.island.getMemberSet()) {
diff --git a/src/main/java/world/bentobox/level/calculators/Results.java b/src/main/java/world/bentobox/level/calculators/Results.java
index db8124c..01c0c0a 100644
--- a/src/main/java/world/bentobox/level/calculators/Results.java
+++ b/src/main/java/world/bentobox/level/calculators/Results.java
@@ -54,6 +54,10 @@ public enum Result {
* Total points before any death penalties
*/
AtomicLong totalPoints = new AtomicLong(0);
+ /**
+ * Points contributed via block donation (permanent)
+ */
+ AtomicLong donatedPoints = new AtomicLong(0);
final Result state;
public Results(Result state) {
@@ -179,4 +183,18 @@ public void setInitialCount(Long count) {
this.initialCount.set(count);
}
+ /**
+ * @return the donated points
+ */
+ public long getDonatedPoints() {
+ return donatedPoints.get();
+ }
+
+ /**
+ * @param points the donated points to set
+ */
+ public void setDonatedPoints(long points) {
+ this.donatedPoints.set(points);
+ }
+
}
diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java
new file mode 100644
index 0000000..37e1f28
--- /dev/null
+++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java
@@ -0,0 +1,159 @@
+package world.bentobox.level.commands;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+
+import world.bentobox.bentobox.api.commands.CompositeCommand;
+import world.bentobox.bentobox.api.commands.ConfirmableCommand;
+import world.bentobox.bentobox.api.localization.TextVariables;
+import world.bentobox.bentobox.api.user.User;
+import world.bentobox.bentobox.database.objects.Island;
+import world.bentobox.bentobox.util.Util;
+import world.bentobox.level.Level;
+import world.bentobox.level.panels.DonationPanel;
+import world.bentobox.level.util.Utils;
+
+/**
+ * Command: /island donate [hand [amount]]
+ * Opens a donation GUI or donates blocks from hand.
+ *
+ * @author tastybento
+ */
+public class IslandDonateCommand extends ConfirmableCommand {
+
+ private final Level addon;
+
+ public IslandDonateCommand(Level addon, CompositeCommand parent) {
+ super(parent, "donate");
+ this.addon = addon;
+ }
+
+ @Override
+ public void setup() {
+ this.setPermission("island.donate");
+ this.setOnlyPlayer(true);
+ this.setParametersHelp("island.donate.parameters");
+ this.setDescription("island.donate.description");
+ }
+
+ @Override
+ public boolean execute(User user, String label, List args) {
+ // Check the player is on an island they are part of
+ Island island = addon.getIslands().getIsland(getWorld(), user);
+ if (island == null) {
+ user.sendMessage("general.errors.no-island");
+ return false;
+ }
+
+ // Check the player is on their island
+ if (!island.onIsland(user.getLocation())) {
+ user.sendMessage("island.donate.must-be-on-island");
+ return false;
+ }
+
+ // Check flag permission
+ if (!island.isAllowed(user, Level.BLOCK_DONATION)) {
+ user.sendMessage("island.donate.no-permission");
+ return false;
+ }
+
+ // Handle "hand" subcommand
+ if (!args.isEmpty() && "hand".equalsIgnoreCase(args.get(0))) {
+ return handleHandDonation(user, island, args);
+ }
+
+ // No args - open GUI
+ DonationPanel.openPanel(addon, getWorld(), user, island);
+ return true;
+ }
+
+ /**
+ * Handle the /island donate hand [amount] subcommand.
+ */
+ private boolean handleHandDonation(User user, Island island, List args) {
+ ItemStack hand = user.getPlayer().getInventory().getItemInMainHand();
+ if (hand.getType().isAir() || !hand.getType().isBlock()) {
+ user.sendMessage("island.donate.hand.not-block");
+ return false;
+ }
+
+ final Material material = hand.getType();
+ final Integer blockValue = addon.getBlockConfig().getValue(getWorld(), material);
+ if (blockValue == null || blockValue <= 0) {
+ user.sendMessage("island.donate.no-value");
+ return false;
+ }
+
+ int requested = hand.getAmount();
+ if (args.size() > 1) {
+ if ("help".equalsIgnoreCase(args.get(1))) {
+ showHelp(this, user);
+ return true;
+ }
+ try {
+ requested = Integer.parseInt(args.get(1));
+ if (requested < 1) {
+ user.sendMessage("island.donate.invalid-amount");
+ return false;
+ }
+ } catch (NumberFormatException e) {
+ user.sendMessage("island.donate.invalid-amount");
+ return false;
+ }
+ }
+
+ final int previewAmount = Math.min(requested, hand.getAmount());
+ final long previewPoints = (long) previewAmount * blockValue;
+ final int finalRequested = requested;
+
+ String prompt = user.getTranslation("island.donate.hand.confirm-prompt",
+ TextVariables.NUMBER, String.valueOf(previewAmount),
+ "[material]", Utils.prettifyObject(material, user),
+ "[points]", Utils.formatNumber(user, previewPoints));
+
+ askConfirmation(user, prompt, () -> performHandDonation(user, island, material, blockValue, finalRequested));
+ return true;
+ }
+
+ private void performHandDonation(User user, Island island, Material material, int blockValue, int requested) {
+ ItemStack currentHand = user.getPlayer().getInventory().getItemInMainHand();
+ if (currentHand.getType() != material || currentHand.getAmount() == 0) {
+ user.sendMessage("island.donate.hand.not-block");
+ return;
+ }
+ int amount = Math.min(requested, currentHand.getAmount());
+ long points = (long) amount * blockValue;
+
+ if (amount >= currentHand.getAmount()) {
+ user.getPlayer().getInventory().setItemInMainHand(null);
+ } else {
+ currentHand.setAmount(currentHand.getAmount() - amount);
+ }
+
+ addon.getManager().donateBlocks(island, user.getUniqueId(), material.name(), amount, points);
+ addon.getManager().recalculateAfterDonation(island);
+
+ user.sendMessage("island.donate.hand.success",
+ TextVariables.NUMBER, String.valueOf(amount),
+ "[material]", Utils.prettifyObject(material, user),
+ "[points]", Utils.formatNumber(user, points));
+ }
+
+ @Override
+ public Optional> tabComplete(User user, String alias, List args) {
+ String lastArg = !args.isEmpty() ? args.get(args.size() - 1) : "";
+ if (args.size() <= 1) {
+ return Optional.of(Util.tabLimit(List.of("hand"), lastArg));
+ }
+ if (args.size() == 2 && "hand".equalsIgnoreCase(args.get(0)) && user.isPlayer()) {
+ int held = user.getPlayer().getInventory().getItemInMainHand().getAmount();
+ if (held > 0) {
+ return Optional.of(Util.tabLimit(List.of(String.valueOf(held)), lastArg));
+ }
+ }
+ return Optional.of(List.of());
+ }
+}
diff --git a/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java b/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java
index bf471de..240df2e 100644
--- a/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java
+++ b/src/main/java/world/bentobox/level/commands/IslandLevelCommand.java
@@ -11,6 +11,7 @@
import world.bentobox.level.Level;
import world.bentobox.level.calculators.Results;
import world.bentobox.level.calculators.Results.Result;
+import world.bentobox.level.util.Utils;
public class IslandLevelCommand extends CompositeCommand {
@@ -112,9 +113,9 @@ private void showResult(User user, UUID playerUUID, Island island, long oldLevel
// Send player how many points are required to reach next island level
if (results.getPointsToNextLevel() >= 0) {
user.sendMessage("island.level.required-points-to-next-level",
- "[points]", String.valueOf(results.getPointsToNextLevel()),
- "[progress]", String.valueOf(this.addon.getSettings().getLevelCost()-results.getPointsToNextLevel()),
- "[levelcost]", String.valueOf(this.addon.getSettings().getLevelCost())
+ "[points]", Utils.formatNumber(user, results.getPointsToNextLevel()),
+ "[progress]", Utils.formatNumber(user, this.addon.getSettings().getLevelCost() - results.getPointsToNextLevel()),
+ "[levelcost]", Utils.formatNumber(user, this.addon.getSettings().getLevelCost())
);
}
// Tell other team members
diff --git a/src/main/java/world/bentobox/level/objects/IslandLevels.java b/src/main/java/world/bentobox/level/objects/IslandLevels.java
index 3666ea6..b15bb69 100644
--- a/src/main/java/world/bentobox/level/objects/IslandLevels.java
+++ b/src/main/java/world/bentobox/level/objects/IslandLevels.java
@@ -1,6 +1,8 @@
package world.bentobox.level.objects;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -74,6 +76,28 @@ public class IslandLevels implements DataObject {
@Expose
private Map