From 4c52bc2dcad3b31e74cca0a9d08367636d03ef9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 5 Nov 2026 04:48:04 +0100 Subject: [PATCH] fix survival data loss and lifecycle: crafting consumes the grid atomically in the click (no same-tick dupes), photos that do not fit are dropped like /give, failed or aborted renders give the film back (offline via pending.yml, crash recovery on start) and keep a blank placeholder instead of turning into world maps, shutdown finishes every job synchronously without scheduling after disable and tracing stops on interrupt, renderers detached and reattached across /reload, vanilla map renderer really removed, photos stored per primary world uid with migration and no overwrite on a reset map counter, cleanup only deletes photos unseen for the given days and referenced nowhere in loaded state or saved world and player data, async dither fallback, cooldown map cleared on quit, cleanup permission without op bypass --- .../eu/mhsl/minecraft/pixelpics/Main.java | 14 +- .../pixelpics/commands/PixelPicsCommand.java | 77 ++++- .../pixelpics/listeners/OnMapInitialize.java | 30 +- .../pixelpics/render/RenderManager.java | 90 ++++-- .../render/render/DefaultScreenRenderer.java | 16 +- .../pixelpics/survival/CameraItems.java | 24 ++ .../pixelpics/survival/CameraListener.java | 8 +- .../pixelpics/survival/CraftingListener.java | 22 +- .../pixelpics/survival/JoinListener.java | 19 +- .../pixelpics/survival/PendingPhotos.java | 92 ++++++ .../pixelpics/survival/PhotoService.java | 71 ++++- .../pixelpics/utils/ImageMapRenderer.java | 16 +- .../minecraft/pixelpics/utils/MapManager.java | 178 +++++++++--- .../pixelpics/utils/MapUsageScan.java | 268 ++++++++++++++++++ 14 files changed, 819 insertions(+), 106 deletions(-) create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/survival/PendingPhotos.java create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapUsageScan.java 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; + } +}