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:
+ *
+ * - Scan the sphere once to collect all leaf positions and all
+ * active (not removed) log positions.
+ * - BFS outward from the active log seeds through leaves and logs.
+ * Any leaf reached is "connected" to a living log.
+ * - Leaves not reached = orphaned = scheduled for removal.
+ *
*
- * @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