14 Commits
Author SHA1 Message Date
besiece a202565341 Add command permissions for dimension scheduling and Nether access management 2026-11-08 23:27:46 +01:00
besiece c36b0a02fa Update dimension-schedule.yml to configure Nether opening timings and associated sound effects 2026-11-08 23:17:43 +01:00
besiece fe3af32f89 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.
2026-11-08 22:29:23 +01:00
besiece 895d2bc2d3 Implement Nether access management and enhance dimension scheduling
- Added `NetherAccess` class to manage Nether access state and commands.
- Introduced `NetherOpeningSchedule` for polling Nether opening times.
- Created `NetherAccessListener` to handle portal events and deny access when closed.
- Implemented `NetherCommand` for public information about Nether access.
- Added `NetherPreventCommand` for controlling Nether access via commands.
- Enhanced `DimensionScheduleText` to include Nether-specific messages and colors.
- Refactored `EndOpeningSchedule` to utilize `OpeningAnnouncements` for managing announcements.
- Updated `DimensionScheduleCommand` to display Nether opening times.
- Improved README documentation to reflect new Nether functionality and commands.
2026-11-08 21:53:49 +01:00
besiece c747c3d1b6 Add nether activation messages and opening effects to dimension schedule 2026-11-08 20:55:00 +01:00
besiece ad8a2991f3 Update dimension-schedule.yml for end opening timings and sound effects volume adjustments 2026-11-08 20:53:04 +01:00
besiece e558ae3fac Refactor DimensionScheduler to support sound settings in message groups and effects 2026-11-08 20:44:55 +01:00
besiece 23d8fc85de Add EndCommand and DimensionScheduleText for improved End dimension management and messaging 2026-11-08 20:15:16 +01:00
besiece 63dbefdde8 Refactor EndPreventListener and EndOpeningEffects for improved message handling and consistency in chat notifications 2026-11-08 20:07:42 +01:00
besiece 20616f1e02 Enhance DimensionScheduler and EndOpeningEffects with dynamic effect handling and YAML configuration support 2026-11-08 19:47:21 +01:00
besiece f3f19a3370 Enhance DimensionScheduler and EndOpeningSchedule with load time tracking and state restoration logic 2026-11-08 19:35:22 +01:00
besiece 45685c0c13 Refactor End dimension access management and enhance announcement system 2026-11-08 19:23:45 +01:00
besiece 02d396db68 Implement EndOpeningSchedule for managing End dimension access timing 2026-11-08 18:55:11 +01:00
besiece 3b43b594f4 Add DimensionScheduler and DimensionScheduleCommand for managing dimension access timings 2026-11-08 18:42:32 +01:00
23 changed files with 2323 additions and 20 deletions

No files matched your search

