From bd238749954801e5c651cf6faf021ff08bd46d4f Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 8 Apr 2026 23:43:13 -0700 Subject: [PATCH 01/39] Phase 1: Add housekeeping scheduler + extract PurgeRegionsService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding for the shift away from chunk-copy island deletion. No behavior change yet — reset and admin delete still go through the old pipeline. - Settings: add island.deletion.housekeeping.{enabled,interval-days, region-age-days} (defaults off/30/60). Deprecate keep-previous-island-on-reset and slow-deletion config entries (unbound from config; getters/setters kept as @Deprecated(forRemoval=true) for binary compat until Phase 4). - PurgeRegionsService: extract scan/filter/delete/player-cleanup logic out of AdminPurgeRegionsCommand so the command and the scheduler share one code path. Handles both pre-26.1 (DIM-1/DIM1 subfolders) and 26.1.1+ (sibling world folders) dimension layouts. - AdminPurgeRegionsCommand: reduced to ~180 LOC, delegates to the service and retains only the two-step confirmation UX + per-island display. - HousekeepingManager: new manager wired in BentoBox.onEnable(). Hourly wall-clock check; runs the purge service across every gamemode overworld if enabled and interval has elapsed. Last-run timestamp persisted to /database/housekeeping.yml regardless of DB backend, so the schedule survives restarts. Progress logged to console. - AdminPurgeRegionsCommandTest: stub plugin.getPurgeRegionsService() with a real service over the mocked plugin so the extraction is exercised exactly as the command runs. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/BentoBox.java | 31 + .../world/bentobox/bentobox/Settings.java | 116 ++- .../admin/purge/AdminPurgeRegionsCommand.java | 719 ++---------------- .../managers/HousekeepingManager.java | 229 ++++++ .../managers/PurgeRegionsService.java | 639 ++++++++++++++++ .../purge/AdminPurgeRegionsCommandTest.java | 5 + 6 files changed, 1052 insertions(+), 687 deletions(-) create mode 100644 src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java create mode 100644 src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java index b97654e6c..47edea158 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,9 @@ public void onDisable() { if (chunkPregenManager != null) { chunkPregenManager.shutdown(); } + if (housekeepingManager != null) { + housekeepingManager.stop(); + } } @@ -566,6 +580,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..9cee6e591 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -319,27 +319,44 @@ 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") + /** + * @deprecated No longer bound to config. Reset always soft-deletes now. + * Slated for removal. + */ + @Deprecated(since = "3.14.0", forRemoval = true) 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; + // Chunk pre-generation settings @ConfigComment("") @ConfigComment("Chunk pre-generation settings.") @@ -828,22 +845,27 @@ public void setDatabasePrefix(String 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 + * @deprecated Reset always soft-deletes now. Physical cleanup is handled + * by the housekeeping auto-purge. Slated for removal. */ + @Deprecated(since = "3.14.0", forRemoval = true) 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 + * @deprecated See {@link #isKeepPreviousIslandOnReset()}. */ + @Deprecated(since = "3.14.0", forRemoval = true) public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) { this.keepPreviousIslandOnReset = keepPreviousIslandOnReset; } @@ -1014,7 +1036,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 +1049,63 @@ 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 whether chunk pre-generation is enabled * @since 3.14.0 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..0af812802 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,56 @@ 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 Bukkit.getWorlds().forEach(World::save); - // Find the potential islands - Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), ()-> findIslands(getWorld(), days)); + 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 - Bukkit.getWorlds().forEach(World::save); - // Recheck to see if any regions are newer than days and if so, stop everything - getPlugin().log("Now deleting region files"); - if (!deleteRegionFiles()) { - // Fail! - getPlugin().logError("Not all region files could be deleted"); - } - // Delete islands and regions - for (Set islandIDs : deleteableRegions.values()) { - for (String islandID : islandIDs) { - deletePlayerFromWorldFolder(islandID); - // Remove island from the cache - getPlugin().getIslands().getIslandCache().deleteIslandFromCache(islandID); - // Delete island from database using id - if (getPlugin().getIslands().deleteIslandId(islandID)) { - // Log - getPlugin().log("Island ID " + islandID + " deleted from cache and database" ); - } - } - } - - user.sendMessage("general.success"); + PurgeScanResult scan = lastScan; + lastScan = null; 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"); - } - } - + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + boolean ok = getPlugin().getPurgeRegionsService().delete(scan); + Bukkit.getScheduler().runTask(getPlugin(), () -> + user.sendMessage(ok ? "general.success" : NONE_FOUND)); + }); 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.deleteableRegions().values().stream() .flatMap(Set::stream) .map(getPlugin().getIslands()::getIslandById) .flatMap(Optional::stream) @@ -514,16 +128,18 @@ private void displayResultsAndPrompt() { uniqueIslands.forEach(this::displayIsland); - deleteableRegions.entrySet().stream() + scan.deleteableRegions().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 +147,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/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java new file mode 100644 index 000000000..cbeeb25d7 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -0,0 +1,229 @@ +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.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. + * + *

Enabled via {@code island.deletion.housekeeping.enabled}. The task runs + * every {@code interval-days} days (wall-clock, not uptime) and scans for + * regions older than {@code region-age-days}. Since player resets now + * orphan islands instead of physically deleting their blocks, this scheduler + * is how the disk space is eventually reclaimed. + * + *

Last-run timestamp is 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. Default is OFF. + * + * @since 3.14.0 + */ +public class HousekeepingManager { + + private static final String LAST_RUN_KEY = "lastRunMillis"; + 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 lastRunMillis; + 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"); + this.lastRunMillis = loadLastRun(); + } + + // --------------------------------------------------------------- + // 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; + } + // Check hourly; each check runs the purge only if the wall-clock + // interval since the last run has elapsed and the feature is enabled. + scheduledTask = Bukkit.getScheduler().runTaskTimer(plugin, + this::checkAndMaybeRun, STARTUP_DELAY_TICKS, CHECK_INTERVAL_TICKS); + plugin.log("Housekeeping scheduler started (enabled=" + + plugin.getSettings().isHousekeepingEnabled() + + ", interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + + ", region-age=" + plugin.getSettings().getHousekeepingRegionAgeDays() + "d" + + ", last-run=" + (lastRunMillis == 0 ? "never" : Instant.ofEpochMilli(lastRunMillis)) + ")"); + } + + /** + * Stops the periodic housekeeping check. Does not clear the last-run + * timestamp 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 the wall-clock timestamp (millis) of the last successful run, + * or {@code 0} if the task has never run. + */ + public long getLastRunMillis() { + return lastRunMillis; + } + + private void checkAndMaybeRun() { + if (inProgress) { + return; + } + if (!plugin.getSettings().isHousekeepingEnabled()) { + return; + } + int intervalDays = plugin.getSettings().getHousekeepingIntervalDays(); + if (intervalDays <= 0) { + plugin.logWarning("Housekeeping: interval-days must be >= 1, skipping run"); + return; + } + long intervalMillis = TimeUnit.DAYS.toMillis(intervalDays); + long now = System.currentTimeMillis(); + if (lastRunMillis != 0 && (now - lastRunMillis) < intervalMillis) { + return; + } + runNow(); + } + + /** + * Triggers an immediate housekeeping cycle, regardless of the + * wall-clock interval (but still respecting {@code enabled}). + * Runs asynchronously. + */ + public synchronized void runNow() { + if (inProgress) { + plugin.log("Housekeeping: run requested but already in progress, ignoring"); + return; + } + inProgress = true; + Bukkit.getScheduler().runTaskAsynchronously(plugin, this::executeCycle); + } + + // --------------------------------------------------------------- + // Cycle execution + // --------------------------------------------------------------- + + private void executeCycle() { + long startMillis = System.currentTimeMillis(); + try { + int ageDays = plugin.getSettings().getHousekeepingRegionAgeDays(); + if (ageDays <= 0) { + plugin.logError("Housekeeping: region-age-days must be >= 1, aborting run"); + return; + } + List gameModes = plugin.getAddonsManager().getGameModeAddons(); + plugin.log("Housekeeping: starting auto-purge cycle across " + gameModes.size() + + " gamemode(s), region-age=" + ageDays + "d"); + // Save worlds up-front so disk state matches memory + Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getWorlds().forEach(World::save)); + + int totalWorlds = 0; + int totalRegionsPurged = 0; + for (GameModeAddon gm : gameModes) { + World overworld = gm.getOverWorld(); + if (overworld == null) { + continue; + } + totalWorlds++; + plugin.log("Housekeeping: scanning gamemode '" + gm.getDescription().getName() + + "' world '" + overworld.getName() + "'"); + PurgeScanResult scan = plugin.getPurgeRegionsService().scan(overworld, ageDays); + if (scan.isEmpty()) { + plugin.log("Housekeeping: nothing to purge in " + overworld.getName()); + continue; + } + plugin.log("Housekeeping: " + scan.deleteableRegions().size() + " region(s) and " + + scan.uniqueIslandCount() + " island(s) eligible in " + overworld.getName()); + boolean ok = plugin.getPurgeRegionsService().delete(scan); + if (ok) { + totalRegionsPurged += scan.deleteableRegions().size(); + } else { + plugin.logError("Housekeeping: purge of " + overworld.getName() + + " completed with errors"); + } + } + + Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis); + plugin.log("Housekeeping: cycle complete — " + totalWorlds + " world(s) processed, " + + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s"); + lastRunMillis = System.currentTimeMillis(); + saveLastRun(); + } catch (Exception e) { + plugin.logError("Housekeeping: cycle failed: " + e.getMessage()); + plugin.logStacktrace(e); + } finally { + inProgress = false; + } + } + + // --------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------- + + private long loadLastRun() { + if (!stateFile.exists()) { + return 0L; + } + try { + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(stateFile); + return yaml.getLong(LAST_RUN_KEY, 0L); + } catch (Exception e) { + plugin.logError("Housekeeping: could not read " + stateFile.getAbsolutePath() + + ": " + e.getMessage()); + return 0L; + } + } + + private void saveLastRun() { + 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_RUN_KEY, lastRunMillis); + 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/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java new file mode 100644 index 000000000..06042d8a8 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -0,0 +1,639 @@ +package world.bentobox.bentobox.managers; + +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.util.ArrayList; +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.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. + * + *

All public methods perform blocking disk I/O and must be called from an + * async 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; + + 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 deleteableRegions 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> deleteableRegions, + boolean isNether, + boolean isEnd, + FilterStats stats) { + public boolean isEmpty() { + return deleteableRegions.isEmpty(); + } + + public int uniqueIslandCount() { + Set ids = new HashSet<>(); + deleteableRegions.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 (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> deleteableRegions = mapIslandsToRegions(oldRegions, islandGrid); + FilterStats stats = filterNonDeletableRegions(deleteableRegions, days); + logFilterStats(stats); + return new PurgeScanResult(world, days, deleteableRegions, 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. Worlds should be saved + * first to flush any in-memory chunk state. + * + * @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.deleteableRegions().isEmpty()) { + return false; + } + // Save the worlds to flush any in-memory region state + Bukkit.getWorlds().forEach(World::save); + + 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"); + } + + // Delete islands + player data + int islandsRemoved = 0; + for (Set islandIDs : scan.deleteableRegions().values()) { + for (String islandID : islandIDs) { + deletePlayerFromWorldFolder(scan.world(), islandID, scan.deleteableRegions(), 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.deleteableRegions().size() + " region(s), " + + islandsRemoved + " island(s) removed"); + return ok; + } + + // --------------------------------------------------------------- + // Filtering + // --------------------------------------------------------------- + + /** + * Removes regions whose island-set contains at least one island that + * cannot be deleted, returning blocking statistics. + */ + private FilterStats filterNonDeletableRegions( + Map, Set> deleteableRegions, int days) { + 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(), 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); + } + + 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 zero or negative!"); + return false; + } + long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + + 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 + for (Pair coords : scan.deleteableRegions().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.deleteableRegions().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> deleteableRegions, int days) { + File playerData = resolvePlayerDataFolder(world); + plugin.getIslands().getIslandById(islandID) + .ifPresent(island -> island.getMemberSet() + .forEach(uuid -> maybeDeletePlayerData(world, uuid, playerData, deleteableRegions, days))); + } + + private void maybeDeletePlayerData(World world, UUID uuid, File playerData, + Map, Set> deleteableRegions, int days) { + List memberOf = new ArrayList<>(plugin.getIslands().getIslands(world, 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); + 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/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..5b2ac779a 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); From da21feaba31955facb095e4df4a777a6fd55311b Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 04:26:54 -0700 Subject: [PATCH 02/39] Fix async World.save() crash in purge delete path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running /bbox purge regions confirm on Paper 26.1.1 tripped AsyncCatcher because PurgeRegionsService.delete() was saving worlds from the async worker thread, and World.save() is main-thread-only: IllegalStateException: Asynchronous world save! at PurgeRegionsService.delete(PurgeRegionsService.java:151) The pre-refactor command ran the save on the main thread inside execute() but I collapsed it into the service. Move the save back out of the service so all callers are responsible for flushing on the main thread before dispatching the async delete. - PurgeRegionsService.delete(): no longer calls Bukkit.getWorlds().save(). Javadoc updated to state the caller contract. - AdminPurgeRegionsCommand.deleteEverything(): call Bukkit.getWorlds() .forEach(World::save) before scheduling the async delete. Runs on the main thread since execute() is invoked there. - HousekeepingManager.executeCycle(): the existing runTask() save was fire-and-forget — the async cycle could start scanning/deleting before the save finished. Block via CompletableFuture.join() until the main-thread save completes. - AdminPurgeRegionsCommandTest: add regression asserting the service never calls Bukkit.getWorlds() itself (would have caught this bug). Co-Authored-By: Claude Opus 4.6 --- .../admin/purge/AdminPurgeRegionsCommand.java | 3 ++ .../managers/HousekeepingManager.java | 16 ++++++++-- .../managers/PurgeRegionsService.java | 10 +++---- .../purge/AdminPurgeRegionsCommandTest.java | 29 +++++++++++++++++++ 4 files changed, 51 insertions(+), 7 deletions(-) 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 0af812802..adb3f4b92 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 @@ -111,6 +111,9 @@ private boolean deleteEverything() { 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. + Bukkit.getWorlds().forEach(World::save); Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { boolean ok = getPlugin().getPurgeRegionsService().delete(scan); Bukkit.getScheduler().runTask(getPlugin(), () -> diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index cbeeb25d7..0b8f4274a 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -5,6 +5,7 @@ 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; @@ -151,8 +152,19 @@ private void executeCycle() { List gameModes = plugin.getAddonsManager().getGameModeAddons(); plugin.log("Housekeeping: starting auto-purge cycle across " + gameModes.size() + " gamemode(s), region-age=" + ageDays + "d"); - // Save worlds up-front so disk state matches memory - Bukkit.getScheduler().runTask(plugin, () -> Bukkit.getWorlds().forEach(World::save)); + // Save worlds up-front so disk state matches memory. World.save() + // must run on the main thread — hop over and block the async + // cycle until the save completes. + CompletableFuture saved = new CompletableFuture<>(); + Bukkit.getScheduler().runTask(plugin, () -> { + try { + Bukkit.getWorlds().forEach(World::save); + saved.complete(null); + } catch (Exception e) { + saved.completeExceptionally(e); + } + }); + saved.join(); int totalWorlds = 0; int totalRegionsPurged = 0; diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 06042d8a8..60bd6d3a9 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -136,8 +136,11 @@ public PurgeScanResult scan(World world, int days) { * 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. Worlds should be saved - * first to flush any in-memory chunk state. + * 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 @@ -147,9 +150,6 @@ public boolean delete(PurgeScanResult scan) { if (scan.deleteableRegions().isEmpty()) { return false; } - // Save the worlds to flush any in-memory region state - Bukkit.getWorlds().forEach(World::save); - plugin.log("Now deleting region files for world " + scan.world().getName()); boolean ok = deleteRegionFiles(scan); if (!ok) { 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 5b2ac779a..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 @@ -518,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()); + } } From 9c90160e54ac91e87425ae2c5e95db6f7a8ce2e7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 04:33:04 -0700 Subject: [PATCH 03/39] Log explicit save messages around purge world saves Paper rate-limits its built-in "plugin-induced save detected" warning, so after the scan save fired once, the confirm-path save was silent and looked like it wasn't running. Add explicit plugin.log lines on both sides of every World.save() call in the purge code paths (scan, confirm, housekeeping) so operators always see when the save is happening. Co-Authored-By: Claude Opus 4.6 --- .../api/commands/admin/purge/AdminPurgeRegionsCommand.java | 4 ++++ .../world/bentobox/bentobox/managers/HousekeepingManager.java | 2 ++ 2 files changed, 6 insertions(+) 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 adb3f4b92..6d90789c4 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 @@ -87,7 +87,9 @@ public boolean execute(User user, String label, List args) { 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); + getPlugin().log("Purge: world save complete"); inPurge = true; final int finalDays = days; @@ -113,7 +115,9 @@ private boolean deleteEverything() { 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); + getPlugin().log("Purge: world save complete, dispatching deletion"); Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { boolean ok = getPlugin().getPurgeRegionsService().delete(scan); Bukkit.getScheduler().runTask(getPlugin(), () -> diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index 0b8f4274a..af58427db 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -155,6 +155,7 @@ private void executeCycle() { // Save worlds up-front so disk state matches memory. World.save() // must run on the main thread — hop over and block the async // cycle until the save completes. + plugin.log("Housekeeping: saving all worlds before purge..."); CompletableFuture saved = new CompletableFuture<>(); Bukkit.getScheduler().runTask(plugin, () -> { try { @@ -165,6 +166,7 @@ private void executeCycle() { } }); saved.join(); + plugin.log("Housekeeping: world save complete"); int totalWorlds = 0; int totalRegionsPurged = 0; From 6bc7ae3186cfde8f914271657e8a6275d63a9817 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 04:48:43 -0700 Subject: [PATCH 04/39] Add admin purge age-regions test helper Adds /bbox admin purge age-regions to rewrite per-chunk timestamp tables in .mca files so regions become purgable without waiting wall-clock time. The purge scanner reads timestamps from the region header, not file mtime, so `touch` cannot fake ageing. Co-Authored-By: Claude Opus 4.6 --- .../purge/AdminPurgeAgeRegionsCommand.java | 96 +++++++++++++++ .../admin/purge/AdminPurgeCommand.java | 1 + .../managers/PurgeRegionsService.java | 109 ++++++++++++++++++ src/main/resources/locales/en-US.yml | 4 + .../admin/purge/AdminPurgeCommandTest.java | 2 +- 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java 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..164239989 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java @@ -0,0 +1,96 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +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; + CompletableFuture.runAsync(() -> { + 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..7c7653595 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 @@ -48,6 +48,7 @@ public void setup() { new AdminPurgeUnownedCommand(this); new AdminPurgeProtectCommand(this); new AdminPurgeRegionsCommand(this); + new AdminPurgeAgeRegionsCommand(this); } @Override diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 60bd6d3a9..b6c3ecbaf 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -3,6 +3,7 @@ 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; @@ -174,6 +175,114 @@ public boolean delete(PurgeScanResult scan) { return ok; } + // --------------------------------------------------------------- + // 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 // --------------------------------------------------------------- diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 3743546e7..59d9ea582 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -128,6 +128,10 @@ commands: 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.' protect: description: toggle [prefix_island] purge protection move-to-island: 'Move to [prefix_an-island] first!' 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..d2766b70e 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(7, apc.getSubCommands().size()); } From 9f33f648b405c502a9343b5e02e8c28cf9300674 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 05:43:47 -0700 Subject: [PATCH 05/39] Phase 2: Reset always soft-deletes via deletable flag IslandsManager.deleteIsland() used to branch on keepPreviousIslandOnReset: false -> evict from cache, enqueue IslandChunkDeletionManager, MultiLib notify, delete DB row. true -> save with deletable=true and fire the deletion event. With the new region-file purge flow (Phase 1), physical cleanup no longer happens inline at all - old islands are left in place with deletable=true and reaped later by PurgeRegionsService / HousekeepingManager. So the hard-path branch goes away entirely: every call with removeBlocks=true now soft-deletes. Consequences in this commit: - AdminDeleteCommand also soft-deletes until Phase 3 splits it on GameModeAddon.isUsesNewChunkGeneration() (new-gen -> soft-delete, void gamemodes -> ChunkGenerator regen). - Nether/End cascade is a no-op in the soft path (nothing touches chunks); PurgeRegionsService.scan already gates nether/end on isNetherIslands/isEndIslands so vanilla-owned dimensions are skipped when the regions are eventually reaped. - keepPreviousIslandOnReset setter/getter remain as deprecated shims (no longer consulted at runtime); Phase 4 removes the field. - The bentobox-deleteIsland MultiLib subscriber is now unreachable from this server's publishers but stays until Phase 4 deletes the deletion infrastructure wholesale. Co-Authored-By: Claude Opus 4.6 --- .../bentobox/managers/IslandsManager.java | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java index aa5c74497..d57ab8494 100644 --- a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java @@ -305,23 +305,13 @@ 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(); } } From 0a515c2f57d0731cb4a7ea25c2cd92334b72a3b4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 05:56:52 -0700 Subject: [PATCH 06/39] Surface soft-deleted islands to admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 made reset leave orphaned islands in place with deletable=true until the region purge reaps them. That meant admins walking around a server had no way to tell an orphan from a normal unowned island — /bbox admin info just showed "Unowned" and entering the area was silent. Two visible cues now: - IslandInfo.showAdminInfo() prints a new "deletable: flagged for deletion and awaiting region purge" line when island.isDeletable() is true, right after the purge-protected line. - LockAndBanListener notifies ops (once per entry, same pattern as the existing lock notification) when they step onto an island flagged deletable. Non-ops still see nothing; this is strictly an admin heads-up. The notification state is cleared when the op leaves the island, so walking back in re-triggers it. New locale keys commands.admin.info.deletable and protection.deletable-island-admin in en-US.yml. Co-Authored-By: Claude Opus 4.6 --- .../flags/protection/LockAndBanListener.java | 34 +++++++++++++++++++ .../bentobox/bentobox/util/IslandInfo.java | 3 ++ src/main/resources/locales/en-US.yml | 2 ++ 3 files changed, 39 insertions(+) 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..d65628125 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 @@ -37,6 +37,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 * @@ -177,9 +185,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/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/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 59d9ea582..331b46db3 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -280,6 +280,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]' @@ -1755,6 +1756,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].' From 73a3240fb2a146f6ff054a7c16af8252a1adfef4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 06:16:07 -0700 Subject: [PATCH 07/39] Phase 3: Split AdminDeleteCommand on isUsesNewChunkGeneration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /bbox admin delete used to always call deleteIsland(island, true, uuid), which after Phase 2 soft-deletes unconditionally. That is the right behavior for new-chunk-generation gamemodes like Boxed where chunks are expensive and the region-file purge reaps them later on the HousekeepingManager schedule. For void/simple-generator gamemodes it is the wrong behavior — chunks are cheap, admins expect "delete" to actually delete, and soft-deleted rows would linger forever because the repainted region files always look fresh to the purge scan. Branch on GameModeAddon.isUsesNewChunkGeneration(): - true (new-gen): soft-delete via IslandsManager.deleteIsland(), same as /is reset. Physical cleanup happens later via PurgeRegionsService / HousekeepingManager. - false (void/simple): kick off DeleteIslandChunks (which routes to WorldRegenerator.regenerateSimple with correct nether/end cascade gating) to repaint the chunks via the addon's own ChunkGenerator, then hard-delete the island row immediately. DeleteIslandChunks snapshots the bounds in its constructor so the row can be removed before the async regen completes. Adds IslandsManager.hardDeleteIsland(island): fires the pre-delete event, kicks members, nulls owner, evicts from cache, deletes the DB row. Does not touch world chunks — caller handles physical cleanup. Phase 4 will remove DeleteIslandChunks, IslandDeletion, and the CopyWorldRegenerator.regenerateCopy seed-world path; regenerateSimple and the split here survive. Co-Authored-By: Claude Opus 4.6 --- .../commands/admin/AdminDeleteCommand.java | 27 ++++++++++++++++- .../bentobox/managers/IslandsManager.java | 30 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) 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..6d2d38339 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 — repaint them via + // the addon's own ChunkGenerator right now using the existing + // DeleteIslandChunks + WorldRegenerator.regenerateSimple path, + // then hard-delete the island row so it does not linger. + // + // If we can't resolve the gamemode, default to soft-delete. + GameModeAddon gm = getIWM().getAddon(getWorld()).orElse(null); + if (gm != null && !gm.isUsesNewChunkGeneration()) { + // DeleteIslandChunks snapshots the island bounds in its + // constructor, so it is safe to hard-delete the row + // immediately after kicking off the regen. + new DeleteIslandChunks(getPlugin(), new IslandDeletion(oldIsland)); + getIslands().hardDeleteIsland(oldIsland); + } else { + getIslands().deleteIsland(oldIsland, true, targetUUID); + } } private void deletePlayer(User user) { diff --git a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java index d57ab8494..6de4b588b 100644 --- a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java @@ -315,6 +315,36 @@ public void deleteIsland(@NonNull Island island, boolean removeBlocks, @Nullable } } + /** + * 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 From 23bd5cb5b97cda2ed8a70ee37ab6a6da0c9336fc Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Apr 2026 14:38:37 -0700 Subject: [PATCH 08/39] Phase 3.5: Add /bbox admin purge deleted + daily housekeeping sweep Add a second purge mode that reaps region files for any island already flagged as deletable, regardless of region-file age. Exposed as /bbox admin purge deleted and run from HousekeepingManager on a configurable hourly cadence (default 24h) alongside the existing monthly age sweep. Closes the post-reset gap where orphan island regions sat on disk for 60+ days waiting for the age threshold. Fix: evict in-memory chunks via World.unloadChunk(cx, cz, false) on the main thread before the async file delete, otherwise Paper's autosave re-flushes the deleted region files with the stale chunks. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/Settings.java | 25 ++ .../admin/purge/AdminPurgeCommand.java | 1 + .../admin/purge/AdminPurgeDeletedCommand.java | 138 +++++++ .../managers/HousekeepingManager.java | 305 +++++++++----- .../managers/PurgeRegionsService.java | 186 ++++++++- src/main/resources/locales/en-US.yml | 4 + .../admin/purge/AdminPurgeCommandTest.java | 2 +- .../purge/AdminPurgeDeletedCommandTest.java | 375 ++++++++++++++++++ .../managers/HousekeepingManagerTest.java | 341 ++++++++++++++++ .../managers/PurgeRegionsServiceTest.java | 359 +++++++++++++++++ 10 files changed, 1634 insertions(+), 102 deletions(-) create mode 100644 src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java create mode 100644 src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java create mode 100644 src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java create mode 100644 src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 9cee6e591..ba5d4556d 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -357,6 +357,13 @@ public class Settings implements ConfigObject { @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.") @@ -1106,6 +1113,24 @@ 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/purge/AdminPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java index 7c7653595..91a466c12 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 @@ -49,6 +49,7 @@ public void setup() { new AdminPurgeProtectCommand(this); new AdminPurgeRegionsCommand(this); new AdminPurgeAgeRegionsCommand(this); + new AdminPurgeDeletedCommand(this); } @Override 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..1fb65f10c --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java @@ -0,0 +1,138 @@ +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(), () -> + user.sendMessage(ok ? "general.success" : NONE_FOUND)); + }); + return true; + } + + private void displayResultsAndPrompt(PurgeScanResult scan) { + Set uniqueIslands = scan.deleteableRegions().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/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index af58427db..1d7bbcc2d 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -19,39 +19,47 @@ /** * Periodic housekeeping: automatically runs the region-files purge against - * every gamemode overworld on a configurable schedule. + * every gamemode overworld on a configurable schedule. Two independent cycles: * - *

Enabled via {@code island.deletion.housekeeping.enabled}. The task runs - * every {@code interval-days} days (wall-clock, not uptime) and scans for - * regions older than {@code region-age-days}. Since player resets now - * orphan islands instead of physically deleting their blocks, this scheduler - * is how the disk space is eventually reclaimed. + *

    + *
  • 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.
  • + *
* - *

Last-run timestamp is persisted to + *

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. Default is OFF. + * files from disk. * * @since 3.14.0 */ public class HousekeepingManager { - private static final String LAST_RUN_KEY = "lastRunMillis"; + 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 lastRunMillis; + 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"); - this.lastRunMillis = loadLastRun(); + loadState(); } // --------------------------------------------------------------- @@ -66,20 +74,24 @@ public synchronized void start() { if (scheduledTask != null) { return; } - // Check hourly; each check runs the purge only if the wall-clock - // interval since the last run has elapsed and the feature is enabled. scheduledTask = Bukkit.getScheduler().runTaskTimer(plugin, this::checkAndMaybeRun, STARTUP_DELAY_TICKS, CHECK_INTERVAL_TICKS); plugin.log("Housekeeping scheduler started (enabled=" + plugin.getSettings().isHousekeepingEnabled() - + ", interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + + ", age-interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + ", region-age=" + plugin.getSettings().getHousekeepingRegionAgeDays() + "d" - + ", last-run=" + (lastRunMillis == 0 ? "never" : Instant.ofEpochMilli(lastRunMillis)) + ")"); + + ", 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 - * timestamp on disk. + * timestamps on disk. */ public synchronized void stop() { if (scheduledTask != null) { @@ -96,11 +108,19 @@ public boolean isInProgress() { } /** - * @return the wall-clock timestamp (millis) of the last successful run, - * or {@code 0} if the task has never run. + * @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 getLastRunMillis() { - return lastRunMillis; + public long getLastDeletedRunMillis() { + return lastDeletedRunMillis; } private void checkAndMaybeRun() { @@ -110,122 +130,222 @@ private void checkAndMaybeRun() { 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) { - plugin.logWarning("Housekeeping: interval-days must be >= 1, skipping run"); - return; + return false; } long intervalMillis = TimeUnit.DAYS.toMillis(intervalDays); - long now = System.currentTimeMillis(); - if (lastRunMillis != 0 && (now - lastRunMillis) < intervalMillis) { - return; + return lastAgeRunMillis == 0 || (now - lastAgeRunMillis) >= intervalMillis; + } + + private boolean isDeletedCycleDue(long now) { + int intervalHours = plugin.getSettings().getHousekeepingDeletedIntervalHours(); + if (intervalHours <= 0) { + return false; } - runNow(); + long intervalMillis = TimeUnit.HOURS.toMillis(intervalHours); + return lastDeletedRunMillis == 0 || (now - lastDeletedRunMillis) >= intervalMillis; } /** - * Triggers an immediate housekeeping cycle, regardless of the - * wall-clock interval (but still respecting {@code enabled}). - * Runs asynchronously. + * 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, this::executeCycle); + 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 void executeCycle() { - long startMillis = System.currentTimeMillis(); - try { - int ageDays = plugin.getSettings().getHousekeepingRegionAgeDays(); - if (ageDays <= 0) { - plugin.logError("Housekeeping: region-age-days must be >= 1, aborting run"); - return; + 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); } - List gameModes = plugin.getAddonsManager().getGameModeAddons(); - plugin.log("Housekeeping: starting auto-purge cycle across " + gameModes.size() - + " gamemode(s), region-age=" + ageDays + "d"); - // Save worlds up-front so disk state matches memory. World.save() - // must run on the main thread — hop over and block the async - // cycle until the save completes. - 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; + } + } - int totalWorlds = 0; - int totalRegionsPurged = 0; - for (GameModeAddon gm : gameModes) { - World overworld = gm.getOverWorld(); - if (overworld == null) { - continue; - } - totalWorlds++; - plugin.log("Housekeeping: scanning gamemode '" + gm.getDescription().getName() - + "' world '" + overworld.getName() + "'"); - PurgeScanResult scan = plugin.getPurgeRegionsService().scan(overworld, ageDays); - if (scan.isEmpty()) { - plugin.log("Housekeeping: nothing to purge in " + overworld.getName()); - continue; - } - plugin.log("Housekeeping: " + scan.deleteableRegions().size() + " region(s) and " - + scan.uniqueIslandCount() + " island(s) eligible in " + overworld.getName()); - boolean ok = plugin.getPurgeRegionsService().delete(scan); - if (ok) { - totalRegionsPurged += scan.deleteableRegions().size(); - } else { - plugin.logError("Housekeeping: purge of " + overworld.getName() - + " completed with errors"); - } + 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)"); - Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis); - plugin.log("Housekeeping: cycle complete — " + totalWorlds + " world(s) processed, " - + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s"); - lastRunMillis = System.currentTimeMillis(); - saveLastRun(); + 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: cycle failed: " + e.getMessage()); - plugin.logStacktrace(e); - } finally { - inProgress = false; + 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.deleteableRegions().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.deleteableRegions().size(); + } + // --------------------------------------------------------------- // Persistence // --------------------------------------------------------------- - private long loadLastRun() { + private void loadState() { if (!stateFile.exists()) { - return 0L; + lastAgeRunMillis = 0L; + lastDeletedRunMillis = 0L; + return; } try { YamlConfiguration yaml = YamlConfiguration.loadConfiguration(stateFile); - return yaml.getLong(LAST_RUN_KEY, 0L); + // 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()); - return 0L; + lastAgeRunMillis = 0L; + lastDeletedRunMillis = 0L; } } - private void saveLastRun() { + private void saveState() { try { File parent = stateFile.getParentFile(); if (parent != null && !parent.exists() && !parent.mkdirs()) { @@ -233,7 +353,8 @@ private void saveLastRun() { return; } YamlConfiguration yaml = new YamlConfiguration(); - yaml.set(LAST_RUN_KEY, lastRunMillis); + 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() diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index b6c3ecbaf..8b6067141 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -102,6 +102,63 @@ 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> deleteableRegions = + mapIslandsToRegions(new ArrayList<>(candidateRegions), islandGrid); + FilterStats stats = filterForDeletedSweep(deleteableRegions); + logFilterStats(stats); + return new PurgeScanResult(world, 0, deleteableRegions, 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 @@ -175,6 +232,72 @@ public boolean delete(PurgeScanResult scan) { return ok; } + // --------------------------------------------------------------- + // Chunk eviction + // --------------------------------------------------------------- + + /** + * Unloads every loaded chunk that falls inside any region in + * {@code scan.deleteableRegions()} 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.deleteableRegions().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.deleteableRegions().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.deleteableRegions().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 // --------------------------------------------------------------- @@ -314,6 +437,38 @@ private FilterStats filterNonDeletableRegions( 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> deleteableRegions) { + int regionsBlockedByProtection = 0; + var iter = deleteableRegions.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; @@ -506,11 +661,14 @@ private Map, Set> mapIslandsToRegions( private boolean deleteRegionFiles(PurgeScanResult scan) { int days = scan.days(); - if (days <= 0) { - plugin.logError("Days is somehow zero or negative!"); + if (days < 0) { + plugin.logError("Days is somehow negative!"); return false; } - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + // 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(); @@ -530,12 +688,16 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { File endEntities = new File(endBase, ENTITIES); File endPoi = new File(endBase, POI); - // Verify none of the files have been updated since the cutoff - for (Pair coords : scan.deleteableRegions().keySet()) { - String name = "r." + coords.x() + "." + coords.z() + ".mca"; - if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, - scan.isNether(), scan.isEnd())) { - return false; + // 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.deleteableRegions().keySet()) { + String name = "r." + coords.x() + "." + coords.z() + ".mca"; + if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, + scan.isNether(), scan.isEnd())) { + return false; + } } } @@ -598,6 +760,12 @@ private void deletePlayerFromWorldFolder(World world, String islandID, private void maybeDeletePlayerData(World world, UUID uuid, File playerData, Map, Set> deleteableRegions, 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)); deleteableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); if (!memberOf.isEmpty()) { diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 331b46db3..41cfbf147 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -132,6 +132,10 @@ commands: 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' protect: description: toggle [prefix_island] purge protection move-to-island: 'Move to [prefix_an-island] first!' 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 d2766b70e..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(7, 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..ac96b885a --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java @@ -0,0 +1,375 @@ +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.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("general.success"); + assertFalse(regionFile.toFile().exists(), "Fresh region file should be reaped by the deleted sweep"); + verify(im).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("general.success"); + 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/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..cc1a1f13e --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -0,0 +1,359 @@ +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.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.deleteableRegions().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.deleteableRegions().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.deleteableRegions().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); + } + + /** + * 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)); + } +} From a63da7cf687212d9032d1bba222dbed1987d8750 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Apr 2026 05:30:25 -0700 Subject: [PATCH 09/39] Defer deleted-sweep island DB removal to plugin shutdown Paper's internal chunk cache keeps serving stale block data even after the .mca region files are deleted from disk. The chunks only clear on server restart when Paper discards its cache. Deleting the island DB row immediately left a window where players see old blocks but BentoBox reports no island at that location. The deleted sweep (days==0) now adds island IDs to a pendingDeletions set instead of removing them from the DB inline. On plugin shutdown (BentoBox.onDisable), flushPendingDeletions() processes the set. If the server crashes before a clean shutdown, the islands stay deletable=true and the next purge cycle retries safely. The age-based sweep (days>0) keeps immediate DB removal with the existing residual-region completeness check, since old regions won't be in Paper's memory cache. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/BentoBox.java | 6 + .../admin/purge/AdminPurgeDeletedCommand.java | 9 +- .../managers/PurgeRegionsService.java | 167 ++++++++++++- src/main/resources/locales/en-US.yml | 1 + .../purge/AdminPurgeDeletedCommandTest.java | 8 +- .../managers/PurgeRegionsServiceTest.java | 234 ++++++++++++++++++ 6 files changed, 414 insertions(+), 11 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java index 47edea158..c23eb54e4 100644 --- a/src/main/java/world/bentobox/bentobox/BentoBox.java +++ b/src/main/java/world/bentobox/bentobox/BentoBox.java @@ -319,6 +319,12 @@ 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(); } 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 index 1fb65f10c..f9a89d25e 100644 --- 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 @@ -106,8 +106,13 @@ private boolean deleteEverything() { getPlugin().log("Purge deleted: world save complete, dispatching deletion"); Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { boolean ok = getPlugin().getPurgeRegionsService().delete(scan); - Bukkit.getScheduler().runTask(getPlugin(), () -> - user.sendMessage(ok ? "general.success" : NONE_FOUND)); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + if (ok) { + user.sendMessage("commands.admin.purge.deleted.deferred"); + } else { + user.sendMessage(NONE_FOUND); + } + }); }); return true; } diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 8b6067141..d69c9ea73 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -8,6 +8,7 @@ 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; @@ -15,6 +16,7 @@ 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; @@ -57,6 +59,15 @@ public class PurgeRegionsService { 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; } @@ -214,10 +225,44 @@ public boolean delete(PurgeScanResult scan) { plugin.logError("Not all region files could be deleted"); } - // Delete islands + player data - int islandsRemoved = 0; + // 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.deleteableRegions().values()) { - for (String islandID : islandIDs) { + 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.deleteableRegions(), scan.days()); plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); if (plugin.getIslands().deleteIslandId(islandID)) { @@ -228,10 +273,96 @@ public boolean delete(PurgeScanResult scan) { } plugin.log("Purge complete for world " + scan.world().getName() + ": " + scan.deleteableRegions().size() + " region(s), " - + islandsRemoved + " island(s) removed"); + + 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 // --------------------------------------------------------------- @@ -704,6 +835,16 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi); DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); DimFolders end = new DimFolders(endRegion, endEntities, endPoi); + plugin.log("Purge delete: overworld region folder = " + overworldRegion.getAbsolutePath() + + " (exists=" + overworldRegion.isDirectory() + ")"); + if (scan.isNether()) { + plugin.log("Purge delete: nether region folder = " + netherRegion.getAbsolutePath() + + " (exists=" + netherRegion.isDirectory() + ")"); + } + if (scan.isEnd()) { + plugin.log("Purge delete: end region folder = " + endRegion.getAbsolutePath() + + " (exists=" + endRegion.isDirectory() + ")"); + } boolean allOk = true; for (Pair coords : scan.deleteableRegions().keySet()) { String name = "r." + coords.x() + "." + coords.z() + ".mca"; @@ -735,13 +876,27 @@ && deleteIfExists(new File(overworld.entities(), name)) private boolean deleteIfExists(File file) { if (!file.getParentFile().exists()) { + plugin.log("Purge delete: parent folder missing, skipping " + file.getAbsolutePath()); return true; } + boolean existedBefore = file.exists(); + long sizeBefore = existedBefore ? file.length() : -1L; try { - Files.deleteIfExists(file.toPath()); + boolean removed = Files.deleteIfExists(file.toPath()); + boolean existsAfter = file.exists(); + if (existedBefore) { + plugin.log("Purge delete: " + file.getAbsolutePath() + + " size=" + sizeBefore + "B" + + " removed=" + removed + + " existsAfter=" + existsAfter); + if (existsAfter) { + plugin.logError("Purge delete: file still present after delete! " + file.getAbsolutePath()); + return false; + } + } return true; } catch (IOException e) { - plugin.logError("Failed to delete file: " + file.getAbsolutePath()); + plugin.logError("Failed to delete file: " + file.getAbsolutePath() + " — " + e.getMessage()); return false; } } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 41cfbf147..bdca6d8c5 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -136,6 +136,7 @@ commands: 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!' 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 index ac96b885a..2817e63fc 100644 --- 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 @@ -7,6 +7,7 @@ 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; @@ -320,9 +321,10 @@ void testExecuteConfirmReapsFreshRegion() throws IOException { // Confirm assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); - verify(user).sendMessage("general.success"); + verify(user).sendMessage("commands.admin.purge.deleted.deferred"); assertFalse(regionFile.toFile().exists(), "Fresh region file should be reaped by the deleted sweep"); - verify(im).deleteIslandId("island-deletable"); + // DB row deletion is deferred to shutdown for days==0 (deleted sweep). + verify(im, never()).deleteIslandId("island-deletable"); } /** @@ -358,7 +360,7 @@ void testExecuteConfirmLeavesPlayerData() throws IOException { assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); - verify(user).sendMessage("general.success"); + verify(user).sendMessage("commands.admin.purge.deleted.deferred"); assertTrue(playerFile.toFile().exists(), "Deleted sweep must NOT remove player data — only the age sweep does"); } diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java index cc1a1f13e..47d4f33a9 100644 --- a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -5,6 +5,7 @@ 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; @@ -343,6 +344,122 @@ void testEvictChunksUsesCorrectChunkCoordsForNonZeroRegion() { 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. */ @@ -356,4 +473,121 @@ void testEvictChunksEmptyScanIsNoop() { 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()); + } } From 107e56ffb316168b5631d21de55142b0248eef5c Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Apr 2026 05:38:16 -0700 Subject: [PATCH 10/39] Remove verbose per-file debug logging from deleteRegionFiles Strip the diagnostic logging added during development that printed file size, removed status, and existsAfter for every .mca deletion. Co-Authored-By: Claude Opus 4.6 --- .../managers/PurgeRegionsService.java | 28 ++----------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index d69c9ea73..91063a111 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -835,16 +835,6 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi); DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); DimFolders end = new DimFolders(endRegion, endEntities, endPoi); - plugin.log("Purge delete: overworld region folder = " + overworldRegion.getAbsolutePath() - + " (exists=" + overworldRegion.isDirectory() + ")"); - if (scan.isNether()) { - plugin.log("Purge delete: nether region folder = " + netherRegion.getAbsolutePath() - + " (exists=" + netherRegion.isDirectory() + ")"); - } - if (scan.isEnd()) { - plugin.log("Purge delete: end region folder = " + endRegion.getAbsolutePath() - + " (exists=" + endRegion.isDirectory() + ")"); - } boolean allOk = true; for (Pair coords : scan.deleteableRegions().keySet()) { String name = "r." + coords.x() + "." + coords.z() + ".mca"; @@ -876,27 +866,13 @@ && deleteIfExists(new File(overworld.entities(), name)) private boolean deleteIfExists(File file) { if (!file.getParentFile().exists()) { - plugin.log("Purge delete: parent folder missing, skipping " + file.getAbsolutePath()); return true; } - boolean existedBefore = file.exists(); - long sizeBefore = existedBefore ? file.length() : -1L; try { - boolean removed = Files.deleteIfExists(file.toPath()); - boolean existsAfter = file.exists(); - if (existedBefore) { - plugin.log("Purge delete: " + file.getAbsolutePath() - + " size=" + sizeBefore + "B" - + " removed=" + removed - + " existsAfter=" + existsAfter); - if (existsAfter) { - plugin.logError("Purge delete: file still present after delete! " + file.getAbsolutePath()); - return false; - } - } + Files.deleteIfExists(file.toPath()); return true; } catch (IOException e) { - plugin.logError("Failed to delete file: " + file.getAbsolutePath() + " — " + e.getMessage()); + plugin.logError("Failed to delete file: " + file.getAbsolutePath()); return false; } } From 8fd252a4d0ecf6b1e88bf2ef661ccc07ad54cda7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Apr 2026 05:47:49 -0700 Subject: [PATCH 11/39] Remove keep-previous-island-on-reset setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This setting was made obsolete by Phase 2 which changed /is reset to always soft-delete. The only remaining references were in AdminPurgeCommand for conditional logging — now simplified to always use tier-based progress reporting. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/Settings.java | 33 ------------------- .../admin/purge/AdminPurgeCommand.java | 12 +++---- src/main/resources/config.yml | 10 ------ src/main/resources/locales/en-US.yml | 6 +--- .../world/bentobox/bentobox/SettingsTest.java | 20 ----------- 5 files changed, 5 insertions(+), 76 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index ba5d4556d..5c322cfcc 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -319,12 +319,6 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "island.delete-speed", since = "1.7.0") private int deleteSpeed = 1; - /** - * @deprecated No longer bound to config. Reset always soft-deletes now. - * Slated for removal. - */ - @Deprecated(since = "3.14.0", forRemoval = true) - private boolean keepPreviousIslandOnReset = false; /** * @deprecated No longer bound to config. The chunk-by-chunk deletion @@ -850,33 +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 - * @deprecated Reset always soft-deletes now. Physical cleanup is handled - * by the housekeeping auto-purge. Slated for removal. - */ - @Deprecated(since = "3.14.0", forRemoval = true) - 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 - * @deprecated See {@link #isKeepPreviousIslandOnReset()}. - */ - @Deprecated(since = "3.14.0", forRemoval = true) - public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) { - this.keepPreviousIslandOnReset = keepPreviousIslandOnReset; - } - /** * Returns a MongoDB client connection URI to override default connection * options. 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 91a466c12..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; @@ -91,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()) { @@ -132,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/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 bdca6d8c5..3623cb69d 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 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()}. From 9aa7259ad2a0e95268b61d01e285a2650933012a Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Apr 2026 16:00:29 -0700 Subject: [PATCH 12/39] Bump version to 4.0.0 Co-Authored-By: Claude Opus 4.6 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index cb3a31791..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.0" +val buildVersion = "4.0.0" val buildNumberDefault = "-LOCAL" // Local build identifier val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version From 1a369e43feb908d094ac7f476d25f0c7a3c0bad5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Apr 2026 16:01:34 -0700 Subject: [PATCH 13/39] Update src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../bentobox/managers/PurgeRegionsService.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 91063a111..1c287985d 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -34,9 +34,13 @@ * {@code /bbox admin purge regions} command and the periodic * {@link HousekeepingManager} auto-purge task. * - *

All public methods perform blocking disk I/O and must be called from an - * async thread. The service does not interact with players or issue - * confirmations — the caller is responsible for any user-facing UX. + *

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 From 9e84fe9317887b64a787dbc44df1b0dc0e518289 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Apr 2026 16:03:02 -0700 Subject: [PATCH 14/39] Update src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/commands/admin/purge/AdminPurgeDeletedCommand.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index f9a89d25e..3654779f7 100644 --- 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 @@ -110,7 +110,8 @@ private boolean deleteEverything() { if (ok) { user.sendMessage("commands.admin.purge.deleted.deferred"); } else { - user.sendMessage(NONE_FOUND); + getPlugin().log("Purge deleted: failed to delete one or more region files after a non-empty scan"); + user.sendMessage("commands.admin.purge.failed"); } }); }); From a10199fcd9de11e20fe34350deebad4c5ffa1402 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 11 Apr 2026 16:03:44 -0700 Subject: [PATCH 15/39] Update src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/commands/admin/AdminDeleteCommand.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 6d2d38339..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 @@ -123,19 +123,19 @@ private void deleteIsland(User user, Island oldIsland) { // left in place) and PurgeRegionsService / HousekeepingManager // reaps the region files and DB row later on its schedule. // - // - Simple/void generation: chunks are cheap — repaint them via - // the addon's own ChunkGenerator right now using the existing - // DeleteIslandChunks + WorldRegenerator.regenerateSimple path, - // then hard-delete the island row so it does not linger. + // - 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()) { - // DeleteIslandChunks snapshots the island bounds in its - // constructor, so it is safe to hard-delete the row - // immediately after kicking off the regen. - new DeleteIslandChunks(getPlugin(), new IslandDeletion(oldIsland)); 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); } From 204d6c50a73cc3cdbbd15c78351ba6cfe9851643 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:10:56 +0000 Subject: [PATCH 16/39] Address PR review: fix typo, error messages, scheduler, quit cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `deleteableRegions` → `deletableRegions` in PurgeScanResult and all callers (fixes API surface typo) - Distinguish "none-found" from "purge failed" in AdminPurgeRegionsCommand.deleteEverything() with dedicated locale key - Replace CompletableFuture.runAsync() with Bukkit scheduler in AdminPurgeAgeRegionsCommand (ties task to plugin lifecycle) - Clear notifiedPlayers and deletableNotified on PlayerQuitEvent in LockAndBanListener (prevents unbounded set growth) - Add `commands.admin.purge.failed` locale key in en-US.yml Agent-Logs-Url: https://github.com/BentoBoxWorld/BentoBox/sessions/99688668-0dd8-455e-9002-3f229fbefbdc Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../purge/AdminPurgeAgeRegionsCommand.java | 3 +- .../admin/purge/AdminPurgeDeletedCommand.java | 2 +- .../admin/purge/AdminPurgeRegionsCommand.java | 14 +++-- .../flags/protection/LockAndBanListener.java | 9 +++ .../managers/HousekeepingManager.java | 4 +- .../managers/PurgeRegionsService.java | 56 +++++++++---------- src/main/resources/locales/en-US.yml | 1 + .../managers/PurgeRegionsServiceTest.java | 6 +- 8 files changed, 55 insertions(+), 40 deletions(-) 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 index 164239989..d4aaaae69 100644 --- 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 @@ -1,7 +1,6 @@ package world.bentobox.bentobox.api.commands.admin.purge; import java.util.List; -import java.util.concurrent.CompletableFuture; import org.bukkit.Bukkit; import org.bukkit.World; @@ -78,7 +77,7 @@ public boolean execute(User user, String label, List args) { running = true; final int finalDays = days; - CompletableFuture.runAsync(() -> { + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { try { int count = getPlugin().getPurgeRegionsService().ageRegions(getWorld(), finalDays); Bukkit.getScheduler().runTask(getPlugin(), () -> { 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 index 3654779f7..349516b4f 100644 --- 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 @@ -119,7 +119,7 @@ private boolean deleteEverything() { } private void displayResultsAndPrompt(PurgeScanResult scan) { - Set uniqueIslands = scan.deleteableRegions().values().stream() + Set uniqueIslands = scan.deletableRegions().values().stream() .flatMap(Set::stream) .map(getPlugin().getIslands()::getIslandById) .flatMap(Optional::stream) 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 6d90789c4..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 @@ -120,14 +120,20 @@ private boolean deleteEverything() { getPlugin().log("Purge: world save complete, dispatching deletion"); Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { boolean ok = getPlugin().getPurgeRegionsService().delete(scan); - Bukkit.getScheduler().runTask(getPlugin(), () -> - user.sendMessage(ok ? "general.success" : NONE_FOUND)); + 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"); + } + }); }); return true; } private void displayResultsAndPrompt(PurgeScanResult scan) { - Set uniqueIslands = scan.deleteableRegions().values().stream() + Set uniqueIslands = scan.deletableRegions().values().stream() .flatMap(Set::stream) .map(getPlugin().getIslands()::getIslandById) .flatMap(Optional::stream) @@ -135,7 +141,7 @@ private void displayResultsAndPrompt(PurgeScanResult scan) { uniqueIslands.forEach(this::displayIsland); - scan.deleteableRegions().entrySet().stream() + scan.deletableRegions().entrySet().stream() .filter(e -> e.getValue().isEmpty()) .forEach(e -> displayEmptyRegion(e.getKey())); 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 d65628125..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; @@ -127,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 diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index 1d7bbcc2d..d79c22537 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -306,7 +306,7 @@ private int runDeleteIfNonEmpty(PurgeScanResult scan, World overworld, String la plugin.log("Housekeeping " + label + ": nothing to purge in " + overworld.getName()); return 0; } - plugin.log("Housekeeping " + label + ": " + scan.deleteableRegions().size() + " region(s) and " + 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) { @@ -314,7 +314,7 @@ private int runDeleteIfNonEmpty(PurgeScanResult scan, World overworld, String la + " completed with errors"); return 0; } - return scan.deleteableRegions().size(); + return scan.deletableRegions().size(); } // --------------------------------------------------------------- diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 1c287985d..089d968e9 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -82,7 +82,7 @@ public PurgeRegionsService(BentoBox plugin) { * * @param world the world scanned * @param days the age cutoff (days) used - * @param deleteableRegions regions considered deletable keyed by region + * @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 @@ -91,17 +91,17 @@ public PurgeRegionsService(BentoBox plugin) { public record PurgeScanResult( World world, int days, - Map, Set> deleteableRegions, + Map, Set> deletableRegions, boolean isNether, boolean isEnd, FilterStats stats) { public boolean isEmpty() { - return deleteableRegions.isEmpty(); + return deletableRegions.isEmpty(); } public int uniqueIslandCount() { Set ids = new HashSet<>(); - deleteableRegions.values().forEach(ids::addAll); + deletableRegions.values().forEach(ids::addAll); return ids.size(); } } @@ -167,11 +167,11 @@ public PurgeScanResult scanDeleted(World world) { plugin.log("Purge deleted-sweep: " + candidateRegions.size() + " candidate region(s) from deletable islands in world " + world.getName()); - Map, Set> deleteableRegions = + Map, Set> deletableRegions = mapIslandsToRegions(new ArrayList<>(candidateRegions), islandGrid); - FilterStats stats = filterForDeletedSweep(deleteableRegions); + FilterStats stats = filterForDeletedSweep(deletableRegions); logFilterStats(stats); - return new PurgeScanResult(world, 0, deleteableRegions, isNether, isEnd, stats); + return new PurgeScanResult(world, 0, deletableRegions, isNether, isEnd, stats); } /** @@ -197,10 +197,10 @@ public PurgeScanResult scan(World world, int days) { } List> oldRegions = findOldRegions(world, days, isNether, isEnd); - Map, Set> deleteableRegions = mapIslandsToRegions(oldRegions, islandGrid); - FilterStats stats = filterNonDeletableRegions(deleteableRegions, days); + Map, Set> deletableRegions = mapIslandsToRegions(oldRegions, islandGrid); + FilterStats stats = filterNonDeletableRegions(deletableRegions, days); logFilterStats(stats); - return new PurgeScanResult(world, days, deleteableRegions, isNether, isEnd, stats); + return new PurgeScanResult(world, days, deletableRegions, isNether, isEnd, stats); } /** @@ -220,7 +220,7 @@ public PurgeScanResult scan(World world, int days) { * any file was unexpectedly fresh or could not be deleted */ public boolean delete(PurgeScanResult scan) { - if (scan.deleteableRegions().isEmpty()) { + if (scan.deletableRegions().isEmpty()) { return false; } plugin.log("Now deleting region files for world " + scan.world().getName()); @@ -232,7 +232,7 @@ public boolean delete(PurgeScanResult scan) { // 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.deleteableRegions().values()) { + for (Set islandIDs : scan.deletableRegions().values()) { affectedIds.addAll(islandIDs); } @@ -267,7 +267,7 @@ public boolean delete(PurgeScanResult scan) { + " \u2014 DB row retained for a future purge"); continue; } - deletePlayerFromWorldFolder(scan.world(), islandID, scan.deleteableRegions(), scan.days()); + 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"); @@ -276,7 +276,7 @@ public boolean delete(PurgeScanResult scan) { } } plugin.log("Purge complete for world " + scan.world().getName() - + ": " + scan.deleteableRegions().size() + " region(s), " + + ": " + scan.deletableRegions().size() + " region(s), " + islandsRemoved + " island(s) removed, " + islandsDeferred + " island(s) deferred" + (scan.days() == 0 ? " (to shutdown)" : " (partial cleanup)")); @@ -373,7 +373,7 @@ private static boolean regionFileExists(File dir, String name) { /** * Unloads every loaded chunk that falls inside any region in - * {@code scan.deleteableRegions()} with {@code save = false}, so the + * {@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. * @@ -396,7 +396,7 @@ private static boolean regionFileExists(File dir, String name) { * @param scan a prior scan result whose regions should be evicted */ public void evictChunks(PurgeScanResult scan) { - if (scan.deleteableRegions().isEmpty()) { + if (scan.deletableRegions().isEmpty()) { return; } World overworld = scan.world(); @@ -404,7 +404,7 @@ public void evictChunks(PurgeScanResult scan) { World endWorld = scan.isEnd() ? plugin.getIWM().getEndWorld(overworld) : null; int evicted = 0; - for (Pair coords : scan.deleteableRegions().keySet()) { + for (Pair coords : scan.deletableRegions().keySet()) { int baseCx = coords.x() << 5; // rX * 32 int baseCz = coords.z() << 5; evicted += evictRegion(overworld, baseCx, baseCz); @@ -416,7 +416,7 @@ public void evictChunks(PurgeScanResult scan) { } } plugin.log("Purge deleted: evicted " + evicted + " loaded chunk(s) from " - + scan.deleteableRegions().size() + " target region(s)"); + + scan.deletableRegions().size() + " target region(s)"); } private int evictRegion(World world, int baseCx, int baseCz) { @@ -550,13 +550,13 @@ private boolean writeTimestampTable(File regionFile, long targetSeconds) { * cannot be deleted, returning blocking statistics. */ private FilterStats filterNonDeletableRegions( - Map, Set> deleteableRegions, int days) { + Map, Set> deletableRegions, int days) { int islandsOverLevel = 0; int islandsPurgeProtected = 0; int regionsBlockedByLevel = 0; int regionsBlockedByProtection = 0; - var iter = deleteableRegions.entrySet().iterator(); + var iter = deletableRegions.entrySet().iterator(); while (iter.hasNext()) { var entry = iter.next(); int[] regionCounts = evaluateRegionIslands(entry.getValue(), days); @@ -579,9 +579,9 @@ private FilterStats filterNonDeletableRegions( * matters. */ private FilterStats filterForDeletedSweep( - Map, Set> deleteableRegions) { + Map, Set> deletableRegions) { int regionsBlockedByProtection = 0; - var iter = deleteableRegions.entrySet().iterator(); + var iter = deletableRegions.entrySet().iterator(); while (iter.hasNext()) { var entry = iter.next(); boolean block = false; @@ -827,7 +827,7 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { // 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.deleteableRegions().keySet()) { + 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())) { @@ -840,7 +840,7 @@ private boolean deleteRegionFiles(PurgeScanResult scan) { DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); DimFolders end = new DimFolders(endRegion, endEntities, endPoi); boolean allOk = true; - for (Pair coords : scan.deleteableRegions().keySet()) { + 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"); @@ -886,15 +886,15 @@ private boolean deleteIfExists(File file) { // --------------------------------------------------------------- private void deletePlayerFromWorldFolder(World world, String islandID, - Map, Set> deleteableRegions, int days) { + Map, Set> deletableRegions, int days) { File playerData = resolvePlayerDataFolder(world); plugin.getIslands().getIslandById(islandID) .ifPresent(island -> island.getMemberSet() - .forEach(uuid -> maybeDeletePlayerData(world, uuid, playerData, deleteableRegions, days))); + .forEach(uuid -> maybeDeletePlayerData(world, uuid, playerData, deletableRegions, days))); } private void maybeDeletePlayerData(World world, UUID uuid, File playerData, - Map, Set> deleteableRegions, int days) { + 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. @@ -902,7 +902,7 @@ private void maybeDeletePlayerData(World world, UUID uuid, File playerData, return; } List memberOf = new ArrayList<>(plugin.getIslands().getIslands(world, uuid)); - deleteableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); + deletableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); if (!memberOf.isEmpty()) { return; } diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 486b5399f..88fa8c416 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -120,6 +120,7 @@ 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' diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java index 47d4f33a9..81556055b 100644 --- a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -145,7 +145,7 @@ void testScanDeletedLoneDeletableIsland() { PurgeScanResult result = service.scanDeleted(world); assertFalse(result.isEmpty()); - assertEquals(1, result.deleteableRegions().size()); + assertEquals(1, result.deletableRegions().size()); assertEquals(0, result.days()); } @@ -171,7 +171,7 @@ void testScanDeletedIslandStraddlesRegionBoundary() { when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); PurgeScanResult result = service.scanDeleted(world); - assertEquals(2, result.deleteableRegions().size(), + assertEquals(2, result.deletableRegions().size(), "Island straddling r.0.0 and r.1.0 should produce two candidate regions"); } @@ -233,7 +233,7 @@ void testScanDeletedMissingIslandRowDoesNotBlock() { PurgeScanResult result = service.scanDeleted(world); assertFalse(result.isEmpty(), "Ghost island (no DB row) must not block the reap"); - assertEquals(1, result.deleteableRegions().size()); + assertEquals(1, result.deletableRegions().size()); } /** From 8da7109752b7a454953c63281af3e9903d80a369 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 12 Apr 2026 05:20:30 -0700 Subject: [PATCH 17/39] Fix spurious defaultRank warnings for PVP setting flags The minimumRank validation was clamping SETTING flags that use -1 (disabled) as their defaultRank. Since -1 is a valid disabled state for SETTING/WORLD_SETTING flags (Island.isAllowed checks >= 0), restrict the validation to PROTECTION flags only. Co-Authored-By: Claude Opus 4.6 --- .../world/bentobox/bentobox/api/flags/Flag.java | 6 ++++-- .../world/bentobox/bentobox/api/flags/FlagTest.java | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/api/flags/Flag.java b/src/main/java/world/bentobox/bentobox/api/flags/Flag.java index 0b9680388..3033d197c 100644 --- a/src/main/java/world/bentobox/bentobox/api/flags/Flag.java +++ b/src/main/java/world/bentobox/bentobox/api/flags/Flag.java @@ -797,8 +797,10 @@ public Builder hideWhen(HideWhen hideWhen) { * @return Flag */ public Flag build() { - // Ensure the default rank is not below the minimum selectable rank - if (defaultRank < minimumRank) { + // Ensure the default rank is not below the minimum selectable rank. + // Only applies to PROTECTION flags — SETTING/WORLD_SETTING flags use -1 + // as a valid "disabled" state (Island.isAllowed checks >= 0). + if (type == Type.PROTECTION && defaultRank < minimumRank) { BentoBox.getInstance().logWarning("Flag " + id + " defaultRank (" + defaultRank + ") is below minimumRank (" + minimumRank + "); raising defaultRank to minimumRank."); defaultRank = minimumRank; diff --git a/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java b/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java index 4dc4f167e..0746be2e7 100644 --- a/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java +++ b/src/test/java/world/bentobox/bentobox/api/flags/FlagTest.java @@ -450,4 +450,17 @@ void testDefaultRankClampedToMinimumRank() { .build(); assertEquals(RanksManager.MEMBER_RANK, flag.getDefaultRank()); } + + /** + * SETTING flags should allow -1 (disabled) as defaultRank without clamping, + * since Island.isAllowed() uses >= 0 as the enabled threshold. + */ + @Test + void testSettingFlagAllowsNegativeDefaultRank() { + Flag flag = new Flag.Builder("pvp_test", Material.ARROW) + .type(Flag.Type.SETTING) + .defaultRank(-1) + .build(); + assertEquals(-1, flag.getDefaultRank()); + } } From 5b79eb7a372c680b0d4a9fbd452c79f31faef728 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 12 Apr 2026 05:21:45 -0700 Subject: [PATCH 18/39] Update build version to 3.14.2 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 3c4a888d8..c2dd75be3 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 = "3.14.2" val buildNumberDefault = "-LOCAL" // Local build identifier val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version From 9eb101a45e1988bf4cac53ee42971b17d5fb75cc Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 12 Apr 2026 21:31:58 -0700 Subject: [PATCH 19/39] Add compatibility version V26_1_2 for 3.14.2 --- .../bentobox/bentobox/versions/ServerCompatibility.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/bentobox/versions/ServerCompatibility.java b/src/main/java/world/bentobox/bentobox/versions/ServerCompatibility.java index 6ed4fe903..825e058b4 100644 --- a/src/main/java/world/bentobox/bentobox/versions/ServerCompatibility.java +++ b/src/main/java/world/bentobox/bentobox/versions/ServerCompatibility.java @@ -173,7 +173,11 @@ public enum ServerVersion { /** * @since 3.12.2 */ - V26_1_1(Compatibility.COMPATIBLE),; + V26_1_1(Compatibility.COMPATIBLE), + /** + * @since 3.14.2 + */ + V26_1_2(Compatibility.COMPATIBLE),; private final Compatibility compatibility; From f92c393fe78b544e4bc1dc6c01db433193a7c882 Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 14 Apr 2026 03:58:07 -0700 Subject: [PATCH 20/39] feat: support item model keys and namespaced materials for blueprint bundle icon The "icon" field in blueprint bundle JSON files now accepts three formats: - Plain material name: "DIAMOND" - Vanilla namespaced material: "minecraft:diamond" - Custom item model key: "myserver:island_tropical" Material.matchMaterial() is tried first, handling plain names and vanilla namespaced keys. If the string contains a colon and is not a recognised vanilla material, it is treated as a custom item model key applied to a PAPER base item via ItemMeta.setItemModel(). getIcon() (returning Material) is preserved for binary compatibility. getIconItemStack() is the new method that returns the full ItemStack with model data applied. Panels now call getIconItemStack() to render icons. Closes #2940 Co-Authored-By: Claude Sonnet 4.6 --- .../dataobjects/BlueprintBundle.java | 77 +++++++++- .../panels/BlueprintManagementPanel.java | 4 +- .../customizable/IslandCreationPanel.java | 2 +- .../dataobjects/BlueprintBundleTest.java | 133 ++++++++++++++++++ .../panels/BlueprintManagementPanelTest.java | 5 + .../customizable/IslandCreationPanelTest.java | 4 + 6 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java diff --git a/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java b/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java index 788e5916e..b83e58477 100644 --- a/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java +++ b/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java @@ -6,7 +6,10 @@ import java.util.Map; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.World; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; import com.google.gson.annotations.Expose; @@ -27,10 +30,12 @@ public class BlueprintBundle implements DataObject { @Expose private String uniqueId; /** - * Icon of the bundle + * Icon of the bundle. Supports plain material names (e.g. "DIAMOND"), + * vanilla namespaced materials (e.g. "minecraft:diamond"), and custom + * item model keys (e.g. "myserver:island_tropical"). */ @Expose - private Material icon = Material.PAPER; + private String icon = "PAPER"; /** * Name on the icon */ @@ -97,16 +102,76 @@ public void setUniqueId(String uniqueId) { this.uniqueId = uniqueId; } /** - * @return the icon + * Returns the base Material for this bundle's icon. + * Resolves plain names ("DIAMOND") and vanilla namespaced keys ("minecraft:diamond") + * via {@link Material#matchMaterial}. For custom item-model keys that are not + * valid vanilla materials (e.g. "myserver:island_tropical"), returns {@link Material#PAPER} + * as the base item — use {@link #getIconItemStack()} to get the full item with model data. + * @return the icon material, never null */ public Material getIcon() { - return icon; + if (icon == null) { + return Material.PAPER; + } + Material m = Material.matchMaterial(icon); + return m != null ? m : Material.PAPER; + } + + /** + * Returns an {@link ItemStack} representing this bundle's icon. + *

    + *
  • Plain material name (e.g. {@code "DIAMOND"}) → {@code new ItemStack(Material.DIAMOND)}
  • + *
  • Vanilla namespaced material (e.g. {@code "minecraft:diamond"}) → same as above
  • + *
  • Custom item-model key (e.g. {@code "myserver:island_tropical"}) → PAPER base item + * with the model key set via {@link ItemMeta#setItemModel(NamespacedKey)}
  • + *
+ * @return ItemStack for this bundle's icon, never null + * @since 3.0.0 + */ + public ItemStack getIconItemStack() { + if (icon == null) { + return new ItemStack(Material.PAPER); + } + // matchMaterial handles plain names ("DIAMOND") and namespaced vanilla ("minecraft:diamond") + Material m = Material.matchMaterial(icon); + if (m != null) { + return new ItemStack(m); + } + // Contains a colon but isn't a vanilla material → treat as a custom item model key + if (icon.contains(":")) { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + String[] parts = icon.split(":", 2); + try { + meta.setItemModel(new NamespacedKey(parts[0], parts[1])); + item.setItemMeta(meta); + } catch (IllegalArgumentException ignored) { + // Invalid namespace/key format — fall through and return plain PAPER + } + } + return item; + } + return new ItemStack(Material.PAPER); } + /** - * @param icon the icon to set + * Sets the icon from a Material (backward-compatible setter). + * @param icon the icon material to set; if null, defaults to {@link Material#PAPER} */ public void setIcon(Material icon) { - this.icon = icon; + this.icon = icon != null ? icon.name() : "PAPER"; + } + + /** + * Sets the icon from a string. Accepts plain material names (e.g. {@code "DIAMOND"}), + * vanilla namespaced materials (e.g. {@code "minecraft:diamond"}), and custom item-model + * keys (e.g. {@code "myserver:island_tropical"}). + * @param icon the icon string; if null, defaults to {@code "PAPER"} + * @since 3.0.0 + */ + public void setIcon(String icon) { + this.icon = icon != null ? icon : "PAPER"; } /** * @return the displayName diff --git a/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java b/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java index 3f1d52c6b..88a82dc72 100644 --- a/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java +++ b/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java @@ -134,7 +134,7 @@ public void openPanel() { PanelItem item = new PanelItemBuilder() .name(bb.getDisplayName()) .description(t("edit"), t("rename")) - .icon(bb.getIcon()) + .icon(bb.getIconItemStack()) .clickHandler((panel, u, clickType, s) -> { u.closeInventory(); if (clickType.equals(ClickType.RIGHT)) { @@ -350,7 +350,7 @@ protected PanelItem getBundleIcon(BlueprintBundle bb) { return new PanelItemBuilder() .name(t("edit-description")) .description(bb.getDescription()) - .icon(bb.getIcon()) + .icon(bb.getIconItemStack()) .clickHandler((panel, u, clickType, slot) -> { u.closeInventory(); // Description conversation diff --git a/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java b/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java index d47f01bac..953450779 100644 --- a/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java +++ b/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java @@ -414,7 +414,7 @@ private void applyTemplate(PanelItemBuilder builder, ItemTemplateRecord template } else { - builder.icon(bundle.getIcon()); + builder.icon(bundle.getIconItemStack()); } if (template.title() != null) diff --git a/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java b/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java new file mode 100644 index 000000000..82a05946a --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java @@ -0,0 +1,133 @@ +package world.bentobox.bentobox.blueprints.dataobjects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.CommonTestSetup; + +/** + * Tests for {@link BlueprintBundle} icon field parsing. + * @author tastybento + */ +class BlueprintBundleTest extends CommonTestSetup { + + private BlueprintBundle bundle; + + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + bundle = new BlueprintBundle(); + } + + @AfterEach + @Override + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * Default icon should be PAPER. + */ + @Test + void testDefaultIcon() { + assertEquals(Material.PAPER, bundle.getIcon()); + } + + /** + * Default getIconItemStack should return a PAPER ItemStack. + */ + @Test + void testDefaultIconItemStack() { + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.PAPER, is.getType()); + } + + /** + * Plain material name (e.g. "DIAMOND") should resolve correctly. + */ + @Test + void testPlainMaterialName() { + bundle.setIcon("DIAMOND"); + assertEquals(Material.DIAMOND, bundle.getIcon()); + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.DIAMOND, is.getType()); + } + + /** + * Setting icon via Material enum should store correctly. + */ + @Test + void testSetIconMaterial() { + bundle.setIcon(Material.GOLD_INGOT); + assertEquals(Material.GOLD_INGOT, bundle.getIcon()); + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.GOLD_INGOT, is.getType()); + } + + /** + * Setting icon via Material enum with null should fall back to PAPER. + */ + @Test + void testSetIconMaterialNull() { + bundle.setIcon((Material) null); + assertEquals(Material.PAPER, bundle.getIcon()); + } + + /** + * Setting icon via String with null should fall back to PAPER. + */ + @Test + void testSetIconStringNull() { + bundle.setIcon((String) null); + assertEquals(Material.PAPER, bundle.getIcon()); + } + + /** + * Vanilla namespaced material key (e.g. "minecraft:diamond") should resolve to the correct Material. + */ + @Test + void testNamespacedVanillaMaterial() { + bundle.setIcon("minecraft:diamond"); + assertEquals(Material.DIAMOND, bundle.getIcon()); + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.DIAMOND, is.getType()); + } + + /** + * A custom item-model key (namespace:key that is not a vanilla material) should return + * PAPER as the base material, since the player never sees the base item. + */ + @Test + void testCustomItemModelKey() { + bundle.setIcon("myserver:island_tropical"); + // getIcon() falls back to PAPER for unrecognised model keys + assertEquals(Material.PAPER, bundle.getIcon()); + // getIconItemStack() returns a PAPER-based item + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.PAPER, is.getType()); + } + + /** + * An icon string without a colon that is not a valid material should fall back to PAPER. + */ + @Test + void testUnknownMaterialName() { + bundle.setIcon("NOT_A_REAL_MATERIAL"); + assertEquals(Material.PAPER, bundle.getIcon()); + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.PAPER, is.getType()); + } +} diff --git a/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java b/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java index 7bca07f25..eb429ca17 100644 --- a/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java +++ b/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java @@ -17,6 +17,7 @@ import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -80,6 +81,7 @@ public void setUp() throws Exception { when(bb.getUniqueId()).thenReturn("test"); when(bb.getDisplayName()).thenReturn("test"); when(bb.getIcon()).thenReturn(Material.STONE); + when(bb.getIconItemStack()).thenReturn(new ItemStack(Material.STONE)); when(bb.getDescription()).thenReturn(Collections.singletonList("A description")); when(bb.getCommands()).thenReturn(Collections.emptyList()); when(bb.getSlot()).thenReturn(5); @@ -87,12 +89,14 @@ public void setUp() throws Exception { when(bb2.getUniqueId()).thenReturn("test2"); when(bb2.getDisplayName()).thenReturn("test2"); when(bb2.getIcon()).thenReturn(Material.ACACIA_BOAT); + when(bb2.getIconItemStack()).thenReturn(new ItemStack(Material.ACACIA_BOAT)); when(bb2.getDescription()).thenReturn(Collections.singletonList("A description 2")); when(bb2.getSlot()).thenReturn(-5); // Too large slot for panel when(bb3.getUniqueId()).thenReturn("test3"); when(bb3.getDisplayName()).thenReturn("test3"); when(bb3.getIcon()).thenReturn(Material.BAKED_POTATO); + when(bb3.getIconItemStack()).thenReturn(new ItemStack(Material.BAKED_POTATO)); when(bb3.getDescription()).thenReturn(Collections.singletonList("A description 3")); when(bb3.getSlot()).thenReturn(65); @@ -224,6 +228,7 @@ void testOpenPanelWithManyBundles() { when(bundle.getUniqueId()).thenReturn("bundle" + i); when(bundle.getDisplayName()).thenReturn("Bundle " + String.format("%02d", i)); when(bundle.getIcon()).thenReturn(Material.STONE); + when(bundle.getIconItemStack()).thenReturn(new ItemStack(Material.STONE)); when(bundle.getDescription()).thenReturn(Collections.singletonList("Desc")); map.put("bundle" + i, bundle); } diff --git a/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java b/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java index d79fdc053..26785bca3 100644 --- a/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java +++ b/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java @@ -20,6 +20,7 @@ import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.inventory.ItemStack; import org.bukkit.entity.Player; import org.bukkit.plugin.PluginDescriptionFile; import org.junit.jupiter.api.AfterEach; @@ -128,6 +129,7 @@ public void setUp() throws Exception { when(bundle1.getUniqueId()).thenReturn("default"); when(bundle1.getDisplayName()).thenReturn("Default"); when(bundle1.getIcon()).thenReturn(Material.GRASS_BLOCK); + when(bundle1.getIconItemStack()).thenReturn(new ItemStack(Material.GRASS_BLOCK)); when(bundle1.getDescription()).thenReturn(Collections.singletonList("Default island")); when(bundle1.getSlot()).thenReturn(0); when(bundle1.isRequirePermission()).thenReturn(false); @@ -138,6 +140,7 @@ public void setUp() throws Exception { when(bundle2.getUniqueId()).thenReturn("nether"); when(bundle2.getDisplayName()).thenReturn("Nether"); when(bundle2.getIcon()).thenReturn(Material.NETHERRACK); + when(bundle2.getIconItemStack()).thenReturn(new ItemStack(Material.NETHERRACK)); when(bundle2.getDescription()).thenReturn(Collections.singletonList("Nether island")); when(bundle2.getSlot()).thenReturn(1); when(bundle2.isRequirePermission()).thenReturn(false); @@ -148,6 +151,7 @@ public void setUp() throws Exception { when(bundle3.getUniqueId()).thenReturn("end"); when(bundle3.getDisplayName()).thenReturn("End"); when(bundle3.getIcon()).thenReturn(Material.END_STONE); + when(bundle3.getIconItemStack()).thenReturn(new ItemStack(Material.END_STONE)); when(bundle3.getDescription()).thenReturn(Collections.singletonList("End island")); when(bundle3.getSlot()).thenReturn(2); when(bundle3.isRequirePermission()).thenReturn(false); From 08fffa0b63b6d14debb4710feed85f864369e13a Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 14 Apr 2026 03:59:36 -0700 Subject: [PATCH 21/39] Update build version to 3.15.0 Co-Authored-By: Claude Sonnet 4.6 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index c2dd75be3..caab2525a 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.2" +val buildVersion = "3.15.0" val buildNumberDefault = "-LOCAL" // Local build identifier val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version From 3054afb0653910d35c2e17040020aab24392ad70 Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 14 Apr 2026 04:05:39 -0700 Subject: [PATCH 22/39] feat: detect item model on clicked item when setting bundle icon in-game When an admin clicks an item from their inventory to set a blueprint bundle icon, IconChanger now checks ItemMeta.hasItemModel() first. If the item carries a custom model key (e.g. paper[item_model="myserver:island_tropical"]), that NamespacedKey string is stored instead of the plain Material name, preserving the full item model through to getIconItemStack(). Plain material items continue to work exactly as before. Co-Authored-By: Claude Sonnet 4.6 --- .../bentobox/bentobox/panels/IconChanger.java | 11 +- .../bentobox/panels/IconChangerTest.java | 179 ++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java diff --git a/src/main/java/world/bentobox/bentobox/panels/IconChanger.java b/src/main/java/world/bentobox/bentobox/panels/IconChanger.java index 809b15455..cab5d688c 100644 --- a/src/main/java/world/bentobox/bentobox/panels/IconChanger.java +++ b/src/main/java/world/bentobox/bentobox/panels/IconChanger.java @@ -6,6 +6,7 @@ import org.bukkit.Sound; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.meta.ItemMeta; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.GameModeAddon; @@ -48,8 +49,14 @@ public void onInventoryClick(User user, InventoryClickEvent event) { Entry selected = blueprintManagementPanel.getSelected(); user.getPlayer().playSound(user.getLocation(), Sound.BLOCK_METAL_HIT, 1F, 1F); if (selected == null) { - // Change the Bundle Icon - bb.setIcon(icon); + // Change the Bundle Icon — prefer item model key over plain material so that + // datapacked items (e.g. paper[item_model="myserver:island_tropical"]) are stored correctly. + ItemMeta meta = event.getCurrentItem().getItemMeta(); + if (meta != null && meta.hasItemModel()) { + bb.setIcon(meta.getItemModel().toString()); + } else { + bb.setIcon(icon); + } // Save it plugin.getBlueprintsManager().saveBlueprintBundle(addon, bb); diff --git a/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java new file mode 100644 index 000000000..f68deaf21 --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java @@ -0,0 +1,179 @@ +package world.bentobox.bentobox.panels; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.AbstractMap; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Sound; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.blueprints.Blueprint; +import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBundle; +import world.bentobox.bentobox.managers.BlueprintsManager; + +/** + * Tests for {@link IconChanger}. + */ +class IconChangerTest extends CommonTestSetup { + + @Mock + private GameModeAddon addon; + @Mock + private BlueprintManagementPanel bmp; + @Mock + private BlueprintBundle bb; + @Mock + private BlueprintsManager bpManager; + @Mock + private User user; + @Mock + private InventoryClickEvent event; + + private IconChanger iconChanger; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(plugin.getBlueprintsManager()).thenReturn(bpManager); + when(bmp.getSelected()).thenReturn(null); // no blueprint selected by default + when(user.getPlayer()).thenReturn(mockPlayer); + when(user.getLocation()).thenReturn(location); + iconChanger = new IconChanger(plugin, addon, bmp, bb); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * Clicking a plain item (no item model) in the player inventory sets the bundle icon by Material. + */ + @Test + void testOnInventoryClickBundlePlainMaterial() { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.STONE); + ItemMeta meta = mock(ItemMeta.class); + when(meta.hasItemModel()).thenReturn(false); + when(item.getItemMeta()).thenReturn(meta); + + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); // player inventory slot + + iconChanger.onInventoryClick(user, event); + + verify(bb).setIcon(Material.STONE); + verify(bb, never()).setIcon(any(String.class)); + verify(bpManager).saveBlueprintBundle(addon, bb); + } + + /** + * Clicking an item that has a custom item model sets the bundle icon by model key string. + */ + @Test + void testOnInventoryClickBundleItemModel() { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.PAPER); + ItemMeta meta = mock(ItemMeta.class); + NamespacedKey modelKey = new NamespacedKey("myserver", "island_tropical"); + when(meta.hasItemModel()).thenReturn(true); + when(meta.getItemModel()).thenReturn(modelKey); + when(item.getItemMeta()).thenReturn(meta); + + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bb).setIcon("myserver:island_tropical"); + verify(bb, never()).setIcon(any(Material.class)); + verify(bpManager).saveBlueprintBundle(addon, bb); + } + + /** + * Clicking a plain item when a blueprint is selected changes the blueprint icon, not the bundle. + */ + @Test + void testOnInventoryClickBlueprintSelected() { + Blueprint bp = mock(Blueprint.class); + when(bmp.getSelected()).thenReturn(new AbstractMap.SimpleEntry<>(1, bp)); + + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.BEACON); + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bp).setIcon(Material.BEACON); + verify(bpManager).saveBlueprint(addon, bp); + verify(bb, never()).setIcon(any(Material.class)); + verify(bb, never()).setIcon(any(String.class)); + } + + /** + * Clicking inside the panel (slot ≤ 44) does nothing. + */ + @Test + void testOnInventoryClickInsidePanel() { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.STONE); + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(10); // inside the GUI + + iconChanger.onInventoryClick(user, event); + + verify(bb, never()).setIcon(any(Material.class)); + verify(bb, never()).setIcon(any(String.class)); + verify(bpManager, never()).saveBlueprintBundle(any(), any()); + } + + /** + * Clicking an AIR slot does nothing. + */ + @Test + void testOnInventoryClickAirItem() { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.AIR); + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bb, never()).setIcon(any(Material.class)); + verify(bb, never()).setIcon(any(String.class)); + verify(bpManager, never()).saveBlueprintBundle(any(), any()); + } + + /** + * Clicking a null item does nothing. + */ + @Test + void testOnInventoryClickNullItem() { + when(event.getCurrentItem()).thenReturn(null); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bb, never()).setIcon(any(Material.class)); + verify(bb, never()).setIcon(any(String.class)); + verify(bpManager, never()).saveBlueprintBundle(any(), any()); + } +} From 472de0f640e4817d1aa6a1d3a311cf08793b37d8 Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 14 Apr 2026 04:11:06 -0700 Subject: [PATCH 23/39] feat: extend item-model/namespaced icon support to Blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the same logic as BlueprintBundle to Blueprint.java: - icon field changed from Material to String internally - getIcon() preserved (returns Material, backward compatible) - getIconItemStack() added — resolves plain names, minecraft: keys, and custom item-model keys (PAPER base + ItemMeta.setItemModel) - setIcon(Material) and setIcon(String) both fluently return Blueprint BlueprintManagementPanel now calls blueprint.getIconItemStack() so custom-model icons are visible to admins in the management GUI. IconChanger also applies item-model detection in the blueprint branch, so admins can click a datapacked item to set a blueprint icon the same way as a bundle icon. Co-Authored-By: Claude Sonnet 4.6 --- .../bentobox/blueprints/Blueprint.java | 78 +++++++++++++++++-- .../panels/BlueprintManagementPanel.java | 2 +- .../bentobox/bentobox/panels/IconChanger.java | 9 ++- .../panels/BlueprintManagementPanelTest.java | 2 + .../bentobox/panels/IconChangerTest.java | 30 ++++++- 5 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java b/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java index 8433f2cbb..a9c0d212a 100644 --- a/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java +++ b/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java @@ -5,7 +5,10 @@ import java.util.Map; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.util.Vector; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; import org.eclipse.jdt.annotation.NonNull; import com.google.gson.annotations.Expose; @@ -27,8 +30,13 @@ public class Blueprint { private @NonNull String name = ""; @Expose private String displayName; + /** + * Icon of the blueprint. Supports plain material names (e.g. "DIAMOND"), + * vanilla namespaced materials (e.g. "minecraft:diamond"), and custom + * item model keys (e.g. "myserver:island_tropical"). + */ @Expose - private @NonNull Material icon = Material.PAPER; + private String icon = "PAPER"; @Expose private List description; @Expose @@ -77,17 +85,77 @@ public Blueprint setDisplayName(String displayName) { return this; } /** - * @return the icon + * Returns the base Material for this blueprint's icon. + * Resolves plain names ("DIAMOND") and vanilla namespaced keys ("minecraft:diamond") + * via {@link Material#matchMaterial}. For custom item-model keys that are not + * valid vanilla materials (e.g. "myserver:island_tropical"), returns {@link Material#PAPER} + * as the base item — use {@link #getIconItemStack()} to get the full item with model data. + * @return the icon material, never null */ public @NonNull Material getIcon() { - return icon; + if (icon == null) { + return Material.PAPER; + } + Material m = Material.matchMaterial(icon); + return m != null ? m : Material.PAPER; } + /** - * @param icon the icon to set + * Returns an {@link ItemStack} representing this blueprint's icon. + *
    + *
  • Plain material name (e.g. {@code "DIAMOND"}) → {@code new ItemStack(Material.DIAMOND)}
  • + *
  • Vanilla namespaced material (e.g. {@code "minecraft:diamond"}) → same as above
  • + *
  • Custom item-model key (e.g. {@code "myserver:island_tropical"}) → PAPER base item + * with the model key set via {@link ItemMeta#setItemModel(NamespacedKey)}
  • + *
+ * @return ItemStack for this blueprint's icon, never null + * @since 3.0.0 + */ + public @NonNull ItemStack getIconItemStack() { + if (icon == null) { + return new ItemStack(Material.PAPER); + } + Material m = Material.matchMaterial(icon); + if (m != null) { + return new ItemStack(m); + } + if (icon.contains(":")) { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + String[] parts = icon.split(":", 2); + try { + meta.setItemModel(new NamespacedKey(parts[0], parts[1])); + item.setItemMeta(meta); + } catch (IllegalArgumentException ignored) { + // Invalid namespace/key format — fall through and return plain PAPER + } + } + return item; + } + return new ItemStack(Material.PAPER); + } + + /** + * Sets the icon from a Material (backward-compatible setter). + * @param icon the icon material to set; if null, defaults to {@link Material#PAPER} * @return blueprint */ public Blueprint setIcon(Material icon) { - this.icon = icon; + this.icon = icon != null ? icon.name() : "PAPER"; + return this; + } + + /** + * Sets the icon from a string. Accepts plain material names (e.g. {@code "DIAMOND"}), + * vanilla namespaced materials (e.g. {@code "minecraft:diamond"}), and custom item-model + * keys (e.g. {@code "myserver:island_tropical"}). + * @param icon the icon string; if null, defaults to {@code "PAPER"} + * @return blueprint + * @since 3.0.0 + */ + public Blueprint setIcon(String icon) { + this.icon = icon != null ? icon : "PAPER"; return this; } /** diff --git a/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java b/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java index 88a82dc72..6a81375cc 100644 --- a/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java +++ b/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java @@ -458,7 +458,7 @@ protected PanelItem getBlueprintItem(GameModeAddon addon, int pos, BlueprintBund return new PanelItemBuilder() .name(blueprint.getDisplayName() == null ? blueprint.getName() : blueprint.getDisplayName()) .description(desc) - .icon(blueprint.getIcon()) + .icon(blueprint.getIconItemStack()) .glow(selected != null && pos == selected.getKey()) .clickHandler((panel, u, clickType, slot) -> { // Handle the world squares diff --git a/src/main/java/world/bentobox/bentobox/panels/IconChanger.java b/src/main/java/world/bentobox/bentobox/panels/IconChanger.java index cab5d688c..fdccef3ff 100644 --- a/src/main/java/world/bentobox/bentobox/panels/IconChanger.java +++ b/src/main/java/world/bentobox/bentobox/panels/IconChanger.java @@ -61,9 +61,14 @@ public void onInventoryClick(User user, InventoryClickEvent event) { plugin.getBlueprintsManager().saveBlueprintBundle(addon, bb); } else { - // Change the Blueprint icon + // Change the Blueprint icon — same model-key detection as for bundles Blueprint bp = selected.getValue(); - bp.setIcon(icon); + ItemMeta bpMeta = event.getCurrentItem().getItemMeta(); + if (bpMeta != null && bpMeta.hasItemModel()) { + bp.setIcon(bpMeta.getItemModel().toString()); + } else { + bp.setIcon(icon); + } // Save it plugin.getBlueprintsManager().saveBlueprint(addon, bp); } diff --git a/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java b/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java index eb429ca17..8e11f9144 100644 --- a/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java +++ b/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java @@ -171,6 +171,7 @@ void testGetBlueprintItem() { void testGetBlueprintItemWithDisplayNameAndIcon() { when(blueprint.getDisplayName()).thenReturn("Display Name"); when(blueprint.getIcon()).thenReturn(Material.BEACON); + when(blueprint.getIconItemStack()).thenReturn(new ItemStack(Material.BEACON)); PanelItem pi = bmp.getBlueprintItem(addon, 0, bb, blueprint); assertEquals("Display Name", pi.getName()); assertEquals(Material.BEACON, pi.getItem().getType()); @@ -184,6 +185,7 @@ void testGetBlueprintItemWithDisplayNameAndIcon() { void testGetBlueprintItemWithDisplayNameAndIconInWorldSlot() { when(blueprint.getDisplayName()).thenReturn("Display Name"); when(blueprint.getIcon()).thenReturn(Material.BEACON); + when(blueprint.getIconItemStack()).thenReturn(new ItemStack(Material.BEACON)); PanelItem pi = bmp.getBlueprintItem(addon, 5, bb, blueprint); assertEquals("Display Name", pi.getName()); assertEquals(Material.BEACON, pi.getItem().getType()); diff --git a/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java index f68deaf21..57d64a6b2 100644 --- a/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java +++ b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java @@ -108,7 +108,7 @@ void testOnInventoryClickBundleItemModel() { } /** - * Clicking a plain item when a blueprint is selected changes the blueprint icon, not the bundle. + * Clicking a plain item when a blueprint is selected changes the blueprint icon by Material. */ @Test void testOnInventoryClickBlueprintSelected() { @@ -117,6 +117,9 @@ void testOnInventoryClickBlueprintSelected() { ItemStack item = mock(ItemStack.class); when(item.getType()).thenReturn(Material.BEACON); + ItemMeta meta = mock(ItemMeta.class); + when(meta.hasItemModel()).thenReturn(false); + when(item.getItemMeta()).thenReturn(meta); when(event.getCurrentItem()).thenReturn(item); when(event.getRawSlot()).thenReturn(45); @@ -128,6 +131,31 @@ void testOnInventoryClickBlueprintSelected() { verify(bb, never()).setIcon(any(String.class)); } + /** + * Clicking a custom item model item when a blueprint is selected stores the model key string. + */ + @Test + void testOnInventoryClickBlueprintItemModel() { + Blueprint bp = mock(Blueprint.class); + when(bmp.getSelected()).thenReturn(new AbstractMap.SimpleEntry<>(1, bp)); + + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.PAPER); + ItemMeta meta = mock(ItemMeta.class); + NamespacedKey modelKey = new NamespacedKey("myserver", "island_tropical"); + when(meta.hasItemModel()).thenReturn(true); + when(meta.getItemModel()).thenReturn(modelKey); + when(item.getItemMeta()).thenReturn(meta); + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bp).setIcon("myserver:island_tropical"); + verify(bp, never()).setIcon(any(Material.class)); + verify(bpManager).saveBlueprint(addon, bp); + } + /** * Clicking inside the panel (slot ≤ 44) does nothing. */ From c13d6e0a43b2febc92827d56b14b107d66f4589b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:19:16 +0000 Subject: [PATCH 24/39] Initial plan From f33acd8e78e02190dc516e92de9dc1c917d0422c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:20:08 +0000 Subject: [PATCH 25/39] chore: add .paper-nms/ to .gitignore and untrack committed files Agent-Logs-Url: https://github.com/BentoBoxWorld/BentoBox/sessions/4b1a4ffa-5cf3-4123-9b9c-80fb712c76fc Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .gitignore | 3 +++ .paper-nms/1.21.11/mappings_1.21.11.tiny.missing | 0 2 files changed, 3 insertions(+) delete mode 100644 .paper-nms/1.21.11/mappings_1.21.11.tiny.missing diff --git a/.gitignore b/.gitignore index 1611f19b2..5bcbb5c38 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ $RECYCLE.BIN/ *.ear hs_err_pid* + # Paper NMS +.paper-nms/ + # Maven target/ pom.xml.tag diff --git a/.paper-nms/1.21.11/mappings_1.21.11.tiny.missing b/.paper-nms/1.21.11/mappings_1.21.11.tiny.missing deleted file mode 100644 index e69de29bb..000000000 From 7fe828e4fac6685615df96135e9ef66ece20a1d6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 14 Apr 2026 04:35:26 -0700 Subject: [PATCH 26/39] refactor: extract icon resolution into ItemParser to eliminate duplication SonarCloud flagged 8.9% code duplication and repeated "PAPER" literals. - Add ItemParser.parseIconMaterial() and parseIconItemStack() as the single source of truth for icon string resolution - Both BlueprintBundle and Blueprint now delegate to these methods instead of duplicating the logic - Replace repeated "PAPER" string literals with a DEFAULT_ICON constant in each class - Remove unused Sound import from IconChangerTest Co-Authored-By: Claude Sonnet 4.6 --- .../bentobox/blueprints/Blueprint.java | 42 +++----------- .../dataobjects/BlueprintBundle.java | 44 +++------------ .../bentobox/bentobox/util/ItemParser.java | 56 +++++++++++++++++++ .../bentobox/panels/IconChangerTest.java | 1 - 4 files changed, 74 insertions(+), 69 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java b/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java index a9c0d212a..13bdf1672 100644 --- a/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java +++ b/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java @@ -5,16 +5,15 @@ import java.util.Map; import org.bukkit.Material; -import org.bukkit.NamespacedKey; import org.bukkit.util.Vector; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; import org.eclipse.jdt.annotation.NonNull; import com.google.gson.annotations.Expose; import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBlock; import world.bentobox.bentobox.blueprints.dataobjects.BlueprintEntity; +import world.bentobox.bentobox.util.ItemParser; /** * Stores all details of a blueprint @@ -23,6 +22,8 @@ */ public class Blueprint { + private static final String DEFAULT_ICON = "PAPER"; + /** * Unique name for this blueprint. The filename will be this plus the blueprint suffix */ @@ -36,7 +37,7 @@ public class Blueprint { * item model keys (e.g. "myserver:island_tropical"). */ @Expose - private String icon = "PAPER"; + private String icon = DEFAULT_ICON; @Expose private List description; @Expose @@ -93,11 +94,7 @@ public Blueprint setDisplayName(String displayName) { * @return the icon material, never null */ public @NonNull Material getIcon() { - if (icon == null) { - return Material.PAPER; - } - Material m = Material.matchMaterial(icon); - return m != null ? m : Material.PAPER; + return ItemParser.parseIconMaterial(icon); } /** @@ -106,34 +103,13 @@ public Blueprint setDisplayName(String displayName) { *
  • Plain material name (e.g. {@code "DIAMOND"}) → {@code new ItemStack(Material.DIAMOND)}
  • *
  • Vanilla namespaced material (e.g. {@code "minecraft:diamond"}) → same as above
  • *
  • Custom item-model key (e.g. {@code "myserver:island_tropical"}) → PAPER base item - * with the model key set via {@link ItemMeta#setItemModel(NamespacedKey)}
  • + * with the model key set via {@link ItemMeta#setItemModel} * * @return ItemStack for this blueprint's icon, never null * @since 3.0.0 */ public @NonNull ItemStack getIconItemStack() { - if (icon == null) { - return new ItemStack(Material.PAPER); - } - Material m = Material.matchMaterial(icon); - if (m != null) { - return new ItemStack(m); - } - if (icon.contains(":")) { - ItemStack item = new ItemStack(Material.PAPER); - ItemMeta meta = item.getItemMeta(); - if (meta != null) { - String[] parts = icon.split(":", 2); - try { - meta.setItemModel(new NamespacedKey(parts[0], parts[1])); - item.setItemMeta(meta); - } catch (IllegalArgumentException ignored) { - // Invalid namespace/key format — fall through and return plain PAPER - } - } - return item; - } - return new ItemStack(Material.PAPER); + return ItemParser.parseIconItemStack(icon); } /** @@ -142,7 +118,7 @@ public Blueprint setDisplayName(String displayName) { * @return blueprint */ public Blueprint setIcon(Material icon) { - this.icon = icon != null ? icon.name() : "PAPER"; + this.icon = icon != null ? icon.name() : DEFAULT_ICON; return this; } @@ -155,7 +131,7 @@ public Blueprint setIcon(Material icon) { * @since 3.0.0 */ public Blueprint setIcon(String icon) { - this.icon = icon != null ? icon : "PAPER"; + this.icon = icon != null ? icon : DEFAULT_ICON; return this; } /** diff --git a/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java b/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java index b83e58477..7f43094d0 100644 --- a/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java +++ b/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java @@ -6,15 +6,14 @@ import java.util.Map; import org.bukkit.Material; -import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; import com.google.gson.annotations.Expose; import world.bentobox.bentobox.blueprints.Blueprint; import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.util.ItemParser; /** * Represents a bundle of three {@link Blueprint}s. @@ -24,6 +23,8 @@ */ public class BlueprintBundle implements DataObject { + private static final String DEFAULT_ICON = "PAPER"; + /** * The unique id of this bundle */ @@ -35,7 +36,7 @@ public class BlueprintBundle implements DataObject { * item model keys (e.g. "myserver:island_tropical"). */ @Expose - private String icon = "PAPER"; + private String icon = DEFAULT_ICON; /** * Name on the icon */ @@ -110,11 +111,7 @@ public void setUniqueId(String uniqueId) { * @return the icon material, never null */ public Material getIcon() { - if (icon == null) { - return Material.PAPER; - } - Material m = Material.matchMaterial(icon); - return m != null ? m : Material.PAPER; + return ItemParser.parseIconMaterial(icon); } /** @@ -123,36 +120,13 @@ public Material getIcon() { *
  • Plain material name (e.g. {@code "DIAMOND"}) → {@code new ItemStack(Material.DIAMOND)}
  • *
  • Vanilla namespaced material (e.g. {@code "minecraft:diamond"}) → same as above
  • *
  • Custom item-model key (e.g. {@code "myserver:island_tropical"}) → PAPER base item - * with the model key set via {@link ItemMeta#setItemModel(NamespacedKey)}
  • + * with the model key set via {@link ItemMeta#setItemModel} * * @return ItemStack for this bundle's icon, never null * @since 3.0.0 */ public ItemStack getIconItemStack() { - if (icon == null) { - return new ItemStack(Material.PAPER); - } - // matchMaterial handles plain names ("DIAMOND") and namespaced vanilla ("minecraft:diamond") - Material m = Material.matchMaterial(icon); - if (m != null) { - return new ItemStack(m); - } - // Contains a colon but isn't a vanilla material → treat as a custom item model key - if (icon.contains(":")) { - ItemStack item = new ItemStack(Material.PAPER); - ItemMeta meta = item.getItemMeta(); - if (meta != null) { - String[] parts = icon.split(":", 2); - try { - meta.setItemModel(new NamespacedKey(parts[0], parts[1])); - item.setItemMeta(meta); - } catch (IllegalArgumentException ignored) { - // Invalid namespace/key format — fall through and return plain PAPER - } - } - return item; - } - return new ItemStack(Material.PAPER); + return ItemParser.parseIconItemStack(icon); } /** @@ -160,7 +134,7 @@ public ItemStack getIconItemStack() { * @param icon the icon material to set; if null, defaults to {@link Material#PAPER} */ public void setIcon(Material icon) { - this.icon = icon != null ? icon.name() : "PAPER"; + this.icon = icon != null ? icon.name() : DEFAULT_ICON; } /** @@ -171,7 +145,7 @@ public void setIcon(Material icon) { * @since 3.0.0 */ public void setIcon(String icon) { - this.icon = icon != null ? icon : "PAPER"; + this.icon = icon != null ? icon : DEFAULT_ICON; } /** * @return the displayName diff --git a/src/main/java/world/bentobox/bentobox/util/ItemParser.java b/src/main/java/world/bentobox/bentobox/util/ItemParser.java index 6f47b9f3e..4648171f9 100644 --- a/src/main/java/world/bentobox/bentobox/util/ItemParser.java +++ b/src/main/java/world/bentobox/bentobox/util/ItemParser.java @@ -12,6 +12,7 @@ import org.bukkit.Bukkit; import org.bukkit.DyeColor; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.block.banner.Pattern; import org.bukkit.block.banner.PatternType; import org.bukkit.inventory.ItemStack; @@ -43,6 +44,61 @@ public class ItemParser { private static final int MAX_AMOUNT = 99; private ItemParser() {} // private constructor to hide the implicit public one. + + /** + * Resolves an icon string to a {@link Material}. + * Accepts plain material names (e.g. {@code "DIAMOND"}), vanilla namespaced keys + * (e.g. {@code "minecraft:diamond"}), and custom item-model keys + * (e.g. {@code "myserver:island_tropical"}). Custom model keys that are not + * recognised vanilla materials fall back to {@link Material#PAPER}. + * @param icon the icon string, may be null + * @return resolved Material, never null + * @since 3.0.0 + */ + public static Material parseIconMaterial(@Nullable String icon) { + if (icon == null) { + return Material.PAPER; + } + Material m = Material.matchMaterial(icon); + return m != null ? m : Material.PAPER; + } + + /** + * Resolves an icon string to an {@link ItemStack}. + *
      + *
    • Plain material name or vanilla namespaced key → {@code new ItemStack(material)}
    • + *
    • Custom item-model key (namespace:key not matching any vanilla material) → + * PAPER base item with the model applied via {@link ItemMeta#setItemModel(NamespacedKey)}
    • + *
    + * @param icon the icon string, may be null + * @return resolved ItemStack, never null + * @since 3.0.0 + */ + public static ItemStack parseIconItemStack(@Nullable String icon) { + if (icon == null) { + return new ItemStack(Material.PAPER); + } + Material m = Material.matchMaterial(icon); + if (m != null) { + return new ItemStack(m); + } + // Contains a colon but isn't a vanilla material → treat as a custom item model key + if (icon.contains(":")) { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + String[] parts = icon.split(":", 2); + try { + meta.setItemModel(new NamespacedKey(parts[0], parts[1])); + item.setItemMeta(meta); + } catch (IllegalArgumentException ignored) { + // Invalid namespace/key format — return plain PAPER + } + } + return item; + } + return new ItemStack(Material.PAPER); + } /** * Parse given string to ItemStack. * @param text String value of item stack. diff --git a/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java index 57d64a6b2..5a03522b5 100644 --- a/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java +++ b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java @@ -10,7 +10,6 @@ import org.bukkit.Material; import org.bukkit.NamespacedKey; -import org.bukkit.Sound; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; From 59f3907ea9291ea4ca0250e5ee7ac071c120a62b Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 14 Apr 2026 05:22:32 -0700 Subject: [PATCH 27/39] Address PR #2933 review issues: version, events, housekeeping split flags, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version 4.0.0 → 3.15.0; all @since 3.14.0 → @since 3.15.0 - hardDeleteIsland: fire Reason.DELETED post-event so hooks (Level, BlueMap) are notified consistently with the soft-delete path - HousekeepingManager.saveAllWorlds: replace unbounded join() with a 2-minute timeout to avoid hanging the async cycle thread indefinitely - Split single housekeepingEnabled flag into two independent flags: deleted-sweep.enabled (default true — safe, only reaps reset orphans) and age-sweep.enabled (default false — opt-in, more aggressive). Admins get automatic cleanup of reset islands with no config needed. - AdminPurgeDeletedCommand: remove spurious implements Listener / registerListener (class has no @EventHandler methods) - LockAndBanListener.notifyIfDeletable: lambda → Island::isDeletable method ref - Settings: restore keepPreviousIslandOnReset as @Deprecated(forRemoval=true) no-ops returning false to preserve binary compatibility for external addons Co-Authored-By: Claude Sonnet 4.6 --- build.gradle.kts | 2 +- .../world/bentobox/bentobox/BentoBox.java | 6 +- .../world/bentobox/bentobox/Settings.java | 152 ++++++++++++------ .../bentobox/api/addons/GameModeAddon.java | 2 +- .../purge/AdminPurgeAgeRegionsCommand.java | 2 +- .../admin/purge/AdminPurgeDeletedCommand.java | 7 +- .../IslandDefaultCycleClick.java | 2 +- .../bentobox/database/objects/Island.java | 6 +- .../bentobox/hooks/MythicMobsHook.java | 2 +- .../listeners/BentoBoxListenerRegistrar.java | 2 +- .../flags/protection/LockAndBanListener.java | 3 +- .../bentobox/managers/ChunkPregenManager.java | 2 +- .../managers/HousekeepingManager.java | 31 ++-- .../bentobox/managers/IslandsManager.java | 3 +- .../managers/PurgeRegionsService.java | 4 +- .../settings/IslandDefaultSettingsTab.java | 2 +- .../purge/AdminPurgeDeletedCommandTest.java | 5 +- .../managers/HousekeepingManagerTest.java | 12 +- 18 files changed, 150 insertions(+), 95 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d6382351a..caab2525a 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 = "4.0.0" +val buildVersion = "3.15.0" val buildNumberDefault = "-LOCAL" // Local build identifier val snapshotSuffix = "-SNAPSHOT" // Indicates development/snapshot version diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java index c23eb54e4..5ade339ba 100644 --- a/src/main/java/world/bentobox/bentobox/BentoBox.java +++ b/src/main/java/world/bentobox/bentobox/BentoBox.java @@ -580,7 +580,7 @@ public IslandDeletionManager getIslandDeletionManager() { /** * @return the chunkPregenManager - * @since 3.14.0 + * @since 3.15.0 */ public ChunkPregenManager getChunkPregenManager() { return chunkPregenManager; @@ -589,7 +589,7 @@ public ChunkPregenManager getChunkPregenManager() { /** * @return the shared {@link PurgeRegionsService} used by the purge * regions command and the housekeeping scheduler. - * @since 3.14.0 + * @since 3.15.0 */ public PurgeRegionsService getPurgeRegionsService() { return purgeRegionsService; @@ -597,7 +597,7 @@ public PurgeRegionsService getPurgeRegionsService() { /** * @return the {@link HousekeepingManager}, or {@code null} if not yet initialized. - * @since 3.14.0 + * @since 3.15.0 */ public HousekeepingManager getHousekeepingManager() { return housekeepingManager; diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 5c322cfcc..492f5d312 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -329,35 +329,43 @@ public class Settings implements ConfigObject { // 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("When a player resets their island, the old island is orphaned (marked") + @ConfigComment("deletable) and its region files are reclaimed later by a scheduled purge.") @ConfigComment("") @ConfigComment("WARNING: housekeeping deletes .mca region files from disk. It uses the same") @ConfigComment("protections as the /bbox purge regions command (online players, island level,") - @ConfigComment("purge-protected flag, spawn islands, 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") + @ConfigComment("purge-protected flag, spawn islands, and unowned-but-not-deletable islands are") + @ConfigComment("all skipped) but is destructive by design.") + @ConfigComment("") + @ConfigComment("--- Deleted-island sweep (safe, on by default) ---") + @ConfigComment("Reaps region files for islands explicitly marked deletable (e.g. /is reset).") + @ConfigComment("Only removes islands that BentoBox itself soft-deleted — never touches") + @ConfigComment("active or unvisited islands. Enable this and leave age-sweep off for a") + @ConfigComment("'just works' experience that reclaims disk space from reset islands.") + @ConfigEntry(path = "island.deletion.housekeeping.deleted-sweep.enabled", since = "3.15.0") + private boolean housekeepingDeletedEnabled = true; + + @ConfigComment("How often the deleted-island sweep runs, in hours.") + @ConfigEntry(path = "island.deletion.housekeeping.deleted-sweep.interval-hours", since = "3.15.0") + private int housekeepingDeletedIntervalHours = 24; + + @ConfigComment("") + @ConfigComment("--- Age-based sweep (opt-in, more aggressive) ---") + @ConfigComment("Reaps region files whose .mca files are older than min-age-days, regardless") + @ConfigComment("of whether those islands are marked deletable. Can remove islands that were") + @ConfigComment("never reset but simply abandoned. Disabled by default — enable only if you") + @ConfigComment("want automatic reclamation of all old, unvisited islands.") + @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.enabled", since = "3.15.0") + private boolean housekeepingAgeEnabled = false; + + @ConfigComment("How often the age-based sweep runs, in days.") + @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.interval-days", since = "3.15.0") private int housekeepingIntervalDays = 30; - @ConfigComment("Minimum age (in days) of region files 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") + @ConfigComment("Minimum age (in days) of region files eligible for the age-based purge.") + @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.min-age-days", since = "3.15.0") private int housekeepingRegionAgeDays = 60; - @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.") @@ -1031,41 +1039,85 @@ public void setSlowDeletion(boolean slowDeletion) { } /** - * @return whether the periodic housekeeping task (auto-purge of unused - * region files) is enabled. - * @since 3.14.0 + * Returns whether islands, when reset, should be kept or deleted. + * + * @return always {@code false} — islands are now soft-deleted on reset and + * their region files reaped asynchronously by the housekeeping purge. + * @since 1.13.0 + * @deprecated No longer configurable. Islands are soft-deleted on reset. + * This method always returns {@code false} and will be removed + * in a future release. + */ + @Deprecated(since = "3.15.0", forRemoval = true) + public boolean isKeepPreviousIslandOnReset() { + return false; + } + + /** + * No-op — the keep-previous-island setting is no longer configurable. + * + * @param keepPreviousIslandOnReset ignored + * @since 1.13.0 + * @deprecated No longer configurable. Islands are soft-deleted on reset. + */ + @Deprecated(since = "3.15.0", forRemoval = true) + public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) { + // no-op + } + + /** + * @return whether the deleted-island sweep is enabled (reaps regions for + * islands explicitly flagged deletable, e.g. from {@code /is reset}). + * @since 3.15.0 + */ + public boolean isHousekeepingDeletedEnabled() { + return housekeepingDeletedEnabled; + } + + /** + * @param housekeepingDeletedEnabled whether the deleted-island sweep is enabled. + * @since 3.15.0 + */ + public void setHousekeepingDeletedEnabled(boolean housekeepingDeletedEnabled) { + this.housekeepingDeletedEnabled = housekeepingDeletedEnabled; + } + + /** + * @return whether the age-based sweep is enabled (reaps regions for islands + * whose .mca files are older than {@link #getHousekeepingRegionAgeDays()}). + * @since 3.15.0 */ - public boolean isHousekeepingEnabled() { - return housekeepingEnabled; + public boolean isHousekeepingAgeEnabled() { + return housekeepingAgeEnabled; } /** - * @param housekeepingEnabled whether the periodic housekeeping task is enabled. - * @since 3.14.0 + * @param housekeepingAgeEnabled whether the age-based sweep is enabled. + * @since 3.15.0 */ - public void setHousekeepingEnabled(boolean housekeepingEnabled) { - this.housekeepingEnabled = housekeepingEnabled; + public void setHousekeepingAgeEnabled(boolean housekeepingAgeEnabled) { + this.housekeepingAgeEnabled = housekeepingAgeEnabled; } /** - * @return how often the housekeeping task runs, in days. - * @since 3.14.0 + * @return how often the age-based sweep runs, in days. + * @since 3.15.0 */ public int getHousekeepingIntervalDays() { return housekeepingIntervalDays; } /** - * @param housekeepingIntervalDays how often the housekeeping task runs, in days. - * @since 3.14.0 + * @param housekeepingIntervalDays how often the age-based sweep runs, in days. + * @since 3.15.0 */ public void setHousekeepingIntervalDays(int housekeepingIntervalDays) { this.housekeepingIntervalDays = housekeepingIntervalDays; } /** - * @return minimum age (in days) of region files considered for auto-purge. - * @since 3.14.0 + * @return minimum age (in days) of region files eligible for the age-based purge. + * @since 3.15.0 */ public int getHousekeepingRegionAgeDays() { return housekeepingRegionAgeDays; @@ -1074,7 +1126,7 @@ public int getHousekeepingRegionAgeDays() { /** * @param housekeepingRegionAgeDays minimum age (in days) of region files * considered for auto-purge. - * @since 3.14.0 + * @since 3.15.0 */ public void setHousekeepingRegionAgeDays(int housekeepingRegionAgeDays) { this.housekeepingRegionAgeDays = housekeepingRegionAgeDays; @@ -1083,7 +1135,7 @@ public void setHousekeepingRegionAgeDays(int housekeepingRegionAgeDays) { /** * @return how often the deleted-islands sweep runs, in hours. {@code 0} * disables the deleted sweep. - * @since 3.14.0 + * @since 3.15.0 */ public int getHousekeepingDeletedIntervalHours() { return housekeepingDeletedIntervalHours; @@ -1092,7 +1144,7 @@ public int getHousekeepingDeletedIntervalHours() { /** * @param housekeepingDeletedIntervalHours how often the deleted sweep runs * in hours. {@code 0} disables it. - * @since 3.14.0 + * @since 3.15.0 */ public void setHousekeepingDeletedIntervalHours(int housekeepingDeletedIntervalHours) { this.housekeepingDeletedIntervalHours = housekeepingDeletedIntervalHours; @@ -1100,7 +1152,7 @@ public void setHousekeepingDeletedIntervalHours(int housekeepingDeletedIntervalH /** * @return whether chunk pre-generation is enabled - * @since 3.14.0 + * @since 3.15.0 */ public boolean isPregenEnabled() { return pregenEnabled; @@ -1108,7 +1160,7 @@ public boolean isPregenEnabled() { /** * @param pregenEnabled whether chunk pre-generation is enabled - * @since 3.14.0 + * @since 3.15.0 */ public void setPregenEnabled(boolean pregenEnabled) { this.pregenEnabled = pregenEnabled; @@ -1116,7 +1168,7 @@ public void setPregenEnabled(boolean pregenEnabled) { /** * @return number of future islands to pre-generate per game mode - * @since 3.14.0 + * @since 3.15.0 */ public int getPregenIslandsAhead() { return pregenIslandsAhead; @@ -1124,7 +1176,7 @@ public int getPregenIslandsAhead() { /** * @param pregenIslandsAhead number of future islands to pre-generate - * @since 3.14.0 + * @since 3.15.0 */ public void setPregenIslandsAhead(int pregenIslandsAhead) { this.pregenIslandsAhead = pregenIslandsAhead; @@ -1132,7 +1184,7 @@ public void setPregenIslandsAhead(int pregenIslandsAhead) { /** * @return max async chunk generation requests per tick batch - * @since 3.14.0 + * @since 3.15.0 */ public int getPregenChunksPerTick() { return pregenChunksPerTick; @@ -1140,7 +1192,7 @@ public int getPregenChunksPerTick() { /** * @param pregenChunksPerTick max async chunk requests per tick - * @since 3.14.0 + * @since 3.15.0 */ public void setPregenChunksPerTick(int pregenChunksPerTick) { this.pregenChunksPerTick = pregenChunksPerTick; @@ -1148,7 +1200,7 @@ public void setPregenChunksPerTick(int pregenChunksPerTick) { /** * @return ticks between pre-generation batches - * @since 3.14.0 + * @since 3.15.0 */ public int getPregenTickInterval() { return pregenTickInterval; @@ -1156,7 +1208,7 @@ public int getPregenTickInterval() { /** * @param pregenTickInterval ticks between batches - * @since 3.14.0 + * @since 3.15.0 */ public void setPregenTickInterval(int pregenTickInterval) { this.pregenTickInterval = pregenTickInterval; @@ -1325,7 +1377,7 @@ public void setObsidianScoopingCooldown(int obsidianScoopingCooldown) { * newly formed obsidian blocks that can be scooped. * * @return the lava tip duration in seconds; 0 or less means disabled - * @since 3.14.0 + * @since 3.15.0 */ public int getObsidianScoopingLavaTipDuration() { return obsidianScoopingLavaTipDuration; @@ -1336,7 +1388,7 @@ public int getObsidianScoopingLavaTipDuration() { * newly formed obsidian blocks that can be scooped. * * @param obsidianScoopingLavaTipDuration the duration in seconds; 0 or less disables - * @since 3.14.0 + * @since 3.15.0 */ public void setObsidianScoopingLavaTipDuration(int obsidianScoopingLavaTipDuration) { this.obsidianScoopingLavaTipDuration = obsidianScoopingLavaTipDuration; diff --git a/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java b/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java index 4c239313f..b4c21cd42 100644 --- a/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java +++ b/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java @@ -211,7 +211,7 @@ public boolean isEnforceEqualRanges() { * * * @return number of islands ahead, -1 for global default, 0 to disable - * @since 3.14.0 + * @since 3.15.0 */ public int getPregenIslandsAhead() { return -1; diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java index d4aaaae69..8d697e416 100644 --- 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 @@ -22,7 +22,7 @@ * *

    Usage: {@code / purge age-regions } * - * @since 3.14.0 + * @since 3.15.0 */ public class AdminPurgeAgeRegionsCommand extends CompositeCommand implements Listener { 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 index 349516b4f..b5cf9381e 100644 --- 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 @@ -7,8 +7,6 @@ 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; @@ -28,9 +26,9 @@ *

    Heavy lifting is delegated to {@link PurgeRegionsService#scanDeleted(World)} * and {@link PurgeRegionsService#delete(PurgeScanResult)}. * - * @since 3.14.0 + * @since 3.15.0 */ -public class AdminPurgeDeletedCommand extends CompositeCommand implements Listener { +public class AdminPurgeDeletedCommand extends CompositeCommand { private static final String NONE_FOUND = "commands.admin.purge.none-found"; @@ -41,7 +39,6 @@ public class AdminPurgeDeletedCommand extends CompositeCommand implements Listen public AdminPurgeDeletedCommand(CompositeCommand parent) { super(parent, "deleted"); - getAddon().registerListener(this); } @Override diff --git a/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java b/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java index a72491c1e..d3d189f14 100644 --- a/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java +++ b/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java @@ -17,7 +17,7 @@ * Left-clicks increase the default island protection rank, right-clicks decrease it. * This modifies the default rank that new islands will receive for a protection flag. * @author tastybento - * @since 3.14.0 + * @since 3.15.0 */ public class IslandDefaultCycleClick implements PanelItem.ClickHandler { diff --git a/src/main/java/world/bentobox/bentobox/database/objects/Island.java b/src/main/java/world/bentobox/bentobox/database/objects/Island.java index b48d672ef..6b747ab66 100644 --- a/src/main/java/world/bentobox/bentobox/database/objects/Island.java +++ b/src/main/java/world/bentobox/bentobox/database/objects/Island.java @@ -1735,7 +1735,7 @@ public void clearChanged() { * Calls to this method must be balanced with calls to {@link #endDeferSaves()}. * Multiple callers may defer simultaneously (reference-counted). * - * @since 3.14.0 + * @since 3.15.0 */ public void beginDeferSaves() { deferSaveCount++; @@ -1745,7 +1745,7 @@ public void beginDeferSaves() { * End deferring database saves. Decrements the defer counter; when it reaches * zero the island is saved if it has been marked as changed. * - * @since 3.14.0 + * @since 3.15.0 */ public void endDeferSaves() { if (deferSaveCount > 0) { @@ -1758,7 +1758,7 @@ public void endDeferSaves() { /** * @return true if saves are currently being deferred - * @since 3.14.0 + * @since 3.15.0 */ public boolean isDeferSaves() { return deferSaveCount > 0; diff --git a/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java b/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java index 0cff041d7..538bea305 100644 --- a/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java +++ b/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java @@ -71,7 +71,7 @@ public boolean spawnMythicMob(MythicMobRecord mmr, Location spawnLocation) { * @param spawnLocation location * @param onSpawn callback invoked with the spawned Bukkit entity; may be {@code null} * @return true if the mob type exists and a spawn was scheduled - * @since 3.14.0 + * @since 3.15.0 */ public boolean spawnMythicMob(MythicMobRecord mmr, Location spawnLocation, Consumer onSpawn) { return spawnMythicMob(mmr, spawnLocation, onSpawn, 40L); diff --git a/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java b/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java index b814405f0..9c7af0cbc 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java +++ b/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java @@ -53,7 +53,7 @@ public IslandDeletionManager getIslandDeletionManager() { /** * @return the {@link ChunkPregenManager} created during registration. - * @since 3.14.0 + * @since 3.15.0 */ public ChunkPregenManager getChunkPregenManager() { return chunkPregenManager; diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java index 06d20907b..3dda116f7 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java @@ -21,6 +21,7 @@ import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.flags.FlagListener; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.lists.Flags; import world.bentobox.bentobox.util.Util; @@ -213,7 +214,7 @@ private void notifyIfDeletable(@NonNull Player player, Location loc) { return; } boolean deletable = getIslands().getProtectedIslandAt(loc) - .map(i -> i.isDeletable()).orElse(false); + .map(Island::isDeletable).orElse(false); if (deletable) { if (deletableNotified.add(player.getUniqueId())) { User.getInstance(player).notify("protection.deletable-island-admin"); diff --git a/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java b/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java index 08cd2b1d9..ebff0eec6 100644 --- a/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java @@ -34,7 +34,7 @@ * round-robining between worlds to ensure fairness and prevent server overload. * * @author tastybento - * @since 3.14.0 + * @since 3.15.0 */ public class ChunkPregenManager implements Listener { diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java index d79c22537..2c7a9ff7a 100644 --- a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -6,7 +6,9 @@ import java.time.Instant; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.bukkit.Bukkit; import org.bukkit.World; @@ -39,7 +41,7 @@ *

    This manager is destructive by design: it deletes {@code .mca} region * files from disk. * - * @since 3.14.0 + * @since 3.15.0 */ public class HousekeepingManager { @@ -76,11 +78,12 @@ public synchronized void start() { } scheduledTask = Bukkit.getScheduler().runTaskTimer(plugin, this::checkAndMaybeRun, STARTUP_DELAY_TICKS, CHECK_INTERVAL_TICKS); - plugin.log("Housekeeping scheduler started (enabled=" - + plugin.getSettings().isHousekeepingEnabled() + plugin.log("Housekeeping scheduler started (deleted-sweep=" + + plugin.getSettings().isHousekeepingDeletedEnabled() + + ", deleted-interval=" + plugin.getSettings().getHousekeepingDeletedIntervalHours() + "h" + + ", age-sweep=" + plugin.getSettings().isHousekeepingAgeEnabled() + ", age-interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + ", region-age=" + plugin.getSettings().getHousekeepingRegionAgeDays() + "d" - + ", deleted-interval=" + plugin.getSettings().getHousekeepingDeletedIntervalHours() + "h" + ", last-age-run=" + formatTs(lastAgeRunMillis) + ", last-deleted-run=" + formatTs(lastDeletedRunMillis) + ")"); } @@ -127,12 +130,9 @@ private void checkAndMaybeRun() { if (inProgress) { return; } - if (!plugin.getSettings().isHousekeepingEnabled()) { - return; - } long now = System.currentTimeMillis(); - boolean ageDue = isAgeCycleDue(now); - boolean deletedDue = isDeletedCycleDue(now); + boolean ageDue = plugin.getSettings().isHousekeepingAgeEnabled() && isAgeCycleDue(now); + boolean deletedDue = plugin.getSettings().isHousekeepingDeletedEnabled() && isDeletedCycleDue(now); if (!ageDue && !deletedDue) { return; } @@ -212,11 +212,18 @@ private boolean saveAllWorlds() { } }); try { - saved.join(); + saved.get(2, TimeUnit.MINUTES); plugin.log("Housekeeping: world save complete"); return true; - } catch (Exception e) { - plugin.logError("Housekeeping: world save failed: " + e.getMessage()); + } catch (TimeoutException e) { + plugin.logError("Housekeeping: world save timed out after 2 minutes, aborting cycle"); + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + plugin.logError("Housekeeping: world save interrupted: " + e.getMessage()); + return false; + } catch (ExecutionException e) { + plugin.logError("Housekeeping: world save failed: " + e.getCause().getMessage()); return false; } } diff --git a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java index 6de4b588b..096e1d0da 100644 --- a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java @@ -331,7 +331,7 @@ public void deleteIsland(@NonNull Island island, boolean removeBlocks, @Nullable * 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 + * @since 3.15.0 */ public void hardDeleteIsland(@NonNull Island island) { IslandBaseEvent event = IslandEvent.builder().island(island).reason(Reason.DELETE).build(); @@ -343,6 +343,7 @@ public void hardDeleteIsland(@NonNull Island island) { island.setFlag(Flags.LOCK, RanksManager.VISITOR_RANK); islandCache.deleteIslandFromCache(island); handler.deleteObject(island); + IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build(); } /** diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 089d968e9..85b58dfcc 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -47,7 +47,7 @@ * deleting region files across the overworld + optional nether/end * dimensions. * - * @since 3.14.0 + * @since 3.15.0 */ public class PurgeRegionsService { @@ -137,7 +137,7 @@ private record DimFolders(File region, File entities, File poi) {} * * @param world the gamemode overworld to scan * @return scan result, never {@code null} - * @since 3.14.0 + * @since 3.15.0 */ public PurgeScanResult scanDeleted(World world) { boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world); diff --git a/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java b/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java index 7e0bf0287..496ac67ca 100644 --- a/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java +++ b/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java @@ -20,7 +20,7 @@ * Implements a {@link Tab} that enables the default island protection settings to be changed. * These are the protection flag rank values that new islands will receive. * @author tastybento - * @since 3.14.0 + * @since 3.15.0 */ public class IslandDefaultSettingsTab extends SettingsTab implements Tab { diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java index 2817e63fc..c008e58fa 100644 --- 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 @@ -135,12 +135,11 @@ public void tearDown() throws Exception { } /** - * The command is registered as a listener both during AdminPurgeCommand.setup() - * and again when constructed directly in the test. + * The command is not registered as a listener — it has no event handlers. */ @Test void testConstructor() { - verify(addon, times(2)).registerListener(any(AdminPurgeDeletedCommand.class)); + assertEquals("admin.purge.deleted", apdc.getPermission()); } @Test diff --git a/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java index c817eff0a..fd2ac1db7 100644 --- a/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java @@ -169,7 +169,7 @@ void testLoadStatePrefersNewKeysOverLegacy() throws Exception { */ @Test void testSaveStateRoundTripsBothKeys() { - settings.setHousekeepingEnabled(true); + settings.setHousekeepingAgeEnabled(true); settings.setHousekeepingIntervalDays(1); settings.setHousekeepingRegionAgeDays(30); settings.setHousekeepingDeletedIntervalHours(1); @@ -200,7 +200,8 @@ void testSaveStateRoundTripsBothKeys() { */ @Test void testDisabledFeatureSkipsAllCycles() throws Exception { - settings.setHousekeepingEnabled(false); + settings.setHousekeepingDeletedEnabled(false); + settings.setHousekeepingAgeEnabled(false); HousekeepingManager hm = new HousekeepingManager(plugin); // Invoke the internal hourly check via reflection so we go through @@ -217,7 +218,7 @@ void testDisabledFeatureSkipsAllCycles() throws Exception { */ @Test void testRunNowDispatchesBothCycles() { - settings.setHousekeepingEnabled(true); + settings.setHousekeepingAgeEnabled(true); settings.setHousekeepingRegionAgeDays(30); when(purgeService.scan(eq(world), eq(30))).thenReturn(emptyScan(30)); @@ -236,7 +237,7 @@ void testRunNowDispatchesBothCycles() { */ @Test void testDeletedIntervalZeroDisablesDeletedCycle() throws Exception { - settings.setHousekeepingEnabled(true); + settings.setHousekeepingAgeEnabled(true); settings.setHousekeepingIntervalDays(1); settings.setHousekeepingRegionAgeDays(30); settings.setHousekeepingDeletedIntervalHours(0); // disabled @@ -255,7 +256,6 @@ void testDeletedIntervalZeroDisablesDeletedCycle() throws Exception { */ @Test void testAgeIntervalZeroDisablesAgeCycle() throws Exception { - settings.setHousekeepingEnabled(true); settings.setHousekeepingIntervalDays(0); // disabled settings.setHousekeepingDeletedIntervalHours(1); @@ -274,7 +274,6 @@ void testAgeIntervalZeroDisablesAgeCycle() throws Exception { */ @Test void testBothCyclesRecentlyRunIsNoop() throws Exception { - settings.setHousekeepingEnabled(true); settings.setHousekeepingIntervalDays(30); settings.setHousekeepingDeletedIntervalHours(24); @@ -299,7 +298,6 @@ void testBothCyclesRecentlyRunIsNoop() throws Exception { */ @Test void testOnlyDeletedCycleDueDispatchesDeletedOnly() throws Exception { - settings.setHousekeepingEnabled(true); settings.setHousekeepingIntervalDays(30); settings.setHousekeepingDeletedIntervalHours(24); From 68a7a540be5f8dc92034c9ff526325a3c66b1d90 Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 14 Apr 2026 05:44:00 -0700 Subject: [PATCH 28/39] Add PURGED event reason; fire DELETED immediately on soft-delete; fix javadoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DELETED event already fires immediately in deleteIsland() when the island is marked deletable — addons (OneBlock, Level, etc.) receive it at /is reset time, not when the housekeeping eventually reclaims the region files. - Fix DELETED javadoc: clarify it fires at logical-delete time (soft-delete or hard-delete), not "after all island chunks deleted" as the old wording said - Add Reason.PURGED: fires when the region files AND database row are both physically gone. Fires from: - PurgeRegionsService.flushPendingDeletions() (deleted-sweep, at shutdown) - PurgeRegionsService.delete() age-sweep branch when the DB row is removed - For age-swept islands that were never soft-deleted (isDeletable == false), also fire DELETED before the DB row is removed so addons can clean up data for islands that were pruned by age rather than by an explicit reset Co-Authored-By: Claude Sonnet 4.6 --- .../api/events/island/IslandEvent.java | 28 ++++++++++++++++++- .../managers/PurgeRegionsService.java | 25 +++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/main/java/world/bentobox/bentobox/api/events/island/IslandEvent.java b/src/main/java/world/bentobox/bentobox/api/events/island/IslandEvent.java index 89ce1e217..29b7b9e28 100644 --- a/src/main/java/world/bentobox/bentobox/api/events/island/IslandEvent.java +++ b/src/main/java/world/bentobox/bentobox/api/events/island/IslandEvent.java @@ -90,9 +90,35 @@ public enum Reason { */ DELETE_CHUNKS, /** - * Fired after all island chunks have been deleted or set for regeneration by the server + * Fired when an island is logically/effectively deleted — that is, when it is + * no longer reachable or usable by players. This fires immediately: + *

      + *
    • For a soft-delete (e.g. {@code /is reset} on new-chunk-generation + * gamemodes): fires as soon as the island is marked {@code deletable=true} + * in the database, which is at reset time. The region files on disk may not + * be reclaimed until later (see {@link #PURGED}).
    • + *
    • For a hard-delete (e.g. {@code /bbox admin delete} on void/simple + * gamemodes): fires after the database row is removed.
    • + *
    • For an age-sweep purge of an island that was never explicitly reset: + * fires when the database row is finally removed.
    • + *
    + * Addon listeners should use this event to clean up their own per-island data + * (caches, database rows, etc.). */ DELETED, + /** + * Fired when an island's region files and database row have been + * physically removed from disk and the database. This always follows a + * {@link #DELETED} event, but may be separated from it by hours or days + * (e.g. when the housekeeping purge runs). + * + *

    Most addons do not need this event — use {@link #DELETED} for cleanup. + * {@code PURGED} is intended for addons that specifically track region-file + * or disk-space state. + * + * @since 3.15.0 + */ + PURGED, /** * Fired when a player enters an island */ diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 85b58dfcc..f19eedf93 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -23,6 +23,8 @@ import org.bukkit.World; import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.events.island.IslandEvent; +import world.bentobox.bentobox.api.events.island.IslandEvent.Reason; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.managers.island.IslandGrid; import world.bentobox.bentobox.managers.island.IslandGrid.IslandData; @@ -268,10 +270,25 @@ public boolean delete(PurgeScanResult scan) { continue; } deletePlayerFromWorldFolder(scan.world(), islandID, scan.deletableRegions(), scan.days()); + // For the age sweep, DELETED may never have fired (the island was + // pruned by age, not by an explicit /is reset). Fire it now so addons + // (Level, OneBlock, etc.) can clean up their per-island data. + if (!island.isDeletable()) { + IslandEvent.builder() + .island(island) + .reason(Reason.DELETED) + .build(); + } plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); if (plugin.getIslands().deleteIslandId(islandID)) { plugin.log("Island ID " + islandID + " deleted from cache and database"); islandsRemoved++; + // Fire PURGED so addons that track physical storage state know + // the region files and DB row are both gone. + IslandEvent.builder() + .island(island) + .reason(Reason.PURGED) + .build(); } } } @@ -300,9 +317,17 @@ public void flushPendingDeletions() { plugin.log("Flushing " + pendingDeletions.size() + " deferred island deletion(s)..."); int count = 0; for (String islandID : pendingDeletions) { + // Capture island before cache eviction so we can build the event. + // DELETED was already fired at soft-delete time; PURGED signals that + // the DB row is now physically removed. + Optional opt = plugin.getIslands().getIslandById(islandID); plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); if (plugin.getIslands().deleteIslandId(islandID)) { count++; + opt.ifPresent(island -> IslandEvent.builder() + .island(island) + .reason(Reason.PURGED) + .build()); } } pendingDeletions.clear(); From 4b7a03a6e4fc8f1a1222c716795a07adf302bb5a Mon Sep 17 00:00:00 2001 From: tastybento Date: Tue, 14 Apr 2026 21:46:47 -0700 Subject: [PATCH 29/39] Update CLAUDE.md version reference to track buildVersion Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5789b55fe..74b66d8a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,7 @@ A template like `[description]` looks harmless but is a trap. Tra - The Gradle build uses the Paper `userdev` plugin and Shadow plugin to produce a fat/shaded JAR at `build/libs/BentoBox-{version}.jar`. - `plugin.yml` and `config.yml` are filtered for the `${version}` placeholder at build time; locale files are copied without filtering. - Java preview features are enabled for both compilation and test execution. -- Local builds produce version `3.13.0-LOCAL-SNAPSHOT`; CI builds append `-b{BUILD_NUMBER}-SNAPSHOT`; `origin/master` builds produce the bare version. +- Local builds produce version `{buildVersion}-LOCAL-SNAPSHOT` (current: `3.14.2-LOCAL-SNAPSHOT`); CI builds append `-b{BUILD_NUMBER}-SNAPSHOT`; `origin/master` builds produce the bare version. The authoritative version is `buildVersion` in `build.gradle.kts`. ## Dependency Source Lookup From 01c41eb7c6120b38fdb60c3d53bfe8d54e48e729 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 15 Apr 2026 22:27:14 -0700 Subject: [PATCH 30/39] Fix typo in Japanese locale file for block count message --- src/main/resources/locales/ja.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 44f8c5b59..12db2eb2e 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -609,7 +609,7 @@ commands: you-cannot-make-team: 'チームメンバーは、チーム[prefix_island]と同じ世界で島を作ることはできません。' pasting: estimated-time: '推定時間:[number]s。' - blocks: 'ブロックごとに構築します。 合計[numbber]ブロック。' + blocks: 'ブロックごとに構築します。 合計[number]ブロック。' entities: 'それを生き物で満たす。 合計[number]体のクリーチャー。' dimension-done: '[world]に島が建設されます。' done: '完了しました。あなたの島の準備が整い、あなたを待っています!' From f4bd1ab461a1dda87a044ac15106ec44f3726332 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 04:06:33 +0000 Subject: [PATCH 31/39] Initial plan From 085bf4eae294f340d64791ee9681cbd39100fc82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 04:22:16 +0000 Subject: [PATCH 32/39] Fix: skip InvincibleVisitors targeting cancellation for XP orbs Agent-Logs-Url: https://github.com/BentoBoxWorld/BentoBox/sessions/19b02680-6fb9-456c-9cae-a1c06b2d716b Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../InvincibleVisitorsListener.java | 2 + .../InvincibleVisitorsListenerTest.java | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java index 4cb3356f4..fb3cad9f2 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java @@ -8,6 +8,7 @@ import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.World; +import org.bukkit.entity.ExperienceOrb; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -185,6 +186,7 @@ public void onVisitorTargeting(EntityTargetLivingEntityEvent e) if (!(e.getTarget() instanceof Player p) || !this.getIWM().inWorld(world) || e.getTarget().hasMetadata("NPC") || + e.getEntity() instanceof ExperienceOrb || this.getIslands().userIsOnIsland(world, User.getInstance(e.getTarget())) || this.isPvpAllowed(p.getLocation()) || e.getReason() == EntityTargetEvent.TargetReason.TARGET_DIED || diff --git a/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java index 8a0eac4a9..551d4443d 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java @@ -26,9 +26,13 @@ import org.bukkit.Location; import org.bukkit.World; import org.bukkit.World.Environment; +import org.bukkit.entity.ExperienceOrb; import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Zombie; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageEvent.DamageCause; +import org.bukkit.event.entity.EntityTargetEvent.TargetReason; +import org.bukkit.event.entity.EntityTargetLivingEntityEvent; import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; @@ -291,4 +295,59 @@ void testOnVisitorGetDamageVoidPlayerHasIsland() { verify(im).homeTeleportAsync(any(), eq(mockPlayer)); verify(pim).callEvent(any(InvincibleVistorFlagDamageRemovalEvent.class)); } + + /** + * Test that onVisitorTargeting cancels mob targeting of a visitor when ENTITY_ATTACK is in IV settings. + */ + @Test + void testOnVisitorTargetingCancelsMobTargeting() { + ivSettings.add(DamageCause.ENTITY_ATTACK.name()); + Zombie zombie = mock(Zombie.class); + when(zombie.getWorld()).thenReturn(world); + EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER); + listener.onVisitorTargeting(e); + assertTrue(e.isCancelled()); + } + + /** + * Test that onVisitorTargeting does NOT cancel experience orb targeting of a visitor. + * XP orbs should still be able to track visitors for pickup, regardless of IV settings. + */ + @Test + void testOnVisitorTargetingDoesNotCancelExperienceOrbTargeting() { + ivSettings.add(DamageCause.ENTITY_ATTACK.name()); + ExperienceOrb orb = mock(ExperienceOrb.class); + when(orb.getWorld()).thenReturn(world); + EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(orb, mockPlayer, TargetReason.CLOSEST_PLAYER); + listener.onVisitorTargeting(e); + assertFalse(e.isCancelled()); + } + + /** + * Test that onVisitorTargeting does not cancel when entity_attack is not in IV settings. + */ + @Test + void testOnVisitorTargetingNotInIvSettings() { + // ENTITY_ATTACK is not in ivSettings by default + Zombie zombie = mock(Zombie.class); + when(zombie.getWorld()).thenReturn(world); + EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER); + listener.onVisitorTargeting(e); + assertFalse(e.isCancelled()); + } + + /** + * Test that onVisitorTargeting does not cancel when user is on their own island. + */ + @Test + void testOnVisitorTargetingNotVisitor() { + ivSettings.add(DamageCause.ENTITY_ATTACK.name()); + when(im.userIsOnIsland(any(), any())).thenReturn(true); + Zombie zombie = mock(Zombie.class); + when(zombie.getWorld()).thenReturn(world); + EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER); + listener.onVisitorTargeting(e); + assertFalse(e.isCancelled()); + } + } From 8cd1eb51ee92a169885cf139ca72dd6e86de22f0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Apr 2026 06:11:51 -0700 Subject: [PATCH 33/39] Merge /purge regions into top-level /purge; drop status/stop; simplify /purge unowned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old /purge command only soft-flagged islands as deletable and left the .mca region files in place. Disk wasn't freed until the next housekeeping sweep (or a manual /purge regions). This merges the region-file deletion directly into /purge so admins get the full reap in one step, and removes now-redundant subcommands. Changes: - /purge now delegates to PurgeRegionsService: scans for region files older than N days whose islands aren't protected/spawn/unowned, lists each island location to the log, and reaps the .mca files on confirm. - Dropped /purge regions (merged in), /purge status, /purge stop (regions approach is synchronous from the admin's POV — no state to supervise). - /purge unowned: simplified to flag orphan islands deletable via IslandsManager.deleteIsland(island, true, null); logs each orphan's location; tells the admin to run /purge deleted to reap. - /purge deleted unchanged — still reaps anything already flagged. - Locale cleanup: removed keys for the dropped subcommands; updated description to reflect disk-freeing behavior; added unowned.flagged. - Tests: AdminPurgeCommandTest rewritten end-to-end against a real PurgeRegionsService with a tempDir region layout; AdminPurgeRegionsCommandTest removed; AdminPurgeUnownedCommandTest gains coverage for already-deletable islands. Co-Authored-By: Claude Opus 4.7 --- .../admin/purge/AdminPurgeCommand.java | 296 ++++------ .../admin/purge/AdminPurgeDeletedCommand.java | 6 +- .../admin/purge/AdminPurgeRegionsCommand.java | 194 ------ .../admin/purge/AdminPurgeStatusCommand.java | 49 -- .../admin/purge/AdminPurgeStopCommand.java | 38 -- .../admin/purge/AdminPurgeUnownedCommand.java | 66 ++- .../managers/PurgeRegionsService.java | 8 +- src/main/resources/locales/en-US.yml | 33 +- .../admin/purge/AdminPurgeCommandTest.java | 523 +++++++++-------- .../purge/AdminPurgeRegionsCommandTest.java | 550 ------------------ .../purge/AdminPurgeUnownedCommandTest.java | 45 +- 11 files changed, 497 insertions(+), 1311 deletions(-) delete mode 100644 src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java delete mode 100644 src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java delete mode 100644 src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java delete mode 100644 src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java 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 77ca92b27..344c9360f 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java @@ -1,39 +1,51 @@ package world.bentobox.bentobox.api.commands.admin.purge; -import java.util.HashSet; -import java.util.Iterator; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Optional; import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; import org.bukkit.Bukkit; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; +import org.bukkit.World; import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.events.island.IslandDeletedEvent; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; - -public class AdminPurgeCommand extends CompositeCommand implements Listener { - - private static final Long YEAR2000 = 946713600L; - private static final int TOO_MANY = 1000; - private int count; - private boolean inPurge; - private boolean scanning; +import world.bentobox.bentobox.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; +import world.bentobox.bentobox.util.Pair; +import world.bentobox.bentobox.util.Util; + +/** + * Admin command to purge abandoned islands by deleting their region files + * directly from disk. + * + *

    Since 3.15.0 this command does not soft-delete islands via the + * DB pipeline the way older versions did — it scans {@code .mca} region files + * older than {@code }, filters out spawn, purge-protected and still-active + * islands, and deletes the underlying files. This is what the former + * {@code /bbox admin purge regions} subcommand used to do; the two have been + * merged because disk-freeing is the only form of purge that matters. + * + *

    Heavy lifting is delegated to {@link PurgeRegionsService}. + */ +public class AdminPurgeCommand extends CompositeCommand { + + private static final String NONE_FOUND = "commands.admin.purge.none-found"; + private static final String IN_WORLD = " in world "; + private static final String WILL_BE_DELETED = " will be deleted"; + + private volatile boolean inPurge; private boolean toBeConfirmed; - private Iterator it; private User user; - private Set islands = new HashSet<>(); - private final Set loggedTiers = new HashSet<>(); // Set to store logged percentage tiers + private PurgeScanResult lastScan; public AdminPurgeCommand(CompositeCommand parent) { super(parent, "purge"); - getAddon().registerListener(this); } @Override @@ -42,27 +54,19 @@ public void setup() { setOnlyPlayer(false); setParametersHelp("commands.admin.purge.parameters"); setDescription("commands.admin.purge.description"); - new AdminPurgeStatusCommand(this); - new AdminPurgeStopCommand(this); new AdminPurgeUnownedCommand(this); new AdminPurgeProtectCommand(this); - new AdminPurgeRegionsCommand(this); new AdminPurgeAgeRegionsCommand(this); new AdminPurgeDeletedCommand(this); } @Override public boolean canExecute(User user, String label, List args) { - if (scanning) { - user.sendMessage("commands.admin.purge.scanning-in-progress"); - return false; - } if (inPurge) { user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); return false; } if (args.isEmpty()) { - // Show help showHelp(this, user); return false; } @@ -71,177 +75,125 @@ public boolean canExecute(User user, String label, List args) { @Override public boolean execute(User user, String label, List args) { + this.user = user; if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed && this.user.equals(user)) { - removeIslands(); - return true; + return deleteEverything(); } - // Clear tbc toBeConfirmed = false; - islands.clear(); - this.user = user; + + int days; try { - int days = Integer.parseInt(args.getFirst()); - if (days < 1) { + days = Integer.parseInt(args.getFirst()); + if (days <= 0) { user.sendMessage("commands.admin.purge.days-one-or-more"); return false; } - user.sendMessage("commands.admin.purge.scanning"); - scanning = true; - getOldIslands(days).thenAccept(islandSet -> { - user.sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, - String.valueOf(islandSet.size())); - if (islandSet.size() > TOO_MANY) { - user.sendMessage("commands.admin.purge.too-many"); // Give warning - } - if (!islandSet.isEmpty()) { - toBeConfirmed = true; - user.sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, this.getTopLabel()); - islands = islandSet; - } else { - user.sendMessage("commands.admin.purge.none-found"); - } - scanning = false; - }); - } catch (NumberFormatException e) { - user.sendMessage("commands.admin.purge.number-error"); + user.sendMessage("commands.admin.purge.days-one-or-more"); return false; } - return true; - } - void removeIslands() { + user.sendMessage("commands.admin.purge.scanning"); + getPlugin().log("Purge: saving all worlds before scanning region files..."); + Bukkit.getWorlds().forEach(World::save); + getPlugin().log("Purge: world save complete"); + inPurge = true; - user.sendMessage("commands.admin.purge.see-console-for-status", TextVariables.LABEL, this.getTopLabel()); - it = islands.iterator(); - count = 0; - loggedTiers.clear(); // % reporting - // Delete first island - deleteIsland(); + 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 void deleteIsland() { - if (it.hasNext()) { - getIslands().getIslandById(it.next()).ifPresent(i -> { - getIslands().deleteIsland(i, true, null); - count++; - float percentage = ((float) count / getPurgeableIslandsCount()) * 100; - String percentageStr = String.format("%.1f", percentage); - // Round the percentage to check for specific tiers - int roundedPercentage = (int) Math.floor(percentage); - - // Log at 1%, 5%, and every multiple of 5% thereafter - if (roundedPercentage > 0 - && (roundedPercentage == 1 || roundedPercentage % 5 == 0) - && !loggedTiers.contains(roundedPercentage)) { - getPlugin().log(count + " islands purged out of " + getPurgeableIslandsCount() + " (" - + percentageStr + " %)"); - loggedTiers.add(roundedPercentage); + private boolean deleteEverything() { + if (lastScan == null || lastScan.isEmpty()) { + user.sendMessage(NONE_FOUND); + return false; + } + PurgeScanResult scan = lastScan; + lastScan = null; + toBeConfirmed = false; + getPlugin().log("Purge: saving all worlds before deleting region files..."); + Bukkit.getWorlds().forEach(World::save); + 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"); } }); - } else { - user.sendMessage("commands.admin.purge.completed"); - inPurge = false; - } - - } - - @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) - void onIslandDeleted(IslandDeletedEvent e) { - if (inPurge) { - // Run after one tick - you cannot run millions of events in one tick otherwise the server shuts down - Bukkit.getScheduler().runTaskLater(getPlugin(), this::deleteIsland, 2L); // 10 a second - } - } - - /** - * Gets a set of islands that are older than the parameter in days - * @param days days - * @return set of islands - */ - CompletableFuture> getOldIslands(int days) { - CompletableFuture> result = new CompletableFuture<>(); - // Process islands in one pass, logging and adding to the set if applicable - getPlugin().getIslands().getIslandsASync().thenAccept(list -> { - user.sendMessage("commands.admin.purge.total-islands", TextVariables.NUMBER, String.valueOf(list.size())); - Set oldIslands = new HashSet<>(); - list.stream() - .filter(i -> !i.isSpawn()).filter(i -> !i.isPurgeProtected()) - .filter(i -> i.getWorld() != null) // to handle currently unloaded world islands - .filter(i -> i.getWorld().equals(this.getWorld())) // Island needs to be in this world - .filter(Island::isOwned) // The island needs to be owned - .filter(i -> i.getMemberSet().stream().allMatch(member -> checkLastLoginTimestamp(days, member))) - .forEach(i -> oldIslands.add(i.getUniqueId())); // Add the unique island ID to the set - - result.complete(oldIslands); }); - return result; - } - - private boolean checkLastLoginTimestamp(int days, UUID member) { - long daysInMilliseconds = days * 24L * 3600 * 1000; // Calculate days in milliseconds - Long lastLoginTimestamp = getPlayers().getLastLoginTimestamp(member); - // If no valid last login time is found, or it's before the year 2000, try to fetch from Bukkit - if (lastLoginTimestamp == null || lastLoginTimestamp < YEAR2000) { - lastLoginTimestamp = Bukkit.getOfflinePlayer(member).getLastSeen(); - - // If still invalid, set the current timestamp to mark the user for eventual purging - if (lastLoginTimestamp < YEAR2000) { - getPlayers().setLoginTimeStamp(member, System.currentTimeMillis()); - return false; // User will be purged in the future - } else { - // Otherwise, update the last login timestamp with the valid value from Bukkit - getPlayers().setLoginTimeStamp(member, lastLoginTimestamp); - } - } - // Check if the difference between now and the last login is greater than the allowed days - return System.currentTimeMillis() - lastLoginTimestamp > daysInMilliseconds; + 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()); - /** - * @return the inPurge - */ - boolean isInPurge() { - return inPurge; - } + uniqueIslands.forEach(this::displayIsland); - /** - * Stop the purge - */ - void stop() { - inPurge = false; - } + scan.deletableRegions().entrySet().stream() + .filter(e -> e.getValue().isEmpty()) + .forEach(e -> displayEmptyRegion(e.getKey())); - /** - * @param user the user to set - */ - void setUser(User user) { - this.user = user; + 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.confirm", + TextVariables.LABEL, this.getTopLabel()); + user.sendMessage("general.beta"); + toBeConfirmed = true; + }); + } } - /** - * @param islands the islands to set - */ - void setIslands(Set islands) { - this.islands = islands; + private void displayIsland(Island island) { + if (island.isDeletable()) { + getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); + return; + } + if (island.getOwner() == null) { + getPlugin().log("Unowned island at " + Util.xyz(island.getCenter().toVector()) + + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); + return; + } + getPlugin().log("Island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + + " owned by " + getPlugin().getPlayers().getName(island.getOwner()) + + " who last logged in " + + formatLocalTimestamp(getPlugin().getPlayers().getLastLoginTimestamp(island.getOwner())) + + WILL_BE_DELETED); } - /** - * Returns the amount of purged islands. - * @return the amount of islands that have been purged. - * @since 1.13.0 - */ - int getPurgedIslandsCount() { - return this.count; + private void displayEmptyRegion(Pair region) { + getPlugin().log("Empty region at r." + region.x() + "." + region.z() + IN_WORLD + + getWorld().getName() + " will be deleted (no islands)"); } - /** - * Returns the amount of islands that can be purged. - * @return the amount of islands that can be purged. - * @since 1.13.0 - */ - int getPurgeableIslandsCount() { - return this.islands.size(); + private String formatLocalTimestamp(Long millis) { + if (millis == null) { + return "(unknown or never recorded)"; + } + Instant instant = Instant.ofEpochMilli(millis); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + .withZone(ZoneId.systemDefault()); + return formatter.format(instant); } } diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommand.java index b5cf9381e..968357fae 100644 --- 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 @@ -19,9 +19,9 @@ * 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. + *

    Counterpart to {@link AdminPurgeCommand} which filters on the age of + * the .mca files. This command trusts the {@code deletable} flag set by + * {@code /is reset} (and Phase 2 soft-delete) and reaps immediately. * *

    Heavy lifting is delegated to {@link PurgeRegionsService#scanDeleted(World)} * and {@link PurgeRegionsService#delete(PurgeScanResult)}. diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java deleted file mode 100644 index e69cd444b..000000000 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java +++ /dev/null @@ -1,194 +0,0 @@ -package world.bentobox.bentobox.api.commands.admin.purge; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -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.Pair; -import world.bentobox.bentobox.util.Util; - -/** - * 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 IN_WORLD = " in world "; - private static final String WILL_BE_DELETED = " will be deleted"; - - private volatile boolean inPurge; - private boolean toBeConfirmed; - private User user; - private PurgeScanResult lastScan; - - public AdminPurgeRegionsCommand(CompositeCommand parent) { - super(parent, "regions"); - getAddon().registerListener(this); - } - - @Override - public void setup() { - setPermission("admin.purge.regions"); - setOnlyPlayer(false); - setParametersHelp("commands.admin.purge.regions.parameters"); - setDescription("commands.admin.purge.regions.description"); - } - - @Override - public boolean canExecute(User user, String label, List args) { - if (inPurge) { - user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); - return false; - } - if (args.isEmpty()) { - showHelp(this, user); - return false; - } - return true; - } - - @Override - public boolean execute(User user, String label, List args) { - this.user = user; - if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed && this.user.equals(user)) { - return deleteEverything(); - } - 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); - 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 (lastScan == null || lastScan.isEmpty()) { - user.sendMessage(NONE_FOUND); - return false; - } - 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); - 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"); - } - }); - }); - 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(this::displayIsland); - - scan.deletableRegions().entrySet().stream() - .filter(e -> e.getValue().isEmpty()) - .forEach(e -> displayEmptyRegion(e.getKey())); - - 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("general.beta"); // TODO Remove beta in the future - this.toBeConfirmed = true; - }); - } - } - - private void displayIsland(Island island) { - if (island.isDeletable()) { - getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) - + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); - return; - } - if (island.getOwner() == null) { - getPlugin().log("Unowned island at " + Util.xyz(island.getCenter().toVector()) - + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); - return; - } - getPlugin().log("Island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() - + " owned by " + getPlugin().getPlayers().getName(island.getOwner()) - + " who last logged in " - + formatLocalTimestamp(getPlugin().getPlayers().getLastLoginTimestamp(island.getOwner())) - + WILL_BE_DELETED); - } - - private void displayEmptyRegion(Pair region) { - getPlugin().log("Empty region at r." + region.x() + "." + region.z() + IN_WORLD - + getWorld().getName() + " will be deleted (no islands)"); - } - - private String formatLocalTimestamp(Long millis) { - if (millis == null) { - return "(unknown or never recorded)"; - } - Instant instant = Instant.ofEpochMilli(millis); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") - .withZone(ZoneId.systemDefault()); - return formatter.format(instant); - } -} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java deleted file mode 100644 index 3f5bde9aa..000000000 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java +++ /dev/null @@ -1,49 +0,0 @@ -package world.bentobox.bentobox.api.commands.admin.purge; - -import java.util.List; - -import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; - -/** - * Displays the current status and progress of the purge. - * @since 1.13.0 - * @author Poslovitch - */ -public class AdminPurgeStatusCommand extends CompositeCommand { - - public AdminPurgeStatusCommand(AdminPurgeCommand parent) { - super(parent, "status"); - } - - @Override - public void setup() { - setPermission("admin.purge.status"); - setOnlyPlayer(false); - setParametersHelp("commands.admin.purge.status.parameters"); - setDescription("commands.admin.purge.status.description"); - } - - @Override - public boolean execute(User user, String label, List args) { - if (!args.isEmpty()) { - // Show help - showHelp(this, user); - return false; - } - AdminPurgeCommand parentCommand = ((AdminPurgeCommand)getParent()); - if (parentCommand.isInPurge()) { - int purged = parentCommand.getPurgedIslandsCount(); - int purgeable = parentCommand.getPurgeableIslandsCount(); - user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); - user.sendMessage("commands.admin.purge.status.status", - "[purged]", String.valueOf(purged), - "[purgeable]", String.valueOf(purgeable), - "[percentage]", String.format("%.1f", (((float) purged)/purgeable) * 100)); - } else { - user.sendMessage("commands.admin.purge.no-purge-in-progress"); - } - return true; - } -} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java deleted file mode 100644 index 84aeb6190..000000000 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java +++ /dev/null @@ -1,38 +0,0 @@ -package world.bentobox.bentobox.api.commands.admin.purge; - -import java.util.List; - -import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.user.User; - -public class AdminPurgeStopCommand extends CompositeCommand { - - public AdminPurgeStopCommand(CompositeCommand parent) { - super(parent, "stop", "cancel"); - } - - @Override - public void setup() { - setPermission("admin.purge.stop"); - setOnlyPlayer(false); - setDescription("commands.admin.purge.stop.description"); - } - - @Override - public boolean execute(User user, String label, List args) { - if (!args.isEmpty()) { - // Show help - showHelp(this, user); - return false; - } - AdminPurgeCommand parentCommand = ((AdminPurgeCommand)getParent()); - if (parentCommand.isInPurge()) { - user.sendMessage("commands.admin.purge.stop.stopping"); - parentCommand.stop(); - return true; - } else { - user.sendMessage("commands.admin.purge.no-purge-in-progress"); - return false; - } - } -} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java index d3334b13b..89519fabf 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java @@ -8,7 +8,17 @@ import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; +/** + * Flags every unowned, non-spawn, non-purge-protected island in the current + * world as {@code deletable} so that the next {@code /bbox admin purge deleted} + * (or the housekeeping deleted-sweep) reaps their region files. + * + *

    Since 3.15.0 the region-files purge excludes {@code !isOwned()} by design, + * so orphans never accumulate on disk unless something flags them + * {@code deletable} first. This command is that bridge. + */ public class AdminPurgeUnownedCommand extends ConfirmableCommand { public AdminPurgeUnownedCommand(AdminPurgeCommand parent) { @@ -26,36 +36,54 @@ public void setup() { @Override public boolean execute(User user, String label, List args) { if (!args.isEmpty()) { - // Show help showHelp(this, user); return false; } - AdminPurgeCommand parentCommand = ((AdminPurgeCommand)getParent()); - if (parentCommand.isInPurge()) { - user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); - return false; - } - Set unowned = getUnownedIslands(); - user.sendMessage("commands.admin.purge.unowned.unowned-islands", TextVariables.NUMBER, String.valueOf(unowned.size())); - if (!unowned.isEmpty()) { - this.askConfirmation(user, () -> { - parentCommand.setUser(user); - parentCommand.setIslands(unowned); - parentCommand.removeIslands(); - }); + Set unowned = getUnownedIslands(); + user.sendMessage("commands.admin.purge.unowned.unowned-islands", + TextVariables.NUMBER, String.valueOf(unowned.size())); + if (unowned.isEmpty()) { + return true; } + // Log locations up front so the admin can see what they are + // confirming before they confirm. + unowned.forEach(i -> getPlugin().log("Unowned island at " + + Util.xyz(i.getCenter().toVector()) + + " in world " + getWorld().getName() + + " will be flagged deletable")); + askConfirmation(user, () -> flagDeletable(user, unowned)); return true; } - Set getUnownedIslands() { + private void flagDeletable(User user, Set unowned) { + int flagged = 0; + for (Island island : unowned) { + getPlugin().log("Flagging unowned island at " + + Util.xyz(island.getCenter().toVector()) + + " in world " + getWorld().getName() + " as deletable"); + // Reuses the standard soft-delete path: fires the cancellable + // DELETE event, kicks any trespassers, sets deletable=true, + // saves, and fires DELETED. An addon veto skips the island. + getPlugin().getIslands().deleteIsland(island, true, null); + if (island.isDeletable()) { + flagged++; + } + } + getPlugin().log("Purge unowned: " + flagged + " of " + unowned.size() + + " island(s) flagged deletable in " + getWorld().getName()); + user.sendMessage("commands.admin.purge.unowned.flagged", + TextVariables.NUMBER, String.valueOf(flagged), + TextVariables.LABEL, getTopLabel()); + } + + Set getUnownedIslands() { return getPlugin().getIslands().getIslands().stream() .filter(i -> !i.isSpawn()) .filter(i -> !i.isPurgeProtected()) - .filter(i -> this.getWorld().equals(i.getWorld())) + .filter(i -> getWorld().equals(i.getWorld())) .filter(Island::isUnowned) - .map(Island::getUniqueId) + .filter(i -> !i.isDeletable()) .collect(Collectors.toSet()); - } -} \ No newline at end of file +} diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index f19eedf93..eba2c72b0 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -44,10 +44,10 @@ *

    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. + *

    Shared code path for scanning, filtering, and deleting region files + * across the overworld + optional nether/end dimensions. Used by + * {@code /bbox admin purge} (the top-level command), {@code purge deleted}, + * and the periodic {@link HousekeepingManager}. * * @since 3.15.0 */ diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 88fa8c416..738f8459f 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -103,28 +103,14 @@ commands: [prefix_island], decreasing the total to [total]resets.' purge: parameters: '[days]' - description: purge [prefix_Islands] abandoned for more than [days] + description: 'delete region files for [prefix_Islands] untouched for more than [days] days (frees disk)' days-one-or-more: 'Must be at least 1 day or more' purgable-islands: 'Found [number] purgable [prefix_Islands].' - too-many: 'This is a lot 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 - on how many you have...' - scanning-in-progress: 'Scanning in progress, please wait' + purge-in-progress: 'Purging in progress. Please wait for it to finish.' + scanning: 'Scanning region files. This may take a while depending on world size...' none-found: 'No [prefix_Islands] found to purge.' - total-islands: 'You have [number] [prefix_Islands] in your database in all worlds.' - number-error: 'Argument must be a number of days' confirm: 'Type /[label] purge confirm to start purging' - completed: 'Purging stopped.' - see-console-for-status: 'Purge started. See console for status or use - /[label] purge status.' - no-purge-in-progress: 'There is currently no purge in progress.' 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' @@ -139,16 +125,11 @@ commands: move-to-island: 'Move to [prefix_an-island] first!' protecting: 'Protecting [prefix_island] from purge.' unprotecting: 'Removing purge protection.' - stop: - description: stop a purge in progress - stopping: Stopping the purge unowned: - description: purge unowned islands - unowned-islands: 'Found [number] unowned islands.' - status: - description: displays the status of the purge - status: '[purged] islands purged out of [purgeable] ([percentage] - %).' + parameters: '' + description: flag unowned [prefix_Islands] as deletable so they can be reaped + unowned-islands: 'Found [number] unowned [prefix_Islands].' + flagged: 'Flagged [number] [prefix_island](s) as deletable. Run /[label] purge deleted to reap the region files.' team: description: manage teams add: 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 c2789d9be..739ffcc5d 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java @@ -4,52 +4,53 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; -import org.bukkit.World; import org.bukkit.scheduler.BukkitScheduler; import org.bukkit.util.Vector; -import org.eclipse.jdt.annotation.NonNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; -import org.mockito.Mockito; import com.google.common.collect.ImmutableSet; import world.bentobox.bentobox.CommonTestSetup; -import world.bentobox.bentobox.Settings; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.events.island.IslandDeletedEvent; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.AddonsManager; import world.bentobox.bentobox.managers.CommandsManager; import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.island.IslandCache; +import world.bentobox.bentobox.managers.island.IslandGrid; /** - * @author tastybento + * Tests for {@link AdminPurgeCommand}. * + *

    Since 3.15.0 the top-level purge command does a region-files purge + * (formerly {@code /bbox admin purge regions}). Tests drive it through the + * async scheduler mock and assert against the real {@link PurgeRegionsService}. */ class AdminPurgeCommandTest extends CommonTestSetup { @@ -57,56 +58,68 @@ class AdminPurgeCommandTest extends CommonTestSetup { private CompositeCommand ac; @Mock private User user; - - private AdminPurgeCommand apc; @Mock private Addon addon; @Mock + private BukkitScheduler scheduler; + @Mock + private IslandCache islandCache; + @Mock private PlayersManager pm; @Mock - private BukkitScheduler scheduler; + private AddonsManager addonsManager; + + @TempDir + Path tempDir; + + private AdminPurgeCommand apc; @Override @BeforeEach public void setUp() throws Exception { super.setUp(); - // Mock the method to immediately run the Runnable - when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(invocation -> { - Runnable task = invocation.getArgument(1); - task.run(); // Immediately run the Runnable - return null; // or return a mock of the Task if needed + + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; }); mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList()); - // Command manager CommandsManager cm = mock(CommandsManager.class); when(plugin.getCommandsManager()).thenReturn(cm); when(ac.getWorld()).thenReturn(world); - when(ac.getAddon()).thenReturn(addon); when(ac.getTopLabel()).thenReturn("bsb"); - // No islands by default - when(im.getIslands()).thenReturn(Collections.emptyList()); - when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList())); - - // IWM + when(iwm.isNetherGenerate(world)).thenReturn(false); + when(iwm.isNetherIslands(world)).thenReturn(false); + when(iwm.isEndGenerate(world)).thenReturn(false); + when(iwm.isEndIslands(world)).thenReturn(false); when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); + when(iwm.getNetherWorld(world)).thenReturn(null); + when(iwm.getEndWorld(world)).thenReturn(null); + + when(plugin.getPlayers()).thenReturn(pm); + when(pm.getName(any())).thenReturn("PlayerName"); + + when(im.getIslandCache()).thenReturn(islandCache); + when(islandCache.getIslandGrid(world)).thenReturn(null); + + when(world.getWorldFolder()).thenReturn(tempDir.toFile()); - // Island - when(island.isOwned()).thenReturn(true); // Default owned - when(location.toVector()).thenReturn(new Vector(1, 2, 3)); when(island.getCenter()).thenReturn(location); + when(location.toVector()).thenReturn(new Vector(0, 0, 0)); - // Player manager - when(plugin.getPlayers()).thenReturn(pm); - when(pm.getName(any())).thenReturn("name"); + when(plugin.getAddonsManager()).thenReturn(addonsManager); + when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); - Settings settings = new Settings(); - // Settings - when(plugin.getSettings()).thenReturn(settings); + when(plugin.getPurgeRegionsService()).thenReturn(new PurgeRegionsService(plugin)); - // Command apc = new AdminPurgeCommand(ac); } @@ -116,273 +129,293 @@ public void tearDown() throws Exception { super.tearDown(); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#AdminPurgeCommand(CompositeCommand)}. - */ - @Test - void testConstructor() { - verify(addon).registerListener(apc); - } - - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#setup()}. - */ @Test void testSetup() { assertEquals("admin.purge", apc.getPermission()); assertFalse(apc.isOnlyPlayer()); assertEquals("commands.admin.purge.parameters", apc.getParameters()); assertEquals("commands.admin.purge.description", apc.getDescription()); - assertEquals(8, apc.getSubCommands().size()); + // 4 explicit subcommands (unowned, protect, age-regions, deleted) + help + assertEquals(5, apc.getSubCommands().size()); } - - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#canExecute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testCanExecuteUserStringListOfStringEmptyArgs() { - assertFalse(apc.canExecute(user, "", Collections.emptyList())); - verify(user).sendMessage("commands.help.header", - "[label]", - "BSkyBlock"); + void testCanExecuteEmptyArgs() { + assertFalse(apc.canExecute(user, "purge", Collections.emptyList())); + verify(user).sendMessage(eq("commands.help.header"), any(), any()); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#canExecute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testCanExecuteUserStringListOfStringWithArg() { - assertTrue(apc.canExecute(user, "", Collections.singletonList("23"))); + void testCanExecuteWithArgs() { + assertTrue(apc.canExecute(user, "purge", List.of("10"))); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringNotNumber() { - assertFalse(apc.execute(user, "", Collections.singletonList("abc"))); - verify(user).sendMessage("commands.admin.purge.number-error"); + void testExecuteNotANumber() { + assertFalse(apc.execute(user, "purge", List.of("notanumber"))); + verify(user).sendMessage("commands.admin.purge.days-one-or-more"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringZero() { - assertFalse(apc.execute(user, "", Collections.singletonList("0"))); + void testExecuteZeroDays() { + assertFalse(apc.execute(user, "purge", List.of("0"))); verify(user).sendMessage("commands.admin.purge.days-one-or-more"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringNoIslands() { - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + void testExecuteNegativeDays() { + assertFalse(apc.execute(user, "purge", List.of("-3"))); + verify(user).sendMessage("commands.admin.purge.days-one-or-more"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringNoIslandsPurgeProtected() { - when(island.isPurgeProtected()).thenReturn(true); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); - } + void testExecuteNullIslandGrid() { + when(islandCache.getIslandGrid(world)).thenReturn(null); - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ - @Test - void testExecuteUserStringListOfStringNoIslandsWrongWorld() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(mock(World.class)); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringNoIslandsUnowned() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.getOwner()).thenReturn(null); - when(island.isUnowned()).thenReturn(true); - when(island.isOwned()).thenReturn(false); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); - } + void testExecuteEmptyGrid() { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); - /** - * Makes sure that no spawn islands are deleted - */ - @Test - void testExecuteUserStringListOfStringOnlyIslandSpawn() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.isSpawn()).thenReturn(true); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ - @SuppressWarnings("deprecation") @Test - void testExecuteUserStringListOfStringNoIslandsTeamIsland() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.getOwner()).thenReturn(UUID.randomUUID()); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID(), UUID.randomUUID())); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - - // All players are up to date - OfflinePlayer op = mock(OfflinePlayer.class); - when(op.getLastPlayed()).thenReturn(System.currentTimeMillis()); - mockedBukkit.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))).thenReturn(op); - - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); - } + void testExecuteNoRegionFiles() throws IOException { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ - @Test - void testExecuteUserStringListOfStringNoIslandsRecentLogin() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.getOwner()).thenReturn(UUID.randomUUID()); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID())); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - OfflinePlayer op = mock(OfflinePlayer.class); - when(op.getLastPlayed()).thenReturn(System.currentTimeMillis()); - mockedBukkit.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))).thenReturn(op); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + Files.createDirectories(tempDir.resolve("region")); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringIslandsFound() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.getOwner()).thenReturn(UUID.randomUUID()); - when(island.isOwned()).thenReturn(true); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID())); - when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(List.of(island))); - when(pm.getLastLoginTimestamp(any())).thenReturn(962434800L); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); // 10 days ago + void testExecuteOldRegionFileNoIslands() throws IOException { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + + assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.total-islands", "[number]", "1"); - verify(user, never()).sendMessage("commands.admin.purge.none-found"); + verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "0"); verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); } - - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#removeIslands()}. - */ @Test - void testRemoveIslands() { - @NonNull - Optional opIsland = Optional.of(island); - when(im.getIslandById(any())).thenReturn(opIsland); - testExecuteUserStringListOfStringIslandsFound(); - assertTrue(apc.execute(user, "", Collections.singletonList("confirm"))); - verify(im).deleteIsland(island, true, null); - verify(plugin).log(any()); - verify(user).sendMessage("commands.admin.purge.see-console-for-status", "[label]", "bsb"); + void testExecuteConfirmDeletesRegions() throws IOException { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Path regionFile = regionDir.resolve("r.0.0.mca"); + Files.createFile(regionFile); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); + + assertTrue(apc.execute(user, "purge", List.of("confirm"))); + verify(user).sendMessage("general.success"); + assertFalse(regionFile.toFile().exists(), "Region file should have been deleted"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#onIslandDeleted(world.bentobox.bentobox.api.events.island.IslandEvent.IslandDeletedEvent)}. - */ @Test - void testOnIslandDeletedNotInPurge() { - IslandDeletedEvent e = mock(IslandDeletedEvent.class); - apc.onIslandDeleted(e); - verify(user, Mockito.never()).sendMessage(any()); - verify(plugin, Mockito.never()).log(any()); + void testExecuteConfirmWithoutPriorScan() { + assertFalse(apc.execute(user, "purge", List.of("confirm"))); + verify(user).sendMessage("commands.admin.purge.days-one-or-more"); + verify(user, never()).sendMessage("general.success"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#onIslandDeleted(world.bentobox.bentobox.api.events.island.IslandEvent.IslandDeletedEvent)}. - */ @Test - void testOnIslandDeletedPurgeCompleted() { - testRemoveIslands(); - IslandDeletedEvent e = mock(IslandDeletedEvent.class); - apc.onIslandDeleted(e); - verify(user).sendMessage("commands.admin.purge.completed"); - verify(plugin, Mockito.never()).log(""); + void testExecuteRecentRegionFileExcluded() throws IOException { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + File regionFile = regionDir.resolve("r.0.0.mca").toFile(); + + byte[] data = new byte[8192]; + int nowSeconds = (int) (System.currentTimeMillis() / 1000L); + for (int i = 0; i < 1024; i++) { + int offset = 4096 + i * 4; + data[offset] = (byte) (nowSeconds >> 24); + data[offset + 1] = (byte) (nowSeconds >> 16); + data[offset + 2] = (byte) (nowSeconds >> 8); + data[offset + 3] = (byte) nowSeconds; + } + Files.write(regionFile.toPath(), data); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#isInPurge()}. - */ @Test - void testIsInPurge() { - assertFalse(apc.isInPurge()); - testRemoveIslands(); - assertTrue(apc.isInPurge()); + void testExecuteIslandWithRecentLoginIsExcluded() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-1"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isOwned()).thenReturn(true); + when(island.isDeletable()).thenReturn(false); + when(island.isPurgeProtected()).thenReturn(false); + when(island.isSpawn()).thenReturn(false); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + + IslandGrid.IslandData data = new IslandGrid.IslandData("island-1", 0, 0, 100); + Collection islandList = List.of(data); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-1")).thenReturn(Optional.of(island)); + + when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(System.currentTimeMillis()); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#stop()}. - */ @Test - void testStop() { - testRemoveIslands(); - assertTrue(apc.isInPurge()); - apc.stop(); - assertFalse(apc.isInPurge()); + void testExecuteSpawnIslandNotPurged() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-spawn"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isOwned()).thenReturn(true); + when(island.isDeletable()).thenReturn(false); + when(island.isPurgeProtected()).thenReturn(false); + when(island.isSpawn()).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + + IslandGrid.IslandData data = new IslandGrid.IslandData("island-spawn", 0, 0, 100); + Collection islandList = List.of(data); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-spawn")).thenReturn(Optional.of(island)); + + when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#setUser(world.bentobox.bentobox.api.user.User)}. - */ @Test - void testSetUser() { - apc.setUser(user); - apc.removeIslands(); - verify(user, Mockito.times(1)).sendMessage(anyString()); + void testExecutePurgeProtectedIslandNotPurged() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-protected"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isOwned()).thenReturn(true); + when(island.isDeletable()).thenReturn(false); + when(island.isPurgeProtected()).thenReturn(true); + when(island.isSpawn()).thenReturn(false); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + + IslandGrid.IslandData data = new IslandGrid.IslandData("island-protected", 0, 0, 100); + Collection islandList = List.of(data); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-protected")).thenReturn(Optional.of(island)); + + when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#getOldIslands(int)} - * @throws TimeoutException - * @throws ExecutionException - * @throws InterruptedException - */ @Test - void testGetOldIslands() throws InterruptedException, ExecutionException, TimeoutException { - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); // 10 days ago - // First, ensure that the result is empty - CompletableFuture> result = apc.getOldIslands(10); - Set set = result.join(); - assertTrue(set.isEmpty()); - // Mocking Islands and their retrieval - Island island1 = mock(Island.class); - Island island2 = mock(Island.class); - - when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(List.of(island1, island2))); - // Now, check again after mocking islands - CompletableFuture> futureWithIslands = apc.getOldIslands(10); - assertTrue(futureWithIslands.get(5, TimeUnit.SECONDS).isEmpty()); // Adjust this assertion based on the expected behavior of getOldIslands + void testExecuteDeletableIslandIncluded() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-deletable"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isOwned()).thenReturn(true); + when(island.isDeletable()).thenReturn(true); + when(island.isPurgeProtected()).thenReturn(false); + when(island.isSpawn()).thenReturn(false); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); + Collection islandList = List.of(data); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1"); + verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); } + @Test + void testExecuteConfirmDeletesPlayerData() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-deletable"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isOwned()).thenReturn(true); + when(island.isDeletable()).thenReturn(true); + when(island.isPurgeProtected()).thenReturn(false); + when(island.isSpawn()).thenReturn(false); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + + IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); + Collection islandList = List.of(data); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); + + when(im.getIslands(world, ownerUUID)).thenReturn(List.of(island)); + + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + when(offlinePlayer.isOp()).thenReturn(false); + when(offlinePlayer.getLastSeen()).thenReturn(0L); + mockedBukkit.when(() -> Bukkit.getOfflinePlayer(ownerUUID)).thenReturn(offlinePlayer); + when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); + + when(im.deleteIslandId("island-deletable")).thenReturn(true); + + 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(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); + + assertTrue(apc.execute(user, "purge", List.of("confirm"))); + verify(user).sendMessage("general.success"); + verify(im).deleteIslandId("island-deletable"); + assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted"); + } } diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java deleted file mode 100644 index 97d02b579..000000000 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java +++ /dev/null @@ -1,550 +0,0 @@ -package world.bentobox.bentobox.api.commands.admin.purge; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.scheduler.BukkitScheduler; -import org.bukkit.util.Vector; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; - -import com.google.common.collect.ImmutableSet; - -import world.bentobox.bentobox.CommonTestSetup; -import world.bentobox.bentobox.api.addons.Addon; -import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.managers.AddonsManager; -import world.bentobox.bentobox.managers.CommandsManager; -import world.bentobox.bentobox.managers.PlayersManager; -import world.bentobox.bentobox.managers.PurgeRegionsService; -import world.bentobox.bentobox.managers.island.IslandCache; -import world.bentobox.bentobox.managers.island.IslandGrid; - -/** - * Tests for {@link AdminPurgeRegionsCommand}. - */ -class AdminPurgeRegionsCommandTest extends CommonTestSetup { - - @Mock - private CompositeCommand ac; - @Mock - private User user; - @Mock - private Addon addon; - @Mock - private BukkitScheduler scheduler; - @Mock - private IslandCache islandCache; - @Mock - private PlayersManager pm; - @Mock - private AddonsManager addonsManager; - - @TempDir - Path tempDir; - - private AdminPurgeCommand apc; - private AdminPurgeRegionsCommand aprc; - - @Override - @BeforeEach - public void setUp() throws Exception { - super.setUp(); - - // Scheduler - run tasks immediately (both async and sync) - when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { - invocation.getArgument(1).run(); - return null; - }); - when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { - invocation.getArgument(1).run(); - return null; - }); - mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler); - mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList()); - - // Command manager - CommandsManager cm = mock(CommandsManager.class); - when(plugin.getCommandsManager()).thenReturn(cm); - when(ac.getWorld()).thenReturn(world); - when(ac.getAddon()).thenReturn(addon); - when(ac.getTopLabel()).thenReturn("bsb"); - - // IWM - no nether/end by default - when(iwm.isNetherGenerate(world)).thenReturn(false); - when(iwm.isNetherIslands(world)).thenReturn(false); - when(iwm.isEndGenerate(world)).thenReturn(false); - when(iwm.isEndIslands(world)).thenReturn(false); - when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); - when(iwm.getNetherWorld(world)).thenReturn(null); - when(iwm.getEndWorld(world)).thenReturn(null); - - // Players manager - when(plugin.getPlayers()).thenReturn(pm); - when(pm.getName(any())).thenReturn("PlayerName"); - - // Island cache - when(im.getIslandCache()).thenReturn(islandCache); - when(islandCache.getIslandGrid(world)).thenReturn(null); - - // World folder points to our temp directory - when(world.getWorldFolder()).thenReturn(tempDir.toFile()); - - // Island - when(island.getCenter()).thenReturn(location); - when(location.toVector()).thenReturn(new Vector(0, 0, 0)); - - // Addons manager (used by canDeleteIsland for Level check) - when(plugin.getAddonsManager()).thenReturn(addonsManager); - when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); - - // 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); - } - - @Override - @AfterEach - public void tearDown() throws Exception { - super.tearDown(); - } - - /** - * Verify the command registers itself as a Bukkit listener via the addon. - * AdminPurgeCommand.setup() creates one instance, and setUp() creates a second — so two calls. - */ - @Test - void testConstructor() { - verify(addon, times(2)).registerListener(any(AdminPurgeRegionsCommand.class)); - } - - /** - * Verify command metadata set in setup(). - */ - @Test - void testSetup() { - assertEquals("admin.purge.regions", aprc.getPermission()); - assertFalse(aprc.isOnlyPlayer()); - assertEquals("commands.admin.purge.regions.parameters", aprc.getParameters()); - assertEquals("commands.admin.purge.regions.description", aprc.getDescription()); - } - - /** - * canExecute with no args should show help and return false. - */ - @Test - void testCanExecuteEmptyArgs() { - assertFalse(aprc.canExecute(user, "regions", Collections.emptyList())); - verify(user).sendMessage(eq("commands.help.header"), any(), any()); - } - - /** - * canExecute with a valid argument should return true. - */ - @Test - void testCanExecuteWithArgs() { - assertTrue(aprc.canExecute(user, "regions", List.of("10"))); - verify(user, never()).sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, "bsb"); - } - - /** - * A non-numeric argument should produce an error message and return false. - */ - @Test - void testExecuteNotANumber() { - assertFalse(aprc.execute(user, "regions", List.of("notanumber"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - } - - /** - * Zero days is invalid — must be one or more. - */ - @Test - void testExecuteZeroDays() { - assertFalse(aprc.execute(user, "regions", List.of("0"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - } - - /** - * Negative days is invalid. - */ - @Test - void testExecuteNegativeDays() { - assertFalse(aprc.execute(user, "regions", List.of("-3"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - } - - /** - * When the island grid is null (no islands registered for the world), - * the command should report none found. - */ - @Test - void testExecuteNullIslandGrid() { - when(islandCache.getIslandGrid(world)).thenReturn(null); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * When the island grid is empty (no islands), - * the command should report none found. - */ - @Test - void testExecuteEmptyGrid() { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * When there are no .mca files in the region folder, none-found should be sent. - */ - @Test - void testExecuteNoRegionFiles() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - // Create the region directory but leave it empty - Files.createDirectories(tempDir.resolve("region")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * When there is an old region file with no associated islands (empty grid), - * the command should propose deletion and ask for confirmation. - */ - @Test - void testExecuteOldRegionFileNoIslands() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - // Create a small .mca file — getRegionTimestamp() returns 0 for files < 8192 bytes, - // which is treated as very old (older than any cutoff). - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - // 0 islands found, but the empty region itself is deletable → confirm prompt - verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "0"); - verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions"); - } - - /** - * After scanning finds deletable regions, executing "confirm" should delete - * the region files and send the success message. - */ - @Test - void testExecuteConfirmDeletesRegions() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Path regionFile = regionDir.resolve("r.0.0.mca"); - Files.createFile(regionFile); - - // First execution: scan and find the old empty region - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions"); - - // Second execution: confirm deletion - assertTrue(aprc.execute(user, "regions", List.of("confirm"))); - verify(user).sendMessage("general.success"); - assertFalse(regionFile.toFile().exists(), "Region file should have been deleted"); - } - - /** - * If "confirm" is sent before a scan, it should not trigger deletion - * and should fall through to normal argument parsing (returning false for "confirm" - * which is not a valid number). - */ - @Test - void testExecuteConfirmWithoutPriorScan() { - assertFalse(aprc.execute(user, "regions", List.of("confirm"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - verify(user, never()).sendMessage("general.success"); - } - - /** - * A recent region file (newer than the cutoff) should NOT be included in the deletion list. - * The command should report none found. - */ - @Test - void testExecuteRecentRegionFileExcluded() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - // Create a valid 8192-byte .mca file with a very recent timestamp so it is excluded. - // The timestamp table occupies the second 4KB block; we'll write a recent Unix timestamp. - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - File regionFile = regionDir.resolve("r.0.0.mca").toFile(); - - // Build a minimal 8192-byte region file with current timestamp in all 1024 chunk slots - byte[] data = new byte[8192]; - // Write current time (in seconds) as big-endian int into each 4-byte slot of the timestamp table - int nowSeconds = (int) (System.currentTimeMillis() / 1000L); - for (int i = 0; i < 1024; i++) { - int offset = 4096 + i * 4; - data[offset] = (byte) (nowSeconds >> 24); - data[offset + 1] = (byte) (nowSeconds >> 16); - data[offset + 2] = (byte) (nowSeconds >> 8); - data[offset + 3] = (byte) nowSeconds; - } - Files.write(regionFile.toPath(), data); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * An island whose member logged in recently must be excluded from purge candidates. - */ - @Test - void testExecuteIslandWithRecentLoginIsExcluded() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-1"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - // Island occupies region r.0.0 (minX=0, minZ=0, range=100) - IslandGrid.IslandData data = new IslandGrid.IslandData("island-1", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-1")).thenReturn(Optional.of(island)); - - // Owner logged in recently — island must be protected from purge - when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(System.currentTimeMillis()); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * Spawn islands must never be purged. - */ - @Test - void testExecuteSpawnIslandNotPurged() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-spawn"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(true); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-spawn", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-spawn")).thenReturn(Optional.of(island)); - - // Owner hasn't logged in for a long time - when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * Purge-protected islands must never be purged. - */ - @Test - void testExecutePurgeProtectedIslandNotPurged() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-protected"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(true); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-protected", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-protected")).thenReturn(Optional.of(island)); - - when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * An island marked as deletable should always be eligible regardless of other flags. - */ - @Test - void testExecuteDeletableIslandIncluded() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-deletable"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(true); // deletable flag set - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - // The island is deletable → canDeleteIsland returns false → region is kept → confirm prompt - verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1"); - verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions"); - } - - /** - * Deleting a region where the island has members removes their playerdata files - * when they have no remaining islands and haven't logged in recently. - */ - @Test - void testExecuteConfirmDeletesPlayerData() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-deletable"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(true); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); - - // Player has no remaining islands after deletion - when(im.getIslands(world, ownerUUID)).thenReturn(List.of(island)); - - // Player is not op and hasn't logged in recently - OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); - when(offlinePlayer.isOp()).thenReturn(false); - when(offlinePlayer.getLastSeen()).thenReturn(0L); - mockedBukkit.when(() -> Bukkit.getOfflinePlayer(ownerUUID)).thenReturn(offlinePlayer); - when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); - - // Island cache operations - when(im.deleteIslandId("island-deletable")).thenReturn(true); - - // Create region and playerdata files - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata")); - Path playerFile = playerDataDir.resolve(ownerUUID + ".dat"); - Files.createFile(playerFile); - - // Scan - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions"); - - // Confirm - assertTrue(aprc.execute(user, "regions", List.of("confirm"))); - verify(user).sendMessage("general.success"); - verify(im).deleteIslandId("island-deletable"); - assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted"); - } - - /** - * 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/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java index c3ff40833..9f475efe8 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java @@ -8,6 +8,7 @@ import java.util.Collections; +import org.bukkit.util.Vector; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -20,8 +21,11 @@ import world.bentobox.bentobox.managers.CommandsManager; /** - * @author Poslovitch - * + * Tests for {@link AdminPurgeUnownedCommand}. The command scans for orphan + * islands and (on confirmation) soft-deletes them so the region-files purge + * can reap their {@code .mca} files later. These tests cover the scan phase; + * the confirmation-triggered soft-delete path is exercised via + * {@link world.bentobox.bentobox.managers.IslandsManager#deleteIsland}. */ class AdminPurgeUnownedCommandTest extends CommonTestSetup { @@ -34,14 +38,12 @@ class AdminPurgeUnownedCommandTest extends CommonTestSetup { private AdminPurgeUnownedCommand apuc; @Mock private Addon addon; - @Override @BeforeEach public void setUp() throws Exception { super.setUp(); - // Command manager CommandsManager cm = mock(CommandsManager.class); when(plugin.getCommandsManager()).thenReturn(cm); when(ac.getWorld()).thenReturn(world); @@ -49,20 +51,19 @@ public void setUp() throws Exception { when(ac.getAddon()).thenReturn(addon); when(ac.getTopLabel()).thenReturn("bsb"); - // No islands by default when(im.getIslands()).thenReturn(Collections.emptyList()); - // IWM when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); - // Island when(island.getWorld()).thenReturn(world); when(island.isSpawn()).thenReturn(false); when(island.isPurgeProtected()).thenReturn(false); - when(island.isOwned()).thenReturn(true); // Default owned + when(island.isOwned()).thenReturn(true); when(island.isUnowned()).thenReturn(false); + when(island.isDeletable()).thenReturn(false); + when(island.getCenter()).thenReturn(location); + when(location.toVector()).thenReturn(new Vector(0, 0, 0)); - // Command apc = new AdminPurgeCommand(ac); apuc = new AdminPurgeUnownedCommand(apc); } @@ -74,7 +75,7 @@ public void tearDown() throws Exception { } /** - * Makes sure no spawn islands are purged whatsoever + * Spawn islands must never be flagged deletable. */ @Test void testNoPurgeIfIslandIsSpawn() { @@ -84,6 +85,9 @@ void testNoPurgeIfIslandIsSpawn() { verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0"); } + /** + * Owned islands must never be flagged deletable. + */ @Test void testNoPurgeIfIslandIsOwned() { when(im.getIslands()).thenReturn(Collections.singleton(island)); @@ -91,16 +95,21 @@ void testNoPurgeIfIslandIsOwned() { verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0"); } + /** + * A genuine orphan gets counted and (later, on confirm) flagged. + */ @Test void testPurgeIfIslandIsUnowned() { when(island.isOwned()).thenReturn(false); when(island.isUnowned()).thenReturn(true); - when(island.getWorld()).thenReturn(world); when(im.getIslands()).thenReturn(Collections.singleton(island)); assertTrue(apuc.execute(user, "", Collections.emptyList())); verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "1"); } + /** + * Purge-protected islands must never be flagged deletable, even if unowned. + */ @Test void testNoPurgeIfIslandIsPurgeProtected() { when(island.isPurgeProtected()).thenReturn(true); @@ -108,4 +117,18 @@ void testNoPurgeIfIslandIsPurgeProtected() { assertTrue(apuc.execute(user, "", Collections.emptyList())); verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0"); } + + /** + * Islands already flagged deletable must not be counted again — they are + * already in the queue the regions-purge will drain. + */ + @Test + void testNoPurgeIfIslandAlreadyDeletable() { + when(island.isOwned()).thenReturn(false); + when(island.isUnowned()).thenReturn(true); + when(island.isDeletable()).thenReturn(true); + when(im.getIslands()).thenReturn(Collections.singleton(island)); + assertTrue(apuc.execute(user, "", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0"); + } } From fcdca35f19bae4aabad93f56b5e5ad3eada3a834 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Apr 2026 08:20:27 -0700 Subject: [PATCH 34/39] Address SonarCloud quality gate failures on #2949 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop always-true `this.user.equals(user)` guards in AdminPurgeCommand and AdminPurgeDeletedCommand — `this.user = user` was assigned on the prior line, so the check could never fail. - Parameterize the three days-validation tests (notanumber / 0 / -3) as a single @ParameterizedTest. - Extract `wireIsland(id, deletable, purgeProtected, spawn)`, `wireEmptyGrid()`, and `createRegionFile()` helpers in AdminPurgeCommandTest to collapse ~15-line setup blocks duplicated across the 8 scenario tests. Co-Authored-By: Claude Opus 4.7 --- .../admin/purge/AdminPurgeCommand.java | 2 +- .../admin/purge/AdminPurgeDeletedCommand.java | 3 +- .../admin/purge/AdminPurgeCommandTest.java | 194 ++++++------------ 3 files changed, 61 insertions(+), 138 deletions(-) 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 344c9360f..bc708ff9b 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 @@ -76,7 +76,7 @@ public boolean canExecute(User user, String label, List args) { @Override public boolean execute(User user, String label, List args) { this.user = user; - if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed && this.user.equals(user)) { + if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed) { return deleteEverything(); } toBeConfirmed = false; 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 index 968357fae..72621798c 100644 --- 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 @@ -61,8 +61,7 @@ public boolean canExecute(User user, String label, List args) { @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)) { + if (!args.isEmpty() && args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed) { return deleteEverything(); } toBeConfirmed = false; 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 739ffcc5d..04efc21ae 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java @@ -29,6 +29,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import com.google.common.collect.ImmutableSet; @@ -150,21 +152,10 @@ void testCanExecuteWithArgs() { assertTrue(apc.canExecute(user, "purge", List.of("10"))); } - @Test - void testExecuteNotANumber() { - assertFalse(apc.execute(user, "purge", List.of("notanumber"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - } - - @Test - void testExecuteZeroDays() { - assertFalse(apc.execute(user, "purge", List.of("0"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - } - - @Test - void testExecuteNegativeDays() { - assertFalse(apc.execute(user, "purge", List.of("-3"))); + @ParameterizedTest + @ValueSource(strings = {"notanumber", "0", "-3"}) + void testExecuteInvalidDays(String arg) { + assertFalse(apc.execute(user, "purge", List.of(arg))); verify(user).sendMessage("commands.admin.purge.days-one-or-more"); } @@ -179,9 +170,7 @@ void testExecuteNullIslandGrid() { @Test void testExecuteEmptyGrid() { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); + wireEmptyGrid(); assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.scanning"); @@ -190,10 +179,7 @@ void testExecuteEmptyGrid() { @Test void testExecuteNoRegionFiles() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - + wireEmptyGrid(); Files.createDirectories(tempDir.resolve("region")); assertTrue(apc.execute(user, "purge", List.of("10"))); @@ -203,12 +189,8 @@ void testExecuteNoRegionFiles() throws IOException { @Test void testExecuteOldRegionFileNoIslands() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); + wireEmptyGrid(); + createRegionFile(); assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.scanning"); @@ -218,13 +200,9 @@ void testExecuteOldRegionFileNoIslands() throws IOException { @Test void testExecuteConfirmDeletesRegions() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Path regionFile = regionDir.resolve("r.0.0.mca"); - Files.createFile(regionFile); + wireEmptyGrid(); + Path regionFile = tempDir.resolve("region").resolve("r.0.0.mca"); + createRegionFile(); assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); @@ -243,10 +221,7 @@ void testExecuteConfirmWithoutPriorScan() { @Test void testExecuteRecentRegionFileExcluded() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - + wireEmptyGrid(); Path regionDir = Files.createDirectories(tempDir.resolve("region")); File regionFile = regionDir.resolve("r.0.0.mca").toFile(); @@ -268,27 +243,9 @@ void testExecuteRecentRegionFileExcluded() throws IOException { @Test void testExecuteIslandWithRecentLoginIsExcluded() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-1"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-1", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-1")).thenReturn(Optional.of(island)); - + UUID ownerUUID = wireIsland("island-1", false, false, false); when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(System.currentTimeMillis()); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); + createRegionFile(); assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.none-found"); @@ -296,26 +253,9 @@ void testExecuteIslandWithRecentLoginIsExcluded() throws IOException { @Test void testExecuteSpawnIslandNotPurged() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-spawn"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(true); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-spawn", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-spawn")).thenReturn(Optional.of(island)); - + UUID ownerUUID = wireIsland("island-spawn", false, false, true); when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); + createRegionFile(); assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.none-found"); @@ -323,26 +263,9 @@ void testExecuteSpawnIslandNotPurged() throws IOException { @Test void testExecutePurgeProtectedIslandNotPurged() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-protected"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(true); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-protected", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-protected")).thenReturn(Optional.of(island)); - + UUID ownerUUID = wireIsland("island-protected", false, true, false); when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); + createRegionFile(); assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.none-found"); @@ -350,25 +273,8 @@ void testExecutePurgeProtectedIslandNotPurged() throws IOException { @Test void testExecuteDeletableIslandIncluded() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-deletable"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(true); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); + wireIsland("island-deletable", true, false, false); + createRegionFile(); assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1"); @@ -377,23 +283,7 @@ void testExecuteDeletableIslandIncluded() throws IOException { @Test void testExecuteConfirmDeletesPlayerData() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-deletable"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(true); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); - + UUID ownerUUID = wireIsland("island-deletable", true, false, false); when(im.getIslands(world, ownerUUID)).thenReturn(List.of(island)); OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); @@ -404,8 +294,7 @@ void testExecuteConfirmDeletesPlayerData() throws IOException { when(im.deleteIslandId("island-deletable")).thenReturn(true); - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); + createRegionFile(); Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata")); Path playerFile = playerDataDir.resolve(ownerUUID + ".dat"); Files.createFile(playerFile); @@ -418,4 +307,39 @@ void testExecuteConfirmDeletesPlayerData() throws IOException { verify(im).deleteIslandId("island-deletable"); assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted"); } + + /** + * Wires the shared {@code island} mock with the given id and flags, puts it + * in an IslandGrid at (0,0), and returns the generated owner UUID. + */ + private UUID wireIsland(String id, boolean deletable, boolean purgeProtected, boolean spawn) { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn(id); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isOwned()).thenReturn(true); + when(island.isDeletable()).thenReturn(deletable); + when(island.isPurgeProtected()).thenReturn(purgeProtected); + when(island.isSpawn()).thenReturn(spawn); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + + IslandGrid.IslandData data = new IslandGrid.IslandData(id, 0, 0, 100); + Collection islandList = List.of(data); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById(id)).thenReturn(Optional.of(island)); + return ownerUUID; + } + + private void createRegionFile() throws IOException { + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + } + + private void wireEmptyGrid() { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + } } From 64aae80a00d5c4ec1bc1973571fa53e15beec959 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Apr 2026 10:37:54 -0700 Subject: [PATCH 35/39] Extract AbstractPurgeCommand to remove duplication between purge subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminPurgeCommand and AdminPurgeDeletedCommand each carried their own inPurge/toBeConfirmed/lastScan state machine and near-identical canExecute / scan-and-prompt / deleteEverything bodies (~75 lines apiece, diverging only in scan source, log prefix, confirm message and an optional chunk-evict step). Pull the shared scaffolding into a package-private AbstractPurgeCommand, with subclasses supplying: - logPrefix() — log line prefix - successMessageKey() — locale key sent on a successful delete - sendConfirmPrompt() — main-thread prompt(s) before user types confirm - beforeDelete(scan) — optional pre-delete hook (used by /purge deleted to evict in-memory chunks before the async file delete) - logScanContents(islands, scan) — optional scan-time logging Behaviour is preserved: same locale keys, same log prefixes, same async threading. The "after a non-empty scan" tail on the failed-delete log line in /purge deleted is dropped — it was always true (we'd already returned on empty) and made the message harder to share. Co-Authored-By: Claude Opus 4.7 --- .../admin/purge/AbstractPurgeCommand.java | 137 ++++++++++++++++++ .../admin/purge/AdminPurgeCommand.java | 90 +++--------- .../admin/purge/AdminPurgeDeletedCommand.java | 99 +++---------- 3 files changed, 176 insertions(+), 150 deletions(-) create mode 100644 src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java new file mode 100644 index 000000000..d93fd705b --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java @@ -0,0 +1,137 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.bukkit.Bukkit; +import org.bukkit.World; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; + +/** + * Shared scaffolding for purge subcommands that scan for region files and + * (on confirmation) reap them via {@link world.bentobox.bentobox.managers.PurgeRegionsService}. + * + *

    Subclasses supply the scan source (age-filtered or deletable-only), + * confirmation prompt, success message and any pre-delete side-effects via + * the abstract / overridable hooks. + */ +abstract class AbstractPurgeCommand extends CompositeCommand { + + protected static final String NONE_FOUND = "commands.admin.purge.none-found"; + + protected volatile boolean inPurge; + protected boolean toBeConfirmed; + protected User user; + protected PurgeScanResult lastScan; + + protected AbstractPurgeCommand(CompositeCommand parent, String label) { + super(parent, label); + } + + @Override + public boolean canExecute(User user, String label, java.util.List args) { + if (inPurge) { + user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); + return false; + } + return true; + } + + /** + * Saves all worlds, runs the scan on an async thread, stores the result + * in {@link #lastScan} and prompts for confirmation if non-empty. + */ + protected final void runScanAndPrompt(Supplier scanFn) { + user.sendMessage("commands.admin.purge.scanning"); + getPlugin().log(logPrefix() + ": saving all worlds before scanning..."); + Bukkit.getWorlds().forEach(World::save); + getPlugin().log(logPrefix() + ": world save complete"); + + inPurge = true; + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + try { + lastScan = scanFn.get(); + displayResultsAndPrompt(lastScan); + } finally { + inPurge = false; + } + }); + } + + /** + * Confirm path: save worlds, run any pre-delete hook, then dispatch the + * region-file deletion asynchronously. + */ + protected final boolean deleteEverything() { + if (lastScan == null || lastScan.isEmpty()) { + user.sendMessage(NONE_FOUND); + return false; + } + PurgeScanResult scan = lastScan; + lastScan = null; + toBeConfirmed = false; + getPlugin().log(logPrefix() + ": saving all worlds before deleting region files..."); + Bukkit.getWorlds().forEach(World::save); + beforeDelete(scan); + getPlugin().log(logPrefix() + ": world save complete, dispatching deletion"); + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + boolean ok = getPlugin().getPurgeRegionsService().delete(scan); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + if (ok) { + user.sendMessage(successMessageKey()); + } else { + getPlugin().log(logPrefix() + ": failed to delete one or more region files"); + user.sendMessage("commands.admin.purge.failed"); + } + }); + }); + return true; + } + + private void displayResultsAndPrompt(PurgeScanResult scan) { + Set uniqueIslands = scan.deletableRegions().values().stream() + .flatMap(Set::stream) + .map(getPlugin().getIslands()::getIslandById) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + logScanContents(uniqueIslands, scan); + + if (scan.isEmpty()) { + Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND)); + } else { + Bukkit.getScheduler().runTask(getPlugin(), () -> { + user.sendMessage("commands.admin.purge.purgable-islands", + TextVariables.NUMBER, String.valueOf(uniqueIslands.size())); + sendConfirmPrompt(); + toBeConfirmed = true; + }); + } + } + + /** Log prefix used across the scan and delete phases (e.g. {@code "Purge"}). */ + protected abstract String logPrefix(); + + /** Locale key sent to the user when delete completes successfully. */ + protected abstract String successMessageKey(); + + /** Send any subclass-specific confirmation prompt(s). Runs on the main thread. */ + protected abstract void sendConfirmPrompt(); + + /** Hook invoked on the main thread immediately before the async delete. Default: no-op. */ + protected void beforeDelete(PurgeScanResult scan) { + // no-op + } + + /** Hook invoked off-thread to log scan contents before the prompt. Default: no-op. */ + protected void logScanContents(Set uniqueIslands, PurgeScanResult scan) { + // no-op + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java index bc708ff9b..4c35e5e43 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java @@ -4,12 +4,7 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; 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 world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.localization.TextVariables; @@ -33,17 +28,11 @@ * *

    Heavy lifting is delegated to {@link PurgeRegionsService}. */ -public class AdminPurgeCommand extends CompositeCommand { +public class AdminPurgeCommand extends AbstractPurgeCommand { - private static final String NONE_FOUND = "commands.admin.purge.none-found"; private static final String IN_WORLD = " in world "; private static final String WILL_BE_DELETED = " will be deleted"; - private volatile boolean inPurge; - private boolean toBeConfirmed; - private User user; - private PurgeScanResult lastScan; - public AdminPurgeCommand(CompositeCommand parent) { super(parent, "purge"); } @@ -62,8 +51,7 @@ public void setup() { @Override public boolean canExecute(User user, String label, List args) { - if (inPurge) { - user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); + if (!super.canExecute(user, label, args)) { return false; } if (args.isEmpty()) { @@ -93,75 +81,33 @@ public boolean execute(User user, String label, List args) { return false; } - user.sendMessage("commands.admin.purge.scanning"); - getPlugin().log("Purge: saving all worlds before scanning region files..."); - Bukkit.getWorlds().forEach(World::save); - 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; - } - }); + runScanAndPrompt(() -> getPlugin().getPurgeRegionsService().scan(getWorld(), finalDays)); 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: saving all worlds before deleting region files..."); - Bukkit.getWorlds().forEach(World::save); - 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"); - } - }); - }); - return true; + @Override + protected String logPrefix() { + return "Purge"; } - private void displayResultsAndPrompt(PurgeScanResult scan) { - Set uniqueIslands = scan.deletableRegions().values().stream() - .flatMap(Set::stream) - .map(getPlugin().getIslands()::getIslandById) - .flatMap(Optional::stream) - .collect(Collectors.toSet()); + @Override + protected String successMessageKey() { + return "general.success"; + } - uniqueIslands.forEach(this::displayIsland); + @Override + protected void sendConfirmPrompt() { + user.sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, this.getTopLabel()); + user.sendMessage("general.beta"); + } + @Override + protected void logScanContents(Set uniqueIslands, PurgeScanResult scan) { + uniqueIslands.forEach(this::displayIsland); scan.deletableRegions().entrySet().stream() .filter(e -> e.getValue().isEmpty()) .forEach(e -> displayEmptyRegion(e.getKey())); - - 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.confirm", - TextVariables.LABEL, this.getTopLabel()); - user.sendMessage("general.beta"); - toBeConfirmed = true; - }); - } } private void displayIsland(Island island) { 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 index 72621798c..88d128fcb 100644 --- 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 @@ -1,12 +1,10 @@ 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 world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; @@ -28,14 +26,7 @@ * * @since 3.15.0 */ -public class AdminPurgeDeletedCommand extends CompositeCommand { - - 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 class AdminPurgeDeletedCommand extends AbstractPurgeCommand { public AdminPurgeDeletedCommand(CompositeCommand parent) { super(parent, "deleted"); @@ -49,15 +40,6 @@ public void setup() { 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; @@ -65,76 +47,37 @@ public boolean execute(User user, String label, List args) { return deleteEverything(); } toBeConfirmed = false; + runScanAndPrompt(() -> getPlugin().getPurgeRegionsService().scanDeleted(getWorld())); + return true; + } - 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"); + @Override + protected String logPrefix() { + return "Purge deleted"; + } - inPurge = true; - Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { - try { - PurgeRegionsService service = getPlugin().getPurgeRegionsService(); - lastScan = service.scanDeleted(getWorld()); - displayResultsAndPrompt(lastScan); - } finally { - inPurge = false; - } - }); - return true; + @Override + protected String successMessageKey() { + return "commands.admin.purge.deleted.deferred"; } - 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); + @Override + protected void sendConfirmPrompt() { + user.sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, this.getLabel()); + } + + @Override + protected void beforeDelete(PurgeScanResult scan) { // Evict in-memory chunks for the target regions on the main thread, // otherwise Paper's autosave/unload would re-flush them over the // about-to-be-deleted region files (#region-purge bug). getPlugin().getPurgeRegionsService().evictChunks(scan); - 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()); - + @Override + protected void logScanContents(Set uniqueIslands, PurgeScanResult scan) { uniqueIslands.forEach(island -> getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + " in world " + getWorld().getName() + " will be reaped")); - - 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; - }); - } } } From a0ca6c853451accb66a2bc60125b4fdc050c52d5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 18 Apr 2026 14:27:42 -0700 Subject: [PATCH 36/39] Add warning logs for null island grid and test delete branching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PurgeRegionsService: log a warning when scan() or scanDeleted() finds no island grid for a world, instead of silently returning empty. Helps admins diagnose misconfigured worlds. - AdminDeleteCommandTest: add tests for the isUsesNewChunkGeneration() branch in deleteIsland() — verify soft-delete path (new chunk gen) calls deleteIsland(island, true, uuid) and hard-delete path (simple gen) calls hardDeleteIsland(island). - PurgeRegionsServiceTest: add testScanNullGrid() to match existing testScanDeletedNullGrid() coverage. Co-Authored-By: Claude Opus 4.6 --- .../managers/PurgeRegionsService.java | 4 ++ .../admin/AdminDeleteCommandTest.java | 62 +++++++++++++++++++ .../managers/PurgeRegionsServiceTest.java | 13 ++++ 3 files changed, 79 insertions(+) diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index eba2c72b0..796d9161f 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -147,6 +147,8 @@ public PurgeScanResult scanDeleted(World world) { IslandGrid islandGrid = plugin.getIslands().getIslandCache().getIslandGrid(world); if (islandGrid == null) { + plugin.logWarning("Purge deleted-sweep: no island grid for world " + world.getName() + + " — skipping scan"); return new PurgeScanResult(world, 0, new HashMap<>(), isNether, isEnd, new FilterStats(0, 0, 0, 0)); } @@ -194,6 +196,8 @@ public PurgeScanResult scan(World world, int days) { IslandGrid islandGrid = plugin.getIslands().getIslandCache().getIslandGrid(world); if (islandGrid == null) { + plugin.logWarning("Purge age-sweep: no island grid for world " + world.getName() + + " — skipping scan"); return new PurgeScanResult(world, days, new HashMap<>(), isNether, isEnd, new FilterStats(0, 0, 0, 0)); } diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommandTest.java index 25d3cf3e7..25e5487b6 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommandTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -12,6 +13,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.bukkit.Bukkit; @@ -23,15 +25,19 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; import world.bentobox.bentobox.CommonTestSetup; import world.bentobox.bentobox.Settings; +import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.managers.CommandsManager; import world.bentobox.bentobox.managers.LocalesManager; import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.util.DeleteIslandChunks; import world.bentobox.bentobox.util.Util; /** @@ -232,4 +238,60 @@ void testCanExecuteSuccess() { verify(user).sendMessage("commands.confirmation.confirm", "[seconds]", "0"); } + @Test + void testDeleteIslandSoftDeleteForNewChunkGeneration() { + AdminDeleteCommand itl = setupForDeletion(); + + GameModeAddon gm = mock(GameModeAddon.class); + when(gm.isUsesNewChunkGeneration()).thenReturn(true); + when(iwm.getAddon(world)).thenReturn(Optional.of(gm)); + + assertTrue(itl.canExecute(user, itl.getLabel(), List.of("tastybento"))); + itl.execute(user, itl.getLabel(), List.of("tastybento")); + itl.execute(user, itl.getLabel(), List.of("tastybento")); + + verify(im).deleteIsland(eq(island), eq(true), eq(notUUID)); + verify(im, never()).hardDeleteIsland(any()); + } + + @Test + void testDeleteIslandHardDeleteForSimpleGeneration() { + AdminDeleteCommand itl = setupForDeletion(); + + GameModeAddon gm = mock(GameModeAddon.class); + when(gm.isUsesNewChunkGeneration()).thenReturn(false); + when(iwm.getAddon(world)).thenReturn(Optional.of(gm)); + + assertTrue(itl.canExecute(user, itl.getLabel(), List.of("tastybento"))); + itl.execute(user, itl.getLabel(), List.of("tastybento")); + + try (MockedConstruction ignored = + Mockito.mockConstruction(DeleteIslandChunks.class)) { + itl.execute(user, itl.getLabel(), List.of("tastybento")); + } + + verify(im).hardDeleteIsland(island); + verify(im, never()).deleteIsland(any(), eq(true), any()); + } + + private AdminDeleteCommand setupForDeletion() { + when(island.hasTeam()).thenReturn(false); + when(island.getCenter()).thenReturn(location); + when(island.getWorld()).thenReturn(world); + when(im.inTeam(any(), any())).thenReturn(false); + when(pm.getUUID(any())).thenReturn(notUUID); + when(im.getIslands(world, notUUID)).thenReturn(List.of(island)); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + when(scheduler.runTaskLater(any(), any(Runnable.class), any(Long.class))).thenReturn(task); + when(scheduler.runTask(any(), any(Runnable.class))).thenAnswer(inv -> { + inv.getArgument(1).run(); + return task; + }); + mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + + return new AdminDeleteCommand(ac); + } + } diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java index 81556055b..25c794ea8 100644 --- a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -105,6 +105,19 @@ void testScanDeletedNullGrid() { assertEquals(0, result.days(), "days sentinel should be 0 for deleted sweep"); } + /** + * A world with no island grid returns an empty age-sweep result + * rather than crashing. + */ + @Test + void testScanNullGrid() { + when(islandCache.getIslandGrid(world)).thenReturn(null); + + PurgeScanResult result = service.scan(world, 30); + assertTrue(result.isEmpty()); + assertEquals(30, result.days()); + } + /** * A world with only non-deletable islands yields no candidate regions. */ From e25ed1ecb4f82b91530e09eb4d2c1bce97d64809 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 19 Apr 2026 12:59:50 -0700 Subject: [PATCH 37/39] fix: retain DB row for deleted-sweep islands straddling a blocked region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a soft-deleted island's protection box spans two region files and one of those regions is blocked by an active neighbour island, the deleted-sweep previously removed the island's DB row at shutdown even though its chunks remained on disk in the blocked region. On the next server start those orphaned blocks loaded from the still-present .mca file and appeared in-game with no island record to track them ("ghost blocks"). Fix: filterForDeletedSweep now collects the IDs of deletable islands found in blocked regions and returns them via a new DeleteSweepFilter record. scanDeleted propagates these as PurgeScanResult.straddlingIslandIds. In delete(), any island in that set is skipped for pendingDeletions — its DB row stays (deletable=true) so the next housekeeping sweep retries once the blocking island is itself deleted. Three new tests cover: straddling island DB row is retained, non-straddling island is still deferred normally, and scanDeleted populates straddlingIslandIds correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../managers/PurgeRegionsService.java | 55 ++++++-- .../managers/HousekeepingManagerTest.java | 3 +- .../managers/PurgeRegionsServiceTest.java | 124 ++++++++++++++++-- 3 files changed, 162 insertions(+), 20 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java index 796d9161f..4b7091582 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -96,7 +96,8 @@ public record PurgeScanResult( Map, Set> deletableRegions, boolean isNether, boolean isEnd, - FilterStats stats) { + FilterStats stats, + Set straddlingIslandIds) { public boolean isEmpty() { return deletableRegions.isEmpty(); } @@ -115,6 +116,10 @@ public record FilterStats(int islandsOverLevel, int islandsPurgeProtected, /** Groups the three folder types (region, entities, poi) for one world dimension. */ private record DimFolders(File region, File entities, File poi) {} + /** Return type for {@link #filterForDeletedSweep}: filter statistics plus the set of + * deletable island IDs whose chunks lie in a region that was blocked by a live neighbour. */ + private record DeleteSweepFilter(FilterStats stats, Set straddlingIds) {} + // --------------------------------------------------------------- // Public API // --------------------------------------------------------------- @@ -150,7 +155,7 @@ public PurgeScanResult scanDeleted(World world) { plugin.logWarning("Purge deleted-sweep: no island grid for world " + world.getName() + " — skipping scan"); return new PurgeScanResult(world, 0, new HashMap<>(), isNether, isEnd, - new FilterStats(0, 0, 0, 0)); + new FilterStats(0, 0, 0, 0), Set.of()); } // Collect candidate region coords from every deletable island's @@ -173,9 +178,14 @@ public PurgeScanResult scanDeleted(World world) { Map, Set> deletableRegions = mapIslandsToRegions(new ArrayList<>(candidateRegions), islandGrid); - FilterStats stats = filterForDeletedSweep(deletableRegions); - logFilterStats(stats); - return new PurgeScanResult(world, 0, deletableRegions, isNether, isEnd, stats); + DeleteSweepFilter filterResult = filterForDeletedSweep(deletableRegions); + logFilterStats(filterResult.stats()); + if (!filterResult.straddlingIds().isEmpty()) { + plugin.log("Purge deleted-sweep: " + filterResult.straddlingIds().size() + + " island(s) straddle a blocked region — DB row(s) retained for next sweep"); + } + return new PurgeScanResult(world, 0, deletableRegions, isNether, isEnd, + filterResult.stats(), filterResult.straddlingIds()); } /** @@ -199,14 +209,14 @@ public PurgeScanResult scan(World world, int days) { plugin.logWarning("Purge age-sweep: no island grid for world " + world.getName() + " — skipping scan"); return new PurgeScanResult(world, days, new HashMap<>(), isNether, isEnd, - new FilterStats(0, 0, 0, 0)); + new FilterStats(0, 0, 0, 0), Set.of()); } 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); + return new PurgeScanResult(world, days, deletableRegions, isNether, isEnd, stats, Set.of()); } /** @@ -252,6 +262,16 @@ public boolean delete(PurgeScanResult scan) { Island island = opt.get(); if (scan.days() == 0) { + if (scan.straddlingIslandIds().contains(islandID)) { + // This island has chunks in a region that was blocked by an + // active neighbour island — those blocks are still on disk. + // Retaining the DB row (deletable=true) lets the next + // deleted-sweep retry once the blocker is itself gone. + islandsDeferred++; + plugin.log("Island ID " + islandID + + " straddles a blocked region \u2014 DB row retained for next purge sweep"); + continue; + } // 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 @@ -606,15 +626,22 @@ private FilterStats filterNonDeletableRegions( * region blocks the whole region. Unlike {@link #filterNonDeletableRegions} * this has no age/login/level logic — only the {@code deletable} flag * matters. + * + *

    When a region is blocked, any deletable islands in that region + * are recorded as "straddling" — they have chunks in a blocked region that + * cannot be reaped this sweep. Their DB rows must be retained so the next + * sweep can retry once the blocking island is itself deleted. */ - private FilterStats filterForDeletedSweep( + private DeleteSweepFilter filterForDeletedSweep( Map, Set> deletableRegions) { int regionsBlockedByProtection = 0; + Set straddling = new HashSet<>(); var iter = deletableRegions.entrySet().iterator(); while (iter.hasNext()) { var entry = iter.next(); + Set ids = entry.getValue(); boolean block = false; - for (String id : entry.getValue()) { + for (String id : ids) { Optional opt = plugin.getIslands().getIslandById(id); if (opt.isEmpty()) { // Missing rows don't block — they're already gone. @@ -626,11 +653,19 @@ private FilterStats filterForDeletedSweep( } } if (block) { + // Collect the deletable islands whose chunks lie in this blocked + // region — they straddle the boundary between a reaped region and + // this blocked one, so their blocks remain on disk here. + for (String id : ids) { + plugin.getIslands().getIslandById(id) + .filter(Island::isDeletable) + .ifPresent(i -> straddling.add(i.getUniqueId())); + } iter.remove(); regionsBlockedByProtection++; } } - return new FilterStats(0, 0, 0, regionsBlockedByProtection); + return new DeleteSweepFilter(new FilterStats(0, 0, 0, regionsBlockedByProtection), straddling); } private int[] evaluateRegionIslands(Set islandIds, int days) { diff --git a/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java index fd2ac1db7..792ffa0f0 100644 --- a/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java @@ -16,6 +16,7 @@ import java.nio.file.Path; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; import org.bukkit.Bukkit; @@ -326,7 +327,7 @@ void testOnlyDeletedCycleDueDispatchesDeletedOnly() throws Exception { private static PurgeScanResult emptyScan(int days) { return new PurgeScanResult(mock(World.class), days, Collections.emptyMap(), - false, false, new FilterStats(0, 0, 0, 0)); + false, false, new FilterStats(0, 0, 0, 0), Set.of()); } /** Reflective access to the package-private {@code checkAndMaybeRun} so diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java index 25c794ea8..8bc95df0e 100644 --- a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -314,7 +314,7 @@ void testEvictChunksUnloadsLoadedChunksInTargetRegion() { 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)); + new FilterStats(0, 0, 0, 0), Set.of()); when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); when(world.isChunkLoaded(5, 7)).thenReturn(true); @@ -340,7 +340,7 @@ 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)); + new FilterStats(0, 0, 0, 0), Set.of()); when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); // The bottom-left corner of r.1.-1 is (32, -32); the top-right is (63, -1). @@ -387,7 +387,7 @@ void testDeleteDefersDBRowWhenResidualRegionExists() throws IOException { 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)); + new FilterStats(0, 0, 0, 0), Set.of()); boolean ok = service.delete(scan); assertTrue(ok); @@ -422,7 +422,7 @@ void testDeleteRemovesDBRowWhenAllRegionsGone() throws IOException { 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)); + new FilterStats(0, 0, 0, 0), Set.of()); boolean ok = service.delete(scan); assertTrue(ok); @@ -463,7 +463,7 @@ void testDeleteDefersOnlySomeIslandsInMixedBatch() throws IOException { 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)); + new FilterStats(0, 0, 0, 0), Set.of()); boolean ok = service.delete(scan); assertTrue(ok); @@ -479,7 +479,7 @@ void testDeleteDefersOnlySomeIslandsInMixedBatch() throws IOException { @Test void testEvictChunksEmptyScanIsNoop() { PurgeScanResult scan = new PurgeScanResult(world, 0, new HashMap<>(), false, false, - new FilterStats(0, 0, 0, 0)); + new FilterStats(0, 0, 0, 0), Set.of()); service.evictChunks(scan); @@ -512,7 +512,7 @@ void testDeletedSweepDefersDBDeletionToShutdown() throws IOException { 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)); + new FilterStats(0, 0, 0, 0), Set.of()); boolean ok = service.delete(scan); assertTrue(ok); @@ -557,7 +557,7 @@ void testFlushPendingDeletionsRemovesIslands() throws IOException { 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)); + new FilterStats(0, 0, 0, 0), Set.of()); service.delete(scan); // Both deferred — not yet deleted. @@ -573,6 +573,112 @@ void testFlushPendingDeletionsRemovesIslands() throws IOException { assertTrue(service.getPendingDeletions().isEmpty()); } + /** + * Deleted sweep: when an island's chunks appear in BOTH a successfully deleted + * region AND a blocked region (it straddles the boundary), the DB row must NOT + * be added to {@code pendingDeletions}. The island stays {@code deletable=true} + * in the DB so the next sweep can retry once the blocking island is gone. + * + *

    This is the fix for the "ghost blocks" bug where soft-deleted islands near + * a region boundary left orphaned blocks in the blocked region file. + */ + @Test + void testDeletedSweepRetainsDBRowForStraddlingIsland() throws IOException { + Island straddles = mock(Island.class); + when(straddles.getUniqueId()).thenReturn("strad"); + when(straddles.isDeletable()).thenReturn(true); + when(im.getIslandById("strad")).thenReturn(Optional.of(straddles)); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + // r.0.0.mca was in the deleted set — this file gets reaped. + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + + // The island is in both r.0.0 (deletable region) and r.0.-1 (blocked). + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("strad")); + // straddlingIslandIds contains "strad" — its chunks in the blocked region remain. + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 1), Set.of("strad")); + + boolean ok = service.delete(scan); + assertTrue(ok); + // DB row must NOT be deferred to shutdown — the island has orphaned chunks. + assertFalse(service.getPendingDeletions().contains("strad"), + "Straddling island must not be added to pendingDeletions"); + // DB row must not be removed immediately either. + verify(im, never()).deleteIslandId(anyString()); + verify(islandCache, never()).deleteIslandFromCache(anyString()); + } + + /** + * Deleted sweep: an island that is NOT in the straddling set (all its regions + * were successfully deleted) must still be deferred to shutdown normally. + */ + @Test + void testDeletedSweepDefersNonStraddlingIslandToShutdown() throws IOException { + Island clean = mock(Island.class); + when(clean.getUniqueId()).thenReturn("clean"); + when(clean.isDeletable()).thenReturn(true); + when(im.getIslandById("clean")).thenReturn(Optional.of(clean)); + + 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("clean")); + // "clean" is NOT in straddling — all its regions were deleted. + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0), Set.of()); + + boolean ok = service.delete(scan); + assertTrue(ok); + assertTrue(service.getPendingDeletions().contains("clean"), + "Non-straddling island must be added to pendingDeletions for shutdown"); + verify(im, never()).deleteIslandId(anyString()); + } + + /** + * {@link PurgeRegionsService#scanDeleted} must populate + * {@link PurgeScanResult#straddlingIslandIds()} with deletable islands that + * appear in a region blocked by a non-deletable neighbour. + */ + @Test + void testScanDeletedPopulatesStraddlingIslandIds() throws IOException { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + // Protection box spans r.0.0 and r.1.0 (X = 500..700). + when(deletable.getMinProtectedX()).thenReturn(500); + when(deletable.getMaxProtectedX()).thenReturn(700); + 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)); + + // r.0.0 → only the deletable island; r.1.0 → deletable + active (blocks it). + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(eq(0), anyInt(), eq(511), anyInt())) + .thenReturn(List.of(new IslandData("del", 500, 0, 200))); + when(grid.getIslandsInBounds(eq(512), anyInt(), eq(1023), anyInt())) + .thenReturn(List.of( + new IslandData("del", 500, 0, 200), + new IslandData("act", 600, 0, 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); + + // r.0.0 survives the filter; r.1.0 is blocked. + assertEquals(1, result.deletableRegions().size(), "Only r.0.0 should survive"); + assertTrue(result.straddlingIslandIds().contains("del"), + "del must be in straddlingIslandIds because it appears in blocked r.1.0"); + } + /** * Age sweep (days > 0) must still delete DB rows immediately when all * regions are gone from disk — no deferral to shutdown. @@ -596,7 +702,7 @@ void testAgeSweepStillDeletesImmediately() throws IOException { 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)); + new FilterStats(0, 0, 0, 0), Set.of()); service.delete(scan); // Age sweep: immediate deletion, not deferred. From 2e050a42f25f7023f35d1ef0d38b27b2f7f4fa33 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 20 Apr 2026 15:22:05 -0700 Subject: [PATCH 38/39] feat(locale): add 3.15 purge/delete keys to all 22 non-English locales; translate uk.yml parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 15 missing keys (purge failed, age-regions, deleted, unowned.flagged, info.deletable, OBSIDIAN_SCOOPING.lavaTip, deletable-island-admin, ISLAND_DEFAULTS panel) to all 22 non-English locale files with AI-assisted translations - Translate 48 untranslated parameters: lines in uk.yml: →<гравець>, [player]→[гравець], [home name]→[назва дому], [language]→[мова>, player's island→острів гравця, etc. Co-Authored-By: Claude Sonnet 4.6 --- src/main/resources/locales/cs.yml | 22 +++++ src/main/resources/locales/de.yml | 22 +++++ src/main/resources/locales/es.yml | 22 +++++ src/main/resources/locales/fr.yml | 22 +++++ src/main/resources/locales/hr.yml | 22 +++++ src/main/resources/locales/hu.yml | 22 +++++ src/main/resources/locales/id.yml | 22 +++++ src/main/resources/locales/it.yml | 22 +++++ src/main/resources/locales/ja.yml | 22 +++++ src/main/resources/locales/ko.yml | 22 +++++ src/main/resources/locales/lv.yml | 22 +++++ src/main/resources/locales/nl.yml | 22 +++++ src/main/resources/locales/pl.yml | 22 +++++ src/main/resources/locales/pt-BR.yml | 22 +++++ src/main/resources/locales/pt.yml | 22 +++++ src/main/resources/locales/ro.yml | 22 +++++ src/main/resources/locales/ru.yml | 17 ++++ src/main/resources/locales/tr.yml | 22 +++++ src/main/resources/locales/uk.yml | 118 ++++++++++++++++----------- src/main/resources/locales/vi.yml | 22 +++++ src/main/resources/locales/zh-CN.yml | 22 +++++ src/main/resources/locales/zh-HK.yml | 22 +++++ 22 files changed, 527 insertions(+), 48 deletions(-) diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 8e036d1bf..5b3399f43 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -120,6 +120,16 @@ commands: parameters: '[days]' description: Purge Islands odstraněním souborů starého regionu confirm: 'Typ /[label] purge regions potvrdit & d pro začátek čištění' + failed: 'Čištění dokončeno s chybami. Některé soubory regionů nelze odstranit. Podrobnosti najdete v protokolu serveru.' + age-regions: + parameters: '[days]' + description: 'debug/test: přepište časová razítka souborů regionů, aby byly čistitelné' + done: 'Upraveno [number] soubor(ů) regionů v aktuálním světě.' + deleted: + parameters: '' + description: 'vyčistit soubory regionů pro jakýkoliv [prefix_island], který je označen jako smazaný' + confirm: 'Napište /[label] purge deleted confirm pro smazání souborů regionů' + deferred: 'Soubory regionů smazány. Záznamy databáze ostrovů budou odstraněny při příštím restartu serveru.' protect: description: Přepnout protekci proti čištění move-to-island: 'Nejdříve se přesuň na ostrov!' @@ -131,6 +141,8 @@ commands: unowned: description: Vyčistit nevlastněné ostrovy - vyžaduje potvrzení unowned-islands: 'Nalezeno [number] ostrovů' + parameters: '' + flagged: 'Označeno [number] [prefix_island](s) jako smazatelné. Spusťte /[label] purge deleted pro smazání souborů regionů.' status: description: zobrazuje stav čištění status: >- @@ -270,6 +282,7 @@ commands: protection-range-bonus-title: 'Zahrnuje tyto bonusy:' protection-range-bonus: 'Bonus: [number]' purge-protected: Ostrov je chráněn proti smazání + deletable: '[prefix_Island] je označen pro smazání a čeká na vyčištění regionu.' max-protection-range: 'Největší historická chráněná oblast: [range]' protection-coords: 'Chráněné souřadnice: [xz1] - [xz2]' is-spawn: Ostrov je spawn @@ -1486,6 +1499,9 @@ protection: scooping: 'Změna obsidiánu zpět na lávu. Příště pozor!' cooldown: 'Musíte počkat, než budete moci nabrat další obsidiánový blok.' obsidian-nearby: 'Nedaleko jsou obsidiánové bloky, tento blok nemůžete nabrat do lávy. ([radius])' + lavaTip: |- + Naberte to do kýble + jako lávou znovu, pokud ji potřebujete! OFFLINE_GROWTH: description: |- Je-li zakázáno, rostliny @@ -1723,6 +1739,7 @@ protection: name: Světové poškození TNT locked: 'Tento ostrov je zamčen!' locked-island-bypass: 'Tento [prefix_island] je zamčen, ale máte povolení ho obejít.' + deletable-island-admin: '[Admin] Tento [prefix_island] je označen pro smazání a čeká na vyčištění regionu.' protected: 'Ostrov chráněn: [description]' world-protected: 'Svět chráněn: [description]' spawn-protected: 'Spawn chráněn: [description]' @@ -1765,6 +1782,11 @@ protection: description: | Protekční nastavení, pokud je hráč mimo svůj ostrov + ISLAND_DEFAULTS: + title: '[world_name] Výchozí nastavení ostrova' + description: | + Výchozí nastavení ochrany + pro nové [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index b9bc13fbf..217db4720 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -133,6 +133,16 @@ commands: confirm: >- Tippe /[label] purge regions confirm , um mit dem Löschen zu beginnen + failed: 'Bereinigung mit Fehlern abgeschlossen. Einige Regionsdateien konnten nicht gelöscht werden. Einzelheiten im Serverprotokoll.' + age-regions: + parameters: '[days]' + description: 'debug/test: Zeitstempel von Regionsdateien neu schreiben, damit sie gelöscht werden können' + done: 'Gealtert [number] Regionsdatei(en) in der aktuellen Welt.' + deleted: + parameters: '' + description: 'Regionsdateien für [prefix_island] bereinigen, die bereits als gelöscht markiert sind' + confirm: 'Geben Sie /[label] purge deleted confirm ein, um die Regionsdateien zu entfernen' + deferred: 'Regionsdateien gelöscht. Insel-Datenbankeinträge werden beim nächsten Serverneustart entfernt.' protect: description: Umschalten des Insellöschschutzes move-to-island: 'Erst auf eine Insel gehen!' @@ -144,6 +154,8 @@ commands: unowned: description: Löschen freier Inseln - Bestätigung erforderlich unowned-islands: '[number] Inseln gefunden' + parameters: '' + flagged: 'Markiert [number] [prefix_island](s) als löschbar. Führen Sie /[label] purge deleted aus, um die Regionsdateien zu entfernen.' status: description: Zeigt den Status der Bereinigung an status: >- @@ -292,6 +304,7 @@ commands: protection-range-bonus-title: 'Beinhaltet diese Boni:' protection-range-bonus: 'Bonus: [number]' purge-protected: Die Insel ist löschgeschützt + deletable: '[prefix_Island] ist zum Löschen markiert und wartet auf die Regionbereinigung.' max-protection-range: 'Größter historischer Schutzbereich: [range]' protection-coords: 'Schutz-Koordinaten: [xz1] bis [xz2]' is-spawn: Die Insel ist eine Spawn-Insel @@ -1556,6 +1569,9 @@ protection: scooping: 'Obsidian wieder in Lava verwandelt. Sei nächstes Mal vorsichtig!' cooldown: 'Du musst warten, bevor du einen weiteren Obsidianblock schöpfen kannst.' obsidian-nearby: >- + lavaTip: |- + Schöpfe es mit einem Eimer + wieder als Lava, falls du sie brauchst! Es gibt Obsidianblöcke in der Nähe, du kannst diesen Block nicht in Lava umwandeln. ([radius]) OFFLINE_GROWTH: @@ -1801,6 +1817,7 @@ protection: name: Welt-TNT-Schaden locked: 'Diese Insel ist gesperrt!' locked-island-bypass: 'Diese [prefix_island] ist gesperrt, aber du hast die Erlaubnis, sie zu betreten.' + deletable-island-admin: '[Admin] Diese [prefix_island] ist zum Löschen markiert und wartet auf die Regionbereinigung.' protected: 'Insel geschützt: [description]' world-protected: 'Welt geschützt: [description]' spawn-protected: 'Spawn geschützt: [description]' @@ -1843,6 +1860,11 @@ protection: description: |- Schutzeinstellungen, wenn Spieler außerhalb ihrer Insel sind + ISLAND_DEFAULTS: + title: '[world_name] Insel-Standardeinstellungen' + description: | + Standard-Schutzeinstellungen + für neue [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index d86a31332..667f09cde 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -126,6 +126,16 @@ commands: parameters: '[days]' description: Purge Islas eliminando archivos de región antiguas confirm: 'Tipo /[label] purge regions confirm para comenzar a purgar' + failed: 'Purga completada con errores. Algunos archivos de región no pudieron eliminarse. Revisa el registro del servidor.' + age-regions: + parameters: '[days]' + description: 'debug/prueba: reescribe las marcas de tiempo de los archivos de región para que sean eliminables' + done: 'Se envejecieron [number] archivo(s) de región en el mundo actual.' + deleted: + parameters: '' + description: 'purgar archivos de región de cualquier [prefix_island] ya marcada como eliminada' + confirm: 'Escribe /[label] purge deleted confirm para eliminar los archivos de región' + deferred: 'Archivos de región eliminados. Las entradas de la base de datos de islas se eliminarán en el próximo reinicio del servidor.' protect: description: Activar protección de purga de isla move-to-island: '¡Muévete primero a una isla!' @@ -137,6 +147,8 @@ commands: unowned: description: Purga islas sin dueño - requiere confirmación unowned-islands: 'Encontradas [number] islas' + parameters: '' + flagged: 'Marcadas [number] [prefix_island](s) como eliminables. Ejecuta /[label] purge deleted para eliminar los archivos de región.' status: description: muestra el estado de la purga status: >- @@ -280,6 +292,7 @@ commands: protection-range-bonus-title: 'Incluye estos bonificaciones:' protection-range-bonus: 'Bonificación: [number]' purge-protected: La isla está protegida por purga + deletable: '[prefix_Island] está marcada para eliminación y está en espera de purga de región.' max-protection-range: 'El mayor rango de protección histórica: [range]' protection-coords: 'Coordenadas de protección: [xz1] a [xz2]' is-spawn: La isla es el spawn @@ -1528,6 +1541,9 @@ protection: scooping: 'obsidiana cambio de vuelta a lava. ¡Ten mas cuidado la próxima vez!' cooldown: 'Debes esperar antes de recoger otro bloque de obsidiana.' obsidian-nearby: >- + lavaTip: |- + Recógelo con un cubo + como lava de nuevo si la necesitas! Hay bloques de obsidiana cerca, no puedes recoger este bloque en lava. ([radius]) OFFLINE_GROWTH: @@ -1765,6 +1781,7 @@ protection: name: Daño mundial de TNT locked: 'Esta isla esta cerrada!' locked-island-bypass: 'Esta [prefix_island] está cerrada, pero tienes permiso para acceder.' + deletable-island-admin: '[Admin] Esta [prefix_island] está marcada para eliminación y está en espera de purga de región.' protected: 'Isla protegida: [description]' world-protected: 'Mundo protegido: [description]' spawn-protected: 'Spawn protegido: [description]' @@ -1807,6 +1824,11 @@ protection: description: |- Configuración de protección cuando el jugador está fuera de su isla + ISLAND_DEFAULTS: + title: '[world_name] Valores predeterminados de isla' + description: | + Configuración de protección predeterminada + para nuevas [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 6a4d37312..d5e99a1c6 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -130,6 +130,16 @@ commands: parameters: '[days]' description: purge îles en supprimant les anciens fichiers régionaux confirm: 'Type /[label] purge regions confirm pour commencer la purge' + failed: 'Purge terminée avec des erreurs. Certains fichiers de région n''ont pas pu être supprimés. Consultez le journal du serveur.' + age-regions: + parameters: '[days]' + description: 'debug/test: réécrire les horodatages des fichiers de région pour qu''ils soient purgables' + done: 'Vieillis [number] fichier(s) de région dans le monde actuel.' + deleted: + parameters: '' + description: 'purger les fichiers de région pour tout [prefix_island] déjà marqué comme supprimé' + confirm: 'Tapez /[label] purge deleted confirm pour supprimer les fichiers de région' + deferred: 'Fichiers de région supprimés. Les entrées de la base de données des îles seront supprimées au prochain redémarrage du serveur.' protect: description: activer/désactiver la protection de l'île contre la purge move-to-island: 'Déplacez-vous d''abord sur une île.' @@ -141,6 +151,8 @@ commands: unowned: description: supprimer les îles sans propriétaire unowned-islands: 'Trouvé [number] îles sans propriétaire.' + parameters: '' + flagged: 'Marqué [number] [prefix_island](s) comme supprimable(s). Exécutez /[label] purge deleted pour supprimer les fichiers de région.' status: description: affiche l'état de la purge status: >- @@ -302,6 +314,7 @@ commands: protection-range-bonus-title: "Comprend ces bonus\_:" protection-range-bonus: "Bonus\_:\_[number]" purge-protected: L'île est protégée contre la purge + deletable: '[prefix_Island] est marquée pour suppression et en attente de la purge de région.' max-protection-range: 'La plus grande gamme de protection historique: [range]' protection-coords: 'Coordonnées de protection : [xz1] à [xz2]' is-spawn: L'île est un spawn @@ -1559,6 +1572,9 @@ protection: ! cooldown: 'Vous devez attendre avant de ramasser un autre bloc d''obsidienne.' obsidian-nearby: >- + lavaTip: |- + Récupère cela avec un seau + comme de la lave à nouveau si vous en avez besoin ! Il y a d'autres blocs d'obsidienne à proximité, vous ne pouvez pas transformer ce bloc en lave. ([radius]) OFFLINE_GROWTH: @@ -1802,6 +1818,7 @@ protection: name: Dommages mondiaux au TNT locked: 'Cette île est verrouillée!' locked-island-bypass: 'Cette [prefix_island] est verrouillée, mais vous avez la permission de la traverser.' + deletable-island-admin: '[Admin] Cette [prefix_island] est marquée pour suppression et en attente de la purge de région.' protected: 'Île protégée: [description]' world-protected: 'Monde protégé: [description]' spawn-protected: 'Spawn protégé: [description]' @@ -1844,6 +1861,11 @@ protection: description: | Paramètres de protection lorsque le joueur est en dehors de leur île + ISLAND_DEFAULTS: + title: '[world_name] Paramètres par défaut de l''île' + description: | + Paramètres de protection par défaut + pour les nouvelles [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/hr.yml b/src/main/resources/locales/hr.yml index b13e8df03..65c998dd9 100644 --- a/src/main/resources/locales/hr.yml +++ b/src/main/resources/locales/hr.yml @@ -128,6 +128,16 @@ commands: parameters: '[days]' description: očistite otoke brisanjem datoteka stare regije confirm: 'Tip /[label] purge regions confirm da se pokrenu čišćenje' + failed: 'Čišćenje završeno s greškama. Neke datoteke regija nije moguće izbrisati. Provjerite dnevnik poslužitelja.' + age-regions: + parameters: '[days]' + description: 'debug/test: prepišite vremenske oznake datoteka regija da bi bile čistive' + done: 'Stareno [number] datoteka regija u trenutnom svijetu.' + deleted: + parameters: '' + description: 'pročistite datoteke regija za bilo koji [prefix_island] koji je već označen za brisanje' + confirm: 'Unesite /[label] purge deleted confirm za brisanje datoteka regija' + deferred: 'Datoteke regija obrisane. Unosi baze podataka otoka bit će uklonjeni pri sljedećem ponovnom pokretanju poslužitelja.' protect: description: isključiti island purge protection move-to-island: 'Prvo se preseli na otok!' @@ -139,6 +149,8 @@ commands: unowned: description: očistiti neposjedovane otoke unowned-islands: 'Pronađeni [number] otoci bez posjeda.' + parameters: '' + flagged: 'Označeno [number] [prefix_island](s) kao obrisivo. Pokrenite /[label] purge deleted za brisanje datoteka regija.' status: description: prikazuje status čišćenja status: >- @@ -278,6 +290,7 @@ commands: protection-range-bonus-title: 'Uključuje ove bonuse:' protection-range-bonus: 'Bonus: [number]' purge-protected: Otok je zaštićen od čišćenja + deletable: '[prefix_Island] je označen za brisanje i čeka čišćenje regije.' max-protection-range: 'Najveći povijesni raspon zaštite: [range]' protection-coords: 'Koordinate zaštite: [xz1] do [xz2]' is-spawn: Otok je spawn otok @@ -1521,6 +1534,9 @@ protection: scooping: 'Pretvaranje opsidijana natrag u lavu. Budite oprezni sljedeći put!' cooldown: 'Morate pričekati prije nego što možete pokupiti još jedan blok opsidijana.' obsidian-nearby: >- + lavaTip: |- + Skupi ovo vedrom + kao lava ponovo ako vam treba! U blizini su blokovi od opsidijana, ne možete zagrabiti ovaj blok u lavu. ([radius]) OFFLINE_GROWTH: @@ -1763,6 +1779,7 @@ protection: name: Svjetska TNT šteta locked: 'Ovaj otok je zaključan!' locked-island-bypass: 'Ovaj [prefix_island] je zaključan, ali imate dozvolu za pristup.' + deletable-island-admin: '[Admin] Ova [prefix_island] je označena za brisanje i čeka čišćenje regije.' protected: 'Otok zaštićen: [description].' world-protected: 'Svijet zaštićen: [description].' spawn-protected: 'Spawn zaštićeno: [description].' @@ -1805,6 +1822,11 @@ protection: description: | Postavke zaštite kada igrač je izvan svog otoka + ISLAND_DEFAULTS: + title: '[world_name] Zadane postavke otoka' + description: | + Zadane postavke zaštite + za nove [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 3cead1b17..d82f7e74a 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -138,6 +138,16 @@ commands: parameters: '[days]' description: tisztítsa meg a szigeteket a régi régiófájlok törlésével confirm: 'Type /[label] purge regions confirm' + failed: 'A tisztítás hibákkal fejeződött be. Néhány régiófájl nem törölhető. Részletekért lásd a szervernaplót.' + age-regions: + parameters: '[days]' + description: 'debug/teszt: régiófájlok időbélyegeinek átírása, hogy törölhetők legyenek' + done: 'Korított [number] régiófájl(ok) az aktuális világban.' + deleted: + parameters: '' + description: 'régiófájlok törlése bármely [prefix_island]-hoz, amely már töröltként van jelölve' + confirm: 'Írja be: /[label] purge deleted confirm a régiófájlok eltávolításához' + deferred: 'Régiófájlok törölve. A sziget adatbázis bejegyzések a szerver következő újraindításakor törlődnek.' protect: description: '[prefix_Island] törlés elleni védelmének engedélyezése' move-to-island: 'Először menj egy szigetre!' @@ -149,6 +159,8 @@ commands: unowned: description: Tulajdonos nélküli [prefix_islands] törlése. unowned-islands: 'Megtalálva [number] tulajdonos nélküli [prefix_island].' + parameters: '' + flagged: 'Megjelölve [number] [prefix_island](s) törölhetőként. Futtassa a /[label] purge deleted parancsot a régiófájlok eltávolításához.' status: description: Törlés státuszának kijelzése. status: >- @@ -298,6 +310,7 @@ commands: protection-range-bonus-title: 'A következő bónuszokat tartalmazza:' protection-range-bonus: 'Bónusz: [number]' purge-protected: A [prefix_island] takarítással védett + deletable: '[prefix_Island] törlésre van jelölve és régiómegtisztításra vár.' max-protection-range: 'Legnagyobb történelmi védelmi tartomány: [range]' protection-coords: 'Védelmi koordináták: [xz1] – [xz2]' is-spawn: A [prefix_island] spawn [prefix_island] @@ -1578,6 +1591,9 @@ protection: scooping: 'Az obszidián visszaváltása lávává. Legyen óvatos legközelebb!' cooldown: 'Várnia kell, mielőtt újabb obszidián blokkot meríthet.' obsidian-nearby: >- + lavaTip: |- + Vedd fel vödörrel + lávává újra, ha szükséged van rá! Obszidián blokkok vannak a közelben, ezt a tömböt nem lehet lávába szedni. ([radius]) OFFLINE_GROWTH: @@ -1822,6 +1838,7 @@ protection: name: Világ TNT kár locked: 'Ez a [prefix_island] le van zárva!' locked-island-bypass: 'Ez a [prefix_island] le van zárva, de van engedélyed a belépésre.' + deletable-island-admin: '[Admin] Ez a [prefix_island] törlésre van jelölve és régiómegtisztításra vár.' protected: '[prefix_Island] védett: [description].' world-protected: 'Világvédett: [description].' spawn-protected: 'Spawn védett: [description].' @@ -1866,6 +1883,11 @@ protection: description: | Védelmi beállítások mikor játékos a szigetén kívül van + ISLAND_DEFAULTS: + title: '[world_name] Sziget alapértelmezések' + description: | + Alapértelmezett védelmi beállítások + az új [prefix_island]ok számára flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index f89313c32..25c13eb7b 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -127,6 +127,16 @@ commands: parameters: '[days]' description: pulau Purge dengan menghapus file wilayah lama confirm: 'Jenis /[label] purge regions confirm untuk mulai membersihkan' + failed: 'Pembersihan selesai dengan kesalahan. Beberapa file region tidak dapat dihapus. Periksa log server untuk detailnya.' + age-regions: + parameters: '[days]' + description: 'debug/test: tulis ulang stempel waktu file region agar dapat dibersihkan' + done: 'Menua [number] file region di dunia saat ini.' + deleted: + parameters: '' + description: 'bersihkan file region untuk [prefix_island] mana pun yang sudah ditandai sebagai dihapus' + confirm: 'Ketik /[label] purge deleted confirm untuk menghapus file region' + deferred: 'File region dihapus. Entri database pulau akan dihapus saat server dinyalakan ulang berikutnya.' protect: description: beralih perlindungan pembersihan pulau move-to-island: 'Pindah ke pulau dulu!' @@ -138,6 +148,8 @@ commands: unowned: description: membersihkan pulau-pulau yang tidak dimiliki unowned-islands: 'Ditemukan [number] pulau yang tidak dimiliki.' + parameters: '' + flagged: 'Menandai [number] [prefix_island](s) sebagai dapat dihapus. Jalankan /[label] purge deleted untuk menghapus file region.' status: description: menampilkan status pembersihan status: >- @@ -285,6 +297,7 @@ commands: protection-range-bonus-title: 'Termasuk bonus berikut:' protection-range-bonus: 'Bonus: [number]' purge-protected: Pulau dilindungi dari pembersihan + deletable: '[prefix_Island] ditandai untuk dihapus dan menunggu pembersihan region.' max-protection-range: 'Kisaran perlindungan historis terbesar: [range]' protection-coords: 'Koordinat perlindungan: [xz1] hingga [xz2]' is-spawn: Pulau adalah pulau bibit @@ -1543,6 +1556,9 @@ protection: scooping: 'Mengubah obsidian kembali menjadi lava. Lain kali hati-hati!' cooldown: 'Anda harus menunggu sebelum mengambil blok obsidian lagi.' obsidian-nearby: >- + lavaTip: |- + Ambil ini dengan ember + sebagai lava lagi jika Anda membutuhkannya! Ada blok obsidian di dekatnya, Anda tidak dapat memasukkan blok ini ke dalam lava. ([radius]) OFFLINE_GROWTH: @@ -1787,6 +1803,7 @@ protection: name: Kerusakan TNT dunia locked: 'Pulau ini terkunci!' locked-island-bypass: '[prefix_Island] ini terkunci, tetapi kamu memiliki izin untuk melewatinya.' + deletable-island-admin: '[Admin] [prefix_island] ini ditandai untuk dihapus dan menunggu pembersihan region.' protected: '[prefix_Island] dilindungi: [description].' world-protected: 'Dunia dilindungi: [description].' spawn-protected: 'Spawn dilindungi: [description].' @@ -1831,6 +1848,11 @@ protection: description: | Pengaturan perlindungan saat pemain berada di luar [prefix_island] mereka + ISLAND_DEFAULTS: + title: '[world_name] Default Pulau' + description: | + Pengaturan perlindungan default + untuk [prefix_island]s baru flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/it.yml b/src/main/resources/locales/it.yml index 9be1e9337..8ea6d7617 100644 --- a/src/main/resources/locales/it.yml +++ b/src/main/resources/locales/it.yml @@ -128,6 +128,16 @@ commands: parameters: '[days]' description: purge Isole eliminando i vecchi file della regione confirm: 'Tipo /[label] purge regions confirm di iniziare lo spurgo' + failed: 'Pulizia completata con errori. Alcuni file di regione non possono essere eliminati. Controlla il log del server per i dettagli.' + age-regions: + parameters: '[days]' + description: 'debug/test: riscrivi i timestamp dei file di regione per renderli eliminabili' + done: 'Invecchiati [number] file di regione nel mondo attuale.' + deleted: + parameters: '' + description: 'pulisci i file di regione per qualsiasi [prefix_island] già contrassegnata come eliminata' + confirm: 'Digita /[label] purge deleted confirm per eliminare i file di regione' + deferred: 'File di regione eliminati. Le voci del database delle isole verranno rimosse al prossimo riavvio del server.' protect: description: Abilita o disabilita la protezione isola dalla purga move-to-island: 'Spostati su una isola prima!' @@ -139,6 +149,8 @@ commands: unowned: description: Purga le isole senza proprietario - richiede conferma unowned-islands: 'Trovate [number] isole' + parameters: '' + flagged: 'Contrassegnate [number] [prefix_island](s) come eliminabili. Esegui /[label] purge deleted per rimuovere i file di regione.' status: description: visualizza lo stato della purga status: >- @@ -287,6 +299,7 @@ commands: protection-range-bonus-title: 'Include questi bonus:' protection-range-bonus: 'Bonus: [number]' purge-protected: La isola è protetta dalla purga + deletable: '[prefix_Island] è contrassegnata per l''eliminazione e in attesa di pulizia della regione.' max-protection-range: 'Raggio di protezione più grande avuto: [range]' protection-coords: 'Coordinate di protezione: [xz1] to [xz2]' is-spawn: L'isola è un'isola di spawn @@ -1539,6 +1552,9 @@ protection: scooping: 'Recuperando la ossidiana in lava. Stai attento la prossima volta!' cooldown: 'Devi aspettare prima di raccogliere un altro blocco di ossidiana.' obsidian-nearby: >- + lavaTip: |- + Raccoglilo con un secchio + di nuovo come lava se ne hai bisogno! Ci sono blocchi di ossidiana vicini, non puoi riciclare questo blocco in lava. ([radius]) OFFLINE_GROWTH: @@ -1775,6 +1791,7 @@ protection: name: Danno TNT nel mondo locked: 'Quest''isola è bloccata!' locked-island-bypass: 'Questa [prefix_island] è bloccata, ma hai il permesso di accedervi.' + deletable-island-admin: '[Admin] Questa [prefix_island] è contrassegnata per l''eliminazione e in attesa di pulizia della regione.' protected: 'Isola protetta: [description]' world-protected: 'Mondo protetto: [description]' spawn-protected: 'Spawn protetto: [description]' @@ -1815,6 +1832,11 @@ protection: description: |- Impostazioni protezione quando un giocatore è fuori dalla propria isola + ISLAND_DEFAULTS: + title: '[world_name] Impostazioni predefinite isola' + description: | + Impostazioni di protezione predefinite + per le nuove [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 12db2eb2e..75f32616e 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -108,6 +108,16 @@ commands: parameters: '[days]' description: 古い地域ファイルを削除して島をパージします confirm: 'タイプ /[label] purge regions confirm を開始することを確認します' + failed: 'パージがエラーで完了しました。一部のリージョンファイルを削除できませんでした。詳細はサーバーログを確認してください。' + age-regions: + parameters: '[days]' + description: 'デバッグ/テスト: リージョンファイルのタイムスタンプを書き換えてパージ可能にする' + done: '現在のワールドの [number] 個のリージョンファイルを古くしました。' + deleted: + parameters: '' + description: '削除済みとしてフラグされた [prefix_island] のリージョンファイルをパージする' + confirm: 'リージョンファイルを削除するには /[label] purge deleted confirm と入力してください' + deferred: 'リージョンファイルを削除しました。島のデータベースエントリは次回のサーバー再起動時に削除されます。' protect: description: アイランドパージ保護の切り替え move-to-island: '最初に島に移動してください!' @@ -119,6 +129,8 @@ commands: unowned: description: 未所有の島を削除-確認が必要 unowned-islands: '[number]島を見つけました。' + parameters: '' + flagged: '[number] [prefix_island](s) を削除可能としてフラグしました。リージョンファイルを削除するには /[label] purge deleted を実行してください。' status: description: パージのステータスを表示します status: 'パージされた[purgeable] 個の島のうち[purged]個の島 ([percentage] %).' @@ -242,6 +254,7 @@ commands: protection-range-bonus-title: '以下の特典が含まれます:' protection-range-bonus: 'ボーナス: [number]' purge-protected: 島はパージ保護されています + deletable: '[prefix_Island] は削除のフラグが立てられており、リージョンパージを待っています。' max-protection-range: 最大の歴史的保護範囲:[range] protection-coords: '保護座標: [xz1] - [xz2]' is-spawn: この島はスポーン島です @@ -1402,6 +1415,9 @@ protection: scooping: '黒曜石を溶岩に戻す。次回は気をつけて!' cooldown: '次の黒曜石ブロックをすくうには、しばらく待つ必要があります。' obsidian-nearby: '近くに黒曜石のブロックがあり、このブロックを溶岩にすくい上げることはできません。 ([radius])' + lavaTip: |- + バケツですくい取ってください + また溶岩として使いたいなら! OFFLINE_GROWTH: description: |- 無効にすると、すべてのメンバーが @@ -1636,6 +1652,7 @@ protection: name: 世界のTNTダメージ locked: 島はロックされている! locked-island-bypass: 'この[prefix_island]はロックされていますが、あなたはバイパスする権限を持っています。' + deletable-island-admin: '[Admin] この[prefix_island]は削除のフラグが立てられており、リージョンパージを待っています。' protected: '島保護: [description]' world-protected: '世界保護: [description]' spawn-protected: '保護されたスポーン: [description]' @@ -1674,6 +1691,11 @@ protection: description: |- プレイヤーが島の 外にいるときの保護設定 + ISLAND_DEFAULTS: + title: '[world_name] 島のデフォルト設定' + description: | + デフォルトの保護設定 + 新しい[prefix_island]のための flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index d0495a0c0..35314fc41 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -117,6 +117,16 @@ commands: parameters: '[days]' description: 오래된 지역 파일을 삭제하여 섬을 제거합니다 confirm: '유형 /[label] purge regions confirm' + failed: '정리가 오류로 완료되었습니다. 일부 지역 파일을 삭제할 수 없습니다. 서버 로그에서 자세한 내용을 확인하세요.' + age-regions: + parameters: '[days]' + description: '디버그/테스트: 지역 파일의 타임스탬프를 다시 써서 삭제 가능하게 만들기' + done: '현재 세계에서 [number]개의 지역 파일을 오래된 것으로 만들었습니다.' + deleted: + parameters: '' + description: '이미 삭제됨으로 표시된 [prefix_island]의 지역 파일 정리' + confirm: '지역 파일을 제거하려면 /[label] purge deleted confirm 을 입력하세요' + deferred: '지역 파일이 삭제되었습니다. 섬 데이터베이스 항목은 다음 서버 재시작 시 제거됩니다.' protect: description: 섬 청소 보호 여부를 설정합니다 move-to-island: '섬으로 먼저 이동하세요!' @@ -128,6 +138,8 @@ commands: unowned: description: 소유주가 없는 섬들을 청소합니다 unowned-islands: '[number]개의 소유주가 없는 섬을 찾았습니다' + parameters: '' + flagged: '삭제 가능으로 표시된 [number] 개의 [prefix_island](s). 지역 파일을 제거하려면 /[label] purge deleted를 실행하세요.' status: description: 청소 상태를 보여줍니다 status: >- @@ -259,6 +271,7 @@ commands: protection-range-bonus-title: '다음 보너스가 포함됩니다:' protection-range-bonus: '보너스: [number]' purge-protected: 섬은 청소로부터 보호됩니다 + deletable: '[prefix_Island]이(가) 삭제 예정으로 표시되었으며 지역 정리를 기다리고 있습니다.' max-protection-range: '가장 컷던 보호범위: [range]' protection-coords: '보호 좌표: [xz1] 에서 [xz2] 까지' is-spawn: 섬은 스폰섬입니다 @@ -1423,6 +1436,9 @@ protection: scooping: '혹요석을 다시 용암으로 바꿨습니다! 다음번엔 주의하세요!' cooldown: '다른 흑요석 블록을 퍼올리려면 잠시 기다려야 합니다.' obsidian-nearby: '옵시디언이 근처에 있지만 용암으론 다시 바꿀수 없습니다 ([radius])' + lavaTip: |- + 양동이로 이것을 퍼담으세요 + 다시 용암으로 필요하다면! OFFLINE_GROWTH: description: |- 비활성화되면 식물은 @@ -1652,6 +1668,7 @@ protection: name: 월드 TNT 피해 locked: '이 섬은 잠겨있습니다!' locked-island-bypass: '이 [prefix_island]은 잠겨있지만, 당신은 우회할 권한이 있습니다.' + deletable-island-admin: '[Admin] 이 [prefix_island]이(가) 삭제 예정으로 표시되었으며 지역 정리를 기다리고 있습니다.' protected: '섬 보호: [description].' world-protected: '월드 보호: [description].' spawn-protected: '스폰 보호: [description].' @@ -1687,6 +1704,11 @@ protection: title: '[world_name] 월드 보호' description: | 플레이어가 자신의 섬 밖에 있을 때 보호 설정 + ISLAND_DEFAULTS: + title: '[world_name] 섬 기본값' + description: | + 기본 보호 설정 + 새로운 [prefix_island]s을(를) 위한 flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index 990190052..8ff88b862 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -125,6 +125,16 @@ commands: parameters: '[days]' description: Iztīrīt salas, izdzēšot veco reģiona failus confirm: 'Ievadi /[label] purge regions confirm, lai sāktu attīrīt' + failed: 'Tīrīšana pabeigta ar kļūdām. Dažus reģionu failus nevarēja izdzēst. Pārbaudiet servera žurnālu, lai uzzinātu detaļas.' + age-regions: + parameters: '[days]' + description: 'atkļūdošana/tests: pārrakstiet reģionu failu laika zīmogus, lai tie būtu notīrāmi' + done: 'Novecināti [number] reģionu fails(-i) pašreizējā pasaulē.' + deleted: + parameters: '' + description: 'notīrīt reģionu failus jebkuram [prefix_island], kas jau atzīmēts kā dzēsts' + confirm: 'Ierakstiet /[label] purge deleted confirm , lai noņemtu reģionu failus' + deferred: 'Reģionu faili dzēsti. Salu datu bāzes ieraksti tiks noņemti nākamajā servera restartēšanas reizē.' protect: description: Pārslēgt salas aizsargāšanu no dzēšanas move-to-island: 'Sākumā pārvietojies uz salas!' @@ -136,6 +146,8 @@ commands: unowned: description: Dzēst bezīpašnieku salas - nepieciešams apstiprinājums unowned-islands: 'Atrastas [number] salas' + parameters: '' + flagged: 'Atzīmēti [number] [prefix_island](s) kā dzēšami. Palaidiet /[label] purge deleted, lai noņemtu reģionu failus.' status: description: parāda attīrīšanas statusu status: >- @@ -285,6 +297,7 @@ commands: protection-range-bonus-title: 'Ietver šos bonusus:' protection-range-bonus: 'Bonuss: [number]' purge-protected: Sala ir aizsargāta no dzēšanas + deletable: '[prefix_Island] ir atzīmēts dzēšanai un gaida reģiona notīrīšanu.' max-protection-range: 'Lielākā aizsardzības distance salas vēsturē: [range]' protection-coords: 'Aizsardzības koordinātes: no [xz1] līdz [xz2]' is-spawn: Šī ir sākuma sala @@ -1517,6 +1530,9 @@ protection: scooping: 'Pārvēršot obsidianu atpakaļ lavā. Nākamreiz esi uzmanīgs!' cooldown: 'Jums jāgaida, pirms varat uzsūkt vēl vienu obsidiāna bloku.' obsidian-nearby: 'Netālu ir obsidiāna bloki, tu nevar uzsūkt šo bloku lavā. ([radius])' + lavaTip: |- + Uzņem to ar spaini + kā lavu atkal, ja tas tev ir vajadzīgs! OFFLINE_GROWTH: description: |- Kad ir izslēgts, augi neaugs uz @@ -1787,6 +1803,7 @@ protection: name: Pasaule TNT kaitējums locked: 'Šī sala ir slēgta!' locked-island-bypass: 'Šī [prefix_island] ir slēgta, bet jums ir atļauja to apiet.' + deletable-island-admin: '[Admin] Šī [prefix_island] ir atzīmēta dzēšanai un gaida reģiona notīrīšanu.' protected: 'Sala ir aizsargāta: [description]' world-protected: 'Pasaule aizsargāta: [description]' spawn-protected: 'Sākuma sala ir aizsargāta: [description]' @@ -1832,6 +1849,11 @@ protection: Aizsardzības iestatījumi kuri ir aktīvi, ja spēlētājs ir ārpus savas salas + ISLAND_DEFAULTS: + title: '[world_name] Salas noklusējuma iestatījumi' + description: | + Noklusējuma aizsardzības iestatījumi + jaunajām [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index cdc0f8f07..eedc16cf4 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -131,6 +131,16 @@ commands: confirm: >- Type /[label] purge regions confirm om te beginnen met spoelen + failed: 'Opschonen voltooid met fouten. Sommige regiobestanden konden niet worden verwijderd. Controleer het serverlogboek voor details.' + age-regions: + parameters: '[days]' + description: 'debug/test: regiobestandstijdstempels herschrijven zodat ze verwijderbaar worden' + done: 'Verouderd [number] regiobestand(en) in de huidige wereld.' + deleted: + parameters: '' + description: 'verwijder regiobestanden voor elke [prefix_island] die al als verwijderd is gemarkeerd' + confirm: 'Typ /[label] purge deleted confirm om de regiobestanden te verwijderen' + deferred: 'Regiobestanden verwijderd. Eiland database-vermeldingen worden verwijderd bij de volgende herstart van de server.' protect: description: schakel de bescherming tegen het doorspoelen van het eiland in move-to-island: 'Ga eerst naar een eiland!' @@ -142,6 +152,8 @@ commands: unowned: description: zuiveren van eilanden die geen eigendom zijn unowned-islands: 'Gevonden [number] eilanden zonder eigendom.' + parameters: '' + flagged: 'Gemarkeerd [number] [prefix_island](s) als verwijderbaar. Voer /[label] purge deleted uit om de regiobestanden te verwijderen.' status: description: geeft de status van de zuivering weer status: >- @@ -290,6 +302,7 @@ commands: protection-range-bonus-title: 'Inclusief deze bonussen:' protection-range-bonus: 'Bonus: [number]' purge-protected: Het eiland is beschermd tegen zuivering + deletable: '[prefix_Island] is gemarkeerd voor verwijdering en wacht op regioopschoning.' max-protection-range: 'Grootste historische beschermingsbereik: [range]' protection-coords: 'Beschermingscoördinaten: [xz1] tot [xz2]' is-spawn: Eiland is een spawn-eiland @@ -1568,6 +1581,9 @@ protection: keer! cooldown: 'Je moet wachten voordat je nog een obsidiaanblok kunt scheppen.' obsidian-nearby: >- + lavaTip: |- + Schep dit op met een emmer + als lava weer als je het nodig hebt! Er zijn obsidiaanblokken in de buurt, je kunt dit blok niet in lava scheppen. ([radius]) OFFLINE_GROWTH: @@ -1810,6 +1826,7 @@ protection: name: Wereld TNT-schade locked: 'Dit eiland is op slot!' locked-island-bypass: 'Dit [prefix_island] is op slot, maar je hebt toestemming om dit te negeren.' + deletable-island-admin: '[Admin] Dit [prefix_island] is gemarkeerd voor verwijdering en wacht op regioopschoning.' protected: 'Eiland beschermd: [description].' world-protected: 'Wereld beschermd: [description].' spawn-protected: 'Spawn beschermd: [description].' @@ -1852,6 +1869,11 @@ protection: description: | Beveiligingsinstellingen wanneer speler is buiten zijn eiland + ISLAND_DEFAULTS: + title: '[world_name] Eiland standaardinstellingen' + description: | + Standaard beveiligingsinstellingen + voor nieuwe [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index ce56edb80..4ef6478e2 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -128,6 +128,16 @@ commands: parameters: '[days]' description: Oczyszcz wyspy, usuwając stare pliki regionu confirm: 'Wpisz /[label] purge regions confirm aby rozpocząć czyszczenie' + failed: 'Czyszczenie zakończone błędami. Niektórych plików regionów nie można usunąć. Sprawdź dziennik serwera, aby uzyskać szczegółowe informacje.' + age-regions: + parameters: '[days]' + description: 'debug/test: nadpisz znaczniki czasu plików regionów, aby mogły zostać wyczyszczone' + done: 'Zestarzano [number] plik(ów) regionów w bieżącym świecie.' + deleted: + parameters: '' + description: 'wyczyść pliki regionów dla każdej [prefix_island] oznaczonej jako usuniętej' + confirm: 'Wpisz /[label] purge deleted confirm aby usunąć pliki regionów' + deferred: 'Pliki regionów usunięte. Wpisy bazy danych wyspy zostaną usunięte przy następnym restarcie serwera.' protect: description: Przełącz ochronę przed usuwaniem opuszczonych wysp move-to-island: 'Najpierw przenieś się na wyspę!' @@ -139,6 +149,8 @@ commands: unowned: description: Usuń niezamieszkane wyspy (wymaga potwierdzenia) unowned-islands: 'Znaleziono [number] wysp' + parameters: '' + flagged: 'Oznaczono [number] [prefix_island](s) jako do usunięcia. Uruchom /[label] purge deleted, aby usunąć pliki regionów.' status: description: wyświetla status oczyszczenia status: >- @@ -281,6 +293,7 @@ commands: protection-range-bonus-title: 'Zawiera te bonusy:' protection-range-bonus: 'Bonus: [number]' purge-protected: Wyspa jest chroniona przed usunięciem + deletable: '[prefix_Island] jest oznaczony do usunięcia i oczekuje na czyszczenie regionu.' max-protection-range: 'Największy obszar ochrony: [range]' protection-coords: 'Współrzędne ochrony: [xz1] to [xz2]' is-spawn: Wyspa jest wyspą odradzania @@ -1507,6 +1520,9 @@ protection: scooping: 'Zmieniam obsydian z powrotem w lawę. Następnym razem uważaj!' cooldown: 'Musisz poczekać, zanim będziesz mógł zebrać kolejny blok obsydianu.' obsidian-nearby: >- + lavaTip: |- + Nabierz to wiadrem + jako lawę ponownie, jeśli jej potrzebujesz! W pobliżu znajdują się obsydianowe bloki, nie można zebrać tego bloku w lawę. ([radius]) OFFLINE_GROWTH: @@ -1745,6 +1761,7 @@ protection: name: Świat uszkodzenia TNT locked: 'Ta wyspa jest zamknięta!' locked-island-bypass: 'Ta [prefix_island] jest zamknięta, ale masz uprawnienia, by ją ominąć.' + deletable-island-admin: '[Admin] Ta [prefix_island] jest oznaczona do usunięcia i oczekuje na czyszczenie regionu.' protected: 'Wyspa chroniona: [description]' world-protected: 'Świat chroniony: [description]' spawn-protected: 'Spawn chroniony: [description]' @@ -1785,6 +1802,11 @@ protection: description: | Ustawienia ochrony, gdy gracz znajduje się poza swoją wyspą + ISLAND_DEFAULTS: + title: '[world_name] Domyślne ustawienia wyspy' + description: | + Domyślne ustawienia ochrony + dla nowych [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/pt-BR.yml b/src/main/resources/locales/pt-BR.yml index a883cdbd2..334f1b591 100644 --- a/src/main/resources/locales/pt-BR.yml +++ b/src/main/resources/locales/pt-BR.yml @@ -121,6 +121,16 @@ commands: parameters: '[days]' description: purge Islands excluindo arquivos da região antiga confirm: 'Tipo /[label] purge regions confirm para começar a limpar' + failed: 'Limpeza concluída com erros. Alguns arquivos de região não puderam ser excluídos. Verifique o log do servidor para detalhes.' + age-regions: + parameters: '[days]' + description: 'debug/teste: reescreva os registros de data e hora dos arquivos de região para que possam ser purgados' + done: 'Envelhecidos [number] arquivo(s) de região no mundo atual.' + deleted: + parameters: '' + description: 'limpar arquivos de região de qualquer [prefix_island] já marcada como excluída' + confirm: 'Digite /[label] purge deleted confirm para remover os arquivos de região' + deferred: 'Arquivos de região excluídos. As entradas do banco de dados da ilha serão removidas na próxima reinicialização do servidor.' protect: description: alternar proteção de exclusão da ilha move-to-island: 'Mova para uma ilha antes!' @@ -132,6 +142,8 @@ commands: unowned: description: excluir ilhas sem donos unowned-islands: 'Foram encontradas [number] ilhas sem dono.' + parameters: '' + flagged: 'Marcadas [number] [prefix_island](s) como excluíveis. Execute /[label] purge deleted para remover os arquivos de região.' status: description: exibir o estado da exclusão status: >- @@ -277,6 +289,7 @@ commands: protection-range-bonus-title: 'Inclui estes bônus:' protection-range-bonus: 'Bônus: [number]' purge-protected: Ilha protegida de exclusão + deletable: '[prefix_Island] está marcada para exclusão e aguardando a purga de região.' max-protection-range: 'Maior proteção já registrada: [range]' protection-coords: 'Coordenadas da proteção: de [xz1] até [xz2]' is-spawn: Essa ilha é um spawn @@ -1522,6 +1535,9 @@ protection: scooping: 'Transformando sua obsidiana em lava. Tome cuidado da próxima vez!' cooldown: 'Você deve esperar antes de recolher outro bloco de obsidiana.' obsidian-nearby: >- + lavaTip: |- + Colete isso com um balde + como lava novamente se precisar! Há blocos de obsidiana próximos, você não pode coletar esse bloco com balde. ([radius]) OFFLINE_GROWTH: @@ -1758,6 +1774,7 @@ protection: name: Dano de TNT no mundo locked: 'Essa ilha está trancada!' locked-island-bypass: 'Essa [prefix_island] está trancada, mas você tem permissão para acessá-la.' + deletable-island-admin: '[Admin] Esta [prefix_island] está marcada para exclusão e aguardando a purga de região.' protected: 'Ilha protegida: [description].' world-protected: 'Mundo protegido: [description].' spawn-protected: 'Spawn protegido: [description].' @@ -1800,6 +1817,11 @@ protection: description: | Configurações de proteção para quando o jogador está fora de sua ilha + ISLAND_DEFAULTS: + title: '[world_name] Padrões da ilha' + description: | + Configurações de proteção padrão + para novas [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index e7dfc57ff..c95a035b2 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -132,6 +132,16 @@ commands: parameters: '[days]' description: purge Islands excluindo arquivos da região antiga confirm: 'tipo /[label] purge regions confirm para começar a limpar' + failed: 'Limpeza concluída com erros. Alguns ficheiros de região não puderam ser eliminados. Consulte o registo do servidor para obter detalhes.' + age-regions: + parameters: '[days]' + description: 'debug/teste: reescreva os carimbos de data/hora dos ficheiros de região para que possam ser purgados' + done: 'Envelhecidos [number] ficheiro(s) de região no mundo atual.' + deleted: + parameters: '' + description: 'limpar ficheiros de região de qualquer [prefix_island] já marcada como eliminada' + confirm: 'Escreva /[label] purge deleted confirm para remover os ficheiros de região' + deferred: 'Ficheiros de região eliminados. As entradas da base de dados da ilha serão removidas no próximo reinício do servidor.' protect: description: alternar proteção contra exclusão de ilha move-to-island: 'Vá para uma ilha primeiro!' @@ -143,6 +153,8 @@ commands: unowned: description: Excluir ilhas sem dono unowned-islands: 'Encontrado [number] ilhas sem dono.' + parameters: '' + flagged: 'Marcadas [number] [prefix_island](s) como elimináveis. Execute /[label] purge deleted para remover os ficheiros de região.' status: description: exibe o status da limpeza status: >- @@ -284,6 +296,7 @@ commands: protection-range-bonus-title: 'Inclui estes bônus:' protection-range-bonus: 'Bônus: [number]' purge-protected: Ilha é protegida de exclusão + deletable: '[prefix_Island] está marcada para eliminação e aguarda a purga de região.' max-protection-range: 'Maior histórico de alcance de proteção: [range]' protection-coords: 'Coordenadas de Proteção: [xz1] até [xz2]' is-spawn: Ilha é uma ilha de spawn @@ -1539,6 +1552,9 @@ protection: vez! cooldown: 'Deve esperar antes de recolher outro bloco de obsidiana.' obsidian-nearby: >- + lavaTip: |- + Recolhe isso com um balde + como lava novamente se precisar! Existem blocos de obsidiana próximos, você não pode transformar este bloco em lava. ([radius]) OFFLINE_GROWTH: @@ -1783,6 +1799,7 @@ protection: name: Danos mundiais de TNT locked: 'Esta ilha está trancada!' locked-island-bypass: 'Esta [prefix_island] está trancada, mas tem permissão para aceder.' + deletable-island-admin: '[Admin] Esta [prefix_island] está marcada para eliminação e aguarda a purga de região.' protected: 'Ilha protegida: [descrição].' world-protected: 'Protegido mundialmente: [descrição].' spawn-protected: 'Spawn protegido: [descrição].' @@ -1825,6 +1842,11 @@ protection: description: | Configurações de proteção quando jogador está fora de sua [prefix_island] + ISLAND_DEFAULTS: + title: '[world_name] Predefinições da ilha' + description: | + Definições de proteção predefinidas + para novas [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/ro.yml b/src/main/resources/locales/ro.yml index fdb5ddfd3..2fb6a5d6c 100644 --- a/src/main/resources/locales/ro.yml +++ b/src/main/resources/locales/ro.yml @@ -132,6 +132,16 @@ commands: parameters: '[days]' description: insulele de purjare prin ștergerea fișierelor din regiunea veche confirm: 'Tip /[label] purge regions confirm să înceapă purjarea' + failed: 'Curățarea finalizată cu erori. Unele fișiere de regiune nu au putut fi șterse. Verificați jurnalul serverului pentru detalii.' + age-regions: + parameters: '[days]' + description: 'debug/test: rescrie marcajele de timp ale fișierelor de regiune pentru a le face purgabile' + done: 'Îmbătrânit [number] fișier(e) de regiune în lumea curentă.' + deleted: + parameters: '' + description: 'curățați fișierele de regiune pentru orice [prefix_island] deja marcată ca ștearsă' + confirm: 'Tastați /[label] purge deleted confirm pentru a elimina fișierele de regiune' + deferred: 'Fișierele de regiune au fost șterse. Intrările din baza de date ale insulelor vor fi eliminate la repornirea serverului.' protect: description: comutați protecția de purjare a insulei move-to-island: 'Mutați-vă mai întâi pe o insulă!' @@ -143,6 +153,8 @@ commands: unowned: description: curățați insulele neproprietate unowned-islands: 'S-au găsit [number] insule neproprietate. ' + parameters: '' + flagged: 'Marcate [number] [prefix_island](s) ca ștergabile. Rulați /[label] purge deleted pentru a elimina fișierele de regiune.' status: description: afișează starea purjării status: >- @@ -288,6 +300,7 @@ commands: protection-range-bonus-title: 'Include aceste bonusuri:' protection-range-bonus: 'Bonus: [number]' purge-protected: Insula este protejată de purjare + deletable: '[prefix_Island] este marcată pentru ștergere și așteaptă curățarea regiunii.' max-protection-range: 'Cea mai mare gamă de protecție istorică: [range]' protection-coords: 'Coordonate de protecție: de la [xz1] la [xz2]' is-spawn: Insula este o insulă spawn @@ -1557,6 +1570,9 @@ protection: scooping: 'Obsidian în schimbare în lavă. Fii atent data viitoare!' cooldown: 'Trebuie să așteptați înainte de a colecta un alt bloc de obsidian.' obsidian-nearby: >- + lavaTip: |- + Ia-l cu o găleată + din nou ca lavă dacă ai nevoie! Există blocuri de obsidian în apropiere, nu puteți scoate acest bloc în lavă. ([radius]) OFFLINE_GROWTH: @@ -1801,6 +1817,7 @@ protection: name: Daune TNT mondiale locked: 'Această insulă este blocată!' locked-island-bypass: 'Această [prefix_island] este blocată, dar ai permisiunea de a o ocoli.' + deletable-island-admin: '[Admin] Această [prefix_island] este marcată pentru ștergere și așteaptă curățarea regiunii.' protected: 'Insula protejată: [description].' world-protected: 'Protecție mondială: [description].' spawn-protected: 'Spawn protejat: [description].' @@ -1843,6 +1860,11 @@ protection: description: | Setări de protecție când jucător se află în afara insulei lor + ISLAND_DEFAULTS: + title: '[world_name] Setări implicite insulă' + description: | + Setări de protecție implicite + pentru noile [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 6603f43cc..e59035a6f 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -130,6 +130,16 @@ commands: description: очистка островов путём удаления старых файлов регионов confirm: Тип /[label] purge regions confirm чтобы начать чистку. + failed: 'Очистка завершена с ошибками. Некоторые файлы регионов не удалось удалить. Подробности смотрите в журнале сервера.' + age-regions: + parameters: '[days]' + description: 'отладка/тест: перезаписать временные метки файлов регионов, чтобы они стали пригодными для очистки' + done: 'Устаревших [number] файл(ов) регионов в текущем мире.' + deleted: + parameters: '' + description: 'очистить файлы регионов для любого [prefix_island], уже помеченного как удалённый' + confirm: 'Введите /[label] purge deleted confirm чтобы удалить файлы регионов' + deferred: 'Файлы регионов удалены. Записи базы данных островов будут удалены при следующем перезапуске сервера.' protect: description: переключатель защиты острова от очистки move-to-island: Для начала вернитесь на остров! @@ -142,6 +152,8 @@ commands: description: позволяет очистить острова без владельцев unowned-islands: Найден(о) [number] остров(а/ов) без владельца. + parameters: '' + flagged: 'Помечено [number] [prefix_island](s) как удаляемые. Запустите /[label] purge deleted, чтобы удалить файлы регионов.' status: description: отображает статус очистки status: [purged] островов очищено из [purgeable] @@ -284,6 +296,7 @@ commands: protection-range-bonus-title: Включая указанный бонус: protection-range-bonus: 'Бонус: [number]' purge-protected: Остров защищен от очистки + deletable: '[prefix_Island] помечен для удаления и ожидает очистки региона.' max-protection-range: 'Самый большой радиус острова за все время: [range]' protection-coords: 'Радиус границ острова: от [xz1] до [xz2]' is-spawn: Остров является точкой спавна @@ -1575,6 +1588,9 @@ protection: scooping: Обсидиан был превращён в лаву. cooldown: Подождите несколько секунд, чтобы зачерпнуть снова. obsidian-nearby: В радиусе [radius] блоков есть ещё обсидиан. + lavaTip: |- + Зачерпни это ведром + снова как лаву, если она тебе нужна! OFFLINE_GROWTH: name: Офлайн-рост description: |- @@ -1816,6 +1832,7 @@ protection: locked: Этот остров заблокирован! locked-island-bypass: Этот [prefix_island] заблокирован, но у вас есть разрешение на вход. + deletable-island-admin: '[Admin] Этот [prefix_island] помечен для удаления и ожидает очистки региона.' protected: 'Остров защищён: [description].' world-protected: 'Мир защищён: [description].' spawn-protected: 'Спавн защищён: [description].' diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index 64920c52d..f0e45f113 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -126,6 +126,16 @@ commands: confirm: >- Temizlemeyi başlatmak için /[label] purge regions confirm yazın + failed: 'Temizleme hatalarla tamamlandı. Bazı bölge dosyaları silinemedi. Ayrıntılar için sunucu günlüğünü kontrol edin.' + age-regions: + parameters: '[days]' + description: 'hata ayıklama/test: bölge dosyalarının zaman damgalarını yeniden yaz, böylece temizlenebilir olsunlar' + done: 'Mevcut dünyada [number] bölge dosyası eskitildi.' + deleted: + parameters: '' + description: 'zaten silinmiş olarak işaretlenmiş herhangi bir [prefix_island] için bölge dosyalarını temizle' + confirm: 'Bölge dosyalarını kaldırmak için /[label] purge deleted confirm yazın' + deferred: 'Bölge dosyaları silindi. Ada veritabanı girişleri sonraki sunucu yeniden başlatmasında kaldırılacaktır.' protect: description: Adanın anti-silinmesini kapat/aç. move-to-island: 'İlk önce adaya git!' @@ -137,6 +147,8 @@ commands: unowned: description: Sahipsiz adaları temizler. unowned-islands: '[number] silinebilecek ada bulundu!' + parameters: '' + flagged: 'İşaretlendi [number] [prefix_island](s) silinebilir olarak. Bölge dosyalarını kaldırmak için /[label] purge deleted komutunu çalıştırın.' status: description: temizlemenin durumunu gösterir status: >- @@ -279,6 +291,7 @@ commands: protection-range-bonus-title: 'Bu bonusları içerir:' protection-range-bonus: 'Bonus: [number]' purge-protected: Bu ada silinmelere karşı korunuyor. + deletable: '[prefix_Island] silmek için işaretlendi ve bölge temizlenmesini bekliyor.' max-protection-range: 'Önceki en büyük koruma alanı: [range]' protection-coords: 'Koruma kordinatları: [xz1] ile [xz2] arasında.' is-spawn: 'Bu ada başlangıç adası' @@ -1462,6 +1475,9 @@ protection: scooping: 'Obsidyen lava dönüştürüldü, daha dikkatli ol!' cooldown: 'Başka bir obsidyen bloğu almadan önce beklemelisiniz.' obsidian-nearby: 'Etrafta başka obsidyenler varken bu obsidyeni lava dönüştüremezsin! ([radius])' + lavaTip: |- + Bunu bir kova ile topla + tekrar lav olarak, ihtiyacın olursa! OFFLINE_GROWTH: description: |- Eğer deaktif olursa @@ -1681,6 +1697,7 @@ protection: name: Dünya TNT hasarı locked: 'Bu ada kilitli!' locked-island-bypass: 'Bu [prefix_island] kilitli, ancak geçiş izniniz var.' + deletable-island-admin: '[Admin] Bu [prefix_island] silmek için işaretlendi ve bölge temizlenmesini bekliyor.' protected: 'Ada korunuyor: [description]' world-protected: 'Dünya korunuyor: [description]' spawn-protected: 'Spawn koruması: [description]' @@ -1715,6 +1732,11 @@ protection: WORLD_DEFAULTS: title: '[world_name] Dünya ayarı' description: 'Oyuncu adasının dışındayken koruma ayarları' + ISLAND_DEFAULTS: + title: '[world_name] Ada Varsayılanları' + description: | + Varsayılan koruma ayarları + yeni [prefix_island]s için flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index e6cb772a5..ad09718f7 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -72,26 +72,26 @@ commands: unknown-island: Невідомий [prefix_island]! [name] resethome: description: Скинути дім гравця за замовчуванням - parameters: [[prefix_island] name] + parameters: <гравець> [[prefix_island] name] cleared: 'Дім скинуто. [name]' resets: description: редагувати лічильник перезапусків гравця set: description: встановлює, скільки разів гравець перезапускав свій [prefix_island] - parameters: + parameters: <гравець> success: '[name]— лічильник перезапусків [prefix_island] тепер [number].' reset: description: встановлює лічильник перезапусків [prefix_island] гравця в 0 - parameters: + parameters: <гравець> success-everyone: 'Успішно скинуто лічильник перезапусків всімдо 0.' success: 'Успішно скинуто лічильник перезапусків [name]до 0.' add: description: додає перезапуски до лічильника [prefix_island] цього гравця - parameters: + parameters: <гравець> success: 'Успішно додано [number] перезапуск(и/ів) для [name], збільшуючи загалом до [total].' remove: description: зменшує лічильник перезапусків [prefix_island] гравця - parameters: + parameters: <гравець> success: 'Успішно видалено [number] перезапуск(и/ів) із [prefix_island] [name], зменшивши загалом до [total].' purge: parameters: '[days]' @@ -117,6 +117,16 @@ commands: parameters: '[days]' description: 'очищення островів шляхом видалення старих файлів регіонів' confirm: 'Введіть /[label] purge regions confirm щоб почати очищення' + failed: 'Очищення завершено з помилками. Деякі файли регіонів не вдалося видалити. Перевірте журнал сервера для отримання деталей.' + age-regions: + parameters: '[days]' + description: 'налагодження/тест: перезапис часових міток файлів регіонів, щоб зробити їх доступними для очищення' + done: 'Застаріло [number] файл(ів) регіонів у поточному світі.' + deleted: + parameters: '' + description: 'очистити файли регіонів для будь-якого [prefix_island], вже позначеного як видаленого' + confirm: 'Введіть /[label] purge deleted confirm щоб видалити файли регіонів' + deferred: 'Файли регіонів видалено. Записи бази даних островів будуть видалені при наступному перезапуску сервера.' protect: description: перемикання захисту [prefix_island] від очищення move-to-island: 'Спершу перейдіть на [prefix_an-island]!' @@ -128,13 +138,15 @@ commands: unowned: description: очистити острови без власників unowned-islands: 'Знайдено [number] островів без власників.' + parameters: '' + flagged: 'Позначено [number] [prefix_island](s) як видаляльні. Запустіть /[label] purge deleted, щоб видалити файли регіонів.' status: description: показати статус очищення status: '[purged] островів очищено з [purgeable] ([percentage] %).' team: description: керування командами add: - parameters: + parameters: <гравець> description: додати гравця до команди власника name-not-owner: '[name] не є власником.' name-has-island: '[name] має [prefix_an-island]. Спершу зніміть реєстрацію або видаліть!' @@ -163,7 +175,7 @@ commands: success: '[name] вигнано з [prefix_island] гравця [owner].' success-all: 'Гравця видалено з усіх команд у цьому світі' setowner: - parameters: + parameters: <гравець> description: передати право власності [prefix_island] гравцю already-owner: '[name] уже власник цього [prefix_island]!' must-be-on-island: 'Ви маєте бути на цьому [prefix_island], щоб призначити власника' @@ -171,7 +183,7 @@ commands: success: '[name]тепер власник цього [prefix_island].' extra-islands: 'Увага: тепер цей гравець має [number] островів. Це більше ніж дозволено налаштуваннями або пермами: [max].' maxsize: - parameters: + parameters: <гравець> description: встановити максимальний розмір команди для [prefix_island] гравця (0 для скидання до типового значення світу) success: 'Встановлено максимальний розмір команди [prefix_island] гравця [name] на [number].' reset: 'Скинуто максимальний розмір команди [prefix_island] гравця [name] до типового значення світу ([number]).' @@ -192,23 +204,23 @@ commands: Зелені частинки показують типовий захист, якщо він відрізняється. showing: 'Показую індикатори діапазону' set: - parameters: [[prefix_island] location] + parameters: <гравець> [[prefix_island] location] description: встановити діапазон захисту [prefix_island] success: 'Встановлено діапазон захисту [prefix_island] на [number].' reset: - parameters: + parameters: <гравець> description: скинути діапазон захисту [prefix_island] до значення світу за замовчуванням success: 'Скинуто діапазон захисту [prefix_island] до [number].' add: description: збільшити діапазон захисту [prefix_island] - parameters: [[prefix_island] location] + parameters: <гравець> [[prefix_island] location] success: 'Успішно збільшено діапазон захисту [prefix_island] [name]до [total] (+[number]).' remove: description: зменшити діапазон захисту [prefix_island] - parameters: [[prefix_island] location] + parameters: <гравець> [[prefix_island] location] success: 'Успішно зменшено діапазон захисту [prefix_island] [name]до [total] (-[number]).' register: - parameters: + parameters: <гравець> description: зареєструвати гравця на безхазяйному [prefix_island], на якому ви стоїте registered-island: 'Зареєстровано [name] на [prefix_island] за [xyz].' reserved-island: 'Зарезервовано [prefix_island] за [xyz] для [name].' @@ -226,7 +238,7 @@ commands: specify-island-location: 'Вкажіть локацію [prefix_island] у форматі x,y,z' player-has-more-than-one-island: 'У гравця більше ніж один [prefix_island]. Уточніть який.' info: - parameters: + parameters: <гравець> description: отримати інформацію про місце розташування або [prefix_island] гравця no-island: 'Зараз ви не на [prefix_an-island]...' title: ========== Інфо про [prefix_Island] ============ @@ -251,6 +263,7 @@ commands: protection-range-bonus-title: 'Включає ці бонуси:' protection-range-bonus: 'Бонус: [number]' purge-protected: '[prefix_Island] захищено від очищення' + deletable: '[prefix_Island] позначено для видалення та очікує очищення регіону.' max-protection-range: 'Найбільший історичний діапазон захисту: [range]' protection-coords: 'Координати захисту: [xz1] до [xz2]' is-spawn: '[prefix_Island] є спавн-[prefix_island]' @@ -264,7 +277,7 @@ commands: removing: 'Вимикаю обхід захисту...' adding: 'Вмикаю обхід захисту...' switchto: - parameters: + parameters: <гравець> description: перемкнути [prefix_island] гравця на пронумерований у кошику out-of-range: 'Номер має бути від 1 до [number]. Використайте [label] trash [player] щоб побачити номери [prefix_island]' cannot-switch: 'Не вдалося перемкнути. Див. лог консолі для помилок.' @@ -272,20 +285,20 @@ commands: trash: no-unowned-in-trash: 'Немає безхазяйних островів у кошику' no-islands-in-trash: 'У гравця немає островів у кошику' - parameters: '[player]' + parameters: '[гравець]' description: показати безхазяйні острови або острови гравця в кошику title: '=========== Острови в кошику ===========' count: '[prefix_Island] [number]:' use-switch: 'Використайте [label] switchto щоб перемкнути гравця на [prefix_island] з кошика' use-emptytrash: 'Використайте [label] emptytrash [player]щоб остаточно видалити елементи з кошика' emptytrash: - parameters: '[player]' + parameters: '[гравець]' description: Очистити кошик для гравця або всі безхазяйні острови в кошику success: 'Кошик успішно очищено.' version: description: показати версії BentoBox та аддонів setrange: - parameters: + parameters: <гравець> description: встановити діапазон [prefix_island] гравця range-updated: 'Діапазон [prefix_Island] оновлено до [number].' placeholders: @@ -297,18 +310,18 @@ commands: reload: description: перезавантажити tp: - parameters: [player's island] + parameters: <гравець> [острів гравця] description: телепортуватися на [prefix_island] гравця manual: 'Безпечну точку варпу не знайдено! Ручний TP поблизу [location] і перевірка' tpuser: - parameters: <[prefix_island]'s player> [player's island] + parameters: <гравець що телепортується> <[prefix_island]'s player> [острів гравця] description: телепортувати гравця на [prefix_island] іншого гравця getrank: - parameters: [[prefix_island] owner] + parameters: <гравець> [[prefix_island] owner] description: отримати ранг гравця на його [prefix_island] або на [prefix_island] власника rank-is: 'Ранг [rank] на [prefix_island] гравця [name].' setrank: - parameters: [[prefix_island] owner] + parameters: <гравець> [[prefix_island] owner] description: встановити ранг гравцю на його [prefix_island] або на [prefix_island] власника unknown-rank: 'Невідомий ранг!' not-possible: 'Ранг має бути вищий за «відвідувач».' @@ -335,7 +348,7 @@ commands: success: 'Успішно встановлено цю локацію точкою спавну для цього [prefix_island].' island-spawnpoint-changed: '[user] змінив точку спавну [prefix_island].' settings: - parameters: '[player]/[world flag]/spawn-island [flag/active/disable] [rank/active/disable]' + parameters: '[гравець]/[world flag]/spawn-island [flag/active/disable] [rank/active/disable]' description: відкрити GUI налаштувань або встановити налаштування unknown-setting: 'Невідоме налаштування' blueprint: @@ -484,16 +497,16 @@ commands: world: description: Керування налаштуваннями світу delete: - parameters: + parameters: <гравець> description: видалити [prefix_island] гравця cannot-delete-owner: 'Усі учасники [prefix_island] мають бути вигнані до видалення.' deleted-island: '[prefix_Island] за [xyz] успішно видалено.' deletehomes: - parameters: + parameters: <гравець> description: видалити всі названі домівки на [prefix_an-island] warning: 'Усі названі домівки будуть видалені з [prefix_island]!' why: - parameters: + parameters: <гравець> description: увімк./вимк. налагоджувальні звіти захисту в консолі turning-on: 'Увімкнено консольний дебаг для [name].' turning-off: 'Вимкнено консольний дебаг для [name].' @@ -501,19 +514,19 @@ commands: description: редагувати кількість смертей гравців reset: description: скинути смерті гравця - parameters: + parameters: <гравець> success: 'Успішно скинуто смерті [name]до 0.' set: description: встановити кількість смертей гравця - parameters: + parameters: <гравець> success: 'Успішно встановлено смерті [name]на [number].' add: description: додати смерті гравцю - parameters: + parameters: <гравець> success: 'Успішно додано [number] смерт(і/ей) для [name], разом тепер [total].' remove: description: забрати смерті у гравця - parameters: + parameters: <гравець> success: 'Успішно видалено [number] смерт(і/ей) у [name], разом тепер [total].' resetname: description: скинути назву [prefix_island] гравця @@ -589,7 +602,7 @@ commands: about: description: показати дані ліцензії go: - parameters: '[home name]' + parameters: '[назва дому]' description: телепортуватися на ваш [prefix_island] teleport: 'Телепортую на ваш [prefix_island].' in-progress: 'Виконується телепорт, зачекайте...' @@ -625,12 +638,12 @@ commands: you-can-teleport-to-your-island: 'Ви зможете телепортуватися на свій [prefix_island], коли забажаєте.' deletehome: description: видалити локацію дому - parameters: '[home name]' + parameters: '[назва дому]' homes: description: показати список ваших домівок info: description: показати інформацію про ваш [prefix_island] або [prefix_island] гравця - parameters: + parameters: <гравець> near: description: показати назви сусідніх [prefix_islands] навколо вас parameters: '' @@ -665,7 +678,7 @@ commands: the-end: not-allowed: 'Не можна встановлювати дім у Краю.' confirmation: 'Ви впевнені, що хочете встановити дім у Краю?' - parameters: '[home name]' + parameters: '[назва дому]' setname: description: встановити назву для вашого [prefix_island] name-too-short: 'Надто коротко. Мінімум [number] символів.' @@ -675,7 +688,7 @@ commands: success: 'Успішно встановлено назву вашого [prefix_island] на [name].' renamehome: description: перейменувати локацію дому - parameters: '[home name]' + parameters: '[назва дому]' enter-new-name: 'Введіть нову назву' already-exists: 'Така назва вже існує, спробуйте іншу.' resetname: @@ -737,7 +750,7 @@ commands: generic: '[rank] ([number]):' coop: description: надати гравцю ранг «співпраця» на вашому [prefix_island] - parameters: + parameters: <гравець> cannot-coop-yourself: 'Не можна коопити самого себе!' already-has-rank: 'У гравця вже є ранг!' you-are-a-coop-member: 'Вас коопнув [name].' @@ -748,7 +761,7 @@ commands: до свого [prefix_island]. uncoop: description: забрати в гравця ранг «співпраця» - parameters: + parameters: <гравець> cannot-uncoop-yourself: 'Не можна зняти кооп із себе!' cannot-uncoop-member: 'Не можна зняти кооп з учасника команди!' player-not-cooped: 'Гравець не у статусі кооп!' @@ -758,7 +771,7 @@ commands: is-full: 'Ви не можете коопити більше нікого.' trust: description: надати гравцю ранг «довірений» на вашому [prefix_island] - parameters: + parameters: <гравець> trust-in-yourself: 'Довіряйте собі!' name-has-invited-you: | [name] запросив(ла) вас @@ -770,7 +783,7 @@ commands: is-full: 'Ви більше нікого не можете зробити довіреним. Будьте пильні!' untrust: description: зняти статус «довірений» з гравця - parameters: + parameters: <гравець> cannot-untrust-yourself: 'Не можна зняти довіру з себе!' cannot-untrust-member: 'Не можна знімати довіру з учасника команди!' player-not-trusted: 'Гравець не є довіреним!' @@ -820,7 +833,7 @@ commands: already-on-team: 'Цей гравець уже в команді!' invalid-invite: 'Це запрошення вже недійсне, вибачте.' you-have-already-invited: 'Ви вже запросили цього гравця!' - parameters: + parameters: <гравець> you-can-invite: 'Ви можете запросити ще [number] гравців.' accept: description: прийняти запрошення @@ -843,14 +856,14 @@ commands: success: 'Ви покинули цей [prefix_island].' kick: description: видалити учасника з вашого [prefix_island] - parameters: + parameters: <гравець> player-kicked: '[name] вигнав(ла) вас із [prefix_island] у [gamemode]!' cannot-kick: 'Не можна вигнати самого себе!' cannot-kick-rank: 'Ваш ранг не дозволяє вигнати [name]!' success: '[name] вигнано з вашого [prefix_island].' demote: description: знизити ранг гравця на вашому [prefix_island] - parameters: + parameters: <гравець> errors: cant-demote-yourself: 'Не можна знизити ранг самому собі!' cant-demote: 'Не можна знижувати ранги, вищі за ваш!' @@ -859,7 +872,7 @@ commands: success: 'Знижено [name] до [rank]' promote: description: підвищити ранг гравця на вашому [prefix_island] - parameters: + parameters: <гравець> errors: cant-promote-yourself: 'Не можна підвищити самого себе!' cant-promote: 'Не можна підвищувати вище вашого рангу!' @@ -873,11 +886,11 @@ commands: target-is-not-member: 'Цей гравець не є частиною вашої команди [prefix_island]!' at-max: 'У цього гравця вже максимум дозволених [prefix_islands]!' name-is-the-owner: '[name] тепер власник [prefix_island]!' - parameters: + parameters: <гравець> you-are-the-owner: 'Тепер ви — власник [prefix_island]!' ban: description: заблокувати гравця на вашому [prefix_island] - parameters: + parameters: <гравець> cannot-ban-yourself: 'Не можна заблокувати самого себе!' cannot-ban: 'Цього гравця не можна заблокувати.' cannot-ban-member: 'Спершу виженіть учасника команди, потім блокуйте.' @@ -888,7 +901,7 @@ commands: you-are-banned: 'Ви заблоковані на цьому [prefix_island]!' unban: description: розблокувати гравця на вашому [prefix_island] - parameters: + parameters: <гравець> cannot-unban-yourself: 'Не можна розблокувати самого себе!' player-not-banned: 'Гравець не заблокований.' player-unbanned: '[name]тепер розблокований на вашому [prefix_island].' @@ -903,12 +916,12 @@ commands: description: показати налаштування [prefix_island] language: description: обрати мову - parameters: '[language]' + parameters: '[мова]' not-available: 'Ця мова недоступна.' already-selected: 'Ви вже використовуєте цю мову.' expel: description: вигнати гравця з вашого [prefix_island] - parameters: + parameters: <гравець> cannot-expel-yourself: 'Не можна вигнати самого себе!' cannot-expel: 'Цього гравця не можна вигнати.' cannot-expel-member: 'Не можна виганяти учасника команди!' @@ -1440,6 +1453,9 @@ protection: scooping: 'Перетворюю обсидіан назад у лаву. Будьте уважні наступного разу!' cooldown: 'Потрібно зачекати, перш ніж зачерпнути ще один блок обсидіану.' obsidian-nearby: 'Поруч є блоки обсидіану — не можна зачерпнути цей блок. ([radius])' + lavaTip: |- + Підбери це відром + знову як лаву, якщо вона тобі потрібна! OFFLINE_GROWTH: description: |- Якщо вимкнено, рослини @@ -1676,6 +1692,7 @@ protection: name: Світова шкода від ТНТ locked: 'Цей [prefix_island] заблоковано!' locked-island-bypass: 'Цей [prefix_island] заблоковано, але у вас є дозвіл на вхід.' + deletable-island-admin: '[Admin] Цей [prefix_island] позначено для видалення та очікує очищення регіону.' protected: '[prefix_Island] захищено: [description].' world-protected: 'Світ захищено: [description].' spawn-protected: 'Спавн захищено: [description].' @@ -1719,6 +1736,11 @@ protection: description: | Налаштування захисту, коли гравець поза межами свого [prefix_island] + ISLAND_DEFAULTS: + title: '[world_name] Налаштування острова за замовчуванням' + description: | + Налаштування захисту за замовчуванням + для нових [prefix_island]s flag-item: name-layout: '[name]' # Add commands to this list as required diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 27f3ce995..a3ac315f3 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -127,6 +127,16 @@ commands: parameters: '[days]' description: purge Quần đảo bằng cách xóa các tập tin khu vực cũ confirm: 'Loại /[label] purge regions confirm để bắt đầu thanh trừng' + failed: 'Dọn dẹp hoàn thành với lỗi. Một số tệp vùng không thể xóa. Kiểm tra nhật ký máy chủ để biết chi tiết.' + age-regions: + parameters: '[days]' + description: 'gỡ lỗi/kiểm tra: viết lại dấu thời gian của tệp vùng để chúng có thể được dọn dẹp' + done: 'Đã làm cũ [number] tệp vùng trong thế giới hiện tại.' + deleted: + parameters: '' + description: 'dọn dẹp các tệp vùng cho bất kỳ [prefix_island] nào đã được đánh dấu là đã xóa' + confirm: 'Nhập /[label] purge deleted confirm để xóa các tệp vùng' + deferred: 'Các tệp vùng đã được xóa. Các mục cơ sở dữ liệu đảo sẽ bị xóa khi khởi động lại máy chủ tiếp theo.' protect: description: bật bảo vệ đảo khỏi bị xóa move-to-island: 'Di chuyển về đảo trước!' @@ -138,6 +148,8 @@ commands: unowned: description: xóa đảo không chủ unowned-islands: 'Đã tìm thấy [number] đảo không chủ.' + parameters: '' + flagged: 'Đã đánh dấu [number] [prefix_island](s) là có thể xóa. Chạy /[label] purge deleted để xóa các tệp vùng.' status: description: xem trạng thái của quá trình xóa status: >- @@ -278,6 +290,7 @@ commands: protection-range-bonus-title: 'Bao gồm những phần thưởng này:' protection-range-bonus: 'Thưởng: [number]' purge-protected: Đảo đang được bảo vệ khỏi bị xóa + deletable: '[prefix_Island] được đánh dấu là cần xóa và đang chờ dọn dẹp vùng.' max-protection-range: 'Độ dài bảo vệ lớn nhất: [range]' protection-coords: 'Tọa độ bảo vệ: [xz1] to [xz2]' is-spawn: Đảo là điểm triệu hồi @@ -1503,6 +1516,9 @@ protection: scooping: 'Đang chuyển hắc diệm thạch thành nham thạch. Lần sau hãy cẩn thận!' cooldown: 'Bạn phải đợi trước khi có thể xúc thêm hắc diệm thạch.' obsidian-nearby: 'Có hắc diệm thạch xung quanh. Bạn không thể xúc thành nham thạch. ([radius])' + lavaTip: |- + Hãy múc nó bằng xô + thành dung nham một lần nữa nếu cần! OFFLINE_GROWTH: description: |- Khi tắt, cây trồng @@ -1738,6 +1754,7 @@ protection: name: Sát thương từ TNT ngoài đảo locked: 'Đảo đã khóa!' locked-island-bypass: '[prefix_Island] này đã bị khóa, nhưng bạn có quyền đi qua.' + deletable-island-admin: '[Admin] [prefix_island] này được đánh dấu là cần xóa và đang chờ dọn dẹp vùng.' protected: 'Đảo đã bảo vệ: [description].' world-protected: 'Thế giới đã bảo vệ: [description].' spawn-protected: 'Điểm triệu hồi đã bảo vệ: [description].' @@ -1780,6 +1797,11 @@ protection: description: | Tùy chỉnh bảo vệ khi người chơi ở ngoài đảo + ISLAND_DEFAULTS: + title: '[world_name] Mặc định đảo' + description: | + Cài đặt bảo vệ mặc định + cho các [prefix_island]s mới flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index c19b70174..0525ff352 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -114,6 +114,16 @@ commands: parameters: '[days]' description: 通过删除旧区域文件清除岛 confirm: '输入“/[label] purge region confirmed”开始清除' + failed: '清除完成但有错误。某些区域文件无法删除。请查看服务器日志以了解详情。' + age-regions: + parameters: '[days]' + description: '调试/测试:重写区域文件的时间戳,使其可被清除' + done: '已在当前世界中使 [number] 个区域文件过期。' + deleted: + parameters: '' + description: '清除已标记为删除的任意 [prefix_island] 的区域文件' + confirm: '输入 /[label] purge deleted confirm 以删除区域文件' + deferred: '区域文件已删除。岛屿数据库条目将在下次服务器重启时删除。' protect: description: 开/关 岛屿清理保护, 开启清理保护的岛屿不会被清理. move-to-island: '你身处的位置并没有岛屿, 请到要操作的岛上再试!' @@ -125,6 +135,8 @@ commands: unowned: description: 清理被遗弃(无主)的岛屿 unowned-islands: '发现[number]个被遗弃(无主)的岛屿.' + parameters: '' + flagged: '已将 [number] 个 [prefix_island](s) 标记为可删除。运行 /[label] purge deleted 以清除区域文件。' status: description: 显示清理状态 status: '共有[purgeable]个岛屿可清理, 已清理[purged]([percentage] %).' @@ -250,6 +262,7 @@ commands: protection-range-bonus-title: '包含以下奖励:' protection-range-bonus: '奖励: [number]' purge-protected: '岛屿处于清理保护状态' + deletable: '[prefix_Island] 已被标记为删除,正在等待区域清除。' max-protection-range: '历史最大保护范围: [range]' protection-coords: '岛屿保护界线: [xz1] 至 [xz2]' is-spawn: '为出生点岛屿' @@ -1390,6 +1403,9 @@ protection: scooping: '已将黑曜石变回熔岩.' cooldown: '你必须等待一段时间才能再次收集黑曜石。' obsidian-nearby: '附近有其它黑曜石, 这个黑曜石不能变回熔岩. ([radius])' + lavaTip: |- + 用桶舀起来 + 如果需要的话可以再次变成熔岩! OFFLINE_GROWTH: description: |- 允许禁止所有成员离线岛屿上的植物继续生长 @@ -1606,6 +1622,7 @@ protection: name: '边界TNT保护' locked: '这个岛屿已被锁定!' locked-island-bypass: '这个[prefix_island]已被锁定,但你有权限绕过。' + deletable-island-admin: '[Admin] 这个 [prefix_island] 已被标记为删除,正在等待区域清除。' protected: '岛屿保护: [description].' world-protected: '世界保护: [description].' spawn-protected: '出生点保护: [description].' @@ -1640,6 +1657,11 @@ protection: WORLD_DEFAULTS: title: '[world_name] 世界保护' description: '世界默认权限设置' + ISLAND_DEFAULTS: + title: '[world_name] 岛屿默认设置' + description: | + 默认保护设置 + 适用于新的 [prefix_island]s flag-item: name-layout: '[name]' command-instructions: diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index b1378681d..ec1085e11 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -115,6 +115,16 @@ commands: parameters: '[days]' description: 通過刪除舊區域文件清除島 confirm: '輸入 /[label] purge regions confirm 開始清除' + failed: '清除完成但有錯誤。部分區域檔案無法刪除。請查看伺服器日誌以了解詳情。' + age-regions: + parameters: '[days]' + description: '調試/測試:重寫區域檔案的時間戳記,使其可被清除' + done: '已在當前世界中使 [number] 個區域檔案過期。' + deleted: + parameters: '' + description: '清除已標記為刪除的任意 [prefix_island] 的區域檔案' + confirm: '輸入 /[label] purge deleted confirm 以刪除區域檔案' + deferred: '區域檔案已刪除。島嶼資料庫條目將在下次伺服器重啟時刪除。' protect: description: 開/關 島嶼清理保護, 開啟清理保護的島嶼不會被清理。 move-to-island: '請先回到島上再進行操作!' @@ -126,6 +136,8 @@ commands: unowned: description: 清理被遺棄(無主)的島嶼 unowned-islands: '找到 [number] 個被遺棄(無主)的島嶼。' + parameters: '' + flagged: '已將 [number] 個 [prefix_island](s) 標記為可刪除。執行 /[label] purge deleted 以清除區域檔案。' status: description: 顯示清理狀態 status: '共有 [purgeable]個 可清理,已清理 [purged]個 ([percentage] %)' @@ -251,6 +263,7 @@ commands: protection-range-bonus-title: '包括以下加成:' protection-range-bonus: '加成: [number]' purge-protected: 島嶼已被加入清理白名單 不會被自動清理 + deletable: '[prefix_Island] 已被標記為刪除,正在等待區域清除。' max-protection-range: '最大紀錄過的保護範圍: [range]' protection-coords: 保護邊界:[xz1] 至 [xz2] is-spawn: 此島嶼是初始島 @@ -1398,6 +1411,9 @@ protection: scooping: '已將黑曜石變回熔岩。 下次請小心!' cooldown: '你必須等待一段時間才能再次收集黑曜石。' obsidian-nearby: '附近有其它黑曜石, 這個黑曜石不能變回熔岩。 ([radius])' + lavaTip: |- + 用桶舀起來 + 如果需要的話可以再次變成熔岩! OFFLINE_GROWTH: description: |- 允許/禁止 所有成員都已離線 @@ -1608,6 +1624,7 @@ protection: name: '世界TNT傷害' locked: '本島嶼已被鎖定!' locked-island-bypass: '本[prefix_island]已被鎖定,但您有權限繞過。' + deletable-island-admin: '[Admin] 這個 [prefix_island] 已被標記為刪除,正在等待區域清除。' protected: '島嶼保護:[description]' world-protected: '世界保護: [description].' spawn-protected: '生成保護:[description]' @@ -1642,6 +1659,11 @@ protection: WORLD_DEFAULTS: title: '用於 [world_name] 的保護設定' description: '島嶼範圍外適用的保護設定' + ISLAND_DEFAULTS: + title: '[world_name] 島嶼預設設定' + description: | + 預設保護設定 + 適用於新的 [prefix_island]s flag-item: name-layout: '[name]' command-instructions: From 5514144cb8c531db11493b2dcf8fb0b0713cd522 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 20 Apr 2026 15:59:21 -0700 Subject: [PATCH 39/39] feat(locale): translate command parameter display strings in 17 locale files Translate player-facing parameter strings (e.g. [home name], [language], , [flag]) that are pure display text looked up via getTranslationOrNothing. Parsed keyword arguments ([list|add|remove], [air] [biome] [nowater]) are intentionally left in English as they are matched by Java command parsers. Co-Authored-By: Claude Sonnet 4.6 --- src/main/resources/locales/cs.yml | 6 +++--- src/main/resources/locales/de.yml | 2 +- src/main/resources/locales/es.yml | 10 +++++----- src/main/resources/locales/fr.yml | 2 +- src/main/resources/locales/hr.yml | 4 ++-- src/main/resources/locales/hu.yml | 2 +- src/main/resources/locales/id.yml | 6 ++---- src/main/resources/locales/it.yml | 10 +++++----- src/main/resources/locales/ja.yml | 4 ++-- src/main/resources/locales/ko.yml | 6 +++--- src/main/resources/locales/nl.yml | 2 +- src/main/resources/locales/pl.yml | 4 ++-- src/main/resources/locales/tr.yml | 4 ++-- src/main/resources/locales/uk.yml | 2 +- src/main/resources/locales/vi.yml | 2 +- src/main/resources/locales/zh-CN.yml | 4 +--- src/main/resources/locales/zh-HK.yml | 6 ++---- 17 files changed, 35 insertions(+), 41 deletions(-) diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 5b3399f43..47bc41195 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -306,7 +306,7 @@ commands: trash: no-unowned-in-trash: 'V koši nejsou žádné nevlastněné ostrovy' no-islands-in-trash: 'Hráč nemá žádný ostrov v koši' - parameters: '[player]' + parameters: '[hráč]' description: ukázat nevlastněné nebo hráčské ostrovy v koši title: '=========== Ostrovy v koši ===========' count: 'Ostrov [number]:' @@ -317,7 +317,7 @@ commands: Použij [label] emptytrash [player] k trvalému smazání položek emptytrash: - parameters: '[player]' + parameters: '[hráč]' description: Vysypat koš hráče, nebo všech nevlastněných ostrovů success: 'Koš byl úspěšně vysypán.' version: @@ -530,7 +530,7 @@ commands: success: Úspěch! cancelling: Rušení resetflags: - parameters: '[flag]' + parameters: '[příznak]' description: Obnov vlaječky všech ostrovů na výchozí nastavení v souboru config.yml confirm: 'Toto obnoví vlaječku/-y všech ostrovů na výchozí hodnotu!' success: 'Výchozí hodnoty vlaječek všech ostrovů úspěšně obnoveny.' diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 217db4720..63608de54 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -558,7 +558,7 @@ commands: success: Erfolg! cancelling: Abbrechen resetflags: - parameters: '[flag]' + parameters: '[Flagge]' description: >- Alle Inseln in der config.yml auf Standard-Flag-Einstellungen zurücksetzen diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 667f09cde..e27153761 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -316,7 +316,7 @@ commands: trash: no-unowned-in-trash: 'No hay islas sin propietario en la basura' no-islands-in-trash: 'El jugador no tiene islas en la basura' - parameters: '[player]' + parameters: '[jugador]' description: Mostrar islas sin propietarios o islas de jugadores en la basura title: '=========== Islas en la Basura ===========' count: 'Isla [number]:' @@ -327,7 +327,7 @@ commands: Usa [label] emptytrash [player]para eliminar de forma permanente los elementos de la papelera emptytrash: - parameters: '[player]' + parameters: '[jugador]' description: Borrar basura para el jugador, o todas las islas sin dueño en la basura success: 'La basura se vació con éxito.' version: @@ -351,7 +351,7 @@ commands: No se ha encontrado un warp seguro! Manualmente teletransportado cerca de [location] puedes echarle un vistazo tpuser: - parameters: <[prefix_island]'s player> [player's island] + parameters: ' [isla del jugador]' description: teletransportar a un jugador a la [prefix_island] de otro jugador getrank: parameters: @@ -724,7 +724,7 @@ commands: you-can-teleport-to-your-island: 'Puedes teletransportarte a tu isla cuando lo desees.' deletehome: description: eliminar una ubicación de hogar - parameters: '[home name]' + parameters: '[nombre de casa]' homes: description: lista tus hogares info: @@ -782,7 +782,7 @@ commands: success: 'Establezca con éxito el nombre de su isla en [name] .' renamehome: description: renombrar una ubicación de hogar - parameters: '[home name]' + parameters: '[nombre de casa]' enter-new-name: 'Introduce el nuevo nombre' already-exists: 'Ese nombre ya existe, prueba con otro nombre.' resetname: diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index d5e99a1c6..a748aa678 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -572,7 +572,7 @@ commands: success: Succès ! cancelling: Annulation resetflags: - parameters: '[flag]' + parameters: '[indicateur]' description: >- Réinitialiser toutes les îles aux flags par défaut dans le fichier config.yml diff --git a/src/main/resources/locales/hr.yml b/src/main/resources/locales/hr.yml index 65c998dd9..b10a46dd1 100644 --- a/src/main/resources/locales/hr.yml +++ b/src/main/resources/locales/hr.yml @@ -314,7 +314,7 @@ commands: trash: no-unowned-in-trash: 'Nema otoka bez vlasnika u smeću' no-islands-in-trash: 'Igrač nema otoka u smeću' - parameters: '[player]' + parameters: '[igrač]' description: prikaži otoke koji nisu u vlasništvu ili igračeve otoke u smeću title: '=========== Otoci u smeću ===========' count: 'otok [number]:' @@ -325,7 +325,7 @@ commands: Koristite [label] emptytrash [player]za trajno uklanjanje stavki iz smeća emptytrash: - parameters: '[player]' + parameters: '[igrač]' description: Očisti smeće za igrača ili sve otoke koji nisu u vlasništvu u smeće success: 'Otpad je uspješno ispražnjen.' version: diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index d82f7e74a..77f15df6c 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -419,7 +419,7 @@ commands: success: 'Sikeresen beállította ezt a helyet a [prefix_island] spawn pontjaként.' island-spawnpoint-changed: '[user] megváltoztatta a [prefix_island] spawn pontot.' settings: - parameters: '[player]/[world flag]/spawn-island [flag/active/disable] [rank/active/disable]' + parameters: '[játékos]/[világ jelző]/spawn-island [jelző/aktív/letiltás] [rang/aktív/letiltás]' description: nyissa meg a beállítások GUI-ját vagy állítsa be a beállításokat unknown-setting: 'Ismeretlen beállítás' blueprint: diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index 25c13eb7b..f8f638127 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -404,9 +404,7 @@ commands: success: 'Berhasil menetapkan lokasi ini sebagai titik pemijahan pulau ini.' island-spawnpoint-changed: '[user] mengubah titik spawn pulau.' settings: - parameters: >- - [player]/[world flag]/spawn-island [flag/active/disable] - [rank/active/disable] + parameters: '[pemain]/[bendera dunia]/spawn-island [bendera/aktif/nonaktif] [peringkat/aktif/nonaktif]' description: buka pengaturan GUI atau atur pengaturan unknown-setting: 'Pengaturan tidak diketahui' blueprint: @@ -1013,7 +1011,7 @@ commands: description: menampilkan pengaturan pulau language: description: pilih bahasa - parameters: '[language]' + parameters: '[bahasa]' not-available: 'Bahasa ini tidak tersedia.' already-selected: 'Anda sudah menggunakan bahasa ini.' expel: diff --git a/src/main/resources/locales/it.yml b/src/main/resources/locales/it.yml index 8ea6d7617..636b19855 100644 --- a/src/main/resources/locales/it.yml +++ b/src/main/resources/locales/it.yml @@ -327,7 +327,7 @@ commands: trash: no-unowned-in-trash: 'Non ci sono isole senza proprietario nel cestino' no-islands-in-trash: 'Il giocatore non ha isole nel cestino' - parameters: '[player]' + parameters: '[giocatore]' description: mostra le isole senza proprietario o le isole del giocatore nel cestino title: '=========== Isole nel Cestino ===========' count: 'Isola [number]:' @@ -338,7 +338,7 @@ commands: Usa [label] emptytrash [player] per rimuovere permanentemente le isole nel cestino emptytrash: - parameters: '[player]' + parameters: '[giocatore]' description: >- Pulisci il cestino del player, o tutte le isole senza proprietario nel cestino @@ -565,7 +565,7 @@ commands: success: Successo! cancelling: Annullamento resetflags: - parameters: '[flag]' + parameters: '[bandiera]' description: Reimposta le impostazioni di tutte le isole a quelle in config.yml confirm: 'Questo resetterà tutte le flag di tutte le isole ai valori default!' success: 'Resettato con successo le flag di tutte le isole ai valori default.' @@ -736,7 +736,7 @@ commands: you-can-teleport-to-your-island: 'Puoi teletrasportarti alla isola quando puoi.' deletehome: description: cancellare una posizione casa - parameters: '[home name]' + parameters: '[nome casa]' homes: description: elenca le tue info: @@ -796,7 +796,7 @@ commands: success: 'Cambiato il nome della tua isola in [name].' renamehome: description: rinominare una posizione di casa - parameters: '[home name]' + parameters: '[nome casa]' enter-new-name: 'Inserisci il nuovo nome' already-exists: 'Quel nome esiste già, prova con un altro nome.' resetname: diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 75f32616e..ec4a229f1 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -307,7 +307,7 @@ commands: description: プレイヤーの島にテレポート manual: '安全なワープが見つかりません![location]の近くに手動でテレポートします。' tpuser: - parameters: <[prefix_island]'s player> [player's island] + parameters: '<テレポートするプレーヤー> <[prefix_island]のプレーヤー> [プレーヤーの島]' description: プレーヤーを別のプレイヤーの[prefix_island]にテレポートする getrank: parameters: <プレーヤー> @@ -482,7 +482,7 @@ commands: success: 成功! cancelling: キャンセル中 resetflags: - parameters: '[flag]' + parameters: '[フラグ]' description: すべての島をconfig.ymlのデフォルトのフラグ設定にリセットします confirm: 'これにより、すべての島のフラグがデフォルトにリセットされます!' success: 'すべての島のフラグをデフォルト設定に正常にリセットします。' diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index 35314fc41..a62831e45 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -295,14 +295,14 @@ commands: trash: no-unowned-in-trash: '주인없는섬이 쓰레기통에 없습니다' no-islands-in-trash: '플레이어는 쓰레기통에 섬이 없습니다' - parameters: '[player]' + parameters: '[플레이어]' description: 주인 없는 섬이나 플레이어의 쓰레기통에있는 섬을 보여줍니다 title: '=========== 쓰레기통에 있는 섬 ===========' count: '섬 [number]:' use-switch: '[label] switchto 를 사용하여 쓰레기통에 있는 섬으로 교체합니다.' use-emptytrash: '[label] emptytrash [player]를 사용하여 쓰레기통의 섬들을 삭제합니다' emptytrash: - parameters: '[player]' + parameters: '[플레이어]' description: 쓰레기통에 있는 섬을 청소하거나, 쓰레기통에 있는 모든 주인없는 섬을 청소합니다 success: '쓰레기통을 비웠습니다' version: @@ -498,7 +498,7 @@ commands: success: 성공! cancelling: 취소 중 resetflags: - parameters: '[flag]' + parameters: '[플래그]' description: 컨피그의 모든 플래그를 초기화 합니다 confirm: '모든 섬에 대해 플래그를 기본값으로 재설정합니다!' success: '모든섬의 플래그를 기본값으로 초기화 하였습니다' diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index eedc16cf4..74a5d9b35 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -369,7 +369,7 @@ commands: Geen veilige warp gevonden! Handmatig tp in de buurt van [location] en bekijk het tpuser: - parameters: <[prefix_island]'s player> [player's island] + parameters: ' [eiland van speler]' description: teleporteer een speler naar het [prefix_island] van een andere speler getrank: parameters: [eilandeigenaar] diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 4ef6478e2..9baa0f716 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -356,7 +356,7 @@ commands: Nie znaleziono bezpiecznego warpu. Teleportuj się ręcznie do lokalizacji [location] i sprawdź go. tpuser: - parameters: <[prefix_island]'s player> [player's island] + parameters: ' [wyspa gracza]' description: teleportuj gracza na [prefix_island] innego gracza getrank: parameters: @@ -1001,7 +1001,7 @@ commands: description: wyświetl ustawienia wyspy language: description: wybierz język - parameters: '[language]' + parameters: '[język]' not-available: 'Ten język nie jest dostępny.' already-selected: 'Już używasz tego języka.' expel: diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index f0e45f113..b59aa889c 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -317,7 +317,7 @@ commands: trash: no-unowned-in-trash: 'Çöpte sahipsiz ada yok.' no-islands-in-trash: 'Oyuncunun çöpte adası yok.' - parameters: '[player]' + parameters: '[oyuncu]' description: Çöp kutusundaki sahipsiz veya oyuncuların adını gösterir. title: '=========== Çöpteki adalar ===========' count: 'Ada [number]:' @@ -328,7 +328,7 @@ commands: [label] kullanarak [player] tüm eşyalarını sonsuza kadar silebilirsiniz. emptytrash: - parameters: '[player]' + parameters: '[oyuncu]' description: Oyuncu için çöpleri veya çöp kutusundaki tüm sahipsiz adaları temizleyin success: 'Çöp kutusu başarıyla boşaltıldı.' version: diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index ad09718f7..1bbad8cc9 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -489,7 +489,7 @@ commands: success: Успіх! cancelling: Скасування resetflags: - parameters: '[flag]' + parameters: '[прапорець]' description: Скинути всі [prefix_Islands] до типових прапорів у config.yml confirm: 'Це скине прапори до типових для всіх [prefix_Islands]!' success: 'Успішно скинуто прапори всіх [prefix_Islands] до типових налаштувань.' diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index a3ac315f3..37d339210 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -534,7 +534,7 @@ commands: success: Thành công! cancelling: Đang hủy resetflags: - parameters: '[flag]' + parameters: '[cờ]' description: Đặt lại tất cả các đảo về cài đặt cờ mặc định trong config.yml confirm: 'Điều này sẽ đặt lại cờ về mặc định cho tất cả các hòn đảo!' success: >- diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 0525ff352..ca2a06f1d 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -351,9 +351,7 @@ commands: success: '成功将当前位置设置为该岛屿出生点.' island-spawnpoint-changed: '[user]更改了岛屿出生点.' settings: - parameters: >- - [player]/[world flag]/spawn-island [flag/active/disable] - [rank/active/disable] + parameters: '[玩家]/[世界标志]/spawn-island [标志/启用/禁用] [等级/启用/禁用]' description: 打开设置面板或使用命令调整岛屿设置 unknown-setting: '未知设置' blueprint: diff --git a/src/main/resources/locales/zh-HK.yml b/src/main/resources/locales/zh-HK.yml index ec1085e11..540b86f3c 100644 --- a/src/main/resources/locales/zh-HK.yml +++ b/src/main/resources/locales/zh-HK.yml @@ -316,7 +316,7 @@ commands: description: 傳送到玩家的島嶼 manual: '沒有安全傳送點!手動傳送至 [location] 附近並解決這個問題' tpuser: - parameters: <[prefix_island]'s player> [player's island] + parameters: '<傳送玩家> <[prefix_island]的玩家> [玩家的島嶼]' description: 將一個玩家傳送到另一個玩家的[prefix_island] getrank: parameters: [island owner] @@ -352,9 +352,7 @@ commands: success: '成功將所處位置設置為該島嶼的出生點。' island-spawnpoint-changed: '[user] 更改了島嶼出生點。' settings: - parameters: >- - [player]/[world flag]/spawn-island [flag/active/disable] - [rank/active/disable] + parameters: '[玩家]/[世界旗標]/spawn-island [旗標/啟用/停用] [等級/啟用/停用]' description: 打開設置面板或使用命令調整島嶼設置 unknown-setting: '未知設置項' blueprint: