From adcd33fca8d6643ad7e36fbec8a0ba9dc5542d44 Mon Sep 17 00:00:00 2001 From: Sukikui Date: Thu, 12 Feb 2026 14:14:48 +0100 Subject: [PATCH] feat: add `/biomemap status` --- README.md | 1 + .../biomemap/command/BiomeMapCommand.java | 144 +++++++++- .../biomemap/export/AsyncBiomeExportTask.java | 252 +++++++++++++++++- src/main/resources/plugin.yml | 4 +- 4 files changed, 390 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2d4cede..27cb4db 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Only one export can run at a time. | Command | Description | | --- | --- | | `/biomemap [cellSize] [preview]` | Starts a biome export (JSON + optional PNG). | +| `/biomemap status [map]` | Shows current progress, ETA, outputs. Add `map` for compact ASCII completion map. | | `/biomemap stop` | Stops the current export and removes files from that run in `exports/`. | ### Arguments diff --git a/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java b/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java index 7ef0bb0..04d58a4 100644 --- a/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java +++ b/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java @@ -11,6 +11,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.UUID; import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.World; @@ -20,6 +21,7 @@ import org.bukkit.command.ConsoleCommandSender; import org.bukkit.command.RemoteConsoleCommandSender; import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; /** @@ -31,6 +33,11 @@ public final class BiomeMapCommand implements CommandExecutor, TabCompleter { private static final int CHUNK_SIZE = 16; private static final int MIN_CELL_SIZE = 8; private static final String CHAT_PREFIX = "§8[§b§lBiomeMap§8] §r"; + private static final int PLAYER_STATUS_MAP_MAX_WIDTH = 28; + private static final int PLAYER_STATUS_MAP_MAX_HEIGHT = 10; + private static final int CONSOLE_STATUS_MAP_MAX_WIDTH = 56; + private static final int CONSOLE_STATUS_MAP_MAX_HEIGHT = 18; + private static final int STATUS_BAR_WIDTH = 20; private final JavaPlugin plugin; private final BiomeExporter exporter; @@ -71,6 +78,9 @@ public BiomeMapCommand( */ @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length >= 1 && "status".equalsIgnoreCase(args[0])) { + return handleStatusCommand(sender, args); + } if (args.length >= 1 && "stop".equalsIgnoreCase(args[0])) { return handleStopCommand(sender, args); } @@ -79,6 +89,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St sender, "Usage: /biomemap [cellSize] [preview]"); sendWarning(sender, "Stop: /biomemap stop"); + sendWarning(sender, "Status: /biomemap status [map]"); return true; } @@ -215,10 +226,11 @@ public boolean onCommand(CommandSender sender, Command command, String label, St String worldKey = world.getName().toLowerCase(Locale.ROOT); Runnable completion = () -> runningExports.remove(worldKey); + UUID senderPlayerId = sender instanceof Player player ? player.getUniqueId() : null; AsyncBiomeExportTask task = new AsyncBiomeExportTask(plugin, exporter, world, cellSize, width, height, originX, originZ, selectionMinX, selectionMinZ, selectionMaxX, selectionMaxZ, - selectionOutputFile, previewEnabled, sender, logger, completion, + selectionOutputFile, previewEnabled, sender, senderPlayerId, logger, completion, chunksPerTick, maxInFlight, maxConcurrentChunks); @@ -236,6 +248,9 @@ public List onTabComplete( if (args.length == 1) { String prefix = args[0].toLowerCase(Locale.ROOT); List suggestions = new ArrayList<>(); + if ("status".startsWith(prefix)) { + suggestions.add("status"); + } if ("stop".startsWith(prefix)) { suggestions.add("stop"); } @@ -246,6 +261,12 @@ public List onTabComplete( } } return suggestions; + } else if (args.length == 2 && "status".equalsIgnoreCase(args[0])) { + String prefix = args[1].toLowerCase(Locale.ROOT); + if ("map".startsWith(prefix)) { + return List.of("map"); + } + return Collections.emptyList(); } else if (args.length >= 6) { return List.of("8", "16", "32", "64", "128", "256", "preview"); } @@ -278,6 +299,101 @@ private boolean handleStopCommand(CommandSender sender, String[] args) { return true; } + private boolean handleStatusCommand(CommandSender sender, String[] args) { + boolean showMap = false; + if (args.length == 2) { + String option = args[1]; + if ("map".equalsIgnoreCase(option) || "--map".equalsIgnoreCase(option)) { + showMap = true; + } else { + sendWarning(sender, "Usage: /biomemap status [map]"); + return true; + } + } else if (args.length != 1) { + sendWarning(sender, "Usage: /biomemap status [map]"); + return true; + } + if (runningExports.isEmpty()) { + sendWarning(sender, "No biome export is currently running."); + return true; + } + + AsyncBiomeExportTask task = runningExports.values().iterator().next(); + AsyncBiomeExportTask.ExportStatus status = task.snapshotStatus(); + + sendInfo(sender, "§b§lExport status§7 (running)"); + sendInfo( + sender, + String.format( + Locale.ROOT, + "World=§f%s§7 cell=§f%d§7 preview=§f%s", + status.worldName(), + status.cellSize(), + status.previewEnabled() ? "on" : "off")); + sendInfo( + sender, + String.format( + Locale.ROOT, + "Area=§f[%d,%d -> %d,%d]§7 grid=§f%dx%d§7 cells=§f%d", + status.selectionMinX(), + status.selectionMinZ(), + status.selectionMaxX(), + status.selectionMaxZ(), + status.gridWidth(), + status.gridHeight(), + status.totalCells())); + + String eta = status.etaMs() < 0 ? "n/a" : formatDuration(status.etaMs()); + String progressBar = buildProgressBar(status.progressPercent(), STATUS_BAR_WIDTH); + sendInfo( + sender, + String.format( + Locale.ROOT, + "Progress=§f%s§7 §f%.1f%%§7 chunks=§f%d/%d§7 elapsed=§f%s§7 eta=§f%s", + progressBar, + status.progressPercent(), + status.completedChunks(), + status.totalChunks(), + formatDuration(status.elapsedMs()), + eta)); + + if (status.initiatorName() != null && !status.initiatorName().isBlank()) { + String initiatorState = status.initiatorOnline() ? "online" : "offline"; + sendInfo( + sender, + String.format( + Locale.ROOT, + "Initiator=§f%s§7 (%s)", + status.initiatorName(), + initiatorState)); + } + + sendInfo(sender, "JSON=§f" + status.jsonPath()); + if (status.previewPath() != null) { + sendInfo(sender, "PNG=§f" + status.previewPath()); + } + + if (showMap) { + int maxWidth = + isConsoleSender(sender) ? CONSOLE_STATUS_MAP_MAX_WIDTH : PLAYER_STATUS_MAP_MAX_WIDTH; + int maxHeight = + isConsoleSender(sender) ? CONSOLE_STATUS_MAP_MAX_HEIGHT : PLAYER_STATUS_MAP_MAX_HEIGHT; + sendInfo( + sender, + String.format( + Locale.ROOT, + "Selection map (§f%d×%d§7 chunks, compact view):", + status.mapWidth(), + status.mapHeight())); + for (String line : task.renderProgressAsciiMap(maxWidth, maxHeight)) { + sendInfo(sender, "§f" + line); + } + } else { + sendInfo(sender, "Tip: run §f/biomemap status map§7 for ASCII map."); + } + return true; + } + private Integer parseInteger(String raw) { try { return Integer.parseInt(raw); @@ -325,10 +441,36 @@ private void sendWarning(CommandSender sender, String message) { sender.sendMessage(CHAT_PREFIX + "§6" + message); } + private void sendInfo(CommandSender sender, String message) { + sender.sendMessage(CHAT_PREFIX + "§7" + message); + } + private void sendError(CommandSender sender, String message) { sender.sendMessage(CHAT_PREFIX + "§c§lError: §c" + message); } + private String formatDuration(long durationMs) { + long totalSeconds = Math.max(0L, durationMs / 1000L); + long hours = totalSeconds / 3600L; + long minutes = (totalSeconds % 3600L) / 60L; + long seconds = totalSeconds % 60L; + if (hours > 0) { + return String.format(Locale.ROOT, "%dh %02dm %02ds", hours, minutes, seconds); + } + if (minutes > 0) { + return String.format(Locale.ROOT, "%dm %02ds", minutes, seconds); + } + return String.format(Locale.ROOT, "%ds", seconds); + } + + private String buildProgressBar(double percent, int width) { + int safeWidth = Math.max(1, width); + double bounded = Math.max(0.0, Math.min(100.0, percent)); + int filled = (int) Math.round((bounded / 100.0) * safeWidth); + filled = Math.max(0, Math.min(safeWidth, filled)); + return "[" + "#".repeat(filled) + "-".repeat(safeWidth - filled) + "]"; + } + private String toLogPath(File file) { Path absolute = file.toPath().toAbsolutePath().normalize(); Path serverRoot = plugin.getServer().getWorldContainer().toPath().toAbsolutePath().normalize(); diff --git a/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java b/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java index 7431061..965142b 100644 --- a/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java +++ b/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java @@ -8,13 +8,17 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Queue; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -27,6 +31,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.command.ConsoleCommandSender; import org.bukkit.command.RemoteConsoleCommandSender; +import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.bukkit.scheduler.BukkitRunnable; @@ -38,6 +43,8 @@ public final class AsyncBiomeExportTask extends BukkitRunnable { private static final int CHUNK_SIZE = 16; private static final String CHAT_PREFIX = "§8[§b§lBiomeMap§8] §r"; + private static final long PROGRESS_HEARTBEAT_MS = TimeUnit.MINUTES.toMillis(5); + private static final double EPSILON = 0.000001; private final Plugin plugin; private final BiomeExporter exporter; @@ -55,6 +62,7 @@ public final class AsyncBiomeExportTask extends BukkitRunnable { private final File outputFile; private final boolean previewEnabled; private final CommandSender sender; + private final UUID senderPlayerId; private final Logger logger; private final Runnable completionCallback; private final BiomeCell[] cells; @@ -74,7 +82,8 @@ public final class AsyncBiomeExportTask extends BukkitRunnable { private final AtomicInteger inFlight = new AtomicInteger(0); private final AtomicLong chunksCompleted = new AtomicLong(0); private final long totalChunks; - private final int progressInterval; + private int nextProgressPercent = 10; + private final boolean[] chunkCompletedMap; private final Queue completedChunks = new ConcurrentLinkedQueue<>(); private final AtomicBoolean aggregating = new AtomicBoolean(false); @@ -82,6 +91,7 @@ public final class AsyncBiomeExportTask extends BukkitRunnable { private final AtomicBoolean stopRequested = new AtomicBoolean(false); private final AtomicBoolean completionNotified = new AtomicBoolean(false); private final long startTimeMs = System.currentTimeMillis(); + private long lastProgressLogAtMs = startTimeMs; private volatile File outputPreviewFile; /** @@ -103,6 +113,7 @@ public AsyncBiomeExportTask( File outputFile, boolean previewEnabled, CommandSender sender, + UUID senderPlayerId, Logger logger, Runnable completionCallback, int chunksPerTick, @@ -124,6 +135,7 @@ public AsyncBiomeExportTask( this.outputFile = outputFile; this.previewEnabled = previewEnabled; this.sender = sender; + this.senderPlayerId = senderPlayerId; this.logger = logger; this.completionCallback = completionCallback; this.cells = new BiomeCell[width * height]; @@ -145,7 +157,7 @@ public AsyncBiomeExportTask( this.chunkStartZ = Math.floorDiv(originZ, CHUNK_SIZE); this.chunkBiomes = new String[chunkColumns * chunkRows]; this.totalChunks = chunkBiomes.length; - this.progressInterval = Math.max(1, chunkBiomes.length / 10); + this.chunkCompletedMap = new boolean[chunkBiomes.length]; } /** @@ -176,6 +188,11 @@ public void run() { scheduledThisTick++; } + long completedSoFar = chunksCompleted.get(); + if (completedSoFar < totalChunks) { + maybeReportProgress(completedSoFar, true); + } + if (nextChunkIndex.get() >= chunkBiomes.length && inFlight.get() == 0) { if (subChunkSampling) { finishExport(); @@ -313,10 +330,10 @@ private void handleChunkCompletion(ChunkCompletion completion) { chunkBiomes[completion.chunkIndex] = biomeId; } long completed = chunksCompleted.incrementAndGet(); - - if (completed % progressInterval == 0 || completed == totalChunks) { - reportProgress(completed); + if (completion.chunkIndex >= 0 && completion.chunkIndex < chunkCompletedMap.length) { + chunkCompletedMap[completion.chunkIndex] = true; } + maybeReportProgress(completed, false); if (nextChunkIndex.get() >= chunkBiomes.length && remaining == 0) { if (subChunkSampling) { @@ -342,6 +359,28 @@ private void reportProgress(long completedChunks) { } } + private void maybeReportProgress(long completedChunks, boolean timeoutCheck) { + if (totalChunks <= 0) { + return; + } + double percent = (completedChunks * 100.0) / totalChunks; + boolean reachedThreshold = false; + while (nextProgressPercent <= 100 && percent + EPSILON >= nextProgressPercent) { + reachedThreshold = true; + nextProgressPercent += 10; + } + boolean completed = completedChunks >= totalChunks; + long now = System.currentTimeMillis(); + boolean timedOut = timeoutCheck + && !completed + && (now - lastProgressLogAtMs) >= PROGRESS_HEARTBEAT_MS; + if (!reachedThreshold && !completed && !timedOut) { + return; + } + reportProgress(completedChunks); + lastProgressLogAtMs = now; + } + private void startAggregation() { if (stopRequested.get()) { finishTask(); @@ -544,21 +583,218 @@ private record ChunkCompletion( } private void sendInfo(String message) { - sender.sendMessage(CHAT_PREFIX + "§7" + message); + sendToInvoker(CHAT_PREFIX + "§7" + message); } private void sendSuccess(String message) { - sender.sendMessage(CHAT_PREFIX + "§a§l" + message); + sendToInvoker(CHAT_PREFIX + "§a§l" + message); } private void sendError(String message) { - sender.sendMessage(CHAT_PREFIX + "§c§lError: §c" + message); + sendToInvoker(CHAT_PREFIX + "§c§lError: §c" + message); } private boolean isConsoleSender() { + if (senderPlayerId != null) { + return false; + } return sender instanceof ConsoleCommandSender || sender instanceof RemoteConsoleCommandSender; } + private void sendToInvoker(String message) { + CommandSender recipient = resolveCurrentRecipient(); + if (recipient == null) { + return; + } + recipient.sendMessage(message); + } + + private CommandSender resolveCurrentRecipient() { + if (senderPlayerId == null) { + return sender; + } + Player player = Bukkit.getPlayer(senderPlayerId); + if (player == null || !player.isOnline()) { + return null; + } + return player; + } + + /** + * Returns a status snapshot for the currently running export. + */ + public ExportStatus snapshotStatus() { + long completedChunksSnapshot = chunksCompleted.get(); + long elapsedMs = System.currentTimeMillis() - startTimeMs; + long etaMs = -1L; + if (completedChunksSnapshot > 0 && completedChunksSnapshot < totalChunks) { + double msPerChunk = elapsedMs / (double) completedChunksSnapshot; + etaMs = (long) (msPerChunk * (totalChunks - completedChunksSnapshot)); + } + + String previewPath = null; + if (previewEnabled) { + try { + File previewFile = outputPreviewFile != null + ? outputPreviewFile + : exporter.resolvePreviewOutput(outputFile); + previewPath = toLogPath(previewFile); + } catch (IllegalArgumentException ex) { + previewPath = null; + } + } + + String initiatorName = senderPlayerId == null ? sender.getName() : null; + boolean initiatorOnline = false; + if (senderPlayerId != null) { + Player player = Bukkit.getPlayer(senderPlayerId); + if (player != null) { + initiatorName = player.getName(); + initiatorOnline = player.isOnline(); + } else { + initiatorName = sender.getName(); + } + } + + return new ExportStatus( + world.getName(), + selectionMinX, + selectionMinZ, + selectionMaxX, + selectionMaxZ, + cellSize, + width, + height, + cells.length, + previewEnabled, + chunkColumns, + chunkRows, + completedChunksSnapshot, + totalChunks, + totalChunks <= 0 ? 100.0 : (completedChunksSnapshot * 100.0) / totalChunks, + elapsedMs, + etaMs, + toLogPath(outputFile), + previewPath, + initiatorName, + initiatorOnline); + } + + /** + * Renders a compact ASCII minimap of chunk completion. + */ + public List renderProgressAsciiMap(int maxWidth, int maxHeight) { + int targetWidth = Math.max(1, maxWidth); + int targetHeight = Math.max(1, maxHeight); + int[] size = resolveRenderSize(chunkColumns, chunkRows, targetWidth, targetHeight); + int widthChars = size[0]; + int heightChars = size[1]; + + List lines = new ArrayList<>(heightChars + 2); + lines.add("+" + "-".repeat(widthChars) + "+"); + for (int y = 0; y < heightChars; y++) { + int sourceRowStart = (int) Math.floor((y * (double) chunkRows) / heightChars); + int sourceRowEnd = (int) Math.ceil(((y + 1) * (double) chunkRows) / heightChars); + sourceRowEnd = Math.min(chunkRows, Math.max(sourceRowEnd, sourceRowStart + 1)); + + StringBuilder row = new StringBuilder(widthChars + 2); + row.append('|'); + for (int x = 0; x < widthChars; x++) { + int sourceColStart = (int) Math.floor((x * (double) chunkColumns) / widthChars); + int sourceColEnd = (int) Math.ceil(((x + 1) * (double) chunkColumns) / widthChars); + sourceColEnd = Math.min(chunkColumns, Math.max(sourceColEnd, sourceColStart + 1)); + + int total = 0; + int done = 0; + for (int sourceRow = sourceRowStart; sourceRow < sourceRowEnd; sourceRow++) { + int base = sourceRow * chunkColumns; + for (int sourceCol = sourceColStart; sourceCol < sourceColEnd; sourceCol++) { + int index = base + sourceCol; + if (index < 0 || index >= chunkCompletedMap.length) { + continue; + } + total++; + if (chunkCompletedMap[index]) { + done++; + } + } + } + double fill = total == 0 ? 0.0 : done / (double) total; + row.append(fillChar(fill)); + } + row.append('|'); + lines.add(row.toString()); + } + lines.add("+" + "-".repeat(widthChars) + "+"); + return lines; + } + + private int[] resolveRenderSize(int sourceWidth, int sourceHeight, int maxWidth, int maxHeight) { + if (sourceWidth <= 0 || sourceHeight <= 0) { + return new int[] {1, 1}; + } + int width = Math.min(sourceWidth, maxWidth); + double aspect = sourceHeight / (double) sourceWidth; + int height = Math.max(1, (int) Math.round(width * aspect)); + + if (height > maxHeight) { + height = maxHeight; + width = Math.max(1, (int) Math.round(height / aspect)); + width = Math.min(width, maxWidth); + } + + width = Math.max(1, Math.min(width, sourceWidth)); + height = Math.max(1, Math.min(height, sourceHeight)); + return new int[] {width, height}; + } + + private char fillChar(double fill) { + if (fill <= 0.0) { + return '.'; + } + if (fill >= 1.0) { + return '#'; + } + if (fill < 0.2) { + return ':'; + } + if (fill < 0.4) { + return '-'; + } + if (fill < 0.6) { + return '='; + } + if (fill < 0.8) { + return '+'; + } + return '*'; + } + + /** Immutable status payload for command rendering. */ + public record ExportStatus( + String worldName, + int selectionMinX, + int selectionMinZ, + int selectionMaxX, + int selectionMaxZ, + int cellSize, + int gridWidth, + int gridHeight, + int totalCells, + boolean previewEnabled, + int mapWidth, + int mapHeight, + long completedChunks, + long totalChunks, + double progressPercent, + long elapsedMs, + long etaMs, + String jsonPath, + String previewPath, + String initiatorName, + boolean initiatorOnline) { + } + private String toLogPath(File file) { Path absolute = file.toPath().toAbsolutePath().normalize(); Path serverRoot = plugin.getServer().getWorldContainer().toPath().toAbsolutePath().normalize(); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 2a41c6c..ff34b07 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,5 +6,5 @@ author: AUTHOR description: DESCRIPTION commands: biomemap: - description: Export dominant biomes to JSON/PNG and stop the current export. - usage: "/biomemap [cellSize] [preview] | /biomemap stop" + description: Export dominant biomes to JSON/PNG, check status, and stop the current export. + usage: "/biomemap [cellSize] [preview] | /biomemap status [map] | /biomemap stop"