diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionOpeningEffects.java similarity index 59% rename from common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java rename to common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionOpeningEffects.java index 5501796..e49e347 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionOpeningEffects.java @@ -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 getConfiguredEffects() { - return this.scheduler == null ? List.of() : this.scheduler.getEffects("end-opening"); + public List 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 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 = effect.sound().map(SoundSettings::asSound); Bukkit.getOnlinePlayers().forEach(player -> send(player, message, sound)); } diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleCommand.java index 5225736..6024200 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleCommand.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleCommand.java @@ -32,7 +32,8 @@ class DimensionScheduleCommand extends ApplianceCommand { 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 diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleText.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleText.java index 79bc2d5..01d7556 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleText.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleText.java @@ -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. */ diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/OpeningAnnouncements.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/OpeningAnnouncements.java new file mode 100644 index 0000000..d827dc3 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/OpeningAnnouncements.java @@ -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 handled = new HashSet<>(); + private Optional openingAt = Optional.empty(); + private List 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 openingAt, Instant cutoff) { + List 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 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); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/README.md b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/README.md index bd60a3e..43daa2c 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/README.md +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/README.md @@ -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. diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningSchedule.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningSchedule.java index 39f7262..8634c04 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningSchedule.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningSchedule.java @@ -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 handledAnnouncements = new HashSet<>(); - private List previousEffects = List.of(); + private final OpeningAnnouncements announcements; private @Nullable BukkitTask task; private Optional previousEndOpenAt = Optional.empty(); private Optional 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 endOpenAt = this.scheduler.getTime("end-open-at"); Optional outerEndOpenAt = this.scheduler.getTime("outer-end-open-at"); - List 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 endOpenAt, Optional 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 endOpenAt, List 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 endOpenAt, List 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 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); - } } diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPrevent.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPrevent.java index 317202d..378d37d 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPrevent.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPrevent.java @@ -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; } diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventCommand.java index 54f778b..39944ac 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventCommand.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventCommand.java @@ -22,6 +22,7 @@ class EndPreventCommand extends ApplianceCommand { @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 { @Override public @NotNull List 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); } } diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccess.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccess.java new file mode 100644 index 0000000..7703324 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccess.java @@ -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 listeners() { + return List.of(new NetherAccessListener()); + } + + @Override + protected @NotNull List> commands() { + return List.of(new NetherCommand(), new NetherPreventCommand()); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccessListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccessListener.java new file mode 100644 index 0000000..c94089d --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherAccessListener.java @@ -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 { + private static final long MESSAGE_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3); + private final Map 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); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherCommand.java new file mode 100644 index 0000000..665c4c1 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherCommand.java @@ -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 { + 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 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 onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + return List.of(); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherOpeningSchedule.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherOpeningSchedule.java new file mode 100644 index 0000000..f4bd67b --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherOpeningSchedule.java @@ -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 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 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; + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherPreventCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherPreventCommand.java new file mode 100644 index 0000000..e245583 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/netherAccess/NetherPreventCommand.java @@ -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 { + private final Map 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 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); + } +}