Implement Marked Nether Portal System

- Introduced MarkedNetherPortal and MarkedNetherPortals classes to manage the marking and activation of Nether portals.
- Added PortalMarkStore for persistent storage of marked portals in YAML format.
- Created NetherPortalIgniter for a special item that allows marking portals in creative mode.
- Enhanced NetherAccess and NetherAccessListener to integrate the new portal marking system.
- Implemented commands and event handling for marking portals and managing their states.
- Added validation and chunk loading mechanisms to ensure proper portal functionality.
This commit is contained in:
besiece committed 2026-11-08 22:29:23 +01:00
1 parent 895d2bc2d3
commit fe3af32f89
8 files changed
+966 -7

No files matched your search

@@ -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.
@@ -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<Position> 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<Position> 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<Position> interior = new HashSet<>();
Set<Position> 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<Position> 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<ChunkKey> requiredChunks() {
Set<ChunkKey> 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<ChunkKey> 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<Position> framePositions(Position origin, int width, int height, Axis axis) {
List<Position> 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)
);
}
}
@@ -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<String, MarkedNetherPortal> portals = new LinkedHashMap<>();
private final Map<MarkedNetherPortal.ChunkKey, Set<String>> chunkIndex = new HashMap<>();
private final Set<String> removing = new HashSet<>();
private final LinkedHashSet<String> 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<String> 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<String> 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();
}
}
@@ -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<Listener> listeners() {
return List.of(new NetherAccessListener());
@@ -87,6 +98,6 @@ public class NetherAccess extends Appliance {
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new NetherCommand(), new NetherPreventCommand());
return List.of(new NetherCommand(), new NetherPreventCommand(), new NetherIgniterCommand());
}
}
@@ -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<NetherAccess> {
private static final long MESSAGE_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3);
private final Map<UUID, Long> lastDeniedMessageAt = new HashMap<>();
private final Map<UUID, IgnitionAttempt> 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<NetherAccess> {
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<NetherAccess> {
@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<NetherAccess> {
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) {
}
}
@@ -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<NetherAccess> {
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<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return List.of();
}
}
@@ -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));
}
}
@@ -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, ReentrantLock> 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<CompletableFuture<?>> pending = ConcurrentHashMap.newKeySet();
// Only the IO worker accesses the following state.
private Map<String, MarkedNetherPortal> 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<List<MarkedNetherPortal>> load() {
return this.submit(() -> {
if(this.loaded) return List.copyOf(this.committed.values());
Map<String, MarkedNetherPortal> 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<MarkedNetherPortal> 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<String, MarkedNetherPortal> snapshot = new LinkedHashMap<>(this.committed);
snapshot.put(portal.id(), portal);
this.save(snapshot);
this.committed = snapshot;
return portal;
});
}
CompletableFuture<Void> remove(String id) {
return this.submit(() -> {
this.requireLoaded();
if(!this.committed.containsKey(id)) return null;
Map<String, MarkedNetherPortal> 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<String, MarkedNetherPortal> 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<String, Object> writePortal(MarkedNetherPortal portal) {
Map<String, Object> 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<Integer> 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 <T> CompletableFuture<T> submit(Callable<T> operation) {
CompletableFuture<T> 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));
}
}