diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index 6308274..f46f0fb 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -20,8 +20,10 @@ import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; import eu.mhsl.minecraft.pixelpics.survival.CameraListener; import eu.mhsl.minecraft.pixelpics.survival.CraftingListener; import eu.mhsl.minecraft.pixelpics.survival.JoinListener; +import eu.mhsl.minecraft.pixelpics.survival.PendingPhotos; import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes; import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; +import eu.mhsl.minecraft.pixelpics.utils.MapManager; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; @@ -41,6 +43,7 @@ public final class Main extends JavaPlugin { private ResourcePack resourcePack; private DefaultScreenRenderer screenRenderer; private RenderManager renderManager; + private PendingPhotos pendingPhotos; // PDC keys: photo picture id (STRING), camera/film markers (BYTE), loaded film on a camera (INTEGER) public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid"); @@ -53,6 +56,9 @@ public final class Main extends JavaPlugin { instance = this; saveDefaultConfig(); + MapManager.init(getServer().getWorlds().getFirst().getUID()); + pendingPhotos = new PendingPhotos(getDataFolder()); + pendingPhotos.recoverAbandoned(); initRenderManager(); Objects.requireNonNull(getCommand("pixelPic")).setExecutor(new PixelPicsCommand()); @@ -61,6 +67,7 @@ public final class Main extends JavaPlugin { Bukkit.getPluginManager().registerEvents(listener, this); } SurvivalRecipes.register(); + MapManager.reattachAfterReload(OnMapInitialize::attach); initRenderer(); } @@ -150,9 +157,10 @@ public final class Main extends JavaPlugin { public void onDisable() { SurvivalRecipes.unregister(); if (renderManager != null) { - renderManager.shutdown(); + renderManager.shutdown(); // delivers or fails every open job synchronously (film refunds) renderManager = null; } + MapManager.detachAll(); if (resourcePack != null) { resourcePack.close(); resourcePack = null; @@ -164,6 +172,10 @@ public final class Main extends JavaPlugin { return this.screenRenderer; } + public PendingPhotos getPendingPhotos() { + return this.pendingPhotos; + } + public RenderManager getRenderManager() { return this.renderManager; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java index 50ac646..e5c9af0 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java @@ -1,21 +1,30 @@ package eu.mhsl.minecraft.pixelpics.commands; +import eu.mhsl.minecraft.pixelpics.Main; import eu.mhsl.minecraft.pixelpics.survival.PhotoService; import eu.mhsl.minecraft.pixelpics.utils.MapManager; +import eu.mhsl.minecraft.pixelpics.utils.MapUsageScan; +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashSet; import java.util.List; import java.util.Set; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import org.bukkit.plugin.IllegalPluginAccessException; import org.jetbrains.annotations.NotNull; public class PixelPicsCommand implements CommandExecutor { private static final int DEFAULT_CLEANUP_DAYS = 30; + private static boolean cleanupRunning = false; + @Override public boolean onCommand( @NotNull CommandSender sender, @@ -44,13 +53,20 @@ public class PixelPicsCommand implements CommandExecutor { return true; } - /** {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes orphans. */ + /** + * {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes photos that no saved + * or loaded item references (see {@link MapUsageScan}) and that nobody has seen for {@code days} days. + */ private boolean cleanup(CommandSender sender, String[] args) { - if (!sender.hasPermission("pixelpic.admin") && !sender.isOp()) { + if (!sender.hasPermission("pixelpic.admin")) { sender.sendMessage( Component.text("Dafür fehlt dir die Berechtigung (pixelpic.admin).", NamedTextColor.RED)); return true; } + if (cleanupRunning) { + sender.sendMessage(Component.text("Es läuft bereits ein Aufräum-Scan.", NamedTextColor.YELLOW)); + return true; + } boolean confirm = false; int days = DEFAULT_CLEANUP_DAYS; @@ -64,7 +80,46 @@ public class PixelPicsCommand implements CommandExecutor { } } - Set inUse = MapManager.collectInUseMapIds(); + // Loaded state now, saved world and player data off the main thread, loaded state again before deciding. + Set liveBefore = MapUsageScan.live(); + List worlds = MapUsageScan.worldFolders(); + cleanupRunning = true; + sender.sendMessage(Component.text("Durchsuche Welt- und Spielerdaten nach Fotos …", NamedTextColor.GRAY)); + Main plugin = Main.getInstance(); + boolean finalConfirm = confirm; + int finalDays = days; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + Set onDisk = null; + String error = null; + try { + onDisk = MapUsageScan.disk(worlds); + } catch (IOException | RuntimeException e) { + error = e.getMessage(); + } + Set finalOnDisk = onDisk; + String finalError = error; + try { + Bukkit.getScheduler().runTask(plugin, () -> { + cleanupRunning = false; + if (finalOnDisk == null) { + sender.sendMessage(Component.text( + "Konnte nicht alle Weltdaten lesen, es wurde nichts gelöscht: " + finalError, + NamedTextColor.RED)); + return; + } + Set inUse = new HashSet<>(liveBefore); + inUse.addAll(finalOnDisk); + inUse.addAll(MapUsageScan.live()); + finishCleanup(sender, inUse, finalConfirm, finalDays); + }); + } catch (IllegalPluginAccessException e) { + cleanupRunning = false; // disabled meanwhile + } + }); + return true; + } + + private static void finishCleanup(CommandSender sender, Set inUse, boolean confirm, int days) { long cutoff = System.currentTimeMillis() - days * 86_400_000L; List candidates = MapManager.listStored().stream() .filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff) @@ -72,21 +127,24 @@ public class PixelPicsCommand implements CommandExecutor { if (candidates.isEmpty()) { sender.sendMessage(Component.text( - "Keine aufräumbaren Aufnahmen gefunden (älter als " + days + " Tage und nicht in Benutzung).", + "Keine aufräumbaren Aufnahmen gefunden (nirgends mehr vorhanden und seit " + days + + " Tagen nicht gesehen).", NamedTextColor.YELLOW)); - return true; + return; } if (!confirm) { sender.sendMessage(Component.text( - candidates.size() + " Aufnahme(n) könnten gelöscht werden (älter als " + days - + " Tage, nicht in geladenen Itemframes/Online-Inventaren).", + candidates.size() + " Aufnahme(n) könnten gelöscht werden: in keiner geladenen oder gespeicherten" + + " Welt, keinem Spielerinventar/Enderchest (auch offline) und keinem Container/Shulker/" + + "Bündel gefunden und seit " + days + " Tagen nicht geladen oder angesehen.", NamedTextColor.YELLOW)); sender.sendMessage(Component.text( - "Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht erkannt. Zum Löschen: /pixelPic cleanup confirm " + "Achtung: Nicht erkannt werden Karten in gerade nicht geladenen Welten und in Speichern anderer" + + " Plugins (z. B. Rucksäcke, Auktionshäuser). Zum Löschen: /pixelPic cleanup confirm " + days, NamedTextColor.GRAY)); - return true; + return; } int deleted = 0; @@ -94,6 +152,5 @@ public class PixelPicsCommand implements CommandExecutor { if (MapManager.delete(s.id())) deleted++; } sender.sendMessage(Component.text(deleted + " Aufnahme(n) gelöscht.", NamedTextColor.GREEN)); - return true; } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java b/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java index 28abee4..b6086c2 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java @@ -1,27 +1,43 @@ package eu.mhsl.minecraft.pixelpics.listeners; +import eu.mhsl.minecraft.pixelpics.Main; import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; import eu.mhsl.minecraft.pixelpics.utils.MapImageDither; import eu.mhsl.minecraft.pixelpics.utils.MapManager; import java.awt.image.BufferedImage; +import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.server.MapInitializeEvent; +import org.bukkit.map.MapView; /** Reattaches stored photos to their maps when the server loads them. */ public class OnMapInitialize implements Listener { @EventHandler public void onMapInitialize(MapInitializeEvent event) { - int mapId = event.getMap().getId(); + attach(event.getMap()); + } + + /** Gives a map its stored photo, if it has one. */ + public static void attach(MapView view) { + int mapId = view.getId(); + if (!MapManager.isManaged(mapId)) return; + MapManager.touch(mapId); byte[] indices = MapManager.readIndices(mapId); - if (indices == null) { - // No index cache (yet): dither the stored PNG once and cache the result. + if (indices != null) { + MapManager.attachView(view, new ImageMapRenderer(indices)); + return; + } + // No index cache (yet): show blank film and dither the stored PNG off the main thread. + ImageMapRenderer renderer = new ImageMapRenderer(); + MapManager.attachView(view, renderer); + Bukkit.getScheduler().runTaskAsynchronously(Main.getInstance(), () -> { BufferedImage image = MapManager.readImage(mapId); if (image == null) return; - indices = MapImageDither.dither(image); - MapManager.saveIndices(indices, mapId); - } - MapManager.attachView(event.getMap(), new ImageMapRenderer(indices)); + byte[] dithered = MapImageDither.dither(image); + MapManager.saveIndices(dithered, mapId); + renderer.show(dithered); + }); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java index 02fdaea..cb24387 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/RenderManager.java @@ -1,5 +1,6 @@ package eu.mhsl.minecraft.pixelpics.render; +import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -17,6 +18,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Function; import org.bukkit.Bukkit; +import org.bukkit.plugin.IllegalPluginAccessException; import org.bukkit.plugin.Plugin; /** @@ -41,11 +43,15 @@ public final class RenderManager { QUEUE_FULL } + private static final int SHUTDOWN_WAIT_SECONDS = 5; + private final Plugin plugin; private final ForkJoinPool tracePool; private final ThreadPoolExecutor dispatcher; private final ScheduledExecutorService watchdog; private final Set activeUsers = ConcurrentHashMap.newKeySet(); + private final Set> jobs = ConcurrentHashMap.newKeySet(); + private volatile boolean shuttingDown = false; private final AtomicInteger inFlight = new AtomicInteger(); private final int capacity; private final long timeoutMillis; @@ -97,43 +103,93 @@ public final class RenderManager { * Runs {@code work} off the main thread (honoring the global concurrency limit), then delivers the * result back on the main thread via {@code onSuccess}, or {@code onFailure} if it fails, returns * null, or exceeds the configured timeout. The {@link AtomicBoolean} handed to {@code work} is set - * once the deadline passes — {@code work} should poll it and bail out cooperatively. Releases the - * caller's reservation when done. Requires a prior {@link #tryReserve} success. + * once the deadline passes or the plugin shuts down — {@code work} should poll it and bail out + * cooperatively. Exactly one callback runs per job, also when the plugin is disabled meanwhile + * ({@link #shutdown} runs it synchronously). Releases the caller's reservation when done. Requires a + * prior {@link #tryReserve} success. */ public void dispatch(UUID user, Function work, Consumer onSuccess, Runnable onFailure) { + Job job = new Job<>(onSuccess, onFailure); + jobs.add(job); dispatcher.execute(() -> { - AtomicBoolean cancelled = new AtomicBoolean(false); - ScheduledFuture deadline = - watchdog.schedule(() -> cancelled.set(true), timeoutMillis, TimeUnit.MILLISECONDS); - T result = null; - boolean ok = false; + AtomicBoolean timedOut = new AtomicBoolean(false); + ScheduledFuture deadline = watchdog.schedule( + () -> { + timedOut.set(true); + job.cancelled.set(true); + }, + timeoutMillis, + TimeUnit.MILLISECONDS); try { - result = work.apply(cancelled); - ok = result != null && !cancelled.get(); + T result = job.cancelled.get() ? null : work.apply(job.cancelled); + if (result != null && !job.cancelled.get()) job.result = result; } catch (Throwable t) { - plugin.getLogger().warning("Render job failed: " + t); + if (!shuttingDown) plugin.getLogger().warning("Render job failed: " + t); } finally { deadline.cancel(false); - if (cancelled.get()) { + if (timedOut.get()) { plugin.getLogger() .warning( "Render for " + user + " aborted after " + (timeoutMillis / 1000) + "s (timeout)."); } release(user); } - T finalResult = result; - boolean finalOk = ok; - Bukkit.getScheduler().runTask(plugin, () -> { - if (finalOk) onSuccess.accept(finalResult); - else onFailure.run(); - }); + if (shuttingDown) return; // shutdown() delivers it + try { + Bukkit.getScheduler().runTask(plugin, job::finish); + } catch (IllegalPluginAccessException e) { + // disabled in between: shutdown() delivers it + } }); } + /** + * Stops all rendering without leaving a job undelivered: running and queued jobs are cancelled, the workers get + * a few seconds to wind down, then every job's callback runs here (on the main thread) — {@code onSuccess} for + * jobs that had completed but were not delivered yet, {@code onFailure} for the rest. Nothing is scheduled. + */ public void shutdown() { + shuttingDown = true; + for (Job job : jobs) job.cancelled.set(true); watchdog.shutdownNow(); dispatcher.shutdownNow(); + try { + if (!dispatcher.awaitTermination(SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS)) { + plugin.getLogger().warning("Render workers did not stop within " + SHUTDOWN_WAIT_SECONDS + "s."); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } tracePool.shutdownNow(); + for (Job job : List.copyOf(jobs)) { + try { + job.finish(); + } catch (Throwable t) { + plugin.getLogger().warning("Failed to finish a render job on shutdown: " + t); + } + } + } + + /** A dispatched render; {@link #finish} runs exactly one of its callbacks, exactly once. */ + private final class Job { + final AtomicBoolean cancelled = new AtomicBoolean(false); + private final AtomicBoolean finished = new AtomicBoolean(false); + private final Consumer onSuccess; + private final Runnable onFailure; + volatile T result; // set once the work completed successfully + + Job(Consumer onSuccess, Runnable onFailure) { + this.onSuccess = onSuccess; + this.onFailure = onFailure; + } + + void finish() { + if (!finished.compareAndSet(false, true)) return; + jobs.remove(this); + T r = result; + if (r != null) onSuccess.accept(r); + else onFailure.run(); + } } private static ForkJoinPool.ForkJoinWorkerThreadFactory lowPriorityForkJoinFactory() { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java index 4619a39..946a0a6 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java @@ -148,8 +148,8 @@ public class DefaultScreenRenderer { } /** - * As {@link #execute(RenderJob)}, but abortable: once {@code cancelled} is set the remaining work is skipped and - * the returned image is partial (to be discarded). + * As {@link #execute(RenderJob)}, but abortable: once {@code cancelled} is set (or the tracing thread is + * interrupted) the remaining work is skipped and the returned image is partial (to be discarded). */ public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) { int finalW = job.width(); @@ -172,13 +172,13 @@ public class DefaultScreenRenderer { CameraMedium medium = raytracer.cameraMedium(snapshot, origin); int[] superBuf = new int[rayMap.size()]; runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> { - if (!cancelled.get()) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene, medium); + if (!stopped(cancelled)) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene, medium); })); BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB); int[] imageData = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); runParallel(() -> IntStream.range(0, finalH).parallel().forEach(fy -> { - if (cancelled.get()) return; + if (stopped(cancelled)) return; int[] block = new int[SSAA * SSAA]; for (int fx = 0; fx < finalW; fx++) { int n = 0; @@ -194,6 +194,14 @@ public class DefaultScreenRenderer { return image; } + /** Cancelled, or the worker was interrupted (pool shut down), which then cancels the whole job. */ + private static boolean stopped(AtomicBoolean cancelled) { + if (cancelled.get()) return true; + if (!Thread.currentThread().isInterrupted()) return false; + cancelled.set(true); + return true; + } + /** * Runs a parallel-stream task in the {@link #tracePool} (parallel streams adopt the pool of the running fork-join * worker), or on the common pool without one (offline render tools). diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java index 40b5d8d..31250e8 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java @@ -12,9 +12,11 @@ import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.MapMeta; import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.inventory.meta.components.EquippableComponent; import org.bukkit.persistence.PersistentDataType; @@ -68,6 +70,28 @@ public final class CameraItems { return item; } + /** + * Puts {@code item} into the player's inventory; what does not fit is dropped at their feet, like {@code /give} + * (no pickup delay, only that player may pick it up). + */ + public static void give(Player player, ItemStack item) { + for (ItemStack left : player.getInventory().addItem(item).values()) { + player.getWorld().dropItem(player.getLocation(), left, drop -> { + drop.setPickupDelay(0); + drop.setOwner(player.getUniqueId()); + }); + } + } + + /** Whether {@code item} is a photo on map {@code mapId}. */ + public static boolean isPhotoOf(ItemStack item, int mapId) { + return isPhoto(item) + && item.getItemMeta() instanceof MapMeta meta + && meta.hasMapView() + && meta.getMapView() != null + && meta.getMapView().getId() == mapId; + } + public static Component photoDisplayName() { return plain("Foto", NamedTextColor.WHITE); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java index 6043603..582b1e7 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java @@ -11,6 +11,7 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; @@ -23,6 +24,11 @@ public class CameraListener implements Listener { private static final long COOLDOWN_MILLIS = 500; private final Map lastUse = new HashMap<>(); + @EventHandler + public void onQuit(PlayerQuitEvent event) { + lastUse.remove(event.getPlayer().getUniqueId()); + } + @EventHandler public void onInteract(PlayerInteractEvent event) { if (event.getHand() != EquipmentSlot.HAND) return; @@ -45,7 +51,7 @@ public class CameraListener implements Listener { return; } - if (PhotoService.takePhoto(player)) { + if (PhotoService.takePhoto(player, true)) { player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1)); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java index 5e83d76..a022fe4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java @@ -4,7 +4,6 @@ import eu.mhsl.minecraft.pixelpics.Main; import java.util.function.Predicate; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; import org.bukkit.Keyed; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -65,17 +64,14 @@ public class CraftingListener implements Listener { ItemStack result = result(scan); if (result == null) return; + // Consume and hand out atomically within this click: a deferred grid write could be raced by a second click + // or by closing the inventory in the same tick (duplication). Setting the matrix re-runs the recipe match. ItemStack[] matrix = inv.getMatrix(); if (scan.kind() == Kind.LOAD) consumeFirst(matrix, CameraItems::isCamera); consumeFirst(matrix, CameraItems::isFilm); // a copied photo keeps its original - - // Next tick, so the click settles before the grid changes and the item is handed over. - Bukkit.getScheduler().runTask(Main.getInstance(), () -> { - inv.setMatrix(matrix); - give(player, result); - player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f); - player.updateInventory(); - }); + inv.setMatrix(matrix); + give(player, result); + player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f); } private static Scan scan(ItemStack[] matrix) { @@ -145,11 +141,7 @@ public class CraftingListener implements Listener { } private static void give(Player player, ItemStack item) { - if (player.getItemOnCursor().getType().isAir()) { - player.setItemOnCursor(item); - } else { - player.getInventory().addItem(item).values().forEach(left -> player.getWorld() - .dropItemNaturally(player.getLocation(), left)); - } + if (player.getItemOnCursor().getType().isAir()) player.setItemOnCursor(item); + else CameraItems.give(player, item); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/JoinListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/JoinListener.java index 87a7636..d51536d 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/JoinListener.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/JoinListener.java @@ -1,14 +1,29 @@ package eu.mhsl.minecraft.pixelpics.survival; +import eu.mhsl.minecraft.pixelpics.Main; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; -/** Unlocks the survival recipes in each player's recipe book on join (idempotent). */ +/** + * Unlocks the survival recipes in each player's recipe book on join (idempotent) and hands out film owed from photos + * that never developed (see {@link PendingPhotos}). + */ public class JoinListener implements Listener { @EventHandler public void onJoin(PlayerJoinEvent event) { - event.getPlayer().discoverRecipes(SurvivalRecipes.allKeys()); + Player player = event.getPlayer(); + player.discoverRecipes(SurvivalRecipes.allKeys()); + int films = Main.getInstance().getPendingPhotos().takeRefunds(player.getUniqueId()); + for (int i = 0; i < films; i++) CameraItems.give(player, CameraItems.createFilm()); + if (films > 0) { + player.sendActionBar(Component.text( + films + " Aufnahme(n) wurden nicht fertig entwickelt — der Film wurde zurückgegeben.", + NamedTextColor.YELLOW)); + } } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PendingPhotos.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PendingPhotos.java new file mode 100644 index 0000000..6db6f90 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PendingPhotos.java @@ -0,0 +1,92 @@ +package eu.mhsl.minecraft.pixelpics.survival; + +import eu.mhsl.minecraft.pixelpics.Main; +import eu.mhsl.minecraft.pixelpics.utils.MapManager; +import java.io.File; +import java.io.IOException; +import java.util.UUID; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +/** + * Persists photos that are still rendering ({@code pending.yml}), so a crash cannot swallow film: a job still listed + * at the next start never developed, and its film is owed to the photographer ({@code refunds}, handed out on join). + * A graceful stop or reload never leaves entries behind ({@code RenderManager.shutdown} finishes every job). Main + * thread only. + */ +public final class PendingPhotos { + + private static final String JOBS = "jobs"; + private static final String REFUNDS = "refunds"; + + private final File file; + private final YamlConfiguration yml; + + public PendingPhotos(File dataFolder) { + this.file = new File(dataFolder, "pending.yml"); + this.yml = YamlConfiguration.loadConfiguration(file); + } + + /** Turns jobs left over from a crash into film refunds; their maps keep the blank placeholder. */ + public void recoverAbandoned() { + ConfigurationSection jobs = yml.getConfigurationSection(JOBS); + if (jobs == null) return; + int recovered = 0; + for (String key : jobs.getKeys(false)) { + ConfigurationSection job = jobs.getConfigurationSection(key); + if (job == null) continue; + int mapId = Integer.parseInt(key); + if (MapManager.hasImage(mapId)) continue; // developed, only the bookkeeping was lost + if (!MapManager.isManaged(mapId)) MapManager.savePlaceholder(mapId); + String player = job.getString("player"); + if (player != null && job.getBoolean("film")) { + addRefund(UUID.fromString(player)); + recovered++; + } + } + yml.set(JOBS, null); + save(); + if (recovered > 0) { + Main.getInstance() + .getLogger() + .warning(recovered + " photo(s) did not develop before the last shutdown; their film is refunded" + + " when the players join."); + } + } + + public void add(int mapId, UUID player, boolean film) { + yml.set(JOBS + "." + mapId + ".player", player.toString()); + yml.set(JOBS + "." + mapId + ".film", film); + save(); + } + + public void remove(int mapId) { + yml.set(JOBS + "." + mapId, null); + save(); + } + + public void addRefund(UUID player) { + String key = REFUNDS + "." + player; + yml.set(key, yml.getInt(key) + 1); + save(); + } + + /** The films owed to {@code player}, now cleared. */ + public int takeRefunds(UUID player) { + String key = REFUNDS + "." + player; + int n = yml.getInt(key); + if (n > 0) { + yml.set(key, null); + save(); + } + return n; + } + + private void save() { + try { + yml.save(file); + } catch (IOException e) { + Main.getInstance().getLogger().warning("Failed to save " + file.getName() + ": " + e.getMessage()); + } + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java index 4f04e97..d9fc9c2 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java @@ -25,7 +25,8 @@ import org.bukkit.persistence.PersistentDataType; /** * Photographs a player's view into a {@code FILLED_MAP} that develops once the render (throttled by - * {@link RenderManager}) is done. Used by the camera and {@code /pixelPic}; film is the caller's business. + * {@link RenderManager}) is done. Used by the camera and {@code /pixelPic}; the caller takes the film, + * a photo that fails to develop gives it back. */ public final class PhotoService { @@ -33,8 +34,16 @@ public final class PhotoService { private PhotoService() {} - /** False (with action-bar feedback) when no photo was taken: no renderer, or rejected by the queue. */ + /** A debug photo that uses no film ({@code /pixelPic}). */ public static boolean takePhoto(Player player) { + return takePhoto(player, false); + } + + /** + * False (with action-bar feedback) when no photo was taken: no renderer, or rejected by the queue. With + * {@code usesFilm} the caller takes the film on true; it is given back if the photo then fails to develop. + */ + public static boolean takePhoto(Player player, boolean usesFilm) { Main plugin = Main.getInstance(); DefaultScreenRenderer renderer = plugin.getScreenRenderer(); if (renderer == null) { @@ -61,11 +70,14 @@ public final class PhotoService { RenderJob job = renderer.prepare( player.getEyeLocation(), new Resolution(MapImageDither.SIZE, MapImageDither.SIZE), user); - // The map is handed over right away and shows blank film until the picture develops. + // The map is handed over right away and shows blank film until the picture develops. The blank film is + // also stored, so the map never turns into a vanilla world map if the render is lost (crash). ImageMapRenderer mapRenderer = new ImageMapRenderer(); MapView mapView = Bukkit.createMap(player.getWorld()); int id = mapView.getId(); + MapManager.claimNewId(id); MapManager.attachView(mapView, mapRenderer); + MapManager.savePlaceholder(id); ItemStack map = new ItemStack(Material.FILLED_MAP); MapMeta meta = (MapMeta) map.getItemMeta(); @@ -77,9 +89,10 @@ public final class PhotoService { meta.setMapView(mapView); meta.displayName(CameraItems.photoDisplayName()); map.setItemMeta(meta); - player.getInventory().addItem(map); + CameraItems.give(player, map); playShutter(player); + plugin.getPendingPhotos().add(id, user, usesFilm); manager.dispatch( user, cancelled -> { @@ -90,10 +103,14 @@ public final class PhotoService { out -> { MapManager.saveImage(out.image(), id); MapManager.saveIndices(out.indices(), id); + plugin.getPendingPhotos().remove(id); mapRenderer.develop(out.indices()); - player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f); + Player online = Bukkit.getPlayer(user); + if (online != null) { + online.playSound(online.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f); + } }, - () -> player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED))); + () -> failed(id, user, usesFilm)); dispatched = true; } finally { if (!dispatched) manager.release(user); @@ -102,6 +119,48 @@ public final class PhotoService { return true; } + /** + * The photo did not develop (error, timeout, shutdown): its map keeps the blank placeholder, the blank photo is + * taken back from the photographer's inventory if it is still there, and the film is given back — right away, + * or on their next join when they are offline. + */ + private static void failed(int mapId, UUID user, boolean usesFilm) { + Main plugin = Main.getInstance(); + plugin.getPendingPhotos().remove(mapId); + Player player = Bukkit.getPlayer(user); + if (!usesFilm) { + if (player != null) { + player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)); + } + return; + } + if (player == null) { + plugin.getPendingPhotos().addRefund(user); + return; + } + removeOnePhoto(player, mapId); + CameraItems.give(player, CameraItems.createFilm()); + player.sendActionBar( + Component.text("Aufnahme fehlgeschlagen — der Film wurde zurückgegeben.", NamedTextColor.RED)); + } + + private static void removeOnePhoto(Player player, int mapId) { + if (CameraItems.isPhotoOf(player.getItemOnCursor(), mapId)) { + ItemStack cursor = player.getItemOnCursor(); + cursor.setAmount(cursor.getAmount() - 1); + player.setItemOnCursor(cursor.getAmount() > 0 ? cursor : null); + return; + } + ItemStack[] contents = player.getInventory().getContents(); + for (int i = 0; i < contents.length; i++) { + if (CameraItems.isPhotoOf(contents[i], mapId)) { + contents[i].setAmount(contents[i].getAmount() - 1); + player.getInventory().setItem(i, contents[i].getAmount() > 0 ? contents[i] : null); + return; + } + } + } + private static void playShutter(Player player) { player.playSound(player.getLocation(), Sound.BLOCK_VAULT_OPEN_SHUTTER, 1f, 1.4f); Location eye = player.getEyeLocation(); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java index abebb23..da06082 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java @@ -18,11 +18,14 @@ public class ImageMapRenderer extends MapRenderer { private static final long DEVELOP_MILLIS = 3000; private static final byte FILM = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190)); + private static final long TOUCH_MILLIS = 3_600_000; + private volatile byte[] indices; // null while still rendering - private final boolean animate; + private volatile boolean animate; private volatile long developStart; private boolean finished = false; private boolean blankDrawn = false; + private long lastTouch = 0; /** A persisted picture, drawn without animation. */ public ImageMapRenderer(byte[] indices) { @@ -44,8 +47,19 @@ public class ImageMapRenderer extends MapRenderer { this.indices = indices; } + /** Shows indices that became available late (e.g. dithered off-thread) at once, without the develop effect. */ + public void show(byte[] indices) { + this.animate = false; + this.indices = indices; + } + @Override public void render(@NotNull MapView map, @NotNull MapCanvas canvas, @NotNull Player player) { + long now = System.currentTimeMillis(); + if (now - lastTouch > TOUCH_MILLIS) { + lastTouch = now; + MapManager.touch(map.getId()); // "last seen" for cleanup + } if (finished) return; byte[] data = this.indices; if (data == null) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java index 61a276f..37db535 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java @@ -5,40 +5,119 @@ import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.WeakHashMap; +import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.imageio.ImageIO; import org.bukkit.Bukkit; -import org.bukkit.Material; -import org.bukkit.World; -import org.bukkit.entity.ItemFrame; -import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.MapMeta; +import org.bukkit.map.MapRenderer; import org.bukkit.map.MapView; /** - * Photo storage in {@code images/}: the PNG is the source of truth, {@code .mcmap} caches its dithered map indices. - * Also (re)attaches renderers to map views and finds orphans for cleanup. + * Photo storage in {@code images//}: the PNG is the source of truth, {@code .mcmap} caches its dithered map + * indices. Map ids are only unique within the primary world (its map counter), so the folder is bound to that + * world's UID: a new world, whose counter starts at 0 again, never sees older photos. Also (re)attaches renderers to + * map views. */ public class MapManager { private static final Pattern FILE_ID = Pattern.compile("mapId_(\\d+)\\.(png|mcmap)"); private static final int INDEX_BYTES = ImageMapRenderer.MAP_SIZE * ImageMapRenderer.MAP_SIZE; + private static final String RELOAD_FILE = "attached-maps.txt"; + + private static File folder; + // Views carrying one of our renderers; weak, the server owns them. + private static final Map attached = new WeakHashMap<>(); private MapManager() {} - private static File imageFolder() { - return new File(Main.getInstance().getDataFolder(), "images"); + /** + * Binds the storage to the primary world (its map id counter) and moves photos of older versions, stored directly + * in {@code images/}, into its folder. Must run on enable, after the worlds are loaded. + */ + public static void init(UUID primaryWorld) { + File root = new File(Main.getInstance().getDataFolder(), "images"); + folder = new File(root, primaryWorld.toString()); + File[] loose = root.listFiles((dir, name) -> FILE_ID.matcher(name).matches()); + if (loose == null || loose.length == 0 || !ensureFolder()) return; + int moved = 0; + for (File f : loose) { + File target = new File(folder, f.getName()); + if (target.exists()) continue; + try { + Files.move(f.toPath(), target.toPath()); + moved++; + } catch (IOException e) { + Main.getInstance().getLogger().warning("Failed to migrate " + f + ": " + e.getMessage()); + } + } + Main.getInstance() + .getLogger() + .info("Migrated " + moved + " photo file(s) into images/" + primaryWorld + "/ (bound to the primary" + + " world)."); } public static void attachView(MapView mapView, ImageMapRenderer renderer) { - mapView.getRenderers().clear(); + // getRenderers() is a copy: clearing it would not remove the vanilla renderer + for (MapRenderer r : mapView.getRenderers()) mapView.removeRenderer(r); mapView.setScale(MapView.Scale.FARTHEST); mapView.addRenderer(renderer); mapView.setTrackingPosition(false); + attached.put(mapView, Boolean.TRUE); + } + + /** + * Removes our renderers from all views (their classes belong to this plugin instance) and remembers the ids, so + * that {@link #reattachAfterReload} can give already loaded maps new renderers after a {@code /reload}. + */ + public static void detachAll() { + List ids = new ArrayList<>(); + for (MapView view : new ArrayList<>(attached.keySet())) { + for (MapRenderer r : view.getRenderers()) { + if (r instanceof ImageMapRenderer) view.removeRenderer(r); + } + ids.add(Integer.toString(view.getId())); + } + attached.clear(); + try { + Files.write(new File(Main.getInstance().getDataFolder(), RELOAD_FILE).toPath(), ids); + } catch (IOException e) { + Main.getInstance().getLogger().warning("Failed to remember loaded photo maps: " + e.getMessage()); + } + } + + /** + * Map views stay loaded across a {@code /reload} and fire no new {@code MapInitializeEvent}: reattach the ones + * {@link #detachAll} recorded. At server start (tick 0) nothing is loaded yet and the file is just dropped. + */ + public static void reattachAfterReload(Consumer attach) { + File file = new File(Main.getInstance().getDataFolder(), RELOAD_FILE); + if (!file.isFile()) return; + try { + if (Bukkit.getCurrentTick() > 0) { + for (String line : Files.readAllLines(file.toPath())) { + MapView view = Bukkit.getMap(Integer.parseInt(line.trim())); + if (view != null) attach.accept(view); + } + } + } catch (IOException | NumberFormatException e) { + Main.getInstance().getLogger().warning("Failed to reattach photo maps: " + e.getMessage()); + } finally { + file.delete(); + } + } + + private static File imageFolder() { + return folder; } private static File getImagePath(int mapId) { @@ -58,6 +137,39 @@ public class MapManager { return true; } + /** + * A map id the server just handed out for a new photo must be unused. Files under it mean the world's map counter + * was reset (e.g. {@code last_id.dat} restored): they are moved to {@code replaced/} instead of being overwritten. + */ + public static void claimNewId(int id) { + if (!isManaged(id)) return; + File backup = new File(imageFolder(), "replaced"); + backup.mkdirs(); + String suffix = "-" + System.currentTimeMillis(); + for (File f : List.of(getImagePath(id), getIndexPath(id))) { + if (!f.isFile()) continue; + try { + Files.move( + f.toPath(), + new File(backup, f.getName() + suffix).toPath(), + StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + Main.getInstance().getLogger().warning("Failed to move " + f + " aside: " + e.getMessage()); + } + } + Main.getInstance() + .getLogger() + .warning("Map id " + id + " was handed out again (map counter reset?); its old photo was moved to " + + backup + "."); + } + + /** Blank film indices for a photo that has not developed (yet): never a vanilla world map after a restart. */ + public static void savePlaceholder(int id) { + byte[] blank = new byte[INDEX_BYTES]; + Arrays.fill(blank, ImageMapRenderer.filmColor()); + saveIndices(blank, id); + } + public static void saveImage(BufferedImage image, int id) { if (!ensureFolder()) return; try { @@ -100,11 +212,23 @@ public class MapManager { } } + public static boolean hasImage(int id) { + return getImagePath(id).isFile(); + } + public static boolean isManaged(int id) { return getIndexPath(id).isFile() || getImagePath(id).isFile(); } - /** A stored map id with the newest last-modified time of its files. */ + /** Marks a photo as seen now: the files' modification time is its "last seen" for cleanup. */ + public static void touch(int id) { + long now = System.currentTimeMillis(); + for (File f : List.of(getImagePath(id), getIndexPath(id))) { + if (f.isFile()) f.setLastModified(now); + } + } + + /** A stored map id with the newest last-modified ("last seen") time of its files. */ public record StoredMap(int id, long lastModified) {} public static Set listStored() { @@ -127,36 +251,6 @@ public class MapManager { return f.isFile() ? f.lastModified() : 0L; } - /** Map ids held by online players (inventory, ender chest, cursor) and loaded item frames. */ - public static Set collectInUseMapIds() { - Set inUse = new HashSet<>(); - for (Player p : Bukkit.getOnlinePlayers()) { - addFromContents(inUse, p.getInventory().getContents()); - addFromContents(inUse, p.getEnderChest().getContents()); - addFromItem(inUse, p.getItemOnCursor()); - } - for (World w : Bukkit.getWorlds()) { - for (ItemFrame frame : w.getEntitiesByClass(ItemFrame.class)) { - addFromItem(inUse, frame.getItem()); - } - } - return inUse; - } - - private static void addFromContents(Set set, ItemStack[] contents) { - for (ItemStack item : contents) addFromItem(set, item); - } - - private static void addFromItem(Set set, ItemStack item) { - if (item != null - && item.getType() == Material.FILLED_MAP - && item.getItemMeta() instanceof MapMeta meta - && meta.hasMapView() - && meta.getMapView() != null) { - set.add(meta.getMapView().getId()); - } - } - /** Deletes both files of a map id; true if anything was deleted. */ public static boolean delete(int id) { boolean deleted = false; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapUsageScan.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapUsageScan.java new file mode 100644 index 0000000..c7414ff --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapUsageScan.java @@ -0,0 +1,268 @@ +package eu.mhsl.minecraft.pixelpics.utils; + +import io.papermc.paper.datacomponent.DataComponentTypes; +import io.papermc.paper.datacomponent.item.BundleContents; +import io.papermc.paper.datacomponent.item.ItemContainerContents; +import io.papermc.paper.datacomponent.item.MapId; +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Item; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.entity.ItemFrame; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.BlockInventoryHolder; +import org.bukkit.inventory.EntityEquipment; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; + +/** + * Finds the map ids still referenced anywhere, so cleanup never deletes a photo that exists: {@link #live} looks at + * the loaded server state (online players, entities, block inventories of loaded chunks, nested shulker boxes and + * bundles), {@link #disk} at the saved data of all loaded worlds (every chunk and entity region file and the player + * files, which include offline players and ender chests). Any unreadable file aborts the disk scan. + */ +public final class MapUsageScan { + + private static final int MAX_DEPTH = 16; + private static final Pattern REGION = Pattern.compile("r\\.(-?\\d+)\\.(-?\\d+)\\.mca"); + + private MapUsageScan() {} + + /** Map ids in the loaded world and online players. Main thread. */ + public static Set live() { + Set ids = new HashSet<>(); + for (Player p : Bukkit.getOnlinePlayers()) { + addAll(ids, p.getInventory().getContents()); + addAll(ids, p.getEnderChest().getContents()); + add(ids, p.getItemOnCursor(), 0); + addAll(ids, p.getOpenInventory().getTopInventory().getContents()); // e.g. a crafting grid + } + for (World world : Bukkit.getWorlds()) { + for (Entity e : world.getEntities()) { + if (e instanceof Item item) add(ids, item.getItemStack(), 0); + if (e instanceof ItemFrame frame) add(ids, frame.getItem(), 0); + if (e instanceof ItemDisplay display) add(ids, display.getItemStack(), 0); + if (e instanceof InventoryHolder holder) + addAll(ids, holder.getInventory().getContents()); + if (e instanceof LivingEntity living && living.getEquipment() != null) { + addEquipment(ids, living.getEquipment()); + } + } + for (Chunk chunk : world.getLoadedChunks()) { + for (BlockState state : chunk.getTileEntities(false)) { + if (state instanceof BlockInventoryHolder holder) { + addAll(ids, holder.getInventory().getContents()); + } + } + } + } + return ids; + } + + /** + * The save folders (the directories holding {@code level.dat}) of all loaded worlds, nested ones dropped. Since + * 26.1 a world's folder is its dimension directory inside the save, while player data lives at the save's root. + * Main thread. + */ + public static List worldFolders() { + List all = new ArrayList<>(); + for (World world : Bukkit.getWorlds()) { + Path folder = world.getWorldFolder().toPath().toAbsolutePath().normalize(); + Path save = folder; + while (save != null && !Files.isRegularFile(save.resolve("level.dat"))) save = save.getParent(); + all.add(save != null ? save : folder); + } + List roots = new ArrayList<>(); + for (Path p : all) { + if (all.stream().noneMatch(o -> !o.equals(p) && p.startsWith(o)) && !roots.contains(p)) roots.add(p); + } + return roots; + } + + /** Map ids in the saved region and player files below {@code roots}; throws if any of them is unreadable. */ + public static Set disk(List roots) throws IOException { + Set ids = new HashSet<>(); + for (Path root : roots) { + List files; + try (Stream walk = Files.walk(root)) { + files = walk.filter(Files::isRegularFile).toList(); + } + for (Path file : files) { + String name = file.getFileName().toString(); + try { + if (REGION.matcher(name).matches() && !isIn(file, "poi")) { + scanRegion(file, ids); + } else if (name.endsWith(".dat") && (isIn(file, "playerdata") || isPlayersData(file))) { + try (InputStream in = Files.newInputStream(file)) { + readNbt(new GZIPInputStream(in), ids); + } + } + } catch (IOException | RuntimeException e) { + throw new IOException(root.relativize(file) + ": " + e, e); + } + } + } + return ids; + } + + private static boolean isIn(Path file, String dir) { + return file.getParent() != null + && file.getParent().getFileName().toString().equals(dir); + } + + /** {@code players/data/.dat} (since 26.1). */ + private static boolean isPlayersData(Path file) { + Path parent = file.getParent(); + return isIn(file, "data") + && parent.getParent() != null + && parent.getParent().getFileName().toString().equals("players"); + } + + private static void addEquipment(Set ids, EntityEquipment equipment) { + for (EquipmentSlot slot : EquipmentSlot.values()) { + try { + add(ids, equipment.getItem(slot), 0); + } catch (IllegalArgumentException ignored) { + // slot not supported by this entity + } + } + } + + private static void addAll(Set ids, ItemStack[] items) { + for (ItemStack item : items) add(ids, item, 0); + } + + @SuppressWarnings("UnstableApiUsage") + private static void add(Set ids, ItemStack item, int depth) { + if (item == null || item.isEmpty() || depth > MAX_DEPTH) return; + MapId mapId = item.getData(DataComponentTypes.MAP_ID); + if (mapId != null) ids.add(mapId.id()); + ItemContainerContents container = item.getData(DataComponentTypes.CONTAINER); + if (container != null) container.contents().forEach(i -> add(ids, i, depth + 1)); + BundleContents bundle = item.getData(DataComponentTypes.BUNDLE_CONTENTS); + if (bundle != null) bundle.contents().forEach(i -> add(ids, i, depth + 1)); + } + + /** Anvil region file: a 4 KiB offset table, then per chunk a length, a compression byte and the NBT. */ + private static void scanRegion(Path file, Set ids) throws IOException { + byte[] b = Files.readAllBytes(file); + if (b.length < 8192) return; // empty or freshly created + Matcher m = REGION.matcher(file.getFileName().toString()); + if (!m.matches()) return; + int regionX = Integer.parseInt(m.group(1)), regionZ = Integer.parseInt(m.group(2)); + for (int i = 0; i < 1024; i++) { + int location = readInt(b, i * 4); + int offset = (location >>> 8) * 4096; + if (offset == 0) continue; + if (offset + 5 > b.length) throw new IOException("chunk " + i + " points past the end of the file"); + int length = readInt(b, offset); + int type = b[offset + 4] & 0xFF; + InputStream raw; + if ((type & 0x80) != 0) { + // oversized chunk stored in c...mcc next to the region file + int x = regionX * 32 + (i & 31), z = regionZ * 32 + (i >> 5); + raw = Files.newInputStream(file.resolveSibling("c." + x + "." + z + ".mcc")); + } else { + if (length < 1 || offset + 4 + length > b.length) throw new IOException("chunk " + i + " truncated"); + raw = new ByteArrayInputStream(b, offset + 5, length - 1); + } + try (InputStream in = decompress(type & 0x7F, raw)) { + readNbt(in, ids); + } + } + } + + private static InputStream decompress(int type, InputStream raw) throws IOException { + return switch (type) { + case 1 -> new GZIPInputStream(raw); + case 2 -> new InflaterInputStream(raw); + case 3 -> raw; + case 4 -> lz4(raw); + default -> throw new IOException("unsupported chunk compression " + type); + }; + } + + /** LZ4 chunks use the server's bundled lz4-java (not on the plugin's compile classpath). */ + private static InputStream lz4(InputStream raw) throws IOException { + try { + return (InputStream) Class.forName("net.jpountz.lz4.LZ4BlockInputStream") + .getConstructor(InputStream.class) + .newInstance(raw); + } catch (ReflectiveOperationException e) { + throw new IOException("LZ4 chunk compression not supported: " + e); + } + } + + private static int readInt(byte[] b, int pos) { + return ((b[pos] & 0xFF) << 24) | ((b[pos + 1] & 0xFF) << 16) | ((b[pos + 2] & 0xFF) << 8) | (b[pos + 3] & 0xFF); + } + + /** + * Streams an NBT tree and collects every int tag named {@code minecraft:map_id} (item component) or {@code map} + * (pre-1.20.5 item tag in chunks not upgraded yet), wherever it is nested. + */ + private static void readNbt(InputStream stream, Set ids) throws IOException { + DataInputStream in = new DataInputStream(new BufferedInputStream(stream, 65536)); + int type = in.readUnsignedByte(); + if (type == 0) return; + in.skipNBytes(in.readUnsignedShort()); + payload(in, type, null, ids); + } + + private static void payload(DataInputStream in, int type, String name, Set ids) throws IOException { + switch (type) { + case 1 -> in.skipNBytes(1); + case 2 -> in.skipNBytes(2); + case 3 -> { + int value = in.readInt(); + if ("minecraft:map_id".equals(name) || "map".equals(name)) ids.add(value); + } + case 4, 6 -> in.skipNBytes(8); + case 5 -> in.skipNBytes(4); + case 7 -> in.skipNBytes(length(in)); + case 8 -> in.skipNBytes(in.readUnsignedShort()); + case 9 -> { + int elementType = in.readUnsignedByte(); + int n = length(in); + for (int i = 0; i < n; i++) payload(in, elementType, null, ids); + } + case 10 -> { + int t; + while ((t = in.readUnsignedByte()) != 0) { + String key = in.readUTF(); + payload(in, t, key, ids); + } + } + case 11 -> in.skipNBytes(4L * length(in)); + case 12 -> in.skipNBytes(8L * length(in)); + default -> throw new IOException("bad NBT tag type " + type); + } + } + + private static int length(DataInputStream in) throws IOException { + int n = in.readInt(); + if (n < 0) throw new IOException("negative NBT length"); + return n; + } +}