diff --git a/README.md b/README.md index 64a0763..fc3d06e 100644 --- a/README.md +++ b/README.md @@ -41,17 +41,22 @@ It's async-friendly, lightweight, and fully customizable — with built-in suppo - Compatible with Residence, WorldGuard, Lands, GriefPrevention - Supports **CoreProtect** for logging actions -### 🌐 Multi-Language & Locale Support - -- Translations included: `en`, `zh`, `ja`, `de`, `es`, `fr`, `ru`, etc. -- Automatically switches to player's locale if enabled - ### 🗄️ MySQL & SQLite Support - Scale with MySQL or keep it simple with SQLite (default) --- +## Translations + +Automatically switches to the player's locale if enabled. + + + Translation Status + + +--- + ## Supported Plugins > > Since we call the block break event directly by default, plugins such as CoreProtect and Drop2Inventory should be supported without modification. diff --git a/build.gradle b/build.gradle index b46a173..1ea863d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,14 @@ plugins { id 'java' id 'maven-publish' - id 'com.gradleup.shadow' version '9.4.1' + id 'com.gradleup.shadow' version '9.4.2' id 'xyz.jpenilla.run-paper' version '3.+' id 'com.modrinth.minotaur' version '2.+' id 'com.diffplug.spotless' version '8.+' } group = 'org.milkteamc' -version = '1.7.4' +version = '1.7.5' // Java Configuration java { @@ -81,7 +81,7 @@ dependencies { implementation 'com.github.Anon8281:UniversalScheduler:0.1.7' implementation 'com.zaxxer:HikariCP:7.0.2' - implementation 'org.bstats:bstats-bukkit:3.1.0' + implementation 'org.bstats:bstats-bukkit:3.2.1' implementation 'io.github.almighty-satan:XSeries:13.6.0+26.1' implementation 'dev.dejvokep:boosted-yaml:1.3.7' @@ -172,7 +172,7 @@ tasks.shadowJar { } tasks.runServer { - minecraftVersion('26.1.1') + minecraftVersion('26.1.2') def javaVersion = Math.max(25, JavaVersion.current().majorVersion.toInteger()) javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(javaVersion) @@ -189,6 +189,8 @@ modrinth { versionNumber = fullVersion versionName = "v${fullVersion}" changelog = """ + > **⚠️ Deprecation Notice:** Spigot support is deprecated and may be removed as early as v1.8.0. Please consider migrating to Paper or Folia. + 🚨 **This is an ALPHA version - Use at your own risk!** **Branch:** `${gitBranch}` diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d997cfc..b1b8ef5 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118..df6a6ad 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew.bat b/gradlew.bat index e509b2d..aa5f10b 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -23,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,7 +65,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line @@ -73,21 +73,10 @@ goto fail @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/main/java/org/milkteamc/autotreechop/AutoTreeChop.java b/src/main/java/org/milkteamc/autotreechop/AutoTreeChop.java index 531a060..a4cded3 100644 --- a/src/main/java/org/milkteamc/autotreechop/AutoTreeChop.java +++ b/src/main/java/org/milkteamc/autotreechop/AutoTreeChop.java @@ -17,12 +17,8 @@ package org.milkteamc.autotreechop; -import com.github.Anon8281.universalScheduler.UniversalScheduler; import java.io.File; import java.util.Locale; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; import org.bstats.bukkit.Metrics; import org.bukkit.Bukkit; @@ -34,64 +30,37 @@ import org.milkteamc.autotreechop.command.ReloadCommand; import org.milkteamc.autotreechop.command.ToggleCommand; import org.milkteamc.autotreechop.command.UsageCommand; +import org.milkteamc.autotreechop.database.DataManager; import org.milkteamc.autotreechop.database.DatabaseManager; import org.milkteamc.autotreechop.events.BlockBreakListener; import org.milkteamc.autotreechop.events.PlayerJoinListener; import org.milkteamc.autotreechop.events.PlayerQuitListener; import org.milkteamc.autotreechop.events.PlayerSneakListener; -import org.milkteamc.autotreechop.hooks.GriefPreventionHook; -import org.milkteamc.autotreechop.hooks.LandsHook; -import org.milkteamc.autotreechop.hooks.ResidenceHook; -import org.milkteamc.autotreechop.hooks.WorldGuardHook; -import org.milkteamc.autotreechop.tasks.PlayerDataSaveTask; +import org.milkteamc.autotreechop.hooks.HookManager; import org.milkteamc.autotreechop.translation.TranslationManager; import org.milkteamc.autotreechop.updater.ModrinthUpdateChecker; import org.milkteamc.autotreechop.utils.ConfirmationManager; import org.milkteamc.autotreechop.utils.CooldownManager; -import org.milkteamc.autotreechop.utils.SessionManager; import org.milkteamc.autotreechop.utils.TreeChopUtils; import revxrsal.commands.bukkit.BukkitLamp; public class AutoTreeChop extends JavaPlugin { - private static final long SAVE_INTERVAL = 1200L; // 60s - private static final int SAVE_THRESHOLD = 15; - private static AutoTreeChop instance; private Config config; - private AutoTreeChopAPI autoTreeChopAPI; - private Map playerConfigs = new ConcurrentHashMap<>(); - private Metrics metrics; + private DatabaseManager databaseManager; + private DataManager dataManager; + private HookManager hookManager; private TranslationManager translationManager; - private ConfirmationManager confirmationManager; - private ModrinthUpdateChecker updateChecker; - private PluginDescriptionFile description; - - private boolean worldGuardEnabled = false; - private boolean residenceEnabled = false; - private boolean griefPreventionEnabled = false; - private boolean landsEnabled = false; - private WorldGuardHook worldGuardHook = null; - private ResidenceHook residenceHook = null; - private GriefPreventionHook griefPreventionHook = null; - private LandsHook landsHook = null; + private AutoTreeChopAPI autoTreeChopAPI; + private ConfirmationManager confirmationManager; private CooldownManager cooldownManager; - - private DatabaseManager databaseManager; - private PlayerDataSaveTask saveTask; - private TreeChopUtils treeChopUtils; - - /** - * Sends a translated message to a command sender - */ - public static void sendMessage(CommandSender sender, String messageKey, TagResolver... resolvers) { - if (instance != null && instance.translationManager != null) { - instance.translationManager.sendMessage(sender, messageKey, resolvers); - } - } + private ModrinthUpdateChecker updateChecker; + private Metrics metrics; + private PluginDescriptionFile description; public static boolean isFolia() { try { @@ -102,6 +71,27 @@ public static boolean isFolia() { } } + public static boolean isPaper() { + try { + Class.forName("io.papermc.paper.configuration.Configuration"); + return true; + } catch (ClassNotFoundException ex) { + return false; + } + } + + public static String getServerType() { + if (isFolia()) return "folia"; + if (isPaper()) return "paper"; + return "spigot"; + } + + public static void sendMessage(CommandSender sender, String messageKey, TagResolver... resolvers) { + if (instance != null && instance.translationManager != null) { + instance.translationManager.sendMessage(sender, messageKey, resolvers); + } + } + @Override public void onLoad() { @SuppressWarnings("deprecation") @@ -114,137 +104,60 @@ public void onEnable() { instance = this; saveDefaultConfig(); - config = new Config(this); - - metrics = new Metrics(this, 20053); - - // Register event listeners - registerEvents(); - - // Initialize translation system - translationManager = new TranslationManager(this); - loadLocale(); + this.config = new Config(this); + setupTranslation(); - // Register commands - var lamp = BukkitLamp.builder(this).build(); - lamp.register(new ReloadCommand(this, config)); - lamp.register(new AboutCommand(this)); - lamp.register(new ToggleCommand(this)); - lamp.register(new UsageCommand(this, config)); - lamp.register(new ConfirmCommand(this)); - - if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { - new AutoTreeChopExpansion(this).register(); - getLogger().info("PlaceholderAPI expansion for AutoTreeChop has been registered."); - } - - updateChecker = new ModrinthUpdateChecker(this, "autotreechop", "paper") - .setDonationLink("https://ko-fi.com/maoyue") - .setChangelogLink("https://modrinth.com/plugin/autotreechop/changelog") - .setDownloadLink("https://modrinth.com/plugin/autotreechop/versions") - .setNotifyOpsOnJoin(true) - .setNotifyByPermissionOnJoin("autotreechop.updatechecker") - .startPeriodicCheck(); + this.cooldownManager = new CooldownManager(); + this.confirmationManager = new ConfirmationManager(this); + this.treeChopUtils = new TreeChopUtils(this); + this.autoTreeChopAPI = new AutoTreeChopAPI(this); - databaseManager = new DatabaseManager( - this, - config.isUseMysql(), - config.getHostname(), - config.getPort(), - config.getDatabase(), - config.getUsername(), - config.getPassword()); + this.hookManager = new HookManager(this, config); - saveTask = new PlayerDataSaveTask(this, SAVE_THRESHOLD); - UniversalScheduler.getScheduler(this).runTaskTimerAsynchronously(saveTask, SAVE_INTERVAL, SAVE_INTERVAL); - autoTreeChopAPI = new AutoTreeChopAPI(this); - playerConfigs = new ConcurrentHashMap<>(); - initializeHooks(); + setupDatabase(); + this.dataManager = new DataManager(this, databaseManager, confirmationManager); + this.dataManager.startSaveTask(); - cooldownManager = new CooldownManager(); + registerEvents(); + registerCommands(); - confirmationManager = new ConfirmationManager(this); + setupIntegrations(); - this.treeChopUtils = new TreeChopUtils(this); + if (getServerType().equals("spigot")) { + getLogger().warning("====================================================="); + getLogger().warning(" You are running AutoTreeChop on Spigot."); + getLogger().warning(" Spigot support is deprecated and may be removed"); + getLogger().warning(" as early as v1.8.0."); + getLogger().warning(" Please consider migrating to Paper or Folia."); + getLogger().warning("====================================================="); + } getLogger().info("AutoTreeChop enabled!"); } - private void registerEvents() { - getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), this); - getServer().getPluginManager().registerEvents(new PlayerQuitListener(this), this); - getServer().getPluginManager().registerEvents(new BlockBreakListener(this), this); - getServer().getPluginManager().registerEvents(new PlayerSneakListener(this), this); - } - - private void initializeHooks() { - if (Bukkit.getPluginManager().getPlugin("Residence") != null) { - try { - residenceHook = new ResidenceHook(config.getResidenceFlag()); - residenceEnabled = true; - getLogger().info("Residence support enabled"); - } catch (Exception e) { - getLogger() - .warning( - "Residence can't be hook, please report this to our GitHub: https://github.com/milkteamc/AutoTreeChop/issues"); - residenceEnabled = false; - } - } else { - residenceEnabled = false; + @Override + public void onDisable() { + if (dataManager != null) { + dataManager.shutdown(); } - if (Bukkit.getPluginManager().getPlugin("GriefPrevention") != null) { - try { - griefPreventionHook = new GriefPreventionHook(config.getGriefPreventionFlag()); - griefPreventionEnabled = true; - getLogger().info("GriefPrevention support enabled"); - } catch (Exception e) { - getLogger() - .warning( - "GriefPrevention can't be hook, please report this to our GitHub: https://github.com/milkteamc/AutoTreeChop/issues"); - griefPreventionEnabled = false; - } - } else { - griefPreventionEnabled = false; + if (translationManager != null) { + translationManager.close(); } - if (Bukkit.getPluginManager().getPlugin("Lands") != null) { - try { - landsHook = new LandsHook(this); - landsEnabled = true; - getLogger().info("Lands support enabled"); - } catch (Exception e) { - getLogger() - .warning( - "Lands can't be hook, please report this to our GitHub: https://github.com/milkteamc/AutoTreeChop/issues"); - landsEnabled = false; - } - } else { - landsEnabled = false; + if (metrics != null) { + metrics.shutdown(); } - if (Bukkit.getPluginManager().getPlugin("WorldGuard") != null) { - try { - worldGuardHook = new WorldGuardHook(); - worldGuardEnabled = true; - getLogger().info("WorldGuard support enabled"); - } catch (NoClassDefFoundError e) { - getLogger() - .warning( - "WorldGuard can't be hook, please report this to our GitHub: https://github.com/milkteamc/AutoTreeChop/issues"); - worldGuardEnabled = false; - } - } else { - worldGuardEnabled = false; - } + getLogger().info("AutoTreeChop disabled!"); } - private void loadLocale() { + private void setupTranslation() { + this.translationManager = new TranslationManager(this); String[] langs = {"styles", "en", "de", "es", "fr", "ja", "ru", "zh", "ms"}; for (String lang : langs) { saveResourceIfNotExists("lang/" + lang + ".properties"); } - Locale defaultLocale = config.getLocale() == null ? Locale.getDefault() : config.getLocale(); translationManager.initialize(defaultLocale, config.isUseClientLocale()); } @@ -255,147 +168,95 @@ private void saveResourceIfNotExists(String resourcePath) { } } - @Override - public void onDisable() { - getLogger().info("Saving all player data before shutdown..."); - - if (saveTask != null) { - try { - saveTask.cancel(); - } catch (IllegalStateException ignored) { - // Task was never scheduled or already cancelled (e.g. Folia shutdown) - } - } - - if (playerConfigs != null && !playerConfigs.isEmpty()) { - SessionManager sessionManager = SessionManager.getInstance(); - for (Map.Entry entry : playerConfigs.entrySet()) { - UUID uuid = entry.getKey(); - PlayerConfig pConfig = entry.getValue(); - - if (confirmationManager != null) { - confirmationManager.clearPlayer(uuid); - } - - if (pConfig.isDirty() && databaseManager != null) { - databaseManager.savePlayerDataSync(pConfig.getData()); - } - - if (sessionManager != null) { - sessionManager.clearAllPlayerSessions(uuid); - } - } - playerConfigs.clear(); - } - - if (databaseManager != null) { - databaseManager.close(); - } - - if (translationManager != null) { - translationManager.close(); - } - - if (metrics != null) { - metrics.shutdown(); - } - - getLogger().info("AutoTreeChop disabled!"); - } - - public PlayerConfig getPlayerConfig(UUID playerUUID) { - PlayerConfig playerConfig = playerConfigs.get(playerUUID); - - if (playerConfig == null) { - DatabaseManager.PlayerData tempDefaultData = - new DatabaseManager.PlayerData(playerUUID, false, 0, 0, java.time.LocalDate.now()); - return new PlayerConfig(playerUUID, tempDefaultData); - } - - return playerConfig; + private void setupDatabase() { + this.databaseManager = new DatabaseManager( + this, + config.isUseMysql(), + config.getHostname(), + config.getPort(), + config.getDatabase(), + config.getUsername(), + config.getPassword()); } - public int getPlayerDailyUses(UUID playerUUID) { - return getPlayerConfig(playerUUID).getDailyUses(); + private void registerEvents() { + getServer().getPluginManager().registerEvents(new PlayerJoinListener(this), this); + getServer().getPluginManager().registerEvents(new PlayerQuitListener(this), this); + getServer().getPluginManager().registerEvents(new BlockBreakListener(this), this); + getServer().getPluginManager().registerEvents(new PlayerSneakListener(this), this); } - public int getPlayerDailyBlocksBroken(UUID playerUUID) { - return getPlayerConfig(playerUUID).getDailyBlocksBroken(); + private void registerCommands() { + var lamp = BukkitLamp.builder(this).build(); + lamp.register(new ReloadCommand(this, config)); + lamp.register(new AboutCommand(this)); + lamp.register(new ToggleCommand(this)); + lamp.register(new UsageCommand(this, config)); + lamp.register(new ConfirmCommand(this)); } - public AutoTreeChopAPI getAutoTreeChopAPI() { - return autoTreeChopAPI; - } + private void setupIntegrations() { + this.metrics = new Metrics(this, 20053); - public ModrinthUpdateChecker getUpdateChecker() { - return updateChecker; - } + if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { + new AutoTreeChopExpansion(this).register(); + getLogger().info("PlaceholderAPI expansion for AutoTreeChop has been registered."); + } - public PluginDescriptionFile getPluginDescription() { - return description; + this.updateChecker = new ModrinthUpdateChecker(this, "autotreechop", getServerType()) + .setDonationLink("https://ko-fi.com/maoyue") + .setChangelogLink("https://modrinth.com/plugin/autotreechop/changelog") + .setDownloadLink("https://modrinth.com/plugin/autotreechop/versions") + .setNotifyOpsOnJoin(true) + .setNotifyByPermissionOnJoin("autotreechop.updatechecker") + .startPeriodicCheck(); } - public CooldownManager getCooldownManager() { - return cooldownManager; + public static AutoTreeChop getInstance() { + return instance; } public Config getPluginConfig() { return config; } - public Map getAllPlayerConfigs() { - return playerConfigs; + public DataManager getDataManager() { + return dataManager; } public DatabaseManager getDatabaseManager() { return databaseManager; } - public TreeChopUtils getTreeChopUtils() { - return treeChopUtils; - } - - public TranslationManager getTranslationManager() { - return translationManager; - } - - public ConfirmationManager getConfirmationManager() { - return confirmationManager; - } - - public boolean isWorldGuardEnabled() { - return worldGuardEnabled; - } - - public boolean isResidenceEnabled() { - return residenceEnabled; + public HookManager getHookManager() { + return hookManager; } - public boolean isGriefPreventionEnabled() { - return griefPreventionEnabled; + public AutoTreeChopAPI getAutoTreeChopAPI() { + return autoTreeChopAPI; } - public boolean isLandsEnabled() { - return landsEnabled; + public CooldownManager getCooldownManager() { + return cooldownManager; } - public WorldGuardHook getWorldGuardHook() { - return worldGuardHook; + public ConfirmationManager getConfirmationManager() { + return confirmationManager; } - public ResidenceHook getResidenceHook() { - return residenceHook; + public TreeChopUtils getTreeChopUtils() { + return treeChopUtils; } - public GriefPreventionHook getGriefPreventionHook() { - return griefPreventionHook; + public TranslationManager getTranslationManager() { + return translationManager; } - public LandsHook getLandsHook() { - return landsHook; + public ModrinthUpdateChecker getUpdateChecker() { + return updateChecker; } - public static AutoTreeChop getInstance() { - return instance; + public PluginDescriptionFile getPluginDescription() { + return description; } } diff --git a/src/main/java/org/milkteamc/autotreechop/AutoTreeChopAPI.java b/src/main/java/org/milkteamc/autotreechop/AutoTreeChopAPI.java index 734a1be..119fabf 100644 --- a/src/main/java/org/milkteamc/autotreechop/AutoTreeChopAPI.java +++ b/src/main/java/org/milkteamc/autotreechop/AutoTreeChopAPI.java @@ -34,7 +34,7 @@ public AutoTreeChopAPI(AutoTreeChop plugin) { * @return boolean */ public boolean isAutoTreeChopEnabled(Player player) { - PlayerConfig playerConfig = plugin.getPlayerConfig(player.getUniqueId()); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(player.getUniqueId()); return playerConfig.isAutoTreeChopEnabled(); } @@ -42,7 +42,7 @@ public boolean isAutoTreeChopEnabled(Player player) { * Set specific player AutoTreeChop as enabled */ public void enableAutoTreeChop(Player player) { - PlayerConfig playerConfig = plugin.getPlayerConfig(player.getUniqueId()); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(player.getUniqueId()); playerConfig.setAutoTreeChopEnabled(true); } @@ -50,7 +50,7 @@ public void enableAutoTreeChop(Player player) { * Set specific player AutoTreeChop as disable */ public void disableAutoTreeChop(Player player) { - PlayerConfig playerConfig = plugin.getPlayerConfig(player.getUniqueId()); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(player.getUniqueId()); playerConfig.setAutoTreeChopEnabled(false); } @@ -60,7 +60,7 @@ public void disableAutoTreeChop(Player player) { * @return int */ public int getPlayerDailyUses(UUID playerUUID) { - return plugin.getPlayerDailyUses(playerUUID); + return plugin.getDataManager().getPlayerDailyUses(playerUUID); } /** @@ -69,6 +69,6 @@ public int getPlayerDailyUses(UUID playerUUID) { * @return int */ public int getPlayerDailyBlocksBroken(UUID playerUUID) { - return plugin.getPlayerDailyBlocksBroken(playerUUID); + return plugin.getDataManager().getPlayerDailyBlocksBroken(playerUUID); } } diff --git a/src/main/java/org/milkteamc/autotreechop/AutoTreeChopExpansion.java b/src/main/java/org/milkteamc/autotreechop/AutoTreeChopExpansion.java index 3108a38..553f91a 100644 --- a/src/main/java/org/milkteamc/autotreechop/AutoTreeChopExpansion.java +++ b/src/main/java/org/milkteamc/autotreechop/AutoTreeChopExpansion.java @@ -54,11 +54,12 @@ public String onPlaceholderRequest(Player player, @NotNull String params) { UUID playerUUID = player.getUniqueId(); if (params.equalsIgnoreCase("daily_uses")) { - return String.valueOf(plugin.getPlayerDailyUses(playerUUID)); + return String.valueOf(plugin.getDataManager().getPlayerDailyUses(playerUUID)); } else if (params.equalsIgnoreCase("daily_blocks_broken")) { - return String.valueOf(plugin.getPlayerDailyBlocksBroken(playerUUID)); + return String.valueOf(plugin.getDataManager().getPlayerDailyBlocksBroken(playerUUID)); } else if (params.equalsIgnoreCase("status")) { - return String.valueOf(plugin.getPlayerConfig(playerUUID).isAutoTreeChopEnabled()); + return String.valueOf( + plugin.getDataManager().getPlayerConfig(playerUUID).isAutoTreeChopEnabled()); } return null; diff --git a/src/main/java/org/milkteamc/autotreechop/Config.java b/src/main/java/org/milkteamc/autotreechop/Config.java index ded9b7e..0f8f874 100644 --- a/src/main/java/org/milkteamc/autotreechop/Config.java +++ b/src/main/java/org/milkteamc/autotreechop/Config.java @@ -104,6 +104,7 @@ public class Config { private int maxTreeSize; private int maxDiscoveryBlocks; private boolean callBlockBreakEvent; + private boolean limitUsage; public Config(AutoTreeChop plugin) { this.plugin = plugin; @@ -192,6 +193,7 @@ private void sanitizeConfigFile(File file) { private void loadValues() { visualEffect = config.getBoolean("visual-effect", true); toolDamage = config.getBoolean("toolDamage", true); + limitUsage = config.getBoolean("limitUsage", true); maxUsesPerDay = config.getInt("max-uses-per-day", 50); maxBlocksPerDay = config.getInt("max-blocks-per-day", 500); cooldownTime = config.getInt("cooldownTime", 5); @@ -405,6 +407,10 @@ public String getPassword() { return password; } + public boolean getLimitUsage() { + return limitUsage; + } + public boolean getLimitVipUsage() { return limitVipUsage; } diff --git a/src/main/java/org/milkteamc/autotreechop/PlayerConfig.java b/src/main/java/org/milkteamc/autotreechop/PlayerConfig.java index 25808c3..c964c7b 100644 --- a/src/main/java/org/milkteamc/autotreechop/PlayerConfig.java +++ b/src/main/java/org/milkteamc/autotreechop/PlayerConfig.java @@ -37,56 +37,60 @@ private void checkAndUpdateDate() { data.setDailyUses(0); data.setDailyBlocksBroken(0); data.setLastUseDate(LocalDate.now()); - markDirty(); + this.dirty = true; } } - public boolean isAutoTreeChopEnabled() { + public synchronized boolean isAutoTreeChopEnabled() { return data.isAutoTreeChopEnabled(); } - public void setAutoTreeChopEnabled(boolean enabled) { + public synchronized void setAutoTreeChopEnabled(boolean enabled) { if (data.isAutoTreeChopEnabled() != enabled) { data.setAutoTreeChopEnabled(enabled); - markDirty(); + this.dirty = true; } } - public int getDailyUses() { + public synchronized int getDailyUses() { checkAndUpdateDate(); return data.getDailyUses(); } - public void incrementDailyUses() { + public synchronized void incrementDailyUses() { checkAndUpdateDate(); data.incrementDailyUses(); - markDirty(); + this.dirty = true; } - public int getDailyBlocksBroken() { + public synchronized int getDailyBlocksBroken() { checkAndUpdateDate(); return data.getDailyBlocksBroken(); } - public void incrementDailyBlocksBroken() { + public synchronized void incrementDailyBlocksBroken() { checkAndUpdateDate(); data.incrementDailyBlocksBroken(); - markDirty(); + this.dirty = true; } - public void markDirty() { + public synchronized void markDirty() { this.dirty = true; } - public void clearDirty() { - this.dirty = false; + public synchronized boolean isDirty() { + return dirty; } - public boolean isDirty() { - return dirty; + public synchronized DatabaseManager.PlayerData popSnapshotIfDirty() { + if (this.dirty) { + this.dirty = false; + return new DatabaseManager.PlayerData(this.data); + } + return null; } - public DatabaseManager.PlayerData getData() { + public synchronized DatabaseManager.PlayerData getData() { return data; } diff --git a/src/main/java/org/milkteamc/autotreechop/command/ConfirmCommand.java b/src/main/java/org/milkteamc/autotreechop/command/ConfirmCommand.java index e488ec3..37757dd 100644 --- a/src/main/java/org/milkteamc/autotreechop/command/ConfirmCommand.java +++ b/src/main/java/org/milkteamc/autotreechop/command/ConfirmCommand.java @@ -24,6 +24,7 @@ import org.milkteamc.autotreechop.Config; import org.milkteamc.autotreechop.MessageKeys; import org.milkteamc.autotreechop.PlayerConfig; +import org.milkteamc.autotreechop.hooks.HookManager; import org.milkteamc.autotreechop.utils.BlockDiscoveryUtils; import org.milkteamc.autotreechop.utils.ConfirmationManager.ChopData; import org.milkteamc.autotreechop.utils.EffectUtils; @@ -63,7 +64,7 @@ public void confirm(BukkitCommandActor actor) { } Config config = plugin.getPluginConfig(); - PlayerConfig playerConfig = plugin.getPlayerConfig(uuid); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(uuid); // The block may have been broken or replaced during the confirmation window // (e.g. another player cleared it). Re-validate before chopping. @@ -82,15 +83,16 @@ public void confirm(BukkitCommandActor actor) { EffectUtils.showChopEffect(player, block); } + HookManager hookManager = plugin.getHookManager(); ProtectionHooks hooks = new ProtectionHooks( - plugin.isWorldGuardEnabled(), - plugin.getWorldGuardHook(), - plugin.isResidenceEnabled(), - plugin.getResidenceHook(), - plugin.isGriefPreventionEnabled(), - plugin.getGriefPreventionHook(), - plugin.isLandsEnabled(), - plugin.getLandsHook()); + hookManager.isWorldGuardEnabled(), + hookManager.getWorldGuardHook(), + hookManager.isResidenceEnabled(), + hookManager.getResidenceHook(), + hookManager.isGriefPreventionEnabled(), + hookManager.getGriefPreventionHook(), + hookManager.isLandsEnabled(), + hookManager.getLandsHook()); plugin.getTreeChopUtils() .chopTree( diff --git a/src/main/java/org/milkteamc/autotreechop/command/ToggleCommand.java b/src/main/java/org/milkteamc/autotreechop/command/ToggleCommand.java index a564e55..c824a84 100644 --- a/src/main/java/org/milkteamc/autotreechop/command/ToggleCommand.java +++ b/src/main/java/org/milkteamc/autotreechop/command/ToggleCommand.java @@ -58,7 +58,7 @@ public void toggle(BukkitCommandActor actor, @Optional Player targetPlayer) { } UUID targetUUID = targetPlayer.getUniqueId(); - PlayerConfig playerConfig = plugin.getPlayerConfig(targetUUID); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(targetUUID); boolean autoTreeChopEnabled = !playerConfig.isAutoTreeChopEnabled(); playerConfig.setAutoTreeChopEnabled(autoTreeChopEnabled); @@ -96,7 +96,7 @@ public void enable(BukkitCommandActor actor) { AutoTreeChop.sendMessage(actor.sender(), MessageKeys.ONLY_PLAYERS); return; } - PlayerConfig playerConfig = plugin.getPlayerConfig(player.getUniqueId()); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(player.getUniqueId()); if (playerConfig.isAutoTreeChopEnabled()) { AutoTreeChop.sendMessage(player, MessageKeys.ALREADY_ENABLED); return; @@ -112,7 +112,7 @@ public void enable(BukkitCommandActor actor, EntitySelector targetPlayer int count = 0; String lastName = null; for (Player targetPlayer : targetPlayers) { - PlayerConfig cfg = plugin.getPlayerConfig(targetPlayer.getUniqueId()); + PlayerConfig cfg = plugin.getDataManager().getPlayerConfig(targetPlayer.getUniqueId()); if (cfg.isAutoTreeChopEnabled()) continue; // skip already-enabled silently, or send per-player msg cfg.setAutoTreeChopEnabled(true); lastName = targetPlayer.getName(); @@ -144,7 +144,7 @@ public void disable(BukkitCommandActor actor) { return; } UUID playerUUID = player.getUniqueId(); - PlayerConfig playerConfig = plugin.getPlayerConfig(playerUUID); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(playerUUID); if (!playerConfig.isAutoTreeChopEnabled()) { AutoTreeChop.sendMessage(player, MessageKeys.ALREADY_DISABLED); return; @@ -162,7 +162,7 @@ public void disable(BukkitCommandActor actor, EntitySelector targetPlaye String lastName = null; for (Player targetPlayer : targetPlayers) { UUID targetUUID = targetPlayer.getUniqueId(); - PlayerConfig cfg = plugin.getPlayerConfig(targetUUID); + PlayerConfig cfg = plugin.getDataManager().getPlayerConfig(targetUUID); if (!cfg.isAutoTreeChopEnabled()) continue; cfg.setAutoTreeChopEnabled(false); plugin.getConfirmationManager().clearPlayer(targetUUID); @@ -194,7 +194,7 @@ private void performSelfToggle(BukkitCommandActor actor) { } UUID playerUUID = player.getUniqueId(); - PlayerConfig playerConfig = plugin.getPlayerConfig(playerUUID); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(playerUUID); boolean autoTreeChopEnabled = !playerConfig.isAutoTreeChopEnabled(); playerConfig.setAutoTreeChopEnabled(autoTreeChopEnabled); diff --git a/src/main/java/org/milkteamc/autotreechop/command/UsageCommand.java b/src/main/java/org/milkteamc/autotreechop/command/UsageCommand.java index 46a0956..be6354b 100644 --- a/src/main/java/org/milkteamc/autotreechop/command/UsageCommand.java +++ b/src/main/java/org/milkteamc/autotreechop/command/UsageCommand.java @@ -47,7 +47,8 @@ public void usage(BukkitCommandActor actor) { } Player player = actor.asPlayer(); - org.milkteamc.autotreechop.PlayerConfig pConfig = plugin.getPlayerConfig(player.getUniqueId()); + org.milkteamc.autotreechop.PlayerConfig pConfig = + plugin.getDataManager().getPlayerConfig(player.getUniqueId()); boolean isVip = player.hasPermission("autotreechop.vip"); boolean limitVip = config.getLimitVipUsage(); @@ -55,7 +56,10 @@ public void usage(BukkitCommandActor actor) { String maxUsesStr; String maxBlocksStr; - if (!isVip) { + if (!config.getLimitUsage()) { + maxUsesStr = "∞"; + maxBlocksStr = "∞"; + } else if (!isVip) { maxUsesStr = String.valueOf(config.getMaxUsesPerDay()); maxBlocksStr = String.valueOf(config.getMaxBlocksPerDay()); } else if (limitVip) { diff --git a/src/main/java/org/milkteamc/autotreechop/database/DataManager.java b/src/main/java/org/milkteamc/autotreechop/database/DataManager.java new file mode 100644 index 0000000..c0c7d7e --- /dev/null +++ b/src/main/java/org/milkteamc/autotreechop/database/DataManager.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2026 MilkTeaMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.milkteamc.autotreechop.database; + +import com.github.Anon8281.universalScheduler.UniversalScheduler; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.milkteamc.autotreechop.AutoTreeChop; +import org.milkteamc.autotreechop.PlayerConfig; +import org.milkteamc.autotreechop.tasks.PlayerDataSaveTask; +import org.milkteamc.autotreechop.utils.ConfirmationManager; +import org.milkteamc.autotreechop.utils.SessionManager; + +public class DataManager { + + private static final long SAVE_INTERVAL = 1200L; // 60s + private static final int SAVE_THRESHOLD = 15; + + private final AutoTreeChop plugin; + private final DatabaseManager databaseManager; + private final ConfirmationManager confirmationManager; + private final Map playerConfigs = new ConcurrentHashMap<>(); + + private PlayerDataSaveTask saveTask; + + public DataManager(AutoTreeChop plugin, DatabaseManager databaseManager, ConfirmationManager confirmationManager) { + this.plugin = plugin; + this.databaseManager = databaseManager; + this.confirmationManager = confirmationManager; + } + + public void startSaveTask() { + this.saveTask = new PlayerDataSaveTask(plugin, SAVE_THRESHOLD); + UniversalScheduler.getScheduler(plugin).runTaskTimerAsynchronously(saveTask, SAVE_INTERVAL, SAVE_INTERVAL); + } + + public void shutdown() { + plugin.getLogger().info("Saving all player data before shutdown..."); + + if (saveTask != null) { + try { + saveTask.cancel(); + } catch (IllegalStateException ignored) { + // Task was never scheduled or already cancelled (e.g. Folia shutdown) + } + } + + if (!playerConfigs.isEmpty()) { + SessionManager sessionManager = SessionManager.getInstance(); + List dirtyDataList = new ArrayList<>(); + + for (Map.Entry entry : playerConfigs.entrySet()) { + UUID uuid = entry.getKey(); + PlayerConfig pConfig = entry.getValue(); + + if (confirmationManager != null) { + confirmationManager.clearPlayer(uuid); + } + + if (sessionManager != null) { + sessionManager.clearAllPlayerSessions(uuid); + } + + DatabaseManager.PlayerData snapshot = pConfig.popSnapshotIfDirty(); + if (snapshot != null) { + dirtyDataList.add(snapshot); + } + } + + if (!dirtyDataList.isEmpty() && databaseManager != null) { + long startTime = System.currentTimeMillis(); + databaseManager.savePlayerDataBatchSync(dirtyDataList); + long duration = System.currentTimeMillis() - startTime; + plugin.getLogger() + .info("Successfully saved " + dirtyDataList.size() + " player records in " + duration + "ms."); + } + + playerConfigs.clear(); + } + + if (databaseManager != null) { + databaseManager.close(); + } + } + + public void addPlayerConfig(UUID uuid, PlayerConfig config) { + playerConfigs.put(uuid, config); + } + + public PlayerConfig removePlayerConfig(UUID uuid) { + return playerConfigs.remove(uuid); + } + + public PlayerConfig getPlayerConfig(UUID uuid) { + return playerConfigs.get(uuid); + } + + public Collection getOnlinePlayersConfigs() { + return playerConfigs.values(); + } + + public int getPlayerDailyUses(UUID playerUUID) { + PlayerConfig config = getPlayerConfig(playerUUID); + return config != null ? config.getDailyUses() : 0; + } + + public int getPlayerDailyBlocksBroken(UUID playerUUID) { + PlayerConfig config = getPlayerConfig(playerUUID); + return config != null ? config.getDailyBlocksBroken() : 0; + } +} diff --git a/src/main/java/org/milkteamc/autotreechop/database/DatabaseManager.java b/src/main/java/org/milkteamc/autotreechop/database/DatabaseManager.java index c4a37f2..a7d3001 100644 --- a/src/main/java/org/milkteamc/autotreechop/database/DatabaseManager.java +++ b/src/main/java/org/milkteamc/autotreechop/database/DatabaseManager.java @@ -25,6 +25,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.time.LocalDate; +import java.util.Collection; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -124,37 +125,46 @@ public void savePlayerDataSync(PlayerData data) { } } - public CompletableFuture savePlayerDataBatchAsync(Map dataMap) { - return CompletableFuture.runAsync(() -> { - if (dataMap.isEmpty()) return; + public void savePlayerDataBatchSync(Collection dataCollection) { + if (dataCollection == null || dataCollection.isEmpty()) return; + + String sql = buildUpsertSql(); - String sql = buildUpsertSql(); - try (Connection conn = dataSource.getConnection()) { - conn.setAutoCommit(false); + try (Connection conn = dataSource.getConnection()) { + boolean originalAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (PlayerData data : dataMap.values()) { - bindUpsertParams(stmt, data); - stmt.addBatch(); - } - stmt.executeBatch(); - conn.commit(); - } catch (SQLException e) { - conn.rollback(); - throw e; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (PlayerData data : dataCollection) { + bindUpsertParams(stmt, data); + stmt.addBatch(); } + + stmt.executeBatch(); + conn.commit(); + } catch (SQLException e) { - plugin.getLogger().warning("Error batch saving player data: " + e.getMessage()); + conn.rollback(); + plugin.getLogger().severe("Failed to batch save player data: " + e.getMessage()); + throw e; // 如果是嚴重錯誤,可能需要往上拋或在這裡單純記錄 + } finally { + conn.setAutoCommit(originalAutoCommit); } - }); + } catch (SQLException e) { + plugin.getLogger().severe("Database connection error during batch save: " + e.getMessage()); + } + } + + public CompletableFuture savePlayerDataBatchAsync(Map dataMap) { + return CompletableFuture.runAsync(() -> savePlayerDataBatchSync(dataMap.values())); } /** * Returns a dialect-appropriate UPSERT statement. * *
    - *
  • SQLite: {@code INSERT OR REPLACE INTO ...} - *
  • MySQL: {@code INSERT INTO ... ON DUPLICATE KEY UPDATE ...} + *
  • SQLite: {@code INSERT OR REPLACE INTO ...} + *
  • MySQL: {@code INSERT INTO ... ON DUPLICATE KEY UPDATE ...} *
