-val buildVersion = "3.14.2"
+val buildVersion = "3.15.0"
val buildNumberDefault = "-LOCAL" // Local build identifier
val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version
diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java
index b97654e6c..5ade339ba 100644
--- a/src/main/java/world/bentobox/bentobox/BentoBox.java
+++ b/src/main/java/world/bentobox/bentobox/BentoBox.java
@@ -32,7 +32,9 @@
import world.bentobox.bentobox.managers.FlagsManager;
import world.bentobox.bentobox.managers.HooksManager;
import world.bentobox.bentobox.managers.ChunkPregenManager;
+import world.bentobox.bentobox.managers.HousekeepingManager;
import world.bentobox.bentobox.managers.IslandDeletionManager;
+import world.bentobox.bentobox.managers.PurgeRegionsService;
import world.bentobox.bentobox.managers.IslandWorldManager;
import world.bentobox.bentobox.managers.IslandsManager;
import world.bentobox.bentobox.managers.LocalesManager;
@@ -72,6 +74,8 @@ public class BentoBox extends JavaPlugin implements Listener {
private MapManager mapManager;
private IslandDeletionManager islandDeletionManager;
private ChunkPregenManager chunkPregenManager;
+ private PurgeRegionsService purgeRegionsService;
+ private HousekeepingManager housekeepingManager;
private WebManager webManager;
// Settings
@@ -143,6 +147,9 @@ public void onEnable(){
}
islandsManager = new IslandsManager(this);
+ // Shared purge-regions logic (command + housekeeping)
+ purgeRegionsService = new PurgeRegionsService(this);
+
// Start head getter
headGetter = new HeadGetter(this);
@@ -233,6 +240,10 @@ private void completeSetup(long loadTime) {
webManager = new WebManager(this);
+ // Housekeeping: auto-purge of unused region files (default OFF)
+ housekeepingManager = new HousekeepingManager(this);
+ housekeepingManager.start();
+
final long enableTime = System.currentTimeMillis() - enableStart;
// Show banner
@@ -308,6 +319,15 @@ public void onDisable() {
if (chunkPregenManager != null) {
chunkPregenManager.shutdown();
}
+ // Flush deferred island deletions from the deleted-sweep purge.
+ // Paper's internal chunk cache is cleared on shutdown, so the stale
+ // in-memory chunks are guaranteed gone at this point.
+ if (purgeRegionsService != null) {
+ purgeRegionsService.flushPendingDeletions();
+ }
+ if (housekeepingManager != null) {
+ housekeepingManager.stop();
+ }
}
@@ -560,12 +580,29 @@ public IslandDeletionManager getIslandDeletionManager() {
/**
* @return the chunkPregenManager
- * @since 3.14.0
+ * @since 3.15.0
*/
public ChunkPregenManager getChunkPregenManager() {
return chunkPregenManager;
}
+ /**
+ * @return the shared {@link PurgeRegionsService} used by the purge
+ * regions command and the housekeeping scheduler.
+ * @since 3.15.0
+ */
+ public PurgeRegionsService getPurgeRegionsService() {
+ return purgeRegionsService;
+ }
+
+ /**
+ * @return the {@link HousekeepingManager}, or {@code null} if not yet initialized.
+ * @since 3.15.0
+ */
+ public HousekeepingManager getHousekeepingManager() {
+ return housekeepingManager;
+ }
+
/**
* @return an optional of the Bstats instance
* @since 1.1
diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java
index 3bdb5eba6..492f5d312 100644
--- a/src/main/java/world/bentobox/bentobox/Settings.java
+++ b/src/main/java/world/bentobox/bentobox/Settings.java
@@ -319,27 +319,53 @@ public class Settings implements ConfigObject {
@ConfigEntry(path = "island.delete-speed", since = "1.7.0")
private int deleteSpeed = 1;
- // Island deletion related settings
- @ConfigComment("Toggles whether islands, when players are resetting them, should be kept in the world or deleted.")
- @ConfigComment("* If set to 'true', whenever a player resets his island, his previous island will become unowned and won't be deleted from the world.")
- @ConfigComment(" You can, however, still delete those unowned islands through purging.")
- @ConfigComment(" On bigger servers, this can lead to an increasing world size.")
- @ConfigComment(" Yet, this allows admins to retrieve a player's old island in case of an improper use of the reset command.")
- @ConfigComment(" Admins can indeed re-add the player to his old island by registering him to it.")
- @ConfigComment("* If set to 'false', whenever a player resets his island, his previous island will be deleted from the world.")
- @ConfigComment(" This is the default behaviour.")
- @ConfigEntry(path = "island.deletion.keep-previous-island-on-reset", since = "1.13.0")
- private boolean keepPreviousIslandOnReset = false;
-
- @ConfigComment("Toggles how the islands are deleted.")
- @ConfigComment("* If set to 'false', all islands will be deleted at once.")
- @ConfigComment(" This is fast but may cause an impact on the performance")
- @ConfigComment(" as it'll load all the chunks of the in-deletion islands.")
- @ConfigComment("* If set to 'true', the islands will be deleted one by one.")
- @ConfigComment(" This is slower but will not cause any impact on the performance.")
- @ConfigEntry(path = "island.deletion.slow-deletion", since = "1.19.1")
+
+ /**
+ * @deprecated No longer bound to config. The chunk-by-chunk deletion
+ * pipeline has been removed. Slated for removal.
+ */
+ @Deprecated(since = "3.14.0", forRemoval = true)
private boolean slowDeletion = false;
+ // Island deletion housekeeping
+ @ConfigComment("Housekeeping: periodic auto-purge of unused region files.")
+ @ConfigComment("When a player resets their island, the old island is orphaned (marked")
+ @ConfigComment("deletable) and its region files are reclaimed later by a scheduled purge.")
+ @ConfigComment("")
+ @ConfigComment("WARNING: housekeeping deletes .mca region files from disk. It uses the same")
+ @ConfigComment("protections as the /bbox purge regions command (online players, island level,")
+ @ConfigComment("purge-protected flag, spawn islands, and unowned-but-not-deletable islands are")
+ @ConfigComment("all skipped) but is destructive by design.")
+ @ConfigComment("")
+ @ConfigComment("--- Deleted-island sweep (safe, on by default) ---")
+ @ConfigComment("Reaps region files for islands explicitly marked deletable (e.g. /is reset).")
+ @ConfigComment("Only removes islands that BentoBox itself soft-deleted — never touches")
+ @ConfigComment("active or unvisited islands. Enable this and leave age-sweep off for a")
+ @ConfigComment("'just works' experience that reclaims disk space from reset islands.")
+ @ConfigEntry(path = "island.deletion.housekeeping.deleted-sweep.enabled", since = "3.15.0")
+ private boolean housekeepingDeletedEnabled = true;
+
+ @ConfigComment("How often the deleted-island sweep runs, in hours.")
+ @ConfigEntry(path = "island.deletion.housekeeping.deleted-sweep.interval-hours", since = "3.15.0")
+ private int housekeepingDeletedIntervalHours = 24;
+
+ @ConfigComment("")
+ @ConfigComment("--- Age-based sweep (opt-in, more aggressive) ---")
+ @ConfigComment("Reaps region files whose .mca files are older than min-age-days, regardless")
+ @ConfigComment("of whether those islands are marked deletable. Can remove islands that were")
+ @ConfigComment("never reset but simply abandoned. Disabled by default — enable only if you")
+ @ConfigComment("want automatic reclamation of all old, unvisited islands.")
+ @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.enabled", since = "3.15.0")
+ private boolean housekeepingAgeEnabled = false;
+
+ @ConfigComment("How often the age-based sweep runs, in days.")
+ @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.interval-days", since = "3.15.0")
+ private int housekeepingIntervalDays = 30;
+
+ @ConfigComment("Minimum age (in days) of region files eligible for the age-based purge.")
+ @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.min-age-days", since = "3.15.0")
+ private int housekeepingRegionAgeDays = 60;
+
// Chunk pre-generation settings
@ConfigComment("")
@ConfigComment("Chunk pre-generation settings.")
@@ -826,28 +852,6 @@ public void setDatabasePrefix(String databasePrefix) {
this.databasePrefix = databasePrefix;
}
- /**
- * Returns whether islands, when reset, should be kept or deleted.
- *
- * @return {@code true} if islands, when reset, should be kept; {@code false}
- * otherwise.
- * @since 1.13.0
- */
- public boolean isKeepPreviousIslandOnReset() {
- return keepPreviousIslandOnReset;
- }
-
- /**
- * Sets whether islands, when reset, should be kept or deleted.
- *
- * @param keepPreviousIslandOnReset {@code true} if islands, when reset, should
- * be kept; {@code false} otherwise.
- * @since 1.13.0
- */
- public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) {
- this.keepPreviousIslandOnReset = keepPreviousIslandOnReset;
- }
-
/**
* Returns a MongoDB client connection URI to override default connection
* options.
@@ -1014,7 +1018,11 @@ public void setSafeSpotSearchVerticalRange(int safeSpotSearchVerticalRange) {
* Is slow deletion boolean.
*
* @return the boolean
+ * @deprecated The chunk-by-chunk deletion pipeline is being removed. This
+ * setting no longer has any effect and will be deleted in a
+ * future release. Configure the housekeeping auto-purge instead.
*/
+ @Deprecated(since = "3.14.0", forRemoval = true)
public boolean isSlowDeletion() {
return slowDeletion;
}
@@ -1023,14 +1031,128 @@ public boolean isSlowDeletion() {
* Sets slow deletion.
*
* @param slowDeletion the slow deletion
+ * @deprecated See {@link #isSlowDeletion()}.
*/
+ @Deprecated(since = "3.14.0", forRemoval = true)
public void setSlowDeletion(boolean slowDeletion) {
this.slowDeletion = slowDeletion;
}
+ /**
+ * Returns whether islands, when reset, should be kept or deleted.
+ *
+ * @return always {@code false} — islands are now soft-deleted on reset and
+ * their region files reaped asynchronously by the housekeeping purge.
+ * @since 1.13.0
+ * @deprecated No longer configurable. Islands are soft-deleted on reset.
+ * This method always returns {@code false} and will be removed
+ * in a future release.
+ */
+ @Deprecated(since = "3.15.0", forRemoval = true)
+ public boolean isKeepPreviousIslandOnReset() {
+ return false;
+ }
+
+ /**
+ * No-op — the keep-previous-island setting is no longer configurable.
+ *
+ * @param keepPreviousIslandOnReset ignored
+ * @since 1.13.0
+ * @deprecated No longer configurable. Islands are soft-deleted on reset.
+ */
+ @Deprecated(since = "3.15.0", forRemoval = true)
+ public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) {
+ // no-op
+ }
+
+ /**
+ * @return whether the deleted-island sweep is enabled (reaps regions for
+ * islands explicitly flagged deletable, e.g. from {@code /is reset}).
+ * @since 3.15.0
+ */
+ public boolean isHousekeepingDeletedEnabled() {
+ return housekeepingDeletedEnabled;
+ }
+
+ /**
+ * @param housekeepingDeletedEnabled whether the deleted-island sweep is enabled.
+ * @since 3.15.0
+ */
+ public void setHousekeepingDeletedEnabled(boolean housekeepingDeletedEnabled) {
+ this.housekeepingDeletedEnabled = housekeepingDeletedEnabled;
+ }
+
+ /**
+ * @return whether the age-based sweep is enabled (reaps regions for islands
+ * whose .mca files are older than {@link #getHousekeepingRegionAgeDays()}).
+ * @since 3.15.0
+ */
+ public boolean isHousekeepingAgeEnabled() {
+ return housekeepingAgeEnabled;
+ }
+
+ /**
+ * @param housekeepingAgeEnabled whether the age-based sweep is enabled.
+ * @since 3.15.0
+ */
+ public void setHousekeepingAgeEnabled(boolean housekeepingAgeEnabled) {
+ this.housekeepingAgeEnabled = housekeepingAgeEnabled;
+ }
+
+ /**
+ * @return how often the age-based sweep runs, in days.
+ * @since 3.15.0
+ */
+ public int getHousekeepingIntervalDays() {
+ return housekeepingIntervalDays;
+ }
+
+ /**
+ * @param housekeepingIntervalDays how often the age-based sweep runs, in days.
+ * @since 3.15.0
+ */
+ public void setHousekeepingIntervalDays(int housekeepingIntervalDays) {
+ this.housekeepingIntervalDays = housekeepingIntervalDays;
+ }
+
+ /**
+ * @return minimum age (in days) of region files eligible for the age-based purge.
+ * @since 3.15.0
+ */
+ public int getHousekeepingRegionAgeDays() {
+ return housekeepingRegionAgeDays;
+ }
+
+ /**
+ * @param housekeepingRegionAgeDays minimum age (in days) of region files
+ * considered for auto-purge.
+ * @since 3.15.0
+ */
+ public void setHousekeepingRegionAgeDays(int housekeepingRegionAgeDays) {
+ this.housekeepingRegionAgeDays = housekeepingRegionAgeDays;
+ }
+
+ /**
+ * @return how often the deleted-islands sweep runs, in hours. {@code 0}
+ * disables the deleted sweep.
+ * @since 3.15.0
+ */
+ public int getHousekeepingDeletedIntervalHours() {
+ return housekeepingDeletedIntervalHours;
+ }
+
+ /**
+ * @param housekeepingDeletedIntervalHours how often the deleted sweep runs
+ * in hours. {@code 0} disables it.
+ * @since 3.15.0
+ */
+ public void setHousekeepingDeletedIntervalHours(int housekeepingDeletedIntervalHours) {
+ this.housekeepingDeletedIntervalHours = housekeepingDeletedIntervalHours;
+ }
+
/**
* @return whether chunk pre-generation is enabled
- * @since 3.14.0
+ * @since 3.15.0
*/
public boolean isPregenEnabled() {
return pregenEnabled;
@@ -1038,7 +1160,7 @@ public boolean isPregenEnabled() {
/**
* @param pregenEnabled whether chunk pre-generation is enabled
- * @since 3.14.0
+ * @since 3.15.0
*/
public void setPregenEnabled(boolean pregenEnabled) {
this.pregenEnabled = pregenEnabled;
@@ -1046,7 +1168,7 @@ public void setPregenEnabled(boolean pregenEnabled) {
/**
* @return number of future islands to pre-generate per game mode
- * @since 3.14.0
+ * @since 3.15.0
*/
public int getPregenIslandsAhead() {
return pregenIslandsAhead;
@@ -1054,7 +1176,7 @@ public int getPregenIslandsAhead() {
/**
* @param pregenIslandsAhead number of future islands to pre-generate
- * @since 3.14.0
+ * @since 3.15.0
*/
public void setPregenIslandsAhead(int pregenIslandsAhead) {
this.pregenIslandsAhead = pregenIslandsAhead;
@@ -1062,7 +1184,7 @@ public void setPregenIslandsAhead(int pregenIslandsAhead) {
/**
* @return max async chunk generation requests per tick batch
- * @since 3.14.0
+ * @since 3.15.0
*/
public int getPregenChunksPerTick() {
return pregenChunksPerTick;
@@ -1070,7 +1192,7 @@ public int getPregenChunksPerTick() {
/**
* @param pregenChunksPerTick max async chunk requests per tick
- * @since 3.14.0
+ * @since 3.15.0
*/
public void setPregenChunksPerTick(int pregenChunksPerTick) {
this.pregenChunksPerTick = pregenChunksPerTick;
@@ -1078,7 +1200,7 @@ public void setPregenChunksPerTick(int pregenChunksPerTick) {
/**
* @return ticks between pre-generation batches
- * @since 3.14.0
+ * @since 3.15.0
*/
public int getPregenTickInterval() {
return pregenTickInterval;
@@ -1086,7 +1208,7 @@ public int getPregenTickInterval() {
/**
* @param pregenTickInterval ticks between batches
- * @since 3.14.0
+ * @since 3.15.0
*/
public void setPregenTickInterval(int pregenTickInterval) {
this.pregenTickInterval = pregenTickInterval;
@@ -1255,7 +1377,7 @@ public void setObsidianScoopingCooldown(int obsidianScoopingCooldown) {
* newly formed obsidian blocks that can be scooped.
*
* @return the lava tip duration in seconds; 0 or less means disabled
- * @since 3.14.0
+ * @since 3.15.0
*/
public int getObsidianScoopingLavaTipDuration() {
return obsidianScoopingLavaTipDuration;
@@ -1266,7 +1388,7 @@ public int getObsidianScoopingLavaTipDuration() {
* newly formed obsidian blocks that can be scooped.
*
* @param obsidianScoopingLavaTipDuration the duration in seconds; 0 or less disables
- * @since 3.14.0
+ * @since 3.15.0
*/
public void setObsidianScoopingLavaTipDuration(int obsidianScoopingLavaTipDuration) {
this.obsidianScoopingLavaTipDuration = obsidianScoopingLavaTipDuration;
diff --git a/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java b/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java
index 4c239313f..b4c21cd42 100644
--- a/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java
+++ b/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java
@@ -211,7 +211,7 @@ public boolean isEnforceEqualRanges() {
*
*
* @return number of islands ahead, -1 for global default, 0 to disable
- * @since 3.14.0
+ * @since 3.15.0
*/
public int getPregenIslandsAhead() {
return -1;
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java
index 3a990ee41..d4acd4228 100644
--- a/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java
+++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java
@@ -8,6 +8,7 @@
import org.eclipse.jdt.annotation.Nullable;
+import world.bentobox.bentobox.api.addons.GameModeAddon;
import world.bentobox.bentobox.api.commands.CompositeCommand;
import world.bentobox.bentobox.api.commands.ConfirmableCommand;
import world.bentobox.bentobox.api.commands.island.IslandGoCommand;
@@ -17,6 +18,8 @@
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.database.objects.IslandDeletion;
+import world.bentobox.bentobox.util.DeleteIslandChunks;
import world.bentobox.bentobox.util.Util;
public class AdminDeleteCommand extends ConfirmableCommand {
@@ -112,8 +115,30 @@ private void deleteIsland(User user, Island oldIsland) {
.oldIsland(oldIsland).location(oldIsland.getCenter()).build();
user.sendMessage("commands.admin.delete.deleted-island", TextVariables.XYZ,
Util.xyz(oldIsland.getCenter().toVector()));
- getIslands().deleteIsland(oldIsland, true, targetUUID);
+ // Branch on how the gamemode generates its chunks.
+ //
+ // - New chunk generation (e.g. Boxed): chunks are expensive to
+ // recreate, so the island is soft-deleted (marked deletable,
+ // left in place) and PurgeRegionsService / HousekeepingManager
+ // reaps the region files and DB row later on its schedule.
+ //
+ // - Simple/void generation: chunks are cheap — hard-delete the
+ // island first so the cancellable delete path can veto the
+ // operation before any chunk work starts, then repaint them via
+ // the addon's own ChunkGenerator using the existing
+ // DeleteIslandChunks + WorldRegenerator.regenerateSimple path.
+ //
+ // If we can't resolve the gamemode, default to soft-delete.
+ GameModeAddon gm = getIWM().getAddon(getWorld()).orElse(null);
+ if (gm != null && !gm.isUsesNewChunkGeneration()) {
+ getIslands().hardDeleteIsland(oldIsland);
+ // DeleteIslandChunks snapshots the island bounds from oldIsland,
+ // so it can safely run after the row has been hard-deleted.
+ new DeleteIslandChunks(getPlugin(), new IslandDeletion(oldIsland));
+ } else {
+ getIslands().deleteIsland(oldIsland, true, targetUUID);
+ }
}
private void deletePlayer(User user) {
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java
new file mode 100644
index 000000000..d93fd705b
--- /dev/null
+++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java
@@ -0,0 +1,137 @@
+package world.bentobox.bentobox.api.commands.admin.purge;
+
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+
+import world.bentobox.bentobox.api.commands.CompositeCommand;
+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.managers.PurgeRegionsService.PurgeScanResult;
+
+/**
+ * Shared scaffolding for purge subcommands that scan for region files and
+ * (on confirmation) reap them via {@link world.bentobox.bentobox.managers.PurgeRegionsService}.
+ *
+ * Subclasses supply the scan source (age-filtered or deletable-only),
+ * confirmation prompt, success message and any pre-delete side-effects via
+ * the abstract / overridable hooks.
+ */
+abstract class AbstractPurgeCommand extends CompositeCommand {
+
+ protected static final String NONE_FOUND = "commands.admin.purge.none-found";
+
+ protected volatile boolean inPurge;
+ protected boolean toBeConfirmed;
+ protected User user;
+ protected PurgeScanResult lastScan;
+
+ protected AbstractPurgeCommand(CompositeCommand parent, String label) {
+ super(parent, label);
+ }
+
+ @Override
+ public boolean canExecute(User user, String label, java.util.List args) {
+ if (inPurge) {
+ user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel());
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Saves all worlds, runs the scan on an async thread, stores the result
+ * in {@link #lastScan} and prompts for confirmation if non-empty.
+ */
+ protected final void runScanAndPrompt(Supplier scanFn) {
+ user.sendMessage("commands.admin.purge.scanning");
+ getPlugin().log(logPrefix() + ": saving all worlds before scanning...");
+ Bukkit.getWorlds().forEach(World::save);
+ getPlugin().log(logPrefix() + ": world save complete");
+
+ inPurge = true;
+ Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> {
+ try {
+ lastScan = scanFn.get();
+ displayResultsAndPrompt(lastScan);
+ } finally {
+ inPurge = false;
+ }
+ });
+ }
+
+ /**
+ * Confirm path: save worlds, run any pre-delete hook, then dispatch the
+ * region-file deletion asynchronously.
+ */
+ protected final boolean deleteEverything() {
+ if (lastScan == null || lastScan.isEmpty()) {
+ user.sendMessage(NONE_FOUND);
+ return false;
+ }
+ PurgeScanResult scan = lastScan;
+ lastScan = null;
+ toBeConfirmed = false;
+ getPlugin().log(logPrefix() + ": saving all worlds before deleting region files...");
+ Bukkit.getWorlds().forEach(World::save);
+ beforeDelete(scan);
+ getPlugin().log(logPrefix() + ": world save complete, dispatching deletion");
+ Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> {
+ boolean ok = getPlugin().getPurgeRegionsService().delete(scan);
+ Bukkit.getScheduler().runTask(getPlugin(), () -> {
+ if (ok) {
+ user.sendMessage(successMessageKey());
+ } else {
+ getPlugin().log(logPrefix() + ": failed to delete one or more region files");
+ user.sendMessage("commands.admin.purge.failed");
+ }
+ });
+ });
+ return true;
+ }
+
+ private void displayResultsAndPrompt(PurgeScanResult scan) {
+ Set uniqueIslands = scan.deletableRegions().values().stream()
+ .flatMap(Set::stream)
+ .map(getPlugin().getIslands()::getIslandById)
+ .flatMap(Optional::stream)
+ .collect(Collectors.toSet());
+
+ logScanContents(uniqueIslands, scan);
+
+ if (scan.isEmpty()) {
+ Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND));
+ } else {
+ Bukkit.getScheduler().runTask(getPlugin(), () -> {
+ user.sendMessage("commands.admin.purge.purgable-islands",
+ TextVariables.NUMBER, String.valueOf(uniqueIslands.size()));
+ sendConfirmPrompt();
+ toBeConfirmed = true;
+ });
+ }
+ }
+
+ /** Log prefix used across the scan and delete phases (e.g. {@code "Purge"}). */
+ protected abstract String logPrefix();
+
+ /** Locale key sent to the user when delete completes successfully. */
+ protected abstract String successMessageKey();
+
+ /** Send any subclass-specific confirmation prompt(s). Runs on the main thread. */
+ protected abstract void sendConfirmPrompt();
+
+ /** Hook invoked on the main thread immediately before the async delete. Default: no-op. */
+ protected void beforeDelete(PurgeScanResult scan) {
+ // no-op
+ }
+
+ /** Hook invoked off-thread to log scan contents before the prompt. Default: no-op. */
+ protected void logScanContents(Set uniqueIslands, PurgeScanResult scan) {
+ // no-op
+ }
+}
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java
new file mode 100644
index 000000000..8d697e416
--- /dev/null
+++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java
@@ -0,0 +1,95 @@
+package world.bentobox.bentobox.api.commands.admin.purge;
+
+import java.util.List;
+
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.event.Listener;
+
+import world.bentobox.bentobox.api.commands.CompositeCommand;
+import world.bentobox.bentobox.api.localization.TextVariables;
+import world.bentobox.bentobox.api.user.User;
+
+/**
+ * Admin debug/testing command that artificially ages {@code .mca} region
+ * files in the current gamemode world so they become candidates for the
+ * purge regions flow without having to wait real wall-clock time.
+ *
+ * The purge scanner reads per-chunk timestamps from the region file
+ * header, not from file mtime, so {@code touch} cannot fake ageing.
+ * This command rewrites that timestamp table in place via
+ * {@link world.bentobox.bentobox.managers.PurgeRegionsService#ageRegions(World, int)}.
+ *
+ *
Usage: {@code / purge age-regions }
+ *
+ * @since 3.15.0
+ */
+public class AdminPurgeAgeRegionsCommand extends CompositeCommand implements Listener {
+
+ private volatile boolean running;
+
+ public AdminPurgeAgeRegionsCommand(CompositeCommand parent) {
+ super(parent, "age-regions");
+ getAddon().registerListener(this);
+ }
+
+ @Override
+ public void setup() {
+ setPermission("admin.purge.age-regions");
+ setOnlyPlayer(false);
+ setParametersHelp("commands.admin.purge.age-regions.parameters");
+ setDescription("commands.admin.purge.age-regions.description");
+ }
+
+ @Override
+ public boolean canExecute(User user, String label, List args) {
+ if (running) {
+ user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel());
+ return false;
+ }
+ if (args.isEmpty()) {
+ showHelp(this, user);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean execute(User user, String label, List args) {
+ int days;
+ try {
+ days = Integer.parseInt(args.getFirst());
+ if (days <= 0) {
+ user.sendMessage("commands.admin.purge.days-one-or-more");
+ return false;
+ }
+ } catch (NumberFormatException e) {
+ user.sendMessage("commands.admin.purge.days-one-or-more");
+ return false;
+ }
+
+ // Flush in-memory chunk state on the main thread before touching
+ // the region files — otherwise an auto-save can overwrite our
+ // ageing with current timestamps.
+ getPlugin().log("Age-regions: saving all worlds before rewriting timestamps...");
+ Bukkit.getWorlds().forEach(World::save);
+ getPlugin().log("Age-regions: world save complete");
+
+ running = true;
+ final int finalDays = days;
+ Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> {
+ try {
+ int count = getPlugin().getPurgeRegionsService().ageRegions(getWorld(), finalDays);
+ Bukkit.getScheduler().runTask(getPlugin(), () -> {
+ user.sendMessage("commands.admin.purge.age-regions.done",
+ TextVariables.NUMBER, String.valueOf(count));
+ getPlugin().log("Age-regions: " + count + " region file(s) aged by "
+ + finalDays + " day(s) in world " + getWorld().getName());
+ });
+ } finally {
+ running = false;
+ }
+ });
+ return true;
+ }
+}
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java
index b49b5164e..4c35e5e43 100644
--- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java
+++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java
@@ -1,40 +1,40 @@
package world.bentobox.bentobox.api.commands.admin.purge;
-import java.util.HashSet;
-import java.util.Iterator;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
-import java.util.UUID;
-import java.util.concurrent.CompletableFuture;
-import org.bukkit.Bukkit;
-import org.bukkit.event.EventHandler;
-import org.bukkit.event.EventPriority;
-import org.bukkit.event.Listener;
-
-import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.api.commands.CompositeCommand;
-import world.bentobox.bentobox.api.events.island.IslandDeletedEvent;
import world.bentobox.bentobox.api.localization.TextVariables;
import world.bentobox.bentobox.api.user.User;
import world.bentobox.bentobox.database.objects.Island;
-
-public class AdminPurgeCommand extends CompositeCommand implements Listener {
-
- private static final Long YEAR2000 = 946713600L;
- private static final int TOO_MANY = 1000;
- private int count;
- private boolean inPurge;
- private boolean scanning;
- private boolean toBeConfirmed;
- private Iterator it;
- private User user;
- private Set islands = new HashSet<>();
- private final Set loggedTiers = new HashSet<>(); // Set to store logged percentage tiers
+import world.bentobox.bentobox.managers.PurgeRegionsService;
+import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult;
+import world.bentobox.bentobox.util.Pair;
+import world.bentobox.bentobox.util.Util;
+
+/**
+ * Admin command to purge abandoned islands by deleting their region files
+ * directly from disk.
+ *
+ * Since 3.15.0 this command does not soft-delete islands via the
+ * DB pipeline the way older versions did — it scans {@code .mca} region files
+ * older than {@code }, filters out spawn, purge-protected and still-active
+ * islands, and deletes the underlying files. This is what the former
+ * {@code /bbox admin purge regions} subcommand used to do; the two have been
+ * merged because disk-freeing is the only form of purge that matters.
+ *
+ * Heavy lifting is delegated to {@link PurgeRegionsService}.
+ */
+public class AdminPurgeCommand extends AbstractPurgeCommand {
+
+ private static final String IN_WORLD = " in world ";
+ private static final String WILL_BE_DELETED = " will be deleted";
public AdminPurgeCommand(CompositeCommand parent) {
super(parent, "purge");
- getAddon().registerListener(this);
}
@Override
@@ -43,25 +43,18 @@ public void setup() {
setOnlyPlayer(false);
setParametersHelp("commands.admin.purge.parameters");
setDescription("commands.admin.purge.description");
- new AdminPurgeStatusCommand(this);
- new AdminPurgeStopCommand(this);
new AdminPurgeUnownedCommand(this);
new AdminPurgeProtectCommand(this);
- new AdminPurgeRegionsCommand(this);
+ new AdminPurgeAgeRegionsCommand(this);
+ new AdminPurgeDeletedCommand(this);
}
@Override
public boolean canExecute(User user, String label, List args) {
- if (scanning) {
- user.sendMessage("commands.admin.purge.scanning-in-progress");
- return false;
- }
- if (inPurge) {
- user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel());
+ if (!super.canExecute(user, label, args)) {
return false;
}
if (args.isEmpty()) {
- // Show help
showHelp(this, user);
return false;
}
@@ -70,180 +63,83 @@ public boolean canExecute(User user, String label, List args) {
@Override
public boolean execute(User user, String label, List args) {
- if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed && this.user.equals(user)) {
- removeIslands();
- return true;
+ this.user = user;
+ if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed) {
+ return deleteEverything();
}
- // Clear tbc
toBeConfirmed = false;
- islands.clear();
- this.user = user;
+
+ int days;
try {
- int days = Integer.parseInt(args.getFirst());
- if (days < 1) {
+ days = Integer.parseInt(args.getFirst());
+ if (days <= 0) {
user.sendMessage("commands.admin.purge.days-one-or-more");
return false;
}
- user.sendMessage("commands.admin.purge.scanning");
- scanning = true;
- getOldIslands(days).thenAccept(islandSet -> {
- user.sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER,
- String.valueOf(islandSet.size()));
- if (islandSet.size() > TOO_MANY
- && !BentoBox.getInstance().getSettings().isKeepPreviousIslandOnReset()) {
- user.sendMessage("commands.admin.purge.too-many"); // Give warning
- }
- if (!islandSet.isEmpty()) {
- toBeConfirmed = true;
- user.sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, this.getTopLabel());
- islands = islandSet;
- } else {
- user.sendMessage("commands.admin.purge.none-found");
- }
- scanning = false;
- });
-
} catch (NumberFormatException e) {
- user.sendMessage("commands.admin.purge.number-error");
+ user.sendMessage("commands.admin.purge.days-one-or-more");
return false;
}
+
+ final int finalDays = days;
+ runScanAndPrompt(() -> getPlugin().getPurgeRegionsService().scan(getWorld(), finalDays));
return true;
}
- void removeIslands() {
- inPurge = true;
- user.sendMessage("commands.admin.purge.see-console-for-status", TextVariables.LABEL, this.getTopLabel());
- it = islands.iterator();
- count = 0;
- loggedTiers.clear(); // % reporting
- // Delete first island
- deleteIsland();
+ @Override
+ protected String logPrefix() {
+ return "Purge";
}
- private void deleteIsland() {
- if (it.hasNext()) {
- getIslands().getIslandById(it.next()).ifPresent(i -> {
- getIslands().deleteIsland(i, true, null);
- count++;
- float percentage = ((float) count / getPurgeableIslandsCount()) * 100;
- String percentageStr = String.format("%.1f", percentage);
- // Round the percentage to check for specific tiers
- int roundedPercentage = (int) Math.floor(percentage);
-
- // Determine if this percentage should be logged: 1%, 5%, or any new multiple of 5%
- if (!BentoBox.getInstance().getSettings().isKeepPreviousIslandOnReset() || (roundedPercentage > 0
- && (roundedPercentage == 1 || roundedPercentage % 5 == 0)
- && !loggedTiers.contains(roundedPercentage))) {
-
- // Log the message and add the tier to the logged set
- getPlugin().log(count + " islands purged out of " + getPurgeableIslandsCount() + " ("
- + percentageStr + " %)");
- loggedTiers.add(roundedPercentage);
- }
- });
- } else {
- user.sendMessage("commands.admin.purge.completed");
- inPurge = false;
- }
-
+ @Override
+ protected String successMessageKey() {
+ return "general.success";
}
- @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
- void onIslandDeleted(IslandDeletedEvent e) {
- if (inPurge) {
- // Run after one tick - you cannot run millions of events in one tick otherwise the server shuts down
- Bukkit.getScheduler().runTaskLater(getPlugin(), this::deleteIsland, 2L); // 10 a second
- }
+ @Override
+ protected void sendConfirmPrompt() {
+ user.sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, this.getTopLabel());
+ user.sendMessage("general.beta");
}
- /**
- * Gets a set of islands that are older than the parameter in days
- * @param days days
- * @return set of islands
- */
- CompletableFuture> getOldIslands(int days) {
- CompletableFuture> result = new CompletableFuture<>();
- // Process islands in one pass, logging and adding to the set if applicable
- getPlugin().getIslands().getIslandsASync().thenAccept(list -> {
- user.sendMessage("commands.admin.purge.total-islands", TextVariables.NUMBER, String.valueOf(list.size()));
- Set oldIslands = new HashSet<>();
- list.stream()
- .filter(i -> !i.isSpawn()).filter(i -> !i.isPurgeProtected())
- .filter(i -> i.getWorld() != null) // to handle currently unloaded world islands
- .filter(i -> i.getWorld().equals(this.getWorld())) // Island needs to be in this world
- .filter(Island::isOwned) // The island needs to be owned
- .filter(i -> i.getMemberSet().stream().allMatch(member -> checkLastLoginTimestamp(days, member)))
- .forEach(i -> oldIslands.add(i.getUniqueId())); // Add the unique island ID to the set
-
- result.complete(oldIslands);
- });
- return result;
+ @Override
+ protected void logScanContents(Set uniqueIslands, PurgeScanResult scan) {
+ uniqueIslands.forEach(this::displayIsland);
+ scan.deletableRegions().entrySet().stream()
+ .filter(e -> e.getValue().isEmpty())
+ .forEach(e -> displayEmptyRegion(e.getKey()));
}
- private boolean checkLastLoginTimestamp(int days, UUID member) {
- long daysInMilliseconds = days * 24L * 3600 * 1000; // Calculate days in milliseconds
- Long lastLoginTimestamp = getPlayers().getLastLoginTimestamp(member);
- // If no valid last login time is found, or it's before the year 2000, try to fetch from Bukkit
- if (lastLoginTimestamp == null || lastLoginTimestamp < YEAR2000) {
- lastLoginTimestamp = Bukkit.getOfflinePlayer(member).getLastSeen();
-
- // If still invalid, set the current timestamp to mark the user for eventual purging
- if (lastLoginTimestamp < YEAR2000) {
- getPlayers().setLoginTimeStamp(member, System.currentTimeMillis());
- return false; // User will be purged in the future
- } else {
- // Otherwise, update the last login timestamp with the valid value from Bukkit
- getPlayers().setLoginTimeStamp(member, lastLoginTimestamp);
- }
+ private void displayIsland(Island island) {
+ if (island.isDeletable()) {
+ getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector())
+ + IN_WORLD + getWorld().getName() + WILL_BE_DELETED);
+ return;
}
- // Check if the difference between now and the last login is greater than the allowed days
- return System.currentTimeMillis() - lastLoginTimestamp > daysInMilliseconds;
- }
-
-
- /**
- * @return the inPurge
- */
- boolean isInPurge() {
- return inPurge;
- }
-
- /**
- * Stop the purge
- */
- void stop() {
- inPurge = false;
- }
-
- /**
- * @param user the user to set
- */
- void setUser(User user) {
- this.user = user;
- }
-
- /**
- * @param islands the islands to set
- */
- void setIslands(Set islands) {
- this.islands = islands;
+ if (island.getOwner() == null) {
+ getPlugin().log("Unowned island at " + Util.xyz(island.getCenter().toVector())
+ + IN_WORLD + getWorld().getName() + WILL_BE_DELETED);
+ return;
+ }
+ getPlugin().log("Island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName()
+ + " owned by " + getPlugin().getPlayers().getName(island.getOwner())
+ + " who last logged in "
+ + formatLocalTimestamp(getPlugin().getPlayers().getLastLoginTimestamp(island.getOwner()))
+ + WILL_BE_DELETED);
}
- /**
- * Returns the amount of purged islands.
- * @return the amount of islands that have been purged.
- * @since 1.13.0
- */
- int getPurgedIslandsCount() {
- return this.count;
+ private void displayEmptyRegion(Pair region) {
+ getPlugin().log("Empty region at r." + region.x() + "." + region.z() + IN_WORLD
+ + getWorld().getName() + " will be deleted (no islands)");
}
- /**
- * Returns the amount of islands that can be purged.
- * @return the amount of islands that can be purged.
- * @since 1.13.0
- */
- int getPurgeableIslandsCount() {
- return this.islands.size();
+ private String formatLocalTimestamp(Long millis) {
+ if (millis == null) {
+ return "(unknown or never recorded)";
+ }
+ Instant instant = Instant.ofEpochMilli(millis);
+ DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
+ .withZone(ZoneId.systemDefault());
+ return formatter.format(instant);
}
}
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java
new file mode 100644
index 000000000..88d128fcb
--- /dev/null
+++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java
@@ -0,0 +1,83 @@
+package world.bentobox.bentobox.api.commands.admin.purge;
+
+import java.util.List;
+import java.util.Set;
+
+import org.bukkit.World;
+
+import world.bentobox.bentobox.api.commands.CompositeCommand;
+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.managers.PurgeRegionsService;
+import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult;
+import world.bentobox.bentobox.util.Util;
+
+/**
+ * Admin command to reap region files for every island already flagged as
+ * {@code deletable}, ignoring region-file age entirely.
+ *
+ * Counterpart to {@link AdminPurgeCommand} which filters on the age of
+ * the .mca files. This command trusts the {@code deletable} flag set by
+ * {@code /is reset} (and Phase 2 soft-delete) and reaps immediately.
+ *
+ *
Heavy lifting is delegated to {@link PurgeRegionsService#scanDeleted(World)}
+ * and {@link PurgeRegionsService#delete(PurgeScanResult)}.
+ *
+ * @since 3.15.0
+ */
+public class AdminPurgeDeletedCommand extends AbstractPurgeCommand {
+
+ public AdminPurgeDeletedCommand(CompositeCommand parent) {
+ super(parent, "deleted");
+ }
+
+ @Override
+ public void setup() {
+ setPermission("admin.purge.deleted");
+ setOnlyPlayer(false);
+ setParametersHelp("commands.admin.purge.deleted.parameters");
+ setDescription("commands.admin.purge.deleted.description");
+ }
+
+ @Override
+ public boolean execute(User user, String label, List args) {
+ this.user = user;
+ if (!args.isEmpty() && args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed) {
+ return deleteEverything();
+ }
+ toBeConfirmed = false;
+ runScanAndPrompt(() -> getPlugin().getPurgeRegionsService().scanDeleted(getWorld()));
+ return true;
+ }
+
+ @Override
+ protected String logPrefix() {
+ return "Purge deleted";
+ }
+
+ @Override
+ protected String successMessageKey() {
+ return "commands.admin.purge.deleted.deferred";
+ }
+
+ @Override
+ protected void sendConfirmPrompt() {
+ user.sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, this.getLabel());
+ }
+
+ @Override
+ protected void beforeDelete(PurgeScanResult scan) {
+ // Evict in-memory chunks for the target regions on the main thread,
+ // otherwise Paper's autosave/unload would re-flush them over the
+ // about-to-be-deleted region files (#region-purge bug).
+ getPlugin().getPurgeRegionsService().evictChunks(scan);
+ }
+
+ @Override
+ protected void logScanContents(Set uniqueIslands, PurgeScanResult scan) {
+ uniqueIslands.forEach(island ->
+ getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector())
+ + " in world " + getWorld().getName() + " will be reaped"));
+ }
+}
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java
deleted file mode 100644
index 294fe17a4..000000000
--- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java
+++ /dev/null
@@ -1,798 +0,0 @@
-package world.bentobox.bentobox.api.commands.admin.purge;
-
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.nio.file.Files;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.UUID;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Collectors;
-
-import org.bukkit.Bukkit;
-import org.bukkit.World;
-import org.bukkit.event.Listener;
-
-import world.bentobox.bentobox.api.commands.CompositeCommand;
-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.managers.island.IslandGrid;
-import world.bentobox.bentobox.managers.island.IslandGrid.IslandData;
-import world.bentobox.bentobox.util.Pair;
-import world.bentobox.bentobox.util.Util;
-import world.bentobox.level.Level;
-
-public class AdminPurgeRegionsCommand extends CompositeCommand implements Listener {
-
- private static final String NONE_FOUND = "commands.admin.purge.none-found";
- private static final String REGION = "region";
- private static final String ENTITIES = "entities";
- private static final String POI = "poi";
- private static final String DIM_1 = "DIM-1";
- private static final String DIM1 = "DIM1";
- private static final String PLAYERS = "players";
- private static final String PLAYERDATA = "playerdata";
- private static final String IN_WORLD = " in world ";
- private static final String WILL_BE_DELETED = " will be deleted";
- private static final String EXISTS_PREFIX = " (exists=";
- private static final String PURGE_FOUND = "Purge found ";
-
- private volatile boolean inPurge;
- private boolean toBeConfirmed;
- private User user;
- private Map, Set> deleteableRegions;
- private boolean isNether;
- private boolean isEnd;
- private int days;
-
- public AdminPurgeRegionsCommand(CompositeCommand parent) {
- super(parent, "regions");
- getAddon().registerListener(this);
- // isNether/isEnd are NOT computed here: IWM may not have loaded the addon world
- // config yet at command-registration time. They are evaluated lazily in findIslands().
- }
-
- @Override
- public void setup() {
- setPermission("admin.purge.regions");
- setOnlyPlayer(false);
- setParametersHelp("commands.admin.purge.regions.parameters");
- setDescription("commands.admin.purge.regions.description");
- }
-
- @Override
- public boolean canExecute(User user, String label, List args) {
- if (inPurge) {
- user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel());
- return false;
- }
- if (args.isEmpty()) {
- // Show help
- showHelp(this, user);
- return false;
- }
- return true;
- }
-
- @Override
- public boolean execute(User user, String label, List args) {
- this.user = user;
- if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed && this.user.equals(user)) {
- return deleteEverything();
- }
- /*
- * This part does the searching for region files
- */
- // Clear tbc
- toBeConfirmed = false;
-
- try {
- days = Integer.parseInt(args.getFirst());
- if (days <= 0) {
- user.sendMessage("commands.admin.purge.days-one-or-more");
- return false;
- }
-
- } catch (NumberFormatException e) {
- user.sendMessage("commands.admin.purge.days-one-or-more");
- return false;
- }
-
- user.sendMessage("commands.admin.purge.scanning");
- // Save all worlds to update any region files
- Bukkit.getWorlds().forEach(World::save);
-
- // Find the potential islands
- Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), ()-> findIslands(getWorld(), days));
- return true;
- }
-
-
- private boolean deleteEverything() {
- if (deleteableRegions.isEmpty()) {
- user.sendMessage(NONE_FOUND); // Should never happen
- return false;
- }
- // Save the worlds
- Bukkit.getWorlds().forEach(World::save);
- // Recheck to see if any regions are newer than days and if so, stop everything
- getPlugin().log("Now deleting region files");
- if (!deleteRegionFiles()) {
- // Fail!
- getPlugin().logError("Not all region files could be deleted");
- }
- // Delete islands and regions
- for (Set islandIDs : deleteableRegions.values()) {
- for (String islandID : islandIDs) {
- deletePlayerFromWorldFolder(islandID);
- // Remove island from the cache
- getPlugin().getIslands().getIslandCache().deleteIslandFromCache(islandID);
- // Delete island from database using id
- if (getPlugin().getIslands().deleteIslandId(islandID)) {
- // Log
- getPlugin().log("Island ID " + islandID + " deleted from cache and database" );
- }
- }
- }
-
- user.sendMessage("general.success");
- toBeConfirmed = false;
- deleteableRegions.clear();
- return true;
-
- }
-
- private void deletePlayerFromWorldFolder(String islandID) {
- File playerData = resolvePlayerDataFolder();
- getPlugin().getIslands().getIslandById(islandID)
- .ifPresent(island -> island.getMemberSet()
- .forEach(uuid -> maybeDeletePlayerData(uuid, playerData)));
- }
-
- private void maybeDeletePlayerData(UUID uuid, File playerData) {
- List memberOf = new ArrayList<>(getIslands().getIslands(getWorld(), uuid));
- deleteableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId())));
- if (!memberOf.isEmpty()) {
- return;
- }
- if (Bukkit.getOfflinePlayer(uuid).isOp()) {
- return;
- }
- long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days);
- if (resolveLastLogin(uuid) >= cutoffMillis) {
- return;
- }
- deletePlayerFiles(uuid, playerData);
- }
-
- private long resolveLastLogin(UUID uuid) {
- Long lastLogin = getPlugin().getPlayers().getLastLoginTimestamp(uuid);
- return lastLogin != null ? lastLogin : Bukkit.getOfflinePlayer(uuid).getLastSeen();
- }
-
- private void deletePlayerFiles(UUID uuid, File playerData) {
- if (!playerData.exists()) {
- return;
- }
- deletePlayerFile(new File(playerData, uuid + ".dat"), "player data file");
- deletePlayerFile(new File(playerData, uuid + ".dat_old"), "player data backup file");
- }
-
- private void deletePlayerFile(File file, String description) {
- try {
- Files.deleteIfExists(file.toPath());
- } catch (IOException ex) {
- getPlugin().logError("Failed to delete " + description + ": " + file.getAbsolutePath());
- }
- }
-
- /**
- * Resolves the base data folder for a world, accounting for the dimension
- * subfolder layout.
- *
- * Pre-Minecraft version 26.1 (old format): Nether data lives in {@code DIM-1/} and
- * End data lives in {@code DIM1/} subfolders inside the world folder.
- *
- * Minecraft version 26.1.1+ (new format): Each dimension has its own world folder
- * under {@code dimensions/minecraft/} and data (region/, entities/, poi/)
- * lives directly in it — no DIM-1/DIM1 subfolders.
- *
- * @param world the world to resolve
- * @return the base folder containing region/, entities/, poi/ subfolders
- */
- private File resolveDataFolder(World world) {
- File worldFolder = world.getWorldFolder();
- return switch (world.getEnvironment()) {
- case NETHER -> {
- File dim = new File(worldFolder, DIM_1);
- yield dim.isDirectory() ? dim : worldFolder;
- }
- case THE_END -> {
- File dim = new File(worldFolder, DIM1);
- yield dim.isDirectory() ? dim : worldFolder;
- }
- default -> worldFolder;
- };
- }
-
- /**
- * Resolves the player data folder, supporting both old and new formats.
- *
- * Pre-26.1: {@code /playerdata/}
- *
- * 26.1.1+: {@code /players/data/} (centralized)
- *
- * @return the folder containing player .dat files
- */
- private File resolvePlayerDataFolder() {
- File worldFolder = getWorld().getWorldFolder();
- // Old format
- File oldPath = new File(worldFolder, PLAYERDATA);
- if (oldPath.isDirectory()) {
- return oldPath;
- }
- // New 26.1.1 format: walk up from dimensions/minecraft// to world root
- File root = worldFolder.getParentFile(); // minecraft/
- if (root != null) root = root.getParentFile(); // dimensions/
- if (root != null) root = root.getParentFile(); // world root
- if (root != null) {
- File newPath = new File(root, PLAYERS + File.separator + "data");
- if (newPath.isDirectory()) {
- return newPath;
- }
- }
- return oldPath; // fallback
- }
-
- /**
- * Resolves the nether data folder when the Nether World object is unavailable.
- * Tries the old DIM-1 subfolder first, then the 26.1.1 sibling world folder.
- *
- * @param overworldFolder the overworld's world folder
- * @return the nether base folder (may not exist)
- */
- private File resolveNetherFallback(File overworldFolder) {
- // Old format: /DIM-1/
- File dim = new File(overworldFolder, DIM_1);
- if (dim.isDirectory()) {
- return dim;
- }
- // New 26.1.1 format: sibling folder _nether in same parent
- File parent = overworldFolder.getParentFile();
- if (parent != null) {
- File sibling = new File(parent, overworldFolder.getName() + "_nether");
- if (sibling.isDirectory()) {
- return sibling;
- }
- }
- return dim; // fallback to old path
- }
-
- /**
- * Resolves the end data folder when the End World object is unavailable.
- * Tries the old DIM1 subfolder first, then the 26.1.1 sibling world folder.
- *
- * @param overworldFolder the overworld's world folder
- * @return the end base folder (may not exist)
- */
- private File resolveEndFallback(File overworldFolder) {
- // Old format: /DIM1/
- File dim = new File(overworldFolder, DIM1);
- if (dim.isDirectory()) {
- return dim;
- }
- // New 26.1.1 format: sibling folder _the_end in same parent
- File parent = overworldFolder.getParentFile();
- if (parent != null) {
- File sibling = new File(parent, overworldFolder.getName() + "_the_end");
- if (sibling.isDirectory()) {
- return sibling;
- }
- }
- return dim; // fallback to old path
- }
-
- /**
- * Deletes a file if it exists, logging an error if deletion fails.
- * Does not log if the parent folder does not exist (normal for entities/poi).
- * @param file the file to delete
- * @return true if deleted or does not exist, false if exists but could not be deleted
- */
- private boolean deleteIfExists(File file) {
- if (!file.getParentFile().exists()) {
- // Parent folder missing is normal for entities/poi, do not log
- return true;
- }
- try {
- Files.deleteIfExists(file.toPath());
- return true;
- } catch (IOException e) {
- getPlugin().logError("Failed to delete file: " + file.getAbsolutePath());
- return false;
- }
- }
-
- /**
- * Deletes all region files in deleteableRegions that are older than {@code days}.
- * Also deletes corresponding entities and poi files in each dimension.
- * @return {@code true} if deletion was performed; {@code false} if cancelled
- * due to any file being newer than the cutoff
- */
- private boolean deleteRegionFiles() {
- if (days <= 0) {
- getPlugin().logError("Days is somehow zero or negative!");
- return false;
- }
- long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days);
-
- World world = getWorld();
- File base = world.getWorldFolder();
- File overworldRegion = new File(base, REGION);
- File overworldEntities = new File(base, ENTITIES);
- File overworldPoi = new File(base, POI);
-
- World netherWorld = getPlugin().getIWM().getNetherWorld(world);
- File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base);
- File netherRegion = new File(netherBase, REGION);
- File netherEntities = new File(netherBase, ENTITIES);
- File netherPoi = new File(netherBase, POI);
-
- World endWorld = getPlugin().getIWM().getEndWorld(world);
- File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base);
- File endRegion = new File(endBase, REGION);
- File endEntities = new File(endBase, ENTITIES);
- File endPoi = new File(endBase, POI);
-
- // Phase 1: verify none of the files have been updated since the cutoff
- for (Pair coords : deleteableRegions.keySet()) {
- String name = "r." + coords.x() + "." + coords.z() + ".mca";
- if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis)) {
- return false;
- }
- }
-
- // Phase 2: perform deletions
- DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi);
- DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi);
- DimFolders end = new DimFolders(endRegion, endEntities, endPoi);
- for (Pair coords : deleteableRegions.keySet()) {
- String name = "r." + coords.x() + "." + coords.z() + ".mca";
- if (!deleteOneRegion(name, ow, nether, end)) {
- getPlugin().logError("Could not delete all the region/entity/poi files for some reason");
- }
- }
-
- return true;
- }
-
- private boolean isFileFresh(File file, long cutoffMillis) {
- return file.exists() && getRegionTimestamp(file) >= cutoffMillis;
- }
-
- private boolean isAnyDimensionFresh(String name, File overworldRegion, File netherRegion,
- File endRegion, long cutoffMillis) {
- if (isFileFresh(new File(overworldRegion, name), cutoffMillis)) return true;
- if (isNether && isFileFresh(new File(netherRegion, name), cutoffMillis)) return true;
- return isEnd && isFileFresh(new File(endRegion, name), cutoffMillis);
- }
-
- /** Groups the three folder types (region, entities, poi) for one world dimension. */
- private record DimFolders(File region, File entities, File poi) {}
-
- private boolean deleteOneRegion(String name, DimFolders overworld, DimFolders nether, DimFolders end) {
- boolean owRegionOk = deleteIfExists(new File(overworld.region(), name));
- boolean owEntitiesOk = deleteIfExists(new File(overworld.entities(), name));
- boolean owPoiOk = deleteIfExists(new File(overworld.poi(), name));
- boolean ok = owRegionOk && owEntitiesOk && owPoiOk;
- if (isNether) {
- ok &= deleteIfExists(new File(nether.region(), name));
- ok &= deleteIfExists(new File(nether.entities(), name));
- ok &= deleteIfExists(new File(nether.poi(), name));
- }
- if (isEnd) {
- ok &= deleteIfExists(new File(end.region(), name));
- ok &= deleteIfExists(new File(end.entities(), name));
- ok &= deleteIfExists(new File(end.poi(), name));
- }
- return ok;
- }
-
- /** Tracks island-level and region-level block counts during filtering. */
- private record FilterStats(int islandsOverLevel, int islandsPurgeProtected,
- int regionsBlockedByLevel, int regionsBlockedByProtection) {}
-
- /**
- * This method is run async!
- * @param world world
- * @param days days old
- */
- private void findIslands(World world, int days) {
- // Evaluate here, not in the constructor - IWM config is loaded by the time a command runs
- isNether = getPlugin().getIWM().isNetherGenerate(world) && getPlugin().getIWM().isNetherIslands(world);
- isEnd = getPlugin().getIWM().isEndGenerate(world) && getPlugin().getIWM().isEndIslands(world);
- try {
- // Get the grid that covers this world
- IslandGrid islandGrid = getPlugin().getIslands().getIslandCache().getIslandGrid(world);
- if (islandGrid == null) {
- Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND));
- return;
- }
- // Find old regions
- List> oldRegions = this.findOldRegions(days);
- // Get islands that are associated with these regions
- deleteableRegions = this.mapIslandsToRegions(oldRegions, islandGrid);
- // Filter regions and log summary
- FilterStats stats = filterNonDeletableRegions();
- logFilterStats(stats);
- // Display results and prompt for confirmation
- displayResultsAndPrompt();
- } finally {
- inPurge = false;
- }
- }
-
- /**
- * Removes regions from {@code deleteableRegions} whose island-set contains
- * at least one island that cannot be deleted, and returns blocking statistics.
- */
- private FilterStats filterNonDeletableRegions() {
- int islandsOverLevel = 0;
- int islandsPurgeProtected = 0;
- int regionsBlockedByLevel = 0;
- int regionsBlockedByProtection = 0;
-
- var iter = deleteableRegions.entrySet().iterator();
- while (iter.hasNext()) {
- var entry = iter.next();
- int[] regionCounts = evaluateRegionIslands(entry.getValue());
- if (regionCounts[0] > 0) { // shouldRemove
- iter.remove();
- islandsOverLevel += regionCounts[1];
- islandsPurgeProtected += regionCounts[2];
- if (regionCounts[1] > 0) regionsBlockedByLevel++;
- if (regionCounts[2] > 0) regionsBlockedByProtection++;
- }
- }
- return new FilterStats(islandsOverLevel, islandsPurgeProtected,
- regionsBlockedByLevel, regionsBlockedByProtection);
- }
-
- /**
- * Evaluates a set of island IDs for a single region.
- * @return int array: [shouldRemove (0 or 1), levelBlockCount, purgeProtectedCount]
- */
- private int[] evaluateRegionIslands(Set islandIds) {
- int shouldRemove = 0;
- int levelBlocked = 0;
- int purgeBlocked = 0;
- for (String id : islandIds) {
- Optional opt = getPlugin().getIslands().getIslandById(id);
- if (opt.isEmpty()) {
- shouldRemove = 1;
- continue;
- }
- Island isl = opt.get();
- if (canDeleteIsland(isl)) {
- shouldRemove = 1;
- if (isl.isPurgeProtected()) purgeBlocked++;
- if (isLevelTooHigh(isl)) levelBlocked++;
- }
- }
- return new int[] { shouldRemove, levelBlocked, purgeBlocked };
- }
-
- private void logFilterStats(FilterStats stats) {
- if (stats.islandsOverLevel() > 0) {
- getPlugin().log("Purge: " + stats.islandsOverLevel() + " island(s) exceed the level threshold of "
- + getPlugin().getSettings().getIslandPurgeLevel()
- + " - preventing " + stats.regionsBlockedByLevel() + " region(s) from being purged");
- }
- if (stats.islandsPurgeProtected() > 0) {
- getPlugin().log("Purge: " + stats.islandsPurgeProtected() + " island(s) are purge-protected"
- + " - preventing " + stats.regionsBlockedByProtection() + " region(s) from being purged");
- }
- }
-
- private void displayResultsAndPrompt() {
- Set uniqueIslands = deleteableRegions.values().stream()
- .flatMap(Set::stream)
- .map(getPlugin().getIslands()::getIslandById)
- .flatMap(Optional::stream)
- .collect(Collectors.toSet());
-
- uniqueIslands.forEach(this::displayIsland);
-
- deleteableRegions.entrySet().stream()
- .filter(e -> e.getValue().isEmpty())
- .forEach(e -> displayEmptyRegion(e.getKey()));
-
- if (deleteableRegions.isEmpty()) {
- Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND));
- } else {
- Bukkit.getScheduler().runTask(getPlugin(), () -> {
- user.sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, String.valueOf(uniqueIslands.size()));
- user.sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, this.getLabel());
- user.sendMessage("general.beta"); // TODO Remove beta in the future
- this.toBeConfirmed = true;
- });
- }
- }
-
- private void displayIsland(Island island) {
- // Log the island data
- if (island.isDeletable()) {
- getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + WILL_BE_DELETED);
- return;
- }
- if (island.getOwner() == null) {
- getPlugin().log("Unowned island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + WILL_BE_DELETED);
- return;
- }
- getPlugin().log("Island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName()
- + " owned by " + getPlugin().getPlayers().getName(island.getOwner())
- + " who last logged in " + formatLocalTimestamp(getPlugin().getPlayers().getLastLoginTimestamp(island.getOwner()))
- + WILL_BE_DELETED);
- }
-
- private void displayEmptyRegion(Pair region) {
- getPlugin().log("Empty region at r." + region.x() + "." + region.z() + IN_WORLD + getWorld().getName() + " will be deleted (no islands)");
- }
-
- /**
- * Formats a millisecond timestamp into a human-readable string
- * using the system's local time zone.
- *
- * @param millis the timestamp in milliseconds
- * @return formatted string in the form "yyyy-MM-dd HH:mm"
- */
- private String formatLocalTimestamp(Long millis) {
- if (millis == null) {
- return "(unknown or never recorded)";
- }
- Instant instant = Instant.ofEpochMilli(millis);
-
- DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
- .withZone(ZoneId.systemDefault()); // Uses the machine's local time zone
-
- return formatter.format(instant);
- }
-
- /**
- * Check if an island cannot be deleted. Purge protected, spawn, or unowned islands cannot be deleted.
- * Islands whose members recently logged in, or that exceed the level threshold, cannot be deleted.
- * @param island island
- * @return true means "cannot delete"
- */
- private boolean canDeleteIsland(Island island) {
- // If the island is marked deletable it can always be purged
- if (island.isDeletable()) {
- return false;
- }
- long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days);
- // Block if ANY member (owner or team) has logged in within the cutoff window
- boolean recentLogin = island.getMemberSet().stream().anyMatch(uuid -> {
- Long lastLogin = getPlugin().getPlayers().getLastLoginTimestamp(uuid);
- if (lastLogin == null) {
- lastLogin = Bukkit.getOfflinePlayer(uuid).getLastSeen();
- }
- return lastLogin >= cutoffMillis;
- });
- if (recentLogin) {
- return true;
- }
- if (isLevelTooHigh(island)) {
- return true;
- }
- return island.isPurgeProtected() || island.isSpawn() || !island.isOwned();
- }
-
- /**
- * Returns true if the island's level meets or exceeds the configured purge threshold.
- * Returns false when the Level addon is not present.
- * @param island island to check
- * @return true if the island level is too high to purge
- */
- private boolean isLevelTooHigh(Island island) {
- return getPlugin().getAddonsManager().getAddonByName("Level")
- .map(l -> ((Level) l).getIslandLevel(getWorld(), island.getOwner())
- >= getPlugin().getSettings().getIslandPurgeLevel())
- .orElse(false);
- }
-
- /**
- * Finds all region files in the overworld (and optionally the Nether and End)
- * that have not been modified in the last {@code days} days, and returns their
- * region coordinates.
- *
- * If {@code nether} is {@code true}, the matching region file in the
- * Nether (DIM-1) must also be older than the cutoff to include the coordinate.
- * If {@code end} is {@code true}, the matching region file in the End (DIM1)
- * must likewise be older than the cutoff. When both {@code nether} and
- * {@code end} are {@code true}, all three dimension files must satisfy the
- * age requirement.
- *
- * @param days the minimum age in days of region files to include
- * @return a list of {@code Pair} for each region meeting
- * the age criteria
- */
- private List> findOldRegions(int days) {
- World world = this.getWorld();
- File worldDir = world.getWorldFolder();
- File overworldRegion = new File(worldDir, REGION);
-
- World netherWorld = getPlugin().getIWM().getNetherWorld(world);
- File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir);
- File netherRegion = new File(netherBase, REGION);
-
- World endWorld = getPlugin().getIWM().getEndWorld(world);
- File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir);
- File endRegion = new File(endBase, REGION);
-
- long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days);
-
- logRegionFolderPaths(overworldRegion, netherRegion, endRegion, world);
-
- // Collect all candidate region names from overworld, nether, and end.
- // This ensures orphaned nether/end files are caught even if the overworld
- // file was already deleted by a previous (buggy) purge run.
- Set candidateNames = collectCandidateNames(overworldRegion, netherRegion, endRegion);
- getPlugin().log("Purge total candidate region coordinates: " + candidateNames.size());
- getPlugin().log("Purge checking candidate region(s) against island data, please wait...");
-
- List> regions = new ArrayList<>();
- for (String name : candidateNames) {
- Pair coords = parseRegionCoords(name);
- if (coords == null) continue;
- if (!isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis)) {
- regions.add(coords);
- }
- }
- return regions;
- }
-
- private void logRegionFolderPaths(File overworldRegion, File netherRegion, File endRegion, World world) {
- getPlugin().log("Purge region folders - Overworld: " + overworldRegion.getAbsolutePath()
- + EXISTS_PREFIX + overworldRegion.isDirectory() + ")");
- if (isNether) {
- getPlugin().log("Purge region folders - Nether: " + netherRegion.getAbsolutePath()
- + EXISTS_PREFIX + netherRegion.isDirectory() + ")");
- } else {
- getPlugin().log("Purge region folders - Nether: disabled (isNetherGenerate="
- + getPlugin().getIWM().isNetherGenerate(world) + ", isNetherIslands="
- + getPlugin().getIWM().isNetherIslands(world) + ")");
- }
- if (isEnd) {
- getPlugin().log("Purge region folders - End: " + endRegion.getAbsolutePath()
- + EXISTS_PREFIX + endRegion.isDirectory() + ")");
- } else {
- getPlugin().log("Purge region folders - End: disabled (isEndGenerate="
- + getPlugin().getIWM().isEndGenerate(world) + ", isEndIslands="
- + getPlugin().getIWM().isEndIslands(world) + ")");
- }
- }
-
- private Set collectCandidateNames(File overworldRegion, File netherRegion, File endRegion) {
- Set names = new HashSet<>();
- addFileNames(names, overworldRegion.listFiles((dir, name) -> name.endsWith(".mca")), "overworld");
- if (isNether) {
- addFileNames(names, netherRegion.listFiles((dir, name) -> name.endsWith(".mca")), "nether");
- }
- if (isEnd) {
- addFileNames(names, endRegion.listFiles((dir, name) -> name.endsWith(".mca")), "end");
- }
- return names;
- }
-
- private void addFileNames(Set names, File[] files, String dimension) {
- if (files != null) {
- for (File f : files) names.add(f.getName());
- }
- getPlugin().log(PURGE_FOUND + (files != null ? files.length : 0) + " " + dimension + " region files");
- }
-
- private Pair parseRegionCoords(String name) {
- // Parse region coords from filename "r...mca"
- String coordsPart = name.substring(2, name.length() - 4);
- String[] parts = coordsPart.split("\\.");
- if (parts.length != 2) return null;
- try {
- return new Pair<>(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
- } catch (NumberFormatException ex) {
- return null;
- }
- }
-
- /**
- * Maps each old region to the set of island IDs whose island-squares overlap it.
- *
- * Each region covers blocks
- * [regionX*512 .. regionX*512 + 511] x [regionZ*512 .. regionZ*512 + 511].
- *
- * @param oldRegions the list of region coordinates to process
- * @param islandGrid the spatial grid to query
- * @return a map from region coords to the set of overlapping island IDs
- */
- private Map, Set> mapIslandsToRegions(
- List> oldRegions,
- IslandGrid islandGrid
- ) {
- final int blocksPerRegion = 512;
- Map, Set> regionToIslands = new HashMap<>();
-
- for (Pair region : oldRegions) {
- int regionMinX = region.x() * blocksPerRegion;
- int regionMinZ = region.z() * blocksPerRegion;
- int regionMaxX = regionMinX + blocksPerRegion - 1;
- int regionMaxZ = regionMinZ + blocksPerRegion - 1;
-
- Set ids = new HashSet<>();
- for (IslandData data : islandGrid.getIslandsInBounds(regionMinX, regionMinZ, regionMaxX, regionMaxZ)) {
- ids.add(data.id());
- }
-
- // Always add the region, even if ids is empty
- regionToIslands.put(region, ids);
- }
-
- return regionToIslands;
- }
-
- /**
- * Reads a Minecraft region file (.mca) and returns the most recent
- * per-chunk timestamp found in its header, in milliseconds since epoch.
- *
- * @param regionFile the .mca file
- * @return the most recent timestamp (in millis) among all chunk entries,
- * or 0 if the file is invalid or empty
- */
- private long getRegionTimestamp(File regionFile) {
- if (!regionFile.exists() || regionFile.length() < 8192) {
- return 0L;
- }
-
- try (FileInputStream fis = new FileInputStream(regionFile)) {
- byte[] buffer = new byte[4096]; // Second 4KB block is the timestamp table
-
- // Skip first 4KB (location table)
- if (fis.skip(4096) != 4096) {
- return 0L;
- }
-
- // Read the timestamp table
- if (fis.read(buffer) != 4096) {
- return 0L;
- }
-
- ByteBuffer bb = ByteBuffer.wrap(buffer);
- bb.order(ByteOrder.BIG_ENDIAN); // Timestamps are stored as big-endian ints
-
- long maxTimestampSeconds = 0;
-
- for (int i = 0; i < 1024; i++) {
- long timestamp = Integer.toUnsignedLong(bb.getInt());
- if (timestamp > maxTimestampSeconds) {
- maxTimestampSeconds = timestamp;
- }
- }
-
- // Convert seconds to milliseconds
- return maxTimestampSeconds * 1000L;
-
- } catch (IOException e) {
- getPlugin().logError("Failed to read region file timestamps: " + regionFile.getAbsolutePath() + " " + e.getMessage());
- return 0L;
- }
- }
-}
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java
deleted file mode 100644
index 3f5bde9aa..000000000
--- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package world.bentobox.bentobox.api.commands.admin.purge;
-
-import java.util.List;
-
-import world.bentobox.bentobox.api.commands.CompositeCommand;
-import world.bentobox.bentobox.api.localization.TextVariables;
-import world.bentobox.bentobox.api.user.User;
-
-/**
- * Displays the current status and progress of the purge.
- * @since 1.13.0
- * @author Poslovitch
- */
-public class AdminPurgeStatusCommand extends CompositeCommand {
-
- public AdminPurgeStatusCommand(AdminPurgeCommand parent) {
- super(parent, "status");
- }
-
- @Override
- public void setup() {
- setPermission("admin.purge.status");
- setOnlyPlayer(false);
- setParametersHelp("commands.admin.purge.status.parameters");
- setDescription("commands.admin.purge.status.description");
- }
-
- @Override
- public boolean execute(User user, String label, List args) {
- if (!args.isEmpty()) {
- // Show help
- showHelp(this, user);
- return false;
- }
- AdminPurgeCommand parentCommand = ((AdminPurgeCommand)getParent());
- if (parentCommand.isInPurge()) {
- int purged = parentCommand.getPurgedIslandsCount();
- int purgeable = parentCommand.getPurgeableIslandsCount();
- user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel());
- user.sendMessage("commands.admin.purge.status.status",
- "[purged]", String.valueOf(purged),
- "[purgeable]", String.valueOf(purgeable),
- "[percentage]", String.format("%.1f", (((float) purged)/purgeable) * 100));
- } else {
- user.sendMessage("commands.admin.purge.no-purge-in-progress");
- }
- return true;
- }
-}
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java
deleted file mode 100644
index 84aeb6190..000000000
--- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package world.bentobox.bentobox.api.commands.admin.purge;
-
-import java.util.List;
-
-import world.bentobox.bentobox.api.commands.CompositeCommand;
-import world.bentobox.bentobox.api.user.User;
-
-public class AdminPurgeStopCommand extends CompositeCommand {
-
- public AdminPurgeStopCommand(CompositeCommand parent) {
- super(parent, "stop", "cancel");
- }
-
- @Override
- public void setup() {
- setPermission("admin.purge.stop");
- setOnlyPlayer(false);
- setDescription("commands.admin.purge.stop.description");
- }
-
- @Override
- public boolean execute(User user, String label, List args) {
- if (!args.isEmpty()) {
- // Show help
- showHelp(this, user);
- return false;
- }
- AdminPurgeCommand parentCommand = ((AdminPurgeCommand)getParent());
- if (parentCommand.isInPurge()) {
- user.sendMessage("commands.admin.purge.stop.stopping");
- parentCommand.stop();
- return true;
- } else {
- user.sendMessage("commands.admin.purge.no-purge-in-progress");
- return false;
- }
- }
-}
diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java
index d3334b13b..89519fabf 100644
--- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java
+++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java
@@ -8,7 +8,17 @@
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;
+/**
+ * Flags every unowned, non-spawn, non-purge-protected island in the current
+ * world as {@code deletable} so that the next {@code /bbox admin purge deleted}
+ * (or the housekeeping deleted-sweep) reaps their region files.
+ *
+ * Since 3.15.0 the region-files purge excludes {@code !isOwned()} by design,
+ * so orphans never accumulate on disk unless something flags them
+ * {@code deletable} first. This command is that bridge.
+ */
public class AdminPurgeUnownedCommand extends ConfirmableCommand {
public AdminPurgeUnownedCommand(AdminPurgeCommand parent) {
@@ -26,36 +36,54 @@ public void setup() {
@Override
public boolean execute(User user, String label, List args) {
if (!args.isEmpty()) {
- // Show help
showHelp(this, user);
return false;
}
- AdminPurgeCommand parentCommand = ((AdminPurgeCommand)getParent());
- if (parentCommand.isInPurge()) {
- user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel());
- return false;
- }
- Set unowned = getUnownedIslands();
- user.sendMessage("commands.admin.purge.unowned.unowned-islands", TextVariables.NUMBER, String.valueOf(unowned.size()));
- if (!unowned.isEmpty()) {
- this.askConfirmation(user, () -> {
- parentCommand.setUser(user);
- parentCommand.setIslands(unowned);
- parentCommand.removeIslands();
- });
+ Set unowned = getUnownedIslands();
+ user.sendMessage("commands.admin.purge.unowned.unowned-islands",
+ TextVariables.NUMBER, String.valueOf(unowned.size()));
+ if (unowned.isEmpty()) {
+ return true;
}
+ // Log locations up front so the admin can see what they are
+ // confirming before they confirm.
+ unowned.forEach(i -> getPlugin().log("Unowned island at "
+ + Util.xyz(i.getCenter().toVector())
+ + " in world " + getWorld().getName()
+ + " will be flagged deletable"));
+ askConfirmation(user, () -> flagDeletable(user, unowned));
return true;
}
- Set getUnownedIslands() {
+ private void flagDeletable(User user, Set unowned) {
+ int flagged = 0;
+ for (Island island : unowned) {
+ getPlugin().log("Flagging unowned island at "
+ + Util.xyz(island.getCenter().toVector())
+ + " in world " + getWorld().getName() + " as deletable");
+ // Reuses the standard soft-delete path: fires the cancellable
+ // DELETE event, kicks any trespassers, sets deletable=true,
+ // saves, and fires DELETED. An addon veto skips the island.
+ getPlugin().getIslands().deleteIsland(island, true, null);
+ if (island.isDeletable()) {
+ flagged++;
+ }
+ }
+ getPlugin().log("Purge unowned: " + flagged + " of " + unowned.size()
+ + " island(s) flagged deletable in " + getWorld().getName());
+ user.sendMessage("commands.admin.purge.unowned.flagged",
+ TextVariables.NUMBER, String.valueOf(flagged),
+ TextVariables.LABEL, getTopLabel());
+ }
+
+ Set getUnownedIslands() {
return getPlugin().getIslands().getIslands().stream()
.filter(i -> !i.isSpawn())
.filter(i -> !i.isPurgeProtected())
- .filter(i -> this.getWorld().equals(i.getWorld()))
+ .filter(i -> getWorld().equals(i.getWorld()))
.filter(Island::isUnowned)
- .map(Island::getUniqueId)
+ .filter(i -> !i.isDeletable())
.collect(Collectors.toSet());
-
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/world/bentobox/bentobox/api/events/island/IslandEvent.java b/src/main/java/world/bentobox/bentobox/api/events/island/IslandEvent.java
index 89ce1e217..29b7b9e28 100644
--- a/src/main/java/world/bentobox/bentobox/api/events/island/IslandEvent.java
+++ b/src/main/java/world/bentobox/bentobox/api/events/island/IslandEvent.java
@@ -90,9 +90,35 @@ public enum Reason {
*/
DELETE_CHUNKS,
/**
- * Fired after all island chunks have been deleted or set for regeneration by the server
+ * Fired when an island is logically/effectively deleted — that is, when it is
+ * no longer reachable or usable by players. This fires immediately:
+ *
+ * - For a soft-delete (e.g. {@code /is reset} on new-chunk-generation
+ * gamemodes): fires as soon as the island is marked {@code deletable=true}
+ * in the database, which is at reset time. The region files on disk may not
+ * be reclaimed until later (see {@link #PURGED}).
+ * - For a hard-delete (e.g. {@code /bbox admin delete} on void/simple
+ * gamemodes): fires after the database row is removed.
+ * - For an age-sweep purge of an island that was never explicitly reset:
+ * fires when the database row is finally removed.
+ *
+ * Addon listeners should use this event to clean up their own per-island data
+ * (caches, database rows, etc.).
*/
DELETED,
+ /**
+ * Fired when an island's region files and database row have been
+ * physically removed from disk and the database. This always follows a
+ * {@link #DELETED} event, but may be separated from it by hours or days
+ * (e.g. when the housekeeping purge runs).
+ *
+ * Most addons do not need this event — use {@link #DELETED} for cleanup.
+ * {@code PURGED} is intended for addons that specifically track region-file
+ * or disk-space state.
+ *
+ * @since 3.15.0
+ */
+ PURGED,
/**
* Fired when a player enters an island
*/
diff --git a/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java b/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java
index a72491c1e..d3d189f14 100644
--- a/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java
+++ b/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java
@@ -17,7 +17,7 @@
* Left-clicks increase the default island protection rank, right-clicks decrease it.
* This modifies the default rank that new islands will receive for a protection flag.
* @author tastybento
- * @since 3.14.0
+ * @since 3.15.0
*/
public class IslandDefaultCycleClick implements PanelItem.ClickHandler {
diff --git a/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java b/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java
index 8433f2cbb..13bdf1672 100644
--- a/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java
+++ b/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java
@@ -6,12 +6,14 @@
import org.bukkit.Material;
import org.bukkit.util.Vector;
+import org.bukkit.inventory.ItemStack;
import org.eclipse.jdt.annotation.NonNull;
import com.google.gson.annotations.Expose;
import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBlock;
import world.bentobox.bentobox.blueprints.dataobjects.BlueprintEntity;
+import world.bentobox.bentobox.util.ItemParser;
/**
* Stores all details of a blueprint
@@ -20,6 +22,8 @@
*/
public class Blueprint {
+ private static final String DEFAULT_ICON = "PAPER";
+
/**
* Unique name for this blueprint. The filename will be this plus the blueprint suffix
*/
@@ -27,8 +31,13 @@ public class Blueprint {
private @NonNull String name = "";
@Expose
private String displayName;
+ /**
+ * Icon of the blueprint. Supports plain material names (e.g. "DIAMOND"),
+ * vanilla namespaced materials (e.g. "minecraft:diamond"), and custom
+ * item model keys (e.g. "myserver:island_tropical").
+ */
@Expose
- private @NonNull Material icon = Material.PAPER;
+ private String icon = DEFAULT_ICON;
@Expose
private List description;
@Expose
@@ -77,17 +86,52 @@ public Blueprint setDisplayName(String displayName) {
return this;
}
/**
- * @return the icon
+ * Returns the base Material for this blueprint's icon.
+ * Resolves plain names ("DIAMOND") and vanilla namespaced keys ("minecraft:diamond")
+ * via {@link Material#matchMaterial}. For custom item-model keys that are not
+ * valid vanilla materials (e.g. "myserver:island_tropical"), returns {@link Material#PAPER}
+ * as the base item — use {@link #getIconItemStack()} to get the full item with model data.
+ * @return the icon material, never null
*/
public @NonNull Material getIcon() {
- return icon;
+ return ItemParser.parseIconMaterial(icon);
+ }
+
+ /**
+ * Returns an {@link ItemStack} representing this blueprint's icon.
+ *
+ * - Plain material name (e.g. {@code "DIAMOND"}) → {@code new ItemStack(Material.DIAMOND)}
+ * - Vanilla namespaced material (e.g. {@code "minecraft:diamond"}) → same as above
+ * - Custom item-model key (e.g. {@code "myserver:island_tropical"}) → PAPER base item
+ * with the model key set via {@link ItemMeta#setItemModel}
+ *
+ * @return ItemStack for this blueprint's icon, never null
+ * @since 3.0.0
+ */
+ public @NonNull ItemStack getIconItemStack() {
+ return ItemParser.parseIconItemStack(icon);
}
+
/**
- * @param icon the icon to set
+ * Sets the icon from a Material (backward-compatible setter).
+ * @param icon the icon material to set; if null, defaults to {@link Material#PAPER}
* @return blueprint
*/
public Blueprint setIcon(Material icon) {
- this.icon = icon;
+ this.icon = icon != null ? icon.name() : DEFAULT_ICON;
+ return this;
+ }
+
+ /**
+ * Sets the icon from a string. Accepts plain material names (e.g. {@code "DIAMOND"}),
+ * vanilla namespaced materials (e.g. {@code "minecraft:diamond"}), and custom item-model
+ * keys (e.g. {@code "myserver:island_tropical"}).
+ * @param icon the icon string; if null, defaults to {@code "PAPER"}
+ * @return blueprint
+ * @since 3.0.0
+ */
+ public Blueprint setIcon(String icon) {
+ this.icon = icon != null ? icon : DEFAULT_ICON;
return this;
}
/**
diff --git a/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java b/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java
index 788e5916e..7f43094d0 100644
--- a/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java
+++ b/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java
@@ -7,11 +7,13 @@
import org.bukkit.Material;
import org.bukkit.World;
+import org.bukkit.inventory.ItemStack;
import com.google.gson.annotations.Expose;
import world.bentobox.bentobox.blueprints.Blueprint;
import world.bentobox.bentobox.database.objects.DataObject;
+import world.bentobox.bentobox.util.ItemParser;
/**
* Represents a bundle of three {@link Blueprint}s.
@@ -21,16 +23,20 @@
*/
public class BlueprintBundle implements DataObject {
+ private static final String DEFAULT_ICON = "PAPER";
+
/**
* The unique id of this bundle
*/
@Expose
private String uniqueId;
/**
- * Icon of the bundle
+ * Icon of the bundle. Supports plain material names (e.g. "DIAMOND"),
+ * vanilla namespaced materials (e.g. "minecraft:diamond"), and custom
+ * item model keys (e.g. "myserver:island_tropical").
*/
@Expose
- private Material icon = Material.PAPER;
+ private String icon = DEFAULT_ICON;
/**
* Name on the icon
*/
@@ -97,16 +103,49 @@ public void setUniqueId(String uniqueId) {
this.uniqueId = uniqueId;
}
/**
- * @return the icon
+ * Returns the base Material for this bundle's icon.
+ * Resolves plain names ("DIAMOND") and vanilla namespaced keys ("minecraft:diamond")
+ * via {@link Material#matchMaterial}. For custom item-model keys that are not
+ * valid vanilla materials (e.g. "myserver:island_tropical"), returns {@link Material#PAPER}
+ * as the base item — use {@link #getIconItemStack()} to get the full item with model data.
+ * @return the icon material, never null
*/
public Material getIcon() {
- return icon;
+ return ItemParser.parseIconMaterial(icon);
}
+
+ /**
+ * Returns an {@link ItemStack} representing this bundle's icon.
+ *
+ * - Plain material name (e.g. {@code "DIAMOND"}) → {@code new ItemStack(Material.DIAMOND)}
+ * - Vanilla namespaced material (e.g. {@code "minecraft:diamond"}) → same as above
+ * - Custom item-model key (e.g. {@code "myserver:island_tropical"}) → PAPER base item
+ * with the model key set via {@link ItemMeta#setItemModel}
+ *
+ * @return ItemStack for this bundle's icon, never null
+ * @since 3.0.0
+ */
+ public ItemStack getIconItemStack() {
+ return ItemParser.parseIconItemStack(icon);
+ }
+
/**
- * @param icon the icon to set
+ * Sets the icon from a Material (backward-compatible setter).
+ * @param icon the icon material to set; if null, defaults to {@link Material#PAPER}
*/
public void setIcon(Material icon) {
- this.icon = icon;
+ this.icon = icon != null ? icon.name() : DEFAULT_ICON;
+ }
+
+ /**
+ * Sets the icon from a string. Accepts plain material names (e.g. {@code "DIAMOND"}),
+ * vanilla namespaced materials (e.g. {@code "minecraft:diamond"}), and custom item-model
+ * keys (e.g. {@code "myserver:island_tropical"}).
+ * @param icon the icon string; if null, defaults to {@code "PAPER"}
+ * @since 3.0.0
+ */
+ public void setIcon(String icon) {
+ this.icon = icon != null ? icon : DEFAULT_ICON;
}
/**
* @return the displayName
diff --git a/src/main/java/world/bentobox/bentobox/database/objects/Island.java b/src/main/java/world/bentobox/bentobox/database/objects/Island.java
index b48d672ef..6b747ab66 100644
--- a/src/main/java/world/bentobox/bentobox/database/objects/Island.java
+++ b/src/main/java/world/bentobox/bentobox/database/objects/Island.java
@@ -1735,7 +1735,7 @@ public void clearChanged() {
* Calls to this method must be balanced with calls to {@link #endDeferSaves()}.
* Multiple callers may defer simultaneously (reference-counted).
*
- * @since 3.14.0
+ * @since 3.15.0
*/
public void beginDeferSaves() {
deferSaveCount++;
@@ -1745,7 +1745,7 @@ public void beginDeferSaves() {
* End deferring database saves. Decrements the defer counter; when it reaches
* zero the island is saved if it has been marked as changed.
*
- * @since 3.14.0
+ * @since 3.15.0
*/
public void endDeferSaves() {
if (deferSaveCount > 0) {
@@ -1758,7 +1758,7 @@ public void endDeferSaves() {
/**
* @return true if saves are currently being deferred
- * @since 3.14.0
+ * @since 3.15.0
*/
public boolean isDeferSaves() {
return deferSaveCount > 0;
diff --git a/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java b/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java
index 0cff041d7..538bea305 100644
--- a/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java
+++ b/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java
@@ -71,7 +71,7 @@ public boolean spawnMythicMob(MythicMobRecord mmr, Location spawnLocation) {
* @param spawnLocation location
* @param onSpawn callback invoked with the spawned Bukkit entity; may be {@code null}
* @return true if the mob type exists and a spawn was scheduled
- * @since 3.14.0
+ * @since 3.15.0
*/
public boolean spawnMythicMob(MythicMobRecord mmr, Location spawnLocation, Consumer onSpawn) {
return spawnMythicMob(mmr, spawnLocation, onSpawn, 40L);
diff --git a/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java b/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java
index b814405f0..9c7af0cbc 100644
--- a/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java
+++ b/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java
@@ -53,7 +53,7 @@ public IslandDeletionManager getIslandDeletionManager() {
/**
* @return the {@link ChunkPregenManager} created during registration.
- * @since 3.14.0
+ * @since 3.15.0
*/
public ChunkPregenManager getChunkPregenManager() {
return chunkPregenManager;
diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java
index 50382e323..3dda116f7 100644
--- a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java
+++ b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java
@@ -12,6 +12,7 @@
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerMoveEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.vehicle.VehicleMoveEvent;
import org.bukkit.util.Vector;
@@ -20,6 +21,7 @@
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.api.flags.FlagListener;
import world.bentobox.bentobox.api.user.User;
+import world.bentobox.bentobox.database.objects.Island;
import world.bentobox.bentobox.lists.Flags;
import world.bentobox.bentobox.util.Util;
@@ -37,6 +39,14 @@ public class LockAndBanListener extends FlagListener {
*/
private final Set notifiedPlayers = new HashSet<>();
+ /**
+ * Tracks ops who have already been notified that they are standing on an
+ * island flagged for deletion (awaiting region purge), to avoid spamming
+ * the notice on every move event. Cleared when the op leaves a deletable
+ * island.
+ */
+ private final Set deletableNotified = new HashSet<>();
+
/**
* Result of checking the island for locked state or player bans
*
@@ -119,6 +129,14 @@ public void onPlayerLogin(PlayerJoinEvent e) {
}
}
+ // Quit cleanup — prevent unbounded growth of notification tracking sets
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onPlayerQuit(PlayerQuitEvent e) {
+ UUID uuid = e.getPlayer().getUniqueId();
+ notifiedPlayers.remove(uuid);
+ deletableNotified.remove(uuid);
+ }
+
/**
* Check if a player is banned or the island is locked
* @param player - player
@@ -177,9 +195,35 @@ private CheckResult checkAndNotify(@NonNull Player player, Location loc)
User.getInstance(player).notify("protection.locked-island-bypass");
}
}
+ notifyIfDeletable(player, loc);
return result;
}
+ /**
+ * Notify ops that the island they just entered is flagged for deletion
+ * and awaiting the region purge. Regular players see nothing — this is
+ * an admin-only heads-up so server staff know the visible chunks will
+ * be reaped the next time housekeeping runs.
+ *
+ * Fires at most once per entry, using the same "move out to reset"
+ * pattern as the lock notification.
+ */
+ private void notifyIfDeletable(@NonNull Player player, Location loc) {
+ if (!player.isOp()) {
+ deletableNotified.remove(player.getUniqueId());
+ return;
+ }
+ boolean deletable = getIslands().getProtectedIslandAt(loc)
+ .map(Island::isDeletable).orElse(false);
+ if (deletable) {
+ if (deletableNotified.add(player.getUniqueId())) {
+ User.getInstance(player).notify("protection.deletable-island-admin");
+ }
+ } else {
+ deletableNotified.remove(player.getUniqueId());
+ }
+ }
+
/**
* Sends player home
* @param player - player
diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java
index 4cb3356f4..fb3cad9f2 100644
--- a/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java
+++ b/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java
@@ -8,6 +8,7 @@
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.World;
+import org.bukkit.entity.ExperienceOrb;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
@@ -185,6 +186,7 @@ public void onVisitorTargeting(EntityTargetLivingEntityEvent e)
if (!(e.getTarget() instanceof Player p) ||
!this.getIWM().inWorld(world) ||
e.getTarget().hasMetadata("NPC") ||
+ e.getEntity() instanceof ExperienceOrb ||
this.getIslands().userIsOnIsland(world, User.getInstance(e.getTarget())) ||
this.isPvpAllowed(p.getLocation()) ||
e.getReason() == EntityTargetEvent.TargetReason.TARGET_DIED ||
diff --git a/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java b/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java
index 08cd2b1d9..ebff0eec6 100644
--- a/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java
+++ b/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java
@@ -34,7 +34,7 @@
* round-robining between worlds to ensure fairness and prevent server overload.
*
* @author tastybento
- * @since 3.14.0
+ * @since 3.15.0
*/
public class ChunkPregenManager implements Listener {
diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java
new file mode 100644
index 000000000..2c7a9ff7a
--- /dev/null
+++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java
@@ -0,0 +1,371 @@
+package world.bentobox.bentobox.managers;
+
+import java.io.File;
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.scheduler.BukkitTask;
+
+import world.bentobox.bentobox.BentoBox;
+import world.bentobox.bentobox.api.addons.GameModeAddon;
+import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult;
+
+/**
+ * Periodic housekeeping: automatically runs the region-files purge against
+ * every gamemode overworld on a configurable schedule. Two independent cycles:
+ *
+ *
+ * - Age sweep — runs every {@code interval-days} days and reaps
+ * regions whose .mca files are older than {@code region-age-days}.
+ * - Deleted sweep — runs every {@code deleted-interval-hours}
+ * hours and reaps regions for any island already flagged as
+ * {@code deletable} (e.g. from {@code /is reset}), ignoring file age.
+ *
+ *
+ * Both cycles are gated on the single {@code housekeeping.enabled} flag
+ * (default OFF) and share an {@code inProgress} guard so they never overlap.
+ *
+ *
Last-run timestamps are persisted to
+ * {@code /database/housekeeping.yml} regardless of the
+ * configured database backend, so the schedule survives restarts.
+ *
+ * This manager is destructive by design: it deletes {@code .mca} region
+ * files from disk.
+ *
+ * @since 3.15.0
+ */
+public class HousekeepingManager {
+
+ private static final String LEGACY_LAST_RUN_KEY = "lastRunMillis";
+ private static final String LAST_AGE_RUN_KEY = "lastAgeRunMillis";
+ private static final String LAST_DELETED_RUN_KEY = "lastDeletedRunMillis";
+ private static final long CHECK_INTERVAL_TICKS = 20L * 60L * 60L; // 1 hour
+ private static final long STARTUP_DELAY_TICKS = 20L * 60L * 5L; // 5 minutes
+
+ private final BentoBox plugin;
+ private final File stateFile;
+ private volatile long lastAgeRunMillis;
+ private volatile long lastDeletedRunMillis;
+ private volatile boolean inProgress;
+ private BukkitTask scheduledTask;
+
+ public HousekeepingManager(BentoBox plugin) {
+ this.plugin = plugin;
+ this.stateFile = new File(new File(plugin.getDataFolder(), "database"), "housekeeping.yml");
+ loadState();
+ }
+
+ // ---------------------------------------------------------------
+ // Scheduling
+ // ---------------------------------------------------------------
+
+ /**
+ * Starts the periodic housekeeping check. Safe to call multiple times —
+ * the task is only scheduled once.
+ */
+ public synchronized void start() {
+ if (scheduledTask != null) {
+ return;
+ }
+ scheduledTask = Bukkit.getScheduler().runTaskTimer(plugin,
+ this::checkAndMaybeRun, STARTUP_DELAY_TICKS, CHECK_INTERVAL_TICKS);
+ plugin.log("Housekeeping scheduler started (deleted-sweep="
+ + plugin.getSettings().isHousekeepingDeletedEnabled()
+ + ", deleted-interval=" + plugin.getSettings().getHousekeepingDeletedIntervalHours() + "h"
+ + ", age-sweep=" + plugin.getSettings().isHousekeepingAgeEnabled()
+ + ", age-interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d"
+ + ", region-age=" + plugin.getSettings().getHousekeepingRegionAgeDays() + "d"
+ + ", last-age-run=" + formatTs(lastAgeRunMillis)
+ + ", last-deleted-run=" + formatTs(lastDeletedRunMillis) + ")");
+ }
+
+ private static String formatTs(long millis) {
+ return millis == 0 ? "never" : Instant.ofEpochMilli(millis).toString();
+ }
+
+ /**
+ * Stops the periodic housekeeping check. Does not clear the last-run
+ * timestamps on disk.
+ */
+ public synchronized void stop() {
+ if (scheduledTask != null) {
+ scheduledTask.cancel();
+ scheduledTask = null;
+ }
+ }
+
+ /**
+ * @return {@code true} if a housekeeping run is currently in progress.
+ */
+ public boolean isInProgress() {
+ return inProgress;
+ }
+
+ /**
+ * @return wall-clock timestamp (millis) of the last successful age sweep,
+ * or {@code 0} if it has never run.
+ */
+ public long getLastAgeRunMillis() {
+ return lastAgeRunMillis;
+ }
+
+ /**
+ * @return wall-clock timestamp (millis) of the last successful deleted
+ * sweep, or {@code 0} if it has never run.
+ */
+ public long getLastDeletedRunMillis() {
+ return lastDeletedRunMillis;
+ }
+
+ private void checkAndMaybeRun() {
+ if (inProgress) {
+ return;
+ }
+ long now = System.currentTimeMillis();
+ boolean ageDue = plugin.getSettings().isHousekeepingAgeEnabled() && isAgeCycleDue(now);
+ boolean deletedDue = plugin.getSettings().isHousekeepingDeletedEnabled() && isDeletedCycleDue(now);
+ if (!ageDue && !deletedDue) {
+ return;
+ }
+ runNow(ageDue, deletedDue);
+ }
+
+ private boolean isAgeCycleDue(long now) {
+ int intervalDays = plugin.getSettings().getHousekeepingIntervalDays();
+ if (intervalDays <= 0) {
+ return false;
+ }
+ long intervalMillis = TimeUnit.DAYS.toMillis(intervalDays);
+ return lastAgeRunMillis == 0 || (now - lastAgeRunMillis) >= intervalMillis;
+ }
+
+ private boolean isDeletedCycleDue(long now) {
+ int intervalHours = plugin.getSettings().getHousekeepingDeletedIntervalHours();
+ if (intervalHours <= 0) {
+ return false;
+ }
+ long intervalMillis = TimeUnit.HOURS.toMillis(intervalHours);
+ return lastDeletedRunMillis == 0 || (now - lastDeletedRunMillis) >= intervalMillis;
+ }
+
+ /**
+ * Triggers an immediate housekeeping cycle for both sweeps (respecting
+ * the enabled flag but ignoring the interval timers). Runs asynchronously.
+ */
+ public synchronized void runNow() {
+ runNow(true, true);
+ }
+
+ private synchronized void runNow(boolean runAge, boolean runDeleted) {
+ if (inProgress) {
+ plugin.log("Housekeeping: run requested but already in progress, ignoring");
+ return;
+ }
+ if (!runAge && !runDeleted) {
+ return;
+ }
+ inProgress = true;
+ Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
+ try {
+ // Save worlds once per cycle — both sweeps see a consistent
+ // on-disk snapshot.
+ if (!saveAllWorlds()) {
+ return;
+ }
+ if (runAge) {
+ executeAgeCycle();
+ }
+ if (runDeleted) {
+ executeDeletedCycle();
+ }
+ } catch (Exception e) {
+ plugin.logError("Housekeeping: cycle failed: " + e.getMessage());
+ plugin.logStacktrace(e);
+ } finally {
+ inProgress = false;
+ }
+ });
+ }
+
+ // ---------------------------------------------------------------
+ // Cycle execution
+ // ---------------------------------------------------------------
+
+ private boolean saveAllWorlds() {
+ plugin.log("Housekeeping: saving all worlds before purge...");
+ CompletableFuture saved = new CompletableFuture<>();
+ Bukkit.getScheduler().runTask(plugin, () -> {
+ try {
+ Bukkit.getWorlds().forEach(World::save);
+ saved.complete(null);
+ } catch (Exception e) {
+ saved.completeExceptionally(e);
+ }
+ });
+ try {
+ saved.get(2, TimeUnit.MINUTES);
+ plugin.log("Housekeeping: world save complete");
+ return true;
+ } catch (TimeoutException e) {
+ plugin.logError("Housekeeping: world save timed out after 2 minutes, aborting cycle");
+ return false;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ plugin.logError("Housekeeping: world save interrupted: " + e.getMessage());
+ return false;
+ } catch (ExecutionException e) {
+ plugin.logError("Housekeeping: world save failed: " + e.getCause().getMessage());
+ return false;
+ }
+ }
+
+ private void executeAgeCycle() {
+ long startMillis = System.currentTimeMillis();
+ int ageDays = plugin.getSettings().getHousekeepingRegionAgeDays();
+ if (ageDays <= 0) {
+ plugin.logError("Housekeeping: region-age-days must be >= 1, skipping age sweep");
+ return;
+ }
+ List gameModes = plugin.getAddonsManager().getGameModeAddons();
+ plugin.log("Housekeeping age sweep: starting across " + gameModes.size()
+ + " gamemode(s), region-age=" + ageDays + "d");
+
+ int totalWorlds = 0;
+ int totalRegionsPurged = 0;
+ for (GameModeAddon gm : gameModes) {
+ World overworld = gm.getOverWorld();
+ if (overworld == null) {
+ continue;
+ }
+ totalWorlds++;
+ plugin.log("Housekeeping age sweep: scanning '" + gm.getDescription().getName()
+ + "' world '" + overworld.getName() + "'");
+ PurgeScanResult scan = plugin.getPurgeRegionsService().scan(overworld, ageDays);
+ totalRegionsPurged += runDeleteIfNonEmpty(scan, overworld, "age sweep");
+ }
+
+ Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis);
+ plugin.log("Housekeeping age sweep: complete — " + totalWorlds + " world(s) processed, "
+ + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s");
+ lastAgeRunMillis = System.currentTimeMillis();
+ saveState();
+ }
+
+ private void executeDeletedCycle() {
+ long startMillis = System.currentTimeMillis();
+ List gameModes = plugin.getAddonsManager().getGameModeAddons();
+ plugin.log("Housekeeping deleted sweep: starting across " + gameModes.size() + " gamemode(s)");
+
+ int totalWorlds = 0;
+ int totalRegionsPurged = 0;
+ for (GameModeAddon gm : gameModes) {
+ World overworld = gm.getOverWorld();
+ if (overworld == null) {
+ continue;
+ }
+ totalWorlds++;
+ plugin.log("Housekeeping deleted sweep: scanning '" + gm.getDescription().getName()
+ + "' world '" + overworld.getName() + "'");
+ PurgeScanResult scan = plugin.getPurgeRegionsService().scanDeleted(overworld);
+ // Evict in-memory chunks on the main thread before the async delete,
+ // so Paper's autosave can't re-flush them over the deleted region files.
+ if (!scan.isEmpty()) {
+ evictChunksOnMainThread(scan);
+ }
+ totalRegionsPurged += runDeleteIfNonEmpty(scan, overworld, "deleted sweep");
+ }
+
+ Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis);
+ plugin.log("Housekeeping deleted sweep: complete — " + totalWorlds + " world(s) processed, "
+ + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s");
+ lastDeletedRunMillis = System.currentTimeMillis();
+ saveState();
+ }
+
+ private void evictChunksOnMainThread(PurgeScanResult scan) {
+ CompletableFuture done = new CompletableFuture<>();
+ Bukkit.getScheduler().runTask(plugin, () -> {
+ try {
+ plugin.getPurgeRegionsService().evictChunks(scan);
+ done.complete(null);
+ } catch (Exception e) {
+ done.completeExceptionally(e);
+ }
+ });
+ try {
+ done.join();
+ } catch (Exception e) {
+ plugin.logError("Housekeeping: chunk eviction failed: " + e.getMessage());
+ }
+ }
+
+ private int runDeleteIfNonEmpty(PurgeScanResult scan, World overworld, String label) {
+ if (scan.isEmpty()) {
+ plugin.log("Housekeeping " + label + ": nothing to purge in " + overworld.getName());
+ return 0;
+ }
+ plugin.log("Housekeeping " + label + ": " + scan.deletableRegions().size() + " region(s) and "
+ + scan.uniqueIslandCount() + " island(s) eligible in " + overworld.getName());
+ boolean ok = plugin.getPurgeRegionsService().delete(scan);
+ if (!ok) {
+ plugin.logError("Housekeeping " + label + ": purge of " + overworld.getName()
+ + " completed with errors");
+ return 0;
+ }
+ return scan.deletableRegions().size();
+ }
+
+ // ---------------------------------------------------------------
+ // Persistence
+ // ---------------------------------------------------------------
+
+ private void loadState() {
+ if (!stateFile.exists()) {
+ lastAgeRunMillis = 0L;
+ lastDeletedRunMillis = 0L;
+ return;
+ }
+ try {
+ YamlConfiguration yaml = YamlConfiguration.loadConfiguration(stateFile);
+ // Migrate legacy single-cycle key: if the new key is absent but
+ // the old one is present, adopt it as the age-cycle timestamp.
+ if (yaml.contains(LAST_AGE_RUN_KEY)) {
+ lastAgeRunMillis = yaml.getLong(LAST_AGE_RUN_KEY, 0L);
+ } else {
+ lastAgeRunMillis = yaml.getLong(LEGACY_LAST_RUN_KEY, 0L);
+ }
+ lastDeletedRunMillis = yaml.getLong(LAST_DELETED_RUN_KEY, 0L);
+ } catch (Exception e) {
+ plugin.logError("Housekeeping: could not read " + stateFile.getAbsolutePath()
+ + ": " + e.getMessage());
+ lastAgeRunMillis = 0L;
+ lastDeletedRunMillis = 0L;
+ }
+ }
+
+ private void saveState() {
+ try {
+ File parent = stateFile.getParentFile();
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
+ plugin.logError("Housekeeping: could not create " + parent.getAbsolutePath());
+ return;
+ }
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set(LAST_AGE_RUN_KEY, lastAgeRunMillis);
+ yaml.set(LAST_DELETED_RUN_KEY, lastDeletedRunMillis);
+ yaml.save(stateFile);
+ } catch (IOException e) {
+ plugin.logError("Housekeeping: could not write " + stateFile.getAbsolutePath()
+ + ": " + e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java
index aa5c74497..096e1d0da 100644
--- a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java
+++ b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java
@@ -305,26 +305,47 @@ public void deleteIsland(@NonNull Island island, boolean removeBlocks, @Nullable
if (removeBlocks) {
// Remove players from island
removePlayersFromIsland(island);
- // Mark island as deletable
+ // Mark island as deletable - physical cleanup is handled later by
+ // the region-file purge (see PurgeRegionsService / HousekeepingManager).
island.setDeletable(true);
- if (!plugin.getSettings().isKeepPreviousIslandOnReset()) {
- // Remove island from the cache
- islandCache.deleteIslandFromCache(island);
- // Remove blocks from world
- IslandDeletion id = new IslandDeletion(island);
- plugin.getIslandDeletionManager().getIslandChunkDeletionManager().add(id);
- // Tell other servers
- MultiLib.notify("bentobox-deleteIsland", getGson().toJson(id));
- // Delete the island from the database
- handler.deleteObject(island);
- } else {
- handler.saveObject(island);
- // Fire the deletion event immediately
- IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build();
- }
+ handler.saveObject(island);
+ // Fire the deletion event immediately so listeners (hooks, maps, etc.)
+ // can update now that the island is orphaned.
+ IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build();
}
}
+ /**
+ * Hard-deletes an island: fires the pre-delete event, kicks members,
+ * nulls the owner, evicts the island from the cache, and removes the
+ * DB row. Does not touch world chunks — the caller is
+ * responsible for any physical cleanup (e.g. kicking off
+ * {@link world.bentobox.bentobox.util.DeleteIslandChunks}
+ * to regenerate via the addon's own {@code ChunkGenerator}).
+ *
+ * Used by {@code AdminDeleteCommand} for void/simple-generator
+ * gamemodes where chunks are cheap to repaint and the island should
+ * be fully gone from the database immediately. For
+ * new-chunk-generation gamemodes, use
+ * {@link #deleteIsland(Island, boolean, UUID)} which soft-deletes
+ * and leaves the region-file purge to reap the chunks and row later.
+ *
+ * @param island the island to hard-delete, not null
+ * @since 3.15.0
+ */
+ public void hardDeleteIsland(@NonNull Island island) {
+ IslandBaseEvent event = IslandEvent.builder().island(island).reason(Reason.DELETE).build();
+ if (event.getNewEvent().map(IslandBaseEvent::isCancelled).orElse(event.isCancelled())) {
+ return;
+ }
+ removePlayersFromIsland(island);
+ island.setOwner(null);
+ island.setFlag(Flags.LOCK, RanksManager.VISITOR_RANK);
+ islandCache.deleteIslandFromCache(island);
+ handler.deleteObject(island);
+ IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build();
+ }
+
/**
* Deletes an island by ID. If the id doesn't exist it will do nothing.
* @param uniqueId island ID
diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java
new file mode 100644
index 000000000..eba2c72b0
--- /dev/null
+++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java
@@ -0,0 +1,1076 @@
+package world.bentobox.bentobox.managers;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+
+import world.bentobox.bentobox.BentoBox;
+import world.bentobox.bentobox.api.events.island.IslandEvent;
+import world.bentobox.bentobox.api.events.island.IslandEvent.Reason;
+import world.bentobox.bentobox.database.objects.Island;
+import world.bentobox.bentobox.managers.island.IslandGrid;
+import world.bentobox.bentobox.managers.island.IslandGrid.IslandData;
+import world.bentobox.bentobox.util.Pair;
+import world.bentobox.level.Level;
+
+/**
+ * Core implementation of the "purge region files" operation shared by the
+ * {@code /bbox admin purge regions} command and the periodic
+ * {@link HousekeepingManager} auto-purge task.
+ *
+ *
Threading requirements are method-specific. Methods that scan, read, or
+ * delete region data perform blocking disk I/O and should be called from an
+ * async thread. Methods that interact with Bukkit world or chunk APIs must be
+ * called from the main server thread.
+ *
+ *
The service does not interact with players or issue confirmations — the
+ * caller is responsible for any user-facing UX.
+ *
+ *
Shared code path for scanning, filtering, and deleting region files
+ * across the overworld + optional nether/end dimensions. Used by
+ * {@code /bbox admin purge} (the top-level command), {@code purge deleted},
+ * and the periodic {@link HousekeepingManager}.
+ *
+ * @since 3.15.0
+ */
+public class PurgeRegionsService {
+
+ private static final String REGION = "region";
+ private static final String ENTITIES = "entities";
+ private static final String POI = "poi";
+ private static final String DIM_1 = "DIM-1";
+ private static final String DIM1 = "DIM1";
+ private static final String PLAYERS = "players";
+ private static final String PLAYERDATA = "playerdata";
+ private static final String EXISTS_PREFIX = " (exists=";
+ private static final String PURGE_FOUND = "Purge found ";
+
+ private final BentoBox plugin;
+
+ /**
+ * Island IDs whose region files were deleted by a deleted-sweep
+ * ({@code days == 0}) but whose DB rows are deferred until plugin
+ * shutdown. Paper's internal chunk cache may still serve stale block
+ * data even after the {@code .mca} file is gone from disk; only a
+ * clean shutdown guarantees the cache is cleared.
+ */
+ private final Set pendingDeletions = Collections.newSetFromMap(new ConcurrentHashMap<>());
+
+ public PurgeRegionsService(BentoBox plugin) {
+ this.plugin = plugin;
+ }
+
+ /**
+ * Result of a purge scan — a map of deletable region coordinates to the
+ * set of island IDs in each region, plus filtering statistics.
+ *
+ * @param world the world scanned
+ * @param days the age cutoff (days) used
+ * @param deletableRegions regions considered deletable keyed by region
+ * coordinate {@code (regionX, regionZ)}
+ * @param isNether whether the nether dimension was included
+ * @param isEnd whether the end dimension was included
+ * @param stats filter statistics for logging/reporting
+ */
+ public record PurgeScanResult(
+ World world,
+ int days,
+ Map, Set> deletableRegions,
+ boolean isNether,
+ boolean isEnd,
+ FilterStats stats) {
+ public boolean isEmpty() {
+ return deletableRegions.isEmpty();
+ }
+
+ public int uniqueIslandCount() {
+ Set ids = new HashSet<>();
+ deletableRegions.values().forEach(ids::addAll);
+ return ids.size();
+ }
+ }
+
+ /** Tracks island-level and region-level block counts during filtering. */
+ public record FilterStats(int islandsOverLevel, int islandsPurgeProtected,
+ int regionsBlockedByLevel, int regionsBlockedByProtection) {}
+
+ /** Groups the three folder types (region, entities, poi) for one world dimension. */
+ private record DimFolders(File region, File entities, File poi) {}
+
+ // ---------------------------------------------------------------
+ // Public API
+ // ---------------------------------------------------------------
+
+ /**
+ * Scans the given world for islands flagged as {@code deletable} and
+ * returns the set of region files that can be reaped immediately,
+ * ignoring region-file age.
+ *
+ * Unlike {@link #scan(World, int)} this does not look at region
+ * timestamps at all: the {@code deletable} flag is the sole source of
+ * truth. A region is only returned if every island that
+ * overlaps it is deletable — a lone active neighbour blocks the whole
+ * region.
+ *
+ *
The returned {@link PurgeScanResult} uses {@code days = 0} as a
+ * sentinel meaning "no age filter" so that {@link #delete(PurgeScanResult)}
+ * and {@code deleteRegionFiles} skip their freshness re-check.
+ *
+ *
Runs synchronously on the calling thread and performs disk I/O.
+ * Callers must invoke this from an async task.
+ *
+ * @param world the gamemode overworld to scan
+ * @return scan result, never {@code null}
+ * @since 3.15.0
+ */
+ public PurgeScanResult scanDeleted(World world) {
+ boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world);
+ boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world);
+
+ IslandGrid islandGrid = plugin.getIslands().getIslandCache().getIslandGrid(world);
+ if (islandGrid == null) {
+ return new PurgeScanResult(world, 0, new HashMap<>(), isNether, isEnd,
+ new FilterStats(0, 0, 0, 0));
+ }
+
+ // Collect candidate region coords from every deletable island's
+ // protection bounds. A single island may straddle multiple regions.
+ Set> candidateRegions = new HashSet<>();
+ for (Island island : plugin.getIslands().getIslandCache().getIslands(world)) {
+ if (!island.isDeletable()) continue;
+ int minRX = island.getMinProtectedX() >> 9;
+ int maxRX = (island.getMaxProtectedX() - 1) >> 9;
+ int minRZ = island.getMinProtectedZ() >> 9;
+ int maxRZ = (island.getMaxProtectedZ() - 1) >> 9;
+ for (int rx = minRX; rx <= maxRX; rx++) {
+ for (int rz = minRZ; rz <= maxRZ; rz++) {
+ candidateRegions.add(new Pair<>(rx, rz));
+ }
+ }
+ }
+ plugin.log("Purge deleted-sweep: " + candidateRegions.size()
+ + " candidate region(s) from deletable islands in world " + world.getName());
+
+ Map, Set> deletableRegions =
+ mapIslandsToRegions(new ArrayList<>(candidateRegions), islandGrid);
+ FilterStats stats = filterForDeletedSweep(deletableRegions);
+ logFilterStats(stats);
+ return new PurgeScanResult(world, 0, deletableRegions, isNether, isEnd, stats);
+ }
+
+ /**
+ * Scans the given world (and its nether/end if the gamemode owns them)
+ * for region files older than {@code days} and returns the set of
+ * regions whose overlapping islands may all be safely deleted.
+ *
+ * Runs synchronously on the calling thread and performs disk I/O.
+ * Callers must invoke this from an async task.
+ *
+ * @param world the gamemode overworld to scan
+ * @param days minimum age in days for region files to be candidates
+ * @return scan result, never {@code null}
+ */
+ public PurgeScanResult scan(World world, int days) {
+ boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world);
+ boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world);
+
+ IslandGrid islandGrid = plugin.getIslands().getIslandCache().getIslandGrid(world);
+ if (islandGrid == null) {
+ return new PurgeScanResult(world, days, new HashMap<>(), isNether, isEnd,
+ new FilterStats(0, 0, 0, 0));
+ }
+
+ List> oldRegions = findOldRegions(world, days, isNether, isEnd);
+ Map, Set> deletableRegions = mapIslandsToRegions(oldRegions, islandGrid);
+ FilterStats stats = filterNonDeletableRegions(deletableRegions, days);
+ logFilterStats(stats);
+ return new PurgeScanResult(world, days, deletableRegions, isNether, isEnd, stats);
+ }
+
+ /**
+ * Deletes the region files identified by a prior {@link #scan(World, int)}
+ * along with any island database entries, island cache entries, and
+ * orphaned player data files that correspond to them.
+ *
+ * Runs synchronously on the calling thread and performs disk I/O.
+ * Callers must invoke this from an async task. Callers are also
+ * responsible for flushing in-memory chunk state by calling
+ * {@code World.save()} on the main thread before dispatching
+ * this method — {@code World.save()} is not safe to invoke from an
+ * async thread.
+ *
+ * @param scan the prior scan result
+ * @return {@code true} if all file deletions succeeded; {@code false} if
+ * any file was unexpectedly fresh or could not be deleted
+ */
+ public boolean delete(PurgeScanResult scan) {
+ if (scan.deletableRegions().isEmpty()) {
+ return false;
+ }
+ plugin.log("Now deleting region files for world " + scan.world().getName());
+ boolean ok = deleteRegionFiles(scan);
+ if (!ok) {
+ plugin.logError("Not all region files could be deleted");
+ }
+
+ // Collect unique island IDs across all reaped regions. An island
+ // that spans multiple regions will only be considered once here.
+ Set affectedIds = new HashSet<>();
+ for (Set islandIDs : scan.deletableRegions().values()) {
+ affectedIds.addAll(islandIDs);
+ }
+
+ int islandsRemoved = 0;
+ int islandsDeferred = 0;
+ for (String islandID : affectedIds) {
+ Optional opt = plugin.getIslands().getIslandById(islandID);
+ if (opt.isEmpty()) {
+ continue;
+ }
+ Island island = opt.get();
+
+ if (scan.days() == 0) {
+ // Deleted sweep: region files are gone from disk but Paper
+ // may still serve stale chunk data from its internal memory
+ // cache. Defer DB row removal to plugin shutdown when the
+ // cache is guaranteed clear.
+ pendingDeletions.add(islandID);
+ islandsDeferred++;
+ plugin.log("Island ID " + islandID
+ + " region files deleted \u2014 DB row deferred to shutdown");
+ } else {
+ // Age sweep: regions are old enough that Paper won't have
+ // them cached. Gate on residual-region completeness check
+ // to avoid orphaning blocks when the strict filter blocked
+ // some of the island's regions.
+ List> residual = findResidualRegions(island, scan.world());
+ if (!residual.isEmpty()) {
+ islandsDeferred++;
+ plugin.log("Island ID " + islandID + " has " + residual.size()
+ + " residual region(s) still on disk: " + residual
+ + " \u2014 DB row retained for a future purge");
+ continue;
+ }
+ deletePlayerFromWorldFolder(scan.world(), islandID, scan.deletableRegions(), scan.days());
+ // For the age sweep, DELETED may never have fired (the island was
+ // pruned by age, not by an explicit /is reset). Fire it now so addons
+ // (Level, OneBlock, etc.) can clean up their per-island data.
+ if (!island.isDeletable()) {
+ IslandEvent.builder()
+ .island(island)
+ .reason(Reason.DELETED)
+ .build();
+ }
+ plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID);
+ if (plugin.getIslands().deleteIslandId(islandID)) {
+ plugin.log("Island ID " + islandID + " deleted from cache and database");
+ islandsRemoved++;
+ // Fire PURGED so addons that track physical storage state know
+ // the region files and DB row are both gone.
+ IslandEvent.builder()
+ .island(island)
+ .reason(Reason.PURGED)
+ .build();
+ }
+ }
+ }
+ plugin.log("Purge complete for world " + scan.world().getName()
+ + ": " + scan.deletableRegions().size() + " region(s), "
+ + islandsRemoved + " island(s) removed, "
+ + islandsDeferred + " island(s) deferred"
+ + (scan.days() == 0 ? " (to shutdown)" : " (partial cleanup)"));
+ return ok;
+ }
+
+ /**
+ * Processes all island IDs whose region files were deleted by a prior
+ * deleted-sweep but whose DB rows were deferred because Paper's internal
+ * memory cache may still serve stale chunk data. Call this on plugin
+ * shutdown when the cache is guaranteed to be cleared.
+ *
+ * If the server crashes before a clean shutdown, the pending set is
+ * lost — the islands stay {@code deletable=true} in the database and the
+ * next purge cycle will pick them up again (safe failure mode).
+ */
+ public void flushPendingDeletions() {
+ if (pendingDeletions.isEmpty()) {
+ return;
+ }
+ plugin.log("Flushing " + pendingDeletions.size() + " deferred island deletion(s)...");
+ int count = 0;
+ for (String islandID : pendingDeletions) {
+ // Capture island before cache eviction so we can build the event.
+ // DELETED was already fired at soft-delete time; PURGED signals that
+ // the DB row is now physically removed.
+ Optional opt = plugin.getIslands().getIslandById(islandID);
+ plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID);
+ if (plugin.getIslands().deleteIslandId(islandID)) {
+ count++;
+ opt.ifPresent(island -> IslandEvent.builder()
+ .island(island)
+ .reason(Reason.PURGED)
+ .build());
+ }
+ }
+ pendingDeletions.clear();
+ plugin.log("Flushed " + count + " island(s) from cache and database");
+ }
+
+ /**
+ * Returns an unmodifiable view of the island IDs currently pending
+ * DB deletion (deferred to shutdown). Primarily for testing.
+ */
+ public Set getPendingDeletions() {
+ return Collections.unmodifiableSet(pendingDeletions);
+ }
+
+ /**
+ * Returns the region coordinates for every {@code r.X.Z.mca} file still
+ * present on disk that overlaps the island's protection box, across the
+ * overworld and (if the gamemode owns them) the nether and end
+ * dimensions. An empty list means every region file the island touches
+ * is gone from disk and the island DB row can safely be reaped.
+ *
+ * The protection box is converted to region coordinates with
+ * {@code blockX >> 9} (each .mca covers a 512×512 block square). The
+ * maximum bound is inclusive at the block level so we shift
+ * {@code max - 1} to avoid picking up a neighbour region when the
+ * protection ends exactly on a region boundary.
+ */
+ private List> findResidualRegions(Island island, World overworld) {
+ int rxMin = island.getMinProtectedX() >> 9;
+ int rxMax = (island.getMaxProtectedX() - 1) >> 9;
+ int rzMin = island.getMinProtectedZ() >> 9;
+ int rzMax = (island.getMaxProtectedZ() - 1) >> 9;
+
+ File base = overworld.getWorldFolder();
+ File overworldRegionDir = new File(base, REGION);
+
+ World netherWorld = plugin.getIWM().getNetherWorld(overworld);
+ File netherRegionDir = plugin.getIWM().isNetherIslands(overworld)
+ ? new File(netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base), REGION)
+ : null;
+
+ World endWorld = plugin.getIWM().getEndWorld(overworld);
+ File endRegionDir = plugin.getIWM().isEndIslands(overworld)
+ ? new File(endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base), REGION)
+ : null;
+
+ List> residual = new ArrayList<>();
+ for (int rx = rxMin; rx <= rxMax; rx++) {
+ for (int rz = rzMin; rz <= rzMax; rz++) {
+ String name = "r." + rx + "." + rz + ".mca";
+ if (regionFileExists(overworldRegionDir, name)
+ || regionFileExists(netherRegionDir, name)
+ || regionFileExists(endRegionDir, name)) {
+ residual.add(new Pair<>(rx, rz));
+ }
+ }
+ }
+ return residual;
+ }
+
+ private static boolean regionFileExists(File dir, String name) {
+ return dir != null && new File(dir, name).exists();
+ }
+
+ // ---------------------------------------------------------------
+ // Chunk eviction
+ // ---------------------------------------------------------------
+
+ /**
+ * Unloads every loaded chunk that falls inside any region in
+ * {@code scan.deletableRegions()} with {@code save = false}, so the
+ * in-memory chunk copy is thrown away rather than flushed back over the
+ * region files we are about to delete.
+ *
+ * Each {@code r.X.Z.mca} covers a 32×32 chunk square. For every target
+ * region this iterates {@code (rX*32 .. rX*32+31, rZ*32 .. rZ*32+31)} and
+ * unloads any chunk currently loaded. Chunks that cannot be unloaded
+ * (e.g. a player is inside, or the chunk is force-loaded) are silently
+ * skipped — reaping a chunk out from under a present player would be
+ * worse than waiting for the next sweep.
+ *
+ *
The deleted-sweep callers (manual command + housekeeping) must
+ * invoke this on the main thread before dispatching the async
+ * {@link #delete(PurgeScanResult)}; otherwise Paper's autosave or shutdown
+ * will rewrite the region file with the stale in-memory chunks immediately
+ * after we delete it on disk.
+ *
+ *
Nether and end dimensions are evicted only when the gamemode owns
+ * them, mirroring the dimension gating in {@link #deleteRegionFiles}.
+ *
+ * @param scan a prior scan result whose regions should be evicted
+ */
+ public void evictChunks(PurgeScanResult scan) {
+ if (scan.deletableRegions().isEmpty()) {
+ return;
+ }
+ World overworld = scan.world();
+ World netherWorld = scan.isNether() ? plugin.getIWM().getNetherWorld(overworld) : null;
+ World endWorld = scan.isEnd() ? plugin.getIWM().getEndWorld(overworld) : null;
+
+ int evicted = 0;
+ for (Pair coords : scan.deletableRegions().keySet()) {
+ int baseCx = coords.x() << 5; // rX * 32
+ int baseCz = coords.z() << 5;
+ evicted += evictRegion(overworld, baseCx, baseCz);
+ if (netherWorld != null) {
+ evicted += evictRegion(netherWorld, baseCx, baseCz);
+ }
+ if (endWorld != null) {
+ evicted += evictRegion(endWorld, baseCx, baseCz);
+ }
+ }
+ plugin.log("Purge deleted: evicted " + evicted + " loaded chunk(s) from "
+ + scan.deletableRegions().size() + " target region(s)");
+ }
+
+ private int evictRegion(World world, int baseCx, int baseCz) {
+ int count = 0;
+ for (int dx = 0; dx < 32; dx++) {
+ for (int dz = 0; dz < 32; dz++) {
+ int cx = baseCx + dx;
+ int cz = baseCz + dz;
+ if (world.isChunkLoaded(cx, cz) && world.unloadChunk(cx, cz, false)) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ // ---------------------------------------------------------------
+ // Debug / testing: artificially age region files
+ // ---------------------------------------------------------------
+
+ /**
+ * Debug/testing utility. Rewrites the per-chunk timestamp table of every
+ * {@code .mca} region file in the given world's overworld (and nether/end
+ * if the gamemode owns those dimensions) so that every chunk entry looks
+ * like it was last written {@code days} days ago.
+ *
+ * The purge scanner reads per-chunk timestamps from the second 4KB
+ * block of each region file's header (not file mtime), so {@code touch}
+ * cannot fake ageing. This rewrites that 4KB timestamp table in place,
+ * setting all 1024 slots to {@code now - days*86400} seconds. File mtime
+ * is not modified.
+ *
+ *
Runs synchronously and performs disk I/O. Callers must invoke
+ * this from an async task, and should call {@code World.save()} on the
+ * main thread first to flush in-memory chunk state.
+ *
+ * @param world the gamemode overworld whose regions should be aged
+ * @param days how many days in the past to pretend the regions were
+ * last written
+ * @return number of {@code .mca} files successfully rewritten across
+ * all dimensions
+ */
+ public int ageRegions(World world, int days) {
+ boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world);
+ boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world);
+
+ File worldDir = world.getWorldFolder();
+ File overworldRegion = new File(worldDir, REGION);
+
+ World netherWorld = plugin.getIWM().getNetherWorld(world);
+ File netherRegion = new File(
+ netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir), REGION);
+
+ World endWorld = plugin.getIWM().getEndWorld(world);
+ File endRegion = new File(
+ endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir), REGION);
+
+ long targetSeconds = (System.currentTimeMillis() / 1000L) - (days * 86400L);
+ int total = 0;
+ total += ageRegionsInFolder(overworldRegion, "overworld", targetSeconds);
+ if (isNether) {
+ total += ageRegionsInFolder(netherRegion, "nether", targetSeconds);
+ }
+ if (isEnd) {
+ total += ageRegionsInFolder(endRegion, "end", targetSeconds);
+ }
+ return total;
+ }
+
+ private int ageRegionsInFolder(File folder, String dimension, long targetSeconds) {
+ if (!folder.isDirectory()) {
+ plugin.log("Age-regions: " + dimension + " folder does not exist, skipping: "
+ + folder.getAbsolutePath());
+ return 0;
+ }
+ File[] files = folder.listFiles((dir, name) -> name.endsWith(".mca"));
+ if (files == null || files.length == 0) {
+ plugin.log("Age-regions: no .mca files in " + dimension + " folder " + folder.getAbsolutePath());
+ return 0;
+ }
+ int count = 0;
+ for (File file : files) {
+ if (writeTimestampTable(file, targetSeconds)) {
+ count++;
+ }
+ }
+ plugin.log("Age-regions: rewrote " + count + "/" + files.length + " " + dimension + " region file(s)");
+ return count;
+ }
+
+ /**
+ * Overwrites the 4KB timestamp table (bytes 4096..8191) of a Minecraft
+ * {@code .mca} file with a single repeating big-endian int timestamp.
+ *
+ * @param regionFile the file to rewrite
+ * @param targetSeconds the Unix timestamp (seconds) to write into every slot
+ * @return {@code true} if the table was rewritten
+ */
+ private boolean writeTimestampTable(File regionFile, long targetSeconds) {
+ if (!regionFile.exists() || regionFile.length() < 8192) {
+ plugin.log("Age-regions: skipping " + regionFile.getName()
+ + " (missing or smaller than 8192 bytes)");
+ return false;
+ }
+ byte[] table = new byte[4096];
+ int ts = (int) targetSeconds;
+ for (int i = 0; i < 1024; i++) {
+ int offset = i * 4;
+ table[offset] = (byte) (ts >> 24);
+ table[offset + 1] = (byte) (ts >> 16);
+ table[offset + 2] = (byte) (ts >> 8);
+ table[offset + 3] = (byte) ts;
+ }
+ try (RandomAccessFile raf = new RandomAccessFile(regionFile, "rw")) {
+ raf.seek(4096);
+ raf.write(table);
+ return true;
+ } catch (IOException e) {
+ plugin.logError("Age-regions: failed to rewrite timestamp table of "
+ + regionFile.getAbsolutePath() + ": " + e.getMessage());
+ return false;
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Filtering
+ // ---------------------------------------------------------------
+
+ /**
+ * Removes regions whose island-set contains at least one island that
+ * cannot be deleted, returning blocking statistics.
+ */
+ private FilterStats filterNonDeletableRegions(
+ Map, Set> deletableRegions, int days) {
+ int islandsOverLevel = 0;
+ int islandsPurgeProtected = 0;
+ int regionsBlockedByLevel = 0;
+ int regionsBlockedByProtection = 0;
+
+ var iter = deletableRegions.entrySet().iterator();
+ while (iter.hasNext()) {
+ var entry = iter.next();
+ int[] regionCounts = evaluateRegionIslands(entry.getValue(), days);
+ if (regionCounts[0] > 0) { // shouldRemove
+ iter.remove();
+ islandsOverLevel += regionCounts[1];
+ islandsPurgeProtected += regionCounts[2];
+ if (regionCounts[1] > 0) regionsBlockedByLevel++;
+ if (regionCounts[2] > 0) regionsBlockedByProtection++;
+ }
+ }
+ return new FilterStats(islandsOverLevel, islandsPurgeProtected,
+ regionsBlockedByLevel, regionsBlockedByProtection);
+ }
+
+ /**
+ * Strict filter for the deleted sweep: any non-deletable island in a
+ * region blocks the whole region. Unlike {@link #filterNonDeletableRegions}
+ * this has no age/login/level logic — only the {@code deletable} flag
+ * matters.
+ */
+ private FilterStats filterForDeletedSweep(
+ Map, Set> deletableRegions) {
+ int regionsBlockedByProtection = 0;
+ var iter = deletableRegions.entrySet().iterator();
+ while (iter.hasNext()) {
+ var entry = iter.next();
+ boolean block = false;
+ for (String id : entry.getValue()) {
+ Optional opt = plugin.getIslands().getIslandById(id);
+ if (opt.isEmpty()) {
+ // Missing rows don't block — they're already gone.
+ continue;
+ }
+ if (!opt.get().isDeletable()) {
+ block = true;
+ break;
+ }
+ }
+ if (block) {
+ iter.remove();
+ regionsBlockedByProtection++;
+ }
+ }
+ return new FilterStats(0, 0, 0, regionsBlockedByProtection);
+ }
+
+ private int[] evaluateRegionIslands(Set islandIds, int days) {
+ int shouldRemove = 0;
+ int levelBlocked = 0;
+ int purgeBlocked = 0;
+ for (String id : islandIds) {
+ Optional opt = plugin.getIslands().getIslandById(id);
+ if (opt.isEmpty()) {
+ shouldRemove = 1;
+ continue;
+ }
+ Island isl = opt.get();
+ if (cannotDeleteIsland(isl, days)) {
+ shouldRemove = 1;
+ if (isl.isPurgeProtected()) purgeBlocked++;
+ if (isLevelTooHigh(isl)) levelBlocked++;
+ }
+ }
+ return new int[] { shouldRemove, levelBlocked, purgeBlocked };
+ }
+
+ private void logFilterStats(FilterStats stats) {
+ if (stats.islandsOverLevel() > 0) {
+ plugin.log("Purge: " + stats.islandsOverLevel() + " island(s) exceed the level threshold of "
+ + plugin.getSettings().getIslandPurgeLevel()
+ + " - preventing " + stats.regionsBlockedByLevel() + " region(s) from being purged");
+ }
+ if (stats.islandsPurgeProtected() > 0) {
+ plugin.log("Purge: " + stats.islandsPurgeProtected() + " island(s) are purge-protected"
+ + " - preventing " + stats.regionsBlockedByProtection() + " region(s) from being purged");
+ }
+ }
+
+ /**
+ * Check if an island cannot be deleted. Purge protected, spawn, or
+ * unowned (non-deletable) islands cannot be deleted. Islands whose members
+ * recently logged in, or that exceed the level threshold, cannot be
+ * deleted.
+ *
+ * @param island island
+ * @param days the age cutoff
+ * @return {@code true} if the island cannot be deleted
+ */
+ public boolean cannotDeleteIsland(Island island, int days) {
+ if (island.isDeletable()) {
+ return false;
+ }
+ long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days);
+ boolean recentLogin = island.getMemberSet().stream().anyMatch(uuid -> {
+ Long lastLogin = plugin.getPlayers().getLastLoginTimestamp(uuid);
+ if (lastLogin == null) {
+ lastLogin = Bukkit.getOfflinePlayer(uuid).getLastSeen();
+ }
+ return lastLogin >= cutoffMillis;
+ });
+ if (recentLogin) {
+ return true;
+ }
+ if (isLevelTooHigh(island)) {
+ return true;
+ }
+ return island.isPurgeProtected() || island.isSpawn() || !island.isOwned();
+ }
+
+ private boolean isLevelTooHigh(Island island) {
+ return plugin.getAddonsManager().getAddonByName("Level")
+ .map(l -> ((Level) l).getIslandLevel(island.getWorld(), island.getOwner())
+ >= plugin.getSettings().getIslandPurgeLevel())
+ .orElse(false);
+ }
+
+ // ---------------------------------------------------------------
+ // Scan
+ // ---------------------------------------------------------------
+
+ /**
+ * Finds all region files in the overworld (and optionally nether/end)
+ * that have not been modified in the last {@code days} days.
+ */
+ private List> findOldRegions(World world, int days, boolean isNether, boolean isEnd) {
+ File worldDir = world.getWorldFolder();
+ File overworldRegion = new File(worldDir, REGION);
+
+ World netherWorld = plugin.getIWM().getNetherWorld(world);
+ File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir);
+ File netherRegion = new File(netherBase, REGION);
+
+ World endWorld = plugin.getIWM().getEndWorld(world);
+ File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir);
+ File endRegion = new File(endBase, REGION);
+
+ long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days);
+
+ logRegionFolderPaths(overworldRegion, netherRegion, endRegion, world, isNether, isEnd);
+
+ Set candidateNames = collectCandidateNames(overworldRegion, netherRegion, endRegion, isNether, isEnd);
+ plugin.log("Purge total candidate region coordinates: " + candidateNames.size());
+ plugin.log("Purge checking candidate region(s) against island data, please wait...");
+
+ List> regions = new ArrayList<>();
+ for (String name : candidateNames) {
+ Pair coords = parseRegionCoords(name);
+ if (coords == null) continue;
+ if (!isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, isNether, isEnd)) {
+ regions.add(coords);
+ }
+ }
+ return regions;
+ }
+
+ private void logRegionFolderPaths(File overworldRegion, File netherRegion, File endRegion,
+ World world, boolean isNether, boolean isEnd) {
+ plugin.log("Purge region folders - Overworld: " + overworldRegion.getAbsolutePath()
+ + EXISTS_PREFIX + overworldRegion.isDirectory() + ")");
+ if (isNether) {
+ plugin.log("Purge region folders - Nether: " + netherRegion.getAbsolutePath()
+ + EXISTS_PREFIX + netherRegion.isDirectory() + ")");
+ } else {
+ plugin.log("Purge region folders - Nether: disabled (isNetherGenerate="
+ + plugin.getIWM().isNetherGenerate(world) + ", isNetherIslands="
+ + plugin.getIWM().isNetherIslands(world) + ")");
+ }
+ if (isEnd) {
+ plugin.log("Purge region folders - End: " + endRegion.getAbsolutePath()
+ + EXISTS_PREFIX + endRegion.isDirectory() + ")");
+ } else {
+ plugin.log("Purge region folders - End: disabled (isEndGenerate="
+ + plugin.getIWM().isEndGenerate(world) + ", isEndIslands="
+ + plugin.getIWM().isEndIslands(world) + ")");
+ }
+ }
+
+ private Set collectCandidateNames(File overworldRegion, File netherRegion, File endRegion,
+ boolean isNether, boolean isEnd) {
+ Set names = new HashSet<>();
+ addFileNames(names, overworldRegion.listFiles((dir, name) -> name.endsWith(".mca")), "overworld");
+ if (isNether) {
+ addFileNames(names, netherRegion.listFiles((dir, name) -> name.endsWith(".mca")), "nether");
+ }
+ if (isEnd) {
+ addFileNames(names, endRegion.listFiles((dir, name) -> name.endsWith(".mca")), "end");
+ }
+ return names;
+ }
+
+ private void addFileNames(Set names, File[] files, String dimension) {
+ if (files != null) {
+ for (File f : files) names.add(f.getName());
+ }
+ plugin.log(PURGE_FOUND + (files != null ? files.length : 0) + " " + dimension + " region files");
+ }
+
+ private Pair parseRegionCoords(String name) {
+ String coordsPart = name.substring(2, name.length() - 4);
+ String[] parts = coordsPart.split("\\.");
+ if (parts.length != 2) return null;
+ try {
+ return new Pair<>(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ /**
+ * Maps each old region to the set of island IDs whose island-squares
+ * overlap it. Each region covers a 512x512 block square.
+ */
+ private Map, Set> mapIslandsToRegions(
+ List> oldRegions, IslandGrid islandGrid) {
+ final int blocksPerRegion = 512;
+ Map, Set> regionToIslands = new HashMap<>();
+
+ for (Pair region : oldRegions) {
+ int regionMinX = region.x() * blocksPerRegion;
+ int regionMinZ = region.z() * blocksPerRegion;
+ int regionMaxX = regionMinX + blocksPerRegion - 1;
+ int regionMaxZ = regionMinZ + blocksPerRegion - 1;
+
+ Set ids = new HashSet<>();
+ for (IslandData data : islandGrid.getIslandsInBounds(regionMinX, regionMinZ, regionMaxX, regionMaxZ)) {
+ ids.add(data.id());
+ }
+ regionToIslands.put(region, ids);
+ }
+ return regionToIslands;
+ }
+
+ // ---------------------------------------------------------------
+ // Delete
+ // ---------------------------------------------------------------
+
+ private boolean deleteRegionFiles(PurgeScanResult scan) {
+ int days = scan.days();
+ if (days < 0) {
+ plugin.logError("Days is somehow negative!");
+ return false;
+ }
+ // days == 0 is the "deleted sweep" sentinel — no age filter and no
+ // freshness recheck. days > 0 is the age-based sweep.
+ boolean ageGated = days > 0;
+ long cutoffMillis = ageGated ? System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days) : 0L;
+
+ World world = scan.world();
+ File base = world.getWorldFolder();
+ File overworldRegion = new File(base, REGION);
+ File overworldEntities = new File(base, ENTITIES);
+ File overworldPoi = new File(base, POI);
+
+ World netherWorld = plugin.getIWM().getNetherWorld(world);
+ File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base);
+ File netherRegion = new File(netherBase, REGION);
+ File netherEntities = new File(netherBase, ENTITIES);
+ File netherPoi = new File(netherBase, POI);
+
+ World endWorld = plugin.getIWM().getEndWorld(world);
+ File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base);
+ File endRegion = new File(endBase, REGION);
+ File endEntities = new File(endBase, ENTITIES);
+ File endPoi = new File(endBase, POI);
+
+ // Verify none of the files have been updated since the cutoff.
+ // Skipped for the deleted sweep (ageGated == false) — the deletable
+ // flag on the island row is the sole authority there.
+ if (ageGated) {
+ for (Pair coords : scan.deletableRegions().keySet()) {
+ String name = "r." + coords.x() + "." + coords.z() + ".mca";
+ if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis,
+ scan.isNether(), scan.isEnd())) {
+ return false;
+ }
+ }
+ }
+
+ DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi);
+ DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi);
+ DimFolders end = new DimFolders(endRegion, endEntities, endPoi);
+ boolean allOk = true;
+ for (Pair coords : scan.deletableRegions().keySet()) {
+ String name = "r." + coords.x() + "." + coords.z() + ".mca";
+ if (!deleteOneRegion(name, ow, nether, end, scan.isNether(), scan.isEnd())) {
+ plugin.logError("Could not delete all the region/entity/poi files for some reason");
+ allOk = false;
+ }
+ }
+ return allOk;
+ }
+
+ private boolean deleteOneRegion(String name, DimFolders overworld, DimFolders nether, DimFolders end,
+ boolean isNether, boolean isEnd) {
+ boolean ok = deleteIfExists(new File(overworld.region(), name))
+ && deleteIfExists(new File(overworld.entities(), name))
+ && deleteIfExists(new File(overworld.poi(), name));
+ if (isNether) {
+ ok &= deleteIfExists(new File(nether.region(), name));
+ ok &= deleteIfExists(new File(nether.entities(), name));
+ ok &= deleteIfExists(new File(nether.poi(), name));
+ }
+ if (isEnd) {
+ ok &= deleteIfExists(new File(end.region(), name));
+ ok &= deleteIfExists(new File(end.entities(), name));
+ ok &= deleteIfExists(new File(end.poi(), name));
+ }
+ return ok;
+ }
+
+ private boolean deleteIfExists(File file) {
+ if (!file.getParentFile().exists()) {
+ return true;
+ }
+ try {
+ Files.deleteIfExists(file.toPath());
+ return true;
+ } catch (IOException e) {
+ plugin.logError("Failed to delete file: " + file.getAbsolutePath());
+ return false;
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Player data cleanup
+ // ---------------------------------------------------------------
+
+ private void deletePlayerFromWorldFolder(World world, String islandID,
+ Map, Set> deletableRegions, int days) {
+ File playerData = resolvePlayerDataFolder(world);
+ plugin.getIslands().getIslandById(islandID)
+ .ifPresent(island -> island.getMemberSet()
+ .forEach(uuid -> maybeDeletePlayerData(world, uuid, playerData, deletableRegions, days)));
+ }
+
+ private void maybeDeletePlayerData(World world, UUID uuid, File playerData,
+ Map, Set> deletableRegions, int days) {
+ // Deleted sweep (days == 0) skips player-data cleanup entirely —
+ // the player might still be active, and the age-based sweep will
+ // reap orphaned .dat files later.
+ if (days <= 0) {
+ return;
+ }
+ List memberOf = new ArrayList<>(plugin.getIslands().getIslands(world, uuid));
+ deletableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId())));
+ if (!memberOf.isEmpty()) {
+ return;
+ }
+ if (Bukkit.getOfflinePlayer(uuid).isOp()) {
+ return;
+ }
+ long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days);
+ Long lastLogin = plugin.getPlayers().getLastLoginTimestamp(uuid);
+ long actualLast = lastLogin != null ? lastLogin : Bukkit.getOfflinePlayer(uuid).getLastSeen();
+ if (actualLast >= cutoffMillis) {
+ return;
+ }
+ deletePlayerFiles(uuid, playerData);
+ }
+
+ private void deletePlayerFiles(UUID uuid, File playerData) {
+ if (!playerData.exists()) {
+ return;
+ }
+ deletePlayerFile(new File(playerData, uuid + ".dat"), "player data file");
+ deletePlayerFile(new File(playerData, uuid + ".dat_old"), "player data backup file");
+ }
+
+ private void deletePlayerFile(File file, String description) {
+ try {
+ Files.deleteIfExists(file.toPath());
+ } catch (IOException ex) {
+ plugin.logError("Failed to delete " + description + ": " + file.getAbsolutePath());
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Dimension path resolution (pre-26.1 vs 26.1.1+)
+ // ---------------------------------------------------------------
+
+ private File resolveDataFolder(World world) {
+ File worldFolder = world.getWorldFolder();
+ return switch (world.getEnvironment()) {
+ case NETHER -> {
+ File dim = new File(worldFolder, DIM_1);
+ yield dim.isDirectory() ? dim : worldFolder;
+ }
+ case THE_END -> {
+ File dim = new File(worldFolder, DIM1);
+ yield dim.isDirectory() ? dim : worldFolder;
+ }
+ default -> worldFolder;
+ };
+ }
+
+ private File resolvePlayerDataFolder(World world) {
+ File worldFolder = world.getWorldFolder();
+ File oldPath = new File(worldFolder, PLAYERDATA);
+ if (oldPath.isDirectory()) {
+ return oldPath;
+ }
+ File root = worldFolder.getParentFile();
+ if (root != null) root = root.getParentFile();
+ if (root != null) root = root.getParentFile();
+ if (root != null) {
+ File newPath = new File(root, PLAYERS + File.separator + "data");
+ if (newPath.isDirectory()) {
+ return newPath;
+ }
+ }
+ return oldPath;
+ }
+
+ private File resolveNetherFallback(File overworldFolder) {
+ File dim = new File(overworldFolder, DIM_1);
+ if (dim.isDirectory()) {
+ return dim;
+ }
+ File parent = overworldFolder.getParentFile();
+ if (parent != null) {
+ File sibling = new File(parent, overworldFolder.getName() + "_nether");
+ if (sibling.isDirectory()) {
+ return sibling;
+ }
+ }
+ return dim;
+ }
+
+ private File resolveEndFallback(File overworldFolder) {
+ File dim = new File(overworldFolder, DIM1);
+ if (dim.isDirectory()) {
+ return dim;
+ }
+ File parent = overworldFolder.getParentFile();
+ if (parent != null) {
+ File sibling = new File(parent, overworldFolder.getName() + "_the_end");
+ if (sibling.isDirectory()) {
+ return sibling;
+ }
+ }
+ return dim;
+ }
+
+ // ---------------------------------------------------------------
+ // Freshness checks + region timestamp reader
+ // ---------------------------------------------------------------
+
+ private boolean isFileFresh(File file, long cutoffMillis) {
+ return file.exists() && getRegionTimestamp(file) >= cutoffMillis;
+ }
+
+ private boolean isAnyDimensionFresh(String name, File overworldRegion, File netherRegion,
+ File endRegion, long cutoffMillis, boolean isNether, boolean isEnd) {
+ if (isFileFresh(new File(overworldRegion, name), cutoffMillis)) return true;
+ if (isNether && isFileFresh(new File(netherRegion, name), cutoffMillis)) return true;
+ return isEnd && isFileFresh(new File(endRegion, name), cutoffMillis);
+ }
+
+ /**
+ * Reads the most recent per-chunk timestamp in a Minecraft .mca file
+ * header, in milliseconds since epoch.
+ */
+ private long getRegionTimestamp(File regionFile) {
+ if (!regionFile.exists() || regionFile.length() < 8192) {
+ return 0L;
+ }
+ try (FileInputStream fis = new FileInputStream(regionFile)) {
+ byte[] buffer = new byte[4096];
+ if (fis.skip(4096) != 4096) {
+ return 0L;
+ }
+ if (fis.read(buffer) != 4096) {
+ return 0L;
+ }
+ ByteBuffer bb = ByteBuffer.wrap(buffer);
+ bb.order(ByteOrder.BIG_ENDIAN);
+ long maxTimestampSeconds = 0;
+ for (int i = 0; i < 1024; i++) {
+ long timestamp = Integer.toUnsignedLong(bb.getInt());
+ if (timestamp > maxTimestampSeconds) {
+ maxTimestampSeconds = timestamp;
+ }
+ }
+ return maxTimestampSeconds * 1000L;
+ } catch (IOException e) {
+ plugin.logError("Failed to read region file timestamps: " + regionFile.getAbsolutePath()
+ + " " + e.getMessage());
+ return 0L;
+ }
+ }
+}
diff --git a/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java b/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java
index 3f1d52c6b..6a81375cc 100644
--- a/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java
+++ b/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java
@@ -134,7 +134,7 @@ public void openPanel() {
PanelItem item = new PanelItemBuilder()
.name(bb.getDisplayName())
.description(t("edit"), t("rename"))
- .icon(bb.getIcon())
+ .icon(bb.getIconItemStack())
.clickHandler((panel, u, clickType, s) -> {
u.closeInventory();
if (clickType.equals(ClickType.RIGHT)) {
@@ -350,7 +350,7 @@ protected PanelItem getBundleIcon(BlueprintBundle bb) {
return new PanelItemBuilder()
.name(t("edit-description"))
.description(bb.getDescription())
- .icon(bb.getIcon())
+ .icon(bb.getIconItemStack())
.clickHandler((panel, u, clickType, slot) -> {
u.closeInventory();
// Description conversation
@@ -458,7 +458,7 @@ protected PanelItem getBlueprintItem(GameModeAddon addon, int pos, BlueprintBund
return new PanelItemBuilder()
.name(blueprint.getDisplayName() == null ? blueprint.getName() : blueprint.getDisplayName())
.description(desc)
- .icon(blueprint.getIcon())
+ .icon(blueprint.getIconItemStack())
.glow(selected != null && pos == selected.getKey())
.clickHandler((panel, u, clickType, slot) -> {
// Handle the world squares
diff --git a/src/main/java/world/bentobox/bentobox/panels/IconChanger.java b/src/main/java/world/bentobox/bentobox/panels/IconChanger.java
index 809b15455..fdccef3ff 100644
--- a/src/main/java/world/bentobox/bentobox/panels/IconChanger.java
+++ b/src/main/java/world/bentobox/bentobox/panels/IconChanger.java
@@ -6,6 +6,7 @@
import org.bukkit.Sound;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
+import org.bukkit.inventory.meta.ItemMeta;
import world.bentobox.bentobox.BentoBox;
import world.bentobox.bentobox.api.addons.GameModeAddon;
@@ -48,15 +49,26 @@ public void onInventoryClick(User user, InventoryClickEvent event) {
Entry selected = blueprintManagementPanel.getSelected();
user.getPlayer().playSound(user.getLocation(), Sound.BLOCK_METAL_HIT, 1F, 1F);
if (selected == null) {
- // Change the Bundle Icon
- bb.setIcon(icon);
+ // Change the Bundle Icon — prefer item model key over plain material so that
+ // datapacked items (e.g. paper[item_model="myserver:island_tropical"]) are stored correctly.
+ ItemMeta meta = event.getCurrentItem().getItemMeta();
+ if (meta != null && meta.hasItemModel()) {
+ bb.setIcon(meta.getItemModel().toString());
+ } else {
+ bb.setIcon(icon);
+ }
// Save it
plugin.getBlueprintsManager().saveBlueprintBundle(addon, bb);
} else {
- // Change the Blueprint icon
+ // Change the Blueprint icon — same model-key detection as for bundles
Blueprint bp = selected.getValue();
- bp.setIcon(icon);
+ ItemMeta bpMeta = event.getCurrentItem().getItemMeta();
+ if (bpMeta != null && bpMeta.hasItemModel()) {
+ bp.setIcon(bpMeta.getItemModel().toString());
+ } else {
+ bp.setIcon(icon);
+ }
// Save it
plugin.getBlueprintsManager().saveBlueprint(addon, bp);
}
diff --git a/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java b/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java
index d47f01bac..953450779 100644
--- a/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java
+++ b/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java
@@ -414,7 +414,7 @@ private void applyTemplate(PanelItemBuilder builder, ItemTemplateRecord template
}
else
{
- builder.icon(bundle.getIcon());
+ builder.icon(bundle.getIconItemStack());
}
if (template.title() != null)
diff --git a/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java b/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java
index 7e0bf0287..496ac67ca 100644
--- a/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java
+++ b/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java
@@ -20,7 +20,7 @@
* Implements a {@link Tab} that enables the default island protection settings to be changed.
* These are the protection flag rank values that new islands will receive.
* @author tastybento
- * @since 3.14.0
+ * @since 3.15.0
*/
public class IslandDefaultSettingsTab extends SettingsTab implements Tab {
diff --git a/src/main/java/world/bentobox/bentobox/util/IslandInfo.java b/src/main/java/world/bentobox/bentobox/util/IslandInfo.java
index 579831f2c..7917baf01 100644
--- a/src/main/java/world/bentobox/bentobox/util/IslandInfo.java
+++ b/src/main/java/world/bentobox/bentobox/util/IslandInfo.java
@@ -119,6 +119,9 @@ public void showAdminInfo(User user, Addon addon) {
if (island.isPurgeProtected()) {
user.sendMessage("commands.admin.info.purge-protected");
}
+ if (island.isDeletable()) {
+ user.sendMessage("commands.admin.info.deletable");
+ }
// Show bundle info if available
island.getMetaData("bundle").ifPresent(mdv -> user.sendMessage("commands.admin.info.bundle", TextVariables.NAME, mdv.asString()));
// Fire info event to allow other addons to add to info
diff --git a/src/main/java/world/bentobox/bentobox/util/ItemParser.java b/src/main/java/world/bentobox/bentobox/util/ItemParser.java
index 6f47b9f3e..4648171f9 100644
--- a/src/main/java/world/bentobox/bentobox/util/ItemParser.java
+++ b/src/main/java/world/bentobox/bentobox/util/ItemParser.java
@@ -12,6 +12,7 @@
import org.bukkit.Bukkit;
import org.bukkit.DyeColor;
import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
import org.bukkit.block.banner.Pattern;
import org.bukkit.block.banner.PatternType;
import org.bukkit.inventory.ItemStack;
@@ -43,6 +44,61 @@ public class ItemParser {
private static final int MAX_AMOUNT = 99;
private ItemParser() {} // private constructor to hide the implicit public one.
+
+ /**
+ * Resolves an icon string to a {@link Material}.
+ * Accepts plain material names (e.g. {@code "DIAMOND"}), vanilla namespaced keys
+ * (e.g. {@code "minecraft:diamond"}), and custom item-model keys
+ * (e.g. {@code "myserver:island_tropical"}). Custom model keys that are not
+ * recognised vanilla materials fall back to {@link Material#PAPER}.
+ * @param icon the icon string, may be null
+ * @return resolved Material, never null
+ * @since 3.0.0
+ */
+ public static Material parseIconMaterial(@Nullable String icon) {
+ if (icon == null) {
+ return Material.PAPER;
+ }
+ Material m = Material.matchMaterial(icon);
+ return m != null ? m : Material.PAPER;
+ }
+
+ /**
+ * Resolves an icon string to an {@link ItemStack}.
+ *
+ * - Plain material name or vanilla namespaced key → {@code new ItemStack(material)}
+ * - Custom item-model key (namespace:key not matching any vanilla material) →
+ * PAPER base item with the model applied via {@link ItemMeta#setItemModel(NamespacedKey)}
+ *
+ * @param icon the icon string, may be null
+ * @return resolved ItemStack, never null
+ * @since 3.0.0
+ */
+ public static ItemStack parseIconItemStack(@Nullable String icon) {
+ if (icon == null) {
+ return new ItemStack(Material.PAPER);
+ }
+ Material m = Material.matchMaterial(icon);
+ if (m != null) {
+ return new ItemStack(m);
+ }
+ // Contains a colon but isn't a vanilla material → treat as a custom item model key
+ if (icon.contains(":")) {
+ ItemStack item = new ItemStack(Material.PAPER);
+ ItemMeta meta = item.getItemMeta();
+ if (meta != null) {
+ String[] parts = icon.split(":", 2);
+ try {
+ meta.setItemModel(new NamespacedKey(parts[0], parts[1]));
+ item.setItemMeta(meta);
+ } catch (IllegalArgumentException ignored) {
+ // Invalid namespace/key format — return plain PAPER
+ }
+ }
+ return item;
+ }
+ return new ItemStack(Material.PAPER);
+ }
/**
* Parse given string to ItemStack.
* @param text String value of item stack.
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
index 00001355e..e487df1de 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -206,16 +206,6 @@ island:
# Added since 1.7.0.
delete-speed: 100
deletion:
- # Toggles whether islands, when players are resetting them, should be kept in the world or deleted.
- # * If set to 'true', whenever a player resets his island, his previous island will become unowned and won't be deleted from the world.
- # You can, however, still delete those unowned islands through purging.
- # On bigger servers, this can lead to an increasing world size.
- # Yet, this allows admins to retrieve a player's old island in case of an improper use of the reset command.
- # Admins can indeed re-add the player to his old island by registering him to it.
- # * If set to 'false', whenever a player resets his island, his previous island will be deleted from the world.
- # This is the default behaviour.
- # Added since 1.13.0.
- keep-previous-island-on-reset: false
# Toggles how the islands are deleted.
# * If set to 'false', all islands will be deleted at once.
# This is fast but may cause an impact on the performance
diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml
index 59416f7bc..738f8459f 100644
--- a/src/main/resources/locales/en-US.yml
+++ b/src/main/resources/locales/en-US.yml
@@ -103,46 +103,33 @@ commands:
[prefix_island], decreasing the total to [total]resets.'
purge:
parameters: '[days]'
- description: purge [prefix_Islands] abandoned for more than [days]
+ description: 'delete region files for [prefix_Islands] untouched for more than [days] days (frees disk)'
days-one-or-more: 'Must be at least 1 day or more'
purgable-islands: 'Found [number] purgable [prefix_Islands].'
- too-many: |
- This is a lot and could take a very long time to delete.
- Consider using Regionerator plugin for deleting world chunks
- and setting keep-previous-island-on-reset: true in BentoBox's config.yml.
- Then run a purge.
- purge-in-progress: 'Purging in progress. Use /[label] purge stop to
- cancel.'
- scanning: 'Scanning [prefix_Islands] in the database. This may take a while depending
- on how many you have...'
- scanning-in-progress: 'Scanning in progress, please wait'
+ purge-in-progress: 'Purging in progress. Please wait for it to finish.'
+ scanning: 'Scanning region files. This may take a while depending on world size...'
none-found: 'No [prefix_Islands] found to purge.'
- total-islands: 'You have [number] [prefix_Islands] in your database in all worlds.'
- number-error: 'Argument must be a number of days'
confirm: 'Type /[label] purge confirm to start purging'
- completed: 'Purging stopped.'
- see-console-for-status: 'Purge started. See console for status or use
- /[label] purge status.'
- no-purge-in-progress: 'There is currently no purge in progress.'
- regions:
+ failed: 'Purge completed with errors. Some region files could not be deleted. Check the server log for details.'
+ age-regions:
parameters: '[days]'
- description: 'purge islands by deleting old region files'
- confirm: 'Type /[label] purge regions confirm to start purging'
+ description: 'debug/test: rewrite region file timestamps so they become purgable'
+ done: 'Aged [number] region file(s) in the current world.'
+ deleted:
+ parameters: ''
+ description: 'purge region files for any [prefix_island] already flagged as deleted'
+ confirm: 'Type /[label] purge deleted confirm to reap the region files'
+ deferred: 'Region files deleted. Island database entries will be removed on next server restart.'
protect:
description: toggle [prefix_island] purge protection
move-to-island: 'Move to [prefix_an-island] first!'
protecting: 'Protecting [prefix_island] from purge.'
unprotecting: 'Removing purge protection.'
- stop:
- description: stop a purge in progress
- stopping: Stopping the purge
unowned:
- description: purge unowned islands
- unowned-islands: 'Found [number] unowned islands.'
- status:
- description: displays the status of the purge
- status: '[purged] islands purged out of [purgeable] ([percentage]
- %).'
+ parameters: ''
+ description: flag unowned [prefix_Islands] as deletable so they can be reaped
+ unowned-islands: 'Found [number] unowned [prefix_Islands].'
+ flagged: 'Flagged [number] [prefix_island](s) as deletable. Run /[label] purge deleted to reap the region files.'
team:
description: manage teams
add:
@@ -276,6 +263,7 @@ commands:
protection-range-bonus-title: 'Includes these bonues:'
protection-range-bonus: 'Bonus: [number]'
purge-protected: '[prefix_Island] is purge protected'
+ deletable: '[prefix_Island] is flagged for deletion and awaiting region purge.'
max-protection-range: 'Largest historical protection range: [range]'
protection-coords: 'Protection coordinates: [xz1] to [xz2]'
is-spawn: '[prefix_Island] is a spawn [prefix_island]'
@@ -1751,6 +1739,7 @@ protection:
name: World TNT damage
locked: 'This [prefix_island] is locked!'
locked-island-bypass: 'This [prefix_island] is locked, but you have permission to bypass.'
+ deletable-island-admin: '[Admin] This [prefix_island] is flagged for deletion and awaiting region purge.'
protected: '[prefix_Island] protected: [description].'
world-protected: 'World protected: [description].'
spawn-protected: 'Spawn protected: [description].'
diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml
index 44f8c5b59..12db2eb2e 100644
--- a/src/main/resources/locales/ja.yml
+++ b/src/main/resources/locales/ja.yml
@@ -609,7 +609,7 @@ commands:
you-cannot-make-team: 'チームメンバーは、チーム[prefix_island]と同じ世界で島を作ることはできません。'
pasting:
estimated-time: '推定時間:[number]s。'
- blocks: 'ブロックごとに構築します。 合計[numbber]ブロック。'
+ blocks: 'ブロックごとに構築します。 合計[number]ブロック。'
entities: 'それを生き物で満たす。 合計[number]体のクリーチャー。'
dimension-done: '[world]に島が建設されます。'
done: '完了しました。あなたの島の準備が整い、あなたを待っています!'
diff --git a/src/test/java/world/bentobox/bentobox/SettingsTest.java b/src/test/java/world/bentobox/bentobox/SettingsTest.java
index 0d325b52f..657503652 100644
--- a/src/test/java/world/bentobox/bentobox/SettingsTest.java
+++ b/src/test/java/world/bentobox/bentobox/SettingsTest.java
@@ -733,26 +733,6 @@ void testSetDatabasePrefix() {
assertEquals("Prefix", s.getDatabasePrefix());
}
- /**
- * Test method for
- * {@link world.bentobox.bentobox.Settings#isKeepPreviousIslandOnReset()}.
- */
- @Test
- void testIsKeepPreviousIslandOnReset() {
- assertFalse(s.isKeepPreviousIslandOnReset());
- }
-
- /**
- * Test method for
- * {@link world.bentobox.bentobox.Settings#setKeepPreviousIslandOnReset(boolean)}.
- */
- @Test
- void testSetKeepPreviousIslandOnReset() {
- assertFalse(s.isKeepPreviousIslandOnReset());
- s.setKeepPreviousIslandOnReset(true);
- assertTrue(s.isKeepPreviousIslandOnReset());
- }
-
/**
* Test method for
* {@link world.bentobox.bentobox.Settings#getMongodbConnectionUri()}.
diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java
index b9723c827..04efc21ae 100644
--- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java
+++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java
@@ -4,52 +4,55 @@
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.anyLong;
-import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
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.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
-import java.util.Set;
import java.util.UUID;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
-import org.bukkit.World;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.util.Vector;
-import org.eclipse.jdt.annotation.NonNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
-import org.mockito.Mockito;
import com.google.common.collect.ImmutableSet;
import world.bentobox.bentobox.CommonTestSetup;
-import world.bentobox.bentobox.Settings;
import world.bentobox.bentobox.api.addons.Addon;
import world.bentobox.bentobox.api.commands.CompositeCommand;
-import world.bentobox.bentobox.api.events.island.IslandDeletedEvent;
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.managers.AddonsManager;
import world.bentobox.bentobox.managers.CommandsManager;
import world.bentobox.bentobox.managers.PlayersManager;
+import world.bentobox.bentobox.managers.PurgeRegionsService;
+import world.bentobox.bentobox.managers.island.IslandCache;
+import world.bentobox.bentobox.managers.island.IslandGrid;
/**
- * @author tastybento
+ * Tests for {@link AdminPurgeCommand}.
*
+ * Since 3.15.0 the top-level purge command does a region-files purge
+ * (formerly {@code /bbox admin purge regions}). Tests drive it through the
+ * async scheduler mock and assert against the real {@link PurgeRegionsService}.
*/
class AdminPurgeCommandTest extends CommonTestSetup {
@@ -57,56 +60,68 @@ class AdminPurgeCommandTest extends CommonTestSetup {
private CompositeCommand ac;
@Mock
private User user;
-
- private AdminPurgeCommand apc;
@Mock
private Addon addon;
@Mock
+ private BukkitScheduler scheduler;
+ @Mock
+ private IslandCache islandCache;
+ @Mock
private PlayersManager pm;
@Mock
- private BukkitScheduler scheduler;
+ private AddonsManager addonsManager;
+
+ @TempDir
+ Path tempDir;
+
+ private AdminPurgeCommand apc;
@Override
@BeforeEach
public void setUp() throws Exception {
super.setUp();
- // Mock the method to immediately run the Runnable
- when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(invocation -> {
- Runnable task = invocation.getArgument(1);
- task.run(); // Immediately run the Runnable
- return null; // or return a mock of the Task if needed
+
+ when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
+ invocation.getArgument(1).run();
+ return null;
+ });
+ when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
+ invocation.getArgument(1).run();
+ return null;
});
mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler);
+ mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList());
- // Command manager
CommandsManager cm = mock(CommandsManager.class);
when(plugin.getCommandsManager()).thenReturn(cm);
when(ac.getWorld()).thenReturn(world);
-
when(ac.getAddon()).thenReturn(addon);
when(ac.getTopLabel()).thenReturn("bsb");
- // No islands by default
- when(im.getIslands()).thenReturn(Collections.emptyList());
- when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList()));
-
- // IWM
+ when(iwm.isNetherGenerate(world)).thenReturn(false);
+ when(iwm.isNetherIslands(world)).thenReturn(false);
+ when(iwm.isEndGenerate(world)).thenReturn(false);
+ when(iwm.isEndIslands(world)).thenReturn(false);
when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock");
+ when(iwm.getNetherWorld(world)).thenReturn(null);
+ when(iwm.getEndWorld(world)).thenReturn(null);
+
+ when(plugin.getPlayers()).thenReturn(pm);
+ when(pm.getName(any())).thenReturn("PlayerName");
+
+ when(im.getIslandCache()).thenReturn(islandCache);
+ when(islandCache.getIslandGrid(world)).thenReturn(null);
+
+ when(world.getWorldFolder()).thenReturn(tempDir.toFile());
- // Island
- when(island.isOwned()).thenReturn(true); // Default owned
- when(location.toVector()).thenReturn(new Vector(1, 2, 3));
when(island.getCenter()).thenReturn(location);
+ when(location.toVector()).thenReturn(new Vector(0, 0, 0));
- // Player manager
- when(plugin.getPlayers()).thenReturn(pm);
- when(pm.getName(any())).thenReturn("name");
+ when(plugin.getAddonsManager()).thenReturn(addonsManager);
+ when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty());
- Settings settings = new Settings();
- // Settings
- when(plugin.getSettings()).thenReturn(settings);
+ when(plugin.getPurgeRegionsService()).thenReturn(new PurgeRegionsService(plugin));
- // Command
apc = new AdminPurgeCommand(ac);
}
@@ -116,273 +131,215 @@ public void tearDown() throws Exception {
super.tearDown();
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#AdminPurgeCommand(CompositeCommand)}.
- */
- @Test
- void testConstructor() {
- verify(addon).registerListener(apc);
- }
-
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#setup()}.
- */
@Test
void testSetup() {
assertEquals("admin.purge", apc.getPermission());
assertFalse(apc.isOnlyPlayer());
assertEquals("commands.admin.purge.parameters", apc.getParameters());
assertEquals("commands.admin.purge.description", apc.getDescription());
- assertEquals(6, apc.getSubCommands().size());
+ // 4 explicit subcommands (unowned, protect, age-regions, deleted) + help
+ assertEquals(5, apc.getSubCommands().size());
}
-
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#canExecute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
@Test
- void testCanExecuteUserStringListOfStringEmptyArgs() {
- assertFalse(apc.canExecute(user, "", Collections.emptyList()));
- verify(user).sendMessage("commands.help.header",
- "[label]",
- "BSkyBlock");
+ void testCanExecuteEmptyArgs() {
+ assertFalse(apc.canExecute(user, "purge", Collections.emptyList()));
+ verify(user).sendMessage(eq("commands.help.header"), any(), any());
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#canExecute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
@Test
- void testCanExecuteUserStringListOfStringWithArg() {
- assertTrue(apc.canExecute(user, "", Collections.singletonList("23")));
+ void testCanExecuteWithArgs() {
+ assertTrue(apc.canExecute(user, "purge", List.of("10")));
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
- @Test
- void testExecuteUserStringListOfStringNotNumber() {
- assertFalse(apc.execute(user, "", Collections.singletonList("abc")));
- verify(user).sendMessage("commands.admin.purge.number-error");
- }
-
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
- @Test
- void testExecuteUserStringListOfStringZero() {
- assertFalse(apc.execute(user, "", Collections.singletonList("0")));
+ @ParameterizedTest
+ @ValueSource(strings = {"notanumber", "0", "-3"})
+ void testExecuteInvalidDays(String arg) {
+ assertFalse(apc.execute(user, "purge", List.of(arg)));
verify(user).sendMessage("commands.admin.purge.days-one-or-more");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
@Test
- void testExecuteUserStringListOfStringNoIslands() {
- assertTrue(apc.execute(user, "", Collections.singletonList("10")));
- verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0");
- }
+ void testExecuteNullIslandGrid() {
+ when(islandCache.getIslandGrid(world)).thenReturn(null);
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
- @Test
- void testExecuteUserStringListOfStringNoIslandsPurgeProtected() {
- when(island.isPurgeProtected()).thenReturn(true);
- when(im.getIslands()).thenReturn(Collections.singleton(island));
- assertTrue(apc.execute(user, "", Collections.singletonList("10")));
- verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0");
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.scanning");
+ verify(user).sendMessage("commands.admin.purge.none-found");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
@Test
- void testExecuteUserStringListOfStringNoIslandsWrongWorld() {
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.getWorld()).thenReturn(mock(World.class));
- when(im.getIslands()).thenReturn(Collections.singleton(island));
- assertTrue(apc.execute(user, "", Collections.singletonList("10")));
- verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0");
+ void testExecuteEmptyGrid() {
+ wireEmptyGrid();
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.scanning");
+ verify(user).sendMessage("commands.admin.purge.none-found");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
@Test
- void testExecuteUserStringListOfStringNoIslandsUnowned() {
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.getWorld()).thenReturn(world);
- when(island.getOwner()).thenReturn(null);
- when(island.isUnowned()).thenReturn(true);
- when(island.isOwned()).thenReturn(false);
- when(im.getIslands()).thenReturn(Collections.singleton(island));
- assertTrue(apc.execute(user, "", Collections.singletonList("10")));
- verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0");
+ void testExecuteNoRegionFiles() throws IOException {
+ wireEmptyGrid();
+ Files.createDirectories(tempDir.resolve("region"));
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.scanning");
+ verify(user).sendMessage("commands.admin.purge.none-found");
}
- /**
- * Makes sure that no spawn islands are deleted
- */
@Test
- void testExecuteUserStringListOfStringOnlyIslandSpawn() {
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.getWorld()).thenReturn(world);
- when(island.isSpawn()).thenReturn(true);
- when(im.getIslands()).thenReturn(Collections.singleton(island));
- assertTrue(apc.execute(user, "", Collections.singletonList("10")));
- verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0");
+ void testExecuteOldRegionFileNoIslands() throws IOException {
+ wireEmptyGrid();
+ createRegionFile();
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.scanning");
+ verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "0");
+ verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
- @SuppressWarnings("deprecation")
@Test
- void testExecuteUserStringListOfStringNoIslandsTeamIsland() {
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.getWorld()).thenReturn(world);
- when(island.getOwner()).thenReturn(UUID.randomUUID());
- when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID(), UUID.randomUUID()));
- when(im.getIslands()).thenReturn(Collections.singleton(island));
-
- // All players are up to date
- OfflinePlayer op = mock(OfflinePlayer.class);
- when(op.getLastPlayed()).thenReturn(System.currentTimeMillis());
- mockedBukkit.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))).thenReturn(op);
-
- assertTrue(apc.execute(user, "", Collections.singletonList("10")));
- verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0");
+ void testExecuteConfirmDeletesRegions() throws IOException {
+ wireEmptyGrid();
+ Path regionFile = tempDir.resolve("region").resolve("r.0.0.mca");
+ createRegionFile();
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb");
+
+ assertTrue(apc.execute(user, "purge", List.of("confirm")));
+ verify(user).sendMessage("general.success");
+ assertFalse(regionFile.toFile().exists(), "Region file should have been deleted");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
@Test
- void testExecuteUserStringListOfStringNoIslandsRecentLogin() {
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.getWorld()).thenReturn(world);
- when(island.getOwner()).thenReturn(UUID.randomUUID());
- when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID()));
- when(im.getIslands()).thenReturn(Collections.singleton(island));
- OfflinePlayer op = mock(OfflinePlayer.class);
- when(op.getLastPlayed()).thenReturn(System.currentTimeMillis());
- mockedBukkit.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))).thenReturn(op);
- assertTrue(apc.execute(user, "", Collections.singletonList("10")));
- verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0");
+ void testExecuteConfirmWithoutPriorScan() {
+ assertFalse(apc.execute(user, "purge", List.of("confirm")));
+ verify(user).sendMessage("commands.admin.purge.days-one-or-more");
+ verify(user, never()).sendMessage("general.success");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}.
- */
@Test
- void testExecuteUserStringListOfStringIslandsFound() {
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.getWorld()).thenReturn(world);
- when(island.getOwner()).thenReturn(UUID.randomUUID());
- when(island.isOwned()).thenReturn(true);
- when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID()));
- when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(List.of(island)));
- when(pm.getLastLoginTimestamp(any())).thenReturn(962434800L);
- assertTrue(apc.execute(user, "", Collections.singletonList("10"))); // 10 days ago
+ void testExecuteRecentRegionFileExcluded() throws IOException {
+ wireEmptyGrid();
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ File regionFile = regionDir.resolve("r.0.0.mca").toFile();
+
+ byte[] data = new byte[8192];
+ int nowSeconds = (int) (System.currentTimeMillis() / 1000L);
+ for (int i = 0; i < 1024; i++) {
+ int offset = 4096 + i * 4;
+ data[offset] = (byte) (nowSeconds >> 24);
+ data[offset + 1] = (byte) (nowSeconds >> 16);
+ data[offset + 2] = (byte) (nowSeconds >> 8);
+ data[offset + 3] = (byte) nowSeconds;
+ }
+ Files.write(regionFile.toPath(), data);
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
verify(user).sendMessage("commands.admin.purge.scanning");
- verify(user).sendMessage("commands.admin.purge.total-islands", "[number]", "1");
- verify(user, never()).sendMessage("commands.admin.purge.none-found");
- verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb");
+ verify(user).sendMessage("commands.admin.purge.none-found");
}
-
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#removeIslands()}.
- */
@Test
- void testRemoveIslands() {
- @NonNull
- Optional opIsland = Optional.of(island);
- when(im.getIslandById(any())).thenReturn(opIsland);
- testExecuteUserStringListOfStringIslandsFound();
- assertTrue(apc.execute(user, "", Collections.singletonList("confirm")));
- verify(im).deleteIsland(island, true, null);
- verify(plugin).log(any());
- verify(user).sendMessage("commands.admin.purge.see-console-for-status", "[label]", "bsb");
- }
+ void testExecuteIslandWithRecentLoginIsExcluded() throws IOException {
+ UUID ownerUUID = wireIsland("island-1", false, false, false);
+ when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(System.currentTimeMillis());
+ createRegionFile();
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#onIslandDeleted(world.bentobox.bentobox.api.events.island.IslandEvent.IslandDeletedEvent)}.
- */
- @Test
- void testOnIslandDeletedNotInPurge() {
- IslandDeletedEvent e = mock(IslandDeletedEvent.class);
- apc.onIslandDeleted(e);
- verify(user, Mockito.never()).sendMessage(any());
- verify(plugin, Mockito.never()).log(any());
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.none-found");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#onIslandDeleted(world.bentobox.bentobox.api.events.island.IslandEvent.IslandDeletedEvent)}.
- */
@Test
- void testOnIslandDeletedPurgeCompleted() {
- testRemoveIslands();
- IslandDeletedEvent e = mock(IslandDeletedEvent.class);
- apc.onIslandDeleted(e);
- verify(user).sendMessage("commands.admin.purge.completed");
- verify(plugin, Mockito.never()).log("");
+ void testExecuteSpawnIslandNotPurged() throws IOException {
+ UUID ownerUUID = wireIsland("island-spawn", false, false, true);
+ when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L);
+ createRegionFile();
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.none-found");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#isInPurge()}.
- */
@Test
- void testIsInPurge() {
- assertFalse(apc.isInPurge());
- testRemoveIslands();
- assertTrue(apc.isInPurge());
+ void testExecutePurgeProtectedIslandNotPurged() throws IOException {
+ UUID ownerUUID = wireIsland("island-protected", false, true, false);
+ when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L);
+ createRegionFile();
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.none-found");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#stop()}.
- */
@Test
- void testStop() {
- testRemoveIslands();
- assertTrue(apc.isInPurge());
- apc.stop();
- assertFalse(apc.isInPurge());
+ void testExecuteDeletableIslandIncluded() throws IOException {
+ wireIsland("island-deletable", true, false, false);
+ createRegionFile();
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1");
+ verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb");
}
- /**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#setUser(world.bentobox.bentobox.api.user.User)}.
- */
@Test
- void testSetUser() {
- apc.setUser(user);
- apc.removeIslands();
- verify(user, Mockito.times(1)).sendMessage(anyString());
+ void testExecuteConfirmDeletesPlayerData() throws IOException {
+ UUID ownerUUID = wireIsland("island-deletable", true, false, false);
+ when(im.getIslands(world, ownerUUID)).thenReturn(List.of(island));
+
+ OfflinePlayer offlinePlayer = mock(OfflinePlayer.class);
+ when(offlinePlayer.isOp()).thenReturn(false);
+ when(offlinePlayer.getLastSeen()).thenReturn(0L);
+ mockedBukkit.when(() -> Bukkit.getOfflinePlayer(ownerUUID)).thenReturn(offlinePlayer);
+ when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L);
+
+ when(im.deleteIslandId("island-deletable")).thenReturn(true);
+
+ createRegionFile();
+ Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata"));
+ Path playerFile = playerDataDir.resolve(ownerUUID + ".dat");
+ Files.createFile(playerFile);
+
+ assertTrue(apc.execute(user, "purge", List.of("10")));
+ verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb");
+
+ assertTrue(apc.execute(user, "purge", List.of("confirm")));
+ verify(user).sendMessage("general.success");
+ verify(im).deleteIslandId("island-deletable");
+ assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted");
}
/**
- * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#getOldIslands(int)}
- * @throws TimeoutException
- * @throws ExecutionException
- * @throws InterruptedException
+ * Wires the shared {@code island} mock with the given id and flags, puts it
+ * in an IslandGrid at (0,0), and returns the generated owner UUID.
*/
- @Test
- void testGetOldIslands() throws InterruptedException, ExecutionException, TimeoutException {
- assertTrue(apc.execute(user, "", Collections.singletonList("10"))); // 10 days ago
- // First, ensure that the result is empty
- CompletableFuture> result = apc.getOldIslands(10);
- Set set = result.join();
- assertTrue(set.isEmpty());
- // Mocking Islands and their retrieval
- Island island1 = mock(Island.class);
- Island island2 = mock(Island.class);
-
- when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(List.of(island1, island2)));
- // Now, check again after mocking islands
- CompletableFuture> futureWithIslands = apc.getOldIslands(10);
- assertTrue(futureWithIslands.get(5, TimeUnit.SECONDS).isEmpty()); // Adjust this assertion based on the expected behavior of getOldIslands
+ private UUID wireIsland(String id, boolean deletable, boolean purgeProtected, boolean spawn) {
+ UUID ownerUUID = UUID.randomUUID();
+ when(island.getUniqueId()).thenReturn(id);
+ when(island.getOwner()).thenReturn(ownerUUID);
+ when(island.isOwned()).thenReturn(true);
+ when(island.isDeletable()).thenReturn(deletable);
+ when(island.isPurgeProtected()).thenReturn(purgeProtected);
+ when(island.isSpawn()).thenReturn(spawn);
+ when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
+ when(island.getCenter()).thenReturn(location);
+ IslandGrid.IslandData data = new IslandGrid.IslandData(id, 0, 0, 100);
+ Collection islandList = List.of(data);
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList);
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById(id)).thenReturn(Optional.of(island));
+ return ownerUUID;
}
+ private void createRegionFile() throws IOException {
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Files.createFile(regionDir.resolve("r.0.0.mca"));
+ }
+
+ private void wireEmptyGrid() {
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList());
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ }
}
diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java
new file mode 100644
index 000000000..c008e58fa
--- /dev/null
+++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java
@@ -0,0 +1,376 @@
+package world.bentobox.bentobox.api.commands.admin.purge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.bukkit.util.Vector;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+
+import com.google.common.collect.ImmutableSet;
+
+import world.bentobox.bentobox.CommonTestSetup;
+import world.bentobox.bentobox.api.addons.Addon;
+import world.bentobox.bentobox.api.commands.CompositeCommand;
+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.managers.AddonsManager;
+import world.bentobox.bentobox.managers.CommandsManager;
+import world.bentobox.bentobox.managers.PlayersManager;
+import world.bentobox.bentobox.managers.PurgeRegionsService;
+import world.bentobox.bentobox.managers.island.IslandCache;
+import world.bentobox.bentobox.managers.island.IslandGrid;
+
+/**
+ * Tests for {@link AdminPurgeDeletedCommand}.
+ *
+ * Exercises the command against a real {@link PurgeRegionsService}
+ * wired over the mocked plugin, so the scan/filter/delete logic is
+ * driven end-to-end through the async scheduler mock.
+ */
+class AdminPurgeDeletedCommandTest extends CommonTestSetup {
+
+ @Mock
+ private CompositeCommand ac;
+ @Mock
+ private User user;
+ @Mock
+ private Addon addon;
+ @Mock
+ private BukkitScheduler scheduler;
+ @Mock
+ private IslandCache islandCache;
+ @Mock
+ private PlayersManager pm;
+ @Mock
+ private AddonsManager addonsManager;
+
+ @TempDir
+ Path tempDir;
+
+ private AdminPurgeCommand apc;
+ private AdminPurgeDeletedCommand apdc;
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+
+ // Run scheduled tasks inline so async/main scheduling collapses into
+ // a synchronous call chain for the test.
+ when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
+ invocation.getArgument(1).run();
+ return null;
+ });
+ when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
+ invocation.getArgument(1).run();
+ return null;
+ });
+ mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler);
+ mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList());
+
+ CommandsManager cm = mock(CommandsManager.class);
+ when(plugin.getCommandsManager()).thenReturn(cm);
+ when(ac.getWorld()).thenReturn(world);
+ when(ac.getAddon()).thenReturn(addon);
+ when(ac.getTopLabel()).thenReturn("bsb");
+
+ when(iwm.isNetherGenerate(world)).thenReturn(false);
+ when(iwm.isNetherIslands(world)).thenReturn(false);
+ when(iwm.isEndGenerate(world)).thenReturn(false);
+ when(iwm.isEndIslands(world)).thenReturn(false);
+ when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock");
+ when(iwm.getNetherWorld(world)).thenReturn(null);
+ when(iwm.getEndWorld(world)).thenReturn(null);
+
+ when(plugin.getPlayers()).thenReturn(pm);
+ when(pm.getName(any())).thenReturn("PlayerName");
+
+ when(im.getIslandCache()).thenReturn(islandCache);
+ when(islandCache.getIslands(world)).thenReturn(Collections.emptyList());
+ when(islandCache.getIslandGrid(world)).thenReturn(null);
+
+ when(world.getWorldFolder()).thenReturn(tempDir.toFile());
+
+ when(island.getCenter()).thenReturn(location);
+ when(location.toVector()).thenReturn(new Vector(0, 0, 0));
+
+ when(plugin.getAddonsManager()).thenReturn(addonsManager);
+ when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty());
+
+ when(plugin.getPurgeRegionsService()).thenReturn(new PurgeRegionsService(plugin));
+
+ apc = new AdminPurgeCommand(ac);
+ apdc = new AdminPurgeDeletedCommand(apc);
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ /**
+ * The command is not registered as a listener — it has no event handlers.
+ */
+ @Test
+ void testConstructor() {
+ assertEquals("admin.purge.deleted", apdc.getPermission());
+ }
+
+ @Test
+ void testSetup() {
+ assertEquals("admin.purge.deleted", apdc.getPermission());
+ assertFalse(apdc.isOnlyPlayer());
+ assertEquals("commands.admin.purge.deleted.parameters", apdc.getParameters());
+ assertEquals("commands.admin.purge.deleted.description", apdc.getDescription());
+ }
+
+ /**
+ * canExecute should accept zero arguments — unlike the age-based command,
+ * the deleted sweep takes no parameters.
+ */
+ @Test
+ void testCanExecuteNoArgs() {
+ assertTrue(apdc.canExecute(user, "deleted", Collections.emptyList()));
+ }
+
+ /**
+ * With an empty island cache the scan finds nothing and the user is told.
+ */
+ @Test
+ void testExecuteNoIslands() {
+ when(islandCache.getIslands(world)).thenReturn(Collections.emptyList());
+ IslandGrid grid = mock(IslandGrid.class);
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+
+ assertTrue(apdc.execute(user, "deleted", Collections.emptyList()));
+ verify(user).sendMessage("commands.admin.purge.scanning");
+ verify(user).sendMessage("commands.admin.purge.none-found");
+ }
+
+ /**
+ * A null island grid (world never registered) yields none-found.
+ */
+ @Test
+ void testExecuteNullGrid() {
+ when(islandCache.getIslandGrid(world)).thenReturn(null);
+
+ assertTrue(apdc.execute(user, "deleted", Collections.emptyList()));
+ verify(user).sendMessage("commands.admin.purge.none-found");
+ }
+
+ /**
+ * Non-deletable islands must be ignored by the deleted sweep — no candidate
+ * regions, no confirm prompt.
+ */
+ @Test
+ void testExecuteNonDeletableIgnored() {
+ when(island.getUniqueId()).thenReturn("island-active");
+ when(island.isDeletable()).thenReturn(false);
+ when(island.getMinProtectedX()).thenReturn(0);
+ when(island.getMaxProtectedX()).thenReturn(100);
+ when(island.getMinProtectedZ()).thenReturn(0);
+ when(island.getMaxProtectedZ()).thenReturn(100);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(island));
+ IslandGrid grid = mock(IslandGrid.class);
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+
+ assertTrue(apdc.execute(user, "deleted", Collections.emptyList()));
+ verify(user).sendMessage("commands.admin.purge.none-found");
+ }
+
+ /**
+ * A lone deletable island's region is surfaced and the confirm prompt fires.
+ */
+ @Test
+ void testExecuteDeletableIslandFound() {
+ UUID ownerUUID = UUID.randomUUID();
+ when(island.getUniqueId()).thenReturn("island-deletable");
+ when(island.getOwner()).thenReturn(ownerUUID);
+ when(island.isDeletable()).thenReturn(true);
+ when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
+ when(island.getCenter()).thenReturn(location);
+ // Occupies region r.0.0 (blocks 0..100)
+ when(island.getMinProtectedX()).thenReturn(0);
+ when(island.getMaxProtectedX()).thenReturn(100);
+ when(island.getMinProtectedZ()).thenReturn(0);
+ when(island.getMaxProtectedZ()).thenReturn(100);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(island));
+ IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100);
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(List.of(data));
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island));
+
+ assertTrue(apdc.execute(user, "deleted", Collections.emptyList()));
+ verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1");
+ verify(user).sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, "deleted");
+ }
+
+ /**
+ * A region shared between a deletable and a non-deletable neighbour must
+ * be dropped by the strict filter — non-deletable neighbour blocks reap.
+ */
+ @Test
+ void testExecuteStrictFilterBlocksMixedRegion() {
+ UUID owner1 = UUID.randomUUID();
+ UUID owner2 = UUID.randomUUID();
+
+ // Deletable island straddling r.0.0
+ Island deletable = mock(Island.class);
+ when(deletable.getUniqueId()).thenReturn("del");
+ when(deletable.isDeletable()).thenReturn(true);
+ when(deletable.getMemberSet()).thenReturn(ImmutableSet.of(owner1));
+ when(deletable.getMinProtectedX()).thenReturn(0);
+ when(deletable.getMaxProtectedX()).thenReturn(100);
+ when(deletable.getMinProtectedZ()).thenReturn(0);
+ when(deletable.getMaxProtectedZ()).thenReturn(100);
+
+ // Active neighbour sharing r.0.0
+ Island active = mock(Island.class);
+ when(active.getUniqueId()).thenReturn("act");
+ when(active.isDeletable()).thenReturn(false);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(deletable, active));
+ IslandGrid grid = mock(IslandGrid.class);
+ Collection inRegion = List.of(
+ new IslandGrid.IslandData("del", 0, 0, 100),
+ new IslandGrid.IslandData("act", 200, 200, 100));
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(inRegion);
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("del")).thenReturn(Optional.of(deletable));
+ when(im.getIslandById("act")).thenReturn(Optional.of(active));
+
+ assertTrue(apdc.execute(user, "deleted", Collections.emptyList()));
+ verify(user).sendMessage("commands.admin.purge.none-found");
+ }
+
+ /**
+ * Confirm after a scan deletes the region file on disk regardless of age.
+ * Crucially this file's timestamp is "now" — the age-based sweep would
+ * never touch it, but the deleted sweep must because the island row is
+ * flagged.
+ */
+ @Test
+ void testExecuteConfirmReapsFreshRegion() throws IOException {
+ UUID ownerUUID = UUID.randomUUID();
+ when(island.getUniqueId()).thenReturn("island-deletable");
+ when(island.getOwner()).thenReturn(ownerUUID);
+ when(island.isDeletable()).thenReturn(true);
+ when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
+ when(island.getCenter()).thenReturn(location);
+ when(island.getMinProtectedX()).thenReturn(0);
+ when(island.getMaxProtectedX()).thenReturn(100);
+ when(island.getMinProtectedZ()).thenReturn(0);
+ when(island.getMaxProtectedZ()).thenReturn(100);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(island));
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(List.of(new IslandGrid.IslandData("island-deletable", 0, 0, 100)));
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island));
+ when(im.deleteIslandId("island-deletable")).thenReturn(true);
+
+ // Build a fresh 8KB .mca with "now" timestamps — age sweep would skip
+ // this; deleted sweep must still reap it.
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Path regionFile = regionDir.resolve("r.0.0.mca");
+ byte[] data = new byte[8192];
+ int nowSeconds = (int) (System.currentTimeMillis() / 1000L);
+ for (int i = 0; i < 1024; i++) {
+ int offset = 4096 + i * 4;
+ data[offset] = (byte) (nowSeconds >> 24);
+ data[offset + 1] = (byte) (nowSeconds >> 16);
+ data[offset + 2] = (byte) (nowSeconds >> 8);
+ data[offset + 3] = (byte) nowSeconds;
+ }
+ Files.write(regionFile, data);
+
+ // Scan
+ assertTrue(apdc.execute(user, "deleted", Collections.emptyList()));
+ verify(user).sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, "deleted");
+
+ // Confirm
+ assertTrue(apdc.execute(user, "deleted", List.of("confirm")));
+ verify(user).sendMessage("commands.admin.purge.deleted.deferred");
+ assertFalse(regionFile.toFile().exists(), "Fresh region file should be reaped by the deleted sweep");
+ // DB row deletion is deferred to shutdown for days==0 (deleted sweep).
+ verify(im, never()).deleteIslandId("island-deletable");
+ }
+
+ /**
+ * Player data files must NOT be touched by the deleted sweep — the active
+ * player could still be playing and reaping their .dat would be harmful.
+ */
+ @Test
+ void testExecuteConfirmLeavesPlayerData() throws IOException {
+ UUID ownerUUID = UUID.randomUUID();
+ when(island.getUniqueId()).thenReturn("island-deletable");
+ when(island.getOwner()).thenReturn(ownerUUID);
+ when(island.isDeletable()).thenReturn(true);
+ when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
+ when(island.getCenter()).thenReturn(location);
+ when(island.getMinProtectedX()).thenReturn(0);
+ when(island.getMaxProtectedX()).thenReturn(100);
+ when(island.getMinProtectedZ()).thenReturn(0);
+ when(island.getMaxProtectedZ()).thenReturn(100);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(island));
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(List.of(new IslandGrid.IslandData("island-deletable", 0, 0, 100)));
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island));
+ when(im.deleteIslandId("island-deletable")).thenReturn(true);
+
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Files.createFile(regionDir.resolve("r.0.0.mca"));
+ Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata"));
+ Path playerFile = playerDataDir.resolve(ownerUUID + ".dat");
+ Files.createFile(playerFile);
+
+ assertTrue(apdc.execute(user, "deleted", Collections.emptyList()));
+ assertTrue(apdc.execute(user, "deleted", List.of("confirm")));
+ verify(user).sendMessage("commands.admin.purge.deleted.deferred");
+ assertTrue(playerFile.toFile().exists(),
+ "Deleted sweep must NOT remove player data — only the age sweep does");
+ }
+
+ /**
+ * A confirm before any scan falls through to the scan path (no args ==
+ * empty args are equivalent). It should not produce an error.
+ */
+ @Test
+ void testExecuteConfirmWithoutPriorScan() {
+ assertTrue(apdc.execute(user, "deleted", List.of("confirm")));
+ verify(user).sendMessage("commands.admin.purge.scanning");
+ }
+}
diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java
deleted file mode 100644
index b27e00563..000000000
--- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java
+++ /dev/null
@@ -1,516 +0,0 @@
-package world.bentobox.bentobox.api.commands.admin.purge;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-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.anyInt;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-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.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-import java.util.UUID;
-
-import org.bukkit.Bukkit;
-import org.bukkit.OfflinePlayer;
-import org.bukkit.scheduler.BukkitScheduler;
-import org.bukkit.util.Vector;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.io.TempDir;
-import org.mockito.Mock;
-
-import com.google.common.collect.ImmutableSet;
-
-import world.bentobox.bentobox.CommonTestSetup;
-import world.bentobox.bentobox.api.addons.Addon;
-import world.bentobox.bentobox.api.commands.CompositeCommand;
-import world.bentobox.bentobox.api.localization.TextVariables;
-import world.bentobox.bentobox.api.user.User;
-import world.bentobox.bentobox.managers.AddonsManager;
-import world.bentobox.bentobox.managers.CommandsManager;
-import world.bentobox.bentobox.managers.PlayersManager;
-import world.bentobox.bentobox.managers.island.IslandCache;
-import world.bentobox.bentobox.managers.island.IslandGrid;
-
-/**
- * Tests for {@link AdminPurgeRegionsCommand}.
- */
-class AdminPurgeRegionsCommandTest extends CommonTestSetup {
-
- @Mock
- private CompositeCommand ac;
- @Mock
- private User user;
- @Mock
- private Addon addon;
- @Mock
- private BukkitScheduler scheduler;
- @Mock
- private IslandCache islandCache;
- @Mock
- private PlayersManager pm;
- @Mock
- private AddonsManager addonsManager;
-
- @TempDir
- Path tempDir;
-
- private AdminPurgeCommand apc;
- private AdminPurgeRegionsCommand aprc;
-
- @Override
- @BeforeEach
- public void setUp() throws Exception {
- super.setUp();
-
- // Scheduler - run tasks immediately (both async and sync)
- when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
- invocation.getArgument(1).run();
- return null;
- });
- when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
- invocation.getArgument(1).run();
- return null;
- });
- mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler);
- mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList());
-
- // Command manager
- CommandsManager cm = mock(CommandsManager.class);
- when(plugin.getCommandsManager()).thenReturn(cm);
- when(ac.getWorld()).thenReturn(world);
- when(ac.getAddon()).thenReturn(addon);
- when(ac.getTopLabel()).thenReturn("bsb");
-
- // IWM - no nether/end by default
- when(iwm.isNetherGenerate(world)).thenReturn(false);
- when(iwm.isNetherIslands(world)).thenReturn(false);
- when(iwm.isEndGenerate(world)).thenReturn(false);
- when(iwm.isEndIslands(world)).thenReturn(false);
- when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock");
- when(iwm.getNetherWorld(world)).thenReturn(null);
- when(iwm.getEndWorld(world)).thenReturn(null);
-
- // Players manager
- when(plugin.getPlayers()).thenReturn(pm);
- when(pm.getName(any())).thenReturn("PlayerName");
-
- // Island cache
- when(im.getIslandCache()).thenReturn(islandCache);
- when(islandCache.getIslandGrid(world)).thenReturn(null);
-
- // World folder points to our temp directory
- when(world.getWorldFolder()).thenReturn(tempDir.toFile());
-
- // Island
- when(island.getCenter()).thenReturn(location);
- when(location.toVector()).thenReturn(new Vector(0, 0, 0));
-
- // Addons manager (used by canDeleteIsland for Level check)
- when(plugin.getAddonsManager()).thenReturn(addonsManager);
- when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty());
-
- // Create commands
- apc = new AdminPurgeCommand(ac);
- aprc = new AdminPurgeRegionsCommand(apc);
- }
-
- @Override
- @AfterEach
- public void tearDown() throws Exception {
- super.tearDown();
- }
-
- /**
- * Verify the command registers itself as a Bukkit listener via the addon.
- * AdminPurgeCommand.setup() creates one instance, and setUp() creates a second — so two calls.
- */
- @Test
- void testConstructor() {
- verify(addon, times(2)).registerListener(any(AdminPurgeRegionsCommand.class));
- }
-
- /**
- * Verify command metadata set in setup().
- */
- @Test
- void testSetup() {
- assertEquals("admin.purge.regions", aprc.getPermission());
- assertFalse(aprc.isOnlyPlayer());
- assertEquals("commands.admin.purge.regions.parameters", aprc.getParameters());
- assertEquals("commands.admin.purge.regions.description", aprc.getDescription());
- }
-
- /**
- * canExecute with no args should show help and return false.
- */
- @Test
- void testCanExecuteEmptyArgs() {
- assertFalse(aprc.canExecute(user, "regions", Collections.emptyList()));
- verify(user).sendMessage(eq("commands.help.header"), any(), any());
- }
-
- /**
- * canExecute with a valid argument should return true.
- */
- @Test
- void testCanExecuteWithArgs() {
- assertTrue(aprc.canExecute(user, "regions", List.of("10")));
- verify(user, never()).sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, "bsb");
- }
-
- /**
- * A non-numeric argument should produce an error message and return false.
- */
- @Test
- void testExecuteNotANumber() {
- assertFalse(aprc.execute(user, "regions", List.of("notanumber")));
- verify(user).sendMessage("commands.admin.purge.days-one-or-more");
- }
-
- /**
- * Zero days is invalid — must be one or more.
- */
- @Test
- void testExecuteZeroDays() {
- assertFalse(aprc.execute(user, "regions", List.of("0")));
- verify(user).sendMessage("commands.admin.purge.days-one-or-more");
- }
-
- /**
- * Negative days is invalid.
- */
- @Test
- void testExecuteNegativeDays() {
- assertFalse(aprc.execute(user, "regions", List.of("-3")));
- verify(user).sendMessage("commands.admin.purge.days-one-or-more");
- }
-
- /**
- * When the island grid is null (no islands registered for the world),
- * the command should report none found.
- */
- @Test
- void testExecuteNullIslandGrid() {
- when(islandCache.getIslandGrid(world)).thenReturn(null);
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.scanning");
- verify(user).sendMessage("commands.admin.purge.none-found");
- }
-
- /**
- * When the island grid is empty (no islands),
- * the command should report none found.
- */
- @Test
- void testExecuteEmptyGrid() {
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList());
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.scanning");
- verify(user).sendMessage("commands.admin.purge.none-found");
- }
-
- /**
- * When there are no .mca files in the region folder, none-found should be sent.
- */
- @Test
- void testExecuteNoRegionFiles() throws IOException {
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList());
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
-
- // Create the region directory but leave it empty
- Files.createDirectories(tempDir.resolve("region"));
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.scanning");
- verify(user).sendMessage("commands.admin.purge.none-found");
- }
-
- /**
- * When there is an old region file with no associated islands (empty grid),
- * the command should propose deletion and ask for confirmation.
- */
- @Test
- void testExecuteOldRegionFileNoIslands() throws IOException {
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList());
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
-
- // Create a small .mca file — getRegionTimestamp() returns 0 for files < 8192 bytes,
- // which is treated as very old (older than any cutoff).
- Path regionDir = Files.createDirectories(tempDir.resolve("region"));
- Files.createFile(regionDir.resolve("r.0.0.mca"));
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.scanning");
- // 0 islands found, but the empty region itself is deletable → confirm prompt
- verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "0");
- verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions");
- }
-
- /**
- * After scanning finds deletable regions, executing "confirm" should delete
- * the region files and send the success message.
- */
- @Test
- void testExecuteConfirmDeletesRegions() throws IOException {
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList());
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
-
- Path regionDir = Files.createDirectories(tempDir.resolve("region"));
- Path regionFile = regionDir.resolve("r.0.0.mca");
- Files.createFile(regionFile);
-
- // First execution: scan and find the old empty region
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions");
-
- // Second execution: confirm deletion
- assertTrue(aprc.execute(user, "regions", List.of("confirm")));
- verify(user).sendMessage("general.success");
- assertFalse(regionFile.toFile().exists(), "Region file should have been deleted");
- }
-
- /**
- * If "confirm" is sent before a scan, it should not trigger deletion
- * and should fall through to normal argument parsing (returning false for "confirm"
- * which is not a valid number).
- */
- @Test
- void testExecuteConfirmWithoutPriorScan() {
- assertFalse(aprc.execute(user, "regions", List.of("confirm")));
- verify(user).sendMessage("commands.admin.purge.days-one-or-more");
- verify(user, never()).sendMessage("general.success");
- }
-
- /**
- * A recent region file (newer than the cutoff) should NOT be included in the deletion list.
- * The command should report none found.
- */
- @Test
- void testExecuteRecentRegionFileExcluded() throws IOException {
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList());
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
-
- // Create a valid 8192-byte .mca file with a very recent timestamp so it is excluded.
- // The timestamp table occupies the second 4KB block; we'll write a recent Unix timestamp.
- Path regionDir = Files.createDirectories(tempDir.resolve("region"));
- File regionFile = regionDir.resolve("r.0.0.mca").toFile();
-
- // Build a minimal 8192-byte region file with current timestamp in all 1024 chunk slots
- byte[] data = new byte[8192];
- // Write current time (in seconds) as big-endian int into each 4-byte slot of the timestamp table
- int nowSeconds = (int) (System.currentTimeMillis() / 1000L);
- for (int i = 0; i < 1024; i++) {
- int offset = 4096 + i * 4;
- data[offset] = (byte) (nowSeconds >> 24);
- data[offset + 1] = (byte) (nowSeconds >> 16);
- data[offset + 2] = (byte) (nowSeconds >> 8);
- data[offset + 3] = (byte) nowSeconds;
- }
- Files.write(regionFile.toPath(), data);
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.scanning");
- verify(user).sendMessage("commands.admin.purge.none-found");
- }
-
- /**
- * An island whose member logged in recently must be excluded from purge candidates.
- */
- @Test
- void testExecuteIslandWithRecentLoginIsExcluded() throws IOException {
- UUID ownerUUID = UUID.randomUUID();
- when(island.getUniqueId()).thenReturn("island-1");
- when(island.getOwner()).thenReturn(ownerUUID);
- when(island.isOwned()).thenReturn(true);
- when(island.isDeletable()).thenReturn(false);
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.isSpawn()).thenReturn(false);
- when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
- when(island.getCenter()).thenReturn(location);
-
- // Island occupies region r.0.0 (minX=0, minZ=0, range=100)
- IslandGrid.IslandData data = new IslandGrid.IslandData("island-1", 0, 0, 100);
- Collection islandList = List.of(data);
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList);
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
- when(im.getIslandById("island-1")).thenReturn(Optional.of(island));
-
- // Owner logged in recently — island must be protected from purge
- when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(System.currentTimeMillis());
-
- Path regionDir = Files.createDirectories(tempDir.resolve("region"));
- Files.createFile(regionDir.resolve("r.0.0.mca"));
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.none-found");
- }
-
- /**
- * Spawn islands must never be purged.
- */
- @Test
- void testExecuteSpawnIslandNotPurged() throws IOException {
- UUID ownerUUID = UUID.randomUUID();
- when(island.getUniqueId()).thenReturn("island-spawn");
- when(island.getOwner()).thenReturn(ownerUUID);
- when(island.isOwned()).thenReturn(true);
- when(island.isDeletable()).thenReturn(false);
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.isSpawn()).thenReturn(true);
- when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
-
- IslandGrid.IslandData data = new IslandGrid.IslandData("island-spawn", 0, 0, 100);
- Collection islandList = List.of(data);
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList);
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
- when(im.getIslandById("island-spawn")).thenReturn(Optional.of(island));
-
- // Owner hasn't logged in for a long time
- when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L);
-
- Path regionDir = Files.createDirectories(tempDir.resolve("region"));
- Files.createFile(regionDir.resolve("r.0.0.mca"));
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.none-found");
- }
-
- /**
- * Purge-protected islands must never be purged.
- */
- @Test
- void testExecutePurgeProtectedIslandNotPurged() throws IOException {
- UUID ownerUUID = UUID.randomUUID();
- when(island.getUniqueId()).thenReturn("island-protected");
- when(island.getOwner()).thenReturn(ownerUUID);
- when(island.isOwned()).thenReturn(true);
- when(island.isDeletable()).thenReturn(false);
- when(island.isPurgeProtected()).thenReturn(true);
- when(island.isSpawn()).thenReturn(false);
- when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
-
- IslandGrid.IslandData data = new IslandGrid.IslandData("island-protected", 0, 0, 100);
- Collection islandList = List.of(data);
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList);
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
- when(im.getIslandById("island-protected")).thenReturn(Optional.of(island));
-
- when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L);
-
- Path regionDir = Files.createDirectories(tempDir.resolve("region"));
- Files.createFile(regionDir.resolve("r.0.0.mca"));
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.none-found");
- }
-
- /**
- * An island marked as deletable should always be eligible regardless of other flags.
- */
- @Test
- void testExecuteDeletableIslandIncluded() throws IOException {
- UUID ownerUUID = UUID.randomUUID();
- when(island.getUniqueId()).thenReturn("island-deletable");
- when(island.getOwner()).thenReturn(ownerUUID);
- when(island.isOwned()).thenReturn(true);
- when(island.isDeletable()).thenReturn(true); // deletable flag set
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.isSpawn()).thenReturn(false);
- when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
- when(island.getCenter()).thenReturn(location);
-
- IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100);
- Collection islandList = List.of(data);
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList);
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
- when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island));
-
- Path regionDir = Files.createDirectories(tempDir.resolve("region"));
- Files.createFile(regionDir.resolve("r.0.0.mca"));
-
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- // The island is deletable → canDeleteIsland returns false → region is kept → confirm prompt
- verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1");
- verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions");
- }
-
- /**
- * Deleting a region where the island has members removes their playerdata files
- * when they have no remaining islands and haven't logged in recently.
- */
- @Test
- void testExecuteConfirmDeletesPlayerData() throws IOException {
- UUID ownerUUID = UUID.randomUUID();
- when(island.getUniqueId()).thenReturn("island-deletable");
- when(island.getOwner()).thenReturn(ownerUUID);
- when(island.isOwned()).thenReturn(true);
- when(island.isDeletable()).thenReturn(true);
- when(island.isPurgeProtected()).thenReturn(false);
- when(island.isSpawn()).thenReturn(false);
- when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID));
- when(island.getCenter()).thenReturn(location);
-
- IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100);
- Collection islandList = List.of(data);
- IslandGrid grid = mock(IslandGrid.class);
- when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList);
- when(islandCache.getIslandGrid(world)).thenReturn(grid);
- when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island));
-
- // Player has no remaining islands after deletion
- when(im.getIslands(world, ownerUUID)).thenReturn(List.of(island));
-
- // Player is not op and hasn't logged in recently
- OfflinePlayer offlinePlayer = mock(OfflinePlayer.class);
- when(offlinePlayer.isOp()).thenReturn(false);
- when(offlinePlayer.getLastSeen()).thenReturn(0L);
- mockedBukkit.when(() -> Bukkit.getOfflinePlayer(ownerUUID)).thenReturn(offlinePlayer);
- when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L);
-
- // Island cache operations
- when(im.deleteIslandId("island-deletable")).thenReturn(true);
-
- // Create region and playerdata files
- Path regionDir = Files.createDirectories(tempDir.resolve("region"));
- Files.createFile(regionDir.resolve("r.0.0.mca"));
- Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata"));
- Path playerFile = playerDataDir.resolve(ownerUUID + ".dat");
- Files.createFile(playerFile);
-
- // Scan
- assertTrue(aprc.execute(user, "regions", List.of("10")));
- verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions");
-
- // Confirm
- assertTrue(aprc.execute(user, "regions", List.of("confirm")));
- verify(user).sendMessage("general.success");
- verify(im).deleteIslandId("island-deletable");
- assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted");
- }
-}
diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java
index c3ff40833..9f475efe8 100644
--- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java
+++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java
@@ -8,6 +8,7 @@
import java.util.Collections;
+import org.bukkit.util.Vector;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -20,8 +21,11 @@
import world.bentobox.bentobox.managers.CommandsManager;
/**
- * @author Poslovitch
- *
+ * Tests for {@link AdminPurgeUnownedCommand}. The command scans for orphan
+ * islands and (on confirmation) soft-deletes them so the region-files purge
+ * can reap their {@code .mca} files later. These tests cover the scan phase;
+ * the confirmation-triggered soft-delete path is exercised via
+ * {@link world.bentobox.bentobox.managers.IslandsManager#deleteIsland}.
*/
class AdminPurgeUnownedCommandTest extends CommonTestSetup {
@@ -34,14 +38,12 @@ class AdminPurgeUnownedCommandTest extends CommonTestSetup {
private AdminPurgeUnownedCommand apuc;
@Mock
private Addon addon;
-
@Override
@BeforeEach
public void setUp() throws Exception {
super.setUp();
- // Command manager
CommandsManager cm = mock(CommandsManager.class);
when(plugin.getCommandsManager()).thenReturn(cm);
when(ac.getWorld()).thenReturn(world);
@@ -49,20 +51,19 @@ public void setUp() throws Exception {
when(ac.getAddon()).thenReturn(addon);
when(ac.getTopLabel()).thenReturn("bsb");
- // No islands by default
when(im.getIslands()).thenReturn(Collections.emptyList());
- // IWM
when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock");
- // Island
when(island.getWorld()).thenReturn(world);
when(island.isSpawn()).thenReturn(false);
when(island.isPurgeProtected()).thenReturn(false);
- when(island.isOwned()).thenReturn(true); // Default owned
+ when(island.isOwned()).thenReturn(true);
when(island.isUnowned()).thenReturn(false);
+ when(island.isDeletable()).thenReturn(false);
+ when(island.getCenter()).thenReturn(location);
+ when(location.toVector()).thenReturn(new Vector(0, 0, 0));
- // Command
apc = new AdminPurgeCommand(ac);
apuc = new AdminPurgeUnownedCommand(apc);
}
@@ -74,7 +75,7 @@ public void tearDown() throws Exception {
}
/**
- * Makes sure no spawn islands are purged whatsoever
+ * Spawn islands must never be flagged deletable.
*/
@Test
void testNoPurgeIfIslandIsSpawn() {
@@ -84,6 +85,9 @@ void testNoPurgeIfIslandIsSpawn() {
verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0");
}
+ /**
+ * Owned islands must never be flagged deletable.
+ */
@Test
void testNoPurgeIfIslandIsOwned() {
when(im.getIslands()).thenReturn(Collections.singleton(island));
@@ -91,16 +95,21 @@ void testNoPurgeIfIslandIsOwned() {
verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0");
}
+ /**
+ * A genuine orphan gets counted and (later, on confirm) flagged.
+ */
@Test
void testPurgeIfIslandIsUnowned() {
when(island.isOwned()).thenReturn(false);
when(island.isUnowned()).thenReturn(true);
- when(island.getWorld()).thenReturn(world);
when(im.getIslands()).thenReturn(Collections.singleton(island));
assertTrue(apuc.execute(user, "", Collections.emptyList()));
verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "1");
}
+ /**
+ * Purge-protected islands must never be flagged deletable, even if unowned.
+ */
@Test
void testNoPurgeIfIslandIsPurgeProtected() {
when(island.isPurgeProtected()).thenReturn(true);
@@ -108,4 +117,18 @@ void testNoPurgeIfIslandIsPurgeProtected() {
assertTrue(apuc.execute(user, "", Collections.emptyList()));
verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0");
}
+
+ /**
+ * Islands already flagged deletable must not be counted again — they are
+ * already in the queue the regions-purge will drain.
+ */
+ @Test
+ void testNoPurgeIfIslandAlreadyDeletable() {
+ when(island.isOwned()).thenReturn(false);
+ when(island.isUnowned()).thenReturn(true);
+ when(island.isDeletable()).thenReturn(true);
+ when(im.getIslands()).thenReturn(Collections.singleton(island));
+ assertTrue(apuc.execute(user, "", Collections.emptyList()));
+ verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0");
+ }
}
diff --git a/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java b/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java
new file mode 100644
index 000000000..82a05946a
--- /dev/null
+++ b/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java
@@ -0,0 +1,133 @@
+package world.bentobox.bentobox.blueprints.dataobjects;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import world.bentobox.bentobox.CommonTestSetup;
+
+/**
+ * Tests for {@link BlueprintBundle} icon field parsing.
+ * @author tastybento
+ */
+class BlueprintBundleTest extends CommonTestSetup {
+
+ private BlueprintBundle bundle;
+
+ @BeforeEach
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ bundle = new BlueprintBundle();
+ }
+
+ @AfterEach
+ @Override
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ /**
+ * Default icon should be PAPER.
+ */
+ @Test
+ void testDefaultIcon() {
+ assertEquals(Material.PAPER, bundle.getIcon());
+ }
+
+ /**
+ * Default getIconItemStack should return a PAPER ItemStack.
+ */
+ @Test
+ void testDefaultIconItemStack() {
+ ItemStack is = bundle.getIconItemStack();
+ assertNotNull(is);
+ assertEquals(Material.PAPER, is.getType());
+ }
+
+ /**
+ * Plain material name (e.g. "DIAMOND") should resolve correctly.
+ */
+ @Test
+ void testPlainMaterialName() {
+ bundle.setIcon("DIAMOND");
+ assertEquals(Material.DIAMOND, bundle.getIcon());
+ ItemStack is = bundle.getIconItemStack();
+ assertNotNull(is);
+ assertEquals(Material.DIAMOND, is.getType());
+ }
+
+ /**
+ * Setting icon via Material enum should store correctly.
+ */
+ @Test
+ void testSetIconMaterial() {
+ bundle.setIcon(Material.GOLD_INGOT);
+ assertEquals(Material.GOLD_INGOT, bundle.getIcon());
+ ItemStack is = bundle.getIconItemStack();
+ assertNotNull(is);
+ assertEquals(Material.GOLD_INGOT, is.getType());
+ }
+
+ /**
+ * Setting icon via Material enum with null should fall back to PAPER.
+ */
+ @Test
+ void testSetIconMaterialNull() {
+ bundle.setIcon((Material) null);
+ assertEquals(Material.PAPER, bundle.getIcon());
+ }
+
+ /**
+ * Setting icon via String with null should fall back to PAPER.
+ */
+ @Test
+ void testSetIconStringNull() {
+ bundle.setIcon((String) null);
+ assertEquals(Material.PAPER, bundle.getIcon());
+ }
+
+ /**
+ * Vanilla namespaced material key (e.g. "minecraft:diamond") should resolve to the correct Material.
+ */
+ @Test
+ void testNamespacedVanillaMaterial() {
+ bundle.setIcon("minecraft:diamond");
+ assertEquals(Material.DIAMOND, bundle.getIcon());
+ ItemStack is = bundle.getIconItemStack();
+ assertNotNull(is);
+ assertEquals(Material.DIAMOND, is.getType());
+ }
+
+ /**
+ * A custom item-model key (namespace:key that is not a vanilla material) should return
+ * PAPER as the base material, since the player never sees the base item.
+ */
+ @Test
+ void testCustomItemModelKey() {
+ bundle.setIcon("myserver:island_tropical");
+ // getIcon() falls back to PAPER for unrecognised model keys
+ assertEquals(Material.PAPER, bundle.getIcon());
+ // getIconItemStack() returns a PAPER-based item
+ ItemStack is = bundle.getIconItemStack();
+ assertNotNull(is);
+ assertEquals(Material.PAPER, is.getType());
+ }
+
+ /**
+ * An icon string without a colon that is not a valid material should fall back to PAPER.
+ */
+ @Test
+ void testUnknownMaterialName() {
+ bundle.setIcon("NOT_A_REAL_MATERIAL");
+ assertEquals(Material.PAPER, bundle.getIcon());
+ ItemStack is = bundle.getIconItemStack();
+ assertNotNull(is);
+ assertEquals(Material.PAPER, is.getType());
+ }
+}
diff --git a/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java
index 8a0eac4a9..551d4443d 100644
--- a/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java
+++ b/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java
@@ -26,9 +26,13 @@
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.World.Environment;
+import org.bukkit.entity.ExperienceOrb;
import org.bukkit.entity.LivingEntity;
+import org.bukkit.entity.Zombie;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
+import org.bukkit.event.entity.EntityTargetEvent.TargetReason;
+import org.bukkit.event.entity.EntityTargetLivingEntityEvent;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
@@ -291,4 +295,59 @@ void testOnVisitorGetDamageVoidPlayerHasIsland() {
verify(im).homeTeleportAsync(any(), eq(mockPlayer));
verify(pim).callEvent(any(InvincibleVistorFlagDamageRemovalEvent.class));
}
+
+ /**
+ * Test that onVisitorTargeting cancels mob targeting of a visitor when ENTITY_ATTACK is in IV settings.
+ */
+ @Test
+ void testOnVisitorTargetingCancelsMobTargeting() {
+ ivSettings.add(DamageCause.ENTITY_ATTACK.name());
+ Zombie zombie = mock(Zombie.class);
+ when(zombie.getWorld()).thenReturn(world);
+ EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER);
+ listener.onVisitorTargeting(e);
+ assertTrue(e.isCancelled());
+ }
+
+ /**
+ * Test that onVisitorTargeting does NOT cancel experience orb targeting of a visitor.
+ * XP orbs should still be able to track visitors for pickup, regardless of IV settings.
+ */
+ @Test
+ void testOnVisitorTargetingDoesNotCancelExperienceOrbTargeting() {
+ ivSettings.add(DamageCause.ENTITY_ATTACK.name());
+ ExperienceOrb orb = mock(ExperienceOrb.class);
+ when(orb.getWorld()).thenReturn(world);
+ EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(orb, mockPlayer, TargetReason.CLOSEST_PLAYER);
+ listener.onVisitorTargeting(e);
+ assertFalse(e.isCancelled());
+ }
+
+ /**
+ * Test that onVisitorTargeting does not cancel when entity_attack is not in IV settings.
+ */
+ @Test
+ void testOnVisitorTargetingNotInIvSettings() {
+ // ENTITY_ATTACK is not in ivSettings by default
+ Zombie zombie = mock(Zombie.class);
+ when(zombie.getWorld()).thenReturn(world);
+ EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER);
+ listener.onVisitorTargeting(e);
+ assertFalse(e.isCancelled());
+ }
+
+ /**
+ * Test that onVisitorTargeting does not cancel when user is on their own island.
+ */
+ @Test
+ void testOnVisitorTargetingNotVisitor() {
+ ivSettings.add(DamageCause.ENTITY_ATTACK.name());
+ when(im.userIsOnIsland(any(), any())).thenReturn(true);
+ Zombie zombie = mock(Zombie.class);
+ when(zombie.getWorld()).thenReturn(world);
+ EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER);
+ listener.onVisitorTargeting(e);
+ assertFalse(e.isCancelled());
+ }
+
}
diff --git a/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java
new file mode 100644
index 000000000..fd2ac1db7
--- /dev/null
+++ b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java
@@ -0,0 +1,339 @@
+package world.bentobox.bentobox.managers;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+
+import world.bentobox.bentobox.CommonTestSetup;
+import world.bentobox.bentobox.Settings;
+import world.bentobox.bentobox.api.addons.AddonDescription;
+import world.bentobox.bentobox.api.addons.GameModeAddon;
+import world.bentobox.bentobox.managers.PurgeRegionsService.FilterStats;
+import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult;
+
+/**
+ * Tests for {@link HousekeepingManager}.
+ *
+ * Focus areas:
+ *
+ * - State persistence — the YAML file round-trips both
+ * {@code lastAgeRunMillis} and {@code lastDeletedRunMillis}.
+ * - Legacy migration — an existing state file written by the
+ * previous single-cycle implementation (only {@code lastRunMillis})
+ * is adopted as the age-cycle timestamp so existing installs don't
+ * reset their schedule on upgrade.
+ * - Dual cycle dispatch — the hourly check decides which
+ * cycle(s) to run based on the independent interval settings, and
+ * correctly skips when neither is due or the feature is disabled.
+ *
+ */
+class HousekeepingManagerTest extends CommonTestSetup {
+
+ @Mock
+ private BukkitScheduler scheduler;
+ @Mock
+ private AddonsManager addonsManager;
+ @Mock
+ private GameModeAddon gameMode;
+ @Mock
+ private AddonDescription addonDescription;
+ @Mock
+ private PurgeRegionsService purgeService;
+
+ @TempDir
+ Path tempDir;
+
+ private Settings settings;
+ private File stateFile;
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+
+ // Point the plugin data folder at the temp dir so housekeeping.yml
+ // lives in an isolated location per test.
+ when(plugin.getDataFolder()).thenReturn(tempDir.toFile());
+ stateFile = new File(new File(tempDir.toFile(), "database"), "housekeeping.yml");
+
+ // Real settings with test-specific overrides
+ settings = new Settings();
+ when(plugin.getSettings()).thenReturn(settings);
+
+ // Scheduler: run tasks inline so async cycles become synchronous.
+ when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
+ invocation.getArgument(1).run();
+ return null;
+ });
+ when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> {
+ invocation.getArgument(1).run();
+ return null;
+ });
+ mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler);
+ mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList());
+
+ // Addons: single gamemode with a single overworld
+ when(plugin.getAddonsManager()).thenReturn(addonsManager);
+ when(addonsManager.getGameModeAddons()).thenReturn(List.of(gameMode));
+ when(gameMode.getOverWorld()).thenReturn(world);
+ when(gameMode.getDescription()).thenReturn(addonDescription);
+ when(addonDescription.getName()).thenReturn("TestMode");
+
+ when(plugin.getPurgeRegionsService()).thenReturn(purgeService);
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ // ------------------------------------------------------------------
+ // Persistence
+ // ------------------------------------------------------------------
+
+ /**
+ * An install with no prior state file starts both timestamps at zero.
+ */
+ @Test
+ void testLoadStateNoPriorFile() {
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ assertEquals(0L, hm.getLastAgeRunMillis());
+ assertEquals(0L, hm.getLastDeletedRunMillis());
+ }
+
+ /**
+ * Legacy state files from the previous single-cycle implementation wrote
+ * only {@code lastRunMillis}. The new manager must adopt it as the
+ * age-cycle timestamp so upgrades don't reset the schedule. The deleted
+ * cycle starts from scratch.
+ */
+ @Test
+ void testLoadStateMigratesLegacyKey() throws Exception {
+ Files.createDirectories(stateFile.getParentFile().toPath());
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("lastRunMillis", 1700000000000L);
+ yaml.save(stateFile);
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ assertEquals(1700000000000L, hm.getLastAgeRunMillis(),
+ "legacy lastRunMillis should be adopted as age-cycle timestamp");
+ assertEquals(0L, hm.getLastDeletedRunMillis());
+ }
+
+ /**
+ * When both new keys are present the legacy key is ignored even if it's
+ * still in the file.
+ */
+ @Test
+ void testLoadStatePrefersNewKeysOverLegacy() throws Exception {
+ Files.createDirectories(stateFile.getParentFile().toPath());
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("lastRunMillis", 1000L); // legacy — ignored
+ yaml.set("lastAgeRunMillis", 2000L); // new
+ yaml.set("lastDeletedRunMillis", 3000L); // new
+ yaml.save(stateFile);
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ assertEquals(2000L, hm.getLastAgeRunMillis());
+ assertEquals(3000L, hm.getLastDeletedRunMillis());
+ }
+
+ /**
+ * Running a cycle must persist both timestamps to the YAML file so a
+ * restart doesn't lose either cadence.
+ */
+ @Test
+ void testSaveStateRoundTripsBothKeys() {
+ settings.setHousekeepingAgeEnabled(true);
+ settings.setHousekeepingIntervalDays(1);
+ settings.setHousekeepingRegionAgeDays(30);
+ settings.setHousekeepingDeletedIntervalHours(1);
+
+ when(purgeService.scan(eq(world), anyInt())).thenReturn(emptyScan(30));
+ when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0));
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ hm.runNow();
+
+ assertTrue(hm.getLastAgeRunMillis() > 0, "age cycle timestamp should be set");
+ assertTrue(hm.getLastDeletedRunMillis() > 0, "deleted cycle timestamp should be set");
+
+ // Read back from disk with a second manager instance to prove the
+ // state is actually persisted, not just in-memory.
+ HousekeepingManager reread = new HousekeepingManager(plugin);
+ assertEquals(hm.getLastAgeRunMillis(), reread.getLastAgeRunMillis());
+ assertEquals(hm.getLastDeletedRunMillis(), reread.getLastDeletedRunMillis());
+ }
+
+ // ------------------------------------------------------------------
+ // Cycle dispatch
+ // ------------------------------------------------------------------
+
+ /**
+ * When the feature is disabled both cycles are skipped regardless of
+ * what {@code runNow} does with the schedule.
+ */
+ @Test
+ void testDisabledFeatureSkipsAllCycles() throws Exception {
+ settings.setHousekeepingDeletedEnabled(false);
+ settings.setHousekeepingAgeEnabled(false);
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ // Invoke the internal hourly check via reflection so we go through
+ // the enabled gate (runNow bypasses that gate).
+ invokeCheckAndMaybeRun(hm);
+
+ verify(purgeService, never()).scan(any(), anyInt());
+ verify(purgeService, never()).scanDeleted(any());
+ }
+
+ /**
+ * {@code runNow()} fires both cycles unconditionally (subject to the
+ * enabled flag) and dispatches them to the service.
+ */
+ @Test
+ void testRunNowDispatchesBothCycles() {
+ settings.setHousekeepingAgeEnabled(true);
+ settings.setHousekeepingRegionAgeDays(30);
+
+ when(purgeService.scan(eq(world), eq(30))).thenReturn(emptyScan(30));
+ when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0));
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ hm.runNow();
+
+ verify(purgeService, times(1)).scan(world, 30);
+ verify(purgeService, times(1)).scanDeleted(world);
+ }
+
+ /**
+ * When the deleted interval is 0 the hourly check only runs the age
+ * cycle — the deleted cycle is effectively disabled.
+ */
+ @Test
+ void testDeletedIntervalZeroDisablesDeletedCycle() throws Exception {
+ settings.setHousekeepingAgeEnabled(true);
+ settings.setHousekeepingIntervalDays(1);
+ settings.setHousekeepingRegionAgeDays(30);
+ settings.setHousekeepingDeletedIntervalHours(0); // disabled
+
+ when(purgeService.scan(eq(world), eq(30))).thenReturn(emptyScan(30));
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ invokeCheckAndMaybeRun(hm);
+
+ verify(purgeService, times(1)).scan(world, 30);
+ verify(purgeService, never()).scanDeleted(any());
+ }
+
+ /**
+ * When the age interval is 0 only the deleted cycle runs.
+ */
+ @Test
+ void testAgeIntervalZeroDisablesAgeCycle() throws Exception {
+ settings.setHousekeepingIntervalDays(0); // disabled
+ settings.setHousekeepingDeletedIntervalHours(1);
+
+ when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0));
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ invokeCheckAndMaybeRun(hm);
+
+ verify(purgeService, never()).scan(any(), anyInt());
+ verify(purgeService, times(1)).scanDeleted(world);
+ }
+
+ /**
+ * If both cycles ran recently (last-run timestamps inside their intervals)
+ * the hourly check does nothing.
+ */
+ @Test
+ void testBothCyclesRecentlyRunIsNoop() throws Exception {
+ settings.setHousekeepingIntervalDays(30);
+ settings.setHousekeepingDeletedIntervalHours(24);
+
+ // Pre-populate the state file so both timestamps are "just now".
+ long now = System.currentTimeMillis();
+ Files.createDirectories(stateFile.getParentFile().toPath());
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("lastAgeRunMillis", now);
+ yaml.set("lastDeletedRunMillis", now);
+ yaml.save(stateFile);
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ invokeCheckAndMaybeRun(hm);
+
+ verify(purgeService, never()).scan(any(), anyInt());
+ verify(purgeService, never()).scanDeleted(any());
+ }
+
+ /**
+ * If only the deleted cycle has aged past its interval, only it runs —
+ * the age cycle is left alone.
+ */
+ @Test
+ void testOnlyDeletedCycleDueDispatchesDeletedOnly() throws Exception {
+ settings.setHousekeepingIntervalDays(30);
+ settings.setHousekeepingDeletedIntervalHours(24);
+
+ long now = System.currentTimeMillis();
+ long twoHoursAgo = now - TimeUnit.DAYS.toMillis(2);
+ Files.createDirectories(stateFile.getParentFile().toPath());
+ YamlConfiguration yaml = new YamlConfiguration();
+ // Age cycle ran 1 hour ago (<< 30d interval, not due).
+ yaml.set("lastAgeRunMillis", now - TimeUnit.HOURS.toMillis(1));
+ // Deleted cycle ran 2 days ago (>= 24h interval, due).
+ yaml.set("lastDeletedRunMillis", twoHoursAgo);
+ yaml.save(stateFile);
+
+ when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0));
+
+ HousekeepingManager hm = new HousekeepingManager(plugin);
+ invokeCheckAndMaybeRun(hm);
+
+ verify(purgeService, never()).scan(any(), anyInt());
+ verify(purgeService, times(1)).scanDeleted(world);
+ }
+
+ // ------------------------------------------------------------------
+ // helpers
+ // ------------------------------------------------------------------
+
+ private static PurgeScanResult emptyScan(int days) {
+ return new PurgeScanResult(mock(World.class), days, Collections.emptyMap(),
+ false, false, new FilterStats(0, 0, 0, 0));
+ }
+
+ /** Reflective access to the package-private {@code checkAndMaybeRun} so
+ * tests can drive the hourly path without waiting for the scheduler. */
+ private static void invokeCheckAndMaybeRun(HousekeepingManager hm) throws Exception {
+ var m = HousekeepingManager.class.getDeclaredMethod("checkAndMaybeRun");
+ m.setAccessible(true);
+ m.invoke(hm);
+ }
+}
diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java
new file mode 100644
index 000000000..81556055b
--- /dev/null
+++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java
@@ -0,0 +1,593 @@
+package world.bentobox.bentobox.managers;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+
+import world.bentobox.bentobox.CommonTestSetup;
+import world.bentobox.bentobox.database.objects.Island;
+import world.bentobox.bentobox.managers.PurgeRegionsService.FilterStats;
+import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult;
+import world.bentobox.bentobox.managers.island.IslandCache;
+import world.bentobox.bentobox.managers.island.IslandGrid;
+import world.bentobox.bentobox.managers.island.IslandGrid.IslandData;
+import world.bentobox.bentobox.util.Pair;
+
+/**
+ * Direct tests for {@link PurgeRegionsService} focused on the deleted-sweep
+ * path ({@link PurgeRegionsService#scanDeleted(org.bukkit.World)}) and the
+ * {@code days == 0} behavior of {@link PurgeRegionsService#delete}.
+ *
+ * These tests exercise the service directly (no command layer) so the
+ * assertions stay tightly scoped to the scanning/filtering logic.
+ */
+class PurgeRegionsServiceTest extends CommonTestSetup {
+
+ @Mock
+ private IslandCache islandCache;
+ @Mock
+ private AddonsManager addonsManager;
+ @Mock
+ private PlayersManager pm;
+
+ @TempDir
+ Path tempDir;
+
+ private PurgeRegionsService service;
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+
+ when(iwm.isNetherGenerate(world)).thenReturn(false);
+ when(iwm.isNetherIslands(world)).thenReturn(false);
+ when(iwm.isEndGenerate(world)).thenReturn(false);
+ when(iwm.isEndIslands(world)).thenReturn(false);
+ when(iwm.getNetherWorld(world)).thenReturn(null);
+ when(iwm.getEndWorld(world)).thenReturn(null);
+
+ when(plugin.getAddonsManager()).thenReturn(addonsManager);
+ when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty());
+ when(plugin.getPlayers()).thenReturn(pm);
+
+ when(im.getIslandCache()).thenReturn(islandCache);
+ when(world.getWorldFolder()).thenReturn(tempDir.toFile());
+
+ service = new PurgeRegionsService(plugin);
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ /**
+ * A world with no island grid returns an empty deleted-sweep result
+ * rather than crashing.
+ */
+ @Test
+ void testScanDeletedNullGrid() {
+ when(islandCache.getIslandGrid(world)).thenReturn(null);
+
+ PurgeScanResult result = service.scanDeleted(world);
+ assertTrue(result.isEmpty());
+ assertEquals(0, result.days(), "days sentinel should be 0 for deleted sweep");
+ }
+
+ /**
+ * A world with only non-deletable islands yields no candidate regions.
+ */
+ @Test
+ void testScanDeletedNoDeletableIslands() {
+ Island active = mock(Island.class);
+ when(active.isDeletable()).thenReturn(false);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(active));
+ when(islandCache.getIslandGrid(world)).thenReturn(mock(IslandGrid.class));
+
+ PurgeScanResult result = service.scanDeleted(world);
+ assertTrue(result.isEmpty());
+ }
+
+ /**
+ * A single deletable island with no neighbours produces one candidate
+ * region matching its protection bounds.
+ */
+ @Test
+ void testScanDeletedLoneDeletableIsland() {
+ Island deletable = mock(Island.class);
+ when(deletable.getUniqueId()).thenReturn("del");
+ when(deletable.isDeletable()).thenReturn(true);
+ // Occupies r.0.0 (0..100 in X/Z)
+ when(deletable.getMinProtectedX()).thenReturn(0);
+ when(deletable.getMaxProtectedX()).thenReturn(100);
+ when(deletable.getMinProtectedZ()).thenReturn(0);
+ when(deletable.getMaxProtectedZ()).thenReturn(100);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(deletable));
+
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(List.of(new IslandData("del", 0, 0, 100)));
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("del")).thenReturn(Optional.of(deletable));
+
+ PurgeScanResult result = service.scanDeleted(world);
+ assertFalse(result.isEmpty());
+ assertEquals(1, result.deletableRegions().size());
+ assertEquals(0, result.days());
+ }
+
+ /**
+ * An island straddling two regions (X = 500..700 crosses the r.0/r.1
+ * boundary at X=512) produces two candidate region entries.
+ */
+ @Test
+ void testScanDeletedIslandStraddlesRegionBoundary() {
+ Island deletable = mock(Island.class);
+ when(deletable.getUniqueId()).thenReturn("del");
+ when(deletable.isDeletable()).thenReturn(true);
+ when(deletable.getMinProtectedX()).thenReturn(500);
+ when(deletable.getMaxProtectedX()).thenReturn(700);
+ when(deletable.getMinProtectedZ()).thenReturn(0);
+ when(deletable.getMaxProtectedZ()).thenReturn(100);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(deletable));
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(List.of(new IslandData("del", 500, 0, 200)));
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("del")).thenReturn(Optional.of(deletable));
+
+ PurgeScanResult result = service.scanDeleted(world);
+ assertEquals(2, result.deletableRegions().size(),
+ "Island straddling r.0.0 and r.1.0 should produce two candidate regions");
+ }
+
+ /**
+ * Strict filter: a region containing one deletable and one non-deletable
+ * island must be dropped.
+ */
+ @Test
+ void testScanDeletedStrictFilterDropsMixedRegion() {
+ Island deletable = mock(Island.class);
+ when(deletable.getUniqueId()).thenReturn("del");
+ when(deletable.isDeletable()).thenReturn(true);
+ when(deletable.getMinProtectedX()).thenReturn(0);
+ when(deletable.getMaxProtectedX()).thenReturn(100);
+ when(deletable.getMinProtectedZ()).thenReturn(0);
+ when(deletable.getMaxProtectedZ()).thenReturn(100);
+
+ Island active = mock(Island.class);
+ when(active.getUniqueId()).thenReturn("act");
+ when(active.isDeletable()).thenReturn(false);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(deletable, active));
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(List.of(
+ new IslandData("del", 0, 0, 100),
+ new IslandData("act", 200, 200, 100)));
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("del")).thenReturn(Optional.of(deletable));
+ when(im.getIslandById("act")).thenReturn(Optional.of(active));
+
+ PurgeScanResult result = service.scanDeleted(world);
+ assertTrue(result.isEmpty(), "Mixed region must be blocked by strict filter");
+ }
+
+ /**
+ * Missing island rows in the grid must not block the region — they're
+ * already gone and count as "no blocker".
+ */
+ @Test
+ void testScanDeletedMissingIslandRowDoesNotBlock() {
+ Island deletable = mock(Island.class);
+ when(deletable.getUniqueId()).thenReturn("del");
+ when(deletable.isDeletable()).thenReturn(true);
+ when(deletable.getMinProtectedX()).thenReturn(0);
+ when(deletable.getMaxProtectedX()).thenReturn(100);
+ when(deletable.getMinProtectedZ()).thenReturn(0);
+ when(deletable.getMaxProtectedZ()).thenReturn(100);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(deletable));
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(List.of(
+ new IslandData("del", 0, 0, 100),
+ new IslandData("ghost", 300, 300, 100)));
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("del")).thenReturn(Optional.of(deletable));
+ when(im.getIslandById("ghost")).thenReturn(Optional.empty());
+
+ PurgeScanResult result = service.scanDeleted(world);
+ assertFalse(result.isEmpty(), "Ghost island (no DB row) must not block the reap");
+ assertEquals(1, result.deletableRegions().size());
+ }
+
+ /**
+ * {@code delete} with a {@code days == 0} scan must bypass the freshness
+ * recheck — a region file touched seconds ago must still be reaped.
+ * This is the core of the deleted-sweep semantics.
+ */
+ @Test
+ void testDeleteWithZeroDaysBypassesFreshnessCheck() throws IOException {
+ Island deletable = mock(Island.class);
+ when(deletable.getUniqueId()).thenReturn("del");
+ when(deletable.isDeletable()).thenReturn(true);
+ when(deletable.getMemberSet()).thenReturn(ImmutableSet.of());
+ when(deletable.getMinProtectedX()).thenReturn(0);
+ when(deletable.getMaxProtectedX()).thenReturn(100);
+ when(deletable.getMinProtectedZ()).thenReturn(0);
+ when(deletable.getMaxProtectedZ()).thenReturn(100);
+
+ when(islandCache.getIslands(world)).thenReturn(List.of(deletable));
+ IslandGrid grid = mock(IslandGrid.class);
+ when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt()))
+ .thenReturn(List.of(new IslandData("del", 0, 0, 100)));
+ when(islandCache.getIslandGrid(world)).thenReturn(grid);
+ when(im.getIslandById("del")).thenReturn(Optional.of(deletable));
+ when(im.deleteIslandId("del")).thenReturn(true);
+ // deletePlayerFromWorldFolder iterates members (empty set here) so
+ // getIslands(World, UUID) is never reached — no stub needed.
+
+ // Create a fresh .mca file — timestamp is "now". The age sweep would
+ // skip this file; the deleted sweep must reap it anyway.
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Path regionFile = regionDir.resolve("r.0.0.mca");
+ byte[] data = new byte[8192];
+ int nowSeconds = (int) (System.currentTimeMillis() / 1000L);
+ for (int i = 0; i < 1024; i++) {
+ int offset = 4096 + i * 4;
+ data[offset] = (byte) (nowSeconds >> 24);
+ data[offset + 1] = (byte) (nowSeconds >> 16);
+ data[offset + 2] = (byte) (nowSeconds >> 8);
+ data[offset + 3] = (byte) nowSeconds;
+ }
+ Files.write(regionFile, data);
+
+ PurgeScanResult scan = service.scanDeleted(world);
+ assertFalse(scan.isEmpty());
+
+ boolean ok = service.delete(scan);
+ assertTrue(ok, "delete() should return true for a fresh-timestamp region under the deleted sweep");
+ assertFalse(regionFile.toFile().exists(),
+ "Fresh region file must be reaped when days == 0");
+ }
+
+ /**
+ * {@code evictChunks} must walk the full 32x32 chunk square inside each
+ * target region and call {@code unloadChunk(cx, cz, false)} on every chunk
+ * that is currently loaded. This is the fix for the bug where reaped
+ * region files were re-flushed by Paper's autosave because the in-memory
+ * chunks survived the disk delete.
+ */
+ @Test
+ void testEvictChunksUnloadsLoadedChunksInTargetRegion() {
+ // Build a scan result with a single target region at r.0.0 — chunks
+ // (0,0) .. (31,31). Mark only (5,7) and (10,12) as currently loaded
+ // so we can prove the call is gated on isChunkLoaded.
+ Map, Set> regions = new HashMap<>();
+ regions.put(new Pair<>(0, 0), Set.of("del"));
+ PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
+ when(world.isChunkLoaded(5, 7)).thenReturn(true);
+ when(world.isChunkLoaded(10, 12)).thenReturn(true);
+ when(world.unloadChunk(anyInt(), anyInt(), eq(false))).thenReturn(true);
+
+ service.evictChunks(scan);
+
+ // Sweep covers all 32*32 = 1024 chunk coordinates exactly once.
+ verify(world, times(1024)).isChunkLoaded(anyInt(), anyInt());
+ // Only the two loaded chunks were unloaded.
+ verify(world).unloadChunk(5, 7, false);
+ verify(world).unloadChunk(10, 12, false);
+ verify(world, times(2)).unloadChunk(anyInt(), anyInt(), eq(false));
+ }
+
+ /**
+ * Region coordinates must translate to chunk coordinates via {@code << 5}
+ * (each region holds 32×32 chunks). r.1.-1 → chunks (32..63, -32..-1).
+ */
+ @Test
+ void testEvictChunksUsesCorrectChunkCoordsForNonZeroRegion() {
+ Map, Set> regions = new HashMap<>();
+ regions.put(new Pair<>(1, -1), Set.of("del"));
+ PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
+ // The bottom-left corner of r.1.-1 is (32, -32); the top-right is (63, -1).
+ when(world.isChunkLoaded(32, -32)).thenReturn(true);
+ when(world.isChunkLoaded(63, -1)).thenReturn(true);
+ when(world.unloadChunk(anyInt(), anyInt(), eq(false))).thenReturn(true);
+
+ service.evictChunks(scan);
+
+ verify(world).unloadChunk(32, -32, false);
+ verify(world).unloadChunk(63, -1, false);
+ // Coordinates outside the region (e.g. (0,0)) must never be checked.
+ verify(world, never()).isChunkLoaded(0, 0);
+ verify(world, never()).isChunkLoaded(31, -1);
+ }
+
+ /**
+ * When an island's protection box extends beyond the reaped region(s)
+ * and one of its non-reaped regions still has an {@code r.X.Z.mca} file
+ * on disk, {@code delete} must not remove the island DB row —
+ * residual blocks exist with no other cleanup path. The next purge cycle
+ * will retry.
+ */
+ @Test
+ void testDeleteDefersDBRowWhenResidualRegionExists() throws IOException {
+ // Age sweep (days=30): island spans X=0..1000 (crosses r.0 and r.1)
+ // but only r.0.0 is in the scan — the strict filter blocked r.1.0
+ // because of an active neighbour. r.1.0.mca stays on disk.
+ Island spans = mock(Island.class);
+ when(spans.getUniqueId()).thenReturn("spans");
+ when(spans.isDeletable()).thenReturn(true);
+ when(spans.getMinProtectedX()).thenReturn(0);
+ when(spans.getMaxProtectedX()).thenReturn(1000);
+ when(spans.getMinProtectedZ()).thenReturn(0);
+ when(spans.getMaxProtectedZ()).thenReturn(100);
+ when(im.getIslandById("spans")).thenReturn(Optional.of(spans));
+
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Path reaped = regionDir.resolve("r.0.0.mca");
+ Path residual = regionDir.resolve("r.1.0.mca");
+ Files.write(reaped, new byte[0]);
+ Files.write(residual, new byte[0]);
+
+ Map, Set> regions = new HashMap<>();
+ regions.put(new Pair<>(0, 0), Set.of("spans"));
+ PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ boolean ok = service.delete(scan);
+ assertTrue(ok);
+ assertFalse(reaped.toFile().exists());
+ assertTrue(residual.toFile().exists());
+ // Age sweep: DB row must NOT be removed while residual region exists.
+ verify(im, never()).deleteIslandId(anyString());
+ verify(islandCache, never()).deleteIslandFromCache(anyString());
+ }
+
+ /**
+ * Age sweep (days > 0): when every region the island's bounds touch is
+ * absent from disk after the reap, the DB row must be removed immediately.
+ */
+ @Test
+ void testDeleteRemovesDBRowWhenAllRegionsGone() throws IOException {
+ Island tiny = mock(Island.class);
+ when(tiny.getUniqueId()).thenReturn("tiny");
+ when(tiny.isDeletable()).thenReturn(true);
+ when(tiny.getMemberSet()).thenReturn(ImmutableSet.of());
+ // Fits entirely in r.0.0
+ when(tiny.getMinProtectedX()).thenReturn(0);
+ when(tiny.getMaxProtectedX()).thenReturn(100);
+ when(tiny.getMinProtectedZ()).thenReturn(0);
+ when(tiny.getMaxProtectedZ()).thenReturn(100);
+ when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny));
+ when(im.deleteIslandId("tiny")).thenReturn(true);
+
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]);
+
+ Map, Set> regions = new HashMap<>();
+ regions.put(new Pair<>(0, 0), Set.of("tiny"));
+ PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ boolean ok = service.delete(scan);
+ assertTrue(ok);
+ verify(im, times(1)).deleteIslandId("tiny");
+ verify(islandCache, times(1)).deleteIslandFromCache("tiny");
+ }
+
+ /**
+ * Age sweep: a mixed batch where one island is fully reaped and another
+ * has a residual region — only the fully-reaped island's DB row is removed.
+ */
+ @Test
+ void testDeleteDefersOnlySomeIslandsInMixedBatch() throws IOException {
+ Island tiny = mock(Island.class);
+ when(tiny.getUniqueId()).thenReturn("tiny");
+ when(tiny.isDeletable()).thenReturn(true);
+ when(tiny.getMemberSet()).thenReturn(ImmutableSet.of());
+ when(tiny.getMinProtectedX()).thenReturn(0);
+ when(tiny.getMaxProtectedX()).thenReturn(100);
+ when(tiny.getMinProtectedZ()).thenReturn(0);
+ when(tiny.getMaxProtectedZ()).thenReturn(100);
+ when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny));
+ when(im.deleteIslandId("tiny")).thenReturn(true);
+
+ Island spans = mock(Island.class);
+ when(spans.getUniqueId()).thenReturn("spans");
+ when(spans.isDeletable()).thenReturn(true);
+ when(spans.getMinProtectedX()).thenReturn(0);
+ when(spans.getMaxProtectedX()).thenReturn(1000);
+ when(spans.getMinProtectedZ()).thenReturn(0);
+ when(spans.getMaxProtectedZ()).thenReturn(100);
+ when(im.getIslandById("spans")).thenReturn(Optional.of(spans));
+
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]);
+ Files.write(regionDir.resolve("r.1.0.mca"), new byte[0]); // residual for "spans"
+
+ Map, Set> regions = new HashMap<>();
+ regions.put(new Pair<>(0, 0), Set.of("tiny", "spans"));
+ PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ boolean ok = service.delete(scan);
+ assertTrue(ok);
+ verify(im, times(1)).deleteIslandId("tiny");
+ verify(im, never()).deleteIslandId("spans");
+ verify(islandCache, times(1)).deleteIslandFromCache("tiny");
+ verify(islandCache, never()).deleteIslandFromCache("spans");
+ }
+
+ /**
+ * An empty scan must short-circuit — no chunk-loaded probes at all.
+ */
+ @Test
+ void testEvictChunksEmptyScanIsNoop() {
+ PurgeScanResult scan = new PurgeScanResult(world, 0, new HashMap<>(), false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ service.evictChunks(scan);
+
+ verify(world, never()).isChunkLoaded(anyInt(), anyInt());
+ verify(world, never()).unloadChunk(anyInt(), anyInt(), eq(false));
+ }
+
+ // ------------------------------------------------------------------
+ // Deferred deletion (deleted sweep, days == 0)
+ // ------------------------------------------------------------------
+
+ /**
+ * Deleted sweep (days=0): DB row removal must be deferred to shutdown,
+ * not executed immediately. The island ID goes into pendingDeletions.
+ */
+ @Test
+ void testDeletedSweepDefersDBDeletionToShutdown() throws IOException {
+ Island deletable = mock(Island.class);
+ when(deletable.getUniqueId()).thenReturn("del1");
+ when(deletable.isDeletable()).thenReturn(true);
+ when(deletable.getMinProtectedX()).thenReturn(0);
+ when(deletable.getMaxProtectedX()).thenReturn(100);
+ when(deletable.getMinProtectedZ()).thenReturn(0);
+ when(deletable.getMaxProtectedZ()).thenReturn(100);
+ when(im.getIslandById("del1")).thenReturn(Optional.of(deletable));
+
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]);
+
+ Map, Set> regions = new HashMap<>();
+ regions.put(new Pair<>(0, 0), Set.of("del1"));
+ PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ boolean ok = service.delete(scan);
+ assertTrue(ok);
+ // DB row must NOT be removed immediately — deferred to shutdown.
+ verify(im, never()).deleteIslandId(anyString());
+ verify(islandCache, never()).deleteIslandFromCache(anyString());
+ // Island ID must be in pending set.
+ assertTrue(service.getPendingDeletions().contains("del1"));
+ }
+
+ /**
+ * {@link PurgeRegionsService#flushPendingDeletions()} must process all
+ * deferred island IDs and clear the pending set.
+ */
+ @Test
+ void testFlushPendingDeletionsRemovesIslands() throws IOException {
+ Island del1 = mock(Island.class);
+ when(del1.getUniqueId()).thenReturn("del1");
+ when(del1.isDeletable()).thenReturn(true);
+ when(del1.getMinProtectedX()).thenReturn(0);
+ when(del1.getMaxProtectedX()).thenReturn(100);
+ when(del1.getMinProtectedZ()).thenReturn(0);
+ when(del1.getMaxProtectedZ()).thenReturn(100);
+ when(im.getIslandById("del1")).thenReturn(Optional.of(del1));
+ when(im.deleteIslandId("del1")).thenReturn(true);
+
+ Island del2 = mock(Island.class);
+ when(del2.getUniqueId()).thenReturn("del2");
+ when(del2.isDeletable()).thenReturn(true);
+ when(del2.getMinProtectedX()).thenReturn(512);
+ when(del2.getMaxProtectedX()).thenReturn(612);
+ when(del2.getMinProtectedZ()).thenReturn(0);
+ when(del2.getMaxProtectedZ()).thenReturn(100);
+ when(im.getIslandById("del2")).thenReturn(Optional.of(del2));
+ when(im.deleteIslandId("del2")).thenReturn(true);
+
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]);
+ Files.write(regionDir.resolve("r.1.0.mca"), new byte[0]);
+
+ Map, Set> regions = new HashMap<>();
+ regions.put(new Pair<>(0, 0), Set.of("del1"));
+ regions.put(new Pair<>(1, 0), Set.of("del2"));
+ PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ service.delete(scan);
+ // Both deferred — not yet deleted.
+ verify(im, never()).deleteIslandId(anyString());
+ assertEquals(2, service.getPendingDeletions().size());
+
+ // Now flush (simulates plugin shutdown).
+ service.flushPendingDeletions();
+ verify(im, times(1)).deleteIslandId("del1");
+ verify(im, times(1)).deleteIslandId("del2");
+ verify(islandCache, times(1)).deleteIslandFromCache("del1");
+ verify(islandCache, times(1)).deleteIslandFromCache("del2");
+ assertTrue(service.getPendingDeletions().isEmpty());
+ }
+
+ /**
+ * Age sweep (days > 0) must still delete DB rows immediately when all
+ * regions are gone from disk — no deferral to shutdown.
+ */
+ @Test
+ void testAgeSweepStillDeletesImmediately() throws IOException {
+ Island tiny = mock(Island.class);
+ when(tiny.getUniqueId()).thenReturn("tiny");
+ when(tiny.isDeletable()).thenReturn(true);
+ when(tiny.getMemberSet()).thenReturn(ImmutableSet.of());
+ when(tiny.getMinProtectedX()).thenReturn(0);
+ when(tiny.getMaxProtectedX()).thenReturn(100);
+ when(tiny.getMinProtectedZ()).thenReturn(0);
+ when(tiny.getMaxProtectedZ()).thenReturn(100);
+ when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny));
+ when(im.deleteIslandId("tiny")).thenReturn(true);
+
+ Path regionDir = Files.createDirectories(tempDir.resolve("region"));
+ Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]);
+
+ Map, Set> regions = new HashMap<>();
+ regions.put(new Pair<>(0, 0), Set.of("tiny"));
+ PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false,
+ new FilterStats(0, 0, 0, 0));
+
+ service.delete(scan);
+ // Age sweep: immediate deletion, not deferred.
+ verify(im, times(1)).deleteIslandId("tiny");
+ assertTrue(service.getPendingDeletions().isEmpty());
+ }
+}
diff --git a/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java b/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java
index 7bca07f25..8e11f9144 100644
--- a/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java
+++ b/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java
@@ -17,6 +17,7 @@
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -80,6 +81,7 @@ public void setUp() throws Exception {
when(bb.getUniqueId()).thenReturn("test");
when(bb.getDisplayName()).thenReturn("test");
when(bb.getIcon()).thenReturn(Material.STONE);
+ when(bb.getIconItemStack()).thenReturn(new ItemStack(Material.STONE));
when(bb.getDescription()).thenReturn(Collections.singletonList("A description"));
when(bb.getCommands()).thenReturn(Collections.emptyList());
when(bb.getSlot()).thenReturn(5);
@@ -87,12 +89,14 @@ public void setUp() throws Exception {
when(bb2.getUniqueId()).thenReturn("test2");
when(bb2.getDisplayName()).thenReturn("test2");
when(bb2.getIcon()).thenReturn(Material.ACACIA_BOAT);
+ when(bb2.getIconItemStack()).thenReturn(new ItemStack(Material.ACACIA_BOAT));
when(bb2.getDescription()).thenReturn(Collections.singletonList("A description 2"));
when(bb2.getSlot()).thenReturn(-5);
// Too large slot for panel
when(bb3.getUniqueId()).thenReturn("test3");
when(bb3.getDisplayName()).thenReturn("test3");
when(bb3.getIcon()).thenReturn(Material.BAKED_POTATO);
+ when(bb3.getIconItemStack()).thenReturn(new ItemStack(Material.BAKED_POTATO));
when(bb3.getDescription()).thenReturn(Collections.singletonList("A description 3"));
when(bb3.getSlot()).thenReturn(65);
@@ -167,6 +171,7 @@ void testGetBlueprintItem() {
void testGetBlueprintItemWithDisplayNameAndIcon() {
when(blueprint.getDisplayName()).thenReturn("Display Name");
when(blueprint.getIcon()).thenReturn(Material.BEACON);
+ when(blueprint.getIconItemStack()).thenReturn(new ItemStack(Material.BEACON));
PanelItem pi = bmp.getBlueprintItem(addon, 0, bb, blueprint);
assertEquals("Display Name", pi.getName());
assertEquals(Material.BEACON, pi.getItem().getType());
@@ -180,6 +185,7 @@ void testGetBlueprintItemWithDisplayNameAndIcon() {
void testGetBlueprintItemWithDisplayNameAndIconInWorldSlot() {
when(blueprint.getDisplayName()).thenReturn("Display Name");
when(blueprint.getIcon()).thenReturn(Material.BEACON);
+ when(blueprint.getIconItemStack()).thenReturn(new ItemStack(Material.BEACON));
PanelItem pi = bmp.getBlueprintItem(addon, 5, bb, blueprint);
assertEquals("Display Name", pi.getName());
assertEquals(Material.BEACON, pi.getItem().getType());
@@ -224,6 +230,7 @@ void testOpenPanelWithManyBundles() {
when(bundle.getUniqueId()).thenReturn("bundle" + i);
when(bundle.getDisplayName()).thenReturn("Bundle " + String.format("%02d", i));
when(bundle.getIcon()).thenReturn(Material.STONE);
+ when(bundle.getIconItemStack()).thenReturn(new ItemStack(Material.STONE));
when(bundle.getDescription()).thenReturn(Collections.singletonList("Desc"));
map.put("bundle" + i, bundle);
}
diff --git a/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java
new file mode 100644
index 000000000..5a03522b5
--- /dev/null
+++ b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java
@@ -0,0 +1,206 @@
+package world.bentobox.bentobox.panels;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.AbstractMap;
+
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+import world.bentobox.bentobox.CommonTestSetup;
+import world.bentobox.bentobox.api.addons.GameModeAddon;
+import world.bentobox.bentobox.api.user.User;
+import world.bentobox.bentobox.blueprints.Blueprint;
+import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBundle;
+import world.bentobox.bentobox.managers.BlueprintsManager;
+
+/**
+ * Tests for {@link IconChanger}.
+ */
+class IconChangerTest extends CommonTestSetup {
+
+ @Mock
+ private GameModeAddon addon;
+ @Mock
+ private BlueprintManagementPanel bmp;
+ @Mock
+ private BlueprintBundle bb;
+ @Mock
+ private BlueprintsManager bpManager;
+ @Mock
+ private User user;
+ @Mock
+ private InventoryClickEvent event;
+
+ private IconChanger iconChanger;
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+ when(plugin.getBlueprintsManager()).thenReturn(bpManager);
+ when(bmp.getSelected()).thenReturn(null); // no blueprint selected by default
+ when(user.getPlayer()).thenReturn(mockPlayer);
+ when(user.getLocation()).thenReturn(location);
+ iconChanger = new IconChanger(plugin, addon, bmp, bb);
+ }
+
+ @Override
+ @AfterEach
+ public void tearDown() throws Exception {
+ super.tearDown();
+ }
+
+ /**
+ * Clicking a plain item (no item model) in the player inventory sets the bundle icon by Material.
+ */
+ @Test
+ void testOnInventoryClickBundlePlainMaterial() {
+ ItemStack item = mock(ItemStack.class);
+ when(item.getType()).thenReturn(Material.STONE);
+ ItemMeta meta = mock(ItemMeta.class);
+ when(meta.hasItemModel()).thenReturn(false);
+ when(item.getItemMeta()).thenReturn(meta);
+
+ when(event.getCurrentItem()).thenReturn(item);
+ when(event.getRawSlot()).thenReturn(45); // player inventory slot
+
+ iconChanger.onInventoryClick(user, event);
+
+ verify(bb).setIcon(Material.STONE);
+ verify(bb, never()).setIcon(any(String.class));
+ verify(bpManager).saveBlueprintBundle(addon, bb);
+ }
+
+ /**
+ * Clicking an item that has a custom item model sets the bundle icon by model key string.
+ */
+ @Test
+ void testOnInventoryClickBundleItemModel() {
+ ItemStack item = mock(ItemStack.class);
+ when(item.getType()).thenReturn(Material.PAPER);
+ ItemMeta meta = mock(ItemMeta.class);
+ NamespacedKey modelKey = new NamespacedKey("myserver", "island_tropical");
+ when(meta.hasItemModel()).thenReturn(true);
+ when(meta.getItemModel()).thenReturn(modelKey);
+ when(item.getItemMeta()).thenReturn(meta);
+
+ when(event.getCurrentItem()).thenReturn(item);
+ when(event.getRawSlot()).thenReturn(45);
+
+ iconChanger.onInventoryClick(user, event);
+
+ verify(bb).setIcon("myserver:island_tropical");
+ verify(bb, never()).setIcon(any(Material.class));
+ verify(bpManager).saveBlueprintBundle(addon, bb);
+ }
+
+ /**
+ * Clicking a plain item when a blueprint is selected changes the blueprint icon by Material.
+ */
+ @Test
+ void testOnInventoryClickBlueprintSelected() {
+ Blueprint bp = mock(Blueprint.class);
+ when(bmp.getSelected()).thenReturn(new AbstractMap.SimpleEntry<>(1, bp));
+
+ ItemStack item = mock(ItemStack.class);
+ when(item.getType()).thenReturn(Material.BEACON);
+ ItemMeta meta = mock(ItemMeta.class);
+ when(meta.hasItemModel()).thenReturn(false);
+ when(item.getItemMeta()).thenReturn(meta);
+ when(event.getCurrentItem()).thenReturn(item);
+ when(event.getRawSlot()).thenReturn(45);
+
+ iconChanger.onInventoryClick(user, event);
+
+ verify(bp).setIcon(Material.BEACON);
+ verify(bpManager).saveBlueprint(addon, bp);
+ verify(bb, never()).setIcon(any(Material.class));
+ verify(bb, never()).setIcon(any(String.class));
+ }
+
+ /**
+ * Clicking a custom item model item when a blueprint is selected stores the model key string.
+ */
+ @Test
+ void testOnInventoryClickBlueprintItemModel() {
+ Blueprint bp = mock(Blueprint.class);
+ when(bmp.getSelected()).thenReturn(new AbstractMap.SimpleEntry<>(1, bp));
+
+ ItemStack item = mock(ItemStack.class);
+ when(item.getType()).thenReturn(Material.PAPER);
+ ItemMeta meta = mock(ItemMeta.class);
+ NamespacedKey modelKey = new NamespacedKey("myserver", "island_tropical");
+ when(meta.hasItemModel()).thenReturn(true);
+ when(meta.getItemModel()).thenReturn(modelKey);
+ when(item.getItemMeta()).thenReturn(meta);
+ when(event.getCurrentItem()).thenReturn(item);
+ when(event.getRawSlot()).thenReturn(45);
+
+ iconChanger.onInventoryClick(user, event);
+
+ verify(bp).setIcon("myserver:island_tropical");
+ verify(bp, never()).setIcon(any(Material.class));
+ verify(bpManager).saveBlueprint(addon, bp);
+ }
+
+ /**
+ * Clicking inside the panel (slot ≤ 44) does nothing.
+ */
+ @Test
+ void testOnInventoryClickInsidePanel() {
+ ItemStack item = mock(ItemStack.class);
+ when(item.getType()).thenReturn(Material.STONE);
+ when(event.getCurrentItem()).thenReturn(item);
+ when(event.getRawSlot()).thenReturn(10); // inside the GUI
+
+ iconChanger.onInventoryClick(user, event);
+
+ verify(bb, never()).setIcon(any(Material.class));
+ verify(bb, never()).setIcon(any(String.class));
+ verify(bpManager, never()).saveBlueprintBundle(any(), any());
+ }
+
+ /**
+ * Clicking an AIR slot does nothing.
+ */
+ @Test
+ void testOnInventoryClickAirItem() {
+ ItemStack item = mock(ItemStack.class);
+ when(item.getType()).thenReturn(Material.AIR);
+ when(event.getCurrentItem()).thenReturn(item);
+ when(event.getRawSlot()).thenReturn(45);
+
+ iconChanger.onInventoryClick(user, event);
+
+ verify(bb, never()).setIcon(any(Material.class));
+ verify(bb, never()).setIcon(any(String.class));
+ verify(bpManager, never()).saveBlueprintBundle(any(), any());
+ }
+
+ /**
+ * Clicking a null item does nothing.
+ */
+ @Test
+ void testOnInventoryClickNullItem() {
+ when(event.getCurrentItem()).thenReturn(null);
+ when(event.getRawSlot()).thenReturn(45);
+
+ iconChanger.onInventoryClick(user, event);
+
+ verify(bb, never()).setIcon(any(Material.class));
+ verify(bb, never()).setIcon(any(String.class));
+ verify(bpManager, never()).saveBlueprintBundle(any(), any());
+ }
+}
diff --git a/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java b/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java
index d79fdc053..26785bca3 100644
--- a/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java
+++ b/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java
@@ -20,6 +20,7 @@
import org.bukkit.Material;
import org.bukkit.World;
+import org.bukkit.inventory.ItemStack;
import org.bukkit.entity.Player;
import org.bukkit.plugin.PluginDescriptionFile;
import org.junit.jupiter.api.AfterEach;
@@ -128,6 +129,7 @@ public void setUp() throws Exception {
when(bundle1.getUniqueId()).thenReturn("default");
when(bundle1.getDisplayName()).thenReturn("Default");
when(bundle1.getIcon()).thenReturn(Material.GRASS_BLOCK);
+ when(bundle1.getIconItemStack()).thenReturn(new ItemStack(Material.GRASS_BLOCK));
when(bundle1.getDescription()).thenReturn(Collections.singletonList("Default island"));
when(bundle1.getSlot()).thenReturn(0);
when(bundle1.isRequirePermission()).thenReturn(false);
@@ -138,6 +140,7 @@ public void setUp() throws Exception {
when(bundle2.getUniqueId()).thenReturn("nether");
when(bundle2.getDisplayName()).thenReturn("Nether");
when(bundle2.getIcon()).thenReturn(Material.NETHERRACK);
+ when(bundle2.getIconItemStack()).thenReturn(new ItemStack(Material.NETHERRACK));
when(bundle2.getDescription()).thenReturn(Collections.singletonList("Nether island"));
when(bundle2.getSlot()).thenReturn(1);
when(bundle2.isRequirePermission()).thenReturn(false);
@@ -148,6 +151,7 @@ public void setUp() throws Exception {
when(bundle3.getUniqueId()).thenReturn("end");
when(bundle3.getDisplayName()).thenReturn("End");
when(bundle3.getIcon()).thenReturn(Material.END_STONE);
+ when(bundle3.getIconItemStack()).thenReturn(new ItemStack(Material.END_STONE));
when(bundle3.getDescription()).thenReturn(Collections.singletonList("End island"));
when(bundle3.getSlot()).thenReturn(2);
when(bundle3.isRequirePermission()).thenReturn(false);