diff --git a/build.gradle.kts b/build.gradle.kts index 3c4a888d8..d6382351a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,7 @@ paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArt group = "world.bentobox" // From // Base properties from -val buildVersion = "3.14.1" +val buildVersion = "4.0.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..c23eb54e4 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(); + } } @@ -566,6 +586,23 @@ public ChunkPregenManager getChunkPregenManager() { return chunkPregenManager; } + /** + * @return the shared {@link PurgeRegionsService} used by the purge + * regions command and the housekeeping scheduler. + * @since 3.14.0 + */ + public PurgeRegionsService getPurgeRegionsService() { + return purgeRegionsService; + } + + /** + * @return the {@link HousekeepingManager}, or {@code null} if not yet initialized. + * @since 3.14.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..5c322cfcc 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -319,27 +319,45 @@ 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 no longer physically") + @ConfigComment("deleted. Instead it is orphaned (marked deletable) and the region files on") + @ConfigComment("disk are reclaimed later by a scheduled purge. This avoids the brittle") + @ConfigComment("chunk-copy mechanism that required pristine seed worlds.") + @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, unowned-but-not-deletable islands are all") + @ConfigComment("skipped) but is destructive by design. Default is OFF.") + @ConfigComment("Enable the scheduled housekeeping task.") + @ConfigEntry(path = "island.deletion.housekeeping.enabled", since = "3.14.0") + private boolean housekeepingEnabled = false; + + @ConfigComment("How often the housekeeping task runs, in days.") + @ConfigEntry(path = "island.deletion.housekeeping.interval-days", since = "3.14.0") + private int housekeepingIntervalDays = 30; + + @ConfigComment("Minimum age (in days) of region files considered for purge. Passed to the") + @ConfigComment("same scanner the /bbox purge regions command uses.") + @ConfigEntry(path = "island.deletion.housekeeping.region-age-days", since = "3.14.0") + private int housekeepingRegionAgeDays = 60; + + @ConfigComment("How often the deleted-islands sweep runs, in hours. This reaps region") + @ConfigComment("files for any island already flagged as deletable (e.g. from /is reset)") + @ConfigComment("and is independent of the age-based sweep above. Set to 0 to disable") + @ConfigComment("the deleted sweep while leaving the age sweep running.") + @ConfigEntry(path = "island.deletion.housekeeping.deleted-interval-hours", since = "3.14.0") + private int housekeepingDeletedIntervalHours = 24; + // Chunk pre-generation settings @ConfigComment("") @ConfigComment("Chunk pre-generation settings.") @@ -826,28 +844,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 +1010,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,11 +1023,81 @@ 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; } + /** + * @return whether the periodic housekeeping task (auto-purge of unused + * region files) is enabled. + * @since 3.14.0 + */ + public boolean isHousekeepingEnabled() { + return housekeepingEnabled; + } + + /** + * @param housekeepingEnabled whether the periodic housekeeping task is enabled. + * @since 3.14.0 + */ + public void setHousekeepingEnabled(boolean housekeepingEnabled) { + this.housekeepingEnabled = housekeepingEnabled; + } + + /** + * @return how often the housekeeping task runs, in days. + * @since 3.14.0 + */ + public int getHousekeepingIntervalDays() { + return housekeepingIntervalDays; + } + + /** + * @param housekeepingIntervalDays how often the housekeeping task runs, in days. + * @since 3.14.0 + */ + public void setHousekeepingIntervalDays(int housekeepingIntervalDays) { + this.housekeepingIntervalDays = housekeepingIntervalDays; + } + + /** + * @return minimum age (in days) of region files considered for auto-purge. + * @since 3.14.0 + */ + public int getHousekeepingRegionAgeDays() { + return housekeepingRegionAgeDays; + } + + /** + * @param housekeepingRegionAgeDays minimum age (in days) of region files + * considered for auto-purge. + * @since 3.14.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.14.0 + */ + public int getHousekeepingDeletedIntervalHours() { + return housekeepingDeletedIntervalHours; + } + + /** + * @param housekeepingDeletedIntervalHours how often the deleted sweep runs + * in hours. {@code 0} disables it. + * @since 3.14.0 + */ + public void setHousekeepingDeletedIntervalHours(int housekeepingDeletedIntervalHours) { + this.housekeepingDeletedIntervalHours = housekeepingDeletedIntervalHours; + } + /** * @return whether chunk pre-generation is enabled * @since 3.14.0 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/AdminPurgeAgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java new file mode 100644 index 000000000..d4aaaae69 --- /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.14.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..77ca92b27 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 @@ -12,7 +12,6 @@ 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; @@ -48,6 +47,8 @@ public void setup() { new AdminPurgeUnownedCommand(this); new AdminPurgeProtectCommand(this); new AdminPurgeRegionsCommand(this); + new AdminPurgeAgeRegionsCommand(this); + new AdminPurgeDeletedCommand(this); } @Override @@ -89,8 +90,7 @@ public boolean execute(User user, String label, List args) { 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()) { + if (islandSet.size() > TOO_MANY) { user.sendMessage("commands.admin.purge.too-many"); // Give warning } if (!islandSet.isEmpty()) { @@ -130,12 +130,10 @@ private void deleteIsland() { // 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 + // Log at 1%, 5%, and every multiple of 5% thereafter + if (roundedPercentage > 0 && (roundedPercentage == 1 || roundedPercentage % 5 == 0) - && !loggedTiers.contains(roundedPercentage))) { - - // Log the message and add the tier to the logged set + && !loggedTiers.contains(roundedPercentage)) { getPlugin().log(count + " islands purged out of " + getPurgeableIslandsCount() + " (" + percentageStr + " %)"); loggedTiers.add(roundedPercentage); 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..349516b4f --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java @@ -0,0 +1,144 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +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.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 AdminPurgeRegionsCommand} 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.14.0 + */ +public class AdminPurgeDeletedCommand extends CompositeCommand implements Listener { + + private static final String NONE_FOUND = "commands.admin.purge.none-found"; + + private volatile boolean inPurge; + private boolean toBeConfirmed; + private User user; + private PurgeScanResult lastScan; + + public AdminPurgeDeletedCommand(CompositeCommand parent) { + super(parent, "deleted"); + getAddon().registerListener(this); + } + + @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 canExecute(User user, String label, List args) { + if (inPurge) { + user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); + return false; + } + return true; + } + + @Override + public boolean execute(User user, String label, List args) { + this.user = user; + if (!args.isEmpty() && args.getFirst().equalsIgnoreCase("confirm") + && toBeConfirmed && this.user.equals(user)) { + return deleteEverything(); + } + toBeConfirmed = false; + + user.sendMessage("commands.admin.purge.scanning"); + // Save all worlds to flush in-memory chunk state before scanning. + getPlugin().log("Purge deleted: saving all worlds before scanning..."); + Bukkit.getWorlds().forEach(World::save); + getPlugin().log("Purge deleted: world save complete"); + + inPurge = true; + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + try { + PurgeRegionsService service = getPlugin().getPurgeRegionsService(); + lastScan = service.scanDeleted(getWorld()); + displayResultsAndPrompt(lastScan); + } finally { + inPurge = false; + } + }); + return true; + } + + private boolean deleteEverything() { + if (lastScan == null || lastScan.isEmpty()) { + user.sendMessage(NONE_FOUND); + return false; + } + PurgeScanResult scan = lastScan; + lastScan = null; + toBeConfirmed = false; + getPlugin().log("Purge deleted: saving all worlds before deleting region files..."); + Bukkit.getWorlds().forEach(World::save); + // 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); + getPlugin().log("Purge deleted: world save complete, dispatching deletion"); + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + boolean ok = getPlugin().getPurgeRegionsService().delete(scan); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + if (ok) { + user.sendMessage("commands.admin.purge.deleted.deferred"); + } else { + getPlugin().log("Purge deleted: failed to delete one or more region files after a non-empty scan"); + 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()); + + uniqueIslands.forEach(island -> + getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + + " in world " + getWorld().getName() + " will be reaped")); + + 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())); + user.sendMessage("commands.admin.purge.deleted.confirm", + TextVariables.LABEL, this.getLabel()); + this.toBeConfirmed = true; + }); + } + } +} 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 index 294fe17a4..e69cd444b 100644 --- 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 @@ -1,23 +1,11 @@ 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; @@ -28,40 +16,32 @@ 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.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; import world.bentobox.bentobox.util.Pair; import world.bentobox.bentobox.util.Util; -import world.bentobox.level.Level; +/** + * Admin command to scan and delete old region files in the gamemode world. + * + *

