From 50f1ded96c8ccbef556ee3b647bc8decc43240cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 05:48:09 +0000 Subject: [PATCH 1/3] Initial plan From 29a41e4105c3a25edcfc75949d86a3b8fd7f454b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 05:57:44 +0000 Subject: [PATCH 2/3] Implement blended progress reporting for Ancient Debris restoration Co-authored-by: modpotato <81768237+modpotato@users.noreply.github.com> --- src/main/java/top/modpotato/Main.java | 19 ++ .../commands/AntiNetheriteCommand.java | 108 +++++-- .../RestorationProgressTracker.java | 268 ++++++++++++++++ .../restoration/RestorationSession.java | 153 +++++++++ .../top/modpotato/util/DebrisStorage.java | 301 ++++++++++++++++++ 5 files changed, 825 insertions(+), 24 deletions(-) create mode 100644 src/main/java/top/modpotato/restoration/RestorationProgressTracker.java create mode 100644 src/main/java/top/modpotato/restoration/RestorationSession.java diff --git a/src/main/java/top/modpotato/Main.java b/src/main/java/top/modpotato/Main.java index a222c4e..efaecbb 100644 --- a/src/main/java/top/modpotato/Main.java +++ b/src/main/java/top/modpotato/Main.java @@ -13,6 +13,7 @@ import top.modpotato.listeners.ContainerTransferListener; import top.modpotato.commands.AntiNetheriteCommand; import top.modpotato.config.Config; +import top.modpotato.restoration.RestorationProgressTracker; import top.modpotato.scheduler.NetheriteRemover; import top.modpotato.util.DebrisStorage; import top.modpotato.util.NetheriteDetector; @@ -25,6 +26,7 @@ public class Main extends JavaPlugin { private NetheriteRemover netheriteRemover; private NetheriteDetector netheriteDetector; private DebrisStorage debrisStorage; + private RestorationProgressTracker restorationProgressTracker; private CraftListener craftListener; private EquipListener equipListener; @@ -53,6 +55,10 @@ public void onEnable() { // Initialize debris storage debrisStorage = new DebrisStorage(this, config); + // Initialize restoration progress tracker + restorationProgressTracker = new RestorationProgressTracker(this); + restorationProgressTracker.start(); + // Check if running on Folia isFolia = checkFolia(); getLogger().info("Running on " + (isFolia ? "Folia" : "Bukkit") + " server"); @@ -84,6 +90,11 @@ public void onDisable() { isShuttingDown = true; try { + // Stop restoration progress tracker + if (restorationProgressTracker != null) { + restorationProgressTracker.stop(); + } + // Stop tasks if (netheriteRemover != null) { netheriteRemover.stop(); @@ -330,6 +341,14 @@ public DebrisStorage getDebrisStorage() { return debrisStorage; } + /** + * Gets the restoration progress tracker + * @return The restoration progress tracker + */ + public RestorationProgressTracker getRestorationProgressTracker() { + return restorationProgressTracker; + } + /** * Checks if the plugin is shutting down * @return true if the plugin is shutting down, false otherwise diff --git a/src/main/java/top/modpotato/commands/AntiNetheriteCommand.java b/src/main/java/top/modpotato/commands/AntiNetheriteCommand.java index 2154e5d..c366ca9 100644 --- a/src/main/java/top/modpotato/commands/AntiNetheriteCommand.java +++ b/src/main/java/top/modpotato/commands/AntiNetheriteCommand.java @@ -6,11 +6,13 @@ import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import top.modpotato.Main; +import top.modpotato.restoration.RestorationSession; import java.util.ArrayList; import java.util.List; @@ -79,6 +81,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St plugin.reloadPluginConfig(); sender.sendMessage(Component.text("AntiNetherite configuration reloaded.").color(NamedTextColor.GREEN)); return true; + case "restore-feedback": + return handleRestoreFeedback(sender, args); case "restore-debris": // Get the configured cooldown in milliseconds int cooldownSeconds = plugin.getConfig().getInt("anti-netherite.advanced.command-cooldown-seconds", 5); @@ -129,19 +133,26 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } - sender.sendMessage(Component.text("Restoring " + worldLocations + " Ancient Debris in world " + worldName + "...").color(NamedTextColor.YELLOW)); + // Schedule restoration and get session + RestorationSession session = plugin.getDebrisStorage().scheduleRestoreInWorld(sender, world); - // Run the restoration on the main thread (delegating Folia handling to DebrisStorage) - Bukkit.getScheduler().runTask(plugin, () -> { - try { - int count = plugin.getDebrisStorage().restoreDebrisInWorld(world); - sender.sendMessage(Component.text("Restored " + count + " Ancient Debris blocks in world " + worldName + ".").color(NamedTextColor.GREEN)); - } catch (Exception e) { - sender.sendMessage(Component.text("Error restoring Ancient Debris: " + e.getMessage()).color(NamedTextColor.RED)); - plugin.getLogger().severe("Error restoring Ancient Debris: " + e.getMessage()); - e.printStackTrace(); - } - }); + if (session == null) { + sender.sendMessage(Component.text("No Ancient Debris locations to restore in world " + worldName + ".").color(NamedTextColor.YELLOW)); + return true; + } + + // Register session with progress tracker + plugin.getRestorationProgressTracker().registerSession(session); + + // Send immediate scheduling summary + sender.sendMessage(Component.text("Scheduled restoration for ") + .color(NamedTextColor.GREEN) + .append(Component.text(session.getScheduledChunks()).color(NamedTextColor.GOLD)) + .append(Component.text(" chunks, ").color(NamedTextColor.GREEN)) + .append(Component.text(session.getTotalLocations()).color(NamedTextColor.GOLD)) + .append(Component.text(" locations in world ").color(NamedTextColor.GREEN)) + .append(Component.text(worldName).color(NamedTextColor.GOLD)) + .append(Component.text(".").color(NamedTextColor.GREEN))); } else { // Global restore // Check if there are any locations to restore @@ -151,19 +162,24 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } - sender.sendMessage(Component.text("Restoring " + totalLocations + " replaced Ancient Debris...").color(NamedTextColor.YELLOW)); + // Schedule restoration and get session + RestorationSession session = plugin.getDebrisStorage().scheduleRestoreAll(sender); - // Run the restoration on the main thread (delegating Folia handling to DebrisStorage) - Bukkit.getScheduler().runTask(plugin, () -> { - try { - int count = plugin.getDebrisStorage().restoreAllDebris(); - sender.sendMessage(Component.text("Restored " + count + " Ancient Debris blocks.").color(NamedTextColor.GREEN)); - } catch (Exception e) { - sender.sendMessage(Component.text("Error restoring Ancient Debris: " + e.getMessage()).color(NamedTextColor.RED)); - plugin.getLogger().severe("Error restoring Ancient Debris: " + e.getMessage()); - e.printStackTrace(); - } - }); + if (session == null) { + sender.sendMessage(Component.text("No Ancient Debris locations to restore.").color(NamedTextColor.YELLOW)); + return true; + } + + // Register session with progress tracker + plugin.getRestorationProgressTracker().registerSession(session); + + // Send immediate scheduling summary + sender.sendMessage(Component.text("Scheduled restoration for ") + .color(NamedTextColor.GREEN) + .append(Component.text(session.getScheduledChunks()).color(NamedTextColor.GOLD)) + .append(Component.text(" chunks, ").color(NamedTextColor.GREEN)) + .append(Component.text(session.getTotalLocations()).color(NamedTextColor.GOLD)) + .append(Component.text(" locations.").color(NamedTextColor.GREEN))); } return true; case "debris-info": @@ -303,6 +319,8 @@ private void showHelp(CommandSender sender) { sender.sendMessage(Component.text("/antinetherite restore-debris [world] - Restore all replaced Ancient Debris").color(NamedTextColor.YELLOW)); sender.sendMessage(Component.text(" - Optional world parameter to restore only in a specific world").color(NamedTextColor.GRAY)); sender.sendMessage(Component.text(" - Only restores blocks that are still Netherrack").color(NamedTextColor.GRAY)); + sender.sendMessage(Component.text(" - Shows progress updates with blended time and percentage-based reporting").color(NamedTextColor.GRAY)); + sender.sendMessage(Component.text("/antinetherite restore-feedback - Toggle restoration progress feedback").color(NamedTextColor.YELLOW)); sender.sendMessage(Component.text("/antinetherite debris-info - Show information about stored Ancient Debris locations").color(NamedTextColor.YELLOW)); sender.sendMessage(Component.text(" - Displays counts per world and current config status").color(NamedTextColor.GRAY)); sender.sendMessage(Component.text("/antinetherite get - Get a configuration value").color(NamedTextColor.YELLOW)); @@ -342,6 +360,7 @@ public List onTabComplete(CommandSender sender, Command command, String if (args.length == 1) { completions.add("reload"); completions.add("restore-debris"); + completions.add("restore-feedback"); completions.add("debris-info"); completions.add("get"); completions.add("set"); @@ -349,6 +368,12 @@ public List onTabComplete(CommandSender sender, Command command, String } if (args.length == 2) { + if (args[0].equalsIgnoreCase("restore-feedback")) { + completions.add("on"); + completions.add("off"); + return filterCompletions(completions, args[1]); + } + if (args[0].equalsIgnoreCase("get") || args[0].equalsIgnoreCase("set")) { // Add global settings completions.add("global.enable-destructive-actions"); @@ -463,6 +488,41 @@ public List onTabComplete(CommandSender sender, Command command, String return completions; } + /** + * Handles the /antinetherite restore-feedback command + * @param sender The command sender + * @param args The command arguments + * @return true if the command was handled, false otherwise + */ + private boolean handleRestoreFeedback(CommandSender sender, String[] args) { + // Only players can opt in/out of feedback + if (!(sender instanceof Player)) { + sender.sendMessage(Component.text("Only players can use this command.").color(NamedTextColor.RED)); + return true; + } + + Player player = (Player) sender; + + if (args.length < 2) { + sender.sendMessage(Component.text("Usage: /antinetherite restore-feedback ").color(NamedTextColor.RED)); + return true; + } + + String action = args[1].toLowerCase(); + + if (action.equals("on")) { + plugin.getRestorationProgressTracker().setGlobalOptOut(player.getUniqueId(), false); + sender.sendMessage(Component.text("Restoration progress feedback enabled.").color(NamedTextColor.GREEN)); + } else if (action.equals("off")) { + plugin.getRestorationProgressTracker().setGlobalOptOut(player.getUniqueId(), true); + sender.sendMessage(Component.text("Restoration progress feedback disabled.").color(NamedTextColor.YELLOW)); + } else { + sender.sendMessage(Component.text("Usage: /antinetherite restore-feedback ").color(NamedTextColor.RED)); + } + + return true; + } + /** * Handles the /antinetherite get command * @param sender The command sender diff --git a/src/main/java/top/modpotato/restoration/RestorationProgressTracker.java b/src/main/java/top/modpotato/restoration/RestorationProgressTracker.java new file mode 100644 index 0000000..ef9136b --- /dev/null +++ b/src/main/java/top/modpotato/restoration/RestorationProgressTracker.java @@ -0,0 +1,268 @@ +package top.modpotato.restoration; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import top.modpotato.Main; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages restoration sessions and sends periodic progress updates + */ +public class RestorationProgressTracker { + private final Main plugin; + private final Map activeSessions; + private final Map lastTimeUpdate; // sessionId -> last time-based update timestamp + private final Map lastPercentUpdate; // sessionId -> last percent milestone + private final Set globalOptOut; // Players who opted out of ALL feedback + private Object taskId = null; // Can be Integer (Paper) or ScheduledTask (Folia) + + // Configuration + private static final long TIME_UPDATE_INTERVAL_MS = 60000; // 60 seconds + private static final int HIGH_THRESHOLD = 1000; // >= 1000 locations -> 1% updates + private static final int HIGH_THRESHOLD_PERCENT = 1; // 1% + private static final int LOW_THRESHOLD_PERCENT = 10; // 10% + + /** + * Creates a new restoration progress tracker + * @param plugin The plugin instance + */ + public RestorationProgressTracker(Main plugin) { + this.plugin = plugin; + this.activeSessions = new ConcurrentHashMap<>(); + this.lastTimeUpdate = new ConcurrentHashMap<>(); + this.lastPercentUpdate = new ConcurrentHashMap<>(); + this.globalOptOut = ConcurrentHashMap.newKeySet(); + } + + /** + * Starts the progress tracking timer + */ + public void start() { + if (taskId != null) { + return; // Already running + } + + // Check if running on Folia + boolean isFolia = checkFolia(); + + if (isFolia) { + // On Folia, use global region scheduler (runs every second = 20 ticks) + taskId = Bukkit.getGlobalRegionScheduler().runAtFixedRate(plugin, task -> { + checkAndSendUpdates(); + }, 1, 20); // Check every second (20 ticks) + } else { + // On Paper/Spigot, use regular Bukkit scheduler (runs every second = 20 ticks) + taskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, this::checkAndSendUpdates, 20L, 20L); + } + } + + /** + * Stops the progress tracking timer + */ + public void stop() { + if (taskId == null) { + return; // Not running + } + + boolean isFolia = checkFolia(); + + if (isFolia) { + // On Folia, cancel the ScheduledTask + try { + // taskId is a ScheduledTask, call cancel() on it + taskId.getClass().getMethod("cancel").invoke(taskId); + } catch (Exception e) { + plugin.getLogger().warning("Failed to cancel Folia task: " + e.getMessage()); + } + } else { + // On Paper/Spigot, cancel regular task by ID + if (taskId instanceof Integer) { + Bukkit.getScheduler().cancelTask((Integer) taskId); + } + } + + taskId = null; + } + + /** + * Registers a new restoration session + * @param session The session to register + */ + public void registerSession(RestorationSession session) { + activeSessions.put(session.getSessionId(), session); + lastTimeUpdate.put(session.getSessionId(), session.getStartTime()); + lastPercentUpdate.put(session.getSessionId(), 0); + } + + /** + * Marks a session as completed and sends final message + * @param sessionId The session ID + * @param actualRestored The actual number of blocks restored (may differ from completed if some were skipped) + */ + public void completeSession(UUID sessionId, int actualRestored) { + RestorationSession session = activeSessions.get(sessionId); + if (session == null) { + return; + } + + session.markCompleted(); + + long durationMs = session.getElapsedTimeMs(); + String duration = formatDuration(durationMs); + + Component message = Component.text("Restoration complete! Restored ") + .color(NamedTextColor.GREEN) + .append(Component.text(actualRestored).color(NamedTextColor.GOLD)) + .append(Component.text(" Ancient Debris blocks in ").color(NamedTextColor.GREEN)) + .append(Component.text(duration).color(NamedTextColor.GOLD)) + .append(Component.text(".").color(NamedTextColor.GREEN)); + + sendToSubscribers(session, message); + + // Clean up + activeSessions.remove(sessionId); + lastTimeUpdate.remove(sessionId); + lastPercentUpdate.remove(sessionId); + } + + /** + * Opts a player in or out of global restoration feedback + * @param playerUUID The player UUID + * @param optOut true to opt out, false to opt in + */ + public void setGlobalOptOut(UUID playerUUID, boolean optOut) { + if (optOut) { + globalOptOut.add(playerUUID); + } else { + globalOptOut.remove(playerUUID); + } + } + + /** + * Checks if a player is opted out globally + * @param playerUUID The player UUID + * @return true if opted out, false otherwise + */ + public boolean isGlobalOptOut(UUID playerUUID) { + return globalOptOut.contains(playerUUID); + } + + /** + * Checks all active sessions and sends updates as needed + */ + private void checkAndSendUpdates() { + long currentTime = System.currentTimeMillis(); + + for (RestorationSession session : activeSessions.values()) { + if (session.isCompleted()) { + continue; + } + + UUID sessionId = session.getSessionId(); + + // Check time-based update (every 60 seconds) + Long lastTime = lastTimeUpdate.get(sessionId); + if (lastTime != null && (currentTime - lastTime) >= TIME_UPDATE_INTERVAL_MS) { + sendProgressUpdate(session); + lastTimeUpdate.put(sessionId, currentTime); + } + + // Check percentage-based update + double currentPercent = session.getCompletionPercentage(); + int lastPercent = lastPercentUpdate.getOrDefault(sessionId, 0); + + int threshold = session.getTotalLocations() >= HIGH_THRESHOLD ? HIGH_THRESHOLD_PERCENT : LOW_THRESHOLD_PERCENT; + int currentMilestone = ((int) currentPercent / threshold) * threshold; + + if (currentMilestone > lastPercent && currentMilestone <= 100) { + sendProgressUpdate(session); + lastPercentUpdate.put(sessionId, currentMilestone); + } + } + } + + /** + * Sends a progress update for a session + * @param session The session + */ + private void sendProgressUpdate(RestorationSession session) { + int completed = session.getCompletedLocations(); + int total = session.getTotalLocations(); + double percent = session.getCompletionPercentage(); + long elapsedMs = session.getElapsedTimeMs(); + String elapsed = formatDuration(elapsedMs); + + Component message = Component.text("Restoration progress: ") + .color(NamedTextColor.YELLOW) + .append(Component.text(completed).color(NamedTextColor.GOLD)) + .append(Component.text("/").color(NamedTextColor.YELLOW)) + .append(Component.text(total).color(NamedTextColor.GOLD)) + .append(Component.text(String.format(" (%.1f%%) - ", percent)).color(NamedTextColor.YELLOW)) + .append(Component.text(elapsed).color(NamedTextColor.GOLD)) + .append(Component.text(" elapsed").color(NamedTextColor.YELLOW)); + + sendToSubscribers(session, message); + } + + /** + * Sends a message to all subscribers of a session (except those who opted out globally) + * @param session The session + * @param message The message to send + */ + private void sendToSubscribers(RestorationSession session, Component message) { + for (UUID subscriberUUID : session.getSubscribers()) { + // Skip if opted out globally + if (globalOptOut.contains(subscriberUUID)) { + continue; + } + + Player player = Bukkit.getPlayer(subscriberUUID); + if (player != null && player.isOnline()) { + player.sendMessage(message); + } + } + + // Also send to console if initiator is console + if (session.getInitiatorUUID() == null) { + Bukkit.getConsoleSender().sendMessage(message); + } + } + + /** + * Formats a duration in milliseconds to a human-readable string + * @param durationMs The duration in milliseconds + * @return The formatted duration + */ + private String formatDuration(long durationMs) { + long seconds = durationMs / 1000; + long minutes = seconds / 60; + long hours = minutes / 60; + + if (hours > 0) { + return String.format("%dh %dm %ds", hours, minutes % 60, seconds % 60); + } else if (minutes > 0) { + return String.format("%dm %ds", minutes, seconds % 60); + } else { + return String.format("%ds", seconds); + } + } + + /** + * Checks if the server is running on Folia + * @return true if running on Folia, false otherwise + */ + private boolean checkFolia() { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/src/main/java/top/modpotato/restoration/RestorationSession.java b/src/main/java/top/modpotato/restoration/RestorationSession.java new file mode 100644 index 0000000..f62c00a --- /dev/null +++ b/src/main/java/top/modpotato/restoration/RestorationSession.java @@ -0,0 +1,153 @@ +package top.modpotato.restoration; + +import org.bukkit.World; +import org.bukkit.command.CommandSender; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Represents an active restoration session for Ancient Debris + */ +public class RestorationSession { + private final UUID sessionId; + private final CommandSender initiator; + private final UUID initiatorUUID; + private final World worldFilter; // null for all worlds + private final int totalLocations; + private final int scheduledChunks; + private final AtomicInteger completedLocations; + private final long startTime; + private final Set subscribers; // UUIDs of players to notify + private volatile boolean completed; + + /** + * Creates a new restoration session + * @param initiator The command sender who initiated the restoration + * @param worldFilter The world to restore in, or null for all worlds + * @param totalLocations The total number of locations to restore + * @param scheduledChunks The number of chunks scheduled for restoration + */ + public RestorationSession(CommandSender initiator, World worldFilter, int totalLocations, int scheduledChunks) { + this.sessionId = UUID.randomUUID(); + this.initiator = initiator; + this.initiatorUUID = getUUIDFromSender(initiator); + this.worldFilter = worldFilter; + this.totalLocations = totalLocations; + this.scheduledChunks = scheduledChunks; + this.completedLocations = new AtomicInteger(0); + this.startTime = System.currentTimeMillis(); + this.subscribers = ConcurrentHashMap.newKeySet(); + this.completed = false; + + // Add initiator to subscribers if they have a UUID (i.e., a player) + if (initiatorUUID != null) { + subscribers.add(initiatorUUID); + } + } + + /** + * Gets the UUID from a command sender if it's a player + * @param sender The command sender + * @return The UUID, or null if not a player + */ + private UUID getUUIDFromSender(CommandSender sender) { + if (sender instanceof org.bukkit.entity.Player) { + return ((org.bukkit.entity.Player) sender).getUniqueId(); + } + return null; + } + + /** + * Increments the completed locations counter + * @return The new count + */ + public int incrementCompleted() { + return completedLocations.incrementAndGet(); + } + + /** + * Gets the current completion percentage (0-100) + * @return The completion percentage + */ + public double getCompletionPercentage() { + if (totalLocations == 0) { + return 100.0; + } + return (completedLocations.get() * 100.0) / totalLocations; + } + + /** + * Gets the elapsed time in milliseconds + * @return The elapsed time + */ + public long getElapsedTimeMs() { + return System.currentTimeMillis() - startTime; + } + + /** + * Marks the session as completed + */ + public void markCompleted() { + this.completed = true; + } + + // Getters + public UUID getSessionId() { + return sessionId; + } + + public CommandSender getInitiator() { + return initiator; + } + + public UUID getInitiatorUUID() { + return initiatorUUID; + } + + public World getWorldFilter() { + return worldFilter; + } + + public int getTotalLocations() { + return totalLocations; + } + + public int getScheduledChunks() { + return scheduledChunks; + } + + public int getCompletedLocations() { + return completedLocations.get(); + } + + public long getStartTime() { + return startTime; + } + + public Set getSubscribers() { + return subscribers; + } + + public boolean isCompleted() { + return completed; + } + + /** + * Adds a subscriber to receive progress updates + * @param playerUUID The player UUID to add + */ + public void addSubscriber(UUID playerUUID) { + subscribers.add(playerUUID); + } + + /** + * Removes a subscriber from receiving progress updates + * @param playerUUID The player UUID to remove + */ + public void removeSubscriber(UUID playerUUID) { + subscribers.remove(playerUUID); + } +} diff --git a/src/main/java/top/modpotato/util/DebrisStorage.java b/src/main/java/top/modpotato/util/DebrisStorage.java index 915a62c..13a71e3 100644 --- a/src/main/java/top/modpotato/util/DebrisStorage.java +++ b/src/main/java/top/modpotato/util/DebrisStorage.java @@ -10,12 +10,15 @@ import top.modpotato.Main; import top.modpotato.config.Config; +import top.modpotato.restoration.RestorationSession; import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; @@ -23,6 +26,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Level; +import org.bukkit.command.CommandSender; /** * Manages storage of replaced Ancient Debris locations @@ -506,6 +510,303 @@ public void clearAllLocations() { plugin.getLogger().info("Cleared all stored Ancient Debris locations"); } + /** + * Schedules restoration of all Ancient Debris and returns a session for progress tracking + * @param initiator The command sender who initiated the restoration + * @return The restoration session, or null if nothing to restore + */ + public RestorationSession scheduleRestoreAll(CommandSender initiator) { + // Calculate total locations and unique chunks + int totalLocations = getTotalLocationsCount(); + if (totalLocations == 0) { + return null; + } + + Set uniqueChunks = new HashSet<>(); + for (Map.Entry> entry : replacedLocations.entrySet()) { + World world = Bukkit.getWorld(entry.getKey()); + if (world != null) { + for (String locString : entry.getValue()) { + try { + Location loc = deserializeLocation(world, locString); + uniqueChunks.add(world.getUID() + ":" + (loc.getBlockX() >> 4) + "," + (loc.getBlockZ() >> 4)); + } catch (Exception e) { + // Ignore invalid locations + } + } + } + } + + RestorationSession session = new RestorationSession(initiator, null, totalLocations, uniqueChunks.size()); + + // Schedule the actual restoration work + scheduleRestorationWork(session, null); + + return session; + } + + /** + * Schedules restoration of Ancient Debris in a specific world and returns a session for progress tracking + * @param initiator The command sender who initiated the restoration + * @param world The world to restore debris in + * @return The restoration session, or null if nothing to restore + */ + public RestorationSession scheduleRestoreInWorld(CommandSender initiator, World world) { + if (world == null) { + return null; + } + + UUID worldUUID = world.getUID(); + if (!replacedLocations.containsKey(worldUUID)) { + return null; + } + + List locations = replacedLocations.get(worldUUID); + if (locations.isEmpty()) { + return null; + } + + // Calculate unique chunks + Set uniqueChunks = new HashSet<>(); + for (String locString : locations) { + try { + Location loc = deserializeLocation(world, locString); + uniqueChunks.add((loc.getBlockX() >> 4) + "," + (loc.getBlockZ() >> 4)); + } catch (Exception e) { + // Ignore invalid locations + } + } + + RestorationSession session = new RestorationSession(initiator, world, locations.size(), uniqueChunks.size()); + + // Schedule the actual restoration work + scheduleRestorationWork(session, world); + + return session; + } + + /** + * Schedules the actual restoration work for a session + * @param session The restoration session + * @param worldFilter The world to restore in, or null for all worlds + */ + private void scheduleRestorationWork(RestorationSession session, World worldFilter) { + boolean isFolia = checkFolia(); + AtomicInteger restoredCount = new AtomicInteger(0); + + if (isFolia) { + scheduleFoliaRestoration(session, worldFilter, restoredCount); + } else { + schedulePaperRestoration(session, worldFilter, restoredCount); + } + } + + /** + * Schedules restoration work on Paper (main-thread batching) + * @param session The restoration session + * @param worldFilter The world to restore in, or null for all worlds + * @param restoredCount Counter for restored blocks + */ + private void schedulePaperRestoration(RestorationSession session, World worldFilter, AtomicInteger restoredCount) { + // Collect all locations to process + List toRestore = new ArrayList<>(); + + if (worldFilter == null) { + // Restore all worlds + for (Map.Entry> entry : replacedLocations.entrySet()) { + World world = Bukkit.getWorld(entry.getKey()); + if (world != null) { + for (String locString : entry.getValue()) { + toRestore.add(new LocationRestore(world, locString, entry.getKey())); + } + } + } + } else { + // Restore specific world + UUID worldUUID = worldFilter.getUID(); + if (replacedLocations.containsKey(worldUUID)) { + for (String locString : replacedLocations.get(worldUUID)) { + toRestore.add(new LocationRestore(worldFilter, locString, worldUUID)); + } + } + } + + // Process in batches on the main thread to avoid lag + final int BATCH_SIZE = 50; // Process 50 blocks per tick + final int totalBatches = (int) Math.ceil((double) toRestore.size() / BATCH_SIZE); + + Bukkit.getScheduler().runTask(plugin, new Runnable() { + int currentBatch = 0; + + @Override + public void run() { + int start = currentBatch * BATCH_SIZE; + int end = Math.min(start + BATCH_SIZE, toRestore.size()); + + for (int i = start; i < end; i++) { + LocationRestore lr = toRestore.get(i); + try { + Location location = deserializeLocation(lr.world, lr.locString); + + // Skip if chunk not loaded + if (!isChunkLoaded(location) && !loadChunkIfNeeded(location)) { + session.incrementCompleted(); + continue; + } + + Block block = location.getBlock(); + + // Only restore if the block is still Netherrack + if (block.getType() == Material.NETHERRACK) { + block.setType(Material.ANCIENT_DEBRIS); + restoredCount.incrementAndGet(); + } + + session.incrementCompleted(); + } catch (Exception e) { + plugin.getLogger().warning("Error restoring Ancient Debris at " + lr.locString + ": " + e.getMessage()); + session.incrementCompleted(); + } + } + + currentBatch++; + + if (currentBatch < totalBatches) { + // Schedule next batch + Bukkit.getScheduler().runTask(plugin, this); + } else { + // All done - clean up + cleanupAfterRestore(worldFilter); + + // Notify progress tracker + if (plugin.getRestorationProgressTracker() != null) { + plugin.getRestorationProgressTracker().completeSession(session.getSessionId(), restoredCount.get()); + } + } + } + }); + } + + /** + * Schedules restoration work on Folia (region scheduler per location) + * @param session The restoration session + * @param worldFilter The world to restore in, or null for all worlds + * @param restoredCount Counter for restored blocks + */ + private void scheduleFoliaRestoration(RestorationSession session, World worldFilter, AtomicInteger restoredCount) { + // Collect all locations to process + List toRestore = new ArrayList<>(); + + if (worldFilter == null) { + // Restore all worlds + for (Map.Entry> entry : replacedLocations.entrySet()) { + World world = Bukkit.getWorld(entry.getKey()); + if (world != null) { + for (String locString : entry.getValue()) { + toRestore.add(new LocationRestore(world, locString, entry.getKey())); + } + } + } + } else { + // Restore specific world + UUID worldUUID = worldFilter.getUID(); + if (replacedLocations.containsKey(worldUUID)) { + for (String locString : replacedLocations.get(worldUUID)) { + toRestore.add(new LocationRestore(worldFilter, locString, worldUUID)); + } + } + } + + // Count down latch to know when all are done + CountDownLatch latch = new CountDownLatch(toRestore.size()); + + // Schedule each location on its region + for (LocationRestore lr : toRestore) { + try { + Location location = deserializeLocation(lr.world, lr.locString); + + // Skip if chunk not loaded + if (!isChunkLoaded(location) && !loadChunkIfNeeded(location)) { + session.incrementCompleted(); + latch.countDown(); + continue; + } + + // Schedule on region + Bukkit.getRegionScheduler().execute(plugin, location, () -> { + try { + Block block = location.getBlock(); + + // Only restore if the block is still Netherrack + if (block.getType() == Material.NETHERRACK) { + block.setType(Material.ANCIENT_DEBRIS); + restoredCount.incrementAndGet(); + } + + session.incrementCompleted(); + } catch (Exception e) { + plugin.getLogger().warning("Error restoring Ancient Debris: " + e.getMessage()); + session.incrementCompleted(); + } finally { + latch.countDown(); + } + }); + } catch (Exception e) { + plugin.getLogger().warning("Error scheduling restoration at " + lr.locString + ": " + e.getMessage()); + session.incrementCompleted(); + latch.countDown(); + } + } + + // Wait for completion in a separate async task + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + latch.await(5, TimeUnit.MINUTES); // Wait up to 5 minutes + } catch (InterruptedException e) { + plugin.getLogger().warning("Interrupted while waiting for restoration: " + e.getMessage()); + Thread.currentThread().interrupt(); + } + + // Clean up on main thread (for Paper compatibility) + Bukkit.getScheduler().runTask(plugin, () -> { + cleanupAfterRestore(worldFilter); + + // Notify progress tracker + if (plugin.getRestorationProgressTracker() != null) { + plugin.getRestorationProgressTracker().completeSession(session.getSessionId(), restoredCount.get()); + } + }); + }); + } + + /** + * Cleans up restored locations from storage + * @param worldFilter The world filter, or null for all worlds + */ + private void cleanupAfterRestore(World worldFilter) { + if (worldFilter == null) { + replacedLocations.clear(); + } else { + replacedLocations.remove(worldFilter.getUID()); + } + saveStorageAsync(); + } + + /** + * Helper class to hold location restoration data + */ + private static class LocationRestore { + final World world; + final String locString; + final UUID worldUUID; + + LocationRestore(World world, String locString, UUID worldUUID) { + this.world = world; + this.locString = locString; + this.worldUUID = worldUUID; + } + } + /** * Checks if a chunk is loaded * @param location The location to check From 29c410819a34e0cd3b2b9e4b7a547019af53ea72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 06:03:02 +0000 Subject: [PATCH 3/3] Address code review feedback: improve code quality and reduce duplication Co-authored-by: modpotato <81768237+modpotato@users.noreply.github.com> --- .../RestorationProgressTracker.java | 10 ++++------ .../top/modpotato/util/DebrisStorage.java | 19 +++++++++++++++++-- src/main/resources/plugin.yml | 1 + 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/java/top/modpotato/restoration/RestorationProgressTracker.java b/src/main/java/top/modpotato/restoration/RestorationProgressTracker.java index ef9136b..0858cf4 100644 --- a/src/main/java/top/modpotato/restoration/RestorationProgressTracker.java +++ b/src/main/java/top/modpotato/restoration/RestorationProgressTracker.java @@ -21,6 +21,7 @@ public class RestorationProgressTracker { private final Map lastPercentUpdate; // sessionId -> last percent milestone private final Set globalOptOut; // Players who opted out of ALL feedback private Object taskId = null; // Can be Integer (Paper) or ScheduledTask (Folia) + private boolean isFolia; // Cache the Folia check result // Configuration private static final long TIME_UPDATE_INTERVAL_MS = 60000; // 60 seconds @@ -38,6 +39,7 @@ public RestorationProgressTracker(Main plugin) { this.lastTimeUpdate = new ConcurrentHashMap<>(); this.lastPercentUpdate = new ConcurrentHashMap<>(); this.globalOptOut = ConcurrentHashMap.newKeySet(); + this.isFolia = checkFolia(); } /** @@ -48,9 +50,6 @@ public void start() { return; // Already running } - // Check if running on Folia - boolean isFolia = checkFolia(); - if (isFolia) { // On Folia, use global region scheduler (runs every second = 20 ticks) taskId = Bukkit.getGlobalRegionScheduler().runAtFixedRate(plugin, task -> { @@ -70,12 +69,11 @@ public void stop() { return; // Not running } - boolean isFolia = checkFolia(); - if (isFolia) { // On Folia, cancel the ScheduledTask + // The ScheduledTask interface has a cancel() method try { - // taskId is a ScheduledTask, call cancel() on it + // Use reflection only as fallback for API compatibility taskId.getClass().getMethod("cancel").invoke(taskId); } catch (Exception e) { plugin.getLogger().warning("Failed to cancel Folia task: " + e.getMessage()); diff --git a/src/main/java/top/modpotato/util/DebrisStorage.java b/src/main/java/top/modpotato/util/DebrisStorage.java index 13a71e3..0434930 100644 --- a/src/main/java/top/modpotato/util/DebrisStorage.java +++ b/src/main/java/top/modpotato/util/DebrisStorage.java @@ -529,7 +529,7 @@ public RestorationSession scheduleRestoreAll(CommandSender initiator) { for (String locString : entry.getValue()) { try { Location loc = deserializeLocation(world, locString); - uniqueChunks.add(world.getUID() + ":" + (loc.getBlockX() >> 4) + "," + (loc.getBlockZ() >> 4)); + uniqueChunks.add(getChunkKey(loc, true)); } catch (Exception e) { // Ignore invalid locations } @@ -571,7 +571,7 @@ public RestorationSession scheduleRestoreInWorld(CommandSender initiator, World for (String locString : locations) { try { Location loc = deserializeLocation(world, locString); - uniqueChunks.add((loc.getBlockX() >> 4) + "," + (loc.getBlockZ() >> 4)); + uniqueChunks.add(getChunkKey(loc, false)); } catch (Exception e) { // Ignore invalid locations } @@ -911,4 +911,19 @@ private boolean checkFolia() { return false; } } + + /** + * Generates a chunk key for a location + * @param location The location + * @param includeWorld Whether to include the world UUID in the key + * @return The chunk key + */ + private String getChunkKey(Location location, boolean includeWorld) { + int chunkX = location.getBlockX() >> 4; + int chunkZ = location.getBlockZ() >> 4; + if (includeWorld && location.getWorld() != null) { + return location.getWorld().getUID() + ":" + chunkX + "," + chunkZ; + } + return chunkX + "," + chunkZ; + } } \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index e3ed1a0..5da4b8d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -12,6 +12,7 @@ commands: usage: | / reload - Reload the configuration / restore-debris [world] - Restore all replaced Ancient Debris (optionally in a specific world) + / restore-feedback - Toggle restoration progress feedback / debris-info - Show information about stored Ancient Debris locations / get - Get a configuration value / set - Set a configuration value