diff --git a/build.gradle b/build.gradle index fc755ac..a2716a1 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = "fr.sukikui.biomemap" -version = "0.1.3" +version = "0.1.4" def paperApiVersion = '1.21.11-R0.1-SNAPSHOT' def hyphenIndex = paperApiVersion.indexOf('-') diff --git a/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java b/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java index 04d58a4..d863099 100644 --- a/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java +++ b/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java @@ -3,6 +3,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import fr.sukikui.biomemap.export.AsyncBiomeExportTask; import fr.sukikui.biomemap.export.BiomeExporter; +import fr.sukikui.biomemap.util.ProgressFormatter; import java.io.File; import java.nio.file.Path; import java.util.ArrayList; @@ -37,7 +38,6 @@ public final class BiomeMapCommand implements CommandExecutor, TabCompleter { 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; @@ -343,19 +343,15 @@ private boolean handleStatusCommand(CommandSender sender, String[] args) { 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, + ProgressFormatter.formatChatLine( status.progressPercent(), status.completedChunks(), status.totalChunks(), - formatDuration(status.elapsedMs()), - eta)); + status.elapsedMs(), + status.etaMs(), + ProgressFormatter.DEFAULT_BAR_WIDTH)); if (status.initiatorName() != null && !status.initiatorName().isBlank()) { String initiatorState = status.initiatorOnline() ? "online" : "offline"; @@ -449,28 +445,6 @@ 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 965142b..6f8a3d8 100644 --- a/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java +++ b/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java @@ -4,6 +4,7 @@ import fr.sukikui.biomemap.export.BiomeExporter.BiomeCell; import fr.sukikui.biomemap.export.BiomeExporter.BiomeMapExport; import fr.sukikui.biomemap.export.BiomeExporter.Point; +import fr.sukikui.biomemap.util.ProgressFormatter; import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -43,6 +44,7 @@ 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 FIRST_PROGRESS_HEARTBEAT_MS = TimeUnit.SECONDS.toMillis(10); private static final long PROGRESS_HEARTBEAT_MS = TimeUnit.MINUTES.toMillis(5); private static final double EPSILON = 0.000001; @@ -90,6 +92,7 @@ public final class AsyncBiomeExportTask extends BukkitRunnable { private final AtomicBoolean finishing = new AtomicBoolean(false); private final AtomicBoolean stopRequested = new AtomicBoolean(false); private final AtomicBoolean completionNotified = new AtomicBoolean(false); + private boolean hasProgressLog = false; private final long startTimeMs = System.currentTimeMillis(); private long lastProgressLogAtMs = startTimeMs; private volatile File outputPreviewFile; @@ -344,18 +347,24 @@ private void handleChunkCompletion(ChunkCompletion completion) { } } - private void reportProgress(long completedChunks) { - double percent = (completedChunks * 100.0) / totalChunks; - sendInfo(String.format( - Locale.ROOT, - "Progress §f§l%.1f%%§7 (§f%d/%d§7 chunks)", - percent, - completedChunks, - totalChunks)); + private void reportProgress(ProgressFormatter.ProgressSnapshot progress) { + sendInfo( + ProgressFormatter.formatChatLine( + progress.progressPercent(), + progress.completedChunks(), + progress.totalChunks(), + progress.elapsedMs(), + progress.etaMs(), + ProgressFormatter.DEFAULT_BAR_WIDTH)); if (!isConsoleSender()) { - String plainLine = String.format( - Locale.ROOT, "Progress %.1f%% (%d/%d chunks)", percent, completedChunks, totalChunks); - logger.info(plainLine); + logger.info( + ProgressFormatter.formatPlainLine( + progress.progressPercent(), + progress.completedChunks(), + progress.totalChunks(), + progress.elapsedMs(), + progress.etaMs(), + ProgressFormatter.DEFAULT_BAR_WIDTH)); } } @@ -363,7 +372,10 @@ private void maybeReportProgress(long completedChunks, boolean timeoutCheck) { if (totalChunks <= 0) { return; } - double percent = (completedChunks * 100.0) / totalChunks; + long elapsedMs = System.currentTimeMillis() - startTimeMs; + ProgressFormatter.ProgressSnapshot progress = + ProgressFormatter.calculate(completedChunks, totalChunks, elapsedMs); + double percent = progress.progressPercent(); boolean reachedThreshold = false; while (nextProgressPercent <= 100 && percent + EPSILON >= nextProgressPercent) { reachedThreshold = true; @@ -371,13 +383,20 @@ private void maybeReportProgress(long completedChunks, boolean timeoutCheck) { } boolean completed = completedChunks >= totalChunks; long now = System.currentTimeMillis(); - boolean timedOut = timeoutCheck + boolean firstHeartbeatDue = timeoutCheck + && !completed + && !hasProgressLog + && (now - startTimeMs) >= FIRST_PROGRESS_HEARTBEAT_MS; + boolean regularHeartbeatDue = timeoutCheck && !completed + && hasProgressLog && (now - lastProgressLogAtMs) >= PROGRESS_HEARTBEAT_MS; + boolean timedOut = firstHeartbeatDue || regularHeartbeatDue; if (!reachedThreshold && !completed && !timedOut) { return; } - reportProgress(completedChunks); + reportProgress(progress); + hasProgressLog = true; lastProgressLogAtMs = now; } @@ -626,11 +645,8 @@ private CommandSender resolveCurrentRecipient() { 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)); - } + ProgressFormatter.ProgressSnapshot progress = + ProgressFormatter.calculate(completedChunksSnapshot, totalChunks, elapsedMs); String previewPath = null; if (previewEnabled) { @@ -671,9 +687,9 @@ public ExportStatus snapshotStatus() { chunkRows, completedChunksSnapshot, totalChunks, - totalChunks <= 0 ? 100.0 : (completedChunksSnapshot * 100.0) / totalChunks, - elapsedMs, - etaMs, + progress.progressPercent(), + progress.elapsedMs(), + progress.etaMs(), toLogPath(outputFile), previewPath, initiatorName, diff --git a/src/main/java/fr/sukikui/biomemap/util/ProgressFormatter.java b/src/main/java/fr/sukikui/biomemap/util/ProgressFormatter.java new file mode 100644 index 0000000..189bfba --- /dev/null +++ b/src/main/java/fr/sukikui/biomemap/util/ProgressFormatter.java @@ -0,0 +1,129 @@ +package fr.sukikui.biomemap.util; + +import java.util.Locale; + +/** + * Shared helpers to compute and render export progress consistently. + */ +public final class ProgressFormatter { + + public static final int DEFAULT_BAR_WIDTH = 20; + + private ProgressFormatter() { + } + + /** + * Computes percentage and ETA from chunk counters and elapsed duration. + */ + public static ProgressSnapshot calculate(long completedChunks, long totalChunks, long elapsedMs) { + long safeCompletedChunks = Math.max(0L, completedChunks); + long safeTotalChunks = Math.max(0L, totalChunks); + long safeElapsedMs = Math.max(0L, elapsedMs); + + double progressPercent; + if (safeTotalChunks <= 0L) { + progressPercent = 100.0; + } else { + progressPercent = (safeCompletedChunks * 100.0) / safeTotalChunks; + } + progressPercent = Math.max(0.0, Math.min(100.0, progressPercent)); + + long etaMs = -1L; + if (safeCompletedChunks > 0L && safeCompletedChunks < safeTotalChunks) { + double msPerChunk = safeElapsedMs / (double) safeCompletedChunks; + etaMs = (long) (msPerChunk * (safeTotalChunks - safeCompletedChunks)); + } + + return new ProgressSnapshot( + safeCompletedChunks, + safeTotalChunks, + progressPercent, + safeElapsedMs, + etaMs); + } + + /** + * Renders the standard colored chat progress line. + */ + public static String formatChatLine( + double progressPercent, + long completedChunks, + long totalChunks, + long elapsedMs, + long etaMs, + int barWidth) { + return String.format( + Locale.ROOT, + "Progress=§f%s§7 §f%.1f%%§7 chunks=§f%d/%d§7 elapsed=§f%s§7 eta=§f%s", + buildProgressBar(progressPercent, barWidth), + progressPercent, + completedChunks, + totalChunks, + formatDuration(elapsedMs), + formatEta(etaMs)); + } + + /** + * Renders the plain progress line for logger output. + */ + public static String formatPlainLine( + double progressPercent, + long completedChunks, + long totalChunks, + long elapsedMs, + long etaMs, + int barWidth) { + return String.format( + Locale.ROOT, + "Progress=%s %.1f%% chunks=%d/%d elapsed=%s eta=%s", + buildProgressBar(progressPercent, barWidth), + progressPercent, + completedChunks, + totalChunks, + formatDuration(elapsedMs), + formatEta(etaMs)); + } + + /** + * Formats elapsed or remaining duration. + */ + public static 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); + } + + /** + * Builds an ASCII progress bar from 0 to 100%. + */ + public static 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 static String formatEta(long etaMs) { + return etaMs < 0 ? "n/a" : formatDuration(etaMs); + } + + /** + * Immutable calculation payload for progress rendering. + */ + public record ProgressSnapshot( + long completedChunks, + long totalChunks, + double progressPercent, + long elapsedMs, + long etaMs) { + } +}