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.
This commit is contained in:
besiece committed 2026-11-08 21:53:49 +01:00
1 parent c747c3d1b6
commit 895d2bc2d3
13 files changed
+511 -100

No files matched your search

@@ -1,12 +1,11 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler;
import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.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 eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText;
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;
@@ -15,35 +14,44 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
/** Sends each YAML-defined message together with its optional sound. */
final class EndOpeningEffects {
/** 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;
void configure(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;
}
List<Effect> getConfiguredEffects() {
return this.scheduler == null ? List.of() : this.scheduler.getEffects("end-opening");
public List<Effect> getConfiguredEffects() {
return this.scheduler == null ? List.of() : this.scheduler.getEffects(this.effectsGroup);
}
void denyActivation(Player player) {
public void denyActivation(Player player) {
if(this.scheduler == null) return;
MessageGroup group = this.scheduler.getMessageGroup("end-activation-denied");
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), group.sound().map(SoundSettings::asSound));
send(player, DimensionScheduleText.message(message, this.color), group.sound().map(SoundSettings::asSound));
}
void playOpening() {
public void playOpening() {
for(Effect effect : this.getConfiguredEffects()) {
if(effect.minutesBeforeOpening() == 0) this.play(effect);
}
}
void play(Effect effect) {
Component message = DimensionScheduleText.message(effect.message());
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));
}
@@ -32,7 +32,8 @@ class DimensionScheduleCommand extends ApplianceCommand<DimensionScheduler> {
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.appointment(this.getAppliance().getTime("nether-open-at"), now),
DimensionScheduleText.NETHER_RED));
}
@Override
@@ -12,7 +12,8 @@ import java.util.Optional;
/** Shared appearance and German time formatting for dimension messages and commands. */
public final class DimensionScheduleText {
private static final TextColor END_GREEN = TextColor.color(0x80A71A);
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"));
@@ -21,7 +22,11 @@ public final class DimensionScheduleText {
}
public static Component message(String text) {
return Component.text(text, END_GREEN);
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. */
@@ -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);
}
}
@@ -4,13 +4,15 @@
| 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-Aktionen. |
| `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` | Einheitliches End-Grün sowie deutsche Formatierung von Restzeiten und Terminen. Absolute Zeiten werden in `Europe/Berlin` mit Zeitzone ausgegeben. |
| `EndPrevent` | Besitzt die `EndOpeningEffects`. Erstellt in `onEnable()` eine `EndOpeningSchedule`, wenn die Scheduler-Appliance vorhanden ist, und stoppt sie in `onDisable()`. Verwaltet den gemeinsamen Zustandswechsel. |
| `EndOpeningSchedule` | Besitzt einen Bukkit-Task. Startet im nächsten Tick nach der Initialisierung aller Appliances und prüft dann alle 20 Ticks die Uhrzeit und Ankündigungsstufen. |
| `EndOpeningEffects` | Verwendet die geladenen Text- und Effektgruppen. Wählt Aktivierungstexte zufällig aus und gibt Effekte über `play(effect)` aus. |
| `EndCommand` | Öffentlicher Informationsbefehl `/end`, von EndPrevent registriert. Zeigt die Restzeit oder einen passenden Hinweis zum tatsächlichen Zustand. |
| `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. |
| `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
@@ -45,7 +47,8 @@ effects:
```
`times` kann beliebige benannte Zeitpunkte enthalten. `messages.end-activation-denied.texts`
enthält beliebig viele Texte für die zufällige private Chat-Nachricht.
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
@@ -56,17 +59,23 @@ Ohne `sound` oder mit leerem Sound-Namen bleibt es bei der Textausgabe. `volume`
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` können frei benannte Effekte ergänzt oder entfernt werden.
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 End-Öffnung in Minuten an, `0` kennzeichnet einen Effekt beim tatsächlichen Öffnen.
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 zehn Originaltexte mit 240, 60, 10, 1 und 0 Minuten.
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; ihre optionalen Sounds sind zunächst nicht gesetzt.
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.
Zum Anpassen die gewünschten Abschnitte aus der Vorlage in die Serverdatei übernehmen.
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.
@@ -79,50 +88,63 @@ Outer-End-Termin ist die Hauptinsel sofort verfügbar und das Outer End öffnet
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.
`nether-open-at` wird beim Reload als Nether-Termin angezeigt. Die automatische Nether-Steuerung
ist weiterhin nicht angebunden.
`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` ist für alle Spieler und die Konsole verfügbar. Bei geschlossenem End mit zukünftigem
`/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.
Der Informationsbefehl ändert keinen Zustand und schreibt keine Datei.
`/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. Status und Bestätigungen sind auf Deutsch in
End-Grün formuliert; ungültige Argumente zeigen die Verwendung statt einer Erfolgsmeldung.
`/endPrevent` ohne Argument zeigt den Zustand. `/netherPrevent preventNether` sperrt den Nether,
`/netherPrevent allowNether` öffnet ihn und `/netherPrevent` ohne Argument zeigt seinen Zustand.
Beide Verwaltungsbefehle sind einschließlich Tabvervollständigung auf Konsole und OPs beschränkt.
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()`.
Diese Methode führt denselben Zustandswechsel mit Speicherung aus, stellt den aktuellen
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 End-Terminen greift der Zeitplan erneut ein.
Ein Reload mit unveränderten End-Terminen setzt einen manuellen Zustand nicht zurück.
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 das End
geschlossen ist. Bereits vergangene Stufen werden beim Start oder bei einem neuen End-Termin
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` werden nach einem erfolgreichen Reload
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
@@ -131,7 +153,7 @@ 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 von EndPrevent gilt frühestens dessen Startzeitpunkt als Grenze.
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
@@ -140,16 +162,32 @@ konfigurierten Text im Chat, höchstens einmal alle drei echten Sekunden. Versuc
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.
Alle Flavourtexte und zeitgesteuerten Chat-Effekte verwenden einheitlich End-Grün (`#80A71A`).
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`).
## Erweiterungen und Leistung
Weitere Effektarten können später zentral in `EndOpeningEffects.play(effect)`
Weitere Effektarten können später zentral in `DimensionOpeningEffects.play(effect)`
ergänzt werden und werden bei manueller sowie zeitgesteuerter Öffnung gleichermaßen ausgeführt.
Neue Ankündigungen benötigen nur einen weiteren YAML-Eintrag mit Minutenwert und Text.
Es bleibt bei einem Task mit wenigen Zeitvergleichen pro Sekunde und einer kleinen
Menge behandelter Effektnamen. Die Prüfung arbeitet im Arbeitsspeicher. Chat,
Es bleibt 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.
@@ -1,18 +1,15 @@
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.DimensionScheduler.Effect;
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.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* End-specific interpretation of the named times. Created and stopped by EndPrevent.
@@ -21,9 +18,7 @@ import java.util.Set;
final class EndOpeningSchedule {
private final EndPrevent endPrevent;
private final DimensionScheduler scheduler;
private final EndOpeningEffects effects;
private final Set<String> handledAnnouncements = new HashSet<>();
private List<Effect> previousEffects = List.of();
private final OpeningAnnouncements announcements;
private @Nullable BukkitTask task;
private Optional<Instant> previousEndOpenAt = Optional.empty();
private Optional<Instant> previousOuterEndOpenAt = Optional.empty();
@@ -31,10 +26,10 @@ final class EndOpeningSchedule {
private boolean initialized;
private Instant startedAt = Instant.EPOCH;
EndOpeningSchedule(EndPrevent endPrevent, DimensionScheduler scheduler, EndOpeningEffects effects) {
EndOpeningSchedule(EndPrevent endPrevent, DimensionScheduler scheduler, DimensionOpeningEffects effects) {
this.endPrevent = endPrevent;
this.scheduler = scheduler;
this.effects = effects;
this.announcements = new OpeningAnnouncements(effects);
}
void start() {
@@ -53,19 +48,15 @@ final class EndOpeningSchedule {
private void checkOpeningTimes() {
Optional<Instant> endOpenAt = this.scheduler.getTime("end-open-at");
Optional<Instant> outerEndOpenAt = this.scheduler.getTime("outer-end-open-at");
List<Effect> configuredEffects = this.effects.getConfiguredEffects();
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;
if(!this.initialized || endTimeChanged || !configuredEffects.equals(this.previousEffects)) {
this.resetAnnouncements(endOpenAt, configuredEffects, restoreAt);
}
this.announcements.prepare(endOpenAt, restoreAt);
this.previousEndOpenAt = endOpenAt;
this.previousOuterEndOpenAt = outerEndOpenAt;
this.previousEffects = configuredEffects;
this.initialized = true;
if(endOpenAt.isEmpty() && outerEndOpenAt.isEmpty()) {
@@ -96,7 +87,7 @@ final class EndOpeningSchedule {
}
this.previousScheduledState = scheduledState;
}
this.announceDueEffects(endOpenAt, configuredEffects, now);
this.announcements.announceDue(now, this.endPrevent.isEndClosed());
}
private static EndPrevent.State stateAt(Instant at, Optional<Instant> endOpenAt, Optional<Instant> outerEndOpenAt) {
@@ -104,40 +95,4 @@ final class EndOpeningSchedule {
if(outerEndOpenAt.isPresent() && at.isBefore(outerEndOpenAt.get())) return EndPrevent.State.NO_OUTER;
return EndPrevent.State.OPEN;
}
/** A new opening time starts a new cycle; announcements already in the past are consumed. */
private void resetAnnouncements(Optional<Instant> endOpenAt, List<Effect> configuredEffects, Instant cutoff) {
this.handledAnnouncements.clear();
if(endOpenAt.isEmpty()) return;
for(Effect effect : configuredEffects) {
if(effect.minutesBeforeOpening() > 0 && endOpenAt.get().minus(effect.leadTime()).isBefore(cutoff)) {
this.handledAnnouncements.add(effect.name());
}
}
}
private void announceDueEffects(Optional<Instant> endOpenAt, List<Effect> configuredEffects, Instant now) {
if(endOpenAt.isEmpty()) return;
if(!now.isBefore(endOpenAt.get())) {
// Zero-minute effects belong to the opening transition, never to this polling loop.
for(Effect effect : configuredEffects) this.handledAnnouncements.add(effect.name());
return;
}
List<Effect> latestDue = new ArrayList<>();
Instant latestDueAt = null;
for(Effect effect : configuredEffects) {
if(effect.minutesBeforeOpening() == 0 || this.handledAnnouncements.contains(effect.name())) continue;
Instant dueAt = endOpenAt.get().minus(effect.leadTime());
if(now.isBefore(dueAt)) continue;
this.handledAnnouncements.add(effect.name());
if(latestDueAt == null || dueAt.isAfter(latestDueAt)) {
latestDue.clear();
latestDueAt = dueAt;
}
if(dueAt.equals(latestDueAt)) latestDue.add(effect);
}
// Consume all crossed milestones, but announce only the latest while the End is closed.
if(this.endPrevent.isEndClosed()) latestDue.forEach(this.effects::play);
}
}
@@ -1,6 +1,8 @@
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;
@@ -15,7 +17,9 @@ import java.util.List;
public class EndPrevent extends Appliance {
private final String endPreventKey = "endPrevent";
private final EndOpeningEffects openingEffects = new EndOpeningEffects();
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();
@@ -78,7 +82,7 @@ public class EndPrevent extends Appliance {
}
}
EndOpeningEffects effects() {
DimensionOpeningEffects effects() {
return this.openingEffects;
}
@@ -22,6 +22,7 @@ class EndPreventCommand extends ApplianceCommand<EndPrevent> {
@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: /endPrevent [preventEnd|allowEnd|onlyInnerEnd]");
}
@@ -38,7 +39,7 @@ class EndPreventCommand extends ApplianceCommand<EndPrevent> {
@Override
public @NotNull List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length != 1) return List.of();
if(!sender.isOp() || args.length != 1) return List.of();
return this.tabCompleteReducer(this.arguments.keySet().stream().toList(), args);
}
}
@@ -0,0 +1,92 @@
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;
/** 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;
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();
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();
}
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(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;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new NetherAccessListener());
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new NetherCommand(), new NetherPreventCommand());
}
}
@@ -0,0 +1,68 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.netherAccess;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.EntityPortalEvent;
import org.bukkit.event.player.PlayerPortalEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.world.PortalCreateEvent;
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<>();
@EventHandler(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.cancelIfClosed(event, event.getEntity());
}
@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());
}
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;
// 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;
this.lastDeniedMessageAt.put(player.getUniqueId(), now);
this.getAppliance().effects().denyActivation(player);
}
}
@@ -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,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,39 @@
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
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);
}
}