@@ -0,0 +1,63 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.Effect;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.MessageGroup;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.SoundSettings;
import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
/** Shared message/sound delivery. Group names and color are supplied by the owning appliance. */
public final class DimensionOpeningEffects {
private final String effectsGroup;
private final String denialGroup;
private final TextColor color;
private @Nullable DimensionScheduler scheduler;
public DimensionOpeningEffects(String effectsGroup, String denialGroup, TextColor color) {
this.effectsGroup = effectsGroup;
this.denialGroup = denialGroup;
this.color = color;
}
public void configure(DimensionScheduler scheduler) {
this.scheduler = scheduler;
}
public List<Effect> getConfiguredEffects() {
return this.scheduler == null ? List.of() : this.scheduler.getEffects(this.effectsGroup);
}
public void denyActivation(Player player) {
if(this.scheduler == null) return;
MessageGroup group = this.scheduler.getMessageGroup(this.denialGroup);
List<String> messages = group.texts();
if(messages.isEmpty()) return;
String message = messages.get(ThreadLocalRandom.current().nextInt(messages.size()));
send(player, DimensionScheduleText.message(message, this.color), group.sound().map(SoundSettings::asSound));
}
public void playOpening() {
for(Effect effect : this.getConfiguredEffects()) {
if(effect.minutesBeforeOpening() == 0) this.play(effect);
}
}
public void play(Effect effect) {
Component message = DimensionScheduleText.message(effect.message(), this.color);
Optional<Sound> sound = effect.sound().map(SoundSettings::asSound);
Bukkit.getOnlinePlayers().forEach(player -> send(player, message, sound));
}
private static void send(Player player, Component message, Optional<Sound> sound) {
player.sendMessage(message);
sound.ifPresent(value -> player.playSound(value, Sound.Emitter.self()));
}
}
@@ -0,0 +1,49 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.InvalidConfigurationException;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
class DimensionScheduleCommand extends ApplianceCommand<DimensionScheduler> {
public DimensionScheduleCommand() {
super("dimensionSchedule");
}
@Override
public @NotNull String permission() {
return "spawn.command.dimensionschedule";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp()) throw new Error("Dieser Befehl ist nur für die Konsole und OPs verfügbar.");
if(args.length != 1 || !args[0].equalsIgnoreCase("reload")) {
throw new Error("Verwendung: /dimensionSchedule reload");
}
try {
this.getAppliance().reload();
} catch(IOException | InvalidConfigurationException | IllegalArgumentException e) {
throw new Error("dimension-schedule.yml konnte nicht neu eingelesen werden: " + e.getMessage());
}
Instant now = Instant.now();
sender.sendMessage(DimensionScheduleText.message("Die Öffnungszeiten, Texte und Effekte wurden neu eingelesen."));
sender.sendMessage(DimensionScheduleText.message("End-Termin: "
+ DimensionScheduleText.appointment(this.getAppliance().getTime("end-open-at"), now)));
sender.sendMessage(DimensionScheduleText.message("Nether-Termin: "
+ DimensionScheduleText.appointment(this.getAppliance().getTime("nether-open-at"), now),
DimensionScheduleText.NETHER_RED));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp() || args.length != 1) return List.of();
return this.tabCompleteReducer(List.of("reload"), args);
}
}
@@ -0,0 +1,53 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Optional;
/** Shared appearance and German time formatting for dimension messages and commands. */
public final class DimensionScheduleText {
public static final TextColor END_GREEN = TextColor.color(0x80A71A);
public static final TextColor NETHER_RED = TextColor.color(0xB83D32);
private static final DateTimeFormatter DATE = DateTimeFormatter
.ofPattern("dd.MM.yyyy 'um' HH:mm:ss 'Uhr' z", Locale.GERMAN)
.withZone(ZoneId.of("Europe/Berlin"));
private DimensionScheduleText() {
}
public static Component message(String text) {
return message(text, END_GREEN);
}
public static Component message(String text, TextColor color) {
return Component.text(text, color);
}
/** Round up partial minutes so a pending opening never reads as zero minutes away. */
public static String remaining(Instant openingAt, Instant now) {
Duration duration = Duration.between(now, openingAt);
if(duration.isNegative() || duration.isZero()) return "0 Tagen, 0 Stunden und 0 Minuten";
long minutes = duration.toMinutes();
if(!duration.minusMinutes(minutes).isZero()) minutes++;
long days = minutes / (24 * 60);
long hours = minutes / 60 % 24;
long minutePart = minutes % 60;
return days + (days == 1 ? " Tag, " : " Tagen, ")
+ hours + (hours == 1 ? " Stunde und " : " Stunden und ")
+ minutePart + (minutePart == 1 ? " Minute" : " Minuten");
}
public static String appointment(Optional<Instant> openingAt, Instant now) {
if(openingAt.isEmpty()) return "nicht festgelegt";
Instant time = openingAt.get();
return DATE.format(time) + (time.isAfter(now)
? " (in " + remaining(time, now) + ")"
: " (Termin bereits erreicht)");
}
}
@@ -0,0 +1,231 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.key.InvalidKeyException;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.sound.Sound;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Level;
/** Reads named times, message groups and effect definitions; execution belongs to their consumers. */
public class DimensionScheduler extends Appliance {
public record SoundSettings(String name, float volume, float pitch) {
public Sound asSound() {
return Sound.sound(Key.key(this.name), Sound.Source.MASTER, this.volume, this.pitch);
}
}
public record MessageGroup(List<String> texts, Optional<SoundSettings> sound) {
}
public record Effect(String name, int minutesBeforeOpening, String message, Optional<SoundSettings> sound) {
public Duration leadTime() {
return Duration.ofMinutes(this.minutesBeforeOpening);
}
}
private static final String FILE_NAME = "dimension-schedule.yml";
private static final MessageGroup EMPTY_MESSAGE_GROUP = new MessageGroup(List.of(), Optional.empty());
private final File scheduleFile = new File(Main.instance().getDataFolder(), FILE_NAME);
private Map<String, Instant> times = Map.of();
private Map<String, MessageGroup> messages = Map.of();
private Map<String, List<Effect>> effects = Map.of();
private Instant loadedAt = Instant.now();
@Override
public void onEnable() {
try {
if(!this.scheduleFile.exists()) Main.instance().saveResource(FILE_NAME, false);
this.reload();
} catch(IOException | InvalidConfigurationException | IllegalArgumentException e) {
Main.logger().log(Level.WARNING, "Could not load " + FILE_NAME + "; keeping previous configuration", e);
}
}
/** Validates the complete configuration before replacing it. Never creates or writes the file. */
public void reload() throws IOException, InvalidConfigurationException {
YamlConfiguration configuration = new YamlConfiguration();
configuration.load(this.scheduleFile);
YamlConfiguration defaults = this.readDefaults();
Map<String, Instant> loadedTimes = new HashMap<>();
ConfigurationSection timeSection = section(configuration, "times");
boolean legacyTimes = timeSection == null;
if(legacyTimes) timeSection = configuration;
for(Map.Entry<String, Object> entry : timeSection.getValues(false).entrySet()) {
if(legacyTimes && List.of("times", "messages", "effects").contains(entry.getKey())) continue;
readTime(entry.getKey(), entry.getValue()).ifPresent(time -> loadedTimes.put(entry.getKey(), time));
}
ConfigurationSection messageSection = section(configuration, "messages");
ConfigurationSection effectSection = section(configuration, "effects");
Map<String, MessageGroup> loadedMessages = readMessages(messageSection == null ? section(defaults, "messages") : messageSection);
Map<String, List<Effect>> loadedEffects = readEffects(effectSection == null ? section(defaults, "effects") : effectSection);
this.times = Map.copyOf(loadedTimes);
this.messages = loadedMessages;
this.effects = loadedEffects;
this.loadedAt = Instant.now();
}
private YamlConfiguration readDefaults() throws IOException, InvalidConfigurationException {
YamlConfiguration defaults = new YamlConfiguration();
try(InputStream resource = Main.instance().getResource(FILE_NAME)) {
if(resource == null) throw new IOException("Missing bundled " + FILE_NAME);
defaults.load(new InputStreamReader(resource, StandardCharsets.UTF_8));
}
return defaults;
}
private static ConfigurationSection section(ConfigurationSection parent, String key) {
Object value = parent.get(key);
if(value == null) return null;
if(value instanceof ConfigurationSection section) return section;
throw new IllegalArgumentException(parent.getCurrentPath() + "." + key + " muss ein YAML-Abschnitt sein.");
}
private static Map<String, MessageGroup> readMessages(ConfigurationSection section) {
if(section == null) return Map.of();
Map<String, MessageGroup> messages = new HashMap<>();
for(Map.Entry<String, Object> entry : section.getValues(false).entrySet()) {
Object configured = entry.getValue();
Optional<SoundSettings> sound = Optional.empty();
if(configured instanceof ConfigurationSection group) {
configured = group.get("texts");
sound = readSound(group);
}
if(!(configured instanceof List<?> values)) {
throw new IllegalArgumentException("messages." + entry.getKey() + " muss eine Textliste oder ein Abschnitt mit texts sein.");
}
List<String> texts = new ArrayList<>();
for(Object value : values) {
if(!(value instanceof String text) || text.isBlank()) {
throw new IllegalArgumentException("messages." + entry.getKey() + " enthält einen ungültigen Text.");
}
texts.add(text);
}
messages.put(entry.getKey(), new MessageGroup(List.copyOf(texts), sound));
}
return Map.copyOf(messages);
}
private static Map<String, List<Effect>> readEffects(ConfigurationSection section) {
if(section == null) return Map.of();
Map<String, List<Effect>> effects = new HashMap<>();
for(String groupName : section.getKeys(false)) {
ConfigurationSection group = section(section, groupName);
if(group == null) throw new IllegalArgumentException("effects." + groupName + " muss ein YAML-Abschnitt sein.");
List<Effect> definitions = new ArrayList<>();
for(String name : group.getKeys(false)) {
ConfigurationSection effect = section(group, name);
if(effect == null) throw new IllegalArgumentException("effects." + groupName + "." + name + " muss ein YAML-Abschnitt sein.");
Object minutes = effect.get("minutes-before-opening");
Object message = effect.get("message");
if(!(minutes instanceof Integer count) || count < 0) {
throw new IllegalArgumentException(effect.getCurrentPath() + ".minutes-before-opening muss eine nicht negative ganze Zahl sein.");
}
if(!(message instanceof String text) || text.isBlank()) {
throw new IllegalArgumentException(effect.getCurrentPath() + ".message muss ein nicht leerer Text sein.");
}
definitions.add(new Effect(name, count, text, readSound(effect)));
}
effects.put(groupName, List.copyOf(definitions));
}
return Map.copyOf(effects);
}
private static Optional<SoundSettings> readSound(ConfigurationSection section) {
Object configured = section.get("sound");
if(configured == null) return Optional.empty();
if(!(configured instanceof String name)) {
throw new IllegalArgumentException(section.getCurrentPath() + ".sound muss ein Sound-Name sein.");
}
if(name.isBlank()) return Optional.empty();
String key;
try {
key = Key.key(name.trim()).asString();
} catch(InvalidKeyException e) {
throw new IllegalArgumentException("Ungültiger Sound-Name bei " + section.getCurrentPath() + ".sound: " + name, e);
}
float volume = readSoundNumber(section, "volume", true);
float pitch = readSoundNumber(section, "pitch", false);
return Optional.of(new SoundSettings(key, volume, pitch));
}
private static float readSoundNumber(ConfigurationSection section, String key, boolean allowZero) {
Object configured = section.get(key);
if(configured == null) return 1.0f;
if(!(configured instanceof Number number)) {
throw new IllegalArgumentException(section.getCurrentPath() + "." + key + " muss eine Zahl sein.");
}
float value = number.floatValue();
if(!Float.isFinite(value) || (allowZero ? value < 0 : value <= 0)) {
throw new IllegalArgumentException(section.getCurrentPath() + "." + key
+ (allowZero ? " muss endlich und mindestens 0 sein." : " muss endlich und größer als 0 sein."));
}
return value;
}
private static Optional<Instant> readTime(String key, Object configured) {
if(configured == null) return Optional.empty();
if(!(configured instanceof String value)) {
throw new IllegalArgumentException(key + " muss null oder ein Datum in Anführungszeichen mit UTC-Offset sein.");
}
if(value.isBlank()) return Optional.empty();
try {
return Optional.of(OffsetDateTime.parse(value.trim()).toInstant());
} catch(DateTimeParseException e) {
throw new IllegalArgumentException("Ungültiger Zeitpunkt für " + key + ". Beispiel: \"2026-11-15T18:00:00+01:00\"", e);
}
}
public Optional<Instant> getTime(String name) {
return Optional.ofNullable(this.times.get(name));
}
/** Immutable snapshot of all configured, non-empty times. */
public Map<String, Instant> getTimes() {
return this.times;
}
public List<String> getMessages(String name) {
return this.getMessageGroup(name).texts();
}
public MessageGroup getMessageGroup(String name) {
return this.messages.getOrDefault(name, EMPTY_MESSAGE_GROUP);
}
public List<Effect> getEffects(String name) {
return this.effects.getOrDefault(name, List.of());
}
/** Wall-clock time of the last successful load, before consumers check the new times. */
public Instant getLoadedAt() {
return this.loadedAt;
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new DimensionScheduleCommand());
}
}
@@ -0,0 +1,64 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.Effect;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/** Tracks one dimension's announcement cycle in memory. Owns no timer or file operations. */
public final class OpeningAnnouncements {
private final DimensionOpeningEffects effects;
private final Set<String> handled = new HashSet<>();
private Optional<Instant> openingAt = Optional.empty();
private List<Effect> configuredEffects = List.of();
private boolean initialized;
public OpeningAnnouncements(DimensionOpeningEffects effects) {
this.effects = effects;
}
/** Reconfigure only when the opening time or its effects change; skip stages before cutoff. */
public void prepare(Optional<Instant> openingAt, Instant cutoff) {
List<Effect> configuredEffects = this.effects.getConfiguredEffects();
if(this.initialized && openingAt.equals(this.openingAt) && configuredEffects.equals(this.configuredEffects)) return;
this.openingAt = openingAt;
this.configuredEffects = configuredEffects;
this.initialized = true;
this.handled.clear();
if(openingAt.isEmpty()) return;
for(Effect effect : configuredEffects) {
if(effect.minutesBeforeOpening() > 0 && openingAt.get().minus(effect.leadTime()).isBefore(cutoff)) {
this.handled.add(effect.name());
}
}
}
/** Consume all crossed stages, but emit only the latest due time while access is closed. */
public void announceDue(Instant now, boolean closed) {
if(this.openingAt.isEmpty()) return;
if(!now.isBefore(this.openingAt.get())) {
// Zero-minute effects belong to the actual opening transition, never to polling.
for(Effect effect : this.configuredEffects) this.handled.add(effect.name());
return;
}
List<Effect> latestDue = new ArrayList<>();
Instant latestDueAt = null;
for(Effect effect : this.configuredEffects) {
if(effect.minutesBeforeOpening() == 0 || this.handled.contains(effect.name())) continue;
Instant dueAt = this.openingAt.get().minus(effect.leadTime());
if(now.isBefore(dueAt)) continue;
this.handled.add(effect.name());
if(latestDueAt == null || dueAt.isAfter(latestDueAt)) {
latestDue.clear();
latestDueAt = dueAt;
}
if(dueAt.equals(latestDueAt)) latestDue.add(effect);
}
if(closed) latestDue.forEach(this.effects::play);
}
}
@@ -0,0 +1,254 @@
# Dimension schedule
## Zuständigkeiten und Lebenszyklus
| Klasse | Aufgabe und Erstellung |
| --- | --- |
| `DimensionScheduler` | Automatisch geladene Appliance. Erstellt die fehlende `plugins/spawn/dimension-schedule.yml` aus der Vorlage und liest benannte Zeitpunkte, Textgruppen und Effektdefinitionen. Kennt keine End- oder Nether-Aktionen. |
| `DimensionScheduleCommand` | Wird über die Appliance registriert. `/dimensionSchedule reload` liest die Datei für Konsole und OPs neu ein und zeigt nur dem Ausführenden den End- und Nether-Termin. Bei Fehlern bleiben alle bisherigen Werte erhalten. |
| `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. |
| `EndCommand` / `NetherCommand` | Öffentliche Informationsbefehle `/end` und `/nether`. Zeigen die Restzeit oder einen passenden Hinweis zum tatsächlichen Zustand. |
## YAML und dynamische Effekte
```yaml
times:
end-open-at: "2026-11-15T18:00:00+01:00"
outer-end-open-at: "2026-11-16T18:00:00+01:00"
nether-open-at: null
messages:
end-activation-denied:
texts:
- "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..."
sound: "entity.warden.roar"
pitch: 0.2
volume: 1.0
effects:
end-opening:
five-minutes-before:
minutes-before-opening: 5
message: "Etwas regt sich in der Leere ..."
sound: "ambient.cave"
pitch: 0.5
volume: 1.0
opened:
minutes-before-opening: 0
message: "Der Weg ins End ist nun offen!"
sound: "block.end_portal.spawn"
pitch: 1.0
volume: 1.0
```
`times` kann beliebige benannte Zeitpunkte enthalten. `messages.end-activation-denied.texts`
und `messages.nether-activation-denied.texts` enthalten die Texte für zufällige private
Chat-Nachrichten. Beide Dimensionen verwenden dasselbe oben gezeigte Format.
Die bisherige direkte Textliste wird ebenfalls weiterhin gelesen.
`sound`, `pitch` und `volume` stehen direkt bei den zugehörigen Texten beziehungsweise Effekten.
Diese Sound-Angaben werden beim Reload mit eingelesen und direkt mit der jeweiligen Nachricht
einmal beim Empfänger abgespielt. Der Ablehnungs-Sound ist nur für den auslösenden Spieler hörbar;
Ankündigungs- und Öffnungssounds erhalten alle Empfänger der Chat-Nachricht jeweils an ihrer
eigenen Position. Es gibt keine zusätzlichen Tasks oder Schleifen für die Sound-Wiedergabe.
Ohne `sound` oder mit leerem Sound-Namen bleibt es bei der Textausgabe. `volume` und `pitch`
haben den Standardwert `1.0`; Lautstärke muss mindestens `0`, Pitch größer als `0` sein.
Ungültige Sound-Schlüssel oder nicht endliche Zahlen lassen den Reload wie andere ungültige
Konfigurationswerte scheitern und behalten die bisherige Konfiguration.
Unter `effects.end-opening` und `effects.nether-opening` können frei benannte Effekte ergänzt
oder entfernt werden.
`minutes-before-opening` ist eine nicht negative ganze Zahl: Ein positiver Wert gibt den
Abstand zur jeweiligen Öffnung in Minuten an, `0` kennzeichnet einen Effekt beim tatsächlichen Öffnen.
Texte und Vorlaufzeiten stehen ausschließlich in der YAML; eine feste Stufen-Enum gibt es nicht.
Die mitgelieferte Vorlage enthält die bisherigen End-Originaltexte mit 240, 60, 10, 1 und 0 Minuten.
Für den Nether enthält sie fünf Ablehnungstexte sowie Effekte mit 120, 30, 1 und 0 Minuten.
Die Nether-Texte bleiben wortgetreu. Die Vorlage verwendet `entity.wither.ambient` bei Ablehnung
(Lautstärke `1.0`, Pitch `0.3`), `block.portal.ambient` bei 120 Minuten (Lautstärke `2.0`, Pitch `0.3`),
`entity.warden.roar` bei 30 Minuten (Lautstärke `2.0`, Pitch `0.2`), `entity.wither.spawn` bei einer Minute
(Lautstärke `2.0`, Pitch `0.5`) und `block.end_portal.spawn` bei der Öffnung
(Lautstärke `1.0`, Pitch `1.0`). Sounds bleiben optional konfigurierbar.
Die ursprünglichen flachen Zeitangaben (`end-open-at` direkt im YAML-Hauptabschnitt) bleiben
lesbar, solange kein `times`-Abschnitt vorhanden ist. Fehlt der gesamte `messages`- oder
`effects`-Abschnitt, wird nur für diesen Abschnitt die mitgelieferte Vorlage im Arbeitsspeicher
verwendet. Bestehende Serverdateien werden nicht überschrieben oder automatisch ergänzt.
Zur Inbetriebnahme die Termine unter `times` setzen und die gewünschten Abschnitte aus der
Vorlage in die Serverdatei übernehmen. Sind eigene `messages`- beziehungsweise `effects`-Abschnitte
bereits vorhanden, müssen fehlende Nether-Gruppen (`nether-activation-denied` und `nether-opening`)
aus der Vorlage übernommen werden. Anschließend `/dimensionSchedule reload` ausführen.
Explizite leere Abschnitte (`messages: {}`, `effects: {}`) verwenden keine Standardgruppen;
einzelne Gruppen lassen sich durch `end-activation-denied: []` beziehungsweise `end-opening: {}` leeren.
## Öffnungszeiten
Vor `end-open-at` ist das End `CLOSED`, danach bis `outer-end-open-at` ist nur die
Hauptinsel verfügbar (`NO_OUTER`), anschließend ist es vollständig `OPEN`.
Ohne Outer-End-Termin öffnet `end-open-at` das gesamte End. Mit ausschließlich einem
Outer-End-Termin ist die Hauptinsel sofort verfügbar und das Outer End öffnet zum Termin.
Ein zukünftiger End-Termin hat Vorrang vor einem bereits vergangenen Outer-End-Termin.
Sind beide Einträge leer oder `null`, erfolgt keine automatische Zustandsänderung.
Weitere Namen können in der YAML ergänzt werden; nur die End-Klasse interpretiert diese zwei Namen.
`NetherOpeningSchedule` liest `times.nether-open-at`: Vor dem Termin ist der Nether `CLOSED`,
danach `OPEN`. `NetherAccess` speichert den Zustand unter `netherAccess.state` in `config.yml`.
Ohne gespeicherten Zustand startet der Nether geschlossen. Ohne Öffnungstermin bleibt der
gespeicherte Zustand maßgeblich; es erfolgt keine automatische Änderung.
## Gemeinsamer Zustandswechsel und Commands
`/end` und `/nether` sind für alle Spieler und die Konsole verfügbar. Bei geschlossenem End mit zukünftigem
Termin lautet die Antwort beispielsweise: „Das End öffnet in 2 Tagen, 3 Stunden und 4 Minuten.“
Angebrochene Minuten werden aufgerundet. Ist das End schon geöffnet, kein Termin gesetzt oder
ein vergangener Termin bei weiterhin geschlossenem End vorhanden, wird dies entsprechend gemeldet.
`/nether` verwendet dieselbe Restzeitformatierung und entsprechende Hinweise auf einen bereits
geöffneten Nether, einen fehlenden Termin oder einen vergangenen Termin. Informationsbefehle
ändern keinen Zustand und schreiben keine Datei.
`/endPrevent preventEnd`, `/endPrevent allowEnd` und `/endPrevent onlyInnerEnd` bleiben bestehen.
`/endPrevent` ohne Argument zeigt den Zustand. `/netherPrevent preventNether` sperrt den Nether,
`/netherPrevent allowNether` öffnet ihn und `/netherPrevent` ohne Argument zeigt seinen Zustand.
Beide Verwaltungsbefehle sowie `/dimensionSchedule` und `/netherIgniter` besitzen registrierte
Command-Berechtigungen mit `default: op`. Für normale Spieler erscheinen auch die Befehlsnamen
und ihre `spawn:`-Varianten nicht in der Autovervollständigung. Die OP-Prüfungen bei der
Ausführung bleiben zusätzlich erhalten; `/netherIgniter` erfordert außerdem einen Spieler im Kreativmodus.
Nach Installation dieser Änderung den Server neu starten; `/dimensionSchedule reload` lädt nur die Termin-YAML.
Status und Bestätigungen sind auf Deutsch in End-Grün beziehungsweise Nether-Rot formuliert;
ungültige Argumente zeigen die Verwendung statt einer Erfolgsmeldung.
Commands und laufende zeitgesteuerte Übergänge verwenden `EndPrevent.setEndState()`.
Der gemeinsame Zustandswechsel aktualisiert Zustand und Weltgrenze, speichert Änderungen in
`config.yml` und führt beim Wechsel von `CLOSED` zu `NO_OUTER` oder `OPEN` alle Effekte mit
`minutes-before-opening: 0` aus.
Bereits offene Zustände erzeugen bei erneutem Öffnen keine weitere Meldung. Die spätere
Freigabe des Outer Ends (`NO_OUTER` zu `OPEN`) wiederholt die End-Öffnungsmeldung ebenfalls nicht.
Für den Nether verwenden Command und Zeitplan entsprechend denselben zentralen Zustandswechsel
in `NetherAccess`. Jede tatsächliche Zustandsänderung wird gespeichert; nur der Wechsel von
`CLOSED` zu `OPEN` führt alle Nether-Öffnungseffekte aus. Wiederholtes Öffnen erzeugt keine doppelten Effekte.
Beim Start und bei geänderten Öffnungszeiten verwendet der Zeitplan `restoreEndState()`.
Der Nether stellt seinen Zustand auf dieselbe Weise still wieder her. Diese Übergänge
führen denselben Zustandswechsel mit Speicherung aus, stellen den aktuellen
Zustand aber ohne nachträgliche Öffnungseffekte her. Entfernte Termine heben den zuletzt
gesetzten Zustand nicht auf; die Steuerung bleibt dann vollständig manuell.
Manuelle Commands werden nicht bei jeder Prüfung überschrieben: Erst beim nächsten
zeitgesteuerten Zustandswechsel oder bei geänderten Terminen der jeweiligen Dimension greift der
Zeitplan erneut ein. Ein Reload mit unveränderten Terminen setzt einen manuellen Zustand nicht zurück.
Die Reload-Bestätigung zeigt End- und Nether-Termin als Datum mit Uhrzeit und Berliner Zeitzone,
bei zukünftigen Terminen zusätzlich die Restzeit. Fehlende und vergangene Termine sind gekennzeichnet.
Diese Zusammenfassung wird ausschließlich an den ausführenden CommandSender gesendet.
Die End-Zeile ist grün, die Nether-Zeile rot.
## Ankündigungen und Portalversuche
Vorankündigungen erscheinen einmalig für alle Online-Spieler im Chat, solange die jeweilige
Dimension geschlossen ist. Bereits vergangene Stufen werden beim Start oder bei einem neuen Termin
übersprungen. Unveränderte Reloads, neue Outer-End-Termine und Änderungen anderer YAML-Einträge
setzen die bereits behandelten Stufen nicht zurück. Bei Verzögerungen wird höchstens die
zuletzt fällige Vorankündigungszeit berücksichtigt. Mehrere Effekte mit genau derselben
Vorlaufzeit werden gemeinsam ausgeführt. Ist die Öffnung bereits fällig, entfallen alte Teaser.
Während einer manuellen Freigabe fällige Teaser werden still als behandelt markiert.
Geänderte Definitionen unter `effects.end-opening` und `effects.nether-opening` werden nach einem erfolgreichen Reload
ebenfalls übernommen. Bereits vor diesem Reload vergangene Ankündigungen werden übersprungen;
zukünftige Effekte laufen mit den neuen Texten und Minutenwerten. Reine Textänderungen in
`messages` setzen die Ankündigungen nicht zurück. Ungültige Zeiten, Minutenwerte oder Texte
lassen den gesamten Reload scheitern und behalten die bisherige Konfiguration.
Für einen geänderten Termin gilt der erfolgreiche Ladezeitpunkt als Grenze für verpasste
Stufen. Wird eine Ankündigung erst zwischen dem Reload und der nächsten Sekundenprüfung
fällig, wird sie regulär ausgeführt. Dasselbe gilt für eine in diesem Zeitraum fällige Öffnung.
Beim erneuten Aktivieren einer Appliance gilt frühestens deren Startzeitpunkt als Grenze.
Ein Rechtsklick mit Enderauge auf einen leeren, gesperrten Endportalrahmen wird für beide Hände
abgebrochen. Aktivierungs- und Betretversuche gesperrter Endportale verwenden dieselbe Sperrprüfung
und einen gemeinsamen Nachrichten-Cooldown: Nur der auslösende Spieler erhält einen zufälligen
konfigurierten Text im Chat, höchstens einmal alle drei echten Sekunden. Versuche während des
Cooldowns werden weiterhin abgebrochen; sie verlängern den Nachrichten-Cooldown nicht.
Die Prüfung nutzt `System.nanoTime()`, benötigt keinen zusätzlichen Task und entfernt
Spieler-Einträge beim Verlassen. Die Gateway-Meldung für das gesperrte Outer End bleibt getrennt davon.
End-Flavourtexte und zeitgesteuerte End-Effekte verwenden End-Grün (`#80A71A`).
Solange der Nether geschlossen ist, verhindert `NetherAccess` neue Netherportale mit Ursache
`FIRE` außerhalb des Nethers sowie automatisch erzeugte Zielportale mit Ursache `NETHER_PAIR`
im Nether. `END_PLATFORM` bleibt unberührt. Der Eintritt in den Nether durch bestehende Portale
wird für Spieler, Mobs und Gegenstände über `PlayerPortalEvent` und `EntityPortalEvent` gesperrt.
Die Rückreise aus dem Nether und dafür nötige Rückportale bleiben erlaubt; das Zünden eines
Rückportals innerhalb des Nethers wird ebenfalls nicht verhindert.
Bei Spieleraktionen erhält ausschließlich der auslösende Spieler einen zufälligen Text aus
`messages.nether-activation-denied` und gegebenenfalls den konfigurierten Sound. Bei
`PortalCreateEvent` wird dafür nur ein von `getEntity()` gelieferter Spieler verwendet;
automatische Ereignisse ohne Spieler werden still blockiert. Es wird kein Spieler über die
Entfernung zum Portal ermittelt. Nachrichten und Sounds teilen einen Cooldown von drei echten
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.
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
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,53 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/** Public, read-only information command; the existing management command remains separate. */
class EndCommand extends ApplianceCommand<EndPrevent> {
public EndCommand() {
super("end");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length != 0) throw new Error("Verwendung: /end");
EndPrevent end = this.getAppliance();
if(!end.isEndClosed()) {
sender.sendMessage(DimensionScheduleText.message(end.isOnlyInner()
? "Das End ist bereits geöffnet. Das Outer End ist noch gesperrt."
: "Das End ist bereits vollständig geöffnet."));
return;
}
Optional<Instant> openingAt = Main.instance().getAppliances().stream()
.filter(DimensionScheduler.class::isInstance)
.map(DimensionScheduler.class::cast)
.findFirst()
.flatMap(scheduler -> scheduler.getTime("end-open-at"));
Instant now = Instant.now();
String message;
if(openingAt.isEmpty()) {
message = "Für das End ist noch kein Öffnungstermin festgelegt.";
} else if(!openingAt.get().isAfter(now)) {
message = "Der Öffnungstermin ist bereits erreicht. Das End ist derzeit noch geschlossen.";
} else {
message = "Das End öffnet in " + DimensionScheduleText.remaining(openingAt.get(), now) + ".";
}
sender.sendMessage(DimensionScheduleText.message(message));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return List.of();
}
}
@@ -0,0 +1,98 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionOpeningEffects;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.OpeningAnnouncements;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Bukkit;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Optional;
/**
* End-specific interpretation of the named times. Created and stopped by EndPrevent.
* DimensionScheduler only supplies configuration; this class owns the single polling task.
*/
final class EndOpeningSchedule {
private final EndPrevent endPrevent;
private final DimensionScheduler scheduler;
private final OpeningAnnouncements announcements;
private @Nullable BukkitTask task;
private Optional<Instant> previousEndOpenAt = Optional.empty();
private Optional<Instant> previousOuterEndOpenAt = Optional.empty();
private @Nullable EndPrevent.State previousScheduledState;
private boolean initialized;
private Instant startedAt = Instant.EPOCH;
EndOpeningSchedule(EndPrevent endPrevent, DimensionScheduler scheduler, DimensionOpeningEffects effects) {
this.endPrevent = endPrevent;
this.scheduler = scheduler;
this.announcements = new OpeningAnnouncements(effects);
}
void start() {
this.stop();
this.startedAt = Instant.now();
// Wait until the next tick so all appliances have finished loading, regardless of order.
this.task = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::checkOpeningTimes, 1L, 20L);
}
void stop() {
if(this.task == null) return;
this.task.cancel();
this.task = null;
}
private void checkOpeningTimes() {
Optional<Instant> endOpenAt = this.scheduler.getTime("end-open-at");
Optional<Instant> outerEndOpenAt = this.scheduler.getTime("outer-end-open-at");
Instant now = Instant.now();
boolean endTimeChanged = !endOpenAt.equals(this.previousEndOpenAt);
boolean restoreSchedule = !this.initialized || endTimeChanged
|| !outerEndOpenAt.equals(this.previousOuterEndOpenAt);
Instant restoreAt = this.scheduler.getLoadedAt();
if(!this.initialized && this.startedAt.isAfter(restoreAt)) restoreAt = this.startedAt;
this.announcements.prepare(endOpenAt, restoreAt);
this.previousEndOpenAt = endOpenAt;
this.previousOuterEndOpenAt = outerEndOpenAt;
this.initialized = true;
if(endOpenAt.isEmpty() && outerEndOpenAt.isEmpty()) {
this.previousScheduledState = null;
return;
}
EndPrevent.State scheduledState = stateAt(now, endOpenAt, outerEndOpenAt);
EndPrevent.State restoredState = stateAt(restoreAt, endOpenAt, outerEndOpenAt);
boolean crossedAfterLoad = restoreSchedule && restoredState != scheduledState;
if(crossedAfterLoad) {
// Restore the state at load time silently, then execute the newly due live transition.
this.endPrevent.restoreEndState(restoredState);
}
// Remember the schedule's state, not the current manual state: commands remain effective
// until the next scheduled transition or a change to the relevant opening times.
if(restoreSchedule || scheduledState != this.previousScheduledState) {
if(this.endPrevent.getEndPreventState() != scheduledState) {
if(restoreSchedule && !crossedAfterLoad) {
this.endPrevent.restoreEndState(scheduledState);
} else {
this.endPrevent.setEndState(scheduledState);
}
Main.logger().info("End opening schedule applied state " + scheduledState
+ " (end-open-at=" + endOpenAt.orElse(null)
+ ", outer-end-open-at=" + outerEndOpenAt.orElse(null) + ")");
}
this.previousScheduledState = scheduledState;
}
this.announcements.announceDue(now, this.endPrevent.isEndClosed());
}
private static EndPrevent.State stateAt(Instant at, Optional<Instant> endOpenAt, Optional<Instant> outerEndOpenAt) {
if(endOpenAt.isPresent() && at.isBefore(endOpenAt.get())) return EndPrevent.State.CLOSED;
if(outerEndOpenAt.isPresent() && at.isBefore(outerEndOpenAt.get())) return EndPrevent.State.NO_OUTER;
return EndPrevent.State.OPEN;
}
}
@@ -1,5 +1,9 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionOpeningEffects;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
@@ -7,12 +11,17 @@ import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
public class EndPrevent extends Appliance {
private final String endPreventKey = "endPrevent";
private final DimensionOpeningEffects openingEffects = new DimensionOpeningEffects(
"end-opening", "end-activation-denied", DimensionScheduleText.END_GREEN
);
private State endPreventState;
private @Nullable EndOpeningSchedule openingSchedule;
private final World endWorld = Bukkit.getWorlds().stream().filter(world -> world.getEnvironment().equals(World.Environment.THE_END)).findFirst().orElseThrow();
public enum State {
@@ -27,16 +36,54 @@ public class EndPrevent extends Appliance {
this.updateEndBorder();
}
@Override
public void onEnable() {
if(this.openingSchedule != null) this.openingSchedule.stop();
DimensionScheduler scheduler = Main.instance().getAppliances().stream()
.filter(DimensionScheduler.class::isInstance)
.map(DimensionScheduler.class::cast)
.findFirst().orElse(null);
if(scheduler == null) return;
this.openingEffects.configure(scheduler);
this.openingSchedule = new EndOpeningSchedule(this, scheduler, this.openingEffects);
this.openingSchedule.start();
}
@Override
public void onDisable() {
if(this.openingSchedule != null) this.openingSchedule.stop();
}
private void updateEndBorder() {
if(this.endPreventState == State.NO_OUTER) this.endWorld.getWorldBorder().setSize(500);
if(this.endPreventState == State.OPEN) this.endWorld.getWorldBorder().setSize(this.endWorld.getWorldBorder().getMaxSize());
}
public void setEndState(State state) {
this.localConfig().set(this.endPreventKey, state.name());
Configuration.saveChanges();
this.changeEndState(state, true);
}
/** Restores a schedule on startup or after changed times without replaying past effects. */
void restoreEndState(State state) {
this.changeEndState(state, false);
}
/** Shared by commands, live scheduled transitions and silent schedule restoration. */
private void changeEndState(State state, boolean announceOpening) {
if(this.endPreventState == state) return;
boolean wasClosed = this.isEndClosed();
this.endPreventState = state;
this.updateEndBorder();
this.localConfig().set(this.endPreventKey, state.name());
Configuration.saveChanges();
if(announceOpening && wasClosed && !this.isEndClosed()) {
this.openingEffects.playOpening();
}
}
DimensionOpeningEffects effects() {
return this.openingEffects;
}
public boolean isEndClosed() {
@@ -58,6 +105,6 @@ public class EndPrevent extends Appliance {
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new EndPreventCommand());
return List.of(new EndPreventCommand(), new EndCommand());
}
}
@@ -1,12 +1,10 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
@@ -23,19 +21,30 @@ class EndPreventCommand extends ApplianceCommand<EndPrevent> {
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(args.length == 1 && this.arguments.containsKey(args[0])) {
this.getAppliance().setEndState(this.arguments.get(args[0]));
sender.sendMessage(Component.text("Setting updated!", NamedTextColor.GREEN));
}
sender.sendMessage(Component.text(
String.format("The End is now on '%s'!", this.getAppliance().getEndPreventState().name()),
NamedTextColor.GOLD
));
public @NotNull String permission() {
return "spawn.command.endprevent";
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
return this.arguments.keySet().stream().toList();
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp()) throw new Error("Dieser Befehl ist nur für die Konsole und OPs verfügbar.");
if(args.length > 1 || (args.length == 1 && !this.arguments.containsKey(args[0]))) {
throw new Error("Verwendung: /endPrevent [preventEnd|allowEnd|onlyInnerEnd]");
}
if(args.length == 1) {
this.getAppliance().setEndState(this.arguments.get(args[0]));
}
String message = switch(this.getAppliance().getEndPreventState()) {
case CLOSED -> "Das End ist geschlossen.";
case OPEN -> "Das End ist vollständig geöffnet.";
case NO_OUTER -> "Die Hauptinsel des Ends ist geöffnet. Das Outer End ist gesperrt.";
};
sender.sendMessage(DimensionScheduleText.message(message));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp() || args.length != 1) return List.of();
return this.tabCompleteReducer(this.arguments.keySet().stream().toList(), args);
}
}
@@ -4,28 +4,49 @@ import com.destroystokyo.paper.event.player.PlayerTeleportEndGatewayEvent;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.data.type.EndPortalFrame;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerPortalEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
class EndPreventListener extends ApplianceListener<EndPrevent> {
private static final long MESSAGE_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3);
private final Map<UUID, Long> lastDeniedMessageAt = new HashMap<>();
@EventHandler
public void onEnderEyeInteraction(PlayerInteractEvent event) {
if(event.getAction() != Action.RIGHT_CLICK_BLOCK) return;
if(event.getClickedBlock() == null) return;
if(!event.getClickedBlock().getType().equals(Material.END_PORTAL_FRAME)) return;
if(!(event.getClickedBlock().getBlockData() instanceof EndPortalFrame frame) || frame.hasEye()) return;
if(event.getItem() == null) return;
if(!event.getItem().getType().equals(Material.ENDER_EYE)) return;
this.cancelIfClosed(event, event.getPlayer());
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
this.lastDeniedMessageAt.remove(event.getPlayer().getUniqueId());
}
@EventHandler
public void onPlayerPortal(PlayerPortalEvent event) {
if(!event.getTo().getWorld().getEnvironment().equals(World.Environment.THE_END)) return;
Location destination = event.getTo();
if(destination == null || destination.getWorld() == null) return;
if(!destination.getWorld().getEnvironment().equals(World.Environment.THE_END)) return;
this.cancelIfClosed(event, event.getPlayer());
}
@@ -35,10 +56,16 @@ class EndPreventListener extends ApplianceListener<EndPrevent> {
this.cancelIfOnlyInner(event, event.getPlayer());
}
private void cancelIfClosed(Cancellable event, Player p) {
private void cancelIfClosed(Cancellable event, Player player) {
if(!this.getAppliance().isEndClosed()) return;
event.setCancelled(true);
p.sendActionBar(Component.text("Das End ist nicht freigeschaltet!", NamedTextColor.RED));
// Always block access. Only messages are throttled, using elapsed real time independent of TPS.
long now = System.nanoTime();
Long previousMessageAt = this.lastDeniedMessageAt.get(player.getUniqueId());
if(previousMessageAt != null && now - previousMessageAt < MESSAGE_COOLDOWN_NANOS) return;
this.lastDeniedMessageAt.put(player.getUniqueId(), now);
this.getAppliance().effects().denyActivation(player);
}
private void cancelIfOnlyInner(Cancellable event, Player p) {
@@ -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();
}
}
@@ -0,0 +1,103 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionOpeningEffects;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import org.bukkit.event.Listener;
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 {
private final DimensionOpeningEffects openingEffects = new DimensionOpeningEffects(
"nether-opening", "nether-activation-denied", DimensionScheduleText.NETHER_RED
);
private State state;
private @Nullable NetherOpeningSchedule openingSchedule;
private @Nullable MarkedNetherPortals markedPortals;
public enum State {
CLOSED,
OPEN
}
public NetherAccess() {
super("netherAccess");
this.state = State.valueOf(this.localConfig().getString("state", State.CLOSED.name()));
}
@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)
.findFirst().orElse(null);
if(scheduler == null) return;
this.openingEffects.configure(scheduler);
this.openingSchedule = new NetherOpeningSchedule(this, scheduler, this.openingEffects);
this.openingSchedule.start();
}
@Override
public void onDisable() {
if(this.openingSchedule != null) this.openingSchedule.stop();
if(this.markedPortals != null) this.markedPortals.close();
}
public void setState(State state) {
this.changeState(state, true);
}
/** Restore startup or changed schedule state without replaying past opening effects. */
void restoreState(State state) {
this.changeState(state, false);
}
private void changeState(State state, boolean announceOpening) {
if(this.state == state) return;
boolean wasClosed = this.isClosed();
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();
}
public State getState() {
return this.state;
}
public boolean isClosed() {
return this.state == State.CLOSED;
}
DimensionOpeningEffects effects() {
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());
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new NetherCommand(), new NetherPreventCommand(), new NetherIgniterCommand());
}
}
@@ -0,0 +1,143 @@
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;
import java.util.Map;
import java.util.UUID;
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(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()) {
case FIRE -> !nether;
case NETHER_PAIR -> nether;
default -> false;
};
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)
public void onPlayerPortal(PlayerPortalEvent event) {
if(isNetherDestination(event.getTo())) this.cancelIfClosed(event, event.getPlayer());
}
@EventHandler(ignoreCancelled = true)
public void onEntityPortal(EntityPortalEvent event) {
if(isNetherDestination(event.getTo())) this.cancelIfClosed(event, event.getEntity());
}
@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) {
return destination != null && destination.getWorld() != null
&& destination.getWorld().getEnvironment() == World.Environment.NETHER;
}
private void cancelIfClosed(Cancellable event, @Nullable Entity actor) {
if(!this.getAppliance().isClosed()) return;
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 false;
this.lastDeniedMessageAt.put(player.getUniqueId(), now);
return true;
}
private record IgnitionAttempt(UUID worldId, EquipmentSlot hand, int tick,
MarkedNetherPortal.Position ignition, boolean special) {
}
}
@@ -0,0 +1,50 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/** Public information about Nether access and the configured opening date. */
class NetherCommand extends ApplianceCommand<NetherAccess> {
public NetherCommand() {
super("nether", DimensionScheduleText.message("Fehler: ", DimensionScheduleText.NETHER_RED));
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length != 0) throw new Error("Verwendung: /nether");
if(!this.getAppliance().isClosed()) {
sender.sendMessage(DimensionScheduleText.message("Der Nether ist bereits geöffnet.", DimensionScheduleText.NETHER_RED));
return;
}
Optional<Instant> openingAt = Main.instance().getAppliances().stream()
.filter(DimensionScheduler.class::isInstance)
.map(DimensionScheduler.class::cast)
.findFirst()
.flatMap(scheduler -> scheduler.getTime("nether-open-at"));
Instant now = Instant.now();
String message;
if(openingAt.isEmpty()) {
message = "Für den Nether ist noch kein Öffnungstermin festgelegt.";
} else if(!openingAt.get().isAfter(now)) {
message = "Der Öffnungstermin ist bereits erreicht. Der Nether ist derzeit noch geschlossen.";
} else {
message = "Der Nether öffnet in " + DimensionScheduleText.remaining(openingAt.get(), now) + ".";
}
sender.sendMessage(DimensionScheduleText.message(message, 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,40 @@
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
public @NotNull String permission() {
return "spawn.command.netherigniter";
}
@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,86 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionOpeningEffects;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.OpeningAnnouncements;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Bukkit;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Optional;
/** Interprets nether-open-at; NetherAccess creates and stops its single polling task. */
final class NetherOpeningSchedule {
private final NetherAccess netherAccess;
private final DimensionScheduler scheduler;
private final OpeningAnnouncements announcements;
private @Nullable BukkitTask task;
private Optional<Instant> previousOpenAt = Optional.empty();
private @Nullable NetherAccess.State previousScheduledState;
private boolean initialized;
private Instant startedAt = Instant.EPOCH;
NetherOpeningSchedule(NetherAccess netherAccess, DimensionScheduler scheduler, DimensionOpeningEffects effects) {
this.netherAccess = netherAccess;
this.scheduler = scheduler;
this.announcements = new OpeningAnnouncements(effects);
}
void start() {
this.stop();
this.startedAt = Instant.now();
// All appliances must finish loading before the first check, regardless of their order.
this.task = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::checkOpeningTime, 1L, 20L);
}
void stop() {
if(this.task == null) return;
this.task.cancel();
this.task = null;
}
private void checkOpeningTime() {
Optional<Instant> openingAt = this.scheduler.getTime("nether-open-at");
Instant now = Instant.now();
boolean restoreSchedule = !this.initialized || !openingAt.equals(this.previousOpenAt);
Instant restoreAt = this.scheduler.getLoadedAt();
if(!this.initialized && this.startedAt.isAfter(restoreAt)) restoreAt = this.startedAt;
this.announcements.prepare(openingAt, restoreAt);
this.previousOpenAt = openingAt;
this.initialized = true;
if(openingAt.isEmpty()) {
this.previousScheduledState = null;
return;
}
NetherAccess.State scheduledState = stateAt(now, openingAt.get());
NetherAccess.State restoredState = stateAt(restoreAt, openingAt.get());
boolean crossedAfterLoad = restoreSchedule && restoredState != scheduledState;
if(crossedAfterLoad) {
// A boundary crossed since loading is a live transition, not a missed opening.
this.netherAccess.restoreState(restoredState);
}
// Manual changes remain effective until the next scheduled transition or changed date.
if(restoreSchedule || scheduledState != this.previousScheduledState) {
if(this.netherAccess.getState() != scheduledState) {
if(restoreSchedule && !crossedAfterLoad) {
this.netherAccess.restoreState(scheduledState);
} else {
this.netherAccess.setState(scheduledState);
}
Main.logger().info("Nether opening schedule applied state " + scheduledState
+ " (nether-open-at=" + openingAt.get() + ")");
}
this.previousScheduledState = scheduledState;
}
this.announcements.announceDue(now, this.netherAccess.isClosed());
}
private static NetherAccess.State stateAt(Instant at, Instant openingAt) {
return at.isBefore(openingAt) ? NetherAccess.State.CLOSED : NetherAccess.State.OPEN;
}
}
@@ -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,44 @@
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.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Map;
class NetherPreventCommand extends ApplianceCommand<NetherAccess> {
private final Map<String, NetherAccess.State> arguments = Map.of(
"preventNether", NetherAccess.State.CLOSED,
"allowNether", NetherAccess.State.OPEN
);
public NetherPreventCommand() {
super("netherPrevent", DimensionScheduleText.message("Fehler: ", DimensionScheduleText.NETHER_RED));
}
@Override
public @NotNull String permission() {
return "spawn.command.netherprevent";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp()) throw new Error("Dieser Befehl ist nur für die Konsole und OPs verfügbar.");
if(args.length > 1 || (args.length == 1 && !this.arguments.containsKey(args[0]))) {
throw new Error("Verwendung: /netherPrevent [preventNether|allowNether]");
}
if(args.length == 1) this.getAppliance().setState(this.arguments.get(args[0]));
String message = this.getAppliance().isClosed() ? "Der Nether ist geschlossen." : "Der Nether ist geöffnet.";
sender.sendMessage(DimensionScheduleText.message(message, DimensionScheduleText.NETHER_RED));
}
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!sender.isOp() || args.length != 1) return List.of();
return this.tabCompleteReducer(this.arguments.keySet().stream().toList(), args);
}
}
@@ -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));
}
}
@@ -0,0 +1,90 @@
# Datum mit UTC-Offset in Anführungszeichen; null bedeutet: kein Termin.
# Weitere frei benannte Zeitpunkte können hier ergänzt werden.
times:
end-open-at: "2027-01-03T19:00:00+01:00"
outer-end-open-at: null
nether-open-at: "2026-12-29T14:00:00+01:00"
messages:
end-activation-denied:
texts:
- "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..."
- "Der Rahmen fühlt sich ungewöhnlich kalt an. Du hast das Gefühl, als würde er dir die Wärme aus den Fingern ziehen …"
- "Die Luft über dem leeren Rahmen scheint ungewöhnlich dunkel. Du hast das Gefühl, in einen bodenlosen Abgrund zu blicken ..."
- "Für einen kurzen Moment scheint die Umgebung vollkommen still zu werden. Du fragst dich, ob du dir das nur eingebildet hast ..."
- "Ein schwaches, violettes Licht flackert zwischen den Augen auf. Etwas scheint dich zu beobachten ..."
# Ein Sound pro Nachricht, gemeinsam mit dem Nachrichten-Cooldown.
sound: "entity.warden.roar"
pitch: 0.2
volume: 1.0
nether-activation-denied:
texts:
- "Der Obsidian scheint von innen heraus zu glühen. Du spürst eine fremdartige Hitze ..."
- "Eine seltsame Wärme geht von dem schwarzen Gestein aus. Dich beschleicht ein unheimliches Gefühl ..."
- "Für einen kurzen Moment scheint der Obsidian zu pulsieren. Du fragst dich, ob das Feuer etwas in ihm geweckt hat …"
- "Die Luft scheint leicht zu flimmern. Du hast das Gefühl, hier geht etwas nicht mit rechten Dingen zu ..."
- "Das Gestein knistert, aber das Feuer scheint noch keine Wirkung zu haben ..."
sound: "entity.wither.ambient"
pitch: 0.3
volume: 1.0
effects:
end-opening:
# Namen und Anzahl der Effekte sind frei wählbar.
# Positive Minutenwerte: Vorankündigung im Chat, solange das End geschlossen ist.
four-hours-before:
minutes-before-opening: 240
message: "Eine unheimliche Stille scheint sich langsam über die Welt auszubreiten. Du hast das seltsame Gefühl, beobachtet zu werden ..."
sound: "ambient.cave"
pitch: 0.5
volume: 3.0
one-hour-before:
minutes-before-opening: 60
message: "Die Luft fühlt sich plötzlich ungewöhnlich schwer an. Du hast das Gefühl, als würde eine unsichtbare Kraft auf dir lasten ..."
sound: "entity.wither.spawn"
pitch: 0.5
volume: 2.0
ten-minutes-before:
minutes-before-opening: 10
message: "Ein seltsames Geräusch hallt durch die Welt. Könnte es das Schlagen gewaltiger Flügel sein ...?"
sound: "block.sculk_shrieker.shriek"
pitch: 0.3
volume: 3.0
one-minute-before:
minutes-before-opening: 1
message: "Ein gewaltiger Schatten scheint über die Welt zu ziehen. Du blickst zum Himmel, doch dort ist nichts zu sehen ..."
sound: "entity.ender_dragon.growl"
pitch: 0.5
volume: 3.0
# 0: Nur beim tatsächlichen Öffnen, gleichermaßen durch Command oder Zeitplan.
opened:
minutes-before-opening: 0
message: "Ein gewaltiger Riss öffnet sich in der Realität! Die Leere erwacht und die letzte Grenze fällt. Der Weg ins End ist nun offen!"
sound: "block.end_portal.spawn"
pitch: 1.0
volume: 2.0
nether-opening:
two-hours-before:
minutes-before-opening: 120
message: "In der ganzen Welt scheint die Luft leicht zu flimmern. Eine fremdartige Wärme breitet sich aus ..."
sound: "block.portal.ambient"
pitch: 0.3
volume: 2.0
thirty-minutes-before:
minutes-before-opening: 30
message: "Ein tiefes Grollen durchzieht die Welt. Du spürst, wie die Luft um dich herum zu vibrieren beginnt ..."
sound: "entity.warden.roar"
pitch: 0.2
volume: 2.0
one-minute-before:
minutes-before-opening: 1
message: "Die Erde bebt. Für einen kurzen Moment scheint die Realität selbst zu zerreißen. Etwas steht unmittelbar bevor ..."
sound: "entity.wither.spawn"
pitch: 0.5
volume: 2.0
opened:
minutes-before-opening: 0
message: "Ein gewaltiger Riss durchzieht die Realität! Die Grenze zwischen den Welten ist gefallen. Der Weg in den Nether ist nun offen!"
sound: "block.end_portal.spawn"
pitch: 1.0
volume: 1.0
+14
View File
@@ -10,3 +10,17 @@ author: MineTec
contributors: [olischma, 28Pupsi28]
website: https://mhsl.eu/gitea/CraftAttack/spawn
permissions:
spawn.command.dimensionschedule:
description: Öffnungszeiten, Texte und Effekte neu einlesen
default: op
spawn.command.endprevent:
description: End-Zugang verwalten
default: op
spawn.command.netherprevent:
description: Nether-Zugang verwalten
default: op
spawn.command.netherigniter:
description: Portalfeuerzeug im Kreativmodus erhalten
default: op