From ff78ae52d4830b72611aa82ed39c21fcbe238e3d Mon Sep 17 00:00:00 2001 From: Sukikui Date: Wed, 11 Feb 2026 16:38:47 +0100 Subject: [PATCH 1/4] feat: Simplify the system + add `stop` option --- .../java/fr/sukikui/biomemap/BiomeMap.java | 32 +- .../biomemap/command/BiomeMapCommand.java | 316 +++++++-- .../biomemap/export/AsyncBiomeExportTask.java | 609 ++++++++++++++++++ .../biomemap/export/BiomeColorPalette.java | 112 ++++ .../biomemap/export/BiomeExporter.java | 149 +++-- .../biomemap/export/BiomePreviewRenderer.java | 57 ++ .../export/ChunkSnapshotProvider.java | 114 ++++ 7 files changed, 1294 insertions(+), 95 deletions(-) create mode 100644 src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java create mode 100644 src/main/java/fr/sukikui/biomemap/export/BiomeColorPalette.java create mode 100644 src/main/java/fr/sukikui/biomemap/export/BiomePreviewRenderer.java create mode 100644 src/main/java/fr/sukikui/biomemap/export/ChunkSnapshotProvider.java diff --git a/src/main/java/fr/sukikui/biomemap/BiomeMap.java b/src/main/java/fr/sukikui/biomemap/BiomeMap.java index bcdd721..d44ec4d 100644 --- a/src/main/java/fr/sukikui/biomemap/BiomeMap.java +++ b/src/main/java/fr/sukikui/biomemap/BiomeMap.java @@ -14,19 +14,28 @@ */ public final class BiomeMap extends JavaPlugin { - public static final int DEFAULT_CELL_SIZE = 32; - public static final int SCAN_RADIUS = 5000; + public static final int DEFAULT_CELL_SIZE = 16; + + private BiomeMapCommand commandHandler; + private int chunksPerTick; + private int maxInFlight; + private int maxConcurrentChunks; @Override public void onEnable() { PaperLib.suggestPaper(this); ensureDataFolder(); + saveDefaultConfig(); + loadPerformanceSettings(); Logger logger = getLogger(); File dataFolder = getDataFolder(); - BiomeExporter exporter = new BiomeExporter(dataFolder, SCAN_RADIUS); + BiomeExporter exporter = new BiomeExporter(dataFolder); - BiomeMapCommand commandHandler = new BiomeMapCommand(exporter, logger, DEFAULT_CELL_SIZE); + commandHandler = + new BiomeMapCommand( + this, exporter, logger, DEFAULT_CELL_SIZE, chunksPerTick, maxInFlight, + maxConcurrentChunks); PluginCommand biomemapCommand = Objects.requireNonNull( getCommand("biomemap"), "Command biomemap not defined in plugin.yml"); @@ -34,9 +43,24 @@ public void onEnable() { biomemapCommand.setTabCompleter(commandHandler); } + @Override + public void onDisable() { + if (commandHandler != null) { + commandHandler.cancelAllExports(); + } + } + private void ensureDataFolder() { if (!getDataFolder().exists() && !getDataFolder().mkdirs()) { getLogger().warning("Unable to create plugin data folder. Exports may fail."); } } + + private void loadPerformanceSettings() { + chunksPerTick = Math.max(1, getConfig().getInt("performance.chunks-per-tick", 1)); + int configuredMaxInFlight = getConfig().getInt("performance.max-in-flight", chunksPerTick * 2); + maxInFlight = Math.max(chunksPerTick, configuredMaxInFlight); + maxConcurrentChunks = Math.max( + 1, getConfig().getInt("performance.max-concurrent-chunks", 64)); + } } diff --git a/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java b/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java index 479a356..e02000d 100644 --- a/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java +++ b/src/main/java/fr/sukikui/biomemap/command/BiomeMapCommand.java @@ -1,21 +1,26 @@ package fr.sukikui.biomemap.command; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import fr.sukikui.biomemap.export.AsyncBiomeExportTask; import fr.sukikui.biomemap.export.BiomeExporter; -import fr.sukikui.biomemap.export.BiomeExporter.ExportResult; -import java.io.IOException; +import java.io.File; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Locale; -import java.util.logging.Level; +import java.util.Map; import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.command.RemoteConsoleCommandSender; import org.bukkit.command.TabCompleter; +import org.bukkit.plugin.java.JavaPlugin; /** * Handles the /biomemap command registration, parsing, and tab completion. @@ -23,84 +28,207 @@ @SuppressFBWarnings("EI_EXPOSE_REP2") public final class BiomeMapCommand implements CommandExecutor, TabCompleter { - private static final String DEFAULT_WORLD = "world"; + 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 final JavaPlugin plugin; private final BiomeExporter exporter; private final Logger logger; private final int defaultCellSize; + private final int chunksPerTick; + private final int maxInFlight; + private final int maxConcurrentChunks; + private final Map runningExports = new HashMap<>(); /** - * Creates a handler bound to a {@link BiomeExporter}. + * Creates a command handler. * - * @param exporter exporter that performs the heavy lifting - * @param logger plugin logger used for console output - * @param defaultCellSize fallback cell size when the user omits it + * @param plugin owning plugin + * @param exporter exporter providing biome sampling utilities + * @param logger shared plugin logger + * @param defaultCellSize fallback cell size if not provided */ - public BiomeMapCommand(BiomeExporter exporter, Logger logger, int defaultCellSize) { + public BiomeMapCommand( + JavaPlugin plugin, + BiomeExporter exporter, + Logger logger, + int defaultCellSize, + int chunksPerTick, + int maxInFlight, + int maxConcurrentChunks) { + this.plugin = plugin; this.exporter = exporter; this.logger = logger; this.defaultCellSize = defaultCellSize; + this.chunksPerTick = chunksPerTick; + this.maxInFlight = maxInFlight; + this.maxConcurrentChunks = maxConcurrentChunks; } /** - * Parses `/biomemap` arguments and triggers the export. + * Parses `/biomemap` arguments and kicks off the asynchronous export. */ @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - String worldName = args.length >= 1 && !args[0].isBlank() ? args[0] : DEFAULT_WORLD; - int cellSize = defaultCellSize; - if (args.length >= 2) { - try { - cellSize = Integer.parseInt(args[1]); - } catch (NumberFormatException ex) { - sender.sendMessage("§cCell size must be a positive integer."); - return true; - } + if (args.length >= 1 && "stop".equalsIgnoreCase(args[0])) { + return handleStopCommand(sender, args); } - - if (cellSize <= 0) { - sender.sendMessage("§cCell size must be greater than 0."); + if (args.length < 5) { + sendWarning( + sender, + "Usage: /biomemap [cellSize] [preview]"); + sendWarning(sender, "Stop: /biomemap stop"); return true; } + String worldName = args[0]; + World world = Bukkit.getWorld(worldName); if (world == null) { - sender.sendMessage("§cWorld '" + worldName + "' not found."); + sendError(sender, "World '" + worldName + "' not found."); return true; } - logger.info( - String.format( - "[BiomeMap] Exporting biomes for world '%s' (cellSize=%d)...", world.getName(), - cellSize)); - sender.sendMessage( - String.format( - "§a[BiomeMap] Exporting biomes for world '%s' (cellSize=%d)...", world.getName(), - cellSize)); + Integer x1 = parseInteger(args[1]); + Integer z1 = parseInteger(args[2]); + Integer x2 = parseInteger(args[3]); + Integer z2 = parseInteger(args[4]); + if (x1 == null || z1 == null || x2 == null || z2 == null) { + sendError(sender, "Coordinates must be integers."); + return true; + } - ExportResult result; - try { - result = exporter.exportWorld(world, cellSize); - } catch (IOException ex) { - logger.log(Level.SEVERE, "Failed to write biome export", ex); - sender.sendMessage("§cFailed to write biome export: " + ex.getMessage()); + int cellSize = defaultCellSize; + boolean previewEnabled = false; + boolean hasCustomCellSize = false; + for (int index = 5; index < args.length; index++) { + String option = args[index]; + if ("preview".equalsIgnoreCase(option) || "--preview".equalsIgnoreCase(option)) { + previewEnabled = true; + continue; + } + Integer parsedCellSize = parsePositiveInteger(option); + if (!hasCustomCellSize && parsedCellSize != null) { + cellSize = parsedCellSize; + hasCustomCellSize = true; + continue; + } + sendError(sender, "Invalid option '" + option + "'. Use a cell size and/or preview."); + sendWarning( + sender, + "Usage: /biomemap [cellSize] [preview]"); + return true; + } + + int alignedCellSize = alignCellSize(cellSize); + if (alignedCellSize != cellSize) { + sendWarning(sender, String.format( + Locale.ROOT, + "Cell size adjusted to %d to stay aligned with the grid.", + alignedCellSize)); + } + cellSize = alignedCellSize; + + if (!runningExports.isEmpty()) { + String activeWorld = runningExports.keySet().iterator().next(); + sendError(sender, "An export is already running for world '" + activeWorld + "'."); + sendWarning(sender, "Use /biomemap stop before starting a new export."); return true; } + int selectionMinX = Math.min(x1, x2); + int selectionMinZ = Math.min(z1, z2); + int selectionMaxX = Math.max(x1, x2); + int selectionMaxZ = Math.max(z1, z2); - logger.info( + int chunkAlignedSize = cellSize; + int chunkMinX = Math.floorDiv(selectionMinX, chunkAlignedSize); + int chunkMaxX = Math.floorDiv(selectionMaxX + chunkAlignedSize - 1, chunkAlignedSize); + int chunkMinZ = Math.floorDiv(selectionMinZ, chunkAlignedSize); + int chunkMaxZ = Math.floorDiv(selectionMaxZ + chunkAlignedSize - 1, chunkAlignedSize); + + int width = chunkMaxX - chunkMinX + 1; + int height = chunkMaxZ - chunkMinZ + 1; + if (width <= 0 || height <= 0) { + sendError(sender, "Invalid selection: zero-area bounding box."); + return true; + } + + final int originX = chunkMinX * cellSize; + final int originZ = chunkMinZ * cellSize; + + final File selectionOutputFile = exporter.resolveSelectionOutput( + world.getName(), cellSize); + + long totalCells = (long) width * height; + long totalChunks; + if (cellSize >= CHUNK_SIZE) { + int chunksPerCell = Math.max(1, cellSize / CHUNK_SIZE); + totalChunks = totalCells * (long) chunksPerCell * chunksPerCell; + } else { + int cellsPerChunk = CHUNK_SIZE / cellSize; + int chunkColumns = Math.max(1, (width + cellsPerChunk - 1) / cellsPerChunk); + int chunkRows = Math.max(1, (height + cellsPerChunk - 1) / cellsPerChunk); + totalChunks = (long) chunkColumns * chunkRows; + } + notifyInfo( + sender, + String.format( + Locale.ROOT, + "Selection: §f§l%d×%d§7 cells (§f%d§7 total, ~§f%d§7 chunks).", + width, + height, + totalCells, + totalChunks), String.format( - "[BiomeMap] Export complete: %d cells saved to biome-map.json (%.2fs).", - result.cellCount(), result.durationMs() / 1000.0)); - sender.sendMessage( + Locale.ROOT, + "Selection: grid=%dx%d cells=%d chunks~%d", + width, + height, + totalCells, + totalChunks)); + String previewMode = previewEnabled ? "on" : "off"; + notifyInfo( + sender, String.format( - "§a[BiomeMap] Export complete: %d cells saved to biome-map.json.", - result.cellCount())); - sender.sendMessage("§7Location: " + result.outputFile().getAbsolutePath()); + Locale.ROOT, + "§a§lExport started§7 for '§f%s§7' (cell=§f%d§7, preview=§f%s§7).", + world.getName(), + cellSize, + previewMode), + String.format( + Locale.ROOT, + "Export started: world=%s area=[%d,%d -> %d,%d] cell=%d grid=%dx%d " + + "cells=%d chunks~%d preview=%s output=%s", + world.getName(), + selectionMinX, + selectionMinZ, + selectionMaxX, + selectionMaxZ, + cellSize, + width, + height, + totalCells, + totalChunks, + previewMode, + toLogPath(selectionOutputFile))); + + String worldKey = world.getName().toLowerCase(Locale.ROOT); + Runnable completion = () -> runningExports.remove(worldKey); + AsyncBiomeExportTask task = + new AsyncBiomeExportTask(plugin, exporter, world, cellSize, width, height, + originX, originZ, selectionMinX, selectionMinZ, selectionMaxX, selectionMaxZ, + selectionOutputFile, previewEnabled, sender, logger, completion, + chunksPerTick, + maxInFlight, + maxConcurrentChunks); + runningExports.put(worldKey, task); + task.runTaskTimer(plugin, 1L, 1L); return true; } /** - * Provides suggestions for world names and cell sizes. + * Provides suggestions for world names and a few common cell sizes. */ @Override public List onTabComplete( @@ -108,6 +236,9 @@ public List onTabComplete( if (args.length == 1) { String prefix = args[0].toLowerCase(Locale.ROOT); List suggestions = new ArrayList<>(); + if ("stop".startsWith(prefix)) { + suggestions.add("stop"); + } for (World world : Bukkit.getWorlds()) { String name = world.getName(); if (name.toLowerCase(Locale.ROOT).startsWith(prefix)) { @@ -115,9 +246,100 @@ public List onTabComplete( } } return suggestions; - } else if (args.length == 2) { - return List.of("32", "48", "64", "128"); + } else if (args.length >= 6) { + return List.of("8", "16", "32", "64", "128", "256", "preview"); } return Collections.emptyList(); } + + /** + * Cancels and forgets every running export. Invoked when the plugin disables. + */ + public void cancelAllExports() { + for (AsyncBiomeExportTask task : new ArrayList<>(runningExports.values())) { + task.cancelAndCleanup(); + } + runningExports.clear(); + } + + private boolean handleStopCommand(CommandSender sender, String[] args) { + if (args.length != 1) { + sendWarning(sender, "Usage: /biomemap stop"); + return true; + } + if (runningExports.isEmpty()) { + sendWarning(sender, "No biome export is currently running."); + return true; + } + for (AsyncBiomeExportTask task : new ArrayList<>(runningExports.values())) { + task.cancelAndCleanup(); + } + sendWarning(sender, "Stopped running export. Temporary output files removed."); + return true; + } + + private Integer parseInteger(String raw) { + try { + return Integer.parseInt(raw); + } catch (NumberFormatException ex) { + return null; + } + } + + private Integer parsePositiveInteger(String raw) { + Integer value = parseInteger(raw); + if (value == null || value <= 0) { + return null; + } + return value; + } + + private int alignCellSize(int requestedSize) { + if (requestedSize <= MIN_CELL_SIZE) { + return MIN_CELL_SIZE; + } + if (requestedSize < CHUNK_SIZE) { + return CHUNK_SIZE; + } + int remainder = requestedSize % CHUNK_SIZE; + if (remainder == 0) { + return requestedSize; + } + return requestedSize + (CHUNK_SIZE - remainder); + } + + private void notifyInfo(CommandSender sender, String senderMessage, String logMessage) { + if (isConsoleSender(sender)) { + sender.sendMessage(CHAT_PREFIX + senderMessage); + return; + } + sender.sendMessage(CHAT_PREFIX + senderMessage); + logger.info(logMessage); + } + + private boolean isConsoleSender(CommandSender sender) { + return sender instanceof ConsoleCommandSender || sender instanceof RemoteConsoleCommandSender; + } + + private void sendWarning(CommandSender sender, String message) { + sender.sendMessage(CHAT_PREFIX + "§6" + message); + } + + private void sendError(CommandSender sender, String message) { + sender.sendMessage(CHAT_PREFIX + "§c§lError: §c" + message); + } + + private String toLogPath(File file) { + Path absolute = file.toPath().toAbsolutePath().normalize(); + Path serverRoot = plugin.getServer().getWorldContainer().toPath().toAbsolutePath().normalize(); + Path rootParent = serverRoot.getParent(); + if (rootParent != null && absolute.startsWith(rootParent)) { + return rootParent.relativize(absolute).toString().replace('\\', '/'); + } + if (absolute.startsWith(serverRoot)) { + String relative = serverRoot.relativize(absolute).toString().replace('\\', '/'); + return serverRoot.getFileName() + "/" + relative; + } + return absolute.toString().replace('\\', '/'); + } } diff --git a/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java b/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java new file mode 100644 index 0000000..7431061 --- /dev/null +++ b/src/main/java/fr/sukikui/biomemap/export/AsyncBiomeExportTask.java @@ -0,0 +1,609 @@ +package fr.sukikui.biomemap.export; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import fr.sukikui.biomemap.export.BiomeExporter.BiomeCell; +import fr.sukikui.biomemap.export.BiomeExporter.BiomeMapExport; +import fr.sukikui.biomemap.export.BiomeExporter.Point; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.ChunkSnapshot; +import org.bukkit.World; +import org.bukkit.block.Biome; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.command.RemoteConsoleCommandSender; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitRunnable; + +/** + * Processes chunk-level biome sampling and aggregates results per cell. + */ +@SuppressFBWarnings("EI_EXPOSE_REP2") +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 final Plugin plugin; + private final BiomeExporter exporter; + private final ChunkSnapshotProvider snapshotProvider; + private final World world; + private final int cellSize; + private final int width; + private final int height; + private final int originX; + private final int originZ; + private final int selectionMinX; + private final int selectionMinZ; + private final int selectionMaxX; + private final int selectionMaxZ; + private final File outputFile; + private final boolean previewEnabled; + private final CommandSender sender; + private final Logger logger; + private final Runnable completionCallback; + private final BiomeCell[] cells; + private final boolean subChunkSampling; + private final int chunksPerCell; + private final int cellsPerChunk; + private final int chunkColumns; + private final int chunkRows; + private final int chunkStartX; + private final int chunkStartZ; + private final String[] chunkBiomes; + + private final int chunksPerTick; + private final int maxInFlight; + private final int completionBatchPerTick; + private final AtomicInteger nextChunkIndex = new AtomicInteger(0); + private final AtomicInteger inFlight = new AtomicInteger(0); + private final AtomicLong chunksCompleted = new AtomicLong(0); + private final long totalChunks; + private final int progressInterval; + + private final Queue completedChunks = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean aggregating = new AtomicBoolean(false); + private final AtomicBoolean finishing = new AtomicBoolean(false); + private final AtomicBoolean stopRequested = new AtomicBoolean(false); + private final AtomicBoolean completionNotified = new AtomicBoolean(false); + private final long startTimeMs = System.currentTimeMillis(); + private volatile File outputPreviewFile; + + /** + * Creates a new asynchronous export task that pipelines sampling work off the server thread. + */ + public AsyncBiomeExportTask( + Plugin plugin, + BiomeExporter exporter, + World world, + int cellSize, + int width, + int height, + int originX, + int originZ, + int selectionMinX, + int selectionMinZ, + int selectionMaxX, + int selectionMaxZ, + File outputFile, + boolean previewEnabled, + CommandSender sender, + Logger logger, + Runnable completionCallback, + int chunksPerTick, + int maxInFlight, + int maxConcurrentChunks) { + this.plugin = plugin; + this.exporter = exporter; + this.snapshotProvider = new ChunkSnapshotProvider(plugin, world, maxConcurrentChunks); + this.world = world; + this.cellSize = cellSize; + this.width = width; + this.height = height; + this.originX = originX; + this.originZ = originZ; + this.selectionMinX = selectionMinX; + this.selectionMinZ = selectionMinZ; + this.selectionMaxX = selectionMaxX; + this.selectionMaxZ = selectionMaxZ; + this.outputFile = outputFile; + this.previewEnabled = previewEnabled; + this.sender = sender; + this.logger = logger; + this.completionCallback = completionCallback; + this.cells = new BiomeCell[width * height]; + this.chunksPerTick = Math.max(1, chunksPerTick); + int desiredMaxInFlight = Math.max(this.chunksPerTick, maxInFlight); + this.maxInFlight = desiredMaxInFlight; + this.completionBatchPerTick = Math.max(1, this.chunksPerTick * 2); + this.subChunkSampling = cellSize < CHUNK_SIZE; + this.chunksPerCell = subChunkSampling ? 1 : Math.max(1, cellSize / CHUNK_SIZE); + this.cellsPerChunk = subChunkSampling ? Math.max(1, CHUNK_SIZE / cellSize) : 1; + if (subChunkSampling) { + this.chunkColumns = Math.max(1, (width + cellsPerChunk - 1) / cellsPerChunk); + this.chunkRows = Math.max(1, (height + cellsPerChunk - 1) / cellsPerChunk); + } else { + this.chunkColumns = width * chunksPerCell; + this.chunkRows = height * chunksPerCell; + } + this.chunkStartX = Math.floorDiv(originX, CHUNK_SIZE); + 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); + } + + /** + * Each tick, queue additional chunk samples while respecting the concurrency budget. + */ + @Override + public void run() { + drainCompletedChunks(); + if (stopRequested.get() || finishing.get() || aggregating.get() || isCancelled()) { + return; + } + + int scheduledThisTick = 0; + while (scheduledThisTick < chunksPerTick && inFlight.get() < maxInFlight) { + int next = nextChunkIndex.get(); + if (next >= chunkBiomes.length) { + break; + } + if (!snapshotProvider.tryReserveChunkPermit()) { + break; + } + int chunkIndex = nextChunkIndex.getAndIncrement(); + if (chunkIndex >= chunkBiomes.length) { + snapshotProvider.releaseChunkPermit(); + break; + } + scheduleChunk(chunkIndex); + scheduledThisTick++; + } + + if (nextChunkIndex.get() >= chunkBiomes.length && inFlight.get() == 0) { + if (subChunkSampling) { + finishExport(); + } else { + startAggregation(); + } + } + } + + /** + * Stops the export and removes any generated files. + */ + public void cancelAndCleanup() { + stopRequested.set(true); + cancel(); + cleanupOutputFiles(); + finishTask(); + } + + private void scheduleChunk(int chunkIndex) { + int row = chunkIndex / chunkColumns; + int col = chunkIndex % chunkColumns; + int chunkX = chunkStartX + col; + int chunkZ = chunkStartZ + row; + inFlight.incrementAndGet(); + snapshotProvider.snapshotAt(chunkX, chunkZ) + .thenApply(this::resolveChunkSample) + .whenComplete((sample, error) -> completedChunks.add( + new ChunkCompletion(chunkIndex, sample, chunkX, chunkZ, error))); + } + + private ChunkSample resolveChunkSample(ChunkSnapshot snapshot) { + if (snapshot == null) { + return new ChunkSample("minecraft:unknown", null); + } + if (subChunkSampling) { + String[] subCellBiomes = new String[cellsPerChunk * cellsPerChunk]; + int index = 0; + for (int cellRow = 0; cellRow < cellsPerChunk; cellRow++) { + int localMinZ = cellRow * cellSize; + for (int cellCol = 0; cellCol < cellsPerChunk; cellCol++) { + int localMinX = cellCol * cellSize; + subCellBiomes[index++] = resolveCellBiome(snapshot, localMinX, localMinZ); + } + } + return new ChunkSample(null, subCellBiomes); + } + int localX = 8; + int localZ = 8; + int highestY = snapshot.getHighestBlockYAt(localX, localZ); + Biome biome = snapshot.getBiome(localX, highestY, localZ); + return new ChunkSample(BiomeExporter.biomeKey(biome), null); + } + + private String resolveCellBiome(ChunkSnapshot snapshot, int localMinX, int localMinZ) { + Map counts = new HashMap<>(); + int[][] offsets = new int[][] { + {cellSize / 2, cellSize / 2}, + {0, 0}, + {cellSize - 1, 0}, + {0, cellSize - 1}, + {cellSize - 1, cellSize - 1}, + }; + for (int[] offset : offsets) { + int sampleX = localMinX + offset[0]; + int sampleZ = localMinZ + offset[1]; + int y = snapshot.getHighestBlockYAt(sampleX, sampleZ); + Biome biome = snapshot.getBiome(sampleX, y, sampleZ); + String biomeKey = BiomeExporter.biomeKey(biome); + counts.merge(biomeKey, 1, Integer::sum); + } + + return counts.entrySet().stream() + .max(Map.Entry.comparingByValue() + .thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey) + .orElse("minecraft:unknown"); + } + + private void fillCellsFromChunk(int chunkX, int chunkZ, String[] subCellBiomes) { + int baseCol = Math.floorDiv((chunkX * CHUNK_SIZE) - originX, cellSize); + int baseRow = Math.floorDiv((chunkZ * CHUNK_SIZE) - originZ, cellSize); + int index = 0; + for (int subRow = 0; subRow < cellsPerChunk; subRow++) { + int cellRow = baseRow + subRow; + for (int subCol = 0; subCol < cellsPerChunk; subCol++) { + int cellCol = baseCol + subCol; + String biomeId = subCellBiomes[index++]; + if (cellRow < 0 || cellRow >= height || cellCol < 0 || cellCol >= width) { + continue; + } + if (biomeId == null) { + biomeId = "minecraft:unknown"; + } + int cellIndex = (cellRow * width) + cellCol; + int cellMinX = originX + (cellCol * cellSize); + int cellMinZ = originZ + (cellRow * cellSize); + int cellMaxX = cellMinX + cellSize - 1; + int cellMaxZ = cellMinZ + cellSize - 1; + BiomeCell.Bounds bounds = + new BiomeCell.Bounds(new Point(cellMinX, cellMinZ), new Point(cellMaxX, cellMaxZ)); + cells[cellIndex] = new BiomeCell(cellCol, cellRow, bounds, biomeId); + } + } + } + + private void handleChunkCompletion(ChunkCompletion completion) { + snapshotProvider.releaseChunkPermit(); + final int remaining = inFlight.updateAndGet(value -> Math.max(0, value - 1)); + if (stopRequested.get()) { + return; + } + + ChunkSample sample = completion.sample; + if (subChunkSampling) { + String[] subCellBiomes = sample != null ? sample.subCellBiomes() : null; + if (completion.error != null || subCellBiomes == null) { + logger.log(Level.WARNING, + String.format("Failed to resolve chunk biomes at (%d,%d).", + completion.chunkX, completion.chunkZ), + completion.error); + subCellBiomes = new String[cellsPerChunk * cellsPerChunk]; + Arrays.fill(subCellBiomes, "minecraft:unknown"); + } + fillCellsFromChunk(completion.chunkX, completion.chunkZ, subCellBiomes); + } else { + String biomeId = sample != null ? sample.biome() : null; + if (completion.error != null || biomeId == null) { + logger.log(Level.WARNING, + String.format("Failed to resolve chunk biome at (%d,%d).", + completion.chunkX, completion.chunkZ), + completion.error); + biomeId = "minecraft:unknown"; + } + chunkBiomes[completion.chunkIndex] = biomeId; + } + long completed = chunksCompleted.incrementAndGet(); + + if (completed % progressInterval == 0 || completed == totalChunks) { + reportProgress(completed); + } + + if (nextChunkIndex.get() >= chunkBiomes.length && remaining == 0) { + if (subChunkSampling) { + finishExport(); + } else { + startAggregation(); + } + } + } + + 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)); + if (!isConsoleSender()) { + String plainLine = String.format( + Locale.ROOT, "Progress %.1f%% (%d/%d chunks)", percent, completedChunks, totalChunks); + logger.info(plainLine); + } + } + + private void startAggregation() { + if (stopRequested.get()) { + finishTask(); + return; + } + if (!aggregating.compareAndSet(false, true)) { + return; + } + CompletableFuture + .runAsync(this::buildCellsFromChunks) + .whenComplete((ignored, error) -> Bukkit.getScheduler().runTask( + plugin, + () -> handleAggregationResult(error))); + } + + private void buildCellsFromChunks() { + Map counts = new HashMap<>(); + for (int cellIndex = 0; cellIndex < cells.length; cellIndex++) { + if (stopRequested.get()) { + return; + } + counts.clear(); + int cellRow = cellIndex / width; + int cellCol = cellIndex % width; + int chunkBaseRow = cellRow * chunksPerCell; + int chunkBaseCol = cellCol * chunksPerCell; + for (int dz = 0; dz < chunksPerCell; dz++) { + int rowStart = (chunkBaseRow + dz) * chunkColumns; + for (int dx = 0; dx < chunksPerCell; dx++) { + int idx = rowStart + chunkBaseCol + dx; + String biomeId = idx >= 0 && idx < chunkBiomes.length ? chunkBiomes[idx] : null; + if (biomeId == null) { + biomeId = "minecraft:unknown"; + } + counts.merge(biomeId, 1, Integer::sum); + } + } + String dominant = counts.entrySet().stream() + .max(Map.Entry.comparingByValue() + .thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey) + .orElse("minecraft:unknown"); + + int cellMinX = originX + (cellCol * cellSize); + int cellMinZ = originZ + (cellRow * cellSize); + int cellMaxX = cellMinX + cellSize - 1; + int cellMaxZ = cellMinZ + cellSize - 1; + BiomeCell.Bounds bounds = + new BiomeCell.Bounds(new Point(cellMinX, cellMinZ), new Point(cellMaxX, cellMaxZ)); + cells[cellIndex] = new BiomeCell(cellCol, cellRow, bounds, dominant); + } + } + + private void ensureCellsFilled() { + for (int cellIndex = 0; cellIndex < cells.length; cellIndex++) { + if (cells[cellIndex] != null) { + continue; + } + int cellRow = cellIndex / width; + int cellCol = cellIndex % width; + int cellMinX = originX + (cellCol * cellSize); + int cellMinZ = originZ + (cellRow * cellSize); + int cellMaxX = cellMinX + cellSize - 1; + int cellMaxZ = cellMinZ + cellSize - 1; + BiomeCell.Bounds bounds = + new BiomeCell.Bounds(new Point(cellMinX, cellMinZ), new Point(cellMaxX, cellMaxZ)); + cells[cellIndex] = new BiomeCell(cellCol, cellRow, bounds, "minecraft:unknown"); + } + } + + private void handleAggregationResult(Throwable error) { + if (stopRequested.get()) { + cleanupOutputFiles(); + finishTask(); + return; + } + if (error != null) { + logger.log(Level.SEVERE, "Failed to aggregate cells from chunk data", error); + sendError("Failed to aggregate cell biomes: " + error.getMessage()); + cancel(); + finishTask(); + return; + } + finishExport(); + } + + private void finishExport() { + if (stopRequested.get()) { + cleanupOutputFiles(); + finishTask(); + return; + } + if (!finishing.compareAndSet(false, true)) { + return; + } + cancel(); + if (subChunkSampling) { + ensureCellsFilled(); + } + BiomeMapExport export = + new BiomeMapExport( + cellSize, + new Point(selectionMinX, selectionMinZ), + new Point(selectionMaxX, selectionMaxZ), + new Point(originX, originZ), + width, + height, + Arrays.asList(cells)); + outputPreviewFile = previewEnabled ? exporter.resolvePreviewOutput(outputFile) : null; + long requestedChunks = snapshotProvider.getRequestedSnapshots(); + CompletableFuture + .runAsync(() -> { + try { + exporter.writeExport(export, outputFile); + if (outputPreviewFile != null) { + exporter.writePreview(export, this.outputPreviewFile); + } + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }) + .whenComplete((ignored, error) -> Bukkit.getScheduler().runTask( + plugin, + () -> handleExportWriteResult(error, requestedChunks, this.outputPreviewFile))); + } + + private void handleExportWriteResult( + Throwable error, long requestedChunks, File outputPreviewFile) { + try { + if (stopRequested.get()) { + cleanupOutputFiles(); + return; + } + long durationMs = System.currentTimeMillis() - startTimeMs; + if (error == null) { + double durationSeconds = durationMs / 1000.0; + String jsonPath = toLogPath(outputFile); + String pngPath = outputPreviewFile == null ? null : toLogPath(outputPreviewFile); + String summary = String.format( + Locale.ROOT, + "Export complete: world=%s cells=%d chunks=%d requested=%d duration=%.2fs " + + "json=%s%s", + world.getName(), + cells.length, + totalChunks, + requestedChunks, + durationSeconds, + jsonPath, + pngPath == null ? "" : " png=" + pngPath); + if (isConsoleSender()) { + sendSuccess(String.format( + Locale.ROOT, + "Export complete in %.2fs (%d cells).", + durationSeconds, + cells.length)); + sendInfo("JSON: §f" + jsonPath); + if (pngPath != null) { + sendInfo("PNG: §f" + pngPath); + } + } else { + logger.info(summary); + sendSuccess(String.format( + Locale.ROOT, + "Export complete in %.2fs (%d cells).", + durationSeconds, + cells.length)); + sendInfo("JSON: §f" + jsonPath); + if (pngPath != null) { + sendInfo("PNG: §f" + pngPath); + } + } + } else { + Throwable root = error instanceof RuntimeException && error.getCause() != null + ? error.getCause() + : error; + cleanupOutputFiles(); + logger.log(Level.SEVERE, "Failed to write biome export", root); + sendError("Failed to write biome export: " + root.getMessage()); + } + } finally { + finishTask(); + } + } + + private void drainCompletedChunks() { + int processed = 0; + ChunkCompletion completion; + while (processed < completionBatchPerTick + && (completion = completedChunks.poll()) != null) { + handleChunkCompletion(completion); + processed++; + } + } + + private record ChunkSample(String biome, String[] subCellBiomes) { + } + + private record ChunkCompletion( + int chunkIndex, ChunkSample sample, int chunkX, int chunkZ, Throwable error) { + } + + private void sendInfo(String message) { + sender.sendMessage(CHAT_PREFIX + "§7" + message); + } + + private void sendSuccess(String message) { + sender.sendMessage(CHAT_PREFIX + "§a§l" + message); + } + + private void sendError(String message) { + sender.sendMessage(CHAT_PREFIX + "§c§lError: §c" + message); + } + + private boolean isConsoleSender() { + return sender instanceof ConsoleCommandSender || sender instanceof RemoteConsoleCommandSender; + } + + private String toLogPath(File file) { + Path absolute = file.toPath().toAbsolutePath().normalize(); + Path serverRoot = plugin.getServer().getWorldContainer().toPath().toAbsolutePath().normalize(); + Path rootParent = serverRoot.getParent(); + if (rootParent != null && absolute.startsWith(rootParent)) { + return rootParent.relativize(absolute).toString().replace('\\', '/'); + } + if (absolute.startsWith(serverRoot)) { + String relative = serverRoot.relativize(absolute).toString().replace('\\', '/'); + return serverRoot.getFileName() + "/" + relative; + } + return absolute.toString().replace('\\', '/'); + } + + private void finishTask() { + snapshotProvider.reset(); + if (completionNotified.compareAndSet(false, true)) { + completionCallback.run(); + } + } + + private void cleanupOutputFiles() { + deleteIfExists(outputFile); + if (!previewEnabled) { + return; + } + File previewFile = outputPreviewFile; + if (previewFile == null) { + try { + previewFile = exporter.resolvePreviewOutput(outputFile); + } catch (IllegalArgumentException ex) { + logger.log(Level.FINE, "Unable to resolve preview path during cancellation", ex); + } + } + deleteIfExists(previewFile); + } + + private void deleteIfExists(File file) { + if (file == null) { + return; + } + try { + Files.deleteIfExists(file.toPath()); + } catch (IOException ex) { + logger.log(Level.FINE, "Unable to delete cancelled export file " + file, ex); + } + } +} diff --git a/src/main/java/fr/sukikui/biomemap/export/BiomeColorPalette.java b/src/main/java/fr/sukikui/biomemap/export/BiomeColorPalette.java new file mode 100644 index 0000000..37c8171 --- /dev/null +++ b/src/main/java/fr/sukikui/biomemap/export/BiomeColorPalette.java @@ -0,0 +1,112 @@ +package fr.sukikui.biomemap.export; + +import java.awt.Color; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Color palette for vanilla biome ids (Minecraft 1.21.11) used by preview rendering. + */ +public final class BiomeColorPalette { + + private static final Color UNKNOWN_COLOR = new Color(255, 0, 255); + private static final Map COLORS = new HashMap<>(); + + static { + register("minecraft:badlands", 216, 118, 73); + register("minecraft:bamboo_jungle", 72, 122, 54); + register("minecraft:basalt_deltas", 80, 73, 78); + register("minecraft:beach", 250, 240, 192); + register("minecraft:birch_forest", 120, 162, 83); + register("minecraft:cherry_grove", 242, 176, 196); + register("minecraft:cold_ocean", 61, 87, 214); + register("minecraft:crimson_forest", 146, 63, 95); + register("minecraft:dark_forest", 65, 95, 55); + register("minecraft:deep_cold_ocean", 49, 68, 171); + register("minecraft:deep_dark", 35, 44, 53); + register("minecraft:deep_frozen_ocean", 51, 62, 131); + register("minecraft:deep_lukewarm_ocean", 57, 89, 155); + register("minecraft:deep_ocean", 46, 73, 141); + register("minecraft:deep_warm_ocean", 53, 137, 173); + register("minecraft:desert", 250, 148, 24); + register("minecraft:dripstone_caves", 137, 126, 115); + register("minecraft:end_barrens", 163, 163, 100); + register("minecraft:end_highlands", 181, 181, 105); + register("minecraft:end_midlands", 172, 172, 101); + register("minecraft:eroded_badlands", 255, 109, 76); + register("minecraft:flower_forest", 134, 190, 108); + register("minecraft:forest", 92, 140, 57); + register("minecraft:frozen_ocean", 137, 177, 255); + register("minecraft:frozen_peaks", 197, 211, 218); + register("minecraft:frozen_river", 167, 188, 255); + register("minecraft:grove", 161, 183, 150); + register("minecraft:ice_spikes", 181, 200, 201); + register("minecraft:jagged_peaks", 149, 163, 166); + register("minecraft:jungle", 83, 123, 50); + register("minecraft:lukewarm_ocean", 69, 118, 196); + register("minecraft:lush_caves", 79, 153, 89); + register("minecraft:mangrove_swamp", 81, 111, 53); + register("minecraft:meadow", 145, 181, 114); + register("minecraft:mushroom_fields", 141, 88, 139); + register("minecraft:nether_wastes", 87, 37, 38); + register("minecraft:ocean", 48, 80, 180); + register("minecraft:old_growth_birch_forest", 99, 143, 81); + register("minecraft:old_growth_pine_taiga", 89, 122, 84); + register("minecraft:old_growth_spruce_taiga", 87, 112, 78); + register("minecraft:pale_garden", 174, 181, 163); + register("minecraft:plains", 141, 179, 96); + register("minecraft:river", 86, 120, 220); + register("minecraft:savanna", 189, 178, 95); + register("minecraft:savanna_plateau", 167, 157, 100); + register("minecraft:small_end_islands", 160, 160, 102); + register("minecraft:snowy_beach", 243, 249, 255); + register("minecraft:snowy_plains", 247, 254, 255); + register("minecraft:snowy_slopes", 228, 239, 245); + register("minecraft:snowy_taiga", 167, 197, 167); + register("minecraft:soul_sand_valley", 84, 74, 63); + register("minecraft:sparse_jungle", 100, 138, 71); + register("minecraft:stony_peaks", 149, 154, 160); + register("minecraft:stony_shore", 156, 156, 156); + register("minecraft:sunflower_plains", 169, 200, 102); + register("minecraft:swamp", 107, 142, 57); + register("minecraft:taiga", 90, 125, 81); + register("minecraft:the_end", 177, 171, 112); + register("minecraft:the_void", 0, 0, 0); + register("minecraft:warm_ocean", 67, 182, 219); + register("minecraft:warped_forest", 67, 143, 145); + register("minecraft:windswept_forest", 103, 121, 94); + register("minecraft:windswept_gravelly_hills", 136, 136, 136); + register("minecraft:windswept_hills", 117, 130, 103); + register("minecraft:windswept_savanna", 181, 167, 111); + register("minecraft:wooded_badlands", 176, 108, 79); + register("minecraft:unknown", 255, 0, 255); + } + + private BiomeColorPalette() { + } + + /** + * Returns a stable color for the biome id. + */ + public static Color colorFor(String biomeId) { + if (biomeId == null || biomeId.isBlank()) { + return UNKNOWN_COLOR; + } + String key = biomeId.toLowerCase(Locale.ROOT); + Color known = COLORS.get(key); + return known != null ? known : fallbackColor(key); + } + + private static void register(String biomeId, int red, int green, int blue) { + COLORS.put(biomeId, new Color(red, green, blue)); + } + + private static Color fallbackColor(String biomeId) { + int hash = biomeId.hashCode(); + int red = 70 + (hash & 0x7F); + int green = 70 + ((hash >> 8) & 0x7F); + int blue = 70 + ((hash >> 16) & 0x7F); + return new Color(red, green, blue); + } +} diff --git a/src/main/java/fr/sukikui/biomemap/export/BiomeExporter.java b/src/main/java/fr/sukikui/biomemap/export/BiomeExporter.java index 8fef5db..f4d5bd4 100644 --- a/src/main/java/fr/sukikui/biomemap/export/BiomeExporter.java +++ b/src/main/java/fr/sukikui/biomemap/export/BiomeExporter.java @@ -2,18 +2,18 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.File; import java.io.IOException; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.util.ArrayList; +import java.nio.file.Path; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import org.bukkit.Location; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.block.Biome; @@ -23,60 +23,63 @@ */ public final class BiomeExporter { - private static final String EXPORT_RELATIVE_PATH = "data/biome-map.json"; + private static final String EXPORTS_FOLDER = "exports"; private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); private final File pluginFolder; - private final int scanRadius; + private final BiomePreviewRenderer previewRenderer = new BiomePreviewRenderer(); /** * Creates a new exporter tied to the plugin data folder. * * @param pluginFolder location where exports will be saved - * @param scanRadius radius (in blocks) scanned around the spawn */ - public BiomeExporter(File pluginFolder, int scanRadius) { + public BiomeExporter(File pluginFolder) { this.pluginFolder = pluginFolder; - this.scanRadius = scanRadius; } /** - * Exports the dominant biome grid for the given world. - * - * @param world Paper world to sample - * @param cellSize width/height of each sampled cell - * @return metadata about the export output - * @throws IOException if the JSON file cannot be written + * Resolves an output path using world + cell size + incremented index. */ - public ExportResult exportWorld(World world, int cellSize) throws IOException { - long start = System.currentTimeMillis(); - Location spawn = world.getSpawnLocation(); - int originX = spawn.getBlockX() - scanRadius; - int originZ = spawn.getBlockZ() - scanRadius; - int diameter = scanRadius * 2; - int width = Math.max(1, diameter / cellSize); - int height = width; - - List cells = new ArrayList<>(width * height); - for (int j = 0; j < height; j++) { - int cellOriginZ = originZ + (j * cellSize); - for (int i = 0; i < width; i++) { - int cellOriginX = originX + (i * cellSize); - String dominantBiome = determineDominantBiome(world, cellOriginX, cellOriginZ, cellSize); - cells.add(new BiomeCell(i, j, dominantBiome)); + public File resolveSelectionOutput(String worldName, int cellSize) { + File exportsDir = new File(pluginFolder, EXPORTS_FOLDER); + String baseName = String.format( + Locale.ROOT, + "%s_%d", + sanitizeComponent(worldName), + cellSize); + Path basePath = exportsDir.toPath().toAbsolutePath().normalize(); + for (int index = 1; index < Integer.MAX_VALUE; index++) { + String filename = String.format(Locale.ROOT, "%s_%d.json", baseName, index); + Path candidate = basePath.resolve(filename).normalize(); + if (!candidate.startsWith(basePath)) { + throw new IllegalArgumentException("Selection output escaped exports directory"); + } + if (!candidate.toFile().exists()) { + return candidate.toFile(); } } + throw new IllegalStateException("Unable to resolve available output filename"); + } - BiomeMapExport export = - new BiomeMapExport(cellSize, new Origin(originX, originZ), width, height, cells); - File outputFile = new File(pluginFolder, EXPORT_RELATIVE_PATH); - writeExport(export, outputFile); - long duration = System.currentTimeMillis() - start; - return new ExportResult(cells.size(), outputFile, duration); + /** + * Samples the biome for a single cell. + */ + public BiomeCell sampleCell( + World world, int cellSize, int cellMinX, int cellMinZ, int i, int j) { + int cellMaxX = cellMinX + cellSize - 1; + int cellMaxZ = cellMinZ + cellSize - 1; + String dominantBiome = determineDominantBiome(world, cellMinX, cellMinZ, cellSize); + BiomeCell.Bounds bounds = + new BiomeCell.Bounds(new Point(cellMinX, cellMinZ), new Point(cellMaxX, cellMaxZ)); + return new BiomeCell(i, j, bounds, dominantBiome); } - private void writeExport(BiomeMapExport export, File outputFile) throws IOException { + /** + * Writes the export to disk. + */ + public void writeExport(BiomeMapExport export, File outputFile) throws IOException { File parent = outputFile.getParentFile(); if (parent != null && !parent.exists() && !parent.mkdirs()) { throw new IOException("Unable to create directory " + parent); @@ -87,7 +90,42 @@ private void writeExport(BiomeMapExport export, File outputFile) throws IOExcept } } - private String determineDominantBiome( + /** + * Resolves the preview PNG path matching the provided JSON filename. + */ + public File resolvePreviewOutput(File jsonOutputFile) { + Path jsonPath = jsonOutputFile.toPath().toAbsolutePath().normalize(); + Path parent = jsonPath.getParent(); + if (parent == null) { + throw new IllegalArgumentException("JSON output path has no parent directory"); + } + + Path fileName = jsonPath.getFileName(); + if (fileName == null) { + throw new IllegalArgumentException("JSON output path has no filename"); + } + String name = fileName.toString(); + int extensionIndex = name.lastIndexOf('.'); + String baseName = extensionIndex > 0 ? name.substring(0, extensionIndex) : name; + String safeBaseName = sanitizeComponent(baseName); + Path previewPath = parent.resolve(safeBaseName + ".png").normalize(); + if (!previewPath.startsWith(parent)) { + throw new IllegalArgumentException("Preview output escaped target directory"); + } + return previewPath.toFile(); + } + + /** + * Writes a PNG preview where each pixel represents one exported cell. + */ + public void writePreview(BiomeMapExport export, File outputFile) throws IOException { + previewRenderer.writePreview(export, outputFile); + } + + /** + * Determines the dominant biome for a cell by sampling five points. + */ + public String determineDominantBiome( World world, int cellOriginX, int cellOriginZ, int cellSize) { Map counts = new HashMap<>(); int[][] offsets = new int[][] { @@ -114,25 +152,48 @@ private String determineDominantBiome( .orElse("minecraft:unknown"); } - private String biomeKey(Biome biome) { + /** + * Formats a biome object into its minecraft:namespace identifier, falling back to unknown. + */ + public static String biomeKey(Biome biome) { if (biome == null) { return "minecraft:unknown"; } NamespacedKey key = biome.getKey(); - return key.asString(); + return key.getNamespace() + ":" + key.getKey(); } - /** Describes the result of a biome export run. */ - public record ExportResult(int cellCount, File outputFile, long durationMs) { + private static String sanitizeComponent(String raw) { + if (raw == null || raw.isBlank()) { + return "unknown"; + } + String lower = raw.toLowerCase(Locale.ROOT); + String sanitized = lower.replaceAll("[^a-z0-9_-]", "_"); + sanitized = sanitized.replaceAll("_+", "_"); + return sanitized.isBlank() ? "unknown" : sanitized; } - private record BiomeCell(int i, int j, String biome) { + /** Simple DTO describing a cell coordinate and its biome id. */ + public record BiomeCell(int i, int j, Bounds bounds, String biome) { + + /** Bounding box of the cell in world coordinates. */ + public record Bounds(Point min, Point max) { + } } - private record BiomeMapExport(int cellSize, Origin origin, int width, int height, + /** Bundles metadata and sampled cells for JSON export. */ + @SuppressFBWarnings("EI_EXPOSE_REP") + public record BiomeMapExport( + int cellSize, + Point selectionMin, + Point selectionMax, + Point gridOrigin, + int width, + int height, List cells) { } - private record Origin(int x, int z) { + /** Simple x/z coordinate pair. */ + public record Point(int x, int z) { } } diff --git a/src/main/java/fr/sukikui/biomemap/export/BiomePreviewRenderer.java b/src/main/java/fr/sukikui/biomemap/export/BiomePreviewRenderer.java new file mode 100644 index 0000000..afd28fd --- /dev/null +++ b/src/main/java/fr/sukikui/biomemap/export/BiomePreviewRenderer.java @@ -0,0 +1,57 @@ +package fr.sukikui.biomemap.export; + +import fr.sukikui.biomemap.export.BiomeExporter.BiomeCell; +import fr.sukikui.biomemap.export.BiomeExporter.BiomeMapExport; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import javax.imageio.ImageIO; + +/** + * Renders a 1px-per-cell biome preview image. + */ +public final class BiomePreviewRenderer { + + /** + * Writes the preview file as a PNG. + */ + public void writePreview(BiomeMapExport export, File outputFile) throws IOException { + if (export.width() <= 0 || export.height() <= 0) { + throw new IOException("Unable to render preview for an empty export"); + } + + BufferedImage image = + new BufferedImage(export.width(), export.height(), BufferedImage.TYPE_INT_RGB); + int unknownRgb = BiomeColorPalette.colorFor("minecraft:unknown").getRGB(); + fillBackground(image, unknownRgb); + + for (BiomeCell cell : export.cells()) { + if (cell == null) { + continue; + } + int pixelX = cell.i(); + int pixelY = cell.j(); + if (pixelX < 0 || pixelX >= export.width() || pixelY < 0 || pixelY >= export.height()) { + continue; + } + int rgb = BiomeColorPalette.colorFor(cell.biome()).getRGB(); + image.setRGB(pixelX, pixelY, rgb); + } + + File parent = outputFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Unable to create directory " + parent); + } + if (!ImageIO.write(image, "png", outputFile)) { + throw new IOException("No PNG image writer available"); + } + } + + private void fillBackground(BufferedImage image, int rgb) { + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + image.setRGB(x, y, rgb); + } + } + } +} diff --git a/src/main/java/fr/sukikui/biomemap/export/ChunkSnapshotProvider.java b/src/main/java/fr/sukikui/biomemap/export/ChunkSnapshotProvider.java new file mode 100644 index 0000000..915f874 --- /dev/null +++ b/src/main/java/fr/sukikui/biomemap/export/ChunkSnapshotProvider.java @@ -0,0 +1,114 @@ +package fr.sukikui.biomemap.export; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.papermc.lib.PaperLib; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.ChunkSnapshot; +import org.bukkit.World; +import org.bukkit.plugin.Plugin; + +/** + * Throttles asynchronous chunk snapshot requests to avoid overwhelming the server. + */ +@SuppressFBWarnings("EI_EXPOSE_REP2") +public final class ChunkSnapshotProvider { + + private final Plugin plugin; + private final World world; + private final AtomicLong chunkRequests = new AtomicLong(); + private final int maxConcurrentChunks; + private final AtomicInteger availableChunkPermits; + + /** + * Creates a new snapshot provider bound to a world. + * + * @param plugin owning plugin (used for scheduling main-thread work) + * @param world world to load chunks from + * @param maxConcurrentChunks max number of simultaneous chunk loads + */ + public ChunkSnapshotProvider(Plugin plugin, World world, int maxConcurrentChunks) { + this.plugin = plugin; + this.world = world; + this.maxConcurrentChunks = Math.max(1, maxConcurrentChunks); + this.availableChunkPermits = new AtomicInteger(this.maxConcurrentChunks); + } + + /** + * Returns a snapshot future for the requested chunk coordinates. + */ + public CompletableFuture snapshotAt(int chunkX, int chunkZ) { + return loadSnapshot(chunkX, chunkZ); + } + + /** + * Number of snapshot requests that have been queued. + */ + public long getRequestedSnapshots() { + return chunkRequests.get(); + } + + /** + * Clears cached futures. Pending loads will still complete, but future lookups will reload. + */ + public void reset() { + availableChunkPermits.set(maxConcurrentChunks); + chunkRequests.set(0); + } + + /** + * Attempts to reserve a chunk load permit. Returns false if exhausted. + */ + public boolean tryReserveChunkPermit() { + while (true) { + int current = availableChunkPermits.get(); + if (current <= 0) { + return false; + } + if (availableChunkPermits.compareAndSet(current, current - 1)) { + return true; + } + } + } + + /** + * Releases a previously reserved chunk permit. + */ + public void releaseChunkPermit() { + int updated = availableChunkPermits.incrementAndGet(); + if (updated > maxConcurrentChunks) { + availableChunkPermits.set(maxConcurrentChunks); + } + } + + private CompletableFuture loadSnapshot(int chunkX, int chunkZ) { + chunkRequests.incrementAndGet(); + CompletableFuture result = new CompletableFuture<>(); + PaperLib.getChunkAtAsync(world, chunkX, chunkZ, true) + .whenComplete((chunk, throwable) -> { + if (throwable != null) { + result.completeExceptionally(throwable); + return; + } + Chunk loadedChunk = chunk; + if (loadedChunk == null) { + result.completeExceptionally( + new IllegalStateException("Chunk load returned null for " + chunkX + "," + chunkZ)); + return; + } + Bukkit.getScheduler().runTask( + plugin, + () -> { + try { + result.complete(loadedChunk.getChunkSnapshot(true, true, false)); + } catch (Throwable snapshotError) { + result.completeExceptionally(snapshotError); + } + }); + }); + return result; + } +} From 1efa99ba2f145ea056cfff6bebae6bcccfe5d6bb Mon Sep 17 00:00:00 2001 From: Sukikui Date: Wed, 11 Feb 2026 16:39:22 +0100 Subject: [PATCH 2/4] plugin: add the `stop` option --- src/main/resources/config.yml | 8 +++++++- src/main/resources/plugin.yml | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 6f98179..99e4453 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1 +1,7 @@ -# A journey of 1,000 lines starts with a single char \ No newline at end of file +performance: + # Maximum number of chunks scheduled per tick when exporting (>=1). + chunks-per-tick: 1 + # Number of chunks that may be sampled concurrently (must be >= chunks-per-tick). + max-in-flight: 4 + # Global server-side cap for chunks being loaded (IO throttle). + max-concurrent-chunks: 64 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 158202d..2a41c6c 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 world biomes to JSON. - usage: "/biomemap [world] [cellSize]" + description: Export dominant biomes to JSON/PNG and stop the current export. + usage: "/biomemap [cellSize] [preview] | /biomemap stop" From 30413ad7635d818cb5ea64c63eefb2416990167c Mon Sep 17 00:00:00 2001 From: Sukikui Date: Wed, 11 Feb 2026 16:39:36 +0100 Subject: [PATCH 3/4] docs: update README --- README.md | 125 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 23822e7..fa5543c 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,31 @@ # BiomeMap -BiomeMap generates a lightweight JSON file mapping each world region to its dominant biome. Ideal for creating stylized biome maps on external web apps. +Generates a lightweight JSON file mapping each world region to its dominant biome. Ideal for creating stylized biome maps on external web apps. ## 📋 Overview -BiomeMap scans a configurable grid (default ±5000 blocks around spawn, sampled in 32×32 cells) and writes the dominant -biome for each cell to `plugins/BiomeMap/data/biome-map.json`. -This JSON is meant for external dashboards or stylized maps—no world edits, -no database, just a clean file your frontend can colorize. +BiomeMap exports the dominant biome of a rectangular selection. + +You choose a world and 2 corners (`x/z`), and the plugin builds a grid of cells over that area. +Then it samples biomes and writes: +- a JSON file +- optionally a PNG preview (`1 pixel = 1 cell`) + +It is read-only: no world edits, no database, no extra services. ## ✨ Features -- Samples center + corners of every cell to smooth biome transitions -- Handles `/biomemap [world] [cellSize]` with tab completion -- Pretty-printed JSON with origin, dimensions, and biome ids (`minecraft:plains`, etc.) -- Logs progress to console and in-game so you can monitor long exports -- Read-only: the world is never modified +- Rectangular selection defined by two coordinates (`/biomemap world x1 z1 x2 z2 [cellSize] [preview]`) +- Cell size with chunk-friendly alignment (`8`, `16`, `32`, ...) +- Chunk-based sampling to smooth biome transitions +- Asynchronous processing with frequent progress updates (no server freeze) +- One export at a time (global lock) +- Stop command to cancel running exports cleanly +- Structured JSON output with min/max bounds per cell and namespaced biome IDs +- Optional PNG preview output (`1 pixel = 1 cell`) using a biome RGB palette ## 🚀 Installation @@ -30,38 +37,98 @@ no database, just a clean file your frontend can colorize. ## 🕹 Command Usage -| Command | Arguments | Description | -|--------------------------------|----------------------------------------------------------|------------------------------------------------------------------| -| `/biomemap [world] [cellSize]` | `world` defaults to `world`, `cellSize` defaults to `32` | Generates/overwrites `data/biome-map.json` for the target world. | +| Command | Arguments | Description | +| --- | --- | --- | +| `/biomemap [cellSize] [preview]` | `world` required, `cellSize` default `16`, `preview` optional | Exports the full rectangle between the 2 points. If `preview` is present, also writes a PNG. | +| `/biomemap stop` | none | Stops the current export. | + +Notes: +- Good `cellSize` values: `8`, `16`, `32`, `64`, ... +- `preview` can be written as `preview` or `--preview` +- If an export is stopped, files for that run are deleted from `exports/` + +Example: +``` +/biomemap world -512 -512 320 192 32 +``` +→ covers the area between `(-512,-512)` and `(320,192)` using 32×32-block cells (chunk-aligned). -Typical console output: +Preview example: ``` -[BiomeMap] Exporting biomes for world 'world' (cellSize=32)... -[BiomeMap] Export complete: 97,344 cells saved to biome-map.json +/biomemap world -512 -512 320 192 32 preview ``` +→ same JSON export + PNG preview with one pixel per cell. + +### 📁 Output files + +Exports are written to `plugins/BiomeMap/exports/`. + +JSON files use: +- `__.json` + +If the filename already exists, `index` is incremented (`world_32_1.json`, `world_32_2.json`, ...). + +If `preview` is enabled, the plugin also writes PNG files with the same base name as each JSON file: +- `plugins/BiomeMap/exports/__.png` + +Biome colors come from `src/main/java/fr/sukikui/biomemap/export/BiomeColorPalette.java` (vanilla 1.21.11 palette with deterministic fallback for unknown biome ids). + +### ⚙️ Configuration + +`config.yml` exposes performance throttles. + +| Key | Default | Description | +| --- | --- | --- | +| `performance.chunks-per-tick` | `1` | How many new chunk jobs are started each tick. Lower = safer, slower. | +| `performance.max-in-flight` | `4` | Max number of BiomeMap jobs currently in pipeline (queued/running). | +| `performance.max-concurrent-chunks` | `64` | Max real chunk loads at the same time (main server pressure knob). | + +Quick tuning guide: +- If players feel lag, lower `max-concurrent-chunks` first. +- If export feels too slow but server is stable, increase `chunks-per-tick` a bit. +- Keep `max-in-flight >= chunks-per-tick`. ## 🗺 JSON Format ```json { - "cellSize": 32, - "origin": { "x": -5000, "z": -5000 }, - "width": 312, - "height": 312, + "cellSize": 16, + "selectionMin": { "x": -200, "z": -200 }, + "selectionMax": { "x": -50, "z": -20 }, + "gridOrigin": { "x": -208, "z": -208 }, + "width": 10, + "height": 12, "cells": [ - { "i": 0, "j": 0, "biome": "minecraft:plains" }, - { "i": 1, "j": 0, "biome": "minecraft:forest" } + { + "i": 0, + "j": 0, + "bounds": { + "min": { "x": -208, "z": -208 }, + "max": { "x": -193, "z": -193 } + }, + "biome": "minecraft:plains" + }, + { + "i": 1, + "j": 0, + "bounds": { + "min": { "x": -192, "z": -208 }, + "max": { "x": -177, "z": -193 } + }, + "biome": "minecraft:forest" + } ] } ``` -| Field | Type | Description | -|-------------------------|----------|-----------------------------------------------------------| -| `cellSize` | `number` | Width/height of each grid cell in blocks. | -| `origin.x`,`origin.z` | `number` | Southwest corner of the scanned square (spawn − radius). | -| `width`,`height` | `number` | Number of cells sampled on each axis. | -| `cells[].i`,`cells[].j` | `number` | Grid indices; world coords = `origin + index * cellSize`. | -| `cells[].biome` | `string` | Namespaced biome id returned by Paper. | +| Field | Type | Description | +| --- | --- | --- | +| `cellSize` | `number` | Cell size in blocks (minimum 8; values above that are aligned to the chunk grid). | +| `selectionMin/Max` | `object` | Raw coordinates provided in the command. | +| `gridOrigin` | `object` | North-west corner of the grid (min X, min Z). | +| `width`, `height` | `number` | Number of cells on the X and Z axes. | +| `cells[].bounds.min/max` | `object` | Inclusive bounds delimiting the cell. | +| `cells[].biome` | `string` | Namespaced biome ID (e.g. `minecraft:savanna`). | --- From e0c5c3733e77346741f5761bd9077eea35c6895d Mon Sep 17 00:00:00 2001 From: Sukikui Date: Wed, 11 Feb 2026 16:39:47 +0100 Subject: [PATCH 4/4] license: update date --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index f733eb4..0868da5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Sukikui +Copyright (c) 2026 Sukikui Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal