From 20616f1e027470434321afa249f2d90c33fb5ce6 Mon Sep 17 00:00:00 2001 From: besiece Date: Sun, 8 Nov 2026 19:47:21 +0100 Subject: [PATCH] Enhance DimensionScheduler and EndOpeningEffects with dynamic effect handling and YAML configuration support --- .../DimensionScheduler.java | 102 +++++++++++++++++- .../tooling/dimensionScheduler/README.md | 73 +++++++++---- .../tooling/endPrevent/EndOpeningEffects.java | 57 ++++------ .../endPrevent/EndOpeningSchedule.java | 48 +++++---- .../tooling/endPrevent/EndPrevent.java | 3 +- .../src/main/resources/dimension-schedule.yml | 41 +++++-- 6 files changed, 238 insertions(+), 86 deletions(-) diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduler.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduler.java index e777fd3..f3d7a60 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduler.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduler.java @@ -3,26 +3,40 @@ package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler; import eu.mhsl.craftattack.spawn.core.Main; import eu.mhsl.craftattack.spawn.core.appliance.Appliance; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; import org.jetbrains.annotations.NotNull; import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.logging.Level; -/** Reads named times only; applying them to dimension access is handled separately. */ +/** Reads named times, message groups and effect definitions; execution belongs to their consumers. */ public class DimensionScheduler extends Appliance { + public record Effect(String name, int minutesBeforeOpening, String message) { + public Duration leadTime() { + return Duration.ofMinutes(this.minutesBeforeOpening); + } + } + private static final String FILE_NAME = "dimension-schedule.yml"; private final File scheduleFile = new File(Main.instance().getDataFolder(), FILE_NAME); private Map times = Map.of(); + private Map> messages = Map.of(); + private Map> effects = Map.of(); private Instant loadedAt = Instant.now(); @Override @@ -31,23 +45,95 @@ public class DimensionScheduler extends Appliance { if(!this.scheduleFile.exists()) Main.instance().saveResource(FILE_NAME, false); this.reload(); } catch(IOException | InvalidConfigurationException | IllegalArgumentException e) { - Main.logger().log(Level.WARNING, "Could not load " + FILE_NAME + "; keeping previous times", e); + Main.logger().log(Level.WARNING, "Could not load " + FILE_NAME + "; keeping previous configuration", e); } } - /** Validates all entries before replacing the current times. Never creates or writes the file. */ + /** Validates the complete configuration before replacing it. Never creates or writes the file. */ public void reload() throws IOException, InvalidConfigurationException { YamlConfiguration configuration = new YamlConfiguration(); configuration.load(this.scheduleFile); + YamlConfiguration defaults = this.readDefaults(); Map loadedTimes = new HashMap<>(); - for(Map.Entry entry : configuration.getValues(false).entrySet()) { + ConfigurationSection timeSection = section(configuration, "times"); + boolean legacyTimes = timeSection == null; + if(legacyTimes) timeSection = configuration; + for(Map.Entry entry : timeSection.getValues(false).entrySet()) { + if(legacyTimes && List.of("times", "messages", "effects").contains(entry.getKey())) continue; readTime(entry.getKey(), entry.getValue()).ifPresent(time -> loadedTimes.put(entry.getKey(), time)); } + ConfigurationSection messageSection = section(configuration, "messages"); + ConfigurationSection effectSection = section(configuration, "effects"); + Map> loadedMessages = readMessages(messageSection == null ? section(defaults, "messages") : messageSection); + Map> loadedEffects = readEffects(effectSection == null ? section(defaults, "effects") : effectSection); + this.times = Map.copyOf(loadedTimes); + this.messages = loadedMessages; + this.effects = loadedEffects; this.loadedAt = Instant.now(); } + private YamlConfiguration readDefaults() throws IOException, InvalidConfigurationException { + YamlConfiguration defaults = new YamlConfiguration(); + try(InputStream resource = Main.instance().getResource(FILE_NAME)) { + if(resource == null) throw new IOException("Missing bundled " + FILE_NAME); + defaults.load(new InputStreamReader(resource, StandardCharsets.UTF_8)); + } + return defaults; + } + + private static ConfigurationSection section(ConfigurationSection parent, String key) { + Object value = parent.get(key); + if(value == null) return null; + if(value instanceof ConfigurationSection section) return section; + throw new IllegalArgumentException(parent.getCurrentPath() + "." + key + " muss ein YAML-Abschnitt sein."); + } + + private static Map> readMessages(ConfigurationSection section) { + if(section == null) return Map.of(); + Map> messages = new HashMap<>(); + for(Map.Entry entry : section.getValues(false).entrySet()) { + if(!(entry.getValue() instanceof List values)) { + throw new IllegalArgumentException("messages." + entry.getKey() + " muss eine Liste von Texten sein."); + } + List texts = new ArrayList<>(); + for(Object value : values) { + if(!(value instanceof String text) || text.isBlank()) { + throw new IllegalArgumentException("messages." + entry.getKey() + " enthält einen ungültigen Text."); + } + texts.add(text); + } + messages.put(entry.getKey(), List.copyOf(texts)); + } + return Map.copyOf(messages); + } + + private static Map> readEffects(ConfigurationSection section) { + if(section == null) return Map.of(); + Map> effects = new HashMap<>(); + for(String groupName : section.getKeys(false)) { + ConfigurationSection group = section(section, groupName); + if(group == null) throw new IllegalArgumentException("effects." + groupName + " muss ein YAML-Abschnitt sein."); + List definitions = new ArrayList<>(); + for(String name : group.getKeys(false)) { + ConfigurationSection effect = section(group, name); + if(effect == null) throw new IllegalArgumentException("effects." + groupName + "." + name + " muss ein YAML-Abschnitt sein."); + Object minutes = effect.get("minutes-before-opening"); + Object message = effect.get("message"); + if(!(minutes instanceof Integer count) || count < 0) { + throw new IllegalArgumentException(effect.getCurrentPath() + ".minutes-before-opening muss eine nicht negative ganze Zahl sein."); + } + if(!(message instanceof String text) || text.isBlank()) { + throw new IllegalArgumentException(effect.getCurrentPath() + ".message muss ein nicht leerer Text sein."); + } + definitions.add(new Effect(name, count, text)); + } + effects.put(groupName, List.copyOf(definitions)); + } + return Map.copyOf(effects); + } + private static Optional readTime(String key, Object configured) { if(configured == null) return Optional.empty(); if(!(configured instanceof String value)) { @@ -70,6 +156,14 @@ public class DimensionScheduler extends Appliance { return this.times; } + public List getMessages(String name) { + return this.messages.getOrDefault(name, List.of()); + } + + public List getEffects(String name) { + return this.effects.getOrDefault(name, List.of()); + } + /** Wall-clock time of the last successful load, before consumers check the new times. */ public Instant getLoadedAt() { return this.loadedAt; 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 1f91d4a..68eaab9 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,19 +4,51 @@ | Klasse | Aufgabe und Erstellung | | --- | --- | -| `DimensionScheduler` | Automatisch geladene Appliance. Erstellt die fehlende `plugins/spawn/dimension-schedule.yml` aus der Vorlage und liest beliebige benannte Zeitpunkte. 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-Aktionen. | | `DimensionScheduleCommand` | Wird über die Appliance registriert. `/dimensionSchedule reload` liest die Datei für Konsole und OPs neu ein. Bei Fehlern bleiben alle bisherigen Werte erhalten. | | `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` | Enthält die fünf zufälligen Aktivierungstexte und die fünf Ankündigungs-/Öffnungstexte. `Stage` definiert die Vorlaufzeiten, `play(stage)` führt die Stufeneffekte aus. | +| `EndOpeningEffects` | Verwendet die geladenen Text- und Effektgruppen. Wählt Aktivierungstexte zufällig aus und gibt Effekte über `play(effect)` aus. | -## Öffnungszeiten +## YAML und dynamische Effekte ```yaml -end-open-at: "2026-11-15T18:00:00+01:00" -outer-end-open-at: "2026-11-16T18:00:00+01:00" +times: + end-open-at: "2026-11-15T18:00:00+01:00" + outer-end-open-at: "2026-11-16T18:00:00+01:00" + +messages: + end-activation-denied: + - "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..." + +effects: + end-opening: + five-minutes-before: + minutes-before-opening: 5 + message: "Etwas regt sich in der Leere ..." + opened: + minutes-before-opening: 0 + message: "Der Weg ins End ist nun offen!" ``` +`times` kann beliebige benannte Zeitpunkte enthalten. `messages.end-activation-denied` +enthält beliebig viele Texte für die zufällige Actionbar-Meldung. +Unter `effects.end-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. +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 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. +Explizite leere Abschnitte (`messages: {}`, `effects: {}`) verwenden keine Standardgruppen; +einzelne Gruppen lassen sich durch `end-activation-denied: []` beziehungsweise `end-opening: {}` leeren. + +## Öffnungszeiten + Vor `end-open-at` ist das End `CLOSED`, danach bis `outer-end-open-at` ist nur die Hauptinsel verfügbar (`NO_OUTER`), anschließend ist es vollständig `OPEN`. Ohne Outer-End-Termin öffnet `end-open-at` das gesamte End. Mit ausschließlich einem @@ -30,7 +62,8 @@ Weitere Namen können in der YAML ergänzt werden; nur die End-Klasse interpreti `/endPrevent preventEnd`, `/endPrevent allowEnd` und `/endPrevent onlyInnerEnd` bleiben bestehen. 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` den Effekt `OPENED` aus. +`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. @@ -45,40 +78,38 @@ Ein Reload mit unveränderten End-Terminen setzt einen manuellen Zustand nicht z ## Ankündigungen und Portalversuche -| Stufe | Zeitpunkt und Ausgabe | -| --- | --- | -| `FOUR_HOURS_BEFORE` | 4 Stunden vor `end-open-at`: unheimliche Stille | -| `ONE_HOUR_BEFORE` | 1 Stunde vorher: schwere Luft | -| `TEN_MINUTES_BEFORE` | 10 Minuten vorher: Flügelschlagen | -| `ONE_MINUTE_BEFORE` | 1 Minute vorher: Schatten | -| `OPENED` | Bei einer tatsächlichen Öffnung: Riss in der Realität | - 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 ü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ündigung ausgegeben. Ist die Öffnung bereits fällig, entfallen alte Teaser. +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 +ebenfalls übernommen. Bereits vor diesem Reload vergangene Ankündigungen werden übersprungen; +zukünftige Effekte laufen mit den neuen Texten und Minutenwerten. Reine Textänderungen in +`messages` setzen die Ankündigungen nicht zurück. Ungültige Zeiten, Minutenwerte oder Texte +lassen den gesamten Reload scheitern und behalten die bisherige Konfiguration. + Für einen geänderten Termin gilt der erfolgreiche Ladezeitpunkt als Grenze für verpasste Stufen. Wird eine Ankündigung erst zwischen dem Reload und der nächsten Sekundenprüfung fällig, wird sie regulär ausgeführt. Dasselbe gilt für eine in diesem Zeitraum fällige Öffnung. Beim erneuten Aktivieren von EndPrevent gilt frühestens dessen Startzeitpunkt als Grenze. Ein Rechtsklick mit Enderauge auf einen leeren, gesperrten Endportalrahmen wird für beide Hände -abgebrochen. Nur der auslösende Spieler erhält einen der fünf zufälligen Texte in der Actionbar, +abgebrochen. Nur der auslösende Spieler erhält einen zufälligen konfigurierten Text in der Actionbar, höchstens einmal pro Server-Tick. Die Spieler-Zuordnung wird beim Verlassen entfernt. Die bestehenden Meldungen beim Betreten gesperrter Portale und Gateways bleiben getrennt davon. ## Erweiterungen und Leistung -Die Originaltexte stehen ausschließlich in `EndOpeningEffects`. Zusätzliche Sounds oder -andere Effekte können in `play(stage)` ergänzt werden und werden bei manueller sowie -zeitgesteuerter Öffnung gleichermaßen ausgeführt. Weitere Vorankündigungen erhalten einen -neuen `Stage`-Eintrag mit Vorlaufzeit und Text; der Zeitplan berücksichtigt sie automatisch. +Zusätzliche Sounds oder andere Effektarten können später zentral in `EndOpeningEffects.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 -`EnumSet`-Menge für behandelte Stufen. Die Prüfung arbeitet im Arbeitsspeicher. Chat, +Menge behandelter Effektnamen. 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/EndOpeningEffects.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java index 8383c63..3348964 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/endPrevent/EndOpeningEffects.java @@ -1,56 +1,45 @@ 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 net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; -import java.time.Duration; import java.util.List; import java.util.concurrent.ThreadLocalRandom; -/** All End-opening presentation lives here; add sounds or other effects in play(). */ +/** Presents YAML-defined End effects. Add sounds or other effects in play(). */ final class EndOpeningEffects { - enum Stage { - FOUR_HOURS_BEFORE(Duration.ofHours(4), - "Eine unheimliche Stille scheint sich langsam über die Welt auszubreiten. Du hast das seltsame Gefühl, beobachtet zu werden ..."), - ONE_HOUR_BEFORE(Duration.ofHours(1), - "Die Luft fühlt sich plötzlich ungewöhnlich schwer an. Du hast das Gefühl, als würde eine unsichtbare Kraft auf dir lasten ..."), - TEN_MINUTES_BEFORE(Duration.ofMinutes(10), - "Ein seltsames Geräusch hallt durch die Welt. Könnte es das Schlagen gewaltiger Flügel sein ...?"), - ONE_MINUTE_BEFORE(Duration.ofMinutes(1), - "Ein gewaltiger Schatten scheint über die Welt zu ziehen. Du blickst zum Himmel, doch dort ist nichts zu sehen ..."), - OPENED(Duration.ZERO, - "Ein gewaltiger Riss öffnet sich in der Realität! Die Leere erwacht und die letzte Grenze fällt. Der Weg ins End ist nun offen!"); + private @Nullable DimensionScheduler scheduler; - private final Duration leadTime; - private final String message; - - Stage(Duration leadTime, String message) { - this.leadTime = leadTime; - this.message = message; - } - - Duration leadTime() { - return this.leadTime; - } + void configure(DimensionScheduler scheduler) { + this.scheduler = scheduler; } - private static final List ACTIVATION_DENIED_MESSAGES = List.of( - "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ...", - "Der Rahmen fühlt sich ungewöhnlich kalt an. Du hast das Gefühl, als würde er dir die Wärme aus den Fingern ziehen …", - "Die Luft über dem leeren Rahmen scheint ungewöhnlich dunkel. Du hast das Gefühl, in einen bodenlosen Abgrund zu blicken ...", - "Für einen kurzen Moment scheint die Umgebung vollkommen still zu werden. Du fragst dich, ob du dir das nur eingebildet hast ...", - "Ein schwaches, violettes Licht flackert zwischen den Augen auf. Etwas scheint dich zu beobachten ..." - ); + List getConfiguredEffects() { + return this.scheduler == null ? List.of() : this.scheduler.getEffects("end-opening"); + } void denyActivation(Player player) { - String message = ACTIVATION_DENIED_MESSAGES.get(ThreadLocalRandom.current().nextInt(ACTIVATION_DENIED_MESSAGES.size())); + if(this.scheduler == null) return; + List messages = this.scheduler.getMessages("end-activation-denied"); + if(messages.isEmpty()) return; + String message = messages.get(ThreadLocalRandom.current().nextInt(messages.size())); player.sendActionBar(Component.text(message, NamedTextColor.RED)); } - void play(Stage stage) { - Component message = Component.text(stage.message, stage == Stage.OPENED ? NamedTextColor.LIGHT_PURPLE : NamedTextColor.GRAY); + void playOpening() { + for(Effect effect : this.getConfiguredEffects()) { + if(effect.minutesBeforeOpening() == 0) this.play(effect); + } + } + + void play(Effect effect) { + NamedTextColor color = effect.minutesBeforeOpening() == 0 ? NamedTextColor.LIGHT_PURPLE : NamedTextColor.GRAY; + Component message = Component.text(effect.message(), color); Bukkit.getOnlinePlayers().forEach(player -> player.sendMessage(message)); } } 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 c1fa26e..39f7262 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,26 +1,29 @@ 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.endPrevent.EndOpeningEffects.Stage; +import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduler.Effect; 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.EnumSet; +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. * DimensionScheduler only supplies configuration; this class owns the single polling task. */ final class EndOpeningSchedule { - private static final EnumSet ANNOUNCEMENT_STAGES = EnumSet.complementOf(EnumSet.of(Stage.OPENED)); private final EndPrevent endPrevent; private final DimensionScheduler scheduler; private final EndOpeningEffects effects; - private final EnumSet handledAnnouncements = EnumSet.noneOf(Stage.class); + private final Set handledAnnouncements = new HashSet<>(); + private List previousEffects = List.of(); private @Nullable BukkitTask task; private Optional previousEndOpenAt = Optional.empty(); private Optional previousOuterEndOpenAt = Optional.empty(); @@ -50,15 +53,19 @@ 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) this.resetAnnouncements(endOpenAt, restoreAt); + if(!this.initialized || endTimeChanged || !configuredEffects.equals(this.previousEffects)) { + this.resetAnnouncements(endOpenAt, configuredEffects, restoreAt); + } this.previousEndOpenAt = endOpenAt; this.previousOuterEndOpenAt = outerEndOpenAt; + this.previousEffects = configuredEffects; this.initialized = true; if(endOpenAt.isEmpty() && outerEndOpenAt.isEmpty()) { @@ -89,7 +96,7 @@ final class EndOpeningSchedule { } this.previousScheduledState = scheduledState; } - this.announceDueStages(endOpenAt, now); + this.announceDueEffects(endOpenAt, configuredEffects, now); } private static EndPrevent.State stateAt(Instant at, Optional endOpenAt, Optional outerEndOpenAt) { @@ -99,35 +106,38 @@ final class EndOpeningSchedule { } /** A new opening time starts a new cycle; announcements already in the past are consumed. */ - private void resetAnnouncements(Optional endOpenAt, Instant now) { + private void resetAnnouncements(Optional endOpenAt, List configuredEffects, Instant cutoff) { this.handledAnnouncements.clear(); if(endOpenAt.isEmpty()) return; - for(Stage stage : ANNOUNCEMENT_STAGES) { - if(endOpenAt.get().minus(stage.leadTime()).isBefore(now)) this.handledAnnouncements.add(stage); + for(Effect effect : configuredEffects) { + if(effect.minutesBeforeOpening() > 0 && endOpenAt.get().minus(effect.leadTime()).isBefore(cutoff)) { + this.handledAnnouncements.add(effect.name()); + } } } - private void announceDueStages(Optional endOpenAt, Instant now) { + private void announceDueEffects(Optional endOpenAt, List configuredEffects, Instant now) { if(endOpenAt.isEmpty()) return; if(!now.isBefore(endOpenAt.get())) { - // The opening transition owns OPENED. Never replay teasers after opening is due. - this.handledAnnouncements.addAll(ANNOUNCEMENT_STAGES); + // Zero-minute effects belong to the opening transition, never to this polling loop. + for(Effect effect : configuredEffects) this.handledAnnouncements.add(effect.name()); return; } - Stage latestDue = null; + List latestDue = new ArrayList<>(); Instant latestDueAt = null; - for(Stage stage : ANNOUNCEMENT_STAGES) { - if(this.handledAnnouncements.contains(stage)) continue; - Instant dueAt = endOpenAt.get().minus(stage.leadTime()); + 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(stage); + this.handledAnnouncements.add(effect.name()); if(latestDueAt == null || dueAt.isAfter(latestDueAt)) { - latestDue = stage; + 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(latestDue != null && this.endPrevent.isEndClosed()) this.effects.play(latestDue); + 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 71eeb46..2aa522b 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 @@ -41,6 +41,7 @@ public class EndPrevent extends Appliance { .findFirst().orElse(null); if(scheduler == null) return; + this.openingEffects.configure(scheduler); this.openingSchedule = new EndOpeningSchedule(this, scheduler, this.openingEffects); this.openingSchedule.start(); } @@ -73,7 +74,7 @@ public class EndPrevent extends Appliance { this.localConfig().set(this.endPreventKey, state.name()); Configuration.saveChanges(); if(announceOpening && wasClosed && !this.isEndClosed()) { - this.openingEffects.play(EndOpeningEffects.Stage.OPENED); + this.openingEffects.playOpening(); } } diff --git a/common/src/main/resources/dimension-schedule.yml b/common/src/main/resources/dimension-schedule.yml index 8c90575..e7e0a7d 100644 --- a/common/src/main/resources/dimension-schedule.yml +++ b/common/src/main/resources/dimension-schedule.yml @@ -1,7 +1,34 @@ -# Datum mit UTC-Offset, z. B. "2026-11-15T18:00:00+01:00" -# null bedeutet: kein Termin -# Weitere Einträge mit frei wählbaren Namen können ergänzt werden. -# EndPrevent: Bis zu diesem Zeitpunkt bleibt das End geschlossen. -end-open-at: "2026-11-08T19:16:34+01:00" -# Optional: Nach der End-Öffnung nur die Hauptinsel bis zu diesem Zeitpunkt freigeben. -outer-end-open-at: null +# Datum mit UTC-Offset in Anführungszeichen; null bedeutet: kein Termin. +# Weitere frei benannte Zeitpunkte können hier ergänzt werden. +times: + end-open-at: "2026-11-08T19:16:34+01:00" + outer-end-open-at: null + +messages: + end-activation-denied: + - "Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ..." + - "Der Rahmen fühlt sich ungewöhnlich kalt an. Du hast das Gefühl, als würde er dir die Wärme aus den Fingern ziehen …" + - "Die Luft über dem leeren Rahmen scheint ungewöhnlich dunkel. Du hast das Gefühl, in einen bodenlosen Abgrund zu blicken ..." + - "Für einen kurzen Moment scheint die Umgebung vollkommen still zu werden. Du fragst dich, ob du dir das nur eingebildet hast ..." + - "Ein schwaches, violettes Licht flackert zwischen den Augen auf. Etwas scheint dich zu beobachten ..." + +effects: + end-opening: + # Namen und Anzahl der Effekte sind frei wählbar. + # Positive Minutenwerte: Vorankündigung im Chat, solange das End geschlossen ist. + four-hours-before: + minutes-before-opening: 240 + message: "Eine unheimliche Stille scheint sich langsam über die Welt auszubreiten. Du hast das seltsame Gefühl, beobachtet zu werden ..." + one-hour-before: + minutes-before-opening: 60 + message: "Die Luft fühlt sich plötzlich ungewöhnlich schwer an. Du hast das Gefühl, als würde eine unsichtbare Kraft auf dir lasten ..." + ten-minutes-before: + minutes-before-opening: 10 + message: "Ein seltsames Geräusch hallt durch die Welt. Könnte es das Schlagen gewaltiger Flügel sein ...?" + one-minute-before: + minutes-before-opening: 1 + message: "Ein gewaltiger Schatten scheint über die Welt zu ziehen. Du blickst zum Himmel, doch dort ist nichts zu sehen ..." + # 0: Nur beim tatsächlichen Öffnen, gleichermaßen durch Command oder Zeitplan. + opened: + minutes-before-opening: 0 + message: "Ein gewaltiger Riss öffnet sich in der Realität! Die Leere erwacht und die letzte Grenze fällt. Der Weg ins End ist nun offen!"