Heavy lifting (scanning, filtering, deletion) is delegated to + * {@link PurgeRegionsService}. This command owns the two-step confirmation + * flow and the per-island display messages. + */ 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; + private PurgeScanResult lastScan; 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 @@ -79,7 +59,6 @@ public boolean canExecute(User user, String label, List args) { return false; } if (args.isEmpty()) { - // Show help showHelp(this, user); return false; } @@ -92,421 +71,69 @@ public boolean execute(User user, String label, List args) { if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed && this.user.equals(user)) { return deleteEverything(); } - /* - * This part does the searching for region files - */ - // Clear tbc toBeConfirmed = false; + 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; } - + user.sendMessage("commands.admin.purge.scanning"); // Save all worlds to update any region files + getPlugin().log("Purge: saving all worlds before scanning region files..."); Bukkit.getWorlds().forEach(World::save); - - // Find the potential islands - Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), ()-> findIslands(getWorld(), days)); + getPlugin().log("Purge: world save complete"); + + inPurge = true; + final int finalDays = days; + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + try { + PurgeRegionsService service = getPlugin().getPurgeRegionsService(); + lastScan = service.scan(getWorld(), finalDays); + displayResultsAndPrompt(lastScan); + } finally { + inPurge = false; + } + }); return true; } - private boolean deleteEverything() { - if (deleteableRegions.isEmpty()) { - user.sendMessage(NONE_FOUND); // Should never happen + if (lastScan == null || lastScan.isEmpty()) { + user.sendMessage(NONE_FOUND); return false; } - // Save the worlds + PurgeScanResult scan = lastScan; + lastScan = null; + toBeConfirmed = false; + // Flush in-memory chunk state on the main thread before the async + // delete — World.save() is not safe to call off-main. + getPlugin().log("Purge: saving all worlds before deleting region files..."); 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" ); + getPlugin().log("Purge: world save complete, dispatching deletion"); + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + boolean ok = getPlugin().getPurgeRegionsService().delete(scan); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + if (ok) { + user.sendMessage("general.success"); + } else { + getPlugin().log("Purge: failed to delete one or more region files"); + user.sendMessage("commands.admin.purge.failed"); } - } - } - - 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() + private void displayResultsAndPrompt(PurgeScanResult scan) { + Set uniqueIslands = scan.deletableRegions().values().stream() .flatMap(Set::stream) .map(getPlugin().getIslands()::getIslandById) .flatMap(Optional::stream) @@ -514,16 +141,18 @@ private void displayResultsAndPrompt() { uniqueIslands.forEach(this::displayIsland); - deleteableRegions.entrySet().stream() + scan.deletableRegions().entrySet().stream() .filter(e -> e.getValue().isEmpty()) .forEach(e -> displayEmptyRegion(e.getKey())); - if (deleteableRegions.isEmpty()) { + 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())); - user.sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, this.getLabel()); + 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; }); @@ -531,268 +160,35 @@ private void displayResultsAndPrompt() { } 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); + 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); + 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() + 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())) + + " 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)"); + 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 - + .withZone(ZoneId.systemDefault()); 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/listeners/flags/protection/LockAndBanListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java index 50382e323..06d20907b 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; @@ -37,6 +38,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 +128,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 +194,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(i -> i.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/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java new file mode 100644 index 000000000..d79c22537 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -0,0 +1,364 @@ +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.TimeUnit; + +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.14.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 (enabled=" + + plugin.getSettings().isHousekeepingEnabled() + + ", age-interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + + ", region-age=" + plugin.getSettings().getHousekeepingRegionAgeDays() + "d" + + ", deleted-interval=" + plugin.getSettings().getHousekeepingDeletedIntervalHours() + "h" + + ", 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; + } + if (!plugin.getSettings().isHousekeepingEnabled()) { + return; + } + long now = System.currentTimeMillis(); + boolean ageDue = isAgeCycleDue(now); + boolean deletedDue = 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.join(); + plugin.log("Housekeeping: world save complete"); + return true; + } catch (Exception e) { + plugin.logError("Housekeeping: world save failed: " + e.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..6de4b588b 100644 --- a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java @@ -305,26 +305,46 @@ 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.14.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); + } + /** * 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..089d968e9 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -0,0 +1,1051 @@ +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.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. + * + *

Extracted from {@code AdminPurgeRegionsCommand} so the command and the + * scheduler can share a single code path for scanning, filtering, and + * deleting region files across the overworld + optional nether/end + * dimensions. + * + * @since 3.14.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.14.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()); + plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); + if (plugin.getIslands().deleteIslandId(islandID)) { + plugin.log("Island ID " + islandID + " deleted from cache and database"); + islandsRemoved++; + } + } + } + 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) { + plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); + if (plugin.getIslands().deleteIslandId(islandID)) { + count++; + } + } + 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/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/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..88fa8c416 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -106,11 +106,7 @@ commands: description: purge [prefix_Islands] abandoned for more than [days] 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. + too-many: 'This is a lot of [prefix_Islands]. The purge will soft-delete them and housekeeping will clean up the region files on schedule.' 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 @@ -124,10 +120,20 @@ commands: 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.' + failed: 'Purge completed with errors. Some region files could not be deleted. Check the server log for details.' regions: parameters: '[days]' description: 'purge islands by deleting old region files' confirm: 'Type /[label] purge regions confirm to start purging' + age-regions: + parameters: '[days]' + 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!' @@ -276,6 +282,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 +1758,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/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..c2789d9be 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 @@ -133,7 +133,7 @@ void testSetup() { assertFalse(apc.isOnlyPlayer()); assertEquals("commands.admin.purge.parameters", apc.getParameters()); assertEquals("commands.admin.purge.description", apc.getDescription()); - assertEquals(6, apc.getSubCommands().size()); + assertEquals(8, apc.getSubCommands().size()); } 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..2817e63fc --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java @@ -0,0 +1,377 @@ +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 registered as a listener both during AdminPurgeCommand.setup() + * and again when constructed directly in the test. + */ + @Test + void testConstructor() { + verify(addon, times(2)).registerListener(any(AdminPurgeDeletedCommand.class)); + } + + @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 index b27e00563..97d02b579 100644 --- 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 @@ -42,6 +42,7 @@ 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; @@ -123,6 +124,10 @@ public void setUp() throws Exception { when(plugin.getAddonsManager()).thenReturn(addonsManager); when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); + // Real PurgeRegionsService wired over the mocked plugin — exercises + // the extracted scan/filter/delete logic exactly as the command does. + when(plugin.getPurgeRegionsService()).thenReturn(new PurgeRegionsService(plugin)); + // Create commands apc = new AdminPurgeCommand(ac); aprc = new AdminPurgeRegionsCommand(apc); @@ -513,4 +518,33 @@ void testExecuteConfirmDeletesPlayerData() throws IOException { verify(im).deleteIslandId("island-deletable"); assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted"); } + + /** + * Regression for the async {@code World.save()} crash hit on 26.1.1 Paper: + * {@code PurgeRegionsService.delete()} must not call + * {@code Bukkit.getWorlds().forEach(World::save)} because it runs on an + * async worker, and {@code World.save()} is main-thread-only. The world + * save must happen on the main thread *before* delete() is dispatched. + * + *

We call the service's {@code scan} + {@code delete} directly (as + * the async task would), then assert that {@code Bukkit.getWorlds()} + * was never invoked at all by the service — neither the scan nor the + * delete needs it. + */ + @Test + void testServiceDoesNotCallBukkitGetWorlds() throws IOException { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + // Create an old empty region file the scan will pick up + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + + PurgeRegionsService service = new PurgeRegionsService(plugin); + PurgeRegionsService.PurgeScanResult scan = service.scan(world, 10); + service.delete(scan); + + mockedBukkit.verify(Bukkit::getWorlds, never()); + } } 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..c817eff0a --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java @@ -0,0 +1,341 @@ +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: + *

    + *
  1. State persistence — the YAML file round-trips both + * {@code lastAgeRunMillis} and {@code lastDeletedRunMillis}.
  2. + *
  3. 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.
  4. + *
  5. 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.
  6. + *
+ */ +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.setHousekeepingEnabled(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.setHousekeepingEnabled(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.setHousekeepingEnabled(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.setHousekeepingEnabled(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.setHousekeepingEnabled(true); + 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.setHousekeepingEnabled(true); + 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.setHousekeepingEnabled(true); + 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()); + } +}