*/ private String buildUpsertSql() { @@ -224,6 +234,14 @@ public PlayerData( this.lastUseDate = lastUseDate; } + public PlayerData(PlayerData source) { + this.playerUUID = source.playerUUID; + this.autoTreeChopEnabled = source.autoTreeChopEnabled; + this.dailyUses = source.dailyUses; + this.dailyBlocksBroken = source.dailyBlocksBroken; + this.lastUseDate = source.lastUseDate; + } + public UUID getPlayerUUID() { return playerUUID; } diff --git a/src/main/java/org/milkteamc/autotreechop/events/BlockBreakListener.java b/src/main/java/org/milkteamc/autotreechop/events/BlockBreakListener.java index 12c6d9e..46a4e54 100644 --- a/src/main/java/org/milkteamc/autotreechop/events/BlockBreakListener.java +++ b/src/main/java/org/milkteamc/autotreechop/events/BlockBreakListener.java @@ -19,9 +19,7 @@ import java.util.HashMap; import java.util.Map; -import java.util.Set; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.ChunkSnapshot; import org.bukkit.Location; @@ -38,6 +36,7 @@ import org.milkteamc.autotreechop.Config; import org.milkteamc.autotreechop.MessageKeys; import org.milkteamc.autotreechop.PlayerConfig; +import org.milkteamc.autotreechop.hooks.HookManager; import org.milkteamc.autotreechop.utils.AsyncTaskScheduler; import org.milkteamc.autotreechop.utils.BlockDiscoveryUtils; import org.milkteamc.autotreechop.utils.ConfirmationManager; @@ -53,18 +52,6 @@ public class BlockBreakListener implements Listener { private final AutoTreeChop plugin; private final AsyncTaskScheduler scheduler; - /** - * Players who currently have an async leaf-check in flight. - * Guards against the race where the player breaks a second log before the - * first async check completes, which would start two concurrent chop pipelines - * for the same player before either has registered with SessionManager. - * - *

A player is added just before the async task is submitted and removed - * (via try-finally) when the sync callback finishes, whether it dispatches a - * chop, sets a pending confirmation, or discards the event (player offline). - */ - private final Set leafCheckInProgress = ConcurrentHashMap.newKeySet(); - public BlockBreakListener(AutoTreeChop plugin) { this.plugin = plugin; this.scheduler = new AsyncTaskScheduler(plugin); @@ -74,7 +61,7 @@ public BlockBreakListener(AutoTreeChop plugin) { public void onBlockBreak(BlockBreakEvent event) { Player player = event.getPlayer(); UUID playerUUID = player.getUniqueId(); - PlayerConfig playerConfig = plugin.getPlayerConfig(playerUUID); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(playerUUID); Block block = event.getBlock(); ItemStack tool = player.getInventory().getItemInMainHand(); Location location = block.getLocation(); @@ -96,6 +83,17 @@ public void onBlockBreak(BlockBreakEvent event) { return; } + ConfirmationManager confirmationManager = plugin.getConfirmationManager(); + ChopData pending = confirmationManager.consumePendingConfirmation(playerUUID); + + if (pending != null) { + event.setCancelled(true); + confirmationManager.recordSuccessfulChop(playerUUID, pending.reason(), false); + AutoTreeChop.sendMessage(player, MessageKeys.CONFIRMATION_SUCCESS); + dispatchChop(player, playerConfig, block, tool, location, config); + return; + } + if (plugin.getCooldownManager().isInCooldown(playerUUID)) { long remaining = plugin.getCooldownManager().getRemainingCooldown(playerUUID); AutoTreeChop.sendMessage( @@ -105,63 +103,42 @@ public void onBlockBreak(BlockBreakEvent event) { return; } - if (!PermissionUtils.hasVipBlock(player, playerConfig, config) - && playerConfig.getDailyBlocksBroken() >= config.getMaxBlocksPerDay()) { - EffectUtils.sendMaxBlockLimitReachedMessage(player, block); - return; - } + if (config.getLimitUsage()) { + if (!PermissionUtils.hasVipBlock(player, playerConfig, config) + && playerConfig.getDailyBlocksBroken() >= config.getMaxBlocksPerDay()) { + EffectUtils.sendMaxBlockLimitReachedMessage(player, block); + return; + } - if (!PermissionUtils.hasVipUses(player, playerConfig, config) - && playerConfig.getDailyUses() >= config.getMaxUsesPerDay()) { - AutoTreeChop.sendMessage(player, MessageKeys.HIT_MAX_USAGE); - return; + if (!PermissionUtils.hasVipUses(player, playerConfig, config) + && playerConfig.getDailyUses() >= config.getMaxUsesPerDay()) { + AutoTreeChop.sendMessage(player, MessageKeys.HIT_MAX_USAGE); + return; + } } - // Limits cleared — check for a pending confirmation first. - ConfirmationManager confirmationManager = plugin.getConfirmationManager(); - ChopData pending = confirmationManager.consumePendingConfirmation(playerUUID); - event.setCancelled(true); - if (pending != null) { - // Player confirmed by breaking a log within the confirmation window. - // Skip the leaf check entirely; grace is determined by the original reason. - confirmationManager.recordSuccessfulChop(playerUUID, pending.reason(), false); - AutoTreeChop.sendMessage(player, MessageKeys.CONFIRMATION_SUCCESS); - dispatchChop(player, playerConfig, block, tool, location, config); - return; - } - - // Guard against concurrent leaf checks for the same player. - // If a check is already in flight we simply eat this break — the log is - // still present (event was cancelled) so the player can try again. - if (!leafCheckInProgress.add(playerUUID)) { + if (!SessionManager.getInstance().startLeafCheck(playerUUID)) { return; } - // Pre-capture chunk snapshots on the main/region thread (world access is required - // here), then read them on an async thread (snapshots are immutable — thread-safe). int radius = config.getNoLeavesDetectionRadius(); Map snapshots = captureLeafCheckSnapshots(block, radius); - // Clone tool now so we have stable values for the async path. ItemStack frozenTool = tool.clone(); Location frozenLocation = location; scheduler.runTaskAsync(() -> { boolean hasLeaves = hasNearbyLeaves(block, radius, config, snapshots); - // Return to the main/region thread to act on the result. scheduler.runTaskAtLocation(frozenLocation, () -> { - // try-finally guarantees leafCheckInProgress is cleared on every exit path. try { if (!player.isOnline()) return; ConfirmReason reason = confirmationManager.getConfirmationReason(playerUUID, hasLeaves); if (reason != null) { - // Store the chop parameters so /atc confirm can fire the chop - // without requiring the player to physically re-break the log. confirmationManager.setPendingConfirmation(playerUUID, reason, frozenLocation, frozenTool); String timeoutStr = String.valueOf(config.getConfirmationWindowSeconds()); @@ -178,7 +155,7 @@ public void onBlockBreak(BlockBreakEvent event) { confirmationManager.recordSuccessfulChop(playerUUID, null, hasLeaves); dispatchChop(player, playerConfig, block, frozenTool, frozenLocation, config); } finally { - leafCheckInProgress.remove(playerUUID); + SessionManager.getInstance().finishLeafCheck(playerUUID); } }); }); @@ -205,57 +182,41 @@ void dispatchChop( hooks); } - /** - * Builds a {@link ProtectionHooks} snapshot from the plugin's current hook state. - * - *

Extracted from {@link #dispatchChop} so that the hook wiring lives in one - * place and future hook additions only need to be made here. - */ private ProtectionHooks buildProtectionHooks() { + HookManager hm = plugin.getHookManager(); return new ProtectionHooks( - plugin.isWorldGuardEnabled(), - plugin.getWorldGuardHook(), - plugin.isResidenceEnabled(), - plugin.getResidenceHook(), - plugin.isGriefPreventionEnabled(), - plugin.getGriefPreventionHook(), - plugin.isLandsEnabled(), - plugin.getLandsHook()); + hm.isWorldGuardEnabled(), + hm.getWorldGuardHook(), + hm.isResidenceEnabled(), + hm.getResidenceHook(), + hm.isGriefPreventionEnabled(), + hm.getGriefPreventionHook(), + hm.isLandsEnabled(), + hm.getLandsHook()); } - /** - * Captures {@link ChunkSnapshot}s for all chunks within the leaf-detection radius. - * - *

Must be called on the main/region thread since it accesses live world state. - * Once captured, the returned snapshots are immutable and safe to read on any thread. - */ private Map captureLeafCheckSnapshots(Block log, int radius) { World world = log.getWorld(); int cx = log.getX(); int cz = log.getZ(); Map snapshots = new HashMap<>(); - for (int dx = -radius; dx <= radius; dx++) { - for (int dz = -radius; dz <= radius; dz++) { - int chunkX = (cx + dx) >> 4; - int chunkZ = (cz + dz) >> 4; + int minChunkX = (cx - radius) >> 4; + int maxChunkX = (cx + radius) >> 4; + int minChunkZ = (cz - radius) >> 4; + int maxChunkZ = (cz + radius) >> 4; + + for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { if (!world.isChunkLoaded(chunkX, chunkZ)) continue; + long key = chunkKey(chunkX, chunkZ); - snapshots.computeIfAbsent( - key, k -> world.getChunkAt(chunkX, chunkZ).getChunkSnapshot(false, false, false)); + snapshots.put(key, world.getChunkAt(chunkX, chunkZ).getChunkSnapshot(false, false, false)); } } return snapshots; } - /** - * Returns {@code true} if there is at least one leaf block within the configured - * detection radius centred on the given log. - * - *

Safe to call from an async thread — all block data is read from the - * pre-captured {@code snapshots}, which are immutable. Short-circuits on the - * first leaf found. - */ private static boolean hasNearbyLeaves(Block log, int radius, Config config, Map snapshots) { World world = log.getWorld(); int cx = log.getX(); diff --git a/src/main/java/org/milkteamc/autotreechop/events/PlayerJoinListener.java b/src/main/java/org/milkteamc/autotreechop/events/PlayerJoinListener.java index ce59e01..83e1e39 100644 --- a/src/main/java/org/milkteamc/autotreechop/events/PlayerJoinListener.java +++ b/src/main/java/org/milkteamc/autotreechop/events/PlayerJoinListener.java @@ -44,11 +44,14 @@ public void onPlayerJoin(PlayerJoinEvent event) { plugin.getDatabaseManager() .loadPlayerDataAsync(playerUUID, plugin.getPluginConfig().getDefaultTreeChop()) .thenAccept(data -> { + Player onlinePlayer = plugin.getServer().getPlayer(playerUUID); + if (onlinePlayer == null || !onlinePlayer.isOnline()) { + return; + } + PlayerConfig playerConfig = new PlayerConfig(playerUUID, data); - plugin.getAllPlayerConfigs().put(playerUUID, playerConfig); + plugin.getDataManager().addPlayerConfig(playerUUID, playerConfig); - // markRejoin must be called here, after playerConfig is loaded, - // so we know whether ATC was enabled for this player. if (playerConfig.isAutoTreeChopEnabled()) { plugin.getConfirmationManager().markRejoin(playerUUID); } @@ -56,11 +59,18 @@ public void onPlayerJoin(PlayerJoinEvent event) { .exceptionally(ex -> { plugin.getLogger() .warning("Failed to load data for player " + player.getName() + ": " + ex.getMessage()); - DatabaseManager.PlayerData defaultData = new DatabaseManager.PlayerData( - playerUUID, plugin.getPluginConfig().getDefaultTreeChop(), 0, 0, java.time.LocalDate.now()); - PlayerConfig fallback = new PlayerConfig(playerUUID, defaultData); - plugin.getAllPlayerConfigs().put(playerUUID, fallback); - // Default is disabled, so no markRejoin needed here. + + Player onlinePlayer = plugin.getServer().getPlayer(playerUUID); + if (onlinePlayer != null && onlinePlayer.isOnline()) { + DatabaseManager.PlayerData defaultData = new DatabaseManager.PlayerData( + playerUUID, + plugin.getPluginConfig().getDefaultTreeChop(), + 0, + 0, + java.time.LocalDate.now()); + PlayerConfig fallback = new PlayerConfig(playerUUID, defaultData); + plugin.getDataManager().addPlayerConfig(playerUUID, fallback); + } return null; }); ModrinthUpdateChecker checker = plugin.getUpdateChecker(); diff --git a/src/main/java/org/milkteamc/autotreechop/events/PlayerQuitListener.java b/src/main/java/org/milkteamc/autotreechop/events/PlayerQuitListener.java index 4fdcfe1..73e6e87 100644 --- a/src/main/java/org/milkteamc/autotreechop/events/PlayerQuitListener.java +++ b/src/main/java/org/milkteamc/autotreechop/events/PlayerQuitListener.java @@ -17,13 +17,14 @@ package org.milkteamc.autotreechop.events; +import java.util.Map; import java.util.UUID; -import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerQuitEvent; import org.milkteamc.autotreechop.AutoTreeChop; import org.milkteamc.autotreechop.PlayerConfig; +import org.milkteamc.autotreechop.database.DatabaseManager; import org.milkteamc.autotreechop.utils.SessionManager; public class PlayerQuitListener implements Listener { @@ -36,18 +37,26 @@ public PlayerQuitListener(AutoTreeChop plugin) { @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { - Player player = event.getPlayer(); - UUID playerUUID = player.getUniqueId(); - - PlayerConfig playerConfig = plugin.getAllPlayerConfigs().get(playerUUID); - if (playerConfig != null && playerConfig.isDirty()) { - plugin.getDatabaseManager().savePlayerDataSync(playerConfig.getData()); + UUID playerUUID = event.getPlayer().getUniqueId(); + + PlayerConfig playerConfig = plugin.getDataManager().removePlayerConfig(playerUUID); + + if (playerConfig != null) { + DatabaseManager.PlayerData snapshot = playerConfig.popSnapshotIfDirty(); + if (snapshot != null) { + plugin.getDatabaseManager() + .savePlayerDataBatchAsync(Map.of(playerUUID, snapshot)) + .exceptionally(ex -> { + plugin.getLogger() + .warning("Failed to save final data for quitting player " + playerUUID + ": " + + ex.getMessage()); + return null; + }); + } } - plugin.getAllPlayerConfigs().remove(playerUUID); SessionManager.getInstance().clearAllPlayerSessions(playerUUID); - - // Clear all confirmation state so memory doesn't leak between sessions. + SessionManager.getInstance().finishLeafCheck(playerUUID); plugin.getConfirmationManager().clearPlayer(playerUUID); } } diff --git a/src/main/java/org/milkteamc/autotreechop/events/PlayerSneakListener.java b/src/main/java/org/milkteamc/autotreechop/events/PlayerSneakListener.java index 7fc1c2a..83c0aa3 100644 --- a/src/main/java/org/milkteamc/autotreechop/events/PlayerSneakListener.java +++ b/src/main/java/org/milkteamc/autotreechop/events/PlayerSneakListener.java @@ -43,7 +43,7 @@ public void onPlayerToggleSneak(PlayerToggleSneakEvent event) { if (!player.hasPermission("autotreechop.use")) return; - PlayerConfig playerConfig = plugin.getPlayerConfig(playerUUID); + PlayerConfig playerConfig = plugin.getDataManager().getPlayerConfig(playerUUID); if (event.isSneaking()) { playerConfig.setAutoTreeChopEnabled(true); diff --git a/src/main/java/org/milkteamc/autotreechop/hooks/HookManager.java b/src/main/java/org/milkteamc/autotreechop/hooks/HookManager.java new file mode 100644 index 0000000..3c643f5 --- /dev/null +++ b/src/main/java/org/milkteamc/autotreechop/hooks/HookManager.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2026 MilkTeaMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.milkteamc.autotreechop.hooks; + +import org.bukkit.Bukkit; +import org.milkteamc.autotreechop.AutoTreeChop; +import org.milkteamc.autotreechop.Config; + +public class HookManager { + + private final AutoTreeChop plugin; + private WorldGuardHook worldGuardHook = null; + private ResidenceHook residenceHook = null; + private GriefPreventionHook griefPreventionHook = null; + private LandsHook landsHook = null; + + public HookManager(AutoTreeChop plugin, Config config) { + this.plugin = plugin; + initializeHooks(config); + } + + private void initializeHooks(Config config) { + if (Bukkit.getPluginManager().getPlugin("Residence") != null) { + try { + residenceHook = new ResidenceHook(config.getResidenceFlag()); + plugin.getLogger().info("Residence support enabled"); + } catch (Exception e) { + plugin.getLogger() + .warning( + "Residence can't be hooked, please report this to our GitHub: https://github.com/milkteamc/AutoTreeChop/issues"); + } + } + + if (Bukkit.getPluginManager().getPlugin("GriefPrevention") != null) { + try { + griefPreventionHook = new GriefPreventionHook(config.getGriefPreventionFlag()); + plugin.getLogger().info("GriefPrevention support enabled"); + } catch (Exception e) { + plugin.getLogger() + .warning( + "GriefPrevention can't be hooked, please report this to our GitHub: https://github.com/milkteamc/AutoTreeChop/issues"); + } + } + + if (Bukkit.getPluginManager().getPlugin("Lands") != null) { + try { + landsHook = new LandsHook(plugin); + plugin.getLogger().info("Lands support enabled"); + } catch (Exception e) { + plugin.getLogger() + .warning( + "Lands can't be hooked, please report this to our GitHub: https://github.com/milkteamc/AutoTreeChop/issues"); + } + } + + if (Bukkit.getPluginManager().getPlugin("WorldGuard") != null) { + try { + worldGuardHook = new WorldGuardHook(); + plugin.getLogger().info("WorldGuard support enabled"); + } catch (NoClassDefFoundError e) { + plugin.getLogger() + .warning( + "WorldGuard can't be hooked, please report this to our GitHub: https://github.com/milkteamc/AutoTreeChop/issues"); + } + } + } + + public boolean isWorldGuardEnabled() { + return worldGuardHook != null; + } + + public boolean isResidenceEnabled() { + return residenceHook != null; + } + + public boolean isGriefPreventionEnabled() { + return griefPreventionHook != null; + } + + public boolean isLandsEnabled() { + return landsHook != null; + } + + public WorldGuardHook getWorldGuardHook() { + return worldGuardHook; + } + + public ResidenceHook getResidenceHook() { + return residenceHook; + } + + public GriefPreventionHook getGriefPreventionHook() { + return griefPreventionHook; + } + + public LandsHook getLandsHook() { + return landsHook; + } +} diff --git a/src/main/java/org/milkteamc/autotreechop/tasks/PlayerDataSaveTask.java b/src/main/java/org/milkteamc/autotreechop/tasks/PlayerDataSaveTask.java index 40957a4..d90fdb3 100644 --- a/src/main/java/org/milkteamc/autotreechop/tasks/PlayerDataSaveTask.java +++ b/src/main/java/org/milkteamc/autotreechop/tasks/PlayerDataSaveTask.java @@ -50,7 +50,7 @@ public void checkThreshold() { private int countDirtyData() { int count = 0; - for (PlayerConfig config : plugin.getAllPlayerConfigs().values()) { + for (PlayerConfig config : plugin.getDataManager().getOnlinePlayersConfigs()) { if (config.isDirty()) { count++; } @@ -61,30 +61,24 @@ private int countDirtyData() { private void saveAllDirtyData() { Map dirtyDataMap = new HashMap<>(); - for (Map.Entry entry : plugin.getAllPlayerConfigs().entrySet()) { - PlayerConfig config = entry.getValue(); - if (config.isDirty()) { - dirtyDataMap.put(entry.getKey(), config.getData()); - config.clearDirty(); + for (PlayerConfig config : plugin.getDataManager().getOnlinePlayersConfigs()) { + DatabaseManager.PlayerData snapshot = config.popSnapshotIfDirty(); + if (snapshot != null) { + dirtyDataMap.put(snapshot.getPlayerUUID(), snapshot); } } if (!dirtyDataMap.isEmpty()) { - plugin.getDatabaseManager() - .savePlayerDataBatchAsync(dirtyDataMap) - .thenRun(() -> { - dirtyCount = 0; - }) - .exceptionally(ex -> { - plugin.getLogger().warning("Failed to save player data: " + ex.getMessage()); - for (UUID uuid : dirtyDataMap.keySet()) { - PlayerConfig config = plugin.getPlayerConfig(uuid); - if (config != null) { - config.markDirty(); - } - } - return null; - }); + plugin.getDatabaseManager().savePlayerDataBatchAsync(dirtyDataMap).exceptionally(ex -> { + plugin.getLogger().warning("Failed to save player data: " + ex.getMessage()); + for (UUID uuid : dirtyDataMap.keySet()) { + PlayerConfig config = plugin.getDataManager().getPlayerConfig(uuid); + if (config != null) { + config.markDirty(); + } + } + return null; + }); } } } diff --git a/src/main/java/org/milkteamc/autotreechop/updater/ModrinthUpdateChecker.java b/src/main/java/org/milkteamc/autotreechop/updater/ModrinthUpdateChecker.java index 8dce44b..59693b4 100644 --- a/src/main/java/org/milkteamc/autotreechop/updater/ModrinthUpdateChecker.java +++ b/src/main/java/org/milkteamc/autotreechop/updater/ModrinthUpdateChecker.java @@ -75,8 +75,8 @@ public class ModrinthUpdateChecker { @Nullable private String notifyPermission = null; - private int checkIntervalHours = 6; - private boolean suppressUpToDateMessage = false; + private int checkIntervalHours = 24; + private boolean suppressUpToDateMessage = true; private volatile UpdateCheckResult lastResult = UpdateCheckResult.UNKNOWN; public enum UpdateCheckResult { diff --git a/src/main/java/org/milkteamc/autotreechop/utils/BatchProcessor.java b/src/main/java/org/milkteamc/autotreechop/utils/BatchProcessor.java index baea53d..1dad361 100644 --- a/src/main/java/org/milkteamc/autotreechop/utils/BatchProcessor.java +++ b/src/main/java/org/milkteamc/autotreechop/utils/BatchProcessor.java @@ -31,14 +31,21 @@ public BatchProcessor(AutoTreeChop plugin, AsyncTaskScheduler scheduler) { } /** - * Process a list of locations in batches - * IMPORTANT: All processing happens on REGION thread for Folia compatibility + * Process a list of locations in batches. * - * @param locations List of locations to process - * @param startIndex Starting index - * @param batchSize Number of items to process per batch - * @param processor Function to process each location (location, index) -> void - * @param onComplete Callback when all batches are complete + *

Fast path: when all locations fit within a single batch + * (i.e. {@code locations.size() <= batchSize}) the entire work is dispatched + * as one region task. This avoids the recursive scheduling overhead that the + * general path incurs even when only one batch is needed (a very common case + * for small trees). + * + *

IMPORTANT: All processing happens on the REGION thread for Folia compatibility. + * + * @param locations list of locations to process + * @param startIndex starting index + * @param batchSize number of items to process per batch + * @param processor function to process each location: (location, index) -> void + * @param onComplete callback when all batches are complete */ public void processBatch( List locations, @@ -47,18 +54,39 @@ public void processBatch( BiConsumer processor, Runnable onComplete) { + if (locations.isEmpty()) { + if (onComplete != null) { + scheduler.runTask(onComplete); + } + return; + } + + // Fast path: everything fits in one batch – avoid recursive scheduling overhead + if (startIndex == 0 && locations.size() <= batchSize) { + Location first = locations.get(0); + scheduler.runTaskAtLocation(first, () -> { + for (int i = 0; i < locations.size(); i++) { + processor.accept(locations.get(i), i); + } + if (onComplete != null) { + onComplete.run(); + } + }); + return; + } + processBatchInternal(locations, startIndex, batchSize, processor, onComplete, 1L); } /** - * Process batches with custom delay + * Process batches with a custom inter-batch delay. * - * @param locations List of locations to process - * @param startIndex Starting index - * @param batchSize Number of items to process per batch - * @param processor Function to process each location (location, index) -> void - * @param onComplete Callback when all batches are complete - * @param delayTicks Delay between batches in ticks + * @param locations list of locations to process + * @param startIndex starting index + * @param batchSize number of items to process per batch + * @param processor function to process each location: (location, index) -> void + * @param onComplete callback when all batches are complete + * @param delayTicks delay between batches in ticks */ public void processBatchWithDelay( List locations, @@ -68,12 +96,19 @@ public void processBatchWithDelay( Runnable onComplete, long delayTicks) { + if (locations.isEmpty()) { + if (onComplete != null) { + scheduler.runTask(onComplete); + } + return; + } + processBatchInternal(locations, startIndex, batchSize, processor, onComplete, delayTicks); } /** - * Internal batch processing implementation - * Uses REGION scheduler to ensure Folia compatibility + * Internal batch processing implementation. + * Uses REGION scheduler to ensure Folia compatibility. */ private void processBatchInternal( List locations, @@ -85,7 +120,6 @@ private void processBatchInternal( if (locations.isEmpty() || startIndex >= locations.size()) { if (onComplete != null) { - // Run completion callback at first location's region if (!locations.isEmpty()) { scheduler.runTaskAtLocation(locations.get(0), onComplete); } else { @@ -98,23 +132,19 @@ private void processBatchInternal( int endIndex = Math.min(startIndex + batchSize, locations.size()); boolean isLastBatch = endIndex >= locations.size(); - // Get the first location in this batch for region scheduling Location batchLocation = locations.get(startIndex); - // Process current batch on REGION thread Runnable batchTask = () -> { for (int i = startIndex; i < endIndex; i++) { Location location = locations.get(i); processor.accept(location, i); } - // Schedule next batch or complete if (isLastBatch) { if (onComplete != null) { onComplete.run(); } } else { - // Schedule next batch at the next batch's first location Location nextLocation = locations.get(endIndex); Runnable nextBatch = () -> processBatchInternal(locations, endIndex, batchSize, processor, onComplete, delayTicks); @@ -122,19 +152,19 @@ private void processBatchInternal( } }; - // Execute this batch at the batch location's region scheduler.runTaskAtLocation(batchLocation, batchTask); } /** - * Process batches with early termination support - * Uses REGION scheduler to ensure Folia compatibility + * Process batches with early-termination support. * - * @param locations List of locations to process - * @param startIndex Starting index - * @param batchSize Number of items to process per batch - * @param processor Function to process each location, returns false to stop - * @param onComplete Callback when complete or stopped + *

Same single-batch fast path as {@link #processBatch} is applied here. + * + * @param locations list of locations to process + * @param startIndex starting index + * @param batchSize number of items to process per batch + * @param processor function to process each location; return {@code false} to stop + * @param onComplete callback when complete or stopped */ public void processBatchWithTermination( List locations, @@ -143,35 +173,47 @@ public void processBatchWithTermination( java.util.function.BiFunction processor, Runnable onComplete) { - if (locations.isEmpty() || startIndex >= locations.size()) { + if (locations.isEmpty()) { if (onComplete != null) { - if (!locations.isEmpty()) { - scheduler.runTaskAtLocation(locations.get(0), onComplete); - } else { - scheduler.runTask(onComplete); + scheduler.runTask(onComplete); + } + return; + } + + // Fast path: single batch + if (startIndex == 0 && locations.size() <= batchSize) { + Location first = locations.get(0); + scheduler.runTaskAtLocation(first, () -> { + for (int i = 0; i < locations.size(); i++) { + if (!processor.apply(locations.get(i), i)) break; } + if (onComplete != null) { + onComplete.run(); + } + }); + return; + } + + if (startIndex >= locations.size()) { + if (onComplete != null) { + scheduler.runTaskAtLocation(locations.get(0), onComplete); } return; } - // Get the first location in this batch for region scheduling Location batchLocation = locations.get(startIndex); - // Process current batch on REGION thread Runnable batchTask = () -> { int endIndex = Math.min(startIndex + batchSize, locations.size()); - // Process current batch with termination check boolean shouldContinue = true; int i = startIndex; for (; i < endIndex && shouldContinue; i++) { - Location location = locations.get(i); - shouldContinue = processor.apply(location, i); + shouldContinue = processor.apply(locations.get(i), i); } boolean isLastBatch = i >= locations.size(); - // Schedule next batch, complete, or terminate early if (!shouldContinue || isLastBatch) { if (onComplete != null) { onComplete.run(); @@ -185,7 +227,6 @@ public void processBatchWithTermination( } }; - // Execute this batch at the batch location's region scheduler.runTaskAtLocation(batchLocation, batchTask); } } diff --git a/src/main/java/org/milkteamc/autotreechop/utils/BlockDiscoveryUtils.java b/src/main/java/org/milkteamc/autotreechop/utils/BlockDiscoveryUtils.java index 0011f07..adc9a44 100644 --- a/src/main/java/org/milkteamc/autotreechop/utils/BlockDiscoveryUtils.java +++ b/src/main/java/org/milkteamc/autotreechop/utils/BlockDiscoveryUtils.java @@ -17,6 +17,7 @@ package org.milkteamc.autotreechop.utils; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.Queue; @@ -27,20 +28,53 @@ import org.milkteamc.autotreechop.Config; /** - * Refactored BlockDiscoveryUtils - All methods work with BlockSnapshot - * Safe to call from async threads + * Block-discovery utilities – all methods work with {@link BlockSnapshot} and + * are safe to call from async threads. + * + *

Leaf-removal performance improvements

+ *
    + *
  • Smart mode (was critical bottleneck): the old implementation + * called {@code isOrphanedLeaf} → {@code hasNearbyActiveLog} (O(r³) snapshot + * scan) → {@code isConnectedToActiveLog} (recursive DFS, depth 8) for + * every leaf. A large tree with 200 leaves and radius 6 triggered + * ~200 × 216 = 43 000 snapshot accesses plus up to 200 DFS traversals. + * The replacement is a two-pass BFS: one O(r³) sphere scan to collect all + * leaves and active log seeds, then one BFS from those seeds to mark + * connected leaves – total work is O(r³ + leaves × 26), done exactly once. + *
  • + *
  • Radius mode: pre-builds an {@code activeLogSet} with one O(r³) + * scan so the per-leaf proximity check becomes O(4³) set lookups instead of + * O(4³) snapshot {@code getBlockType} calls (~10–50× cheaper per lookup). + *
  • + *
  • Air-block BFS expansion fixed: the old {@code discoverLeavesBFS} + * expanded neighbours for every AIR/other block it encountered, causing the + * visited set to balloon to O(r³) even when most of the sphere was empty. + * The new two-pass approach never walks the air at all. + *
  • + *
  • Unnecessary Location allocation in addNeighborsToQueue: + * {@code snapshot.hasBlock(neighborKey.toLocation(world))} created one + * {@link Location} per neighbour (26 per queued node). The call is now + * skipped by relying on {@link BlockSnapshot#getBlockType} returning a + * non-log material for out-of-range positions, which the existing + * {@code !isLog()} guard in {@code discoverTreeBFS} already handles. + * TODO: add {@code BlockSnapshot.hasBlock(int, int, int)} to make this + * explicit and fully allocation-free. + *
  • + *
*/ public class BlockDiscoveryUtils { + // ── Tree discovery ──────────────────────────────────────────────────────── + /** - * Discover tree blocks from snapshot (ASYNC-SAFE) + * BFS discovery of tree log blocks from a snapshot (async-safe). * - * @param snapshot Block snapshot captured synchronously - * @param startLocation Starting location - * @param config Plugin configuration - * @param connectedOnly Whether to only follow connected blocks - * @param maxBlocks Maximum blocks to discover - * @return Set of locations that are part of the tree + * @param snapshot block snapshot captured synchronously + * @param startLocation starting location + * @param config plugin configuration + * @param connectedOnly whether to only follow face-connected (non-diagonal) blocks + * @param maxBlocks maximum blocks to discover + * @return set of locations that are part of the tree */ public static Set discoverTreeBFS( BlockSnapshot snapshot, Location startLocation, Config config, boolean connectedOnly, int maxBlocks) { @@ -60,95 +94,149 @@ public static Set discoverTreeBFS( BlockSnapshot.LocationKey currentKey = queue.poll(); Material type = snapshot.getBlockType(currentKey.getX(), currentKey.getY(), currentKey.getZ()); - // Check if it's a log if (!isLog(type, config)) { continue; } - // Check same type if required if (config.isStopChoppingIfDifferentTypes() && type != originalType) { continue; } - // Add to tree blocks treeBlocks.add(currentKey.toLocation(world)); - // Add neighbors to queue addNeighborsToQueue(currentKey, queue, visited, snapshot, connectedOnly); } return treeBlocks; } + // ── Leaf discovery – smart mode (two-pass BFS) ─────────────────────────── + /** - * Discover leaves from snapshot (ASYNC-SAFE) + * Discovers orphaned leaf blocks using a two-pass BFS (smart mode only). + * + *

Algorithm: + *

    + *
  1. Scan the sphere once to collect all leaf positions and all + * active (not removed) log positions.
  2. + *
  3. BFS outward from the active log seeds through leaves and logs. + * Any leaf reached is "connected" to a living log.
  4. + *
  5. Leaves not reached = orphaned = scheduled for removal.
  6. + *
* - * @param snapshot Block snapshot captured synchronously - * @param centerLocation Center of search area - * @param radius Search radius - * @param config Plugin configuration - * @param removedLogs Locations of logs that will be removed - * @return Set of leaf blocks that should be removed + *

This is O(r³ + leaves × 26) total – done once, not once per leaf. + * + * @param snapshot block snapshot captured synchronously + * @param centerLocation centre of the removal sphere + * @param radius removal radius + * @param config plugin configuration + * @param removedLogs locations of logs that were (or will be) removed + * @return set of leaf locations that should be removed */ public static Set discoverLeavesBFS( BlockSnapshot snapshot, Location centerLocation, int radius, Config config, Set removedLogs) { - Set leaves = new HashSet<>(); - Set visited = new HashSet<>(); - Queue queue = new LinkedList<>(); - World world = snapshot.getWorld(); BlockSnapshot.LocationKey centerKey = new BlockSnapshot.LocationKey(centerLocation); - int radiusSquared = radius * radius; + int radiusSq = radius * radius; - queue.add(centerKey); - visited.add(centerKey); + // Convert removed-log set once for O(1) membership tests throughout + Set removedLogKeys = toLocationKeySet(removedLogs); - String mode = config.getLeafRemovalMode().toLowerCase(); + // ── Pass 1: collect leaves and active log seeds in the sphere ───────── + // We include a 2-block buffer for log seeds so that logs just outside the + // removal sphere (e.g. neighbouring trees) are recognised as anchors and + // prevent their connected leaves from being incorrectly removed. + int logScanRadius = radius + 2; + int logScanSq = logScanRadius * logScanRadius; + + Set allLeaves = new HashSet<>(); + Set activeLogSeeds = new HashSet<>(); + + for (int dx = -logScanRadius; dx <= logScanRadius; dx++) { + for (int dy = -logScanRadius; dy <= logScanRadius; dy++) { + for (int dz = -logScanRadius; dz <= logScanRadius; dz++) { + int distSq = dx * dx + dy * dy + dz * dz; + if (distSq > logScanSq) continue; + + BlockSnapshot.LocationKey key = new BlockSnapshot.LocationKey( + centerKey.getX() + dx, centerKey.getY() + dy, centerKey.getZ() + dz); + Material type = snapshot.getBlockType(key.getX(), key.getY(), key.getZ()); - // Convert removedLogs to LocationKey set for fast lookup - Set removedLogKeys = new HashSet<>(); - for (Location loc : removedLogs) { - removedLogKeys.add(new BlockSnapshot.LocationKey(loc)); + if (distSq <= radiusSq && isLeafBlock(type, config)) { + allLeaves.add(key); + } else if (isLog(type, config) && !removedLogKeys.contains(key)) { + activeLogSeeds.add(key); + } + } + } } - while (!queue.isEmpty()) { - BlockSnapshot.LocationKey currentKey = queue.poll(); - Location loc = currentKey.toLocation(world); + if (allLeaves.isEmpty()) { + return Collections.emptySet(); + } - // Check if within radius (spherical) - if (getDistanceSquared(currentKey, centerKey) > radiusSquared) { - continue; + // If no living logs remain nearby, every leaf in the sphere is orphaned + if (activeLogSeeds.isEmpty()) { + Set all = new HashSet<>(allLeaves.size() * 2); + for (BlockSnapshot.LocationKey key : allLeaves) { + all.add(key.toLocation(world)); } + return all; + } - Material type = snapshot.getBlockType(currentKey.getX(), currentKey.getY(), currentKey.getZ()); + // ── Pass 2: BFS from active logs to mark connected leaves ───────────── + Set connected = new HashSet<>(); + Queue queue = new LinkedList<>(activeLogSeeds); + Set visited = new HashSet<>(activeLogSeeds); - // If it's a leaf, check if should be removed - if (isLeafBlock(type, config)) { - boolean shouldRemove = shouldRemoveLeaf(snapshot, currentKey, mode, config, removedLogKeys); - if (shouldRemove) { - leaves.add(loc); + while (!queue.isEmpty()) { + BlockSnapshot.LocationKey cur = queue.poll(); + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dy == 0 && dz == 0) continue; + BlockSnapshot.LocationKey nb = + new BlockSnapshot.LocationKey(cur.getX() + dx, cur.getY() + dy, cur.getZ() + dz); + if (visited.contains(nb)) continue; + visited.add(nb); + + if (allLeaves.contains(nb)) { + connected.add(nb); + queue.add(nb); // continue BFS through leaves + } else { + // Also traverse active logs to discover leaves behind them + Material type = snapshot.getBlockType(nb.getX(), nb.getY(), nb.getZ()); + if (isLog(type, config) && !removedLogKeys.contains(nb)) { + queue.add(nb); + } + } + } } - - // Continue searching through leaves - addLeafNeighborsToQueue(currentKey, queue, visited, centerKey, radiusSquared); - } - // If it's a log, continue searching (but don't remove) - else if (isLog(type, config)) { - addLeafNeighborsToQueue(currentKey, queue, visited, centerKey, radiusSquared); - } - // If it's AIR or other blocks, still search neighbors - // This is CRITICAL for starting from a removed log location - else { - addLeafNeighborsToQueue(currentKey, queue, visited, centerKey, radiusSquared); } } - return leaves; + // Leaves not reachable from any active log are orphaned + Set toRemove = new HashSet<>(); + for (BlockSnapshot.LocationKey leaf : allLeaves) { + if (!connected.contains(leaf)) { + toRemove.add(leaf.toLocation(world)); + } + } + return toRemove; } + // ── Leaf discovery – radius / aggressive modes ──────────────────────────── + /** - * Discover leaves using radial scan (ASYNC-SAFE, faster for small areas) + * Discovers leaves using a radial scan for {@code radius} and + * {@code aggressive} modes (async-safe). + * + *

Radius-mode optimisation: the old code called + * {@code snapshot.getBlockType()} 4³ = 64 times per leaf for the + * proximity check. This version pre-builds a {@code Set} of active log + * positions in one O((r+4)³) pass; the per-leaf check then becomes 64 + * hash-set lookups which are 10–50× cheaper than snapshot accesses. */ public static Set discoverLeavesRadial( BlockSnapshot snapshot, Location centerLocation, int radius, Config config, Set removedLogs) { @@ -157,33 +245,54 @@ public static Set discoverLeavesRadial( World world = snapshot.getWorld(); BlockSnapshot.LocationKey centerKey = new BlockSnapshot.LocationKey(centerLocation); String mode = config.getLeafRemovalMode().toLowerCase(); - - // Convert removedLogs to LocationKey set - Set removedLogKeys = new HashSet<>(); - for (Location loc : removedLogs) { - removedLogKeys.add(new BlockSnapshot.LocationKey(loc)); + int radiusSq = radius * radius; + + // Convert removed logs once + Set removedLogKeys = toLocationKeySet(removedLogs); + + // For radius mode: build active-log set once so per-leaf checks are O(4³) + // set lookups rather than O(4³) snapshot.getBlockType() calls. + Set activeLogSet = null; + if ("radius".equals(mode)) { + final int CHECK_RADIUS = 4; // matches original hasNearbyActiveLog radius + int extRadius = radius + CHECK_RADIUS; + int extSq = extRadius * extRadius; + activeLogSet = new HashSet<>(); + for (int dx = -extRadius; dx <= extRadius; dx++) { + for (int dy = -extRadius; dy <= extRadius; dy++) { + for (int dz = -extRadius; dz <= extRadius; dz++) { + if (dx * dx + dy * dy + dz * dz > extSq) continue; + BlockSnapshot.LocationKey key = new BlockSnapshot.LocationKey( + centerKey.getX() + dx, centerKey.getY() + dy, centerKey.getZ() + dz); + Material type = snapshot.getBlockType(key.getX(), key.getY(), key.getZ()); + if (isLog(type, config) && !removedLogKeys.contains(key)) { + activeLogSet.add(key); + } + } + } + } } - int radiusSquared = radius * radius; - - for (int x = -radius; x <= radius; x++) { - for (int y = -radius; y <= radius; y++) { - for (int z = -radius; z <= radius; z++) { - // Spherical check - if (x * x + y * y + z * z > radiusSquared) { - continue; - } + // Scan leaves in sphere + for (int dx = -radius; dx <= radius; dx++) { + for (int dy = -radius; dy <= radius; dy++) { + for (int dz = -radius; dz <= radius; dz++) { + if (dx * dx + dy * dy + dz * dz > radiusSq) continue; BlockSnapshot.LocationKey key = new BlockSnapshot.LocationKey( - centerKey.getX() + x, centerKey.getY() + y, centerKey.getZ() + z); - + centerKey.getX() + dx, centerKey.getY() + dy, centerKey.getZ() + dz); Material type = snapshot.getBlockType(key.getX(), key.getY(), key.getZ()); - if (isLeafBlock(type, config)) { - boolean shouldRemove = shouldRemoveLeaf(snapshot, key, mode, config, removedLogKeys); - if (shouldRemove) { + if (!isLeafBlock(type, config)) continue; + + if ("radius".equals(mode)) { + // O(4³) set lookups – no snapshot accesses + if (!hasNearbyActiveLogInSet(key, activeLogSet, 4)) { leaves.add(key.toLocation(world)); } + } else { + // "aggressive" or any unrecognised mode: remove unconditionally + leaves.add(key.toLocation(world)); } } } @@ -192,60 +301,22 @@ public static Set discoverLeavesRadial( return leaves; } - // ==================== Private Helper Methods ==================== - - private static boolean shouldRemoveLeaf( - BlockSnapshot snapshot, - BlockSnapshot.LocationKey leafKey, - String mode, - Config config, - Set removedLogKeys) { - - switch (mode) { - case "aggressive": - return true; - - case "radius": - return !hasNearbyActiveLog(snapshot, leafKey, config, removedLogKeys, 4); + // ── Private helpers ─────────────────────────────────────────────────────── - case "smart": - default: - return isOrphanedLeaf(snapshot, leafKey, config, removedLogKeys); - } - } - - private static boolean isOrphanedLeaf( - BlockSnapshot snapshot, - BlockSnapshot.LocationKey leafKey, - Config config, - Set removedLogKeys) { - - if (!hasNearbyActiveLog(snapshot, leafKey, config, removedLogKeys, 2)) { - return true; - } - - Set visited = new HashSet<>(); - return !isConnectedToActiveLog(snapshot, leafKey, config, removedLogKeys, visited, 0); - } - - private static boolean hasNearbyActiveLog( - BlockSnapshot snapshot, - BlockSnapshot.LocationKey leafKey, - Config config, - Set removedLogKeys, - int checkRadius) { - - for (int x = -checkRadius; x <= checkRadius; x++) { - for (int y = -checkRadius; y <= checkRadius; y++) { - for (int z = -checkRadius; z <= checkRadius; z++) { - if (x == 0 && y == 0 && z == 0) continue; - - BlockSnapshot.LocationKey checkKey = - new BlockSnapshot.LocationKey(leafKey.getX() + x, leafKey.getY() + y, leafKey.getZ() + z); - - Material type = snapshot.getBlockType(checkKey.getX(), checkKey.getY(), checkKey.getZ()); - - if (isLog(type, config) && !removedLogKeys.contains(checkKey)) { + /** + * Checks whether any entry in {@code activeLogSet} lies within + * {@code checkRadius} blocks of {@code leafKey}. + * All lookups are O(1) hash-set membership tests. + */ + private static boolean hasNearbyActiveLogInSet( + BlockSnapshot.LocationKey leafKey, Set activeLogSet, int checkRadius) { + + for (int dx = -checkRadius; dx <= checkRadius; dx++) { + for (int dy = -checkRadius; dy <= checkRadius; dy++) { + for (int dz = -checkRadius; dz <= checkRadius; dz++) { + if (dx == 0 && dy == 0 && dz == 0) continue; + if (activeLogSet.contains(new BlockSnapshot.LocationKey( + leafKey.getX() + dx, leafKey.getY() + dy, leafKey.getZ() + dz))) { return true; } } @@ -254,46 +325,13 @@ private static boolean hasNearbyActiveLog( return false; } - private static boolean isConnectedToActiveLog( - BlockSnapshot snapshot, - BlockSnapshot.LocationKey startKey, - Config config, - Set removedLogKeys, - Set visited, - int depth) { - - if (depth > 8 || visited.size() > 100) { - return false; - } - - if (visited.contains(startKey)) { - return false; - } - visited.add(startKey); - - for (int x = -1; x <= 1; x++) { - for (int y = -1; y <= 1; y++) { - for (int z = -1; z <= 1; z++) { - if (x == 0 && y == 0 && z == 0) continue; - - BlockSnapshot.LocationKey checkKey = new BlockSnapshot.LocationKey( - startKey.getX() + x, startKey.getY() + y, startKey.getZ() + z); - - Material type = snapshot.getBlockType(checkKey.getX(), checkKey.getY(), checkKey.getZ()); - - if (isLog(type, config) && !removedLogKeys.contains(checkKey)) { - return true; - } - - if (isLeafBlock(type, config) && !visited.contains(checkKey)) { - if (isConnectedToActiveLog(snapshot, checkKey, config, removedLogKeys, visited, depth + 1)) { - return true; - } - } - } - } + /** Converts a {@code Set} to a {@code Set} in one pass. */ + private static Set toLocationKeySet(Set locations) { + Set result = new HashSet<>(locations.size() * 2); + for (Location loc : locations) { + result.add(new BlockSnapshot.LocationKey(loc)); } - return false; + return result; } private static void addNeighborsToQueue( @@ -313,12 +351,14 @@ private static void addNeighborsToQueue( if (visited.contains(neighborKey)) continue; - // Check if block exists in snapshot - if (!snapshot.hasBlock(neighborKey.toLocation(snapshot.getWorld()))) { - continue; - } + // NOTE: the original hasBlock(neighborKey.toLocation(world)) call created one + // Location object per neighbour (up to 26 per queued node). We skip that + // allocation here: if the coordinates are outside the snapshot region, + // getBlockType() returns a non-log material and the !isLog() guard in + // discoverTreeBFS drops the node naturally. + // TODO: add BlockSnapshot.hasBlock(int, int, int) to make the bounds check + // explicit without object allocation. - // Check connectivity if required if (connectedOnly && !isConnectedKeys(current, neighborKey)) { continue; } @@ -330,33 +370,6 @@ private static void addNeighborsToQueue( } } - private static void addLeafNeighborsToQueue( - BlockSnapshot.LocationKey current, - Queue queue, - Set visited, - BlockSnapshot.LocationKey center, - int radiusSquared) { - - for (int x = -1; x <= 1; x++) { - for (int y = -1; y <= 1; y++) { - for (int z = -1; z <= 1; z++) { - if (x == 0 && y == 0 && z == 0) continue; - - BlockSnapshot.LocationKey neighborKey = - new BlockSnapshot.LocationKey(current.getX() + x, current.getY() + y, current.getZ() + z); - - if (visited.contains(neighborKey)) continue; - if (getDistanceSquared(neighborKey, center) > radiusSquared) continue; - - // CRITICAL: Only add to queue if not already visited - // Don't need to check snapshot here - will be checked when processing - visited.add(neighborKey); - queue.add(neighborKey); - } - } - } - } - private static boolean isConnectedKeys(BlockSnapshot.LocationKey k1, BlockSnapshot.LocationKey k2) { int dx = Math.abs(k1.getX() - k2.getX()); int dy = Math.abs(k1.getY() - k2.getY()); @@ -364,12 +377,7 @@ private static boolean isConnectedKeys(BlockSnapshot.LocationKey k1, BlockSnapsh return (dx + dy + dz) == 1; } - private static int getDistanceSquared(BlockSnapshot.LocationKey k1, BlockSnapshot.LocationKey k2) { - int dx = k1.getX() - k2.getX(); - int dy = k1.getY() - k2.getY(); - int dz = k1.getZ() - k2.getZ(); - return dx * dx + dy * dy + dz * dz; - } + // ── Public accessors ────────────────────────────────────────────────────── public static boolean isLog(Material material, Config config) { return config.getLogTypes().contains(material); diff --git a/src/main/java/org/milkteamc/autotreechop/utils/CoordKey.java b/src/main/java/org/milkteamc/autotreechop/utils/CoordKey.java new file mode 100644 index 0000000..db117dc --- /dev/null +++ b/src/main/java/org/milkteamc/autotreechop/utils/CoordKey.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 MilkTeaMC and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.milkteamc.autotreechop.utils; + +import java.util.Objects; +import org.bukkit.Location; + +/** + * Immutable block-coordinate key that provides correct O(1) hash-based equality + * for block positions. + * + *

Bukkit's {@link Location#equals} and {@link Location#hashCode} include yaw and + * pitch as floating-point fields. Block locations obtained from + * {@code block.getLocation()} always carry {@code yaw=0f / pitch=0f}, so a plain + * {@code Set} works for those – but any code that formerly used + * {@code stream().anyMatch()} with manual coordinate comparison was already + * bypassing the hash entirely, paying O(n) per lookup. This class guarantees + * O(1) regardless of how the Location was constructed. + */ +public final class CoordKey { + + private final int x; + private final int y; + private final int z; + private final String world; + + private CoordKey(int x, int y, int z, String world) { + this.x = x; + this.y = y; + this.z = z; + this.world = world; + } + + /** Build a key from a {@link Location} (uses block coordinates). */ + public static CoordKey of(Location loc) { + return new CoordKey( + loc.getBlockX(), + loc.getBlockY(), + loc.getBlockZ(), + loc.getWorld() != null ? loc.getWorld().getName() : ""); + } + + /** Build a key from raw integer block coordinates and a world name. */ + public static CoordKey of(int x, int y, int z, String worldName) { + return new CoordKey(x, y, z, worldName != null ? worldName : ""); + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getZ() { + return z; + } + + public String getWorld() { + return world; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CoordKey other)) return false; + return x == other.x && y == other.y && z == other.z && Objects.equals(world, other.world); + } + + @Override + public int hashCode() { + int h = x; + h = h * 31 + y; + h = h * 31 + z; + h = h * 31 + (world != null ? world.hashCode() : 0); + return h; + } + + @Override + public String toString() { + return world + ":" + x + "," + y + "," + z; + } +} diff --git a/src/main/java/org/milkteamc/autotreechop/utils/SessionManager.java b/src/main/java/org/milkteamc/autotreechop/utils/SessionManager.java index e3f758c..cc68dc1 100644 --- a/src/main/java/org/milkteamc/autotreechop/utils/SessionManager.java +++ b/src/main/java/org/milkteamc/autotreechop/utils/SessionManager.java @@ -17,12 +17,10 @@ package org.milkteamc.autotreechop.utils; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -30,46 +28,62 @@ public class SessionManager { - private static SessionManager instance; + /* + * Singleton – double-checked locking with volatile field. + * The original non-volatile field was unsafe on the Java memory model: + * a second thread could see a partially-constructed instance. + */ + private static volatile SessionManager instance; + + /* + * Block locations obtained from block.getLocation() always carry + * yaw=0f / pitch=0f, so Location.equals / hashCode is consistent for + * them and Set.contains() is O(1) – no manual stream scan needed. + */ private final Map> treeChopProcessingLocations = new ConcurrentHashMap<>(); private final Map> leafRemovalRemovedLogs = new ConcurrentHashMap<>(); + + /* + * Reverse index: playerKey → active sessionId. + * Previously, trackRemovedLogForPlayer and clearAllPlayerSessions iterated + * every entry in leafRemovalRemovedLogs looking for a playerKey prefix – + * O(all active sessions) per call. With this map both operations are O(1). + */ + private final Map playerKeyToSessionId = new ConcurrentHashMap<>(); + private final Set activeLeafRemovalSessions = ConcurrentHashMap.newKeySet(); + private final Set leafCheckInProgress = ConcurrentHashMap.newKeySet(); private SessionManager() {} public static SessionManager getInstance() { - if (instance == null) { - instance = new SessionManager(); + SessionManager result = instance; + if (result == null) { + synchronized (SessionManager.class) { + result = instance; + if (result == null) { + instance = result = new SessionManager(); + } + } } - return instance; + return result; } /** - * Check if a location is currently being processed in a TreeChop session + * O(1) – uses {@code Set.contains()} instead of the previous + * {@code stream().anyMatch()} with manual coordinate comparison. */ public boolean isLocationProcessing(UUID playerUUID, Location location) { Set locations = treeChopProcessingLocations.get(playerUUID); - if (locations == null) return false; - - return locations.stream() - .anyMatch(loc -> loc.getBlockX() == location.getBlockX() - && loc.getBlockY() == location.getBlockY() - && loc.getBlockZ() == location.getBlockZ() - && Objects.equals(loc.getWorld(), location.getWorld())); + return locations != null && locations.contains(location); } - /** - * Add locations to TreeChop processing set - */ public void addTreeChopLocations(UUID playerUUID, Collection locations) { treeChopProcessingLocations .computeIfAbsent(playerUUID, k -> ConcurrentHashMap.newKeySet()) .addAll(locations); } - /** - * Remove locations from TreeChop processing set - */ public void removeTreeChopLocations(UUID playerUUID, Collection locations) { Set playerLocations = treeChopProcessingLocations.get(playerUUID); if (playerLocations != null) { @@ -80,39 +94,30 @@ public void removeTreeChopLocations(UUID playerUUID, Collection locati } } - /** - * Clear all TreeChop locations for a player - */ public void clearTreeChopSession(UUID playerUUID) { treeChopProcessingLocations.remove(playerUUID); } - /** - * Check if player has an active leaf removal session - */ public boolean hasActiveLeafRemovalSession(String playerKey) { return activeLeafRemovalSessions.contains(playerKey); } /** - * Start a new leaf removal session + * Start a new leaf removal session. * - * @return session ID + * @return the session ID, or {@code null} if the player already has one */ public String startLeafRemovalSession(String playerKey) { if (hasActiveLeafRemovalSession(playerKey)) { - return null; // Already has active session + return null; } - String sessionId = playerKey + "_" + System.currentTimeMillis(); leafRemovalRemovedLogs.put(sessionId, ConcurrentHashMap.newKeySet()); activeLeafRemovalSessions.add(playerKey); + playerKeyToSessionId.put(playerKey, sessionId); // populate reverse index return sessionId; } - /** - * Track a removed log in a leaf removal session - */ public void trackRemovedLog(String sessionId, Location location) { Set logs = leafRemovalRemovedLogs.get(sessionId); if (logs != null) { @@ -121,73 +126,84 @@ public void trackRemovedLog(String sessionId, Location location) { } /** - * Track a removed log for all active sessions of a player + * O(1) via reverse index. + * Previously iterated {@code leafRemovalRemovedLogs.entrySet()} searching + * for entries whose key started with {@code playerKey + "_"} – O(all sessions). + * This is called for every broken log block, making the old cost significant. */ public void trackRemovedLogForPlayer(String playerKey, Location location) { - for (Map.Entry> entry : leafRemovalRemovedLogs.entrySet()) { - if (entry.getKey().startsWith(playerKey + "_")) { - entry.getValue().add(location.clone()); + String sessionId = playerKeyToSessionId.get(playerKey); + if (sessionId != null) { + Set logs = leafRemovalRemovedLogs.get(sessionId); + if (logs != null) { + logs.add(location.clone()); } } } - /** - * Get removed logs for a session - */ public Set getRemovedLogs(String sessionId) { return leafRemovalRemovedLogs.getOrDefault(sessionId, Collections.emptySet()); } /** - * Check if a location was removed in this session + * O(1) via {@code Set.contains()}. + * Previously used {@code stream().anyMatch()} with manual coordinate comparison. */ public boolean isLogRemoved(String sessionId, Location location) { Set logs = leafRemovalRemovedLogs.get(sessionId); - if (logs == null) return false; - - return logs.stream() - .anyMatch(loc -> loc.getBlockX() == location.getBlockX() - && loc.getBlockY() == location.getBlockY() - && loc.getBlockZ() == location.getBlockZ() - && Objects.equals(loc.getWorld(), location.getWorld())); + return logs != null && logs.contains(location); } - /** - * End a leaf removal session and cleanup - */ public void endLeafRemovalSession(String sessionId, String playerKey) { leafRemovalRemovedLogs.remove(sessionId); activeLeafRemovalSessions.remove(playerKey); + playerKeyToSessionId.remove(playerKey); // clean up reverse index + } + + public boolean startLeafCheck(UUID uuid) { + return leafCheckInProgress.add(uuid); + } + + public void finishLeafCheck(UUID uuid) { + leafCheckInProgress.remove(uuid); } - /** - * Check if player has any active session (TreeChop or LeafRemoval) - */ public boolean hasAnyActiveSession(UUID playerUUID) { return treeChopProcessingLocations.containsKey(playerUUID) || hasActiveLeafRemovalSession(playerUUID.toString()); } /** - * Clear all sessions for a player (useful for cleanup on logout) + * Clears every session belonging to a player. + * + *

The leaf-removal cleanup now uses the reverse index ({@code playerKeyToSessionId}) + * to locate the player's active session in O(1) instead of scanning all session + * keys with a prefix match. */ public void clearAllPlayerSessions(UUID playerUUID) { clearTreeChopSession(playerUUID); + finishLeafCheck(playerUUID); String playerKey = playerUUID.toString(); - // Find and remove all leaf removal sessions for this player - List toRemove = new ArrayList<>(); - for (String sessionId : leafRemovalRemovedLogs.keySet()) { - if (sessionId.startsWith(playerKey + "_")) { - toRemove.add(sessionId); + + // O(1) lookup via reverse index + String sessionId = playerKeyToSessionId.get(playerKey); + if (sessionId != null) { + endLeafRemovalSession(sessionId, playerKey); + return; + } + + // Fallback: defensive linear scan in case the reverse index missed an entry + // (e.g. sessions started before the index existed during a hot-reload). + List toRemove = new java.util.ArrayList<>(); + for (String sid : leafRemovalRemovedLogs.keySet()) { + if (sid.startsWith(playerKey + "_")) { + toRemove.add(sid); } } - toRemove.forEach(sessionId -> endLeafRemovalSession(sessionId, playerKey)); + toRemove.forEach(sid -> endLeafRemovalSession(sid, playerKey)); } - /** - * Get statistics for debugging - */ public String getStats() { return String.format( "TreeChop sessions: %d, LeafRemoval sessions: %d", diff --git a/src/main/java/org/milkteamc/autotreechop/utils/TreeChopUtils.java b/src/main/java/org/milkteamc/autotreechop/utils/TreeChopUtils.java index 0d5d94c..997fc1e 100644 --- a/src/main/java/org/milkteamc/autotreechop/utils/TreeChopUtils.java +++ b/src/main/java/org/milkteamc/autotreechop/utils/TreeChopUtils.java @@ -51,7 +51,8 @@ public TreeChopUtils(AutoTreeChop plugin) { this.sessionManager = SessionManager.getInstance(); } - private static boolean hasEnoughDurability(ItemStack tool, int blockCount, Config config) { + private static boolean hasEnoughDurability(Player player, int blockCount, Config config) { + ItemStack tool = player.getInventory().getItemInMainHand(); if (tool == null || tool.getType().getMaxDurability() <= 0) { return true; } @@ -77,7 +78,8 @@ private static boolean hasEnoughDurability(ItemStack tool, int blockCount, Confi return remainingDurability > estimatedDamage; } - private static void applyToolDamage(ItemStack tool, Player player, int blocksBroken, Config config) { + private static void applyToolDamage(Player player, int blocksBroken, Config config) { + ItemStack tool = player.getInventory().getItemInMainHand(); if (tool == null || tool.getType().getMaxDurability() <= 0) { return; } @@ -95,11 +97,17 @@ private static void applyToolDamage(ItemStack tool, Player player, int blocksBro } } + if (damageToApply == 0) return; + int currentDamage = damageableMeta.getDamage(); int newDamage = currentDamage + damageToApply; if (newDamage >= tool.getType().getMaxDurability()) { - player.getInventory().setItemInMainHand(null); + tool.setAmount(0); + try { + XSound.ENTITY_ITEM_BREAK.play(player.getLocation(), 1.0f, 1.0f); + } catch (Exception ignored) { + } } else { damageableMeta.setDamage(newDamage); tool.setItemMeta(damageableMeta); @@ -123,11 +131,16 @@ private static boolean shouldApplyDurabilityLoss(int unbreakingLevel, Config con public static boolean isTool(Player player) { ItemStack item = player.getInventory().getItemInMainHand(); - if (item == null || XMaterial.matchXMaterial(item) == XMaterial.AIR) { + if (item == null) { + return false; + } + + XMaterial xMat = XMaterial.matchXMaterial(item); + if (xMat == XMaterial.AIR) { return false; } - String materialName = item.getType().toString(); + String materialName = xMat.name(); if (materialName.endsWith("_AXE") || materialName.endsWith("_HOE") @@ -137,7 +150,6 @@ public static boolean isTool(Player player) { return true; } - XMaterial xMat = XMaterial.matchXMaterial(item); return xMat == XMaterial.SHEARS || xMat == XMaterial.FISHING_ROD || xMat == XMaterial.FLINT_AND_STEEL; } @@ -237,15 +249,17 @@ private void validateAndExecuteChop( return; } - if (!PermissionUtils.hasVipBlock(player, playerConfig, config)) { - if (playerConfig.getDailyBlocksBroken() + treeBlocks.size() > config.getMaxBlocksPerDay()) { - AutoTreeChop.sendMessage(player, MessageKeys.HIT_MAX_BLOCK); - sessionManager.clearTreeChopSession(playerUUID); - return; + if (config.getLimitUsage()) { + if (!PermissionUtils.hasVipBlock(player, playerConfig, config)) { + if (playerConfig.getDailyBlocksBroken() + treeBlocks.size() > config.getMaxBlocksPerDay()) { + AutoTreeChop.sendMessage(player, MessageKeys.HIT_MAX_BLOCK); + sessionManager.clearTreeChopSession(playerUUID); + return; + } } } - if (config.isToolDamage() && !hasEnoughDurability(tool, treeBlocks.size(), config)) { + if (config.isToolDamage() && !hasEnoughDurability(player, treeBlocks.size(), config)) { sessionManager.clearTreeChopSession(playerUUID); return; } @@ -332,7 +346,7 @@ private void executeTreeChop( () -> { // After all logs are removed if (config.isToolDamage()) { - applyToolDamage(tool, player, totalBlocks, config); + applyToolDamage(player, totalBlocks, config); } // Handle leaf removal diff --git a/src/main/java/org/milkteamc/autotreechop/utils/TreeReplantUtils.java b/src/main/java/org/milkteamc/autotreechop/utils/TreeReplantUtils.java index 17c3f03..ef25ae0 100644 --- a/src/main/java/org/milkteamc/autotreechop/utils/TreeReplantUtils.java +++ b/src/main/java/org/milkteamc/autotreechop/utils/TreeReplantUtils.java @@ -18,11 +18,11 @@ package org.milkteamc.autotreechop.utils; import com.cryptomorin.xseries.XMaterial; +import java.util.HashSet; import java.util.Set; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; -import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.entity.Player; @@ -73,7 +73,6 @@ public static void scheduleReplant( if (anchorLocation == null) { return; } - // Check permission at all four sapling positions Block anchor = anchorLocation.getBlock(); for (int[] offset : FORMATION_2X2) { Location pos = anchor.getRelative(offset[0], 0, offset[1]).getLocation(); @@ -97,7 +96,6 @@ public static void scheduleReplant( if (plantLocation == null) { return; } - // Check permission at the actual plant location, not the original break point if (!hasReplantPermission( player, plantLocation, @@ -130,43 +128,50 @@ public static void scheduleReplant( } /** - * Determines whether the chopped tree should be replanted as a 2x2 sapling - * formation. + * Determines whether the chopped tree should be replanted as a 2×2 formation. * - *

Dark Oak and Pale Oak are always 2x2. Spruce and Jungle are 2x2 only when - * the base of the chopped tree contained four logs arranged in a 2x2 square — - * detected by scanning the chopped-log set for a matching pattern at the Y - * level of the lowest broken log. All other tree types are always single. + *

Dark Oak and Pale Oak are always 2×2. Spruce and Jungle are 2×2 only when + * the base of the chopped tree contained four logs arranged in a 2×2 square, + * detected by scanning the chopped-log set at the Y level of the lowest log. + * + *

Performance fix: the old {@code containsBlockLocation} helper was an + * O(n) linear scan called up to 4 anchors × 4 offsets = 16 times. We now + * convert {@code choppedLogs} to a {@link CoordKey} set once (O(n)) and all + * subsequent membership tests are O(1). */ private static boolean isLikely2x2Tree(Material logType, Location lowestLogLocation, Set choppedLogs) { XMaterial xMat = XMaterial.matchXMaterial(logType); - // Dark Oak and Pale Oak are always planted as 2x2 if (xMat == XMaterial.DARK_OAK_LOG || xMat == XMaterial.PALE_OAK_LOG) { return true; } - // Only Spruce and Jungle can be big (2x2) trees — everything else is always single if (xMat != XMaterial.SPRUCE_LOG && xMat != XMaterial.JUNGLE_LOG) { return false; } - // Detect 2x2 by checking whether four logs of this type form a square at - // the base Y level among the actually-chopped blocks. + // Build a CoordKey set once so all 16 membership tests below are O(1) + Set choppedKeys = new HashSet<>(choppedLogs.size() * 2); + String worldName = lowestLogLocation.getWorld() != null + ? lowestLogLocation.getWorld().getName() + : ""; + for (Location loc : choppedLogs) { + choppedKeys.add(CoordKey.of(loc)); + } + int baseY = lowestLogLocation.getBlockY(); int baseX = lowestLogLocation.getBlockX(); int baseZ = lowestLogLocation.getBlockZ(); - World world = lowestLogLocation.getWorld(); - // Try all four possible 2x2 anchors that include the base-log position as a corner int[][] candidateAnchors = {{0, 0}, {-1, 0}, {0, -1}, {-1, -1}}; for (int[] ao : candidateAnchors) { int ax = baseX + ao[0]; int az = baseZ + ao[1]; boolean all4Present = true; for (int[] offset : FORMATION_2X2) { - if (!containsBlockLocation(choppedLogs, world, ax + offset[0], baseY, az + offset[1])) { + // O(1) CoordKey lookup – replaces the old O(n) containsBlockLocation scan + if (!choppedKeys.contains(CoordKey.of(ax + offset[0], baseY, az + offset[1], worldName))) { all4Present = false; break; } @@ -180,27 +185,12 @@ private static boolean isLikely2x2Tree(Material logType, Location lowestLogLocat } /** - * Returns {@code true} if {@code locations} contains a block-coordinate match - * for the given world and integer coordinates. Uses integer comparison to avoid - * floating-point or yaw/pitch equality issues. - */ - private static boolean containsBlockLocation(Set locations, World world, int x, int y, int z) { - for (Location loc : locations) { - if (loc.getWorld() == world && loc.getBlockX() == x && loc.getBlockY() == y && loc.getBlockZ() == z) { - return true; - } - } - return false; - } - - /** - * Finds an anchor location where all four blocks of a 2x2 formation are - * clear and sitting on valid soil. Returns null if no valid anchor is found. + * Finds an anchor location where all four blocks of a 2×2 formation are + * clear and sitting on valid soil. Returns null if no valid anchor is found. */ private static Location find2x2PlantLocation(Location originalLocation, Config config) { Block origin = originalLocation.getBlock(); - // Try all four possible anchors that include the original position as a corner. int[][] originInclusiveAnchors = {{0, 0}, {-1, 0}, {0, -1}, {-1, -1}}; for (int[] ao : originInclusiveAnchors) { Block anchor = origin.getRelative(ao[0], 0, ao[1]); @@ -209,10 +199,8 @@ private static Location find2x2PlantLocation(Location originalLocation, Config c } } - // Widen search, skipping anchors already tested above for (int x = -2; x <= 2; x++) { for (int z = -2; z <= 2; z++) { - // Skip the four origin-inclusive anchors already tried if ((x == 0 || x == -1) && (z == 0 || z == -1)) continue; Block candidate = origin.getRelative(x, 0, z); @@ -225,10 +213,6 @@ private static Location find2x2PlantLocation(Location originalLocation, Config c return null; } - /** - * Checks whether all four blocks of a 2x2 formation starting at anchor are - * clear and sit on valid soil. - */ private static boolean is2x2FormationValid(Block anchor, Config config) { for (int[] offset : FORMATION_2X2) { Block target = anchor.getRelative(offset[0], 0, offset[1]); @@ -240,17 +224,6 @@ private static boolean is2x2FormationValid(Block anchor, Config config) { return true; } - /** - * Plants a full 2x2 sapling formation at the given anchor location. - * - *

All four positions are re-validated before any sapling is placed. If any - * position became occupied between the time the anchor was chosen and now (race - * condition — another player placed a block), the entire formation is aborted - * rather than planting a partial 2x2, which would be useless for dark oak growth. - * - *

If require-sapling-in-inventory is true, all four saplings must be present - * before any are consumed. - */ private static void plant2x2Saplings(Player player, Location anchorLocation, Material saplingType, Config config) { Block anchor = anchorLocation.getBlock(); @@ -259,11 +232,10 @@ private static void plant2x2Saplings(Player player, Location anchorLocation, Mat Block target = anchor.getRelative(offset[0], 0, offset[1]); Block below = target.getRelative(BlockFace.DOWN); if (!isClearForSapling(target) || !isValidSoil(below.getType(), config)) { - return; // Abort — formation is no longer fully clear + return; } } - // Pre-check inventory has enough saplings before consuming any if (config.getRequireSaplingInInventory()) { int available = countSaplingsInInventory(player, saplingType); if (available < 4) { @@ -271,7 +243,6 @@ private static void plant2x2Saplings(Player player, Location anchorLocation, Mat } } - // All checks passed — place all four saplings for (int[] offset : FORMATION_2X2) { Block target = anchor.getRelative(offset[0], 0, offset[1]); @@ -424,9 +395,6 @@ private static boolean plantSapling( return true; } - /** - * Returns how many of the given sapling type the player currently holds. - */ private static int countSaplingsInInventory(Player player, Material saplingType) { int count = 0; for (ItemStack item : player.getInventory().getStorageContents()) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ed7344e..bd38d62 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,5 +1,5 @@ # AutoTreeChop by MilkTeaMC team -# Discord support server: https://discord.gg/uQ4UXANnP2 +# Matrix Support Chat: https://matrix.to/#/#maoyue-dev:matrix.org # Modrinth Page: https://modrinth.com/plugin/autotreechop # Source code: https://github.com/milkteamc/AutoTreeChop # Default config file: https://github.com/milkteamc/AutoTreeChop/blob/master/src/main/resources/config.yml @@ -10,15 +10,16 @@ # Want to help translate? Get started here: https://translate.codeberg.org/projects/autotreechop/autotreechop/ locale: en -# If set to true, the plugin will automatically translate to the locale of the player (if there are translations present) +# If set to true, the plugin will automatically translate to the locale of the player +# (if there are translations present) use-player-locale: true # Tree Chopping setting +# If disable, ALL players (including non-VIP) have unlimited usage +limitUsage: true # The number of times non-VIP players can chop down trees per day, -# you can give everyone "autotreechop.vip" permission to disable it. max-uses-per-day: 50 # Tree blocks that non-VIP players can chop down every day, -# you can give everyone "autotreechop.vip" permission to disable it. max-blocks-per-day: 500 # Cooldown time (second), use 0 to disable cooldownTime: 5 @@ -87,7 +88,8 @@ call-block-break-event: true # Protection plugins setting # If you are using Residence, you can set which Flag players have access to AutoTreeChop in residence. residenceFlag: build -# If you are using GriefPrevention, you can set which Flag players have access to AutoTreeChop in GriefPrevention. +# If you are using GriefPrevention, +# you can set which Flag players have access to AutoTreeChop in GriefPrevention. griefPreventionFlag: Build # Stop chopping if the blocks are not connected (not part of the same tree) stopChoppingIfNotConnected: false @@ -174,4 +176,4 @@ valid-soil-types: - ROOTED_DIRT # Configuration file version - DO NOT MODIFY -config-version: 1 +config-version: 2 diff --git a/src/main/resources/lang/styles.properties b/src/main/resources/lang/styles.properties index e318c70..0be1588 100644 --- a/src/main/resources/lang/styles.properties +++ b/src/main/resources/lang/styles.properties @@ -1,8 +1,5 @@ -# To apply the original style of the plugin we don't show a prefix at all and simply render in text color prefix={slot} -# We therefore make text green text={slot} prefix_negative={slot} -# and just to be very safe that it always looks like the original plugin positive={slot} negative={slot} \ No newline at end of file