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 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 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 diff --git a/src/main/java/world/bentobox/bentobox/BentoBox.java b/src/main/java/world/bentobox/bentobox/BentoBox.java index b97654e6c..5ade339ba 100644 --- a/src/main/java/world/bentobox/bentobox/BentoBox.java +++ b/src/main/java/world/bentobox/bentobox/BentoBox.java @@ -32,7 +32,9 @@ import world.bentobox.bentobox.managers.FlagsManager; import world.bentobox.bentobox.managers.HooksManager; import world.bentobox.bentobox.managers.ChunkPregenManager; +import world.bentobox.bentobox.managers.HousekeepingManager; import world.bentobox.bentobox.managers.IslandDeletionManager; +import world.bentobox.bentobox.managers.PurgeRegionsService; import world.bentobox.bentobox.managers.IslandWorldManager; import world.bentobox.bentobox.managers.IslandsManager; import world.bentobox.bentobox.managers.LocalesManager; @@ -72,6 +74,8 @@ public class BentoBox extends JavaPlugin implements Listener { private MapManager mapManager; private IslandDeletionManager islandDeletionManager; private ChunkPregenManager chunkPregenManager; + private PurgeRegionsService purgeRegionsService; + private HousekeepingManager housekeepingManager; private WebManager webManager; // Settings @@ -143,6 +147,9 @@ public void onEnable(){ } islandsManager = new IslandsManager(this); + // Shared purge-regions logic (command + housekeeping) + purgeRegionsService = new PurgeRegionsService(this); + // Start head getter headGetter = new HeadGetter(this); @@ -233,6 +240,10 @@ private void completeSetup(long loadTime) { webManager = new WebManager(this); + // Housekeeping: auto-purge of unused region files (default OFF) + housekeepingManager = new HousekeepingManager(this); + housekeepingManager.start(); + final long enableTime = System.currentTimeMillis() - enableStart; // Show banner @@ -308,6 +319,15 @@ public void onDisable() { if (chunkPregenManager != null) { chunkPregenManager.shutdown(); } + // Flush deferred island deletions from the deleted-sweep purge. + // Paper's internal chunk cache is cleared on shutdown, so the stale + // in-memory chunks are guaranteed gone at this point. + if (purgeRegionsService != null) { + purgeRegionsService.flushPendingDeletions(); + } + if (housekeepingManager != null) { + housekeepingManager.stop(); + } } @@ -560,12 +580,29 @@ public IslandDeletionManager getIslandDeletionManager() { /** * @return the chunkPregenManager - * @since 3.14.0 + * @since 3.15.0 */ public ChunkPregenManager getChunkPregenManager() { return chunkPregenManager; } + /** + * @return the shared {@link PurgeRegionsService} used by the purge + * regions command and the housekeeping scheduler. + * @since 3.15.0 + */ + public PurgeRegionsService getPurgeRegionsService() { + return purgeRegionsService; + } + + /** + * @return the {@link HousekeepingManager}, or {@code null} if not yet initialized. + * @since 3.15.0 + */ + public HousekeepingManager getHousekeepingManager() { + return housekeepingManager; + } + /** * @return an optional of the Bstats instance * @since 1.1 diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 3bdb5eba6..492f5d312 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -319,27 +319,53 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "island.delete-speed", since = "1.7.0") private int deleteSpeed = 1; - // Island deletion related settings - @ConfigComment("Toggles whether islands, when players are resetting them, should be kept in the world or deleted.") - @ConfigComment("* If set to 'true', whenever a player resets his island, his previous island will become unowned and won't be deleted from the world.") - @ConfigComment(" You can, however, still delete those unowned islands through purging.") - @ConfigComment(" On bigger servers, this can lead to an increasing world size.") - @ConfigComment(" Yet, this allows admins to retrieve a player's old island in case of an improper use of the reset command.") - @ConfigComment(" Admins can indeed re-add the player to his old island by registering him to it.") - @ConfigComment("* If set to 'false', whenever a player resets his island, his previous island will be deleted from the world.") - @ConfigComment(" This is the default behaviour.") - @ConfigEntry(path = "island.deletion.keep-previous-island-on-reset", since = "1.13.0") - private boolean keepPreviousIslandOnReset = false; - - @ConfigComment("Toggles how the islands are deleted.") - @ConfigComment("* If set to 'false', all islands will be deleted at once.") - @ConfigComment(" This is fast but may cause an impact on the performance") - @ConfigComment(" as it'll load all the chunks of the in-deletion islands.") - @ConfigComment("* If set to 'true', the islands will be deleted one by one.") - @ConfigComment(" This is slower but will not cause any impact on the performance.") - @ConfigEntry(path = "island.deletion.slow-deletion", since = "1.19.1") + + /** + * @deprecated No longer bound to config. The chunk-by-chunk deletion + * pipeline has been removed. Slated for removal. + */ + @Deprecated(since = "3.14.0", forRemoval = true) private boolean slowDeletion = false; + // Island deletion housekeeping + @ConfigComment("Housekeeping: periodic auto-purge of unused region files.") + @ConfigComment("When a player resets their island, the old island is orphaned (marked") + @ConfigComment("deletable) and its region files are reclaimed later by a scheduled purge.") + @ConfigComment("") + @ConfigComment("WARNING: housekeeping deletes .mca region files from disk. It uses the same") + @ConfigComment("protections as the /bbox purge regions command (online players, island level,") + @ConfigComment("purge-protected flag, spawn islands, and unowned-but-not-deletable islands are") + @ConfigComment("all skipped) but is destructive by design.") + @ConfigComment("") + @ConfigComment("--- Deleted-island sweep (safe, on by default) ---") + @ConfigComment("Reaps region files for islands explicitly marked deletable (e.g. /is reset).") + @ConfigComment("Only removes islands that BentoBox itself soft-deleted — never touches") + @ConfigComment("active or unvisited islands. Enable this and leave age-sweep off for a") + @ConfigComment("'just works' experience that reclaims disk space from reset islands.") + @ConfigEntry(path = "island.deletion.housekeeping.deleted-sweep.enabled", since = "3.15.0") + private boolean housekeepingDeletedEnabled = true; + + @ConfigComment("How often the deleted-island sweep runs, in hours.") + @ConfigEntry(path = "island.deletion.housekeeping.deleted-sweep.interval-hours", since = "3.15.0") + private int housekeepingDeletedIntervalHours = 24; + + @ConfigComment("") + @ConfigComment("--- Age-based sweep (opt-in, more aggressive) ---") + @ConfigComment("Reaps region files whose .mca files are older than min-age-days, regardless") + @ConfigComment("of whether those islands are marked deletable. Can remove islands that were") + @ConfigComment("never reset but simply abandoned. Disabled by default — enable only if you") + @ConfigComment("want automatic reclamation of all old, unvisited islands.") + @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.enabled", since = "3.15.0") + private boolean housekeepingAgeEnabled = false; + + @ConfigComment("How often the age-based sweep runs, in days.") + @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.interval-days", since = "3.15.0") + private int housekeepingIntervalDays = 30; + + @ConfigComment("Minimum age (in days) of region files eligible for the age-based purge.") + @ConfigEntry(path = "island.deletion.housekeeping.age-sweep.min-age-days", since = "3.15.0") + private int housekeepingRegionAgeDays = 60; + // Chunk pre-generation settings @ConfigComment("") @ConfigComment("Chunk pre-generation settings.") @@ -826,28 +852,6 @@ public void setDatabasePrefix(String databasePrefix) { this.databasePrefix = databasePrefix; } - /** - * Returns whether islands, when reset, should be kept or deleted. - * - * @return {@code true} if islands, when reset, should be kept; {@code false} - * otherwise. - * @since 1.13.0 - */ - public boolean isKeepPreviousIslandOnReset() { - return keepPreviousIslandOnReset; - } - - /** - * Sets whether islands, when reset, should be kept or deleted. - * - * @param keepPreviousIslandOnReset {@code true} if islands, when reset, should - * be kept; {@code false} otherwise. - * @since 1.13.0 - */ - public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) { - this.keepPreviousIslandOnReset = keepPreviousIslandOnReset; - } - /** * Returns a MongoDB client connection URI to override default connection * options. @@ -1014,7 +1018,11 @@ public void setSafeSpotSearchVerticalRange(int safeSpotSearchVerticalRange) { * Is slow deletion boolean. * * @return the boolean + * @deprecated The chunk-by-chunk deletion pipeline is being removed. This + * setting no longer has any effect and will be deleted in a + * future release. Configure the housekeeping auto-purge instead. */ + @Deprecated(since = "3.14.0", forRemoval = true) public boolean isSlowDeletion() { return slowDeletion; } @@ -1023,14 +1031,128 @@ public boolean isSlowDeletion() { * Sets slow deletion. * * @param slowDeletion the slow deletion + * @deprecated See {@link #isSlowDeletion()}. */ + @Deprecated(since = "3.14.0", forRemoval = true) public void setSlowDeletion(boolean slowDeletion) { this.slowDeletion = slowDeletion; } + /** + * Returns whether islands, when reset, should be kept or deleted. + * + * @return always {@code false} — islands are now soft-deleted on reset and + * their region files reaped asynchronously by the housekeeping purge. + * @since 1.13.0 + * @deprecated No longer configurable. Islands are soft-deleted on reset. + * This method always returns {@code false} and will be removed + * in a future release. + */ + @Deprecated(since = "3.15.0", forRemoval = true) + public boolean isKeepPreviousIslandOnReset() { + return false; + } + + /** + * No-op — the keep-previous-island setting is no longer configurable. + * + * @param keepPreviousIslandOnReset ignored + * @since 1.13.0 + * @deprecated No longer configurable. Islands are soft-deleted on reset. + */ + @Deprecated(since = "3.15.0", forRemoval = true) + public void setKeepPreviousIslandOnReset(boolean keepPreviousIslandOnReset) { + // no-op + } + + /** + * @return whether the deleted-island sweep is enabled (reaps regions for + * islands explicitly flagged deletable, e.g. from {@code /is reset}). + * @since 3.15.0 + */ + public boolean isHousekeepingDeletedEnabled() { + return housekeepingDeletedEnabled; + } + + /** + * @param housekeepingDeletedEnabled whether the deleted-island sweep is enabled. + * @since 3.15.0 + */ + public void setHousekeepingDeletedEnabled(boolean housekeepingDeletedEnabled) { + this.housekeepingDeletedEnabled = housekeepingDeletedEnabled; + } + + /** + * @return whether the age-based sweep is enabled (reaps regions for islands + * whose .mca files are older than {@link #getHousekeepingRegionAgeDays()}). + * @since 3.15.0 + */ + public boolean isHousekeepingAgeEnabled() { + return housekeepingAgeEnabled; + } + + /** + * @param housekeepingAgeEnabled whether the age-based sweep is enabled. + * @since 3.15.0 + */ + public void setHousekeepingAgeEnabled(boolean housekeepingAgeEnabled) { + this.housekeepingAgeEnabled = housekeepingAgeEnabled; + } + + /** + * @return how often the age-based sweep runs, in days. + * @since 3.15.0 + */ + public int getHousekeepingIntervalDays() { + return housekeepingIntervalDays; + } + + /** + * @param housekeepingIntervalDays how often the age-based sweep runs, in days. + * @since 3.15.0 + */ + public void setHousekeepingIntervalDays(int housekeepingIntervalDays) { + this.housekeepingIntervalDays = housekeepingIntervalDays; + } + + /** + * @return minimum age (in days) of region files eligible for the age-based purge. + * @since 3.15.0 + */ + public int getHousekeepingRegionAgeDays() { + return housekeepingRegionAgeDays; + } + + /** + * @param housekeepingRegionAgeDays minimum age (in days) of region files + * considered for auto-purge. + * @since 3.15.0 + */ + public void setHousekeepingRegionAgeDays(int housekeepingRegionAgeDays) { + this.housekeepingRegionAgeDays = housekeepingRegionAgeDays; + } + + /** + * @return how often the deleted-islands sweep runs, in hours. {@code 0} + * disables the deleted sweep. + * @since 3.15.0 + */ + public int getHousekeepingDeletedIntervalHours() { + return housekeepingDeletedIntervalHours; + } + + /** + * @param housekeepingDeletedIntervalHours how often the deleted sweep runs + * in hours. {@code 0} disables it. + * @since 3.15.0 + */ + public void setHousekeepingDeletedIntervalHours(int housekeepingDeletedIntervalHours) { + this.housekeepingDeletedIntervalHours = housekeepingDeletedIntervalHours; + } + /** * @return whether chunk pre-generation is enabled - * @since 3.14.0 + * @since 3.15.0 */ public boolean isPregenEnabled() { return pregenEnabled; @@ -1038,7 +1160,7 @@ public boolean isPregenEnabled() { /** * @param pregenEnabled whether chunk pre-generation is enabled - * @since 3.14.0 + * @since 3.15.0 */ public void setPregenEnabled(boolean pregenEnabled) { this.pregenEnabled = pregenEnabled; @@ -1046,7 +1168,7 @@ public void setPregenEnabled(boolean pregenEnabled) { /** * @return number of future islands to pre-generate per game mode - * @since 3.14.0 + * @since 3.15.0 */ public int getPregenIslandsAhead() { return pregenIslandsAhead; @@ -1054,7 +1176,7 @@ public int getPregenIslandsAhead() { /** * @param pregenIslandsAhead number of future islands to pre-generate - * @since 3.14.0 + * @since 3.15.0 */ public void setPregenIslandsAhead(int pregenIslandsAhead) { this.pregenIslandsAhead = pregenIslandsAhead; @@ -1062,7 +1184,7 @@ public void setPregenIslandsAhead(int pregenIslandsAhead) { /** * @return max async chunk generation requests per tick batch - * @since 3.14.0 + * @since 3.15.0 */ public int getPregenChunksPerTick() { return pregenChunksPerTick; @@ -1070,7 +1192,7 @@ public int getPregenChunksPerTick() { /** * @param pregenChunksPerTick max async chunk requests per tick - * @since 3.14.0 + * @since 3.15.0 */ public void setPregenChunksPerTick(int pregenChunksPerTick) { this.pregenChunksPerTick = pregenChunksPerTick; @@ -1078,7 +1200,7 @@ public void setPregenChunksPerTick(int pregenChunksPerTick) { /** * @return ticks between pre-generation batches - * @since 3.14.0 + * @since 3.15.0 */ public int getPregenTickInterval() { return pregenTickInterval; @@ -1086,7 +1208,7 @@ public int getPregenTickInterval() { /** * @param pregenTickInterval ticks between batches - * @since 3.14.0 + * @since 3.15.0 */ public void setPregenTickInterval(int pregenTickInterval) { this.pregenTickInterval = pregenTickInterval; @@ -1255,7 +1377,7 @@ public void setObsidianScoopingCooldown(int obsidianScoopingCooldown) { * newly formed obsidian blocks that can be scooped. * * @return the lava tip duration in seconds; 0 or less means disabled - * @since 3.14.0 + * @since 3.15.0 */ public int getObsidianScoopingLavaTipDuration() { return obsidianScoopingLavaTipDuration; @@ -1266,7 +1388,7 @@ public int getObsidianScoopingLavaTipDuration() { * newly formed obsidian blocks that can be scooped. * * @param obsidianScoopingLavaTipDuration the duration in seconds; 0 or less disables - * @since 3.14.0 + * @since 3.15.0 */ public void setObsidianScoopingLavaTipDuration(int obsidianScoopingLavaTipDuration) { this.obsidianScoopingLavaTipDuration = obsidianScoopingLavaTipDuration; diff --git a/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java b/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java index 4c239313f..b4c21cd42 100644 --- a/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java +++ b/src/main/java/world/bentobox/bentobox/api/addons/GameModeAddon.java @@ -211,7 +211,7 @@ public boolean isEnforceEqualRanges() { * * * @return number of islands ahead, -1 for global default, 0 to disable - * @since 3.14.0 + * @since 3.15.0 */ public int getPregenIslandsAhead() { return -1; diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java index 3a990ee41..d4acd4228 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/AdminDeleteCommand.java @@ -8,6 +8,7 @@ import org.eclipse.jdt.annotation.Nullable; +import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.commands.ConfirmableCommand; import world.bentobox.bentobox.api.commands.island.IslandGoCommand; @@ -17,6 +18,8 @@ import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.database.objects.IslandDeletion; +import world.bentobox.bentobox.util.DeleteIslandChunks; import world.bentobox.bentobox.util.Util; public class AdminDeleteCommand extends ConfirmableCommand { @@ -112,8 +115,30 @@ private void deleteIsland(User user, Island oldIsland) { .oldIsland(oldIsland).location(oldIsland.getCenter()).build(); user.sendMessage("commands.admin.delete.deleted-island", TextVariables.XYZ, Util.xyz(oldIsland.getCenter().toVector())); - getIslands().deleteIsland(oldIsland, true, targetUUID); + // Branch on how the gamemode generates its chunks. + // + // - New chunk generation (e.g. Boxed): chunks are expensive to + // recreate, so the island is soft-deleted (marked deletable, + // left in place) and PurgeRegionsService / HousekeepingManager + // reaps the region files and DB row later on its schedule. + // + // - Simple/void generation: chunks are cheap — hard-delete the + // island first so the cancellable delete path can veto the + // operation before any chunk work starts, then repaint them via + // the addon's own ChunkGenerator using the existing + // DeleteIslandChunks + WorldRegenerator.regenerateSimple path. + // + // If we can't resolve the gamemode, default to soft-delete. + GameModeAddon gm = getIWM().getAddon(getWorld()).orElse(null); + if (gm != null && !gm.isUsesNewChunkGeneration()) { + getIslands().hardDeleteIsland(oldIsland); + // DeleteIslandChunks snapshots the island bounds from oldIsland, + // so it can safely run after the row has been hard-deleted. + new DeleteIslandChunks(getPlugin(), new IslandDeletion(oldIsland)); + } else { + getIslands().deleteIsland(oldIsland, true, targetUUID); + } } private void deletePlayer(User user) { diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java new file mode 100644 index 000000000..d93fd705b --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AbstractPurgeCommand.java @@ -0,0 +1,137 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.bukkit.Bukkit; +import org.bukkit.World; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; + +/** + * Shared scaffolding for purge subcommands that scan for region files and + * (on confirmation) reap them via {@link world.bentobox.bentobox.managers.PurgeRegionsService}. + * + *

Subclasses supply the scan source (age-filtered or deletable-only), + * confirmation prompt, success message and any pre-delete side-effects via + * the abstract / overridable hooks. + */ +abstract class AbstractPurgeCommand extends CompositeCommand { + + protected static final String NONE_FOUND = "commands.admin.purge.none-found"; + + protected volatile boolean inPurge; + protected boolean toBeConfirmed; + protected User user; + protected PurgeScanResult lastScan; + + protected AbstractPurgeCommand(CompositeCommand parent, String label) { + super(parent, label); + } + + @Override + public boolean canExecute(User user, String label, java.util.List args) { + if (inPurge) { + user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); + return false; + } + return true; + } + + /** + * Saves all worlds, runs the scan on an async thread, stores the result + * in {@link #lastScan} and prompts for confirmation if non-empty. + */ + protected final void runScanAndPrompt(Supplier scanFn) { + user.sendMessage("commands.admin.purge.scanning"); + getPlugin().log(logPrefix() + ": saving all worlds before scanning..."); + Bukkit.getWorlds().forEach(World::save); + getPlugin().log(logPrefix() + ": world save complete"); + + inPurge = true; + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + try { + lastScan = scanFn.get(); + displayResultsAndPrompt(lastScan); + } finally { + inPurge = false; + } + }); + } + + /** + * Confirm path: save worlds, run any pre-delete hook, then dispatch the + * region-file deletion asynchronously. + */ + protected final boolean deleteEverything() { + if (lastScan == null || lastScan.isEmpty()) { + user.sendMessage(NONE_FOUND); + return false; + } + PurgeScanResult scan = lastScan; + lastScan = null; + toBeConfirmed = false; + getPlugin().log(logPrefix() + ": saving all worlds before deleting region files..."); + Bukkit.getWorlds().forEach(World::save); + beforeDelete(scan); + getPlugin().log(logPrefix() + ": world save complete, dispatching deletion"); + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + boolean ok = getPlugin().getPurgeRegionsService().delete(scan); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + if (ok) { + user.sendMessage(successMessageKey()); + } else { + getPlugin().log(logPrefix() + ": failed to delete one or more region files"); + user.sendMessage("commands.admin.purge.failed"); + } + }); + }); + return true; + } + + private void displayResultsAndPrompt(PurgeScanResult scan) { + Set uniqueIslands = scan.deletableRegions().values().stream() + .flatMap(Set::stream) + .map(getPlugin().getIslands()::getIslandById) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + + logScanContents(uniqueIslands, scan); + + if (scan.isEmpty()) { + Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND)); + } else { + Bukkit.getScheduler().runTask(getPlugin(), () -> { + user.sendMessage("commands.admin.purge.purgable-islands", + TextVariables.NUMBER, String.valueOf(uniqueIslands.size())); + sendConfirmPrompt(); + toBeConfirmed = true; + }); + } + } + + /** Log prefix used across the scan and delete phases (e.g. {@code "Purge"}). */ + protected abstract String logPrefix(); + + /** Locale key sent to the user when delete completes successfully. */ + protected abstract String successMessageKey(); + + /** Send any subclass-specific confirmation prompt(s). Runs on the main thread. */ + protected abstract void sendConfirmPrompt(); + + /** Hook invoked on the main thread immediately before the async delete. Default: no-op. */ + protected void beforeDelete(PurgeScanResult scan) { + // no-op + } + + /** Hook invoked off-thread to log scan contents before the prompt. Default: no-op. */ + protected void logScanContents(Set uniqueIslands, PurgeScanResult scan) { + // no-op + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java new file mode 100644 index 000000000..8d697e416 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeAgeRegionsCommand.java @@ -0,0 +1,95 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.event.Listener; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; + +/** + * Admin debug/testing command that artificially ages {@code .mca} region + * files in the current gamemode world so they become candidates for the + * purge regions flow without having to wait real wall-clock time. + * + *

The purge scanner reads per-chunk timestamps from the region file + * header, not from file mtime, so {@code touch} cannot fake ageing. + * This command rewrites that timestamp table in place via + * {@link world.bentobox.bentobox.managers.PurgeRegionsService#ageRegions(World, int)}. + * + *

Usage: {@code / purge age-regions } + * + * @since 3.15.0 + */ +public class AdminPurgeAgeRegionsCommand extends CompositeCommand implements Listener { + + private volatile boolean running; + + public AdminPurgeAgeRegionsCommand(CompositeCommand parent) { + super(parent, "age-regions"); + getAddon().registerListener(this); + } + + @Override + public void setup() { + setPermission("admin.purge.age-regions"); + setOnlyPlayer(false); + setParametersHelp("commands.admin.purge.age-regions.parameters"); + setDescription("commands.admin.purge.age-regions.description"); + } + + @Override + public boolean canExecute(User user, String label, List args) { + if (running) { + user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); + return false; + } + if (args.isEmpty()) { + showHelp(this, user); + return false; + } + return true; + } + + @Override + public boolean execute(User user, String label, List args) { + int days; + try { + days = Integer.parseInt(args.getFirst()); + if (days <= 0) { + user.sendMessage("commands.admin.purge.days-one-or-more"); + return false; + } + } catch (NumberFormatException e) { + user.sendMessage("commands.admin.purge.days-one-or-more"); + return false; + } + + // Flush in-memory chunk state on the main thread before touching + // the region files — otherwise an auto-save can overwrite our + // ageing with current timestamps. + getPlugin().log("Age-regions: saving all worlds before rewriting timestamps..."); + Bukkit.getWorlds().forEach(World::save); + getPlugin().log("Age-regions: world save complete"); + + running = true; + final int finalDays = days; + Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), () -> { + try { + int count = getPlugin().getPurgeRegionsService().ageRegions(getWorld(), finalDays); + Bukkit.getScheduler().runTask(getPlugin(), () -> { + user.sendMessage("commands.admin.purge.age-regions.done", + TextVariables.NUMBER, String.valueOf(count)); + getPlugin().log("Age-regions: " + count + " region file(s) aged by " + + finalDays + " day(s) in world " + getWorld().getName()); + }); + } finally { + running = false; + } + }); + return true; + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java index b49b5164e..4c35e5e43 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommand.java @@ -1,40 +1,40 @@ package world.bentobox.bentobox.api.commands.admin.purge; -import java.util.HashSet; -import java.util.Iterator; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import org.bukkit.Bukkit; -import org.bukkit.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.Listener; - -import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.events.island.IslandDeletedEvent; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; - -public class AdminPurgeCommand extends CompositeCommand implements Listener { - - private static final Long YEAR2000 = 946713600L; - private static final int TOO_MANY = 1000; - private int count; - private boolean inPurge; - private boolean scanning; - private boolean toBeConfirmed; - private Iterator it; - private User user; - private Set islands = new HashSet<>(); - private final Set loggedTiers = new HashSet<>(); // Set to store logged percentage tiers +import world.bentobox.bentobox.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; +import world.bentobox.bentobox.util.Pair; +import world.bentobox.bentobox.util.Util; + +/** + * Admin command to purge abandoned islands by deleting their region files + * directly from disk. + * + *

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

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

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

Heavy lifting is delegated to {@link PurgeRegionsService#scanDeleted(World)} + * and {@link PurgeRegionsService#delete(PurgeScanResult)}. + * + * @since 3.15.0 + */ +public class AdminPurgeDeletedCommand extends AbstractPurgeCommand { + + public AdminPurgeDeletedCommand(CompositeCommand parent) { + super(parent, "deleted"); + } + + @Override + public void setup() { + setPermission("admin.purge.deleted"); + setOnlyPlayer(false); + setParametersHelp("commands.admin.purge.deleted.parameters"); + setDescription("commands.admin.purge.deleted.description"); + } + + @Override + public boolean execute(User user, String label, List args) { + this.user = user; + if (!args.isEmpty() && args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed) { + return deleteEverything(); + } + toBeConfirmed = false; + runScanAndPrompt(() -> getPlugin().getPurgeRegionsService().scanDeleted(getWorld())); + return true; + } + + @Override + protected String logPrefix() { + return "Purge deleted"; + } + + @Override + protected String successMessageKey() { + return "commands.admin.purge.deleted.deferred"; + } + + @Override + protected void sendConfirmPrompt() { + user.sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, this.getLabel()); + } + + @Override + protected void beforeDelete(PurgeScanResult scan) { + // Evict in-memory chunks for the target regions on the main thread, + // otherwise Paper's autosave/unload would re-flush them over the + // about-to-be-deleted region files (#region-purge bug). + getPlugin().getPurgeRegionsService().evictChunks(scan); + } + + @Override + protected void logScanContents(Set uniqueIslands, PurgeScanResult scan) { + uniqueIslands.forEach(island -> + getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + + " in world " + getWorld().getName() + " will be reaped")); + } +} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java deleted file mode 100644 index 294fe17a4..000000000 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommand.java +++ /dev/null @@ -1,798 +0,0 @@ -package world.bentobox.bentobox.api.commands.admin.purge; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.file.Files; -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.UUID; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import org.bukkit.Bukkit; -import org.bukkit.World; -import org.bukkit.event.Listener; - -import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.database.objects.Island; -import world.bentobox.bentobox.managers.island.IslandGrid; -import world.bentobox.bentobox.managers.island.IslandGrid.IslandData; -import world.bentobox.bentobox.util.Pair; -import world.bentobox.bentobox.util.Util; -import world.bentobox.level.Level; - -public class AdminPurgeRegionsCommand extends CompositeCommand implements Listener { - - private static final String NONE_FOUND = "commands.admin.purge.none-found"; - private static final String REGION = "region"; - private static final String ENTITIES = "entities"; - private static final String POI = "poi"; - private static final String DIM_1 = "DIM-1"; - private static final String DIM1 = "DIM1"; - private static final String PLAYERS = "players"; - private static final String PLAYERDATA = "playerdata"; - private static final String IN_WORLD = " in world "; - private static final String WILL_BE_DELETED = " will be deleted"; - private static final String EXISTS_PREFIX = " (exists="; - private static final String PURGE_FOUND = "Purge found "; - - private volatile boolean inPurge; - private boolean toBeConfirmed; - private User user; - private Map, Set> deleteableRegions; - private boolean isNether; - private boolean isEnd; - private int days; - - public AdminPurgeRegionsCommand(CompositeCommand parent) { - super(parent, "regions"); - getAddon().registerListener(this); - // isNether/isEnd are NOT computed here: IWM may not have loaded the addon world - // config yet at command-registration time. They are evaluated lazily in findIslands(). - } - - @Override - public void setup() { - setPermission("admin.purge.regions"); - setOnlyPlayer(false); - setParametersHelp("commands.admin.purge.regions.parameters"); - setDescription("commands.admin.purge.regions.description"); - } - - @Override - public boolean canExecute(User user, String label, List args) { - if (inPurge) { - user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); - return false; - } - if (args.isEmpty()) { - // Show help - showHelp(this, user); - return false; - } - return true; - } - - @Override - public boolean execute(User user, String label, List args) { - this.user = user; - if (args.getFirst().equalsIgnoreCase("confirm") && toBeConfirmed && this.user.equals(user)) { - return deleteEverything(); - } - /* - * This part does the searching for region files - */ - // Clear tbc - toBeConfirmed = false; - - try { - days = Integer.parseInt(args.getFirst()); - if (days <= 0) { - user.sendMessage("commands.admin.purge.days-one-or-more"); - return false; - } - - } catch (NumberFormatException e) { - user.sendMessage("commands.admin.purge.days-one-or-more"); - return false; - } - - user.sendMessage("commands.admin.purge.scanning"); - // Save all worlds to update any region files - Bukkit.getWorlds().forEach(World::save); - - // Find the potential islands - Bukkit.getScheduler().runTaskAsynchronously(getPlugin(), ()-> findIslands(getWorld(), days)); - return true; - } - - - private boolean deleteEverything() { - if (deleteableRegions.isEmpty()) { - user.sendMessage(NONE_FOUND); // Should never happen - return false; - } - // Save the worlds - Bukkit.getWorlds().forEach(World::save); - // Recheck to see if any regions are newer than days and if so, stop everything - getPlugin().log("Now deleting region files"); - if (!deleteRegionFiles()) { - // Fail! - getPlugin().logError("Not all region files could be deleted"); - } - // Delete islands and regions - for (Set islandIDs : deleteableRegions.values()) { - for (String islandID : islandIDs) { - deletePlayerFromWorldFolder(islandID); - // Remove island from the cache - getPlugin().getIslands().getIslandCache().deleteIslandFromCache(islandID); - // Delete island from database using id - if (getPlugin().getIslands().deleteIslandId(islandID)) { - // Log - getPlugin().log("Island ID " + islandID + " deleted from cache and database" ); - } - } - } - - user.sendMessage("general.success"); - toBeConfirmed = false; - deleteableRegions.clear(); - return true; - - } - - private void deletePlayerFromWorldFolder(String islandID) { - File playerData = resolvePlayerDataFolder(); - getPlugin().getIslands().getIslandById(islandID) - .ifPresent(island -> island.getMemberSet() - .forEach(uuid -> maybeDeletePlayerData(uuid, playerData))); - } - - private void maybeDeletePlayerData(UUID uuid, File playerData) { - List memberOf = new ArrayList<>(getIslands().getIslands(getWorld(), uuid)); - deleteableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); - if (!memberOf.isEmpty()) { - return; - } - if (Bukkit.getOfflinePlayer(uuid).isOp()) { - return; - } - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); - if (resolveLastLogin(uuid) >= cutoffMillis) { - return; - } - deletePlayerFiles(uuid, playerData); - } - - private long resolveLastLogin(UUID uuid) { - Long lastLogin = getPlugin().getPlayers().getLastLoginTimestamp(uuid); - return lastLogin != null ? lastLogin : Bukkit.getOfflinePlayer(uuid).getLastSeen(); - } - - private void deletePlayerFiles(UUID uuid, File playerData) { - if (!playerData.exists()) { - return; - } - deletePlayerFile(new File(playerData, uuid + ".dat"), "player data file"); - deletePlayerFile(new File(playerData, uuid + ".dat_old"), "player data backup file"); - } - - private void deletePlayerFile(File file, String description) { - try { - Files.deleteIfExists(file.toPath()); - } catch (IOException ex) { - getPlugin().logError("Failed to delete " + description + ": " + file.getAbsolutePath()); - } - } - - /** - * Resolves the base data folder for a world, accounting for the dimension - * subfolder layout. - *

- * Pre-Minecraft version 26.1 (old format): Nether data lives in {@code DIM-1/} and - * End data lives in {@code DIM1/} subfolders inside the world folder. - *

- * Minecraft version 26.1.1+ (new format): Each dimension has its own world folder - * under {@code dimensions/minecraft/} and data (region/, entities/, poi/) - * lives directly in it — no DIM-1/DIM1 subfolders. - * - * @param world the world to resolve - * @return the base folder containing region/, entities/, poi/ subfolders - */ - private File resolveDataFolder(World world) { - File worldFolder = world.getWorldFolder(); - return switch (world.getEnvironment()) { - case NETHER -> { - File dim = new File(worldFolder, DIM_1); - yield dim.isDirectory() ? dim : worldFolder; - } - case THE_END -> { - File dim = new File(worldFolder, DIM1); - yield dim.isDirectory() ? dim : worldFolder; - } - default -> worldFolder; - }; - } - - /** - * Resolves the player data folder, supporting both old and new formats. - *

- * Pre-26.1: {@code /playerdata/} - *

- * 26.1.1+: {@code /players/data/} (centralized) - * - * @return the folder containing player .dat files - */ - private File resolvePlayerDataFolder() { - File worldFolder = getWorld().getWorldFolder(); - // Old format - File oldPath = new File(worldFolder, PLAYERDATA); - if (oldPath.isDirectory()) { - return oldPath; - } - // New 26.1.1 format: walk up from dimensions/minecraft// to world root - File root = worldFolder.getParentFile(); // minecraft/ - if (root != null) root = root.getParentFile(); // dimensions/ - if (root != null) root = root.getParentFile(); // world root - if (root != null) { - File newPath = new File(root, PLAYERS + File.separator + "data"); - if (newPath.isDirectory()) { - return newPath; - } - } - return oldPath; // fallback - } - - /** - * Resolves the nether data folder when the Nether World object is unavailable. - * Tries the old DIM-1 subfolder first, then the 26.1.1 sibling world folder. - * - * @param overworldFolder the overworld's world folder - * @return the nether base folder (may not exist) - */ - private File resolveNetherFallback(File overworldFolder) { - // Old format: /DIM-1/ - File dim = new File(overworldFolder, DIM_1); - if (dim.isDirectory()) { - return dim; - } - // New 26.1.1 format: sibling folder _nether in same parent - File parent = overworldFolder.getParentFile(); - if (parent != null) { - File sibling = new File(parent, overworldFolder.getName() + "_nether"); - if (sibling.isDirectory()) { - return sibling; - } - } - return dim; // fallback to old path - } - - /** - * Resolves the end data folder when the End World object is unavailable. - * Tries the old DIM1 subfolder first, then the 26.1.1 sibling world folder. - * - * @param overworldFolder the overworld's world folder - * @return the end base folder (may not exist) - */ - private File resolveEndFallback(File overworldFolder) { - // Old format: /DIM1/ - File dim = new File(overworldFolder, DIM1); - if (dim.isDirectory()) { - return dim; - } - // New 26.1.1 format: sibling folder _the_end in same parent - File parent = overworldFolder.getParentFile(); - if (parent != null) { - File sibling = new File(parent, overworldFolder.getName() + "_the_end"); - if (sibling.isDirectory()) { - return sibling; - } - } - return dim; // fallback to old path - } - - /** - * Deletes a file if it exists, logging an error if deletion fails. - * Does not log if the parent folder does not exist (normal for entities/poi). - * @param file the file to delete - * @return true if deleted or does not exist, false if exists but could not be deleted - */ - private boolean deleteIfExists(File file) { - if (!file.getParentFile().exists()) { - // Parent folder missing is normal for entities/poi, do not log - return true; - } - try { - Files.deleteIfExists(file.toPath()); - return true; - } catch (IOException e) { - getPlugin().logError("Failed to delete file: " + file.getAbsolutePath()); - return false; - } - } - - /** - * Deletes all region files in deleteableRegions that are older than {@code days}. - * Also deletes corresponding entities and poi files in each dimension. - * @return {@code true} if deletion was performed; {@code false} if cancelled - * due to any file being newer than the cutoff - */ - private boolean deleteRegionFiles() { - if (days <= 0) { - getPlugin().logError("Days is somehow zero or negative!"); - return false; - } - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); - - World world = getWorld(); - File base = world.getWorldFolder(); - File overworldRegion = new File(base, REGION); - File overworldEntities = new File(base, ENTITIES); - File overworldPoi = new File(base, POI); - - World netherWorld = getPlugin().getIWM().getNetherWorld(world); - File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base); - File netherRegion = new File(netherBase, REGION); - File netherEntities = new File(netherBase, ENTITIES); - File netherPoi = new File(netherBase, POI); - - World endWorld = getPlugin().getIWM().getEndWorld(world); - File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base); - File endRegion = new File(endBase, REGION); - File endEntities = new File(endBase, ENTITIES); - File endPoi = new File(endBase, POI); - - // Phase 1: verify none of the files have been updated since the cutoff - for (Pair coords : deleteableRegions.keySet()) { - String name = "r." + coords.x() + "." + coords.z() + ".mca"; - if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis)) { - return false; - } - } - - // Phase 2: perform deletions - DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi); - DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); - DimFolders end = new DimFolders(endRegion, endEntities, endPoi); - for (Pair coords : deleteableRegions.keySet()) { - String name = "r." + coords.x() + "." + coords.z() + ".mca"; - if (!deleteOneRegion(name, ow, nether, end)) { - getPlugin().logError("Could not delete all the region/entity/poi files for some reason"); - } - } - - return true; - } - - private boolean isFileFresh(File file, long cutoffMillis) { - return file.exists() && getRegionTimestamp(file) >= cutoffMillis; - } - - private boolean isAnyDimensionFresh(String name, File overworldRegion, File netherRegion, - File endRegion, long cutoffMillis) { - if (isFileFresh(new File(overworldRegion, name), cutoffMillis)) return true; - if (isNether && isFileFresh(new File(netherRegion, name), cutoffMillis)) return true; - return isEnd && isFileFresh(new File(endRegion, name), cutoffMillis); - } - - /** Groups the three folder types (region, entities, poi) for one world dimension. */ - private record DimFolders(File region, File entities, File poi) {} - - private boolean deleteOneRegion(String name, DimFolders overworld, DimFolders nether, DimFolders end) { - boolean owRegionOk = deleteIfExists(new File(overworld.region(), name)); - boolean owEntitiesOk = deleteIfExists(new File(overworld.entities(), name)); - boolean owPoiOk = deleteIfExists(new File(overworld.poi(), name)); - boolean ok = owRegionOk && owEntitiesOk && owPoiOk; - if (isNether) { - ok &= deleteIfExists(new File(nether.region(), name)); - ok &= deleteIfExists(new File(nether.entities(), name)); - ok &= deleteIfExists(new File(nether.poi(), name)); - } - if (isEnd) { - ok &= deleteIfExists(new File(end.region(), name)); - ok &= deleteIfExists(new File(end.entities(), name)); - ok &= deleteIfExists(new File(end.poi(), name)); - } - return ok; - } - - /** Tracks island-level and region-level block counts during filtering. */ - private record FilterStats(int islandsOverLevel, int islandsPurgeProtected, - int regionsBlockedByLevel, int regionsBlockedByProtection) {} - - /** - * This method is run async! - * @param world world - * @param days days old - */ - private void findIslands(World world, int days) { - // Evaluate here, not in the constructor - IWM config is loaded by the time a command runs - isNether = getPlugin().getIWM().isNetherGenerate(world) && getPlugin().getIWM().isNetherIslands(world); - isEnd = getPlugin().getIWM().isEndGenerate(world) && getPlugin().getIWM().isEndIslands(world); - try { - // Get the grid that covers this world - IslandGrid islandGrid = getPlugin().getIslands().getIslandCache().getIslandGrid(world); - if (islandGrid == null) { - Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND)); - return; - } - // Find old regions - List> oldRegions = this.findOldRegions(days); - // Get islands that are associated with these regions - deleteableRegions = this.mapIslandsToRegions(oldRegions, islandGrid); - // Filter regions and log summary - FilterStats stats = filterNonDeletableRegions(); - logFilterStats(stats); - // Display results and prompt for confirmation - displayResultsAndPrompt(); - } finally { - inPurge = false; - } - } - - /** - * Removes regions from {@code deleteableRegions} whose island-set contains - * at least one island that cannot be deleted, and returns blocking statistics. - */ - private FilterStats filterNonDeletableRegions() { - int islandsOverLevel = 0; - int islandsPurgeProtected = 0; - int regionsBlockedByLevel = 0; - int regionsBlockedByProtection = 0; - - var iter = deleteableRegions.entrySet().iterator(); - while (iter.hasNext()) { - var entry = iter.next(); - int[] regionCounts = evaluateRegionIslands(entry.getValue()); - if (regionCounts[0] > 0) { // shouldRemove - iter.remove(); - islandsOverLevel += regionCounts[1]; - islandsPurgeProtected += regionCounts[2]; - if (regionCounts[1] > 0) regionsBlockedByLevel++; - if (regionCounts[2] > 0) regionsBlockedByProtection++; - } - } - return new FilterStats(islandsOverLevel, islandsPurgeProtected, - regionsBlockedByLevel, regionsBlockedByProtection); - } - - /** - * Evaluates a set of island IDs for a single region. - * @return int array: [shouldRemove (0 or 1), levelBlockCount, purgeProtectedCount] - */ - private int[] evaluateRegionIslands(Set islandIds) { - int shouldRemove = 0; - int levelBlocked = 0; - int purgeBlocked = 0; - for (String id : islandIds) { - Optional opt = getPlugin().getIslands().getIslandById(id); - if (opt.isEmpty()) { - shouldRemove = 1; - continue; - } - Island isl = opt.get(); - if (canDeleteIsland(isl)) { - shouldRemove = 1; - if (isl.isPurgeProtected()) purgeBlocked++; - if (isLevelTooHigh(isl)) levelBlocked++; - } - } - return new int[] { shouldRemove, levelBlocked, purgeBlocked }; - } - - private void logFilterStats(FilterStats stats) { - if (stats.islandsOverLevel() > 0) { - getPlugin().log("Purge: " + stats.islandsOverLevel() + " island(s) exceed the level threshold of " - + getPlugin().getSettings().getIslandPurgeLevel() - + " - preventing " + stats.regionsBlockedByLevel() + " region(s) from being purged"); - } - if (stats.islandsPurgeProtected() > 0) { - getPlugin().log("Purge: " + stats.islandsPurgeProtected() + " island(s) are purge-protected" - + " - preventing " + stats.regionsBlockedByProtection() + " region(s) from being purged"); - } - } - - private void displayResultsAndPrompt() { - Set uniqueIslands = deleteableRegions.values().stream() - .flatMap(Set::stream) - .map(getPlugin().getIslands()::getIslandById) - .flatMap(Optional::stream) - .collect(Collectors.toSet()); - - uniqueIslands.forEach(this::displayIsland); - - deleteableRegions.entrySet().stream() - .filter(e -> e.getValue().isEmpty()) - .forEach(e -> displayEmptyRegion(e.getKey())); - - if (deleteableRegions.isEmpty()) { - Bukkit.getScheduler().runTask(getPlugin(), () -> user.sendMessage(NONE_FOUND)); - } else { - Bukkit.getScheduler().runTask(getPlugin(), () -> { - user.sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, String.valueOf(uniqueIslands.size())); - user.sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, this.getLabel()); - user.sendMessage("general.beta"); // TODO Remove beta in the future - this.toBeConfirmed = true; - }); - } - } - - private void displayIsland(Island island) { - // Log the island data - if (island.isDeletable()) { - getPlugin().log("Deletable island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); - return; - } - if (island.getOwner() == null) { - getPlugin().log("Unowned island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() + WILL_BE_DELETED); - return; - } - getPlugin().log("Island at " + Util.xyz(island.getCenter().toVector()) + IN_WORLD + getWorld().getName() - + " owned by " + getPlugin().getPlayers().getName(island.getOwner()) - + " who last logged in " + formatLocalTimestamp(getPlugin().getPlayers().getLastLoginTimestamp(island.getOwner())) - + WILL_BE_DELETED); - } - - private void displayEmptyRegion(Pair region) { - getPlugin().log("Empty region at r." + region.x() + "." + region.z() + IN_WORLD + getWorld().getName() + " will be deleted (no islands)"); - } - - /** - * Formats a millisecond timestamp into a human-readable string - * using the system's local time zone. - * - * @param millis the timestamp in milliseconds - * @return formatted string in the form "yyyy-MM-dd HH:mm" - */ - private String formatLocalTimestamp(Long millis) { - if (millis == null) { - return "(unknown or never recorded)"; - } - Instant instant = Instant.ofEpochMilli(millis); - - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") - .withZone(ZoneId.systemDefault()); // Uses the machine's local time zone - - return formatter.format(instant); - } - - /** - * Check if an island cannot be deleted. Purge protected, spawn, or unowned islands cannot be deleted. - * Islands whose members recently logged in, or that exceed the level threshold, cannot be deleted. - * @param island island - * @return true means "cannot delete" - */ - private boolean canDeleteIsland(Island island) { - // If the island is marked deletable it can always be purged - if (island.isDeletable()) { - return false; - } - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); - // Block if ANY member (owner or team) has logged in within the cutoff window - boolean recentLogin = island.getMemberSet().stream().anyMatch(uuid -> { - Long lastLogin = getPlugin().getPlayers().getLastLoginTimestamp(uuid); - if (lastLogin == null) { - lastLogin = Bukkit.getOfflinePlayer(uuid).getLastSeen(); - } - return lastLogin >= cutoffMillis; - }); - if (recentLogin) { - return true; - } - if (isLevelTooHigh(island)) { - return true; - } - return island.isPurgeProtected() || island.isSpawn() || !island.isOwned(); - } - - /** - * Returns true if the island's level meets or exceeds the configured purge threshold. - * Returns false when the Level addon is not present. - * @param island island to check - * @return true if the island level is too high to purge - */ - private boolean isLevelTooHigh(Island island) { - return getPlugin().getAddonsManager().getAddonByName("Level") - .map(l -> ((Level) l).getIslandLevel(getWorld(), island.getOwner()) - >= getPlugin().getSettings().getIslandPurgeLevel()) - .orElse(false); - } - - /** - * Finds all region files in the overworld (and optionally the Nether and End) - * that have not been modified in the last {@code days} days, and returns their - * region coordinates. - * - *

If {@code nether} is {@code true}, the matching region file in the - * Nether (DIM-1) must also be older than the cutoff to include the coordinate. - * If {@code end} is {@code true}, the matching region file in the End (DIM1) - * must likewise be older than the cutoff. When both {@code nether} and - * {@code end} are {@code true}, all three dimension files must satisfy the - * age requirement.

- * - * @param days the minimum age in days of region files to include - * @return a list of {@code Pair} for each region meeting - * the age criteria - */ - private List> findOldRegions(int days) { - World world = this.getWorld(); - File worldDir = world.getWorldFolder(); - File overworldRegion = new File(worldDir, REGION); - - World netherWorld = getPlugin().getIWM().getNetherWorld(world); - File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir); - File netherRegion = new File(netherBase, REGION); - - World endWorld = getPlugin().getIWM().getEndWorld(world); - File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir); - File endRegion = new File(endBase, REGION); - - long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); - - logRegionFolderPaths(overworldRegion, netherRegion, endRegion, world); - - // Collect all candidate region names from overworld, nether, and end. - // This ensures orphaned nether/end files are caught even if the overworld - // file was already deleted by a previous (buggy) purge run. - Set candidateNames = collectCandidateNames(overworldRegion, netherRegion, endRegion); - getPlugin().log("Purge total candidate region coordinates: " + candidateNames.size()); - getPlugin().log("Purge checking candidate region(s) against island data, please wait..."); - - List> regions = new ArrayList<>(); - for (String name : candidateNames) { - Pair coords = parseRegionCoords(name); - if (coords == null) continue; - if (!isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis)) { - regions.add(coords); - } - } - return regions; - } - - private void logRegionFolderPaths(File overworldRegion, File netherRegion, File endRegion, World world) { - getPlugin().log("Purge region folders - Overworld: " + overworldRegion.getAbsolutePath() - + EXISTS_PREFIX + overworldRegion.isDirectory() + ")"); - if (isNether) { - getPlugin().log("Purge region folders - Nether: " + netherRegion.getAbsolutePath() - + EXISTS_PREFIX + netherRegion.isDirectory() + ")"); - } else { - getPlugin().log("Purge region folders - Nether: disabled (isNetherGenerate=" - + getPlugin().getIWM().isNetherGenerate(world) + ", isNetherIslands=" - + getPlugin().getIWM().isNetherIslands(world) + ")"); - } - if (isEnd) { - getPlugin().log("Purge region folders - End: " + endRegion.getAbsolutePath() - + EXISTS_PREFIX + endRegion.isDirectory() + ")"); - } else { - getPlugin().log("Purge region folders - End: disabled (isEndGenerate=" - + getPlugin().getIWM().isEndGenerate(world) + ", isEndIslands=" - + getPlugin().getIWM().isEndIslands(world) + ")"); - } - } - - private Set collectCandidateNames(File overworldRegion, File netherRegion, File endRegion) { - Set names = new HashSet<>(); - addFileNames(names, overworldRegion.listFiles((dir, name) -> name.endsWith(".mca")), "overworld"); - if (isNether) { - addFileNames(names, netherRegion.listFiles((dir, name) -> name.endsWith(".mca")), "nether"); - } - if (isEnd) { - addFileNames(names, endRegion.listFiles((dir, name) -> name.endsWith(".mca")), "end"); - } - return names; - } - - private void addFileNames(Set names, File[] files, String dimension) { - if (files != null) { - for (File f : files) names.add(f.getName()); - } - getPlugin().log(PURGE_FOUND + (files != null ? files.length : 0) + " " + dimension + " region files"); - } - - private Pair parseRegionCoords(String name) { - // Parse region coords from filename "r...mca" - String coordsPart = name.substring(2, name.length() - 4); - String[] parts = coordsPart.split("\\."); - if (parts.length != 2) return null; - try { - return new Pair<>(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); - } catch (NumberFormatException ex) { - return null; - } - } - - /** - * Maps each old region to the set of island IDs whose island-squares overlap it. - * - *

Each region covers blocks - * [regionX*512 .. regionX*512 + 511] x [regionZ*512 .. regionZ*512 + 511].

- * - * @param oldRegions the list of region coordinates to process - * @param islandGrid the spatial grid to query - * @return a map from region coords to the set of overlapping island IDs - */ - private Map, Set> mapIslandsToRegions( - List> oldRegions, - IslandGrid islandGrid - ) { - final int blocksPerRegion = 512; - Map, Set> regionToIslands = new HashMap<>(); - - for (Pair region : oldRegions) { - int regionMinX = region.x() * blocksPerRegion; - int regionMinZ = region.z() * blocksPerRegion; - int regionMaxX = regionMinX + blocksPerRegion - 1; - int regionMaxZ = regionMinZ + blocksPerRegion - 1; - - Set ids = new HashSet<>(); - for (IslandData data : islandGrid.getIslandsInBounds(regionMinX, regionMinZ, regionMaxX, regionMaxZ)) { - ids.add(data.id()); - } - - // Always add the region, even if ids is empty - regionToIslands.put(region, ids); - } - - return regionToIslands; - } - - /** - * Reads a Minecraft region file (.mca) and returns the most recent - * per-chunk timestamp found in its header, in milliseconds since epoch. - * - * @param regionFile the .mca file - * @return the most recent timestamp (in millis) among all chunk entries, - * or 0 if the file is invalid or empty - */ - private long getRegionTimestamp(File regionFile) { - if (!regionFile.exists() || regionFile.length() < 8192) { - return 0L; - } - - try (FileInputStream fis = new FileInputStream(regionFile)) { - byte[] buffer = new byte[4096]; // Second 4KB block is the timestamp table - - // Skip first 4KB (location table) - if (fis.skip(4096) != 4096) { - return 0L; - } - - // Read the timestamp table - if (fis.read(buffer) != 4096) { - return 0L; - } - - ByteBuffer bb = ByteBuffer.wrap(buffer); - bb.order(ByteOrder.BIG_ENDIAN); // Timestamps are stored as big-endian ints - - long maxTimestampSeconds = 0; - - for (int i = 0; i < 1024; i++) { - long timestamp = Integer.toUnsignedLong(bb.getInt()); - if (timestamp > maxTimestampSeconds) { - maxTimestampSeconds = timestamp; - } - } - - // Convert seconds to milliseconds - return maxTimestampSeconds * 1000L; - - } catch (IOException e) { - getPlugin().logError("Failed to read region file timestamps: " + regionFile.getAbsolutePath() + " " + e.getMessage()); - return 0L; - } - } -} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java deleted file mode 100644 index 3f5bde9aa..000000000 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStatusCommand.java +++ /dev/null @@ -1,49 +0,0 @@ -package world.bentobox.bentobox.api.commands.admin.purge; - -import java.util.List; - -import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; - -/** - * Displays the current status and progress of the purge. - * @since 1.13.0 - * @author Poslovitch - */ -public class AdminPurgeStatusCommand extends CompositeCommand { - - public AdminPurgeStatusCommand(AdminPurgeCommand parent) { - super(parent, "status"); - } - - @Override - public void setup() { - setPermission("admin.purge.status"); - setOnlyPlayer(false); - setParametersHelp("commands.admin.purge.status.parameters"); - setDescription("commands.admin.purge.status.description"); - } - - @Override - public boolean execute(User user, String label, List args) { - if (!args.isEmpty()) { - // Show help - showHelp(this, user); - return false; - } - AdminPurgeCommand parentCommand = ((AdminPurgeCommand)getParent()); - if (parentCommand.isInPurge()) { - int purged = parentCommand.getPurgedIslandsCount(); - int purgeable = parentCommand.getPurgeableIslandsCount(); - user.sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, this.getTopLabel()); - user.sendMessage("commands.admin.purge.status.status", - "[purged]", String.valueOf(purged), - "[purgeable]", String.valueOf(purgeable), - "[percentage]", String.format("%.1f", (((float) purged)/purgeable) * 100)); - } else { - user.sendMessage("commands.admin.purge.no-purge-in-progress"); - } - return true; - } -} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java deleted file mode 100644 index 84aeb6190..000000000 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeStopCommand.java +++ /dev/null @@ -1,38 +0,0 @@ -package world.bentobox.bentobox.api.commands.admin.purge; - -import java.util.List; - -import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.user.User; - -public class AdminPurgeStopCommand extends CompositeCommand { - - public AdminPurgeStopCommand(CompositeCommand parent) { - super(parent, "stop", "cancel"); - } - - @Override - public void setup() { - setPermission("admin.purge.stop"); - setOnlyPlayer(false); - setDescription("commands.admin.purge.stop.description"); - } - - @Override - public boolean execute(User user, String label, List args) { - if (!args.isEmpty()) { - // Show help - showHelp(this, user); - return false; - } - AdminPurgeCommand parentCommand = ((AdminPurgeCommand)getParent()); - if (parentCommand.isInPurge()) { - user.sendMessage("commands.admin.purge.stop.stopping"); - parentCommand.stop(); - return true; - } else { - user.sendMessage("commands.admin.purge.no-purge-in-progress"); - return false; - } - } -} diff --git a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java index d3334b13b..89519fabf 100644 --- a/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java +++ b/src/main/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommand.java @@ -8,7 +8,17 @@ import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; +/** + * Flags every unowned, non-spawn, non-purge-protected island in the current + * world as {@code deletable} so that the next {@code /bbox admin purge deleted} + * (or the housekeeping deleted-sweep) reaps their region files. + * + *

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

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

Most addons do not need this event — use {@link #DELETED} for cleanup. + * {@code PURGED} is intended for addons that specifically track region-file + * or disk-space state. + * + * @since 3.15.0 + */ + PURGED, /** * Fired when a player enters an island */ diff --git a/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java b/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java index a72491c1e..d3d189f14 100644 --- a/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java +++ b/src/main/java/world/bentobox/bentobox/api/flags/clicklisteners/IslandDefaultCycleClick.java @@ -17,7 +17,7 @@ * Left-clicks increase the default island protection rank, right-clicks decrease it. * This modifies the default rank that new islands will receive for a protection flag. * @author tastybento - * @since 3.14.0 + * @since 3.15.0 */ public class IslandDefaultCycleClick implements PanelItem.ClickHandler { diff --git a/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java b/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java index 8433f2cbb..13bdf1672 100644 --- a/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java +++ b/src/main/java/world/bentobox/bentobox/blueprints/Blueprint.java @@ -6,12 +6,14 @@ import org.bukkit.Material; import org.bukkit.util.Vector; +import org.bukkit.inventory.ItemStack; import org.eclipse.jdt.annotation.NonNull; import com.google.gson.annotations.Expose; import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBlock; import world.bentobox.bentobox.blueprints.dataobjects.BlueprintEntity; +import world.bentobox.bentobox.util.ItemParser; /** * Stores all details of a blueprint @@ -20,6 +22,8 @@ */ public class Blueprint { + private static final String DEFAULT_ICON = "PAPER"; + /** * Unique name for this blueprint. The filename will be this plus the blueprint suffix */ @@ -27,8 +31,13 @@ public class Blueprint { private @NonNull String name = ""; @Expose private String displayName; + /** + * Icon of the blueprint. Supports plain material names (e.g. "DIAMOND"), + * vanilla namespaced materials (e.g. "minecraft:diamond"), and custom + * item model keys (e.g. "myserver:island_tropical"). + */ @Expose - private @NonNull Material icon = Material.PAPER; + private String icon = DEFAULT_ICON; @Expose private List description; @Expose @@ -77,17 +86,52 @@ public Blueprint setDisplayName(String displayName) { return this; } /** - * @return the icon + * Returns the base Material for this blueprint's icon. + * Resolves plain names ("DIAMOND") and vanilla namespaced keys ("minecraft:diamond") + * via {@link Material#matchMaterial}. For custom item-model keys that are not + * valid vanilla materials (e.g. "myserver:island_tropical"), returns {@link Material#PAPER} + * as the base item — use {@link #getIconItemStack()} to get the full item with model data. + * @return the icon material, never null */ public @NonNull Material getIcon() { - return icon; + return ItemParser.parseIconMaterial(icon); + } + + /** + * Returns an {@link ItemStack} representing this blueprint's icon. + *

    + *
  • Plain material name (e.g. {@code "DIAMOND"}) → {@code new ItemStack(Material.DIAMOND)}
  • + *
  • Vanilla namespaced material (e.g. {@code "minecraft:diamond"}) → same as above
  • + *
  • Custom item-model key (e.g. {@code "myserver:island_tropical"}) → PAPER base item + * with the model key set via {@link ItemMeta#setItemModel}
  • + *
+ * @return ItemStack for this blueprint's icon, never null + * @since 3.0.0 + */ + public @NonNull ItemStack getIconItemStack() { + return ItemParser.parseIconItemStack(icon); } + /** - * @param icon the icon to set + * Sets the icon from a Material (backward-compatible setter). + * @param icon the icon material to set; if null, defaults to {@link Material#PAPER} * @return blueprint */ public Blueprint setIcon(Material icon) { - this.icon = icon; + this.icon = icon != null ? icon.name() : DEFAULT_ICON; + return this; + } + + /** + * Sets the icon from a string. Accepts plain material names (e.g. {@code "DIAMOND"}), + * vanilla namespaced materials (e.g. {@code "minecraft:diamond"}), and custom item-model + * keys (e.g. {@code "myserver:island_tropical"}). + * @param icon the icon string; if null, defaults to {@code "PAPER"} + * @return blueprint + * @since 3.0.0 + */ + public Blueprint setIcon(String icon) { + this.icon = icon != null ? icon : DEFAULT_ICON; return this; } /** diff --git a/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java b/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java index 788e5916e..7f43094d0 100644 --- a/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java +++ b/src/main/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundle.java @@ -7,11 +7,13 @@ import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.inventory.ItemStack; import com.google.gson.annotations.Expose; import world.bentobox.bentobox.blueprints.Blueprint; import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.util.ItemParser; /** * Represents a bundle of three {@link Blueprint}s. @@ -21,16 +23,20 @@ */ public class BlueprintBundle implements DataObject { + private static final String DEFAULT_ICON = "PAPER"; + /** * The unique id of this bundle */ @Expose private String uniqueId; /** - * Icon of the bundle + * Icon of the bundle. Supports plain material names (e.g. "DIAMOND"), + * vanilla namespaced materials (e.g. "minecraft:diamond"), and custom + * item model keys (e.g. "myserver:island_tropical"). */ @Expose - private Material icon = Material.PAPER; + private String icon = DEFAULT_ICON; /** * Name on the icon */ @@ -97,16 +103,49 @@ public void setUniqueId(String uniqueId) { this.uniqueId = uniqueId; } /** - * @return the icon + * Returns the base Material for this bundle's icon. + * Resolves plain names ("DIAMOND") and vanilla namespaced keys ("minecraft:diamond") + * via {@link Material#matchMaterial}. For custom item-model keys that are not + * valid vanilla materials (e.g. "myserver:island_tropical"), returns {@link Material#PAPER} + * as the base item — use {@link #getIconItemStack()} to get the full item with model data. + * @return the icon material, never null */ public Material getIcon() { - return icon; + return ItemParser.parseIconMaterial(icon); } + + /** + * Returns an {@link ItemStack} representing this bundle's icon. + *
    + *
  • Plain material name (e.g. {@code "DIAMOND"}) → {@code new ItemStack(Material.DIAMOND)}
  • + *
  • Vanilla namespaced material (e.g. {@code "minecraft:diamond"}) → same as above
  • + *
  • Custom item-model key (e.g. {@code "myserver:island_tropical"}) → PAPER base item + * with the model key set via {@link ItemMeta#setItemModel}
  • + *
+ * @return ItemStack for this bundle's icon, never null + * @since 3.0.0 + */ + public ItemStack getIconItemStack() { + return ItemParser.parseIconItemStack(icon); + } + /** - * @param icon the icon to set + * Sets the icon from a Material (backward-compatible setter). + * @param icon the icon material to set; if null, defaults to {@link Material#PAPER} */ public void setIcon(Material icon) { - this.icon = icon; + this.icon = icon != null ? icon.name() : DEFAULT_ICON; + } + + /** + * Sets the icon from a string. Accepts plain material names (e.g. {@code "DIAMOND"}), + * vanilla namespaced materials (e.g. {@code "minecraft:diamond"}), and custom item-model + * keys (e.g. {@code "myserver:island_tropical"}). + * @param icon the icon string; if null, defaults to {@code "PAPER"} + * @since 3.0.0 + */ + public void setIcon(String icon) { + this.icon = icon != null ? icon : DEFAULT_ICON; } /** * @return the displayName diff --git a/src/main/java/world/bentobox/bentobox/database/objects/Island.java b/src/main/java/world/bentobox/bentobox/database/objects/Island.java index b48d672ef..6b747ab66 100644 --- a/src/main/java/world/bentobox/bentobox/database/objects/Island.java +++ b/src/main/java/world/bentobox/bentobox/database/objects/Island.java @@ -1735,7 +1735,7 @@ public void clearChanged() { * Calls to this method must be balanced with calls to {@link #endDeferSaves()}. * Multiple callers may defer simultaneously (reference-counted). * - * @since 3.14.0 + * @since 3.15.0 */ public void beginDeferSaves() { deferSaveCount++; @@ -1745,7 +1745,7 @@ public void beginDeferSaves() { * End deferring database saves. Decrements the defer counter; when it reaches * zero the island is saved if it has been marked as changed. * - * @since 3.14.0 + * @since 3.15.0 */ public void endDeferSaves() { if (deferSaveCount > 0) { @@ -1758,7 +1758,7 @@ public void endDeferSaves() { /** * @return true if saves are currently being deferred - * @since 3.14.0 + * @since 3.15.0 */ public boolean isDeferSaves() { return deferSaveCount > 0; diff --git a/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java b/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java index 0cff041d7..538bea305 100644 --- a/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java +++ b/src/main/java/world/bentobox/bentobox/hooks/MythicMobsHook.java @@ -71,7 +71,7 @@ public boolean spawnMythicMob(MythicMobRecord mmr, Location spawnLocation) { * @param spawnLocation location * @param onSpawn callback invoked with the spawned Bukkit entity; may be {@code null} * @return true if the mob type exists and a spawn was scheduled - * @since 3.14.0 + * @since 3.15.0 */ public boolean spawnMythicMob(MythicMobRecord mmr, Location spawnLocation, Consumer onSpawn) { return spawnMythicMob(mmr, spawnLocation, onSpawn, 40L); diff --git a/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java b/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java index b814405f0..9c7af0cbc 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java +++ b/src/main/java/world/bentobox/bentobox/listeners/BentoBoxListenerRegistrar.java @@ -53,7 +53,7 @@ public IslandDeletionManager getIslandDeletionManager() { /** * @return the {@link ChunkPregenManager} created during registration. - * @since 3.14.0 + * @since 3.15.0 */ public ChunkPregenManager getChunkPregenManager() { return chunkPregenManager; diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java index 50382e323..3dda116f7 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/protection/LockAndBanListener.java @@ -12,6 +12,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.vehicle.VehicleMoveEvent; import org.bukkit.util.Vector; @@ -20,6 +21,7 @@ import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.flags.FlagListener; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.lists.Flags; import world.bentobox.bentobox.util.Util; @@ -37,6 +39,14 @@ public class LockAndBanListener extends FlagListener { */ private final Set notifiedPlayers = new HashSet<>(); + /** + * Tracks ops who have already been notified that they are standing on an + * island flagged for deletion (awaiting region purge), to avoid spamming + * the notice on every move event. Cleared when the op leaves a deletable + * island. + */ + private final Set deletableNotified = new HashSet<>(); + /** * Result of checking the island for locked state or player bans * @@ -119,6 +129,14 @@ public void onPlayerLogin(PlayerJoinEvent e) { } } + // Quit cleanup — prevent unbounded growth of notification tracking sets + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(PlayerQuitEvent e) { + UUID uuid = e.getPlayer().getUniqueId(); + notifiedPlayers.remove(uuid); + deletableNotified.remove(uuid); + } + /** * Check if a player is banned or the island is locked * @param player - player @@ -177,9 +195,35 @@ private CheckResult checkAndNotify(@NonNull Player player, Location loc) User.getInstance(player).notify("protection.locked-island-bypass"); } } + notifyIfDeletable(player, loc); return result; } + /** + * Notify ops that the island they just entered is flagged for deletion + * and awaiting the region purge. Regular players see nothing — this is + * an admin-only heads-up so server staff know the visible chunks will + * be reaped the next time housekeeping runs. + * + *

Fires at most once per entry, using the same "move out to reset" + * pattern as the lock notification. + */ + private void notifyIfDeletable(@NonNull Player player, Location loc) { + if (!player.isOp()) { + deletableNotified.remove(player.getUniqueId()); + return; + } + boolean deletable = getIslands().getProtectedIslandAt(loc) + .map(Island::isDeletable).orElse(false); + if (deletable) { + if (deletableNotified.add(player.getUniqueId())) { + User.getInstance(player).notify("protection.deletable-island-admin"); + } + } else { + deletableNotified.remove(player.getUniqueId()); + } + } + /** * Sends player home * @param player - player diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java index 4cb3356f4..fb3cad9f2 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListener.java @@ -8,6 +8,7 @@ import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.World; +import org.bukkit.entity.ExperienceOrb; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -185,6 +186,7 @@ public void onVisitorTargeting(EntityTargetLivingEntityEvent e) if (!(e.getTarget() instanceof Player p) || !this.getIWM().inWorld(world) || e.getTarget().hasMetadata("NPC") || + e.getEntity() instanceof ExperienceOrb || this.getIslands().userIsOnIsland(world, User.getInstance(e.getTarget())) || this.isPvpAllowed(p.getLocation()) || e.getReason() == EntityTargetEvent.TargetReason.TARGET_DIED || diff --git a/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java b/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java index 08cd2b1d9..ebff0eec6 100644 --- a/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/ChunkPregenManager.java @@ -34,7 +34,7 @@ * round-robining between worlds to ensure fairness and prevent server overload. * * @author tastybento - * @since 3.14.0 + * @since 3.15.0 */ public class ChunkPregenManager implements Listener { diff --git a/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java new file mode 100644 index 000000000..2c7a9ff7a --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/managers/HousekeepingManager.java @@ -0,0 +1,371 @@ +package world.bentobox.bentobox.managers; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.scheduler.BukkitTask; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; + +/** + * Periodic housekeeping: automatically runs the region-files purge against + * every gamemode overworld on a configurable schedule. Two independent cycles: + * + *

    + *
  • Age sweep — runs every {@code interval-days} days and reaps + * regions whose .mca files are older than {@code region-age-days}.
  • + *
  • Deleted sweep — runs every {@code deleted-interval-hours} + * hours and reaps regions for any island already flagged as + * {@code deletable} (e.g. from {@code /is reset}), ignoring file age.
  • + *
+ * + *

Both cycles are gated on the single {@code housekeeping.enabled} flag + * (default OFF) and share an {@code inProgress} guard so they never overlap. + * + *

Last-run timestamps are persisted to + * {@code /database/housekeeping.yml} regardless of the + * configured database backend, so the schedule survives restarts. + * + *

This manager is destructive by design: it deletes {@code .mca} region + * files from disk. + * + * @since 3.15.0 + */ +public class HousekeepingManager { + + private static final String LEGACY_LAST_RUN_KEY = "lastRunMillis"; + private static final String LAST_AGE_RUN_KEY = "lastAgeRunMillis"; + private static final String LAST_DELETED_RUN_KEY = "lastDeletedRunMillis"; + private static final long CHECK_INTERVAL_TICKS = 20L * 60L * 60L; // 1 hour + private static final long STARTUP_DELAY_TICKS = 20L * 60L * 5L; // 5 minutes + + private final BentoBox plugin; + private final File stateFile; + private volatile long lastAgeRunMillis; + private volatile long lastDeletedRunMillis; + private volatile boolean inProgress; + private BukkitTask scheduledTask; + + public HousekeepingManager(BentoBox plugin) { + this.plugin = plugin; + this.stateFile = new File(new File(plugin.getDataFolder(), "database"), "housekeeping.yml"); + loadState(); + } + + // --------------------------------------------------------------- + // Scheduling + // --------------------------------------------------------------- + + /** + * Starts the periodic housekeeping check. Safe to call multiple times — + * the task is only scheduled once. + */ + public synchronized void start() { + if (scheduledTask != null) { + return; + } + scheduledTask = Bukkit.getScheduler().runTaskTimer(plugin, + this::checkAndMaybeRun, STARTUP_DELAY_TICKS, CHECK_INTERVAL_TICKS); + plugin.log("Housekeeping scheduler started (deleted-sweep=" + + plugin.getSettings().isHousekeepingDeletedEnabled() + + ", deleted-interval=" + plugin.getSettings().getHousekeepingDeletedIntervalHours() + "h" + + ", age-sweep=" + plugin.getSettings().isHousekeepingAgeEnabled() + + ", age-interval=" + plugin.getSettings().getHousekeepingIntervalDays() + "d" + + ", region-age=" + plugin.getSettings().getHousekeepingRegionAgeDays() + "d" + + ", last-age-run=" + formatTs(lastAgeRunMillis) + + ", last-deleted-run=" + formatTs(lastDeletedRunMillis) + ")"); + } + + private static String formatTs(long millis) { + return millis == 0 ? "never" : Instant.ofEpochMilli(millis).toString(); + } + + /** + * Stops the periodic housekeeping check. Does not clear the last-run + * timestamps on disk. + */ + public synchronized void stop() { + if (scheduledTask != null) { + scheduledTask.cancel(); + scheduledTask = null; + } + } + + /** + * @return {@code true} if a housekeeping run is currently in progress. + */ + public boolean isInProgress() { + return inProgress; + } + + /** + * @return wall-clock timestamp (millis) of the last successful age sweep, + * or {@code 0} if it has never run. + */ + public long getLastAgeRunMillis() { + return lastAgeRunMillis; + } + + /** + * @return wall-clock timestamp (millis) of the last successful deleted + * sweep, or {@code 0} if it has never run. + */ + public long getLastDeletedRunMillis() { + return lastDeletedRunMillis; + } + + private void checkAndMaybeRun() { + if (inProgress) { + return; + } + long now = System.currentTimeMillis(); + boolean ageDue = plugin.getSettings().isHousekeepingAgeEnabled() && isAgeCycleDue(now); + boolean deletedDue = plugin.getSettings().isHousekeepingDeletedEnabled() && isDeletedCycleDue(now); + if (!ageDue && !deletedDue) { + return; + } + runNow(ageDue, deletedDue); + } + + private boolean isAgeCycleDue(long now) { + int intervalDays = plugin.getSettings().getHousekeepingIntervalDays(); + if (intervalDays <= 0) { + return false; + } + long intervalMillis = TimeUnit.DAYS.toMillis(intervalDays); + return lastAgeRunMillis == 0 || (now - lastAgeRunMillis) >= intervalMillis; + } + + private boolean isDeletedCycleDue(long now) { + int intervalHours = plugin.getSettings().getHousekeepingDeletedIntervalHours(); + if (intervalHours <= 0) { + return false; + } + long intervalMillis = TimeUnit.HOURS.toMillis(intervalHours); + return lastDeletedRunMillis == 0 || (now - lastDeletedRunMillis) >= intervalMillis; + } + + /** + * Triggers an immediate housekeeping cycle for both sweeps (respecting + * the enabled flag but ignoring the interval timers). Runs asynchronously. + */ + public synchronized void runNow() { + runNow(true, true); + } + + private synchronized void runNow(boolean runAge, boolean runDeleted) { + if (inProgress) { + plugin.log("Housekeeping: run requested but already in progress, ignoring"); + return; + } + if (!runAge && !runDeleted) { + return; + } + inProgress = true; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + // Save worlds once per cycle — both sweeps see a consistent + // on-disk snapshot. + if (!saveAllWorlds()) { + return; + } + if (runAge) { + executeAgeCycle(); + } + if (runDeleted) { + executeDeletedCycle(); + } + } catch (Exception e) { + plugin.logError("Housekeeping: cycle failed: " + e.getMessage()); + plugin.logStacktrace(e); + } finally { + inProgress = false; + } + }); + } + + // --------------------------------------------------------------- + // Cycle execution + // --------------------------------------------------------------- + + private boolean saveAllWorlds() { + plugin.log("Housekeeping: saving all worlds before purge..."); + CompletableFuture saved = new CompletableFuture<>(); + Bukkit.getScheduler().runTask(plugin, () -> { + try { + Bukkit.getWorlds().forEach(World::save); + saved.complete(null); + } catch (Exception e) { + saved.completeExceptionally(e); + } + }); + try { + saved.get(2, TimeUnit.MINUTES); + plugin.log("Housekeeping: world save complete"); + return true; + } catch (TimeoutException e) { + plugin.logError("Housekeeping: world save timed out after 2 minutes, aborting cycle"); + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + plugin.logError("Housekeeping: world save interrupted: " + e.getMessage()); + return false; + } catch (ExecutionException e) { + plugin.logError("Housekeeping: world save failed: " + e.getCause().getMessage()); + return false; + } + } + + private void executeAgeCycle() { + long startMillis = System.currentTimeMillis(); + int ageDays = plugin.getSettings().getHousekeepingRegionAgeDays(); + if (ageDays <= 0) { + plugin.logError("Housekeeping: region-age-days must be >= 1, skipping age sweep"); + return; + } + List gameModes = plugin.getAddonsManager().getGameModeAddons(); + plugin.log("Housekeeping age sweep: starting across " + gameModes.size() + + " gamemode(s), region-age=" + ageDays + "d"); + + int totalWorlds = 0; + int totalRegionsPurged = 0; + for (GameModeAddon gm : gameModes) { + World overworld = gm.getOverWorld(); + if (overworld == null) { + continue; + } + totalWorlds++; + plugin.log("Housekeeping age sweep: scanning '" + gm.getDescription().getName() + + "' world '" + overworld.getName() + "'"); + PurgeScanResult scan = plugin.getPurgeRegionsService().scan(overworld, ageDays); + totalRegionsPurged += runDeleteIfNonEmpty(scan, overworld, "age sweep"); + } + + Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis); + plugin.log("Housekeeping age sweep: complete — " + totalWorlds + " world(s) processed, " + + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s"); + lastAgeRunMillis = System.currentTimeMillis(); + saveState(); + } + + private void executeDeletedCycle() { + long startMillis = System.currentTimeMillis(); + List gameModes = plugin.getAddonsManager().getGameModeAddons(); + plugin.log("Housekeeping deleted sweep: starting across " + gameModes.size() + " gamemode(s)"); + + int totalWorlds = 0; + int totalRegionsPurged = 0; + for (GameModeAddon gm : gameModes) { + World overworld = gm.getOverWorld(); + if (overworld == null) { + continue; + } + totalWorlds++; + plugin.log("Housekeeping deleted sweep: scanning '" + gm.getDescription().getName() + + "' world '" + overworld.getName() + "'"); + PurgeScanResult scan = plugin.getPurgeRegionsService().scanDeleted(overworld); + // Evict in-memory chunks on the main thread before the async delete, + // so Paper's autosave can't re-flush them over the deleted region files. + if (!scan.isEmpty()) { + evictChunksOnMainThread(scan); + } + totalRegionsPurged += runDeleteIfNonEmpty(scan, overworld, "deleted sweep"); + } + + Duration elapsed = Duration.ofMillis(System.currentTimeMillis() - startMillis); + plugin.log("Housekeeping deleted sweep: complete — " + totalWorlds + " world(s) processed, " + + totalRegionsPurged + " region(s) purged in " + elapsed.toSeconds() + "s"); + lastDeletedRunMillis = System.currentTimeMillis(); + saveState(); + } + + private void evictChunksOnMainThread(PurgeScanResult scan) { + CompletableFuture done = new CompletableFuture<>(); + Bukkit.getScheduler().runTask(plugin, () -> { + try { + plugin.getPurgeRegionsService().evictChunks(scan); + done.complete(null); + } catch (Exception e) { + done.completeExceptionally(e); + } + }); + try { + done.join(); + } catch (Exception e) { + plugin.logError("Housekeeping: chunk eviction failed: " + e.getMessage()); + } + } + + private int runDeleteIfNonEmpty(PurgeScanResult scan, World overworld, String label) { + if (scan.isEmpty()) { + plugin.log("Housekeeping " + label + ": nothing to purge in " + overworld.getName()); + return 0; + } + plugin.log("Housekeeping " + label + ": " + scan.deletableRegions().size() + " region(s) and " + + scan.uniqueIslandCount() + " island(s) eligible in " + overworld.getName()); + boolean ok = plugin.getPurgeRegionsService().delete(scan); + if (!ok) { + plugin.logError("Housekeeping " + label + ": purge of " + overworld.getName() + + " completed with errors"); + return 0; + } + return scan.deletableRegions().size(); + } + + // --------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------- + + private void loadState() { + if (!stateFile.exists()) { + lastAgeRunMillis = 0L; + lastDeletedRunMillis = 0L; + return; + } + try { + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(stateFile); + // Migrate legacy single-cycle key: if the new key is absent but + // the old one is present, adopt it as the age-cycle timestamp. + if (yaml.contains(LAST_AGE_RUN_KEY)) { + lastAgeRunMillis = yaml.getLong(LAST_AGE_RUN_KEY, 0L); + } else { + lastAgeRunMillis = yaml.getLong(LEGACY_LAST_RUN_KEY, 0L); + } + lastDeletedRunMillis = yaml.getLong(LAST_DELETED_RUN_KEY, 0L); + } catch (Exception e) { + plugin.logError("Housekeeping: could not read " + stateFile.getAbsolutePath() + + ": " + e.getMessage()); + lastAgeRunMillis = 0L; + lastDeletedRunMillis = 0L; + } + } + + private void saveState() { + try { + File parent = stateFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + plugin.logError("Housekeeping: could not create " + parent.getAbsolutePath()); + return; + } + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(LAST_AGE_RUN_KEY, lastAgeRunMillis); + yaml.set(LAST_DELETED_RUN_KEY, lastDeletedRunMillis); + yaml.save(stateFile); + } catch (IOException e) { + plugin.logError("Housekeeping: could not write " + stateFile.getAbsolutePath() + + ": " + e.getMessage()); + } + } +} diff --git a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java index aa5c74497..096e1d0da 100644 --- a/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/IslandsManager.java @@ -305,26 +305,47 @@ public void deleteIsland(@NonNull Island island, boolean removeBlocks, @Nullable if (removeBlocks) { // Remove players from island removePlayersFromIsland(island); - // Mark island as deletable + // Mark island as deletable - physical cleanup is handled later by + // the region-file purge (see PurgeRegionsService / HousekeepingManager). island.setDeletable(true); - if (!plugin.getSettings().isKeepPreviousIslandOnReset()) { - // Remove island from the cache - islandCache.deleteIslandFromCache(island); - // Remove blocks from world - IslandDeletion id = new IslandDeletion(island); - plugin.getIslandDeletionManager().getIslandChunkDeletionManager().add(id); - // Tell other servers - MultiLib.notify("bentobox-deleteIsland", getGson().toJson(id)); - // Delete the island from the database - handler.deleteObject(island); - } else { - handler.saveObject(island); - // Fire the deletion event immediately - IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build(); - } + handler.saveObject(island); + // Fire the deletion event immediately so listeners (hooks, maps, etc.) + // can update now that the island is orphaned. + IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build(); } } + /** + * Hard-deletes an island: fires the pre-delete event, kicks members, + * nulls the owner, evicts the island from the cache, and removes the + * DB row. Does not touch world chunks — the caller is + * responsible for any physical cleanup (e.g. kicking off + * {@link world.bentobox.bentobox.util.DeleteIslandChunks} + * to regenerate via the addon's own {@code ChunkGenerator}). + * + *

Used by {@code AdminDeleteCommand} for void/simple-generator + * gamemodes where chunks are cheap to repaint and the island should + * be fully gone from the database immediately. For + * new-chunk-generation gamemodes, use + * {@link #deleteIsland(Island, boolean, UUID)} which soft-deletes + * and leaves the region-file purge to reap the chunks and row later. + * + * @param island the island to hard-delete, not null + * @since 3.15.0 + */ + public void hardDeleteIsland(@NonNull Island island) { + IslandBaseEvent event = IslandEvent.builder().island(island).reason(Reason.DELETE).build(); + if (event.getNewEvent().map(IslandBaseEvent::isCancelled).orElse(event.isCancelled())) { + return; + } + removePlayersFromIsland(island); + island.setOwner(null); + island.setFlag(Flags.LOCK, RanksManager.VISITOR_RANK); + islandCache.deleteIslandFromCache(island); + handler.deleteObject(island); + IslandEvent.builder().deletedIslandInfo(new IslandDeletion(island)).reason(Reason.DELETED).build(); + } + /** * Deletes an island by ID. If the id doesn't exist it will do nothing. * @param uniqueId island ID diff --git a/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java new file mode 100644 index 000000000..eba2c72b0 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/managers/PurgeRegionsService.java @@ -0,0 +1,1076 @@ +package world.bentobox.bentobox.managers; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import org.bukkit.Bukkit; +import org.bukkit.World; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.api.events.island.IslandEvent; +import world.bentobox.bentobox.api.events.island.IslandEvent.Reason; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.island.IslandGrid; +import world.bentobox.bentobox.managers.island.IslandGrid.IslandData; +import world.bentobox.bentobox.util.Pair; +import world.bentobox.level.Level; + +/** + * Core implementation of the "purge region files" operation shared by the + * {@code /bbox admin purge regions} command and the periodic + * {@link HousekeepingManager} auto-purge task. + * + *

Threading requirements are method-specific. Methods that scan, read, or + * delete region data perform blocking disk I/O and should be called from an + * async thread. Methods that interact with Bukkit world or chunk APIs must be + * called from the main server thread. + * + *

The service does not interact with players or issue confirmations — the + * caller is responsible for any user-facing UX. + * + *

Shared code path for scanning, filtering, and deleting region files + * across the overworld + optional nether/end dimensions. Used by + * {@code /bbox admin purge} (the top-level command), {@code purge deleted}, + * and the periodic {@link HousekeepingManager}. + * + * @since 3.15.0 + */ +public class PurgeRegionsService { + + private static final String REGION = "region"; + private static final String ENTITIES = "entities"; + private static final String POI = "poi"; + private static final String DIM_1 = "DIM-1"; + private static final String DIM1 = "DIM1"; + private static final String PLAYERS = "players"; + private static final String PLAYERDATA = "playerdata"; + private static final String EXISTS_PREFIX = " (exists="; + private static final String PURGE_FOUND = "Purge found "; + + private final BentoBox plugin; + + /** + * Island IDs whose region files were deleted by a deleted-sweep + * ({@code days == 0}) but whose DB rows are deferred until plugin + * shutdown. Paper's internal chunk cache may still serve stale block + * data even after the {@code .mca} file is gone from disk; only a + * clean shutdown guarantees the cache is cleared. + */ + private final Set pendingDeletions = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + public PurgeRegionsService(BentoBox plugin) { + this.plugin = plugin; + } + + /** + * Result of a purge scan — a map of deletable region coordinates to the + * set of island IDs in each region, plus filtering statistics. + * + * @param world the world scanned + * @param days the age cutoff (days) used + * @param deletableRegions regions considered deletable keyed by region + * coordinate {@code (regionX, regionZ)} + * @param isNether whether the nether dimension was included + * @param isEnd whether the end dimension was included + * @param stats filter statistics for logging/reporting + */ + public record PurgeScanResult( + World world, + int days, + Map, Set> deletableRegions, + boolean isNether, + boolean isEnd, + FilterStats stats) { + public boolean isEmpty() { + return deletableRegions.isEmpty(); + } + + public int uniqueIslandCount() { + Set ids = new HashSet<>(); + deletableRegions.values().forEach(ids::addAll); + return ids.size(); + } + } + + /** Tracks island-level and region-level block counts during filtering. */ + public record FilterStats(int islandsOverLevel, int islandsPurgeProtected, + int regionsBlockedByLevel, int regionsBlockedByProtection) {} + + /** Groups the three folder types (region, entities, poi) for one world dimension. */ + private record DimFolders(File region, File entities, File poi) {} + + // --------------------------------------------------------------- + // Public API + // --------------------------------------------------------------- + + /** + * Scans the given world for islands flagged as {@code deletable} and + * returns the set of region files that can be reaped immediately, + * ignoring region-file age. + * + *

Unlike {@link #scan(World, int)} this does not look at region + * timestamps at all: the {@code deletable} flag is the sole source of + * truth. A region is only returned if every island that + * overlaps it is deletable — a lone active neighbour blocks the whole + * region. + * + *

The returned {@link PurgeScanResult} uses {@code days = 0} as a + * sentinel meaning "no age filter" so that {@link #delete(PurgeScanResult)} + * and {@code deleteRegionFiles} skip their freshness re-check. + * + *

Runs synchronously on the calling thread and performs disk I/O. + * Callers must invoke this from an async task. + * + * @param world the gamemode overworld to scan + * @return scan result, never {@code null} + * @since 3.15.0 + */ + public PurgeScanResult scanDeleted(World world) { + boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world); + boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world); + + IslandGrid islandGrid = plugin.getIslands().getIslandCache().getIslandGrid(world); + if (islandGrid == null) { + return new PurgeScanResult(world, 0, new HashMap<>(), isNether, isEnd, + new FilterStats(0, 0, 0, 0)); + } + + // Collect candidate region coords from every deletable island's + // protection bounds. A single island may straddle multiple regions. + Set> candidateRegions = new HashSet<>(); + for (Island island : plugin.getIslands().getIslandCache().getIslands(world)) { + if (!island.isDeletable()) continue; + int minRX = island.getMinProtectedX() >> 9; + int maxRX = (island.getMaxProtectedX() - 1) >> 9; + int minRZ = island.getMinProtectedZ() >> 9; + int maxRZ = (island.getMaxProtectedZ() - 1) >> 9; + for (int rx = minRX; rx <= maxRX; rx++) { + for (int rz = minRZ; rz <= maxRZ; rz++) { + candidateRegions.add(new Pair<>(rx, rz)); + } + } + } + plugin.log("Purge deleted-sweep: " + candidateRegions.size() + + " candidate region(s) from deletable islands in world " + world.getName()); + + Map, Set> deletableRegions = + mapIslandsToRegions(new ArrayList<>(candidateRegions), islandGrid); + FilterStats stats = filterForDeletedSweep(deletableRegions); + logFilterStats(stats); + return new PurgeScanResult(world, 0, deletableRegions, isNether, isEnd, stats); + } + + /** + * Scans the given world (and its nether/end if the gamemode owns them) + * for region files older than {@code days} and returns the set of + * regions whose overlapping islands may all be safely deleted. + * + *

Runs synchronously on the calling thread and performs disk I/O. + * Callers must invoke this from an async task. + * + * @param world the gamemode overworld to scan + * @param days minimum age in days for region files to be candidates + * @return scan result, never {@code null} + */ + public PurgeScanResult scan(World world, int days) { + boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world); + boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world); + + IslandGrid islandGrid = plugin.getIslands().getIslandCache().getIslandGrid(world); + if (islandGrid == null) { + return new PurgeScanResult(world, days, new HashMap<>(), isNether, isEnd, + new FilterStats(0, 0, 0, 0)); + } + + List> oldRegions = findOldRegions(world, days, isNether, isEnd); + Map, Set> deletableRegions = mapIslandsToRegions(oldRegions, islandGrid); + FilterStats stats = filterNonDeletableRegions(deletableRegions, days); + logFilterStats(stats); + return new PurgeScanResult(world, days, deletableRegions, isNether, isEnd, stats); + } + + /** + * Deletes the region files identified by a prior {@link #scan(World, int)} + * along with any island database entries, island cache entries, and + * orphaned player data files that correspond to them. + * + *

Runs synchronously on the calling thread and performs disk I/O. + * Callers must invoke this from an async task. Callers are also + * responsible for flushing in-memory chunk state by calling + * {@code World.save()} on the main thread before dispatching + * this method — {@code World.save()} is not safe to invoke from an + * async thread. + * + * @param scan the prior scan result + * @return {@code true} if all file deletions succeeded; {@code false} if + * any file was unexpectedly fresh or could not be deleted + */ + public boolean delete(PurgeScanResult scan) { + if (scan.deletableRegions().isEmpty()) { + return false; + } + plugin.log("Now deleting region files for world " + scan.world().getName()); + boolean ok = deleteRegionFiles(scan); + if (!ok) { + plugin.logError("Not all region files could be deleted"); + } + + // Collect unique island IDs across all reaped regions. An island + // that spans multiple regions will only be considered once here. + Set affectedIds = new HashSet<>(); + for (Set islandIDs : scan.deletableRegions().values()) { + affectedIds.addAll(islandIDs); + } + + int islandsRemoved = 0; + int islandsDeferred = 0; + for (String islandID : affectedIds) { + Optional opt = plugin.getIslands().getIslandById(islandID); + if (opt.isEmpty()) { + continue; + } + Island island = opt.get(); + + if (scan.days() == 0) { + // Deleted sweep: region files are gone from disk but Paper + // may still serve stale chunk data from its internal memory + // cache. Defer DB row removal to plugin shutdown when the + // cache is guaranteed clear. + pendingDeletions.add(islandID); + islandsDeferred++; + plugin.log("Island ID " + islandID + + " region files deleted \u2014 DB row deferred to shutdown"); + } else { + // Age sweep: regions are old enough that Paper won't have + // them cached. Gate on residual-region completeness check + // to avoid orphaning blocks when the strict filter blocked + // some of the island's regions. + List> residual = findResidualRegions(island, scan.world()); + if (!residual.isEmpty()) { + islandsDeferred++; + plugin.log("Island ID " + islandID + " has " + residual.size() + + " residual region(s) still on disk: " + residual + + " \u2014 DB row retained for a future purge"); + continue; + } + deletePlayerFromWorldFolder(scan.world(), islandID, scan.deletableRegions(), scan.days()); + // For the age sweep, DELETED may never have fired (the island was + // pruned by age, not by an explicit /is reset). Fire it now so addons + // (Level, OneBlock, etc.) can clean up their per-island data. + if (!island.isDeletable()) { + IslandEvent.builder() + .island(island) + .reason(Reason.DELETED) + .build(); + } + plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); + if (plugin.getIslands().deleteIslandId(islandID)) { + plugin.log("Island ID " + islandID + " deleted from cache and database"); + islandsRemoved++; + // Fire PURGED so addons that track physical storage state know + // the region files and DB row are both gone. + IslandEvent.builder() + .island(island) + .reason(Reason.PURGED) + .build(); + } + } + } + plugin.log("Purge complete for world " + scan.world().getName() + + ": " + scan.deletableRegions().size() + " region(s), " + + islandsRemoved + " island(s) removed, " + + islandsDeferred + " island(s) deferred" + + (scan.days() == 0 ? " (to shutdown)" : " (partial cleanup)")); + return ok; + } + + /** + * Processes all island IDs whose region files were deleted by a prior + * deleted-sweep but whose DB rows were deferred because Paper's internal + * memory cache may still serve stale chunk data. Call this on plugin + * shutdown when the cache is guaranteed to be cleared. + * + *

If the server crashes before a clean shutdown, the pending set is + * lost — the islands stay {@code deletable=true} in the database and the + * next purge cycle will pick them up again (safe failure mode). + */ + public void flushPendingDeletions() { + if (pendingDeletions.isEmpty()) { + return; + } + plugin.log("Flushing " + pendingDeletions.size() + " deferred island deletion(s)..."); + int count = 0; + for (String islandID : pendingDeletions) { + // Capture island before cache eviction so we can build the event. + // DELETED was already fired at soft-delete time; PURGED signals that + // the DB row is now physically removed. + Optional opt = plugin.getIslands().getIslandById(islandID); + plugin.getIslands().getIslandCache().deleteIslandFromCache(islandID); + if (plugin.getIslands().deleteIslandId(islandID)) { + count++; + opt.ifPresent(island -> IslandEvent.builder() + .island(island) + .reason(Reason.PURGED) + .build()); + } + } + pendingDeletions.clear(); + plugin.log("Flushed " + count + " island(s) from cache and database"); + } + + /** + * Returns an unmodifiable view of the island IDs currently pending + * DB deletion (deferred to shutdown). Primarily for testing. + */ + public Set getPendingDeletions() { + return Collections.unmodifiableSet(pendingDeletions); + } + + /** + * Returns the region coordinates for every {@code r.X.Z.mca} file still + * present on disk that overlaps the island's protection box, across the + * overworld and (if the gamemode owns them) the nether and end + * dimensions. An empty list means every region file the island touches + * is gone from disk and the island DB row can safely be reaped. + * + *

The protection box is converted to region coordinates with + * {@code blockX >> 9} (each .mca covers a 512×512 block square). The + * maximum bound is inclusive at the block level so we shift + * {@code max - 1} to avoid picking up a neighbour region when the + * protection ends exactly on a region boundary. + */ + private List> findResidualRegions(Island island, World overworld) { + int rxMin = island.getMinProtectedX() >> 9; + int rxMax = (island.getMaxProtectedX() - 1) >> 9; + int rzMin = island.getMinProtectedZ() >> 9; + int rzMax = (island.getMaxProtectedZ() - 1) >> 9; + + File base = overworld.getWorldFolder(); + File overworldRegionDir = new File(base, REGION); + + World netherWorld = plugin.getIWM().getNetherWorld(overworld); + File netherRegionDir = plugin.getIWM().isNetherIslands(overworld) + ? new File(netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base), REGION) + : null; + + World endWorld = plugin.getIWM().getEndWorld(overworld); + File endRegionDir = plugin.getIWM().isEndIslands(overworld) + ? new File(endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base), REGION) + : null; + + List> residual = new ArrayList<>(); + for (int rx = rxMin; rx <= rxMax; rx++) { + for (int rz = rzMin; rz <= rzMax; rz++) { + String name = "r." + rx + "." + rz + ".mca"; + if (regionFileExists(overworldRegionDir, name) + || regionFileExists(netherRegionDir, name) + || regionFileExists(endRegionDir, name)) { + residual.add(new Pair<>(rx, rz)); + } + } + } + return residual; + } + + private static boolean regionFileExists(File dir, String name) { + return dir != null && new File(dir, name).exists(); + } + + // --------------------------------------------------------------- + // Chunk eviction + // --------------------------------------------------------------- + + /** + * Unloads every loaded chunk that falls inside any region in + * {@code scan.deletableRegions()} with {@code save = false}, so the + * in-memory chunk copy is thrown away rather than flushed back over the + * region files we are about to delete. + * + *

Each {@code r.X.Z.mca} covers a 32×32 chunk square. For every target + * region this iterates {@code (rX*32 .. rX*32+31, rZ*32 .. rZ*32+31)} and + * unloads any chunk currently loaded. Chunks that cannot be unloaded + * (e.g. a player is inside, or the chunk is force-loaded) are silently + * skipped — reaping a chunk out from under a present player would be + * worse than waiting for the next sweep. + * + *

The deleted-sweep callers (manual command + housekeeping) must + * invoke this on the main thread before dispatching the async + * {@link #delete(PurgeScanResult)}; otherwise Paper's autosave or shutdown + * will rewrite the region file with the stale in-memory chunks immediately + * after we delete it on disk. + * + *

Nether and end dimensions are evicted only when the gamemode owns + * them, mirroring the dimension gating in {@link #deleteRegionFiles}. + * + * @param scan a prior scan result whose regions should be evicted + */ + public void evictChunks(PurgeScanResult scan) { + if (scan.deletableRegions().isEmpty()) { + return; + } + World overworld = scan.world(); + World netherWorld = scan.isNether() ? plugin.getIWM().getNetherWorld(overworld) : null; + World endWorld = scan.isEnd() ? plugin.getIWM().getEndWorld(overworld) : null; + + int evicted = 0; + for (Pair coords : scan.deletableRegions().keySet()) { + int baseCx = coords.x() << 5; // rX * 32 + int baseCz = coords.z() << 5; + evicted += evictRegion(overworld, baseCx, baseCz); + if (netherWorld != null) { + evicted += evictRegion(netherWorld, baseCx, baseCz); + } + if (endWorld != null) { + evicted += evictRegion(endWorld, baseCx, baseCz); + } + } + plugin.log("Purge deleted: evicted " + evicted + " loaded chunk(s) from " + + scan.deletableRegions().size() + " target region(s)"); + } + + private int evictRegion(World world, int baseCx, int baseCz) { + int count = 0; + for (int dx = 0; dx < 32; dx++) { + for (int dz = 0; dz < 32; dz++) { + int cx = baseCx + dx; + int cz = baseCz + dz; + if (world.isChunkLoaded(cx, cz) && world.unloadChunk(cx, cz, false)) { + count++; + } + } + } + return count; + } + + // --------------------------------------------------------------- + // Debug / testing: artificially age region files + // --------------------------------------------------------------- + + /** + * Debug/testing utility. Rewrites the per-chunk timestamp table of every + * {@code .mca} region file in the given world's overworld (and nether/end + * if the gamemode owns those dimensions) so that every chunk entry looks + * like it was last written {@code days} days ago. + * + *

The purge scanner reads per-chunk timestamps from the second 4KB + * block of each region file's header (not file mtime), so {@code touch} + * cannot fake ageing. This rewrites that 4KB timestamp table in place, + * setting all 1024 slots to {@code now - days*86400} seconds. File mtime + * is not modified. + * + *

Runs synchronously and performs disk I/O. Callers must invoke + * this from an async task, and should call {@code World.save()} on the + * main thread first to flush in-memory chunk state. + * + * @param world the gamemode overworld whose regions should be aged + * @param days how many days in the past to pretend the regions were + * last written + * @return number of {@code .mca} files successfully rewritten across + * all dimensions + */ + public int ageRegions(World world, int days) { + boolean isNether = plugin.getIWM().isNetherGenerate(world) && plugin.getIWM().isNetherIslands(world); + boolean isEnd = plugin.getIWM().isEndGenerate(world) && plugin.getIWM().isEndIslands(world); + + File worldDir = world.getWorldFolder(); + File overworldRegion = new File(worldDir, REGION); + + World netherWorld = plugin.getIWM().getNetherWorld(world); + File netherRegion = new File( + netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir), REGION); + + World endWorld = plugin.getIWM().getEndWorld(world); + File endRegion = new File( + endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir), REGION); + + long targetSeconds = (System.currentTimeMillis() / 1000L) - (days * 86400L); + int total = 0; + total += ageRegionsInFolder(overworldRegion, "overworld", targetSeconds); + if (isNether) { + total += ageRegionsInFolder(netherRegion, "nether", targetSeconds); + } + if (isEnd) { + total += ageRegionsInFolder(endRegion, "end", targetSeconds); + } + return total; + } + + private int ageRegionsInFolder(File folder, String dimension, long targetSeconds) { + if (!folder.isDirectory()) { + plugin.log("Age-regions: " + dimension + " folder does not exist, skipping: " + + folder.getAbsolutePath()); + return 0; + } + File[] files = folder.listFiles((dir, name) -> name.endsWith(".mca")); + if (files == null || files.length == 0) { + plugin.log("Age-regions: no .mca files in " + dimension + " folder " + folder.getAbsolutePath()); + return 0; + } + int count = 0; + for (File file : files) { + if (writeTimestampTable(file, targetSeconds)) { + count++; + } + } + plugin.log("Age-regions: rewrote " + count + "/" + files.length + " " + dimension + " region file(s)"); + return count; + } + + /** + * Overwrites the 4KB timestamp table (bytes 4096..8191) of a Minecraft + * {@code .mca} file with a single repeating big-endian int timestamp. + * + * @param regionFile the file to rewrite + * @param targetSeconds the Unix timestamp (seconds) to write into every slot + * @return {@code true} if the table was rewritten + */ + private boolean writeTimestampTable(File regionFile, long targetSeconds) { + if (!regionFile.exists() || regionFile.length() < 8192) { + plugin.log("Age-regions: skipping " + regionFile.getName() + + " (missing or smaller than 8192 bytes)"); + return false; + } + byte[] table = new byte[4096]; + int ts = (int) targetSeconds; + for (int i = 0; i < 1024; i++) { + int offset = i * 4; + table[offset] = (byte) (ts >> 24); + table[offset + 1] = (byte) (ts >> 16); + table[offset + 2] = (byte) (ts >> 8); + table[offset + 3] = (byte) ts; + } + try (RandomAccessFile raf = new RandomAccessFile(regionFile, "rw")) { + raf.seek(4096); + raf.write(table); + return true; + } catch (IOException e) { + plugin.logError("Age-regions: failed to rewrite timestamp table of " + + regionFile.getAbsolutePath() + ": " + e.getMessage()); + return false; + } + } + + // --------------------------------------------------------------- + // Filtering + // --------------------------------------------------------------- + + /** + * Removes regions whose island-set contains at least one island that + * cannot be deleted, returning blocking statistics. + */ + private FilterStats filterNonDeletableRegions( + Map, Set> deletableRegions, int days) { + int islandsOverLevel = 0; + int islandsPurgeProtected = 0; + int regionsBlockedByLevel = 0; + int regionsBlockedByProtection = 0; + + var iter = deletableRegions.entrySet().iterator(); + while (iter.hasNext()) { + var entry = iter.next(); + int[] regionCounts = evaluateRegionIslands(entry.getValue(), days); + if (regionCounts[0] > 0) { // shouldRemove + iter.remove(); + islandsOverLevel += regionCounts[1]; + islandsPurgeProtected += regionCounts[2]; + if (regionCounts[1] > 0) regionsBlockedByLevel++; + if (regionCounts[2] > 0) regionsBlockedByProtection++; + } + } + return new FilterStats(islandsOverLevel, islandsPurgeProtected, + regionsBlockedByLevel, regionsBlockedByProtection); + } + + /** + * Strict filter for the deleted sweep: any non-deletable island in a + * region blocks the whole region. Unlike {@link #filterNonDeletableRegions} + * this has no age/login/level logic — only the {@code deletable} flag + * matters. + */ + private FilterStats filterForDeletedSweep( + Map, Set> deletableRegions) { + int regionsBlockedByProtection = 0; + var iter = deletableRegions.entrySet().iterator(); + while (iter.hasNext()) { + var entry = iter.next(); + boolean block = false; + for (String id : entry.getValue()) { + Optional opt = plugin.getIslands().getIslandById(id); + if (opt.isEmpty()) { + // Missing rows don't block — they're already gone. + continue; + } + if (!opt.get().isDeletable()) { + block = true; + break; + } + } + if (block) { + iter.remove(); + regionsBlockedByProtection++; + } + } + return new FilterStats(0, 0, 0, regionsBlockedByProtection); + } + + private int[] evaluateRegionIslands(Set islandIds, int days) { + int shouldRemove = 0; + int levelBlocked = 0; + int purgeBlocked = 0; + for (String id : islandIds) { + Optional opt = plugin.getIslands().getIslandById(id); + if (opt.isEmpty()) { + shouldRemove = 1; + continue; + } + Island isl = opt.get(); + if (cannotDeleteIsland(isl, days)) { + shouldRemove = 1; + if (isl.isPurgeProtected()) purgeBlocked++; + if (isLevelTooHigh(isl)) levelBlocked++; + } + } + return new int[] { shouldRemove, levelBlocked, purgeBlocked }; + } + + private void logFilterStats(FilterStats stats) { + if (stats.islandsOverLevel() > 0) { + plugin.log("Purge: " + stats.islandsOverLevel() + " island(s) exceed the level threshold of " + + plugin.getSettings().getIslandPurgeLevel() + + " - preventing " + stats.regionsBlockedByLevel() + " region(s) from being purged"); + } + if (stats.islandsPurgeProtected() > 0) { + plugin.log("Purge: " + stats.islandsPurgeProtected() + " island(s) are purge-protected" + + " - preventing " + stats.regionsBlockedByProtection() + " region(s) from being purged"); + } + } + + /** + * Check if an island cannot be deleted. Purge protected, spawn, or + * unowned (non-deletable) islands cannot be deleted. Islands whose members + * recently logged in, or that exceed the level threshold, cannot be + * deleted. + * + * @param island island + * @param days the age cutoff + * @return {@code true} if the island cannot be deleted + */ + public boolean cannotDeleteIsland(Island island, int days) { + if (island.isDeletable()) { + return false; + } + long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + boolean recentLogin = island.getMemberSet().stream().anyMatch(uuid -> { + Long lastLogin = plugin.getPlayers().getLastLoginTimestamp(uuid); + if (lastLogin == null) { + lastLogin = Bukkit.getOfflinePlayer(uuid).getLastSeen(); + } + return lastLogin >= cutoffMillis; + }); + if (recentLogin) { + return true; + } + if (isLevelTooHigh(island)) { + return true; + } + return island.isPurgeProtected() || island.isSpawn() || !island.isOwned(); + } + + private boolean isLevelTooHigh(Island island) { + return plugin.getAddonsManager().getAddonByName("Level") + .map(l -> ((Level) l).getIslandLevel(island.getWorld(), island.getOwner()) + >= plugin.getSettings().getIslandPurgeLevel()) + .orElse(false); + } + + // --------------------------------------------------------------- + // Scan + // --------------------------------------------------------------- + + /** + * Finds all region files in the overworld (and optionally nether/end) + * that have not been modified in the last {@code days} days. + */ + private List> findOldRegions(World world, int days, boolean isNether, boolean isEnd) { + File worldDir = world.getWorldFolder(); + File overworldRegion = new File(worldDir, REGION); + + World netherWorld = plugin.getIWM().getNetherWorld(world); + File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(worldDir); + File netherRegion = new File(netherBase, REGION); + + World endWorld = plugin.getIWM().getEndWorld(world); + File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(worldDir); + File endRegion = new File(endBase, REGION); + + long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + + logRegionFolderPaths(overworldRegion, netherRegion, endRegion, world, isNether, isEnd); + + Set candidateNames = collectCandidateNames(overworldRegion, netherRegion, endRegion, isNether, isEnd); + plugin.log("Purge total candidate region coordinates: " + candidateNames.size()); + plugin.log("Purge checking candidate region(s) against island data, please wait..."); + + List> regions = new ArrayList<>(); + for (String name : candidateNames) { + Pair coords = parseRegionCoords(name); + if (coords == null) continue; + if (!isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, isNether, isEnd)) { + regions.add(coords); + } + } + return regions; + } + + private void logRegionFolderPaths(File overworldRegion, File netherRegion, File endRegion, + World world, boolean isNether, boolean isEnd) { + plugin.log("Purge region folders - Overworld: " + overworldRegion.getAbsolutePath() + + EXISTS_PREFIX + overworldRegion.isDirectory() + ")"); + if (isNether) { + plugin.log("Purge region folders - Nether: " + netherRegion.getAbsolutePath() + + EXISTS_PREFIX + netherRegion.isDirectory() + ")"); + } else { + plugin.log("Purge region folders - Nether: disabled (isNetherGenerate=" + + plugin.getIWM().isNetherGenerate(world) + ", isNetherIslands=" + + plugin.getIWM().isNetherIslands(world) + ")"); + } + if (isEnd) { + plugin.log("Purge region folders - End: " + endRegion.getAbsolutePath() + + EXISTS_PREFIX + endRegion.isDirectory() + ")"); + } else { + plugin.log("Purge region folders - End: disabled (isEndGenerate=" + + plugin.getIWM().isEndGenerate(world) + ", isEndIslands=" + + plugin.getIWM().isEndIslands(world) + ")"); + } + } + + private Set collectCandidateNames(File overworldRegion, File netherRegion, File endRegion, + boolean isNether, boolean isEnd) { + Set names = new HashSet<>(); + addFileNames(names, overworldRegion.listFiles((dir, name) -> name.endsWith(".mca")), "overworld"); + if (isNether) { + addFileNames(names, netherRegion.listFiles((dir, name) -> name.endsWith(".mca")), "nether"); + } + if (isEnd) { + addFileNames(names, endRegion.listFiles((dir, name) -> name.endsWith(".mca")), "end"); + } + return names; + } + + private void addFileNames(Set names, File[] files, String dimension) { + if (files != null) { + for (File f : files) names.add(f.getName()); + } + plugin.log(PURGE_FOUND + (files != null ? files.length : 0) + " " + dimension + " region files"); + } + + private Pair parseRegionCoords(String name) { + String coordsPart = name.substring(2, name.length() - 4); + String[] parts = coordsPart.split("\\."); + if (parts.length != 2) return null; + try { + return new Pair<>(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])); + } catch (NumberFormatException ex) { + return null; + } + } + + /** + * Maps each old region to the set of island IDs whose island-squares + * overlap it. Each region covers a 512x512 block square. + */ + private Map, Set> mapIslandsToRegions( + List> oldRegions, IslandGrid islandGrid) { + final int blocksPerRegion = 512; + Map, Set> regionToIslands = new HashMap<>(); + + for (Pair region : oldRegions) { + int regionMinX = region.x() * blocksPerRegion; + int regionMinZ = region.z() * blocksPerRegion; + int regionMaxX = regionMinX + blocksPerRegion - 1; + int regionMaxZ = regionMinZ + blocksPerRegion - 1; + + Set ids = new HashSet<>(); + for (IslandData data : islandGrid.getIslandsInBounds(regionMinX, regionMinZ, regionMaxX, regionMaxZ)) { + ids.add(data.id()); + } + regionToIslands.put(region, ids); + } + return regionToIslands; + } + + // --------------------------------------------------------------- + // Delete + // --------------------------------------------------------------- + + private boolean deleteRegionFiles(PurgeScanResult scan) { + int days = scan.days(); + if (days < 0) { + plugin.logError("Days is somehow negative!"); + return false; + } + // days == 0 is the "deleted sweep" sentinel — no age filter and no + // freshness recheck. days > 0 is the age-based sweep. + boolean ageGated = days > 0; + long cutoffMillis = ageGated ? System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days) : 0L; + + World world = scan.world(); + File base = world.getWorldFolder(); + File overworldRegion = new File(base, REGION); + File overworldEntities = new File(base, ENTITIES); + File overworldPoi = new File(base, POI); + + World netherWorld = plugin.getIWM().getNetherWorld(world); + File netherBase = netherWorld != null ? resolveDataFolder(netherWorld) : resolveNetherFallback(base); + File netherRegion = new File(netherBase, REGION); + File netherEntities = new File(netherBase, ENTITIES); + File netherPoi = new File(netherBase, POI); + + World endWorld = plugin.getIWM().getEndWorld(world); + File endBase = endWorld != null ? resolveDataFolder(endWorld) : resolveEndFallback(base); + File endRegion = new File(endBase, REGION); + File endEntities = new File(endBase, ENTITIES); + File endPoi = new File(endBase, POI); + + // Verify none of the files have been updated since the cutoff. + // Skipped for the deleted sweep (ageGated == false) — the deletable + // flag on the island row is the sole authority there. + if (ageGated) { + for (Pair coords : scan.deletableRegions().keySet()) { + String name = "r." + coords.x() + "." + coords.z() + ".mca"; + if (isAnyDimensionFresh(name, overworldRegion, netherRegion, endRegion, cutoffMillis, + scan.isNether(), scan.isEnd())) { + return false; + } + } + } + + DimFolders ow = new DimFolders(overworldRegion, overworldEntities, overworldPoi); + DimFolders nether = new DimFolders(netherRegion, netherEntities, netherPoi); + DimFolders end = new DimFolders(endRegion, endEntities, endPoi); + boolean allOk = true; + for (Pair coords : scan.deletableRegions().keySet()) { + String name = "r." + coords.x() + "." + coords.z() + ".mca"; + if (!deleteOneRegion(name, ow, nether, end, scan.isNether(), scan.isEnd())) { + plugin.logError("Could not delete all the region/entity/poi files for some reason"); + allOk = false; + } + } + return allOk; + } + + private boolean deleteOneRegion(String name, DimFolders overworld, DimFolders nether, DimFolders end, + boolean isNether, boolean isEnd) { + boolean ok = deleteIfExists(new File(overworld.region(), name)) + && deleteIfExists(new File(overworld.entities(), name)) + && deleteIfExists(new File(overworld.poi(), name)); + if (isNether) { + ok &= deleteIfExists(new File(nether.region(), name)); + ok &= deleteIfExists(new File(nether.entities(), name)); + ok &= deleteIfExists(new File(nether.poi(), name)); + } + if (isEnd) { + ok &= deleteIfExists(new File(end.region(), name)); + ok &= deleteIfExists(new File(end.entities(), name)); + ok &= deleteIfExists(new File(end.poi(), name)); + } + return ok; + } + + private boolean deleteIfExists(File file) { + if (!file.getParentFile().exists()) { + return true; + } + try { + Files.deleteIfExists(file.toPath()); + return true; + } catch (IOException e) { + plugin.logError("Failed to delete file: " + file.getAbsolutePath()); + return false; + } + } + + // --------------------------------------------------------------- + // Player data cleanup + // --------------------------------------------------------------- + + private void deletePlayerFromWorldFolder(World world, String islandID, + Map, Set> deletableRegions, int days) { + File playerData = resolvePlayerDataFolder(world); + plugin.getIslands().getIslandById(islandID) + .ifPresent(island -> island.getMemberSet() + .forEach(uuid -> maybeDeletePlayerData(world, uuid, playerData, deletableRegions, days))); + } + + private void maybeDeletePlayerData(World world, UUID uuid, File playerData, + Map, Set> deletableRegions, int days) { + // Deleted sweep (days == 0) skips player-data cleanup entirely — + // the player might still be active, and the age-based sweep will + // reap orphaned .dat files later. + if (days <= 0) { + return; + } + List memberOf = new ArrayList<>(plugin.getIslands().getIslands(world, uuid)); + deletableRegions.values().forEach(ids -> memberOf.removeIf(i -> ids.contains(i.getUniqueId()))); + if (!memberOf.isEmpty()) { + return; + } + if (Bukkit.getOfflinePlayer(uuid).isOp()) { + return; + } + long cutoffMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days); + Long lastLogin = plugin.getPlayers().getLastLoginTimestamp(uuid); + long actualLast = lastLogin != null ? lastLogin : Bukkit.getOfflinePlayer(uuid).getLastSeen(); + if (actualLast >= cutoffMillis) { + return; + } + deletePlayerFiles(uuid, playerData); + } + + private void deletePlayerFiles(UUID uuid, File playerData) { + if (!playerData.exists()) { + return; + } + deletePlayerFile(new File(playerData, uuid + ".dat"), "player data file"); + deletePlayerFile(new File(playerData, uuid + ".dat_old"), "player data backup file"); + } + + private void deletePlayerFile(File file, String description) { + try { + Files.deleteIfExists(file.toPath()); + } catch (IOException ex) { + plugin.logError("Failed to delete " + description + ": " + file.getAbsolutePath()); + } + } + + // --------------------------------------------------------------- + // Dimension path resolution (pre-26.1 vs 26.1.1+) + // --------------------------------------------------------------- + + private File resolveDataFolder(World world) { + File worldFolder = world.getWorldFolder(); + return switch (world.getEnvironment()) { + case NETHER -> { + File dim = new File(worldFolder, DIM_1); + yield dim.isDirectory() ? dim : worldFolder; + } + case THE_END -> { + File dim = new File(worldFolder, DIM1); + yield dim.isDirectory() ? dim : worldFolder; + } + default -> worldFolder; + }; + } + + private File resolvePlayerDataFolder(World world) { + File worldFolder = world.getWorldFolder(); + File oldPath = new File(worldFolder, PLAYERDATA); + if (oldPath.isDirectory()) { + return oldPath; + } + File root = worldFolder.getParentFile(); + if (root != null) root = root.getParentFile(); + if (root != null) root = root.getParentFile(); + if (root != null) { + File newPath = new File(root, PLAYERS + File.separator + "data"); + if (newPath.isDirectory()) { + return newPath; + } + } + return oldPath; + } + + private File resolveNetherFallback(File overworldFolder) { + File dim = new File(overworldFolder, DIM_1); + if (dim.isDirectory()) { + return dim; + } + File parent = overworldFolder.getParentFile(); + if (parent != null) { + File sibling = new File(parent, overworldFolder.getName() + "_nether"); + if (sibling.isDirectory()) { + return sibling; + } + } + return dim; + } + + private File resolveEndFallback(File overworldFolder) { + File dim = new File(overworldFolder, DIM1); + if (dim.isDirectory()) { + return dim; + } + File parent = overworldFolder.getParentFile(); + if (parent != null) { + File sibling = new File(parent, overworldFolder.getName() + "_the_end"); + if (sibling.isDirectory()) { + return sibling; + } + } + return dim; + } + + // --------------------------------------------------------------- + // Freshness checks + region timestamp reader + // --------------------------------------------------------------- + + private boolean isFileFresh(File file, long cutoffMillis) { + return file.exists() && getRegionTimestamp(file) >= cutoffMillis; + } + + private boolean isAnyDimensionFresh(String name, File overworldRegion, File netherRegion, + File endRegion, long cutoffMillis, boolean isNether, boolean isEnd) { + if (isFileFresh(new File(overworldRegion, name), cutoffMillis)) return true; + if (isNether && isFileFresh(new File(netherRegion, name), cutoffMillis)) return true; + return isEnd && isFileFresh(new File(endRegion, name), cutoffMillis); + } + + /** + * Reads the most recent per-chunk timestamp in a Minecraft .mca file + * header, in milliseconds since epoch. + */ + private long getRegionTimestamp(File regionFile) { + if (!regionFile.exists() || regionFile.length() < 8192) { + return 0L; + } + try (FileInputStream fis = new FileInputStream(regionFile)) { + byte[] buffer = new byte[4096]; + if (fis.skip(4096) != 4096) { + return 0L; + } + if (fis.read(buffer) != 4096) { + return 0L; + } + ByteBuffer bb = ByteBuffer.wrap(buffer); + bb.order(ByteOrder.BIG_ENDIAN); + long maxTimestampSeconds = 0; + for (int i = 0; i < 1024; i++) { + long timestamp = Integer.toUnsignedLong(bb.getInt()); + if (timestamp > maxTimestampSeconds) { + maxTimestampSeconds = timestamp; + } + } + return maxTimestampSeconds * 1000L; + } catch (IOException e) { + plugin.logError("Failed to read region file timestamps: " + regionFile.getAbsolutePath() + + " " + e.getMessage()); + return 0L; + } + } +} diff --git a/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java b/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java index 3f1d52c6b..6a81375cc 100644 --- a/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java +++ b/src/main/java/world/bentobox/bentobox/panels/BlueprintManagementPanel.java @@ -134,7 +134,7 @@ public void openPanel() { PanelItem item = new PanelItemBuilder() .name(bb.getDisplayName()) .description(t("edit"), t("rename")) - .icon(bb.getIcon()) + .icon(bb.getIconItemStack()) .clickHandler((panel, u, clickType, s) -> { u.closeInventory(); if (clickType.equals(ClickType.RIGHT)) { @@ -350,7 +350,7 @@ protected PanelItem getBundleIcon(BlueprintBundle bb) { return new PanelItemBuilder() .name(t("edit-description")) .description(bb.getDescription()) - .icon(bb.getIcon()) + .icon(bb.getIconItemStack()) .clickHandler((panel, u, clickType, slot) -> { u.closeInventory(); // Description conversation @@ -458,7 +458,7 @@ protected PanelItem getBlueprintItem(GameModeAddon addon, int pos, BlueprintBund return new PanelItemBuilder() .name(blueprint.getDisplayName() == null ? blueprint.getName() : blueprint.getDisplayName()) .description(desc) - .icon(blueprint.getIcon()) + .icon(blueprint.getIconItemStack()) .glow(selected != null && pos == selected.getKey()) .clickHandler((panel, u, clickType, slot) -> { // Handle the world squares diff --git a/src/main/java/world/bentobox/bentobox/panels/IconChanger.java b/src/main/java/world/bentobox/bentobox/panels/IconChanger.java index 809b15455..fdccef3ff 100644 --- a/src/main/java/world/bentobox/bentobox/panels/IconChanger.java +++ b/src/main/java/world/bentobox/bentobox/panels/IconChanger.java @@ -6,6 +6,7 @@ import org.bukkit.Sound; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.inventory.meta.ItemMeta; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.GameModeAddon; @@ -48,15 +49,26 @@ public void onInventoryClick(User user, InventoryClickEvent event) { Entry selected = blueprintManagementPanel.getSelected(); user.getPlayer().playSound(user.getLocation(), Sound.BLOCK_METAL_HIT, 1F, 1F); if (selected == null) { - // Change the Bundle Icon - bb.setIcon(icon); + // Change the Bundle Icon — prefer item model key over plain material so that + // datapacked items (e.g. paper[item_model="myserver:island_tropical"]) are stored correctly. + ItemMeta meta = event.getCurrentItem().getItemMeta(); + if (meta != null && meta.hasItemModel()) { + bb.setIcon(meta.getItemModel().toString()); + } else { + bb.setIcon(icon); + } // Save it plugin.getBlueprintsManager().saveBlueprintBundle(addon, bb); } else { - // Change the Blueprint icon + // Change the Blueprint icon — same model-key detection as for bundles Blueprint bp = selected.getValue(); - bp.setIcon(icon); + ItemMeta bpMeta = event.getCurrentItem().getItemMeta(); + if (bpMeta != null && bpMeta.hasItemModel()) { + bp.setIcon(bpMeta.getItemModel().toString()); + } else { + bp.setIcon(icon); + } // Save it plugin.getBlueprintsManager().saveBlueprint(addon, bp); } diff --git a/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java b/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java index d47f01bac..953450779 100644 --- a/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java +++ b/src/main/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanel.java @@ -414,7 +414,7 @@ private void applyTemplate(PanelItemBuilder builder, ItemTemplateRecord template } else { - builder.icon(bundle.getIcon()); + builder.icon(bundle.getIconItemStack()); } if (template.title() != null) diff --git a/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java b/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java index 7e0bf0287..496ac67ca 100644 --- a/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java +++ b/src/main/java/world/bentobox/bentobox/panels/settings/IslandDefaultSettingsTab.java @@ -20,7 +20,7 @@ * Implements a {@link Tab} that enables the default island protection settings to be changed. * These are the protection flag rank values that new islands will receive. * @author tastybento - * @since 3.14.0 + * @since 3.15.0 */ public class IslandDefaultSettingsTab extends SettingsTab implements Tab { diff --git a/src/main/java/world/bentobox/bentobox/util/IslandInfo.java b/src/main/java/world/bentobox/bentobox/util/IslandInfo.java index 579831f2c..7917baf01 100644 --- a/src/main/java/world/bentobox/bentobox/util/IslandInfo.java +++ b/src/main/java/world/bentobox/bentobox/util/IslandInfo.java @@ -119,6 +119,9 @@ public void showAdminInfo(User user, Addon addon) { if (island.isPurgeProtected()) { user.sendMessage("commands.admin.info.purge-protected"); } + if (island.isDeletable()) { + user.sendMessage("commands.admin.info.deletable"); + } // Show bundle info if available island.getMetaData("bundle").ifPresent(mdv -> user.sendMessage("commands.admin.info.bundle", TextVariables.NAME, mdv.asString())); // Fire info event to allow other addons to add to info diff --git a/src/main/java/world/bentobox/bentobox/util/ItemParser.java b/src/main/java/world/bentobox/bentobox/util/ItemParser.java index 6f47b9f3e..4648171f9 100644 --- a/src/main/java/world/bentobox/bentobox/util/ItemParser.java +++ b/src/main/java/world/bentobox/bentobox/util/ItemParser.java @@ -12,6 +12,7 @@ import org.bukkit.Bukkit; import org.bukkit.DyeColor; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.block.banner.Pattern; import org.bukkit.block.banner.PatternType; import org.bukkit.inventory.ItemStack; @@ -43,6 +44,61 @@ public class ItemParser { private static final int MAX_AMOUNT = 99; private ItemParser() {} // private constructor to hide the implicit public one. + + /** + * Resolves an icon string to a {@link Material}. + * Accepts plain material names (e.g. {@code "DIAMOND"}), vanilla namespaced keys + * (e.g. {@code "minecraft:diamond"}), and custom item-model keys + * (e.g. {@code "myserver:island_tropical"}). Custom model keys that are not + * recognised vanilla materials fall back to {@link Material#PAPER}. + * @param icon the icon string, may be null + * @return resolved Material, never null + * @since 3.0.0 + */ + public static Material parseIconMaterial(@Nullable String icon) { + if (icon == null) { + return Material.PAPER; + } + Material m = Material.matchMaterial(icon); + return m != null ? m : Material.PAPER; + } + + /** + * Resolves an icon string to an {@link ItemStack}. + *

    + *
  • Plain material name or vanilla namespaced key → {@code new ItemStack(material)}
  • + *
  • Custom item-model key (namespace:key not matching any vanilla material) → + * PAPER base item with the model applied via {@link ItemMeta#setItemModel(NamespacedKey)}
  • + *
+ * @param icon the icon string, may be null + * @return resolved ItemStack, never null + * @since 3.0.0 + */ + public static ItemStack parseIconItemStack(@Nullable String icon) { + if (icon == null) { + return new ItemStack(Material.PAPER); + } + Material m = Material.matchMaterial(icon); + if (m != null) { + return new ItemStack(m); + } + // Contains a colon but isn't a vanilla material → treat as a custom item model key + if (icon.contains(":")) { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + String[] parts = icon.split(":", 2); + try { + meta.setItemModel(new NamespacedKey(parts[0], parts[1])); + item.setItemMeta(meta); + } catch (IllegalArgumentException ignored) { + // Invalid namespace/key format — return plain PAPER + } + } + return item; + } + return new ItemStack(Material.PAPER); + } /** * Parse given string to ItemStack. * @param text String value of item stack. diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 00001355e..e487df1de 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -206,16 +206,6 @@ island: # Added since 1.7.0. delete-speed: 100 deletion: - # Toggles whether islands, when players are resetting them, should be kept in the world or deleted. - # * If set to 'true', whenever a player resets his island, his previous island will become unowned and won't be deleted from the world. - # You can, however, still delete those unowned islands through purging. - # On bigger servers, this can lead to an increasing world size. - # Yet, this allows admins to retrieve a player's old island in case of an improper use of the reset command. - # Admins can indeed re-add the player to his old island by registering him to it. - # * If set to 'false', whenever a player resets his island, his previous island will be deleted from the world. - # This is the default behaviour. - # Added since 1.13.0. - keep-previous-island-on-reset: false # Toggles how the islands are deleted. # * If set to 'false', all islands will be deleted at once. # This is fast but may cause an impact on the performance diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 59416f7bc..738f8459f 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -103,46 +103,33 @@ commands: [prefix_island], decreasing the total to [total]resets.' purge: parameters: '[days]' - description: purge [prefix_Islands] abandoned for more than [days] + description: 'delete region files for [prefix_Islands] untouched for more than [days] days (frees disk)' days-one-or-more: 'Must be at least 1 day or more' purgable-islands: 'Found [number] purgable [prefix_Islands].' - too-many: | - This is a lot and could take a very long time to delete. - Consider using Regionerator plugin for deleting world chunks - and setting keep-previous-island-on-reset: true in BentoBox's config.yml. - Then run a purge. - purge-in-progress: 'Purging in progress. Use /[label] purge stop to - cancel.' - scanning: 'Scanning [prefix_Islands] in the database. This may take a while depending - on how many you have...' - scanning-in-progress: 'Scanning in progress, please wait' + purge-in-progress: 'Purging in progress. Please wait for it to finish.' + scanning: 'Scanning region files. This may take a while depending on world size...' none-found: 'No [prefix_Islands] found to purge.' - total-islands: 'You have [number] [prefix_Islands] in your database in all worlds.' - number-error: 'Argument must be a number of days' confirm: 'Type /[label] purge confirm to start purging' - completed: 'Purging stopped.' - see-console-for-status: 'Purge started. See console for status or use - /[label] purge status.' - no-purge-in-progress: 'There is currently no purge in progress.' - regions: + failed: 'Purge completed with errors. Some region files could not be deleted. Check the server log for details.' + age-regions: parameters: '[days]' - description: 'purge islands by deleting old region files' - confirm: 'Type /[label] purge regions confirm to start purging' + description: 'debug/test: rewrite region file timestamps so they become purgable' + done: 'Aged [number] region file(s) in the current world.' + deleted: + parameters: '' + description: 'purge region files for any [prefix_island] already flagged as deleted' + confirm: 'Type /[label] purge deleted confirm to reap the region files' + deferred: 'Region files deleted. Island database entries will be removed on next server restart.' protect: description: toggle [prefix_island] purge protection move-to-island: 'Move to [prefix_an-island] first!' protecting: 'Protecting [prefix_island] from purge.' unprotecting: 'Removing purge protection.' - stop: - description: stop a purge in progress - stopping: Stopping the purge unowned: - description: purge unowned islands - unowned-islands: 'Found [number] unowned islands.' - status: - description: displays the status of the purge - status: '[purged] islands purged out of [purgeable] ([percentage] - %).' + parameters: '' + description: flag unowned [prefix_Islands] as deletable so they can be reaped + unowned-islands: 'Found [number] unowned [prefix_Islands].' + flagged: 'Flagged [number] [prefix_island](s) as deletable. Run /[label] purge deleted to reap the region files.' team: description: manage teams add: @@ -276,6 +263,7 @@ commands: protection-range-bonus-title: 'Includes these bonues:' protection-range-bonus: 'Bonus: [number]' purge-protected: '[prefix_Island] is purge protected' + deletable: '[prefix_Island] is flagged for deletion and awaiting region purge.' max-protection-range: 'Largest historical protection range: [range]' protection-coords: 'Protection coordinates: [xz1] to [xz2]' is-spawn: '[prefix_Island] is a spawn [prefix_island]' @@ -1751,6 +1739,7 @@ protection: name: World TNT damage locked: 'This [prefix_island] is locked!' locked-island-bypass: 'This [prefix_island] is locked, but you have permission to bypass.' + deletable-island-admin: '[Admin] This [prefix_island] is flagged for deletion and awaiting region purge.' protected: '[prefix_Island] protected: [description].' world-protected: 'World protected: [description].' spawn-protected: 'Spawn protected: [description].' diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index 44f8c5b59..12db2eb2e 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -609,7 +609,7 @@ commands: you-cannot-make-team: 'チームメンバーは、チーム[prefix_island]と同じ世界で島を作ることはできません。' pasting: estimated-time: '推定時間:[number]s。' - blocks: 'ブロックごとに構築します。 合計[numbber]ブロック。' + blocks: 'ブロックごとに構築します。 合計[number]ブロック。' entities: 'それを生き物で満たす。 合計[number]体のクリーチャー。' dimension-done: '[world]に島が建設されます。' done: '完了しました。あなたの島の準備が整い、あなたを待っています!' diff --git a/src/test/java/world/bentobox/bentobox/SettingsTest.java b/src/test/java/world/bentobox/bentobox/SettingsTest.java index 0d325b52f..657503652 100644 --- a/src/test/java/world/bentobox/bentobox/SettingsTest.java +++ b/src/test/java/world/bentobox/bentobox/SettingsTest.java @@ -733,26 +733,6 @@ void testSetDatabasePrefix() { assertEquals("Prefix", s.getDatabasePrefix()); } - /** - * Test method for - * {@link world.bentobox.bentobox.Settings#isKeepPreviousIslandOnReset()}. - */ - @Test - void testIsKeepPreviousIslandOnReset() { - assertFalse(s.isKeepPreviousIslandOnReset()); - } - - /** - * Test method for - * {@link world.bentobox.bentobox.Settings#setKeepPreviousIslandOnReset(boolean)}. - */ - @Test - void testSetKeepPreviousIslandOnReset() { - assertFalse(s.isKeepPreviousIslandOnReset()); - s.setKeepPreviousIslandOnReset(true); - assertTrue(s.isKeepPreviousIslandOnReset()); - } - /** * Test method for * {@link world.bentobox.bentobox.Settings#getMongodbConnectionUri()}. diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java index b9723c827..04efc21ae 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeCommandTest.java @@ -4,52 +4,55 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; -import org.bukkit.World; import org.bukkit.scheduler.BukkitScheduler; import org.bukkit.util.Vector; -import org.eclipse.jdt.annotation.NonNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; -import org.mockito.Mockito; import com.google.common.collect.ImmutableSet; import world.bentobox.bentobox.CommonTestSetup; -import world.bentobox.bentobox.Settings; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.events.island.IslandDeletedEvent; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.AddonsManager; import world.bentobox.bentobox.managers.CommandsManager; import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.island.IslandCache; +import world.bentobox.bentobox.managers.island.IslandGrid; /** - * @author tastybento + * Tests for {@link AdminPurgeCommand}. * + *

Since 3.15.0 the top-level purge command does a region-files purge + * (formerly {@code /bbox admin purge regions}). Tests drive it through the + * async scheduler mock and assert against the real {@link PurgeRegionsService}. */ class AdminPurgeCommandTest extends CommonTestSetup { @@ -57,56 +60,68 @@ class AdminPurgeCommandTest extends CommonTestSetup { private CompositeCommand ac; @Mock private User user; - - private AdminPurgeCommand apc; @Mock private Addon addon; @Mock + private BukkitScheduler scheduler; + @Mock + private IslandCache islandCache; + @Mock private PlayersManager pm; @Mock - private BukkitScheduler scheduler; + private AddonsManager addonsManager; + + @TempDir + Path tempDir; + + private AdminPurgeCommand apc; @Override @BeforeEach public void setUp() throws Exception { super.setUp(); - // Mock the method to immediately run the Runnable - when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenAnswer(invocation -> { - Runnable task = invocation.getArgument(1); - task.run(); // Immediately run the Runnable - return null; // or return a mock of the Task if needed + + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; }); mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList()); - // Command manager CommandsManager cm = mock(CommandsManager.class); when(plugin.getCommandsManager()).thenReturn(cm); when(ac.getWorld()).thenReturn(world); - when(ac.getAddon()).thenReturn(addon); when(ac.getTopLabel()).thenReturn("bsb"); - // No islands by default - when(im.getIslands()).thenReturn(Collections.emptyList()); - when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(Collections.emptyList())); - - // IWM + when(iwm.isNetherGenerate(world)).thenReturn(false); + when(iwm.isNetherIslands(world)).thenReturn(false); + when(iwm.isEndGenerate(world)).thenReturn(false); + when(iwm.isEndIslands(world)).thenReturn(false); when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); + when(iwm.getNetherWorld(world)).thenReturn(null); + when(iwm.getEndWorld(world)).thenReturn(null); + + when(plugin.getPlayers()).thenReturn(pm); + when(pm.getName(any())).thenReturn("PlayerName"); + + when(im.getIslandCache()).thenReturn(islandCache); + when(islandCache.getIslandGrid(world)).thenReturn(null); + + when(world.getWorldFolder()).thenReturn(tempDir.toFile()); - // Island - when(island.isOwned()).thenReturn(true); // Default owned - when(location.toVector()).thenReturn(new Vector(1, 2, 3)); when(island.getCenter()).thenReturn(location); + when(location.toVector()).thenReturn(new Vector(0, 0, 0)); - // Player manager - when(plugin.getPlayers()).thenReturn(pm); - when(pm.getName(any())).thenReturn("name"); + when(plugin.getAddonsManager()).thenReturn(addonsManager); + when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); - Settings settings = new Settings(); - // Settings - when(plugin.getSettings()).thenReturn(settings); + when(plugin.getPurgeRegionsService()).thenReturn(new PurgeRegionsService(plugin)); - // Command apc = new AdminPurgeCommand(ac); } @@ -116,273 +131,215 @@ public void tearDown() throws Exception { super.tearDown(); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#AdminPurgeCommand(CompositeCommand)}. - */ - @Test - void testConstructor() { - verify(addon).registerListener(apc); - } - - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#setup()}. - */ @Test void testSetup() { assertEquals("admin.purge", apc.getPermission()); assertFalse(apc.isOnlyPlayer()); assertEquals("commands.admin.purge.parameters", apc.getParameters()); assertEquals("commands.admin.purge.description", apc.getDescription()); - assertEquals(6, apc.getSubCommands().size()); + // 4 explicit subcommands (unowned, protect, age-regions, deleted) + help + assertEquals(5, apc.getSubCommands().size()); } - - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#canExecute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testCanExecuteUserStringListOfStringEmptyArgs() { - assertFalse(apc.canExecute(user, "", Collections.emptyList())); - verify(user).sendMessage("commands.help.header", - "[label]", - "BSkyBlock"); + void testCanExecuteEmptyArgs() { + assertFalse(apc.canExecute(user, "purge", Collections.emptyList())); + verify(user).sendMessage(eq("commands.help.header"), any(), any()); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#canExecute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testCanExecuteUserStringListOfStringWithArg() { - assertTrue(apc.canExecute(user, "", Collections.singletonList("23"))); + void testCanExecuteWithArgs() { + assertTrue(apc.canExecute(user, "purge", List.of("10"))); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ - @Test - void testExecuteUserStringListOfStringNotNumber() { - assertFalse(apc.execute(user, "", Collections.singletonList("abc"))); - verify(user).sendMessage("commands.admin.purge.number-error"); - } - - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ - @Test - void testExecuteUserStringListOfStringZero() { - assertFalse(apc.execute(user, "", Collections.singletonList("0"))); + @ParameterizedTest + @ValueSource(strings = {"notanumber", "0", "-3"}) + void testExecuteInvalidDays(String arg) { + assertFalse(apc.execute(user, "purge", List.of(arg))); verify(user).sendMessage("commands.admin.purge.days-one-or-more"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringNoIslands() { - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); - } + void testExecuteNullIslandGrid() { + when(islandCache.getIslandGrid(world)).thenReturn(null); - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ - @Test - void testExecuteUserStringListOfStringNoIslandsPurgeProtected() { - when(island.isPurgeProtected()).thenReturn(true); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringNoIslandsWrongWorld() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(mock(World.class)); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + void testExecuteEmptyGrid() { + wireEmptyGrid(); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringNoIslandsUnowned() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.getOwner()).thenReturn(null); - when(island.isUnowned()).thenReturn(true); - when(island.isOwned()).thenReturn(false); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + void testExecuteNoRegionFiles() throws IOException { + wireEmptyGrid(); + Files.createDirectories(tempDir.resolve("region")); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Makes sure that no spawn islands are deleted - */ @Test - void testExecuteUserStringListOfStringOnlyIslandSpawn() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.isSpawn()).thenReturn(true); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + void testExecuteOldRegionFileNoIslands() throws IOException { + wireEmptyGrid(); + createRegionFile(); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "0"); + verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ - @SuppressWarnings("deprecation") @Test - void testExecuteUserStringListOfStringNoIslandsTeamIsland() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.getOwner()).thenReturn(UUID.randomUUID()); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID(), UUID.randomUUID())); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - - // All players are up to date - OfflinePlayer op = mock(OfflinePlayer.class); - when(op.getLastPlayed()).thenReturn(System.currentTimeMillis()); - mockedBukkit.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))).thenReturn(op); - - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + void testExecuteConfirmDeletesRegions() throws IOException { + wireEmptyGrid(); + Path regionFile = tempDir.resolve("region").resolve("r.0.0.mca"); + createRegionFile(); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); + + assertTrue(apc.execute(user, "purge", List.of("confirm"))); + verify(user).sendMessage("general.success"); + assertFalse(regionFile.toFile().exists(), "Region file should have been deleted"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringNoIslandsRecentLogin() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.getOwner()).thenReturn(UUID.randomUUID()); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID())); - when(im.getIslands()).thenReturn(Collections.singleton(island)); - OfflinePlayer op = mock(OfflinePlayer.class); - when(op.getLastPlayed()).thenReturn(System.currentTimeMillis()); - mockedBukkit.when(() -> Bukkit.getOfflinePlayer(any(UUID.class))).thenReturn(op); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); - verify(user).sendMessage("commands.admin.purge.purgable-islands", "[number]", "0"); + void testExecuteConfirmWithoutPriorScan() { + assertFalse(apc.execute(user, "purge", List.of("confirm"))); + verify(user).sendMessage("commands.admin.purge.days-one-or-more"); + verify(user, never()).sendMessage("general.success"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#execute(world.bentobox.bentobox.api.user.User, java.lang.String, java.util.List)}. - */ @Test - void testExecuteUserStringListOfStringIslandsFound() { - when(island.isPurgeProtected()).thenReturn(false); - when(island.getWorld()).thenReturn(world); - when(island.getOwner()).thenReturn(UUID.randomUUID()); - when(island.isOwned()).thenReturn(true); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(UUID.randomUUID())); - when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(List.of(island))); - when(pm.getLastLoginTimestamp(any())).thenReturn(962434800L); - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); // 10 days ago + void testExecuteRecentRegionFileExcluded() throws IOException { + wireEmptyGrid(); + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + File regionFile = regionDir.resolve("r.0.0.mca").toFile(); + + byte[] data = new byte[8192]; + int nowSeconds = (int) (System.currentTimeMillis() / 1000L); + for (int i = 0; i < 1024; i++) { + int offset = 4096 + i * 4; + data[offset] = (byte) (nowSeconds >> 24); + data[offset + 1] = (byte) (nowSeconds >> 16); + data[offset + 2] = (byte) (nowSeconds >> 8); + data[offset + 3] = (byte) nowSeconds; + } + Files.write(regionFile.toPath(), data); + + assertTrue(apc.execute(user, "purge", List.of("10"))); verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.total-islands", "[number]", "1"); - verify(user, never()).sendMessage("commands.admin.purge.none-found"); - verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); + verify(user).sendMessage("commands.admin.purge.none-found"); } - - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#removeIslands()}. - */ @Test - void testRemoveIslands() { - @NonNull - Optional opIsland = Optional.of(island); - when(im.getIslandById(any())).thenReturn(opIsland); - testExecuteUserStringListOfStringIslandsFound(); - assertTrue(apc.execute(user, "", Collections.singletonList("confirm"))); - verify(im).deleteIsland(island, true, null); - verify(plugin).log(any()); - verify(user).sendMessage("commands.admin.purge.see-console-for-status", "[label]", "bsb"); - } + void testExecuteIslandWithRecentLoginIsExcluded() throws IOException { + UUID ownerUUID = wireIsland("island-1", false, false, false); + when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(System.currentTimeMillis()); + createRegionFile(); - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#onIslandDeleted(world.bentobox.bentobox.api.events.island.IslandEvent.IslandDeletedEvent)}. - */ - @Test - void testOnIslandDeletedNotInPurge() { - IslandDeletedEvent e = mock(IslandDeletedEvent.class); - apc.onIslandDeleted(e); - verify(user, Mockito.never()).sendMessage(any()); - verify(plugin, Mockito.never()).log(any()); + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#onIslandDeleted(world.bentobox.bentobox.api.events.island.IslandEvent.IslandDeletedEvent)}. - */ @Test - void testOnIslandDeletedPurgeCompleted() { - testRemoveIslands(); - IslandDeletedEvent e = mock(IslandDeletedEvent.class); - apc.onIslandDeleted(e); - verify(user).sendMessage("commands.admin.purge.completed"); - verify(plugin, Mockito.never()).log(""); + void testExecuteSpawnIslandNotPurged() throws IOException { + UUID ownerUUID = wireIsland("island-spawn", false, false, true); + when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); + createRegionFile(); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#isInPurge()}. - */ @Test - void testIsInPurge() { - assertFalse(apc.isInPurge()); - testRemoveIslands(); - assertTrue(apc.isInPurge()); + void testExecutePurgeProtectedIslandNotPurged() throws IOException { + UUID ownerUUID = wireIsland("island-protected", false, true, false); + when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); + createRegionFile(); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.none-found"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#stop()}. - */ @Test - void testStop() { - testRemoveIslands(); - assertTrue(apc.isInPurge()); - apc.stop(); - assertFalse(apc.isInPurge()); + void testExecuteDeletableIslandIncluded() throws IOException { + wireIsland("island-deletable", true, false, false); + createRegionFile(); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1"); + verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); } - /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#setUser(world.bentobox.bentobox.api.user.User)}. - */ @Test - void testSetUser() { - apc.setUser(user); - apc.removeIslands(); - verify(user, Mockito.times(1)).sendMessage(anyString()); + void testExecuteConfirmDeletesPlayerData() throws IOException { + UUID ownerUUID = wireIsland("island-deletable", true, false, false); + when(im.getIslands(world, ownerUUID)).thenReturn(List.of(island)); + + OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); + when(offlinePlayer.isOp()).thenReturn(false); + when(offlinePlayer.getLastSeen()).thenReturn(0L); + mockedBukkit.when(() -> Bukkit.getOfflinePlayer(ownerUUID)).thenReturn(offlinePlayer); + when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); + + when(im.deleteIslandId("island-deletable")).thenReturn(true); + + createRegionFile(); + Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata")); + Path playerFile = playerDataDir.resolve(ownerUUID + ".dat"); + Files.createFile(playerFile); + + assertTrue(apc.execute(user, "purge", List.of("10"))); + verify(user).sendMessage("commands.admin.purge.confirm", TextVariables.LABEL, "bsb"); + + assertTrue(apc.execute(user, "purge", List.of("confirm"))); + verify(user).sendMessage("general.success"); + verify(im).deleteIslandId("island-deletable"); + assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted"); } /** - * Test method for {@link world.bentobox.bentobox.api.commands.admin.purge.AdminPurgeCommand#getOldIslands(int)} - * @throws TimeoutException - * @throws ExecutionException - * @throws InterruptedException + * Wires the shared {@code island} mock with the given id and flags, puts it + * in an IslandGrid at (0,0), and returns the generated owner UUID. */ - @Test - void testGetOldIslands() throws InterruptedException, ExecutionException, TimeoutException { - assertTrue(apc.execute(user, "", Collections.singletonList("10"))); // 10 days ago - // First, ensure that the result is empty - CompletableFuture> result = apc.getOldIslands(10); - Set set = result.join(); - assertTrue(set.isEmpty()); - // Mocking Islands and their retrieval - Island island1 = mock(Island.class); - Island island2 = mock(Island.class); - - when(im.getIslandsASync()).thenReturn(CompletableFuture.completedFuture(List.of(island1, island2))); - // Now, check again after mocking islands - CompletableFuture> futureWithIslands = apc.getOldIslands(10); - assertTrue(futureWithIslands.get(5, TimeUnit.SECONDS).isEmpty()); // Adjust this assertion based on the expected behavior of getOldIslands + private UUID wireIsland(String id, boolean deletable, boolean purgeProtected, boolean spawn) { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn(id); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isOwned()).thenReturn(true); + when(island.isDeletable()).thenReturn(deletable); + when(island.isPurgeProtected()).thenReturn(purgeProtected); + when(island.isSpawn()).thenReturn(spawn); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + IslandGrid.IslandData data = new IslandGrid.IslandData(id, 0, 0, 100); + Collection islandList = List.of(data); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById(id)).thenReturn(Optional.of(island)); + return ownerUUID; } + private void createRegionFile() throws IOException { + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + } + + private void wireEmptyGrid() { + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + } } diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java new file mode 100644 index 000000000..c008e58fa --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeDeletedCommandTest.java @@ -0,0 +1,376 @@ +package world.bentobox.bentobox.api.commands.admin.purge; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; + +import com.google.common.collect.ImmutableSet; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.api.addons.Addon; +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.localization.TextVariables; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.AddonsManager; +import world.bentobox.bentobox.managers.CommandsManager; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.bentobox.managers.PurgeRegionsService; +import world.bentobox.bentobox.managers.island.IslandCache; +import world.bentobox.bentobox.managers.island.IslandGrid; + +/** + * Tests for {@link AdminPurgeDeletedCommand}. + * + *

Exercises the command against a real {@link PurgeRegionsService} + * wired over the mocked plugin, so the scan/filter/delete logic is + * driven end-to-end through the async scheduler mock. + */ +class AdminPurgeDeletedCommandTest extends CommonTestSetup { + + @Mock + private CompositeCommand ac; + @Mock + private User user; + @Mock + private Addon addon; + @Mock + private BukkitScheduler scheduler; + @Mock + private IslandCache islandCache; + @Mock + private PlayersManager pm; + @Mock + private AddonsManager addonsManager; + + @TempDir + Path tempDir; + + private AdminPurgeCommand apc; + private AdminPurgeDeletedCommand apdc; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + + // Run scheduled tasks inline so async/main scheduling collapses into + // a synchronous call chain for the test. + when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }); + mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList()); + + CommandsManager cm = mock(CommandsManager.class); + when(plugin.getCommandsManager()).thenReturn(cm); + when(ac.getWorld()).thenReturn(world); + when(ac.getAddon()).thenReturn(addon); + when(ac.getTopLabel()).thenReturn("bsb"); + + when(iwm.isNetherGenerate(world)).thenReturn(false); + when(iwm.isNetherIslands(world)).thenReturn(false); + when(iwm.isEndGenerate(world)).thenReturn(false); + when(iwm.isEndIslands(world)).thenReturn(false); + when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); + when(iwm.getNetherWorld(world)).thenReturn(null); + when(iwm.getEndWorld(world)).thenReturn(null); + + when(plugin.getPlayers()).thenReturn(pm); + when(pm.getName(any())).thenReturn("PlayerName"); + + when(im.getIslandCache()).thenReturn(islandCache); + when(islandCache.getIslands(world)).thenReturn(Collections.emptyList()); + when(islandCache.getIslandGrid(world)).thenReturn(null); + + when(world.getWorldFolder()).thenReturn(tempDir.toFile()); + + when(island.getCenter()).thenReturn(location); + when(location.toVector()).thenReturn(new Vector(0, 0, 0)); + + when(plugin.getAddonsManager()).thenReturn(addonsManager); + when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); + + when(plugin.getPurgeRegionsService()).thenReturn(new PurgeRegionsService(plugin)); + + apc = new AdminPurgeCommand(ac); + apdc = new AdminPurgeDeletedCommand(apc); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * The command is not registered as a listener — it has no event handlers. + */ + @Test + void testConstructor() { + assertEquals("admin.purge.deleted", apdc.getPermission()); + } + + @Test + void testSetup() { + assertEquals("admin.purge.deleted", apdc.getPermission()); + assertFalse(apdc.isOnlyPlayer()); + assertEquals("commands.admin.purge.deleted.parameters", apdc.getParameters()); + assertEquals("commands.admin.purge.deleted.description", apdc.getDescription()); + } + + /** + * canExecute should accept zero arguments — unlike the age-based command, + * the deleted sweep takes no parameters. + */ + @Test + void testCanExecuteNoArgs() { + assertTrue(apdc.canExecute(user, "deleted", Collections.emptyList())); + } + + /** + * With an empty island cache the scan finds nothing and the user is told. + */ + @Test + void testExecuteNoIslands() { + when(islandCache.getIslands(world)).thenReturn(Collections.emptyList()); + IslandGrid grid = mock(IslandGrid.class); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.scanning"); + verify(user).sendMessage("commands.admin.purge.none-found"); + } + + /** + * A null island grid (world never registered) yields none-found. + */ + @Test + void testExecuteNullGrid() { + when(islandCache.getIslandGrid(world)).thenReturn(null); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.none-found"); + } + + /** + * Non-deletable islands must be ignored by the deleted sweep — no candidate + * regions, no confirm prompt. + */ + @Test + void testExecuteNonDeletableIgnored() { + when(island.getUniqueId()).thenReturn("island-active"); + when(island.isDeletable()).thenReturn(false); + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(100); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(island)); + IslandGrid grid = mock(IslandGrid.class); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.none-found"); + } + + /** + * A lone deletable island's region is surfaced and the confirm prompt fires. + */ + @Test + void testExecuteDeletableIslandFound() { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-deletable"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isDeletable()).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + // Occupies region r.0.0 (blocks 0..100) + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(100); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(island)); + IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(List.of(data)); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1"); + verify(user).sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, "deleted"); + } + + /** + * A region shared between a deletable and a non-deletable neighbour must + * be dropped by the strict filter — non-deletable neighbour blocks reap. + */ + @Test + void testExecuteStrictFilterBlocksMixedRegion() { + UUID owner1 = UUID.randomUUID(); + UUID owner2 = UUID.randomUUID(); + + // Deletable island straddling r.0.0 + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMemberSet()).thenReturn(ImmutableSet.of(owner1)); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + // Active neighbour sharing r.0.0 + Island active = mock(Island.class); + when(active.getUniqueId()).thenReturn("act"); + when(active.isDeletable()).thenReturn(false); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable, active)); + IslandGrid grid = mock(IslandGrid.class); + Collection inRegion = List.of( + new IslandGrid.IslandData("del", 0, 0, 100), + new IslandGrid.IslandData("act", 200, 200, 100)); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(inRegion); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + when(im.getIslandById("act")).thenReturn(Optional.of(active)); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.none-found"); + } + + /** + * Confirm after a scan deletes the region file on disk regardless of age. + * Crucially this file's timestamp is "now" — the age-based sweep would + * never touch it, but the deleted sweep must because the island row is + * flagged. + */ + @Test + void testExecuteConfirmReapsFreshRegion() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-deletable"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isDeletable()).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(100); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(island)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandGrid.IslandData("island-deletable", 0, 0, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); + when(im.deleteIslandId("island-deletable")).thenReturn(true); + + // Build a fresh 8KB .mca with "now" timestamps — age sweep would skip + // this; deleted sweep must still reap it. + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Path regionFile = regionDir.resolve("r.0.0.mca"); + byte[] data = new byte[8192]; + int nowSeconds = (int) (System.currentTimeMillis() / 1000L); + for (int i = 0; i < 1024; i++) { + int offset = 4096 + i * 4; + data[offset] = (byte) (nowSeconds >> 24); + data[offset + 1] = (byte) (nowSeconds >> 16); + data[offset + 2] = (byte) (nowSeconds >> 8); + data[offset + 3] = (byte) nowSeconds; + } + Files.write(regionFile, data); + + // Scan + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.deleted.confirm", TextVariables.LABEL, "deleted"); + + // Confirm + assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); + verify(user).sendMessage("commands.admin.purge.deleted.deferred"); + assertFalse(regionFile.toFile().exists(), "Fresh region file should be reaped by the deleted sweep"); + // DB row deletion is deferred to shutdown for days==0 (deleted sweep). + verify(im, never()).deleteIslandId("island-deletable"); + } + + /** + * Player data files must NOT be touched by the deleted sweep — the active + * player could still be playing and reaping their .dat would be harmful. + */ + @Test + void testExecuteConfirmLeavesPlayerData() throws IOException { + UUID ownerUUID = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn("island-deletable"); + when(island.getOwner()).thenReturn(ownerUUID); + when(island.isDeletable()).thenReturn(true); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); + when(island.getCenter()).thenReturn(location); + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(100); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(island)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandGrid.IslandData("island-deletable", 0, 0, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); + when(im.deleteIslandId("island-deletable")).thenReturn(true); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.createFile(regionDir.resolve("r.0.0.mca")); + Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata")); + Path playerFile = playerDataDir.resolve(ownerUUID + ".dat"); + Files.createFile(playerFile); + + assertTrue(apdc.execute(user, "deleted", Collections.emptyList())); + assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); + verify(user).sendMessage("commands.admin.purge.deleted.deferred"); + assertTrue(playerFile.toFile().exists(), + "Deleted sweep must NOT remove player data — only the age sweep does"); + } + + /** + * A confirm before any scan falls through to the scan path (no args == + * empty args are equivalent). It should not produce an error. + */ + @Test + void testExecuteConfirmWithoutPriorScan() { + assertTrue(apdc.execute(user, "deleted", List.of("confirm"))); + verify(user).sendMessage("commands.admin.purge.scanning"); + } +} diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java deleted file mode 100644 index b27e00563..000000000 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeRegionsCommandTest.java +++ /dev/null @@ -1,516 +0,0 @@ -package world.bentobox.bentobox.api.commands.admin.purge; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.scheduler.BukkitScheduler; -import org.bukkit.util.Vector; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; - -import com.google.common.collect.ImmutableSet; - -import world.bentobox.bentobox.CommonTestSetup; -import world.bentobox.bentobox.api.addons.Addon; -import world.bentobox.bentobox.api.commands.CompositeCommand; -import world.bentobox.bentobox.api.localization.TextVariables; -import world.bentobox.bentobox.api.user.User; -import world.bentobox.bentobox.managers.AddonsManager; -import world.bentobox.bentobox.managers.CommandsManager; -import world.bentobox.bentobox.managers.PlayersManager; -import world.bentobox.bentobox.managers.island.IslandCache; -import world.bentobox.bentobox.managers.island.IslandGrid; - -/** - * Tests for {@link AdminPurgeRegionsCommand}. - */ -class AdminPurgeRegionsCommandTest extends CommonTestSetup { - - @Mock - private CompositeCommand ac; - @Mock - private User user; - @Mock - private Addon addon; - @Mock - private BukkitScheduler scheduler; - @Mock - private IslandCache islandCache; - @Mock - private PlayersManager pm; - @Mock - private AddonsManager addonsManager; - - @TempDir - Path tempDir; - - private AdminPurgeCommand apc; - private AdminPurgeRegionsCommand aprc; - - @Override - @BeforeEach - public void setUp() throws Exception { - super.setUp(); - - // Scheduler - run tasks immediately (both async and sync) - when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { - invocation.getArgument(1).run(); - return null; - }); - when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { - invocation.getArgument(1).run(); - return null; - }); - mockedBukkit.when(Bukkit::getScheduler).thenReturn(scheduler); - mockedBukkit.when(Bukkit::getWorlds).thenReturn(Collections.emptyList()); - - // Command manager - CommandsManager cm = mock(CommandsManager.class); - when(plugin.getCommandsManager()).thenReturn(cm); - when(ac.getWorld()).thenReturn(world); - when(ac.getAddon()).thenReturn(addon); - when(ac.getTopLabel()).thenReturn("bsb"); - - // IWM - no nether/end by default - when(iwm.isNetherGenerate(world)).thenReturn(false); - when(iwm.isNetherIslands(world)).thenReturn(false); - when(iwm.isEndGenerate(world)).thenReturn(false); - when(iwm.isEndIslands(world)).thenReturn(false); - when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); - when(iwm.getNetherWorld(world)).thenReturn(null); - when(iwm.getEndWorld(world)).thenReturn(null); - - // Players manager - when(plugin.getPlayers()).thenReturn(pm); - when(pm.getName(any())).thenReturn("PlayerName"); - - // Island cache - when(im.getIslandCache()).thenReturn(islandCache); - when(islandCache.getIslandGrid(world)).thenReturn(null); - - // World folder points to our temp directory - when(world.getWorldFolder()).thenReturn(tempDir.toFile()); - - // Island - when(island.getCenter()).thenReturn(location); - when(location.toVector()).thenReturn(new Vector(0, 0, 0)); - - // Addons manager (used by canDeleteIsland for Level check) - when(plugin.getAddonsManager()).thenReturn(addonsManager); - when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); - - // Create commands - apc = new AdminPurgeCommand(ac); - aprc = new AdminPurgeRegionsCommand(apc); - } - - @Override - @AfterEach - public void tearDown() throws Exception { - super.tearDown(); - } - - /** - * Verify the command registers itself as a Bukkit listener via the addon. - * AdminPurgeCommand.setup() creates one instance, and setUp() creates a second — so two calls. - */ - @Test - void testConstructor() { - verify(addon, times(2)).registerListener(any(AdminPurgeRegionsCommand.class)); - } - - /** - * Verify command metadata set in setup(). - */ - @Test - void testSetup() { - assertEquals("admin.purge.regions", aprc.getPermission()); - assertFalse(aprc.isOnlyPlayer()); - assertEquals("commands.admin.purge.regions.parameters", aprc.getParameters()); - assertEquals("commands.admin.purge.regions.description", aprc.getDescription()); - } - - /** - * canExecute with no args should show help and return false. - */ - @Test - void testCanExecuteEmptyArgs() { - assertFalse(aprc.canExecute(user, "regions", Collections.emptyList())); - verify(user).sendMessage(eq("commands.help.header"), any(), any()); - } - - /** - * canExecute with a valid argument should return true. - */ - @Test - void testCanExecuteWithArgs() { - assertTrue(aprc.canExecute(user, "regions", List.of("10"))); - verify(user, never()).sendMessage("commands.admin.purge.purge-in-progress", TextVariables.LABEL, "bsb"); - } - - /** - * A non-numeric argument should produce an error message and return false. - */ - @Test - void testExecuteNotANumber() { - assertFalse(aprc.execute(user, "regions", List.of("notanumber"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - } - - /** - * Zero days is invalid — must be one or more. - */ - @Test - void testExecuteZeroDays() { - assertFalse(aprc.execute(user, "regions", List.of("0"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - } - - /** - * Negative days is invalid. - */ - @Test - void testExecuteNegativeDays() { - assertFalse(aprc.execute(user, "regions", List.of("-3"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - } - - /** - * When the island grid is null (no islands registered for the world), - * the command should report none found. - */ - @Test - void testExecuteNullIslandGrid() { - when(islandCache.getIslandGrid(world)).thenReturn(null); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * When the island grid is empty (no islands), - * the command should report none found. - */ - @Test - void testExecuteEmptyGrid() { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * When there are no .mca files in the region folder, none-found should be sent. - */ - @Test - void testExecuteNoRegionFiles() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - // Create the region directory but leave it empty - Files.createDirectories(tempDir.resolve("region")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * When there is an old region file with no associated islands (empty grid), - * the command should propose deletion and ask for confirmation. - */ - @Test - void testExecuteOldRegionFileNoIslands() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - // Create a small .mca file — getRegionTimestamp() returns 0 for files < 8192 bytes, - // which is treated as very old (older than any cutoff). - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - // 0 islands found, but the empty region itself is deletable → confirm prompt - verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "0"); - verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions"); - } - - /** - * After scanning finds deletable regions, executing "confirm" should delete - * the region files and send the success message. - */ - @Test - void testExecuteConfirmDeletesRegions() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Path regionFile = regionDir.resolve("r.0.0.mca"); - Files.createFile(regionFile); - - // First execution: scan and find the old empty region - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions"); - - // Second execution: confirm deletion - assertTrue(aprc.execute(user, "regions", List.of("confirm"))); - verify(user).sendMessage("general.success"); - assertFalse(regionFile.toFile().exists(), "Region file should have been deleted"); - } - - /** - * If "confirm" is sent before a scan, it should not trigger deletion - * and should fall through to normal argument parsing (returning false for "confirm" - * which is not a valid number). - */ - @Test - void testExecuteConfirmWithoutPriorScan() { - assertFalse(aprc.execute(user, "regions", List.of("confirm"))); - verify(user).sendMessage("commands.admin.purge.days-one-or-more"); - verify(user, never()).sendMessage("general.success"); - } - - /** - * A recent region file (newer than the cutoff) should NOT be included in the deletion list. - * The command should report none found. - */ - @Test - void testExecuteRecentRegionFileExcluded() throws IOException { - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(Collections.emptyList()); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - - // Create a valid 8192-byte .mca file with a very recent timestamp so it is excluded. - // The timestamp table occupies the second 4KB block; we'll write a recent Unix timestamp. - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - File regionFile = regionDir.resolve("r.0.0.mca").toFile(); - - // Build a minimal 8192-byte region file with current timestamp in all 1024 chunk slots - byte[] data = new byte[8192]; - // Write current time (in seconds) as big-endian int into each 4-byte slot of the timestamp table - int nowSeconds = (int) (System.currentTimeMillis() / 1000L); - for (int i = 0; i < 1024; i++) { - int offset = 4096 + i * 4; - data[offset] = (byte) (nowSeconds >> 24); - data[offset + 1] = (byte) (nowSeconds >> 16); - data[offset + 2] = (byte) (nowSeconds >> 8); - data[offset + 3] = (byte) nowSeconds; - } - Files.write(regionFile.toPath(), data); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.scanning"); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * An island whose member logged in recently must be excluded from purge candidates. - */ - @Test - void testExecuteIslandWithRecentLoginIsExcluded() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-1"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - // Island occupies region r.0.0 (minX=0, minZ=0, range=100) - IslandGrid.IslandData data = new IslandGrid.IslandData("island-1", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-1")).thenReturn(Optional.of(island)); - - // Owner logged in recently — island must be protected from purge - when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(System.currentTimeMillis()); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * Spawn islands must never be purged. - */ - @Test - void testExecuteSpawnIslandNotPurged() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-spawn"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(true); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-spawn", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-spawn")).thenReturn(Optional.of(island)); - - // Owner hasn't logged in for a long time - when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * Purge-protected islands must never be purged. - */ - @Test - void testExecutePurgeProtectedIslandNotPurged() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-protected"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(false); - when(island.isPurgeProtected()).thenReturn(true); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-protected", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-protected")).thenReturn(Optional.of(island)); - - when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.none-found"); - } - - /** - * An island marked as deletable should always be eligible regardless of other flags. - */ - @Test - void testExecuteDeletableIslandIncluded() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-deletable"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(true); // deletable flag set - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); - - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - - assertTrue(aprc.execute(user, "regions", List.of("10"))); - // The island is deletable → canDeleteIsland returns false → region is kept → confirm prompt - verify(user).sendMessage("commands.admin.purge.purgable-islands", TextVariables.NUMBER, "1"); - verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions"); - } - - /** - * Deleting a region where the island has members removes their playerdata files - * when they have no remaining islands and haven't logged in recently. - */ - @Test - void testExecuteConfirmDeletesPlayerData() throws IOException { - UUID ownerUUID = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn("island-deletable"); - when(island.getOwner()).thenReturn(ownerUUID); - when(island.isOwned()).thenReturn(true); - when(island.isDeletable()).thenReturn(true); - when(island.isPurgeProtected()).thenReturn(false); - when(island.isSpawn()).thenReturn(false); - when(island.getMemberSet()).thenReturn(ImmutableSet.of(ownerUUID)); - when(island.getCenter()).thenReturn(location); - - IslandGrid.IslandData data = new IslandGrid.IslandData("island-deletable", 0, 0, 100); - Collection islandList = List.of(data); - IslandGrid grid = mock(IslandGrid.class); - when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(islandList); - when(islandCache.getIslandGrid(world)).thenReturn(grid); - when(im.getIslandById("island-deletable")).thenReturn(Optional.of(island)); - - // Player has no remaining islands after deletion - when(im.getIslands(world, ownerUUID)).thenReturn(List.of(island)); - - // Player is not op and hasn't logged in recently - OfflinePlayer offlinePlayer = mock(OfflinePlayer.class); - when(offlinePlayer.isOp()).thenReturn(false); - when(offlinePlayer.getLastSeen()).thenReturn(0L); - mockedBukkit.when(() -> Bukkit.getOfflinePlayer(ownerUUID)).thenReturn(offlinePlayer); - when(pm.getLastLoginTimestamp(ownerUUID)).thenReturn(0L); - - // Island cache operations - when(im.deleteIslandId("island-deletable")).thenReturn(true); - - // Create region and playerdata files - Path regionDir = Files.createDirectories(tempDir.resolve("region")); - Files.createFile(regionDir.resolve("r.0.0.mca")); - Path playerDataDir = Files.createDirectories(tempDir.resolve("playerdata")); - Path playerFile = playerDataDir.resolve(ownerUUID + ".dat"); - Files.createFile(playerFile); - - // Scan - assertTrue(aprc.execute(user, "regions", List.of("10"))); - verify(user).sendMessage("commands.admin.purge.regions.confirm", TextVariables.LABEL, "regions"); - - // Confirm - assertTrue(aprc.execute(user, "regions", List.of("confirm"))); - verify(user).sendMessage("general.success"); - verify(im).deleteIslandId("island-deletable"); - assertFalse(playerFile.toFile().exists(), "Player data file should have been deleted"); - } -} diff --git a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java index c3ff40833..9f475efe8 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/admin/purge/AdminPurgeUnownedCommandTest.java @@ -8,6 +8,7 @@ import java.util.Collections; +import org.bukkit.util.Vector; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -20,8 +21,11 @@ import world.bentobox.bentobox.managers.CommandsManager; /** - * @author Poslovitch - * + * Tests for {@link AdminPurgeUnownedCommand}. The command scans for orphan + * islands and (on confirmation) soft-deletes them so the region-files purge + * can reap their {@code .mca} files later. These tests cover the scan phase; + * the confirmation-triggered soft-delete path is exercised via + * {@link world.bentobox.bentobox.managers.IslandsManager#deleteIsland}. */ class AdminPurgeUnownedCommandTest extends CommonTestSetup { @@ -34,14 +38,12 @@ class AdminPurgeUnownedCommandTest extends CommonTestSetup { private AdminPurgeUnownedCommand apuc; @Mock private Addon addon; - @Override @BeforeEach public void setUp() throws Exception { super.setUp(); - // Command manager CommandsManager cm = mock(CommandsManager.class); when(plugin.getCommandsManager()).thenReturn(cm); when(ac.getWorld()).thenReturn(world); @@ -49,20 +51,19 @@ public void setUp() throws Exception { when(ac.getAddon()).thenReturn(addon); when(ac.getTopLabel()).thenReturn("bsb"); - // No islands by default when(im.getIslands()).thenReturn(Collections.emptyList()); - // IWM when(iwm.getFriendlyName(any())).thenReturn("BSkyBlock"); - // Island when(island.getWorld()).thenReturn(world); when(island.isSpawn()).thenReturn(false); when(island.isPurgeProtected()).thenReturn(false); - when(island.isOwned()).thenReturn(true); // Default owned + when(island.isOwned()).thenReturn(true); when(island.isUnowned()).thenReturn(false); + when(island.isDeletable()).thenReturn(false); + when(island.getCenter()).thenReturn(location); + when(location.toVector()).thenReturn(new Vector(0, 0, 0)); - // Command apc = new AdminPurgeCommand(ac); apuc = new AdminPurgeUnownedCommand(apc); } @@ -74,7 +75,7 @@ public void tearDown() throws Exception { } /** - * Makes sure no spawn islands are purged whatsoever + * Spawn islands must never be flagged deletable. */ @Test void testNoPurgeIfIslandIsSpawn() { @@ -84,6 +85,9 @@ void testNoPurgeIfIslandIsSpawn() { verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0"); } + /** + * Owned islands must never be flagged deletable. + */ @Test void testNoPurgeIfIslandIsOwned() { when(im.getIslands()).thenReturn(Collections.singleton(island)); @@ -91,16 +95,21 @@ void testNoPurgeIfIslandIsOwned() { verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0"); } + /** + * A genuine orphan gets counted and (later, on confirm) flagged. + */ @Test void testPurgeIfIslandIsUnowned() { when(island.isOwned()).thenReturn(false); when(island.isUnowned()).thenReturn(true); - when(island.getWorld()).thenReturn(world); when(im.getIslands()).thenReturn(Collections.singleton(island)); assertTrue(apuc.execute(user, "", Collections.emptyList())); verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "1"); } + /** + * Purge-protected islands must never be flagged deletable, even if unowned. + */ @Test void testNoPurgeIfIslandIsPurgeProtected() { when(island.isPurgeProtected()).thenReturn(true); @@ -108,4 +117,18 @@ void testNoPurgeIfIslandIsPurgeProtected() { assertTrue(apuc.execute(user, "", Collections.emptyList())); verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0"); } + + /** + * Islands already flagged deletable must not be counted again — they are + * already in the queue the regions-purge will drain. + */ + @Test + void testNoPurgeIfIslandAlreadyDeletable() { + when(island.isOwned()).thenReturn(false); + when(island.isUnowned()).thenReturn(true); + when(island.isDeletable()).thenReturn(true); + when(im.getIslands()).thenReturn(Collections.singleton(island)); + assertTrue(apuc.execute(user, "", Collections.emptyList())); + verify(user).sendMessage("commands.admin.purge.unowned.unowned-islands", "[number]", "0"); + } } diff --git a/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java b/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java new file mode 100644 index 000000000..82a05946a --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/blueprints/dataobjects/BlueprintBundleTest.java @@ -0,0 +1,133 @@ +package world.bentobox.bentobox.blueprints.dataobjects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import world.bentobox.bentobox.CommonTestSetup; + +/** + * Tests for {@link BlueprintBundle} icon field parsing. + * @author tastybento + */ +class BlueprintBundleTest extends CommonTestSetup { + + private BlueprintBundle bundle; + + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + bundle = new BlueprintBundle(); + } + + @AfterEach + @Override + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * Default icon should be PAPER. + */ + @Test + void testDefaultIcon() { + assertEquals(Material.PAPER, bundle.getIcon()); + } + + /** + * Default getIconItemStack should return a PAPER ItemStack. + */ + @Test + void testDefaultIconItemStack() { + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.PAPER, is.getType()); + } + + /** + * Plain material name (e.g. "DIAMOND") should resolve correctly. + */ + @Test + void testPlainMaterialName() { + bundle.setIcon("DIAMOND"); + assertEquals(Material.DIAMOND, bundle.getIcon()); + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.DIAMOND, is.getType()); + } + + /** + * Setting icon via Material enum should store correctly. + */ + @Test + void testSetIconMaterial() { + bundle.setIcon(Material.GOLD_INGOT); + assertEquals(Material.GOLD_INGOT, bundle.getIcon()); + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.GOLD_INGOT, is.getType()); + } + + /** + * Setting icon via Material enum with null should fall back to PAPER. + */ + @Test + void testSetIconMaterialNull() { + bundle.setIcon((Material) null); + assertEquals(Material.PAPER, bundle.getIcon()); + } + + /** + * Setting icon via String with null should fall back to PAPER. + */ + @Test + void testSetIconStringNull() { + bundle.setIcon((String) null); + assertEquals(Material.PAPER, bundle.getIcon()); + } + + /** + * Vanilla namespaced material key (e.g. "minecraft:diamond") should resolve to the correct Material. + */ + @Test + void testNamespacedVanillaMaterial() { + bundle.setIcon("minecraft:diamond"); + assertEquals(Material.DIAMOND, bundle.getIcon()); + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.DIAMOND, is.getType()); + } + + /** + * A custom item-model key (namespace:key that is not a vanilla material) should return + * PAPER as the base material, since the player never sees the base item. + */ + @Test + void testCustomItemModelKey() { + bundle.setIcon("myserver:island_tropical"); + // getIcon() falls back to PAPER for unrecognised model keys + assertEquals(Material.PAPER, bundle.getIcon()); + // getIconItemStack() returns a PAPER-based item + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.PAPER, is.getType()); + } + + /** + * An icon string without a colon that is not a valid material should fall back to PAPER. + */ + @Test + void testUnknownMaterialName() { + bundle.setIcon("NOT_A_REAL_MATERIAL"); + assertEquals(Material.PAPER, bundle.getIcon()); + ItemStack is = bundle.getIconItemStack(); + assertNotNull(is); + assertEquals(Material.PAPER, is.getType()); + } +} diff --git a/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java index 8a0eac4a9..551d4443d 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/InvincibleVisitorsListenerTest.java @@ -26,9 +26,13 @@ import org.bukkit.Location; import org.bukkit.World; import org.bukkit.World.Environment; +import org.bukkit.entity.ExperienceOrb; import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Zombie; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageEvent.DamageCause; +import org.bukkit.event.entity.EntityTargetEvent.TargetReason; +import org.bukkit.event.entity.EntityTargetLivingEntityEvent; import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; @@ -291,4 +295,59 @@ void testOnVisitorGetDamageVoidPlayerHasIsland() { verify(im).homeTeleportAsync(any(), eq(mockPlayer)); verify(pim).callEvent(any(InvincibleVistorFlagDamageRemovalEvent.class)); } + + /** + * Test that onVisitorTargeting cancels mob targeting of a visitor when ENTITY_ATTACK is in IV settings. + */ + @Test + void testOnVisitorTargetingCancelsMobTargeting() { + ivSettings.add(DamageCause.ENTITY_ATTACK.name()); + Zombie zombie = mock(Zombie.class); + when(zombie.getWorld()).thenReturn(world); + EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER); + listener.onVisitorTargeting(e); + assertTrue(e.isCancelled()); + } + + /** + * Test that onVisitorTargeting does NOT cancel experience orb targeting of a visitor. + * XP orbs should still be able to track visitors for pickup, regardless of IV settings. + */ + @Test + void testOnVisitorTargetingDoesNotCancelExperienceOrbTargeting() { + ivSettings.add(DamageCause.ENTITY_ATTACK.name()); + ExperienceOrb orb = mock(ExperienceOrb.class); + when(orb.getWorld()).thenReturn(world); + EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(orb, mockPlayer, TargetReason.CLOSEST_PLAYER); + listener.onVisitorTargeting(e); + assertFalse(e.isCancelled()); + } + + /** + * Test that onVisitorTargeting does not cancel when entity_attack is not in IV settings. + */ + @Test + void testOnVisitorTargetingNotInIvSettings() { + // ENTITY_ATTACK is not in ivSettings by default + Zombie zombie = mock(Zombie.class); + when(zombie.getWorld()).thenReturn(world); + EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER); + listener.onVisitorTargeting(e); + assertFalse(e.isCancelled()); + } + + /** + * Test that onVisitorTargeting does not cancel when user is on their own island. + */ + @Test + void testOnVisitorTargetingNotVisitor() { + ivSettings.add(DamageCause.ENTITY_ATTACK.name()); + when(im.userIsOnIsland(any(), any())).thenReturn(true); + Zombie zombie = mock(Zombie.class); + when(zombie.getWorld()).thenReturn(world); + EntityTargetLivingEntityEvent e = new EntityTargetLivingEntityEvent(zombie, mockPlayer, TargetReason.CLOSEST_PLAYER); + listener.onVisitorTargeting(e); + assertFalse(e.isCancelled()); + } + } diff --git a/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java new file mode 100644 index 000000000..fd2ac1db7 --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/managers/HousekeepingManagerTest.java @@ -0,0 +1,339 @@ +package world.bentobox.bentobox.managers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.Settings; +import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.managers.PurgeRegionsService.FilterStats; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; + +/** + * Tests for {@link HousekeepingManager}. + * + *

Focus areas: + *

    + *
  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.setHousekeepingAgeEnabled(true); + settings.setHousekeepingIntervalDays(1); + settings.setHousekeepingRegionAgeDays(30); + settings.setHousekeepingDeletedIntervalHours(1); + + when(purgeService.scan(eq(world), anyInt())).thenReturn(emptyScan(30)); + when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + hm.runNow(); + + assertTrue(hm.getLastAgeRunMillis() > 0, "age cycle timestamp should be set"); + assertTrue(hm.getLastDeletedRunMillis() > 0, "deleted cycle timestamp should be set"); + + // Read back from disk with a second manager instance to prove the + // state is actually persisted, not just in-memory. + HousekeepingManager reread = new HousekeepingManager(plugin); + assertEquals(hm.getLastAgeRunMillis(), reread.getLastAgeRunMillis()); + assertEquals(hm.getLastDeletedRunMillis(), reread.getLastDeletedRunMillis()); + } + + // ------------------------------------------------------------------ + // Cycle dispatch + // ------------------------------------------------------------------ + + /** + * When the feature is disabled both cycles are skipped regardless of + * what {@code runNow} does with the schedule. + */ + @Test + void testDisabledFeatureSkipsAllCycles() throws Exception { + settings.setHousekeepingDeletedEnabled(false); + settings.setHousekeepingAgeEnabled(false); + + HousekeepingManager hm = new HousekeepingManager(plugin); + // Invoke the internal hourly check via reflection so we go through + // the enabled gate (runNow bypasses that gate). + invokeCheckAndMaybeRun(hm); + + verify(purgeService, never()).scan(any(), anyInt()); + verify(purgeService, never()).scanDeleted(any()); + } + + /** + * {@code runNow()} fires both cycles unconditionally (subject to the + * enabled flag) and dispatches them to the service. + */ + @Test + void testRunNowDispatchesBothCycles() { + settings.setHousekeepingAgeEnabled(true); + settings.setHousekeepingRegionAgeDays(30); + + when(purgeService.scan(eq(world), eq(30))).thenReturn(emptyScan(30)); + when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + hm.runNow(); + + verify(purgeService, times(1)).scan(world, 30); + verify(purgeService, times(1)).scanDeleted(world); + } + + /** + * When the deleted interval is 0 the hourly check only runs the age + * cycle — the deleted cycle is effectively disabled. + */ + @Test + void testDeletedIntervalZeroDisablesDeletedCycle() throws Exception { + settings.setHousekeepingAgeEnabled(true); + settings.setHousekeepingIntervalDays(1); + settings.setHousekeepingRegionAgeDays(30); + settings.setHousekeepingDeletedIntervalHours(0); // disabled + + when(purgeService.scan(eq(world), eq(30))).thenReturn(emptyScan(30)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + invokeCheckAndMaybeRun(hm); + + verify(purgeService, times(1)).scan(world, 30); + verify(purgeService, never()).scanDeleted(any()); + } + + /** + * When the age interval is 0 only the deleted cycle runs. + */ + @Test + void testAgeIntervalZeroDisablesAgeCycle() throws Exception { + settings.setHousekeepingIntervalDays(0); // disabled + settings.setHousekeepingDeletedIntervalHours(1); + + when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + invokeCheckAndMaybeRun(hm); + + verify(purgeService, never()).scan(any(), anyInt()); + verify(purgeService, times(1)).scanDeleted(world); + } + + /** + * If both cycles ran recently (last-run timestamps inside their intervals) + * the hourly check does nothing. + */ + @Test + void testBothCyclesRecentlyRunIsNoop() throws Exception { + settings.setHousekeepingIntervalDays(30); + settings.setHousekeepingDeletedIntervalHours(24); + + // Pre-populate the state file so both timestamps are "just now". + long now = System.currentTimeMillis(); + Files.createDirectories(stateFile.getParentFile().toPath()); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("lastAgeRunMillis", now); + yaml.set("lastDeletedRunMillis", now); + yaml.save(stateFile); + + HousekeepingManager hm = new HousekeepingManager(plugin); + invokeCheckAndMaybeRun(hm); + + verify(purgeService, never()).scan(any(), anyInt()); + verify(purgeService, never()).scanDeleted(any()); + } + + /** + * If only the deleted cycle has aged past its interval, only it runs — + * the age cycle is left alone. + */ + @Test + void testOnlyDeletedCycleDueDispatchesDeletedOnly() throws Exception { + settings.setHousekeepingIntervalDays(30); + settings.setHousekeepingDeletedIntervalHours(24); + + long now = System.currentTimeMillis(); + long twoHoursAgo = now - TimeUnit.DAYS.toMillis(2); + Files.createDirectories(stateFile.getParentFile().toPath()); + YamlConfiguration yaml = new YamlConfiguration(); + // Age cycle ran 1 hour ago (<< 30d interval, not due). + yaml.set("lastAgeRunMillis", now - TimeUnit.HOURS.toMillis(1)); + // Deleted cycle ran 2 days ago (>= 24h interval, due). + yaml.set("lastDeletedRunMillis", twoHoursAgo); + yaml.save(stateFile); + + when(purgeService.scanDeleted(world)).thenReturn(emptyScan(0)); + + HousekeepingManager hm = new HousekeepingManager(plugin); + invokeCheckAndMaybeRun(hm); + + verify(purgeService, never()).scan(any(), anyInt()); + verify(purgeService, times(1)).scanDeleted(world); + } + + // ------------------------------------------------------------------ + // helpers + // ------------------------------------------------------------------ + + private static PurgeScanResult emptyScan(int days) { + return new PurgeScanResult(mock(World.class), days, Collections.emptyMap(), + false, false, new FilterStats(0, 0, 0, 0)); + } + + /** Reflective access to the package-private {@code checkAndMaybeRun} so + * tests can drive the hourly path without waiting for the scheduler. */ + private static void invokeCheckAndMaybeRun(HousekeepingManager hm) throws Exception { + var m = HousekeepingManager.class.getDeclaredMethod("checkAndMaybeRun"); + m.setAccessible(true); + m.invoke(hm); + } +} diff --git a/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java new file mode 100644 index 000000000..81556055b --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/managers/PurgeRegionsServiceTest.java @@ -0,0 +1,593 @@ +package world.bentobox.bentobox.managers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import com.google.common.collect.ImmutableSet; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.PurgeRegionsService.FilterStats; +import world.bentobox.bentobox.managers.PurgeRegionsService.PurgeScanResult; +import world.bentobox.bentobox.managers.island.IslandCache; +import world.bentobox.bentobox.managers.island.IslandGrid; +import world.bentobox.bentobox.managers.island.IslandGrid.IslandData; +import world.bentobox.bentobox.util.Pair; + +/** + * Direct tests for {@link PurgeRegionsService} focused on the deleted-sweep + * path ({@link PurgeRegionsService#scanDeleted(org.bukkit.World)}) and the + * {@code days == 0} behavior of {@link PurgeRegionsService#delete}. + * + *

These tests exercise the service directly (no command layer) so the + * assertions stay tightly scoped to the scanning/filtering logic. + */ +class PurgeRegionsServiceTest extends CommonTestSetup { + + @Mock + private IslandCache islandCache; + @Mock + private AddonsManager addonsManager; + @Mock + private PlayersManager pm; + + @TempDir + Path tempDir; + + private PurgeRegionsService service; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + + when(iwm.isNetherGenerate(world)).thenReturn(false); + when(iwm.isNetherIslands(world)).thenReturn(false); + when(iwm.isEndGenerate(world)).thenReturn(false); + when(iwm.isEndIslands(world)).thenReturn(false); + when(iwm.getNetherWorld(world)).thenReturn(null); + when(iwm.getEndWorld(world)).thenReturn(null); + + when(plugin.getAddonsManager()).thenReturn(addonsManager); + when(addonsManager.getAddonByName("Level")).thenReturn(Optional.empty()); + when(plugin.getPlayers()).thenReturn(pm); + + when(im.getIslandCache()).thenReturn(islandCache); + when(world.getWorldFolder()).thenReturn(tempDir.toFile()); + + service = new PurgeRegionsService(plugin); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * A world with no island grid returns an empty deleted-sweep result + * rather than crashing. + */ + @Test + void testScanDeletedNullGrid() { + when(islandCache.getIslandGrid(world)).thenReturn(null); + + PurgeScanResult result = service.scanDeleted(world); + assertTrue(result.isEmpty()); + assertEquals(0, result.days(), "days sentinel should be 0 for deleted sweep"); + } + + /** + * A world with only non-deletable islands yields no candidate regions. + */ + @Test + void testScanDeletedNoDeletableIslands() { + Island active = mock(Island.class); + when(active.isDeletable()).thenReturn(false); + + when(islandCache.getIslands(world)).thenReturn(List.of(active)); + when(islandCache.getIslandGrid(world)).thenReturn(mock(IslandGrid.class)); + + PurgeScanResult result = service.scanDeleted(world); + assertTrue(result.isEmpty()); + } + + /** + * A single deletable island with no neighbours produces one candidate + * region matching its protection bounds. + */ + @Test + void testScanDeletedLoneDeletableIsland() { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + // Occupies r.0.0 (0..100 in X/Z) + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable)); + + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandData("del", 0, 0, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + + PurgeScanResult result = service.scanDeleted(world); + assertFalse(result.isEmpty()); + assertEquals(1, result.deletableRegions().size()); + assertEquals(0, result.days()); + } + + /** + * An island straddling two regions (X = 500..700 crosses the r.0/r.1 + * boundary at X=512) produces two candidate region entries. + */ + @Test + void testScanDeletedIslandStraddlesRegionBoundary() { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMinProtectedX()).thenReturn(500); + when(deletable.getMaxProtectedX()).thenReturn(700); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandData("del", 500, 0, 200))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + + PurgeScanResult result = service.scanDeleted(world); + assertEquals(2, result.deletableRegions().size(), + "Island straddling r.0.0 and r.1.0 should produce two candidate regions"); + } + + /** + * Strict filter: a region containing one deletable and one non-deletable + * island must be dropped. + */ + @Test + void testScanDeletedStrictFilterDropsMixedRegion() { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + Island active = mock(Island.class); + when(active.getUniqueId()).thenReturn("act"); + when(active.isDeletable()).thenReturn(false); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable, active)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of( + new IslandData("del", 0, 0, 100), + new IslandData("act", 200, 200, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + when(im.getIslandById("act")).thenReturn(Optional.of(active)); + + PurgeScanResult result = service.scanDeleted(world); + assertTrue(result.isEmpty(), "Mixed region must be blocked by strict filter"); + } + + /** + * Missing island rows in the grid must not block the region — they're + * already gone and count as "no blocker". + */ + @Test + void testScanDeletedMissingIslandRowDoesNotBlock() { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of( + new IslandData("del", 0, 0, 100), + new IslandData("ghost", 300, 300, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + when(im.getIslandById("ghost")).thenReturn(Optional.empty()); + + PurgeScanResult result = service.scanDeleted(world); + assertFalse(result.isEmpty(), "Ghost island (no DB row) must not block the reap"); + assertEquals(1, result.deletableRegions().size()); + } + + /** + * {@code delete} with a {@code days == 0} scan must bypass the freshness + * recheck — a region file touched seconds ago must still be reaped. + * This is the core of the deleted-sweep semantics. + */ + @Test + void testDeleteWithZeroDaysBypassesFreshnessCheck() throws IOException { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMemberSet()).thenReturn(ImmutableSet.of()); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + + when(islandCache.getIslands(world)).thenReturn(List.of(deletable)); + IslandGrid grid = mock(IslandGrid.class); + when(grid.getIslandsInBounds(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(List.of(new IslandData("del", 0, 0, 100))); + when(islandCache.getIslandGrid(world)).thenReturn(grid); + when(im.getIslandById("del")).thenReturn(Optional.of(deletable)); + when(im.deleteIslandId("del")).thenReturn(true); + // deletePlayerFromWorldFolder iterates members (empty set here) so + // getIslands(World, UUID) is never reached — no stub needed. + + // Create a fresh .mca file — timestamp is "now". The age sweep would + // skip this file; the deleted sweep must reap it anyway. + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Path regionFile = regionDir.resolve("r.0.0.mca"); + byte[] data = new byte[8192]; + int nowSeconds = (int) (System.currentTimeMillis() / 1000L); + for (int i = 0; i < 1024; i++) { + int offset = 4096 + i * 4; + data[offset] = (byte) (nowSeconds >> 24); + data[offset + 1] = (byte) (nowSeconds >> 16); + data[offset + 2] = (byte) (nowSeconds >> 8); + data[offset + 3] = (byte) nowSeconds; + } + Files.write(regionFile, data); + + PurgeScanResult scan = service.scanDeleted(world); + assertFalse(scan.isEmpty()); + + boolean ok = service.delete(scan); + assertTrue(ok, "delete() should return true for a fresh-timestamp region under the deleted sweep"); + assertFalse(regionFile.toFile().exists(), + "Fresh region file must be reaped when days == 0"); + } + + /** + * {@code evictChunks} must walk the full 32x32 chunk square inside each + * target region and call {@code unloadChunk(cx, cz, false)} on every chunk + * that is currently loaded. This is the fix for the bug where reaped + * region files were re-flushed by Paper's autosave because the in-memory + * chunks survived the disk delete. + */ + @Test + void testEvictChunksUnloadsLoadedChunksInTargetRegion() { + // Build a scan result with a single target region at r.0.0 — chunks + // (0,0) .. (31,31). Mark only (5,7) and (10,12) as currently loaded + // so we can prove the call is gated on isChunkLoaded. + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("del")); + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + when(world.isChunkLoaded(5, 7)).thenReturn(true); + when(world.isChunkLoaded(10, 12)).thenReturn(true); + when(world.unloadChunk(anyInt(), anyInt(), eq(false))).thenReturn(true); + + service.evictChunks(scan); + + // Sweep covers all 32*32 = 1024 chunk coordinates exactly once. + verify(world, times(1024)).isChunkLoaded(anyInt(), anyInt()); + // Only the two loaded chunks were unloaded. + verify(world).unloadChunk(5, 7, false); + verify(world).unloadChunk(10, 12, false); + verify(world, times(2)).unloadChunk(anyInt(), anyInt(), eq(false)); + } + + /** + * Region coordinates must translate to chunk coordinates via {@code << 5} + * (each region holds 32×32 chunks). r.1.-1 → chunks (32..63, -32..-1). + */ + @Test + void testEvictChunksUsesCorrectChunkCoordsForNonZeroRegion() { + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(1, -1), Set.of("del")); + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + // The bottom-left corner of r.1.-1 is (32, -32); the top-right is (63, -1). + when(world.isChunkLoaded(32, -32)).thenReturn(true); + when(world.isChunkLoaded(63, -1)).thenReturn(true); + when(world.unloadChunk(anyInt(), anyInt(), eq(false))).thenReturn(true); + + service.evictChunks(scan); + + verify(world).unloadChunk(32, -32, false); + verify(world).unloadChunk(63, -1, false); + // Coordinates outside the region (e.g. (0,0)) must never be checked. + verify(world, never()).isChunkLoaded(0, 0); + verify(world, never()).isChunkLoaded(31, -1); + } + + /** + * When an island's protection box extends beyond the reaped region(s) + * and one of its non-reaped regions still has an {@code r.X.Z.mca} file + * on disk, {@code delete} must not remove the island DB row — + * residual blocks exist with no other cleanup path. The next purge cycle + * will retry. + */ + @Test + void testDeleteDefersDBRowWhenResidualRegionExists() throws IOException { + // Age sweep (days=30): island spans X=0..1000 (crosses r.0 and r.1) + // but only r.0.0 is in the scan — the strict filter blocked r.1.0 + // because of an active neighbour. r.1.0.mca stays on disk. + Island spans = mock(Island.class); + when(spans.getUniqueId()).thenReturn("spans"); + when(spans.isDeletable()).thenReturn(true); + when(spans.getMinProtectedX()).thenReturn(0); + when(spans.getMaxProtectedX()).thenReturn(1000); + when(spans.getMinProtectedZ()).thenReturn(0); + when(spans.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("spans")).thenReturn(Optional.of(spans)); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Path reaped = regionDir.resolve("r.0.0.mca"); + Path residual = regionDir.resolve("r.1.0.mca"); + Files.write(reaped, new byte[0]); + Files.write(residual, new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("spans")); + PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + boolean ok = service.delete(scan); + assertTrue(ok); + assertFalse(reaped.toFile().exists()); + assertTrue(residual.toFile().exists()); + // Age sweep: DB row must NOT be removed while residual region exists. + verify(im, never()).deleteIslandId(anyString()); + verify(islandCache, never()).deleteIslandFromCache(anyString()); + } + + /** + * Age sweep (days > 0): when every region the island's bounds touch is + * absent from disk after the reap, the DB row must be removed immediately. + */ + @Test + void testDeleteRemovesDBRowWhenAllRegionsGone() throws IOException { + Island tiny = mock(Island.class); + when(tiny.getUniqueId()).thenReturn("tiny"); + when(tiny.isDeletable()).thenReturn(true); + when(tiny.getMemberSet()).thenReturn(ImmutableSet.of()); + // Fits entirely in r.0.0 + when(tiny.getMinProtectedX()).thenReturn(0); + when(tiny.getMaxProtectedX()).thenReturn(100); + when(tiny.getMinProtectedZ()).thenReturn(0); + when(tiny.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny)); + when(im.deleteIslandId("tiny")).thenReturn(true); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("tiny")); + PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + boolean ok = service.delete(scan); + assertTrue(ok); + verify(im, times(1)).deleteIslandId("tiny"); + verify(islandCache, times(1)).deleteIslandFromCache("tiny"); + } + + /** + * Age sweep: a mixed batch where one island is fully reaped and another + * has a residual region — only the fully-reaped island's DB row is removed. + */ + @Test + void testDeleteDefersOnlySomeIslandsInMixedBatch() throws IOException { + Island tiny = mock(Island.class); + when(tiny.getUniqueId()).thenReturn("tiny"); + when(tiny.isDeletable()).thenReturn(true); + when(tiny.getMemberSet()).thenReturn(ImmutableSet.of()); + when(tiny.getMinProtectedX()).thenReturn(0); + when(tiny.getMaxProtectedX()).thenReturn(100); + when(tiny.getMinProtectedZ()).thenReturn(0); + when(tiny.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny)); + when(im.deleteIslandId("tiny")).thenReturn(true); + + Island spans = mock(Island.class); + when(spans.getUniqueId()).thenReturn("spans"); + when(spans.isDeletable()).thenReturn(true); + when(spans.getMinProtectedX()).thenReturn(0); + when(spans.getMaxProtectedX()).thenReturn(1000); + when(spans.getMinProtectedZ()).thenReturn(0); + when(spans.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("spans")).thenReturn(Optional.of(spans)); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + Files.write(regionDir.resolve("r.1.0.mca"), new byte[0]); // residual for "spans" + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("tiny", "spans")); + PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + boolean ok = service.delete(scan); + assertTrue(ok); + verify(im, times(1)).deleteIslandId("tiny"); + verify(im, never()).deleteIslandId("spans"); + verify(islandCache, times(1)).deleteIslandFromCache("tiny"); + verify(islandCache, never()).deleteIslandFromCache("spans"); + } + + /** + * An empty scan must short-circuit — no chunk-loaded probes at all. + */ + @Test + void testEvictChunksEmptyScanIsNoop() { + PurgeScanResult scan = new PurgeScanResult(world, 0, new HashMap<>(), false, false, + new FilterStats(0, 0, 0, 0)); + + service.evictChunks(scan); + + verify(world, never()).isChunkLoaded(anyInt(), anyInt()); + verify(world, never()).unloadChunk(anyInt(), anyInt(), eq(false)); + } + + // ------------------------------------------------------------------ + // Deferred deletion (deleted sweep, days == 0) + // ------------------------------------------------------------------ + + /** + * Deleted sweep (days=0): DB row removal must be deferred to shutdown, + * not executed immediately. The island ID goes into pendingDeletions. + */ + @Test + void testDeletedSweepDefersDBDeletionToShutdown() throws IOException { + Island deletable = mock(Island.class); + when(deletable.getUniqueId()).thenReturn("del1"); + when(deletable.isDeletable()).thenReturn(true); + when(deletable.getMinProtectedX()).thenReturn(0); + when(deletable.getMaxProtectedX()).thenReturn(100); + when(deletable.getMinProtectedZ()).thenReturn(0); + when(deletable.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("del1")).thenReturn(Optional.of(deletable)); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("del1")); + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + boolean ok = service.delete(scan); + assertTrue(ok); + // DB row must NOT be removed immediately — deferred to shutdown. + verify(im, never()).deleteIslandId(anyString()); + verify(islandCache, never()).deleteIslandFromCache(anyString()); + // Island ID must be in pending set. + assertTrue(service.getPendingDeletions().contains("del1")); + } + + /** + * {@link PurgeRegionsService#flushPendingDeletions()} must process all + * deferred island IDs and clear the pending set. + */ + @Test + void testFlushPendingDeletionsRemovesIslands() throws IOException { + Island del1 = mock(Island.class); + when(del1.getUniqueId()).thenReturn("del1"); + when(del1.isDeletable()).thenReturn(true); + when(del1.getMinProtectedX()).thenReturn(0); + when(del1.getMaxProtectedX()).thenReturn(100); + when(del1.getMinProtectedZ()).thenReturn(0); + when(del1.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("del1")).thenReturn(Optional.of(del1)); + when(im.deleteIslandId("del1")).thenReturn(true); + + Island del2 = mock(Island.class); + when(del2.getUniqueId()).thenReturn("del2"); + when(del2.isDeletable()).thenReturn(true); + when(del2.getMinProtectedX()).thenReturn(512); + when(del2.getMaxProtectedX()).thenReturn(612); + when(del2.getMinProtectedZ()).thenReturn(0); + when(del2.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("del2")).thenReturn(Optional.of(del2)); + when(im.deleteIslandId("del2")).thenReturn(true); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + Files.write(regionDir.resolve("r.1.0.mca"), new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("del1")); + regions.put(new Pair<>(1, 0), Set.of("del2")); + PurgeScanResult scan = new PurgeScanResult(world, 0, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + service.delete(scan); + // Both deferred — not yet deleted. + verify(im, never()).deleteIslandId(anyString()); + assertEquals(2, service.getPendingDeletions().size()); + + // Now flush (simulates plugin shutdown). + service.flushPendingDeletions(); + verify(im, times(1)).deleteIslandId("del1"); + verify(im, times(1)).deleteIslandId("del2"); + verify(islandCache, times(1)).deleteIslandFromCache("del1"); + verify(islandCache, times(1)).deleteIslandFromCache("del2"); + assertTrue(service.getPendingDeletions().isEmpty()); + } + + /** + * Age sweep (days > 0) must still delete DB rows immediately when all + * regions are gone from disk — no deferral to shutdown. + */ + @Test + void testAgeSweepStillDeletesImmediately() throws IOException { + Island tiny = mock(Island.class); + when(tiny.getUniqueId()).thenReturn("tiny"); + when(tiny.isDeletable()).thenReturn(true); + when(tiny.getMemberSet()).thenReturn(ImmutableSet.of()); + when(tiny.getMinProtectedX()).thenReturn(0); + when(tiny.getMaxProtectedX()).thenReturn(100); + when(tiny.getMinProtectedZ()).thenReturn(0); + when(tiny.getMaxProtectedZ()).thenReturn(100); + when(im.getIslandById("tiny")).thenReturn(Optional.of(tiny)); + when(im.deleteIslandId("tiny")).thenReturn(true); + + Path regionDir = Files.createDirectories(tempDir.resolve("region")); + Files.write(regionDir.resolve("r.0.0.mca"), new byte[0]); + + Map, Set> regions = new HashMap<>(); + regions.put(new Pair<>(0, 0), Set.of("tiny")); + PurgeScanResult scan = new PurgeScanResult(world, 30, regions, false, false, + new FilterStats(0, 0, 0, 0)); + + service.delete(scan); + // Age sweep: immediate deletion, not deferred. + verify(im, times(1)).deleteIslandId("tiny"); + assertTrue(service.getPendingDeletions().isEmpty()); + } +} diff --git a/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java b/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java index 7bca07f25..8e11f9144 100644 --- a/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java +++ b/src/test/java/world/bentobox/bentobox/panels/BlueprintManagementPanelTest.java @@ -17,6 +17,7 @@ import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -80,6 +81,7 @@ public void setUp() throws Exception { when(bb.getUniqueId()).thenReturn("test"); when(bb.getDisplayName()).thenReturn("test"); when(bb.getIcon()).thenReturn(Material.STONE); + when(bb.getIconItemStack()).thenReturn(new ItemStack(Material.STONE)); when(bb.getDescription()).thenReturn(Collections.singletonList("A description")); when(bb.getCommands()).thenReturn(Collections.emptyList()); when(bb.getSlot()).thenReturn(5); @@ -87,12 +89,14 @@ public void setUp() throws Exception { when(bb2.getUniqueId()).thenReturn("test2"); when(bb2.getDisplayName()).thenReturn("test2"); when(bb2.getIcon()).thenReturn(Material.ACACIA_BOAT); + when(bb2.getIconItemStack()).thenReturn(new ItemStack(Material.ACACIA_BOAT)); when(bb2.getDescription()).thenReturn(Collections.singletonList("A description 2")); when(bb2.getSlot()).thenReturn(-5); // Too large slot for panel when(bb3.getUniqueId()).thenReturn("test3"); when(bb3.getDisplayName()).thenReturn("test3"); when(bb3.getIcon()).thenReturn(Material.BAKED_POTATO); + when(bb3.getIconItemStack()).thenReturn(new ItemStack(Material.BAKED_POTATO)); when(bb3.getDescription()).thenReturn(Collections.singletonList("A description 3")); when(bb3.getSlot()).thenReturn(65); @@ -167,6 +171,7 @@ void testGetBlueprintItem() { void testGetBlueprintItemWithDisplayNameAndIcon() { when(blueprint.getDisplayName()).thenReturn("Display Name"); when(blueprint.getIcon()).thenReturn(Material.BEACON); + when(blueprint.getIconItemStack()).thenReturn(new ItemStack(Material.BEACON)); PanelItem pi = bmp.getBlueprintItem(addon, 0, bb, blueprint); assertEquals("Display Name", pi.getName()); assertEquals(Material.BEACON, pi.getItem().getType()); @@ -180,6 +185,7 @@ void testGetBlueprintItemWithDisplayNameAndIcon() { void testGetBlueprintItemWithDisplayNameAndIconInWorldSlot() { when(blueprint.getDisplayName()).thenReturn("Display Name"); when(blueprint.getIcon()).thenReturn(Material.BEACON); + when(blueprint.getIconItemStack()).thenReturn(new ItemStack(Material.BEACON)); PanelItem pi = bmp.getBlueprintItem(addon, 5, bb, blueprint); assertEquals("Display Name", pi.getName()); assertEquals(Material.BEACON, pi.getItem().getType()); @@ -224,6 +230,7 @@ void testOpenPanelWithManyBundles() { when(bundle.getUniqueId()).thenReturn("bundle" + i); when(bundle.getDisplayName()).thenReturn("Bundle " + String.format("%02d", i)); when(bundle.getIcon()).thenReturn(Material.STONE); + when(bundle.getIconItemStack()).thenReturn(new ItemStack(Material.STONE)); when(bundle.getDescription()).thenReturn(Collections.singletonList("Desc")); map.put("bundle" + i, bundle); } diff --git a/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java new file mode 100644 index 000000000..5a03522b5 --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/panels/IconChangerTest.java @@ -0,0 +1,206 @@ +package world.bentobox.bentobox.panels; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.AbstractMap; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.blueprints.Blueprint; +import world.bentobox.bentobox.blueprints.dataobjects.BlueprintBundle; +import world.bentobox.bentobox.managers.BlueprintsManager; + +/** + * Tests for {@link IconChanger}. + */ +class IconChangerTest extends CommonTestSetup { + + @Mock + private GameModeAddon addon; + @Mock + private BlueprintManagementPanel bmp; + @Mock + private BlueprintBundle bb; + @Mock + private BlueprintsManager bpManager; + @Mock + private User user; + @Mock + private InventoryClickEvent event; + + private IconChanger iconChanger; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(plugin.getBlueprintsManager()).thenReturn(bpManager); + when(bmp.getSelected()).thenReturn(null); // no blueprint selected by default + when(user.getPlayer()).thenReturn(mockPlayer); + when(user.getLocation()).thenReturn(location); + iconChanger = new IconChanger(plugin, addon, bmp, bb); + } + + @Override + @AfterEach + public void tearDown() throws Exception { + super.tearDown(); + } + + /** + * Clicking a plain item (no item model) in the player inventory sets the bundle icon by Material. + */ + @Test + void testOnInventoryClickBundlePlainMaterial() { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.STONE); + ItemMeta meta = mock(ItemMeta.class); + when(meta.hasItemModel()).thenReturn(false); + when(item.getItemMeta()).thenReturn(meta); + + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); // player inventory slot + + iconChanger.onInventoryClick(user, event); + + verify(bb).setIcon(Material.STONE); + verify(bb, never()).setIcon(any(String.class)); + verify(bpManager).saveBlueprintBundle(addon, bb); + } + + /** + * Clicking an item that has a custom item model sets the bundle icon by model key string. + */ + @Test + void testOnInventoryClickBundleItemModel() { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.PAPER); + ItemMeta meta = mock(ItemMeta.class); + NamespacedKey modelKey = new NamespacedKey("myserver", "island_tropical"); + when(meta.hasItemModel()).thenReturn(true); + when(meta.getItemModel()).thenReturn(modelKey); + when(item.getItemMeta()).thenReturn(meta); + + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bb).setIcon("myserver:island_tropical"); + verify(bb, never()).setIcon(any(Material.class)); + verify(bpManager).saveBlueprintBundle(addon, bb); + } + + /** + * Clicking a plain item when a blueprint is selected changes the blueprint icon by Material. + */ + @Test + void testOnInventoryClickBlueprintSelected() { + Blueprint bp = mock(Blueprint.class); + when(bmp.getSelected()).thenReturn(new AbstractMap.SimpleEntry<>(1, bp)); + + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.BEACON); + ItemMeta meta = mock(ItemMeta.class); + when(meta.hasItemModel()).thenReturn(false); + when(item.getItemMeta()).thenReturn(meta); + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bp).setIcon(Material.BEACON); + verify(bpManager).saveBlueprint(addon, bp); + verify(bb, never()).setIcon(any(Material.class)); + verify(bb, never()).setIcon(any(String.class)); + } + + /** + * Clicking a custom item model item when a blueprint is selected stores the model key string. + */ + @Test + void testOnInventoryClickBlueprintItemModel() { + Blueprint bp = mock(Blueprint.class); + when(bmp.getSelected()).thenReturn(new AbstractMap.SimpleEntry<>(1, bp)); + + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.PAPER); + ItemMeta meta = mock(ItemMeta.class); + NamespacedKey modelKey = new NamespacedKey("myserver", "island_tropical"); + when(meta.hasItemModel()).thenReturn(true); + when(meta.getItemModel()).thenReturn(modelKey); + when(item.getItemMeta()).thenReturn(meta); + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bp).setIcon("myserver:island_tropical"); + verify(bp, never()).setIcon(any(Material.class)); + verify(bpManager).saveBlueprint(addon, bp); + } + + /** + * Clicking inside the panel (slot ≤ 44) does nothing. + */ + @Test + void testOnInventoryClickInsidePanel() { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.STONE); + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(10); // inside the GUI + + iconChanger.onInventoryClick(user, event); + + verify(bb, never()).setIcon(any(Material.class)); + verify(bb, never()).setIcon(any(String.class)); + verify(bpManager, never()).saveBlueprintBundle(any(), any()); + } + + /** + * Clicking an AIR slot does nothing. + */ + @Test + void testOnInventoryClickAirItem() { + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(Material.AIR); + when(event.getCurrentItem()).thenReturn(item); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bb, never()).setIcon(any(Material.class)); + verify(bb, never()).setIcon(any(String.class)); + verify(bpManager, never()).saveBlueprintBundle(any(), any()); + } + + /** + * Clicking a null item does nothing. + */ + @Test + void testOnInventoryClickNullItem() { + when(event.getCurrentItem()).thenReturn(null); + when(event.getRawSlot()).thenReturn(45); + + iconChanger.onInventoryClick(user, event); + + verify(bb, never()).setIcon(any(Material.class)); + verify(bb, never()).setIcon(any(String.class)); + verify(bpManager, never()).saveBlueprintBundle(any(), any()); + } +} diff --git a/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java b/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java index d79fdc053..26785bca3 100644 --- a/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java +++ b/src/test/java/world/bentobox/bentobox/panels/customizable/IslandCreationPanelTest.java @@ -20,6 +20,7 @@ import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.inventory.ItemStack; import org.bukkit.entity.Player; import org.bukkit.plugin.PluginDescriptionFile; import org.junit.jupiter.api.AfterEach; @@ -128,6 +129,7 @@ public void setUp() throws Exception { when(bundle1.getUniqueId()).thenReturn("default"); when(bundle1.getDisplayName()).thenReturn("Default"); when(bundle1.getIcon()).thenReturn(Material.GRASS_BLOCK); + when(bundle1.getIconItemStack()).thenReturn(new ItemStack(Material.GRASS_BLOCK)); when(bundle1.getDescription()).thenReturn(Collections.singletonList("Default island")); when(bundle1.getSlot()).thenReturn(0); when(bundle1.isRequirePermission()).thenReturn(false); @@ -138,6 +140,7 @@ public void setUp() throws Exception { when(bundle2.getUniqueId()).thenReturn("nether"); when(bundle2.getDisplayName()).thenReturn("Nether"); when(bundle2.getIcon()).thenReturn(Material.NETHERRACK); + when(bundle2.getIconItemStack()).thenReturn(new ItemStack(Material.NETHERRACK)); when(bundle2.getDescription()).thenReturn(Collections.singletonList("Nether island")); when(bundle2.getSlot()).thenReturn(1); when(bundle2.isRequirePermission()).thenReturn(false); @@ -148,6 +151,7 @@ public void setUp() throws Exception { when(bundle3.getUniqueId()).thenReturn("end"); when(bundle3.getDisplayName()).thenReturn("End"); when(bundle3.getIcon()).thenReturn(Material.END_STONE); + when(bundle3.getIconItemStack()).thenReturn(new ItemStack(Material.END_STONE)); when(bundle3.getDescription()).thenReturn(Collections.singletonList("End island")); when(bundle3.getSlot()).thenReturn(2); when(bundle3.isRequirePermission()).thenReturn(false);