diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/README.md b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/README.md index 43daa2c..fc32c4b 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/README.md +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/README.md @@ -9,6 +9,9 @@ | `DimensionScheduleText` | End-Grün (`#80A71A`), Nether-Rot (`#B83D32`) und deutsche Formatierung von Restzeiten und Terminen. Absolute Zeiten werden in `Europe/Berlin` mit Zeitzone ausgegeben. | | `EndPrevent` | Besitzt die End-Effektausgabe. Erstellt in `onEnable()` eine `EndOpeningSchedule`, wenn die Scheduler-Appliance vorhanden ist, und stoppt sie in `onDisable()`. Verwaltet End-Zustand und Weltgrenze. | | `NetherAccess` | Neue Appliance in `common`. Besitzt die Nether-Effektausgabe, erstellt und stoppt `NetherOpeningSchedule` und verwaltet den Nether-Zustand. Die bisherigen Varo-Klassen bleiben unverändert und werden hierfür nicht eingebunden. | +| `NetherPortalIgniter` / `NetherIgniterCommand` | Erstellen das eindeutig markierte Portalfeuerzeug und geben es über `/netherIgniter` an OPs im Kreativmodus aus. | +| `MarkedNetherPortals` | Wird von `NetherAccess` erstellt. Verwaltet Markierungen, Chunk-Index und die vorübergehende Aktivierungswarteschlange auf dem Hauptthread. | +| `MarkedNetherPortal` / `PortalMarkStore` | Unveränderliche Portalgeometrie ohne gespeicherte Welt-/Block-/Spielerobjekte und serialisierte Hintergrundspeicherung in einer eigenen YAML. | | `EndOpeningSchedule` / `NetherOpeningSchedule` | Je ein Bukkit-Task. Starten im nächsten Tick nach der Initialisierung aller Appliances und prüfen dann alle 20 Ticks Termine und Ankündigungen. Die Zustandslogik bleibt bei der jeweiligen Dimension; End behält seine Outer-End-Logik. | | `DimensionOpeningEffects` | Gemeinsame Nachrichten- und Soundausgabe mit übergebenen Konfigurationsgruppen und Farbe. Wählt Ablehnungstexte zufällig aus und gibt Effekte über `play(effect)` aus. Den Nachrichten-Cooldown verwaltet der jeweilige Portal-Listener. | | `OpeningAnnouncements` | Gemeinsame Verfolgung fälliger und übersprungener Vorankündigungen. Verwendet die dynamischen Effekte, ohne Zustände zu ändern oder eigene Tasks anzulegen. | @@ -179,15 +182,65 @@ Entfernung zum Portal ermittelt. Nachrichten und Sounds teilen einen Cooldown vo Sekunden pro Spieler. Die Sperre gilt auch während des Cooldowns; Spieler-Einträge werden beim Verlassen entfernt. Alle Nether-Ausgaben verwenden Nether-Rot (`#B83D32`). +## Portalfeuerzeug und vorgemerkte Netherportale + +OPs im Kreativmodus erhalten mit `/netherIgniter` ein glänzendes Feuerzeug namens +„Portalfeuerzeug“ mit erklärender Lore. Bei vollem Inventar schlägt der Befehl fehl; +es wird kein Item fallen gelassen. Ein PersistentDataContainer-Marker identifiziert das +Item unabhängig vom Anzeigenamen. Das Umbenennen eines normalen Feuerzeugs genügt nicht. +Auch ein weitergegebenes Portalfeuerzeug lässt sich ausschließlich im Kreativmodus benutzen. + +Bei offenem Nether und für Rückportale innerhalb des Nethers bleibt die gewöhnliche +Vanilla-Benutzung erhalten. Ist der Nether geschlossen, kann das Spezialitem gültige +Zugangsportale außerhalb des Nethers vormerken: Der Listener erfasst die tatsächlich +verwendete Hand, Position und den Server-Tick. Erst das zugehörige native +`PortalCreateEvent(FIRE)` liefert den geprüften Rahmen. Die Portalbildung wird abgebrochen +und das zurückgebliebene Zündfeuer im nächsten Tick gezielt entfernt. Statt einer +Ablehnungsnachricht erhält nur der Spieler eine Bestätigung in Nether-Rot, sobald die +Markierung erfolgreich gespeichert wurde. Mehrfaches Vormerken erzeugt keinen zweiten Eintrag. + +`plugins/spawn/marked-nether-portals.yml` enthält Welt- und Ersteller-UUID, Innenraum, +Achse, Rahmen und Zündposition. Die Datei entsteht beim ersten Speichern einer Markierung. +Ein eigener serieller Hintergrund-Worker schreibt Änderungen zuerst in eine temporäre +Datei und ersetzt anschließend die Speicherdatei. Fehlgeschlagene Schreibvorgänge werden +nicht als erfolgreiche Markierung bestätigt. Eine beschädigte Speicherdatei wird weder +überschrieben noch teilweise geladen; neue Markierungen bleiben bis zur Behebung und +erneuten Aktivierung der Appliance gesperrt. Die Termin- und Effekt-YAML bleibt unverändert. + +Der gemeinsame Zustandswechsel von `NetherAccess` startet vorgemerkte Portale bei Öffnung +per Command, Zeitplan und stiller Wiederherstellung. Beim Serverstart werden gespeicherte +Markierungen erst nach der anfänglichen Terminprüfung berücksichtigt. Dadurch funktioniert +auch ein bereits gespeichert offener Nether, ohne vergangene Öffnungsnachrichten abzuspielen. + +Es werden keine Chunks geladen oder erzeugt. Ein Chunk-Index ordnet reguläre +`ChunkLoadEvent`s den betroffenen Portalen zu. Erst wenn alle Chunks des Rahmens und des +benötigten Vanilla-Suchbereichs geladen sind, wird das Portal zur Aktivierung eingereiht. +Ungeladene Portale bleiben gespeichert und öffnen entsprechend später, wenn ihre Umgebung +regulär geladen wird. Die Warteschlange ist dedupliziert und bearbeitet höchstens ein Portal +pro Tick. Ihr Task läuft ausschließlich bei gefüllter Warteschlange; Schließen des Nethers +oder Deaktivieren der Appliance stoppt die Verarbeitung. + +Vor der Zündung werden Zugangszustand, geladene Chunks, Obsidianrahmen und freier Innenraum +erneut geprüft. Beschädigte oder verbaute Portale werden mit protokollierter Ursache aus der +Registrierung entfernt; fremde Blöcke werden nicht ersetzt. Die Zündung setzt Feuer mit +Physik und überlässt die Portalbildung Vanilla. Abgebrochene `BlockIgniteEvent`s und native +`PortalCreateEvent`s anderer Plugins werden respektiert: Die Markierung bleibt gespeichert, +der Grund wird protokolliert. Es gibt keinen laufenden Wiederholungsversuch; erst erneutes +reguläres Laden der Umgebung oder eine weitere Nether-Öffnung kann das Portal erneut einreihen. +Erfolg wird an den tatsächlichen Portalblöcken geprüft. Erfolgreich aktivierte sowie bereits +vollständig aktive Portale werden aus der Speicherdatei entfernt. + ## Erweiterungen und Leistung Weitere Effektarten können später zentral in `DimensionOpeningEffects.play(effect)` ergänzt werden und werden bei manueller sowie zeitgesteuerter Öffnung gleichermaßen ausgeführt. Neue Ankündigungen benötigen nur einen weiteren YAML-Eintrag mit Minutenwert und Text. -Es bleibt bei einem Task pro Dimension mit wenigen Zeitvergleichen pro Sekunde und je einer +Für Termine bleibt es bei einem Task pro Dimension mit wenigen Zeitvergleichen pro Sekunde und je einer kleinen Menge behandelter Effektnamen. Es entsteht kein allgemeines Scheduler-Framework. Die Prüfung arbeitet im Arbeitsspeicher. Chat, Zustandswechsel und spätere Bukkit-Effekte laufen auf dem Server-Hauptthread. Gespeichert wird nur bei tatsächlichen Zustandsänderungen in `config.yml`; die Termin-YAML wird nach -ihrer erstmaligen Erstellung ausschließlich gelesen. Es gibt keine persistenten Ausführungsstatus. +ihrer erstmaligen Erstellung ausschließlich gelesen. Es gibt keine persistenten Ausführungsstatus +für Ankündigungen. Portalmarkierungen verwenden davon getrennt die oben beschriebene Datei und +begrenzte Warteschlange; ihre Dateioperationen laufen außerhalb des Server-Hauptthreads. diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/MarkedNetherPortal.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/MarkedNetherPortal.java new file mode 100644 index 0000000..bd85744 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/MarkedNetherPortal.java @@ -0,0 +1,232 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess; + +import org.bukkit.Axis; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.data.Orientable; +import org.bukkit.entity.Player; +import org.bukkit.event.world.PortalCreateEvent; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** An immutable portal shape; no blocks, worlds or players survive the marking event. */ +record MarkedNetherPortal( + UUID worldId, + UUID creatorId, + Position origin, + int width, + int height, + Axis axis, + Position ignition, + List frame +) { + private static final int NATIVE_SEARCH_RADIUS = 22; + + record Position(int x, int y, int z) { + Block block(World world) { + return world.getBlockAt(this.x, this.y, this.z); + } + } + + record ChunkKey(UUID worldId, int x, int z) { + } + + enum Validation { + WAITING, + INVALID, + READY, + ALREADY_ACTIVE + } + + MarkedNetherPortal { + Objects.requireNonNull(worldId, "worldId"); + Objects.requireNonNull(creatorId, "creatorId"); + Objects.requireNonNull(origin, "origin"); + Objects.requireNonNull(ignition, "ignition"); + Objects.requireNonNull(frame, "frame"); + if(axis != Axis.X && axis != Axis.Z) throw new IllegalArgumentException("Invalid portal axis"); + if(width < 2 || width > 21 || height < 3 || height > 21) { + throw new IllegalArgumentException("Invalid portal dimensions"); + } + + List expectedFrame; + try { + expectedFrame = framePositions(origin, width, height, axis); + Math.subtractExact(ignition.x, NATIVE_SEARCH_RADIUS); + Math.addExact(ignition.x, NATIVE_SEARCH_RADIUS); + Math.subtractExact(ignition.z, NATIVE_SEARCH_RADIUS); + Math.addExact(ignition.z, NATIVE_SEARCH_RADIUS); + } catch(ArithmeticException exception) { + throw new IllegalArgumentException("Portal coordinates overflow", exception); + } + if(!contains(origin, width, height, axis, ignition)) { + throw new IllegalArgumentException("Ignition position is outside the portal"); + } + if(frame.size() != expectedFrame.size() || !new HashSet<>(frame).equals(new HashSet<>(expectedFrame))) { + throw new IllegalArgumentException("Frame positions do not match the portal perimeter"); + } + frame = List.copyOf(expectedFrame); + } + + String id() { + return this.worldId + ":" + this.origin.x + ":" + this.origin.y + ":" + this.origin.z + + ":" + this.axis + ":" + this.width + ":" + this.height; + } + + /** Uses proposed block snapshots, after Vanilla has already recognised a valid frame. */ + static MarkedNetherPortal from(PortalCreateEvent event, Player creator, Position ignition) { + if(event.getReason() != PortalCreateEvent.CreateReason.FIRE) { + throw new IllegalArgumentException("Only fire-created portals can be marked"); + } + + Set interior = new HashSet<>(); + Set obsidian = new HashSet<>(); + Axis axis = null; + for(BlockState block : event.getBlocks()) { + if(!block.getWorld().getUID().equals(event.getWorld().getUID())) { + throw new IllegalArgumentException("Portal snapshots belong to a different world"); + } + Position position = new Position(block.getX(), block.getY(), block.getZ()); + if(block.getType() == Material.OBSIDIAN) obsidian.add(position); + if(block.getType() != Material.NETHER_PORTAL) continue; + if(!(block.getBlockData() instanceof Orientable orientable)) { + throw new IllegalArgumentException("Portal snapshot has no axis"); + } + Axis blockAxis = orientable.getAxis(); + if(blockAxis != Axis.X && blockAxis != Axis.Z) { + throw new IllegalArgumentException("Invalid portal snapshot axis"); + } + if(axis != null && axis != blockAxis) throw new IllegalArgumentException("Mixed portal axes"); + axis = blockAxis; + if(!interior.add(position)) throw new IllegalArgumentException("Duplicate portal snapshot"); + } + if(interior.isEmpty()) throw new IllegalArgumentException("No proposed portal blocks"); + + int minX = interior.stream().mapToInt(Position::x).min().orElseThrow(); + int maxX = interior.stream().mapToInt(Position::x).max().orElseThrow(); + int minY = interior.stream().mapToInt(Position::y).min().orElseThrow(); + int maxY = interior.stream().mapToInt(Position::y).max().orElseThrow(); + int minZ = interior.stream().mapToInt(Position::z).min().orElseThrow(); + int maxZ = interior.stream().mapToInt(Position::z).max().orElseThrow(); + if((axis == Axis.X && minZ != maxZ) || (axis == Axis.Z && minX != maxX)) { + throw new IllegalArgumentException("Portal snapshots do not form a plane"); + } + long proposedWidth = axis == Axis.X ? (long) maxX - minX + 1 : (long) maxZ - minZ + 1; + long proposedHeight = (long) maxY - minY + 1; + if(proposedWidth < 2 || proposedWidth > 21 || proposedHeight < 3 || proposedHeight > 21 + || interior.size() != proposedWidth * proposedHeight) { + throw new IllegalArgumentException("Portal snapshots do not form a valid rectangle"); + } + int width = (int) proposedWidth; + int height = (int) proposedHeight; + + Position origin = new Position(minX, minY, minZ); + for(int horizontal = 0; horizontal < width; horizontal++) { + for(int vertical = 0; vertical < height; vertical++) { + if(!interior.contains(offset(origin, horizontal, vertical, axis))) { + throw new IllegalArgumentException("Portal snapshots contain a hole"); + } + } + } + List frame = framePositions(origin, width, height, axis); + if(!obsidian.containsAll(frame)) throw new IllegalArgumentException("Missing obsidian frame snapshots"); + return new MarkedNetherPortal( + event.getWorld().getUID(), creator.getUniqueId(), origin, width, height, axis, ignition, frame + ); + } + + Set requiredChunks() { + Set chunks = new LinkedHashSet<>(); + // Native fire placement searches neighbouring possible frames, not only this exact rectangle. + this.addChunkRectangle( + chunks, + this.ignition.x - NATIVE_SEARCH_RADIUS, this.ignition.x + NATIVE_SEARCH_RADIUS, + this.ignition.z - NATIVE_SEARCH_RADIUS, this.ignition.z + NATIVE_SEARCH_RADIUS + ); + for(Position position : this.frame) { + chunks.add(new ChunkKey(this.worldId, Math.floorDiv(position.x, 16), Math.floorDiv(position.z, 16))); + } + return Set.copyOf(chunks); + } + + boolean isLoaded(World world) { + if(world == null || !world.getUID().equals(this.worldId)) return false; + for(ChunkKey chunk : this.requiredChunks()) { + if(!world.isChunkLoaded(chunk.x, chunk.z)) return false; + } + return true; + } + + /** Must run on the main thread; no block is read until every required chunk is present. */ + Validation validate(World world) { + if(!this.isLoaded(world)) return Validation.WAITING; + if(this.origin.y - 1 < world.getMinHeight() || this.origin.y + this.height >= world.getMaxHeight()) { + return Validation.INVALID; + } + for(Position position : this.frame) { + if(position.block(world).getType() != Material.OBSIDIAN) return Validation.INVALID; + } + + int active = 0; + for(int horizontal = 0; horizontal < this.width; horizontal++) { + for(int vertical = 0; vertical < this.height; vertical++) { + Block block = offset(this.origin, horizontal, vertical, this.axis).block(world); + Material type = block.getType(); + if(type == Material.NETHER_PORTAL) { + if(!(block.getBlockData() instanceof Orientable data) || data.getAxis() != this.axis) { + return Validation.INVALID; + } + active++; + } else if(!type.isAir() && type != Material.FIRE) { + return Validation.INVALID; + } + } + } + if(active == this.width * this.height) return Validation.ALREADY_ACTIVE; + return active == 0 ? Validation.READY : Validation.INVALID; + } + + private void addChunkRectangle(Set chunks, int minX, int maxX, int minZ, int maxZ) { + for(int x = Math.floorDiv(minX, 16); x <= Math.floorDiv(maxX, 16); x++) { + for(int z = Math.floorDiv(minZ, 16); z <= Math.floorDiv(maxZ, 16); z++) { + chunks.add(new ChunkKey(this.worldId, x, z)); + } + } + } + + private static boolean contains(Position origin, int width, int height, Axis axis, Position position) { + long horizontal = axis == Axis.X ? (long) position.x - origin.x : (long) position.z - origin.z; + long vertical = (long) position.y - origin.y; + boolean samePlane = axis == Axis.X ? position.z == origin.z : position.x == origin.x; + return samePlane && horizontal >= 0 && horizontal < width && vertical >= 0 && vertical < height; + } + + private static List framePositions(Position origin, int width, int height, Axis axis) { + List positions = new ArrayList<>(2 * (width + height)); + for(int horizontal = 0; horizontal < width; horizontal++) { + positions.add(offset(origin, horizontal, -1, axis)); + positions.add(offset(origin, horizontal, height, axis)); + } + for(int vertical = 0; vertical < height; vertical++) { + positions.add(offset(origin, -1, vertical, axis)); + positions.add(offset(origin, width, vertical, axis)); + } + return positions; + } + + private static Position offset(Position origin, int horizontal, int vertical, Axis axis) { + return new Position( + Math.addExact(origin.x, axis == Axis.X ? horizontal : 0), + Math.addExact(origin.y, vertical), + Math.addExact(origin.z, axis == Axis.Z ? horizontal : 0) + ); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/MarkedNetherPortals.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/MarkedNetherPortals.java new file mode 100644 index 0000000..d40989d --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/MarkedNetherPortals.java @@ -0,0 +1,284 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess; + +import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText; +import eu.mhsl.craftattack.spawn.core.Main; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.world.PortalCreateEvent; +import org.bukkit.plugin.IllegalPluginAccessException; +import org.bukkit.scheduler.BukkitTask; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; + +/** Main-thread registry and bounded activation queue; only the store touches its YAML. */ +final class MarkedNetherPortals { + private final NetherAccess owner; + private final PortalMarkStore store; + private final Map portals = new LinkedHashMap<>(); + private final Map> chunkIndex = new HashMap<>(); + private final Set removing = new HashSet<>(); + private final LinkedHashSet queue = new LinkedHashSet<>(); + private @Nullable BukkitTask processingTask; + private @Nullable BukkitTask startupTask; + private boolean loaded; + private boolean loadFailed; + private boolean ready; + private volatile boolean closed; + + MarkedNetherPortals(NetherAccess owner) { + this.owner = owner; + this.store = new PortalMarkStore(Main.instance().getDataFolder().toPath().resolve("marked-nether-portals.yml")); + } + + void start() { + this.store.load().whenComplete((saved, error) -> { + if(error != null) { + this.logStorageFailure("Portalmarkierungen konnten nicht geladen werden; neue Markierungen sind gesperrt.", error); + this.onMainThread(() -> this.loadFailed = true); + return; + } + this.onMainThread(() -> { + saved.forEach(this::register); + this.loaded = true; + this.enqueueLoadedPortals(); + }); + }); + // The opening schedule reconciles its initial state on tick one. In particular, + // a persisted OPEN must not ignite portals before a future date restores CLOSED. + this.startupTask = Bukkit.getScheduler().runTaskLater(Main.instance(), () -> { + this.startupTask = null; + this.ready = true; + this.enqueueLoadedPortals(); + }, 2L); + } + + void mark(PortalCreateEvent event, Player player, MarkedNetherPortal.Position ignition) { + UUID playerId = player.getUniqueId(); + UUID worldId = event.getWorld().getUID(); + // Cancellation leaves the source fire behind. Remove only that exact fire, + // after the original native ignition has returned, without touching other blocks. + Bukkit.getScheduler().runTask(Main.instance(), () -> { + if(this.closed) return; + World world = Bukkit.getWorld(worldId); + if(world == null || !world.isChunkLoaded(ignition.x() >> 4, ignition.z() >> 4)) return; + Block block = ignition.block(world); + if(block.getType() == Material.FIRE) block.setType(Material.AIR, false); + }); + if(!this.loaded) { + this.message(playerId, this.loadFailed + ? "Die Portal-Speicherdatei konnte nicht geladen werden. Bitte informiere einen Administrator. Das Portal wurde nicht vorgemerkt." + : "Die Portalmarkierungen werden noch geladen. Bitte versuche es gleich erneut."); + return; + } + + final MarkedNetherPortal portal; + try { + portal = MarkedNetherPortal.from(event, player, ignition); + } catch(IllegalArgumentException error) { + this.message(playerId, "Dieses Portal konnte nicht vorgemerkt werden."); + Main.logger().log(Level.WARNING, "Ungültige Geometrie beim Vormerken eines Netherportals.", error); + return; + } + this.store.putIfAbsent(portal).whenComplete((saved, error) -> { + if(error != null) { + this.logStorageFailure("Netherportal " + portal.id() + " konnte nicht gespeichert werden.", error); + this.onMainThread(() -> this.message(playerId, "Das Portal konnte nicht gespeichert werden und wurde nicht vorgemerkt. Bitte informiere einen Administrator.")); + return; + } + this.onMainThread(() -> { + boolean existing = this.portals.containsKey(saved.id()); + this.register(saved); + this.message(playerId, existing + ? "Dieses Portal ist bereits vorgemerkt und wird nach der Nether-Freigabe entzündet." + : "Das Portal wurde vorgemerkt und wird nach der Nether-Freigabe automatisch entzündet."); + this.enqueue(saved); + }); + }); + } + + /** Called for both live transitions and silent schedule restoration. */ + void onStateChanged() { + if(this.owner.isClosed()) { + this.queue.clear(); + this.stopProcessing(); + } else { + this.enqueueLoadedPortals(); + } + } + + void onChunkLoad(Chunk chunk) { + if(!this.canProcess()) return; + Set affected = this.chunkIndex.get(new MarkedNetherPortal.ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ())); + if(affected == null) return; + affected.forEach(id -> this.enqueue(this.portals.get(id))); + } + + void onWorldLoad(World world) { + if(!this.canProcess()) return; + this.portals.values().stream().filter(portal -> portal.worldId().equals(world.getUID())).forEach(this::enqueue); + } + + private void register(MarkedNetherPortal portal) { + if(this.portals.putIfAbsent(portal.id(), portal) != null) return; + for(MarkedNetherPortal.ChunkKey chunk : portal.requiredChunks()) { + this.chunkIndex.computeIfAbsent(chunk, ignored -> new HashSet<>()).add(portal.id()); + } + } + + private boolean canProcess() { + return !this.closed && this.loaded && this.ready && !this.owner.isClosed(); + } + + private void enqueueLoadedPortals() { + if(this.canProcess()) this.portals.values().forEach(this::enqueue); + } + + private void enqueue(@Nullable MarkedNetherPortal portal) { + if(portal == null || !this.canProcess() || this.removing.contains(portal.id())) return; + World world = Bukkit.getWorld(portal.worldId()); + if(world == null || !portal.isLoaded(world)) return; + this.queue.add(portal.id()); + if(this.processingTask == null) { + this.processingTask = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::processNext, 1L, 1L); + } + } + + private void processNext() { + if(!this.canProcess()) { + this.queue.clear(); + this.stopProcessing(); + return; + } + if(this.queue.isEmpty()) { + this.stopProcessing(); + return; + } + String id = this.queue.removeFirst(); + MarkedNetherPortal portal = this.portals.get(id); + World world = portal == null ? null : Bukkit.getWorld(portal.worldId()); + if(portal != null && world != null && !this.removing.contains(id)) { + switch(portal.validate(world)) { + case WAITING -> { /* A required chunk unloaded while this entry was queued. */ } + case INVALID -> this.discard(portal, "Rahmen beschädigt oder Innenraum verbaut"); + case ALREADY_ACTIVE -> this.remove(portal); + case READY -> this.ignite(portal, world); + } + } + if(this.queue.isEmpty()) this.stopProcessing(); + } + + private void ignite(MarkedNetherPortal portal, World world) { + Block ignition = portal.ignition().block(world); + Player creator = Bukkit.getPlayer(portal.creatorId()); + BlockIgniteEvent igniteEvent = new BlockIgniteEvent(ignition, BlockIgniteEvent.IgniteCause.FLINT_AND_STEEL, creator); + Bukkit.getPluginManager().callEvent(igniteEvent); + if(igniteEvent.isCancelled()) { + this.logRetained(portal, "BlockIgniteEvent abgebrochen"); + return; + } + // Other plugins may have changed access, blocks or chunk availability in the event. + if(!this.canProcess()) return; + switch(portal.validate(world)) { + case WAITING -> { return; } + case INVALID -> { this.discard(portal, "Portal während des Zündereignisses verändert"); return; } + case ALREADY_ACTIVE -> { this.remove(portal); return; } + case READY -> { } + } + + boolean hadFire = ignition.getType() == Material.FIRE; + if(hadFire) ignition.setType(Material.AIR, false); + // Native physics performs shape validation and fires PortalCreateEvent. Writing + // portal blocks ourselves would bypass cancellation by protection plugins. + ignition.setType(Material.FIRE, true); + MarkedNetherPortal.Validation result = portal.validate(world); + if(result == MarkedNetherPortal.Validation.ALREADY_ACTIVE) { + this.remove(portal); + } else { + if(!hadFire && world.isChunkLoaded(ignition.getX() >> 4, ignition.getZ() >> 4) + && ignition.getType() == Material.FIRE) ignition.setType(Material.AIR, false); + if(result == MarkedNetherPortal.Validation.INVALID) { + this.discard(portal, "Portal beim Zünden beschädigt oder verbaut"); + } else { + this.logRetained(portal, "Vanilla-Portalbildung nicht erfolgt oder von einem Plugin abgebrochen"); + } + } + } + + private void discard(MarkedNetherPortal portal, String reason) { + Main.logger().warning("Netherportal-Markierung " + portal.id() + " wird entfernt: " + reason + "."); + this.remove(portal); + } + + private void logRetained(MarkedNetherPortal portal, String reason) { + Main.logger().warning("Netherportal " + portal.id() + " bleibt vorgemerkt: " + reason + + ". Kein automatischer Wiederholungsversuch ohne erneutes Laden oder Öffnen."); + } + + private void remove(MarkedNetherPortal portal) { + String id = portal.id(); + if(!this.removing.add(id)) return; + this.store.remove(id).whenComplete((ignored, error) -> { + if(error != null) this.logStorageFailure("Netherportal-Markierung " + id + " konnte nicht entfernt werden.", error); + this.onMainThread(() -> { + this.removing.remove(id); + if(error != null) return; + this.portals.remove(id); + this.queue.remove(id); + for(MarkedNetherPortal.ChunkKey chunk : portal.requiredChunks()) { + Set entries = this.chunkIndex.get(chunk); + if(entries == null) continue; + entries.remove(id); + if(entries.isEmpty()) this.chunkIndex.remove(chunk); + } + }); + }); + } + + private void message(UUID playerId, String text) { + Player player = Bukkit.getPlayer(playerId); + if(player != null) player.sendMessage(DimensionScheduleText.message(text, DimensionScheduleText.NETHER_RED)); + } + + private void onMainThread(Runnable action) { + if(this.closed) return; + try { + Bukkit.getScheduler().runTask(Main.instance(), () -> { + if(!this.closed) action.run(); + }); + } catch(IllegalPluginAccessException ignored) { + // Persisted changes survive shutdown even when no main-thread callback can run. + } + } + + private void logStorageFailure(String message, Throwable error) { + Main.logger().log(Level.SEVERE, message, error); + } + + private void stopProcessing() { + if(this.processingTask == null) return; + this.processingTask.cancel(); + this.processingTask = null; + } + + void close() { + this.closed = true; + if(this.startupTask != null) this.startupTask.cancel(); + this.startupTask = null; + this.stopProcessing(); + this.queue.clear(); + this.store.close(); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccess.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccess.java index 7703324..81a71a6 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccess.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccess.java @@ -12,6 +12,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Objects; /** Owns Nether access; commands and the schedule use the same persisted state transition. */ public class NetherAccess extends Appliance { @@ -20,6 +21,7 @@ public class NetherAccess extends Appliance { ); private State state; private @Nullable NetherOpeningSchedule openingSchedule; + private @Nullable MarkedNetherPortals markedPortals; public enum State { CLOSED, @@ -34,6 +36,9 @@ public class NetherAccess extends Appliance { @Override public void onEnable() { if(this.openingSchedule != null) this.openingSchedule.stop(); + if(this.markedPortals != null) this.markedPortals.close(); + this.markedPortals = new MarkedNetherPortals(this); + this.markedPortals.start(); DimensionScheduler scheduler = Main.instance().getAppliances().stream() .filter(DimensionScheduler.class::isInstance) .map(DimensionScheduler.class::cast) @@ -48,6 +53,7 @@ public class NetherAccess extends Appliance { @Override public void onDisable() { if(this.openingSchedule != null) this.openingSchedule.stop(); + if(this.markedPortals != null) this.markedPortals.close(); } public void setState(State state) { @@ -65,6 +71,7 @@ public class NetherAccess extends Appliance { this.state = state; this.localConfig().set("state", state.name()); Configuration.saveChanges(); + if(this.markedPortals != null) this.markedPortals.onStateChanged(); if(announceOpening && wasClosed && !this.isClosed()) this.openingEffects.playOpening(); } @@ -80,6 +87,10 @@ public class NetherAccess extends Appliance { return this.openingEffects; } + MarkedNetherPortals markedPortals() { + return Objects.requireNonNull(this.markedPortals, "Nether portal registry is not enabled"); + } + @Override protected @NotNull List listeners() { return List.of(new NetherAccessListener()); @@ -87,6 +98,6 @@ public class NetherAccess extends Appliance { @Override protected @NotNull List> commands() { - return List.of(new NetherCommand(), new NetherPreventCommand()); + return List.of(new NetherCommand(), new NetherPreventCommand(), new NetherIgniterCommand()); } } diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccessListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccessListener.java index c94089d..5086e88 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccessListener.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccessListener.java @@ -1,16 +1,28 @@ package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess; +import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.block.Block; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.Action; import org.bukkit.event.entity.EntityPortalEvent; +import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerPortalEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.event.world.PortalCreateEvent; +import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.inventory.EquipmentSlot; import org.jetbrains.annotations.Nullable; import java.util.HashMap; @@ -21,8 +33,39 @@ import java.util.concurrent.TimeUnit; class NetherAccessListener extends ApplianceListener { private static final long MESSAGE_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3); private final Map lastDeniedMessageAt = new HashMap<>(); + private final Map ignitionAttempts = new HashMap<>(); - @EventHandler(ignoreCancelled = true) + @EventHandler(priority = EventPriority.HIGHEST) + public void onSpecialItemUse(PlayerInteractEvent event) { + if(!NetherPortalIgniter.isSpecial(event.getItem()) || event.getPlayer().getGameMode() == GameMode.CREATIVE) return; + event.setCancelled(true); + this.ignitionAttempts.remove(event.getPlayer().getUniqueId()); + if(!this.canSendMessage(event.getPlayer())) return; + event.getPlayer().sendMessage(DimensionScheduleText.message( + "Das Portalfeuerzeug kann nur im Kreativmodus verwendet werden.", DimensionScheduleText.NETHER_RED + )); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onIgnitionAttempt(PlayerInteractEvent event) { + if(event.getAction() != Action.RIGHT_CLICK_BLOCK) return; + UUID playerId = event.getPlayer().getUniqueId(); + // Record the hand actually used, including ordinary tools. Merely holding the special + // item in the other hand must never turn a normal ignition into a marked portal. + this.ignitionAttempts.remove(playerId); + if(event.useItemInHand() == Event.Result.DENY || event.useInteractedBlock() == Event.Result.DENY) return; + if(event.getClickedBlock() == null || event.getHand() == null || event.getItem() == null) return; + Material material = event.getItem().getType(); + if(material != Material.FLINT_AND_STEEL && material != Material.FIRE_CHARGE) return; + Block ignition = event.getClickedBlock().getRelative(event.getBlockFace()); + this.ignitionAttempts.put(playerId, new IgnitionAttempt( + ignition.getWorld().getUID(), event.getHand(), Bukkit.getCurrentTick(), + new MarkedNetherPortal.Position(ignition.getX(), ignition.getY(), ignition.getZ()), + NetherPortalIgniter.isSpecial(event.getItem()) + )); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onPortalCreate(PortalCreateEvent event) { boolean nether = event.getWorld().getEnvironment() == World.Environment.NETHER; boolean blockedCreation = switch(event.getReason()) { @@ -30,7 +73,30 @@ class NetherAccessListener extends ApplianceListener { case NETHER_PAIR -> nether; default -> false; }; - if(blockedCreation) this.cancelIfClosed(event, event.getEntity()); + if(!blockedCreation || !this.getAppliance().isClosed()) return; + if(event.getReason() == PortalCreateEvent.CreateReason.FIRE && event.getEntity() instanceof Player player) { + IgnitionAttempt attempt = this.ignitionAttempts.remove(player.getUniqueId()); + if(attempt != null && attempt.special() && player.getGameMode() == GameMode.CREATIVE + && attempt.tick() == Bukkit.getCurrentTick() && attempt.worldId().equals(event.getWorld().getUID()) + && event.getBlocks().stream().anyMatch(block -> block.getType() == Material.NETHER_PORTAL + && block.getX() == attempt.ignition().x() && block.getY() == attempt.ignition().y() + && block.getZ() == attempt.ignition().z())) { + event.setCancelled(true); + this.getAppliance().markedPortals().mark(event, player, attempt.ignition()); + return; + } + } + this.cancelIfClosed(event, event.getEntity()); + } + + @EventHandler + public void onChunkLoad(ChunkLoadEvent event) { + this.getAppliance().markedPortals().onChunkLoad(event.getChunk()); + } + + @EventHandler + public void onWorldLoad(WorldLoadEvent event) { + this.getAppliance().markedPortals().onWorldLoad(event.getWorld()); } @EventHandler(ignoreCancelled = true) @@ -46,6 +112,7 @@ class NetherAccessListener extends ApplianceListener { @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { this.lastDeniedMessageAt.remove(event.getPlayer().getUniqueId()); + this.ignitionAttempts.remove(event.getPlayer().getUniqueId()); } private static boolean isNetherDestination(@Nullable Location destination) { @@ -58,11 +125,19 @@ class NetherAccessListener extends ApplianceListener { event.setCancelled(true); if(!(actor instanceof Player player)) return; + if(this.canSendMessage(player)) this.getAppliance().effects().denyActivation(player); + } + + private boolean canSendMessage(Player player) { // Cancellation always applies; message and sound share an elapsed-real-time cooldown. long now = System.nanoTime(); Long previousMessageAt = this.lastDeniedMessageAt.get(player.getUniqueId()); - if(previousMessageAt != null && now - previousMessageAt < MESSAGE_COOLDOWN_NANOS) return; + if(previousMessageAt != null && now - previousMessageAt < MESSAGE_COOLDOWN_NANOS) return false; this.lastDeniedMessageAt.put(player.getUniqueId(), now); - this.getAppliance().effects().denyActivation(player); + return true; + } + + private record IgnitionAttempt(UUID worldId, EquipmentSlot hand, int tick, + MarkedNetherPortal.Position ignition, boolean special) { } } diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherIgniterCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherIgniterCommand.java new file mode 100644 index 0000000..d1c8cde --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherIgniterCommand.java @@ -0,0 +1,35 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess; + +import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import org.bukkit.GameMode; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +class NetherIgniterCommand extends ApplianceCommand { + public NetherIgniterCommand() { + super("netherIgniter", DimensionScheduleText.message("Fehler: ", DimensionScheduleText.NETHER_RED)); + } + + @Override + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if(!(sender instanceof Player player)) throw new Error("Dieser Befehl kann nur von Spielern ausgeführt werden."); + if(!player.isOp()) throw new Error("Dieser Befehl ist nur für OPs verfügbar."); + if(player.getGameMode() != GameMode.CREATIVE) throw new Error("Das Portalfeuerzeug ist nur im Kreativmodus verfügbar."); + if(args.length != 0) throw new Error("Verwendung: /netherIgniter"); + if(player.getInventory().firstEmpty() == -1) throw new Error("Dein Inventar ist voll. Schaffe einen freien Platz für das Portalfeuerzeug."); + if(!player.getInventory().addItem(NetherPortalIgniter.create()).isEmpty()) { + throw new Error("Das Portalfeuerzeug konnte nicht in dein Inventar gelegt werden."); + } + player.sendMessage(DimensionScheduleText.message("Du hast ein Portalfeuerzeug erhalten.", DimensionScheduleText.NETHER_RED)); + } + + @Override + public @NotNull List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + return List.of(); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherPortalIgniter.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherPortalIgniter.java new file mode 100644 index 0000000..345ef60 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherPortalIgniter.java @@ -0,0 +1,32 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess; + +import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText; +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.util.inventory.ItemBuilder; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.Nullable; + +/** The data marker identifies the item independently of its displayed name. */ +final class NetherPortalIgniter { + private static final NamespacedKey KEY = new NamespacedKey(Main.instance(), "nether_portal_igniter"); + + private NetherPortalIgniter() { + } + + static ItemStack create() { + return ItemBuilder.of(Material.FLINT_AND_STEEL) + .displayName(DimensionScheduleText.message("Portalfeuerzeug", DimensionScheduleText.NETHER_RED)) + .lore("Nur im Kreativmodus verwendbar. Bei geschlossenem Nether werden gültige Portale zur späteren Öffnung vorgemerkt. Bei offenem Nether funktioniert es wie ein normales Feuerzeug.") + .glint() + .withMeta(meta -> meta.getPersistentDataContainer().set(KEY, PersistentDataType.BOOLEAN, true)) + .build(); + } + + static boolean isSpecial(@Nullable ItemStack item) { + return item != null && item.getType() == Material.FLINT_AND_STEEL && item.hasItemMeta() + && Boolean.TRUE.equals(item.getItemMeta().getPersistentDataContainer().get(KEY, PersistentDataType.BOOLEAN)); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/PortalMarkStore.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/PortalMarkStore.java new file mode 100644 index 0000000..65f69bf --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/PortalMarkStore.java @@ -0,0 +1,237 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess; + +import org.bukkit.Axis; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Logger; + +/** Serial file ownership keeps failed writes out of both the committed map and later snapshots. */ +final class PortalMarkStore implements AutoCloseable { + private static final int FORMAT_VERSION = 1; + private static final Logger LOGGER = Logger.getLogger(PortalMarkStore.class.getName()); + // Kept across store instances: timed-out old IO must finish before a restarted appliance can load this path. + private static final ConcurrentHashMap PATH_LOCKS = new ConcurrentHashMap<>(); + + private final Path path; + private final ReentrantLock pathLock; + private final ExecutorService worker = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "Spawn-PortalMarks"); + thread.setDaemon(true); + return thread; + }); + private final Set> pending = ConcurrentHashMap.newKeySet(); + + // Only the IO worker accesses the following state. + private Map committed = new LinkedHashMap<>(); + private boolean loaded; + + PortalMarkStore(Path path) { + this.path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + this.pathLock = PATH_LOCKS.computeIfAbsent(this.path, ignored -> new ReentrantLock()); + } + + CompletableFuture> load() { + return this.submit(() -> { + if(this.loaded) return List.copyOf(this.committed.values()); + Map entries = new LinkedHashMap<>(); + if(!Files.notExists(this.path)) { + YamlConfiguration yaml = new YamlConfiguration(); + try(Reader reader = Files.newBufferedReader(this.path, StandardCharsets.UTF_8)) { + yaml.load(reader); + } catch(InvalidConfigurationException exception) { + throw new IOException("Invalid portal marking YAML: " + this.path, exception); + } + Object version = yaml.get("version"); + if(!(version instanceof Integer number) || number != FORMAT_VERSION) { + throw new IOException("Unsupported or missing portal marking version: " + this.path); + } + if(!(yaml.get("portals") instanceof List portals)) { + throw new IOException("Missing portal marking list: " + this.path); + } + for(int index = 0; index < portals.size(); index++) { + MarkedNetherPortal portal; + try { + portal = readPortal(portals.get(index)); + } catch(RuntimeException exception) { + throw new IOException("Invalid portal marking at index " + index + ": " + this.path, exception); + } + if(entries.putIfAbsent(portal.id(), portal) != null) { + throw new IOException("Duplicate portal marking: " + portal.id()); + } + } + } + this.committed = entries; + this.loaded = true; + return List.copyOf(entries.values()); + }); + } + + CompletableFuture putIfAbsent(MarkedNetherPortal portal) { + return this.submit(() -> { + this.requireLoaded(); + Objects.requireNonNull(portal, "portal"); + MarkedNetherPortal existing = this.committed.get(portal.id()); + if(existing != null) return existing; + Map snapshot = new LinkedHashMap<>(this.committed); + snapshot.put(portal.id(), portal); + this.save(snapshot); + this.committed = snapshot; + return portal; + }); + } + + CompletableFuture remove(String id) { + return this.submit(() -> { + this.requireLoaded(); + if(!this.committed.containsKey(id)) return null; + Map snapshot = new LinkedHashMap<>(this.committed); + snapshot.remove(id); + this.save(snapshot); + this.committed = snapshot; + return null; + }); + } + + private void requireLoaded() throws IOException { + if(!this.loaded) throw new IOException("Portal markings have not been loaded successfully: " + this.path); + } + + private void save(Map snapshot) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("version", FORMAT_VERSION); + yaml.set("portals", snapshot.values().stream().map(PortalMarkStore::writePortal).toList()); + + Path directory = this.path.getParent(); + Files.createDirectories(directory); + Path temporary = Files.createTempFile(directory, this.path.getFileName() + ".", ".tmp"); + try { + Files.writeString(temporary, yaml.saveToString(), StandardCharsets.UTF_8); + try { + Files.move(temporary, this.path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch(AtomicMoveNotSupportedException exception) { + Files.move(temporary, this.path, StandardCopyOption.REPLACE_EXISTING); + } + } catch(IOException | RuntimeException failure) { + try { + Files.deleteIfExists(temporary); + } catch(IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + + private static Map writePortal(MarkedNetherPortal portal) { + Map values = new LinkedHashMap<>(); + values.put("world", portal.worldId().toString()); + values.put("creator", portal.creatorId().toString()); + values.put("origin", writePosition(portal.origin())); + values.put("width", portal.width()); + values.put("height", portal.height()); + values.put("axis", portal.axis().name()); + values.put("ignition", writePosition(portal.ignition())); + values.put("frame", portal.frame().stream().map(PortalMarkStore::writePosition).toList()); + return values; + } + + private static List writePosition(MarkedNetherPortal.Position position) { + return List.of(position.x(), position.y(), position.z()); + } + + private static MarkedNetherPortal readPortal(Object value) { + if(!(value instanceof Map values)) throw new IllegalArgumentException("Expected a portal mapping"); + if(!(values.get("frame") instanceof List frame)) throw new IllegalArgumentException("Missing portal frame"); + return new MarkedNetherPortal( + UUID.fromString(string(values, "world")), + UUID.fromString(string(values, "creator")), + readPosition(values.get("origin")), + integer(values.get("width")), + integer(values.get("height")), + Axis.valueOf(string(values, "axis")), + readPosition(values.get("ignition")), + frame.stream().map(PortalMarkStore::readPosition).toList() + ); + } + + private static String string(Map values, String key) { + if(values.get(key) instanceof String value) return value; + throw new IllegalArgumentException("Missing portal string: " + key); + } + + private static MarkedNetherPortal.Position readPosition(Object value) { + if(!(value instanceof List coordinates) || coordinates.size() != 3) { + throw new IllegalArgumentException("Expected three integer coordinates"); + } + return new MarkedNetherPortal.Position( + integer(coordinates.get(0)), integer(coordinates.get(1)), integer(coordinates.get(2)) + ); + } + + private static int integer(Object value) { + if(value instanceof Integer number) return number; + throw new IllegalArgumentException("Expected an integer"); + } + + private CompletableFuture submit(Callable operation) { + CompletableFuture result = new CompletableFuture<>(); + this.pending.add(result); + result.whenComplete((value, failure) -> this.pending.remove(result)); + try { + this.worker.execute(() -> { + if(result.isDone()) return; + try { + T value; + this.pathLock.lockInterruptibly(); + try { + if(result.isDone()) return; + value = operation.call(); + } finally { + this.pathLock.unlock(); + } + result.complete(value); + } catch(Throwable failure) { + result.completeExceptionally(failure); + } + }); + } catch(RejectedExecutionException failure) { + result.completeExceptionally(failure); + } + return result; + } + + @Override + public void close() { + this.worker.shutdown(); + try { + if(this.worker.awaitTermination(5, TimeUnit.SECONDS)) return; + LOGGER.warning("Timed out while saving portal markings: " + this.path); + } catch(InterruptedException exception) { + Thread.currentThread().interrupt(); + } + this.worker.shutdownNow(); + IOException failure = new IOException("Portal marking storage stopped before queued operations completed"); + this.pending.forEach(future -> future.completeExceptionally(failure)); + } +}