Enhance DimensionScheduler and EndOpeningEffects with dynamic effect handling and YAML configuration support

This commit is contained in:
besiece committed 2026-11-08 19:47:21 +01:00
1 parent f3f19a3370
commit 20616f1e02
6 files changed
+238 -86

No files matched your search

@@ -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.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance; import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.io.File; import java.io.File;
import java.io.IOException; 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.Instant;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Level; 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 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 static final String FILE_NAME = "dimension-schedule.yml";
private final File scheduleFile = new File(Main.instance().getDataFolder(), FILE_NAME); private final File scheduleFile = new File(Main.instance().getDataFolder(), FILE_NAME);
private Map<String, Instant> times = Map.of(); private Map<String, Instant> times = Map.of();
private Map<String, List<String>> messages = Map.of();
private Map<String, List<Effect>> effects = Map.of();
private Instant loadedAt = Instant.now(); private Instant loadedAt = Instant.now();
@Override @Override
@@ -31,23 +45,95 @@ public class DimensionScheduler extends Appliance {
if(!this.scheduleFile.exists()) Main.instance().saveResource(FILE_NAME, false); if(!this.scheduleFile.exists()) Main.instance().saveResource(FILE_NAME, false);
this.reload(); this.reload();
} catch(IOException | InvalidConfigurationException | IllegalArgumentException e) { } 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 { public void reload() throws IOException, InvalidConfigurationException {
YamlConfiguration configuration = new YamlConfiguration(); YamlConfiguration configuration = new YamlConfiguration();
configuration.load(this.scheduleFile); configuration.load(this.scheduleFile);
YamlConfiguration defaults = this.readDefaults();
Map<String, Instant> loadedTimes = new HashMap<>(); Map<String, Instant> loadedTimes = new HashMap<>();
for(Map.Entry<String, Object> entry : configuration.getValues(false).entrySet()) { ConfigurationSection timeSection = section(configuration, "times");
boolean legacyTimes = timeSection == null;
if(legacyTimes) timeSection = configuration;
for(Map.Entry<String, Object> entry : timeSection.getValues(false).entrySet()) {
if(legacyTimes && List.of("times", "messages", "effects").contains(entry.getKey())) continue;
readTime(entry.getKey(), entry.getValue()).ifPresent(time -> loadedTimes.put(entry.getKey(), time)); readTime(entry.getKey(), entry.getValue()).ifPresent(time -> loadedTimes.put(entry.getKey(), time));
} }
ConfigurationSection messageSection = section(configuration, "messages");
ConfigurationSection effectSection = section(configuration, "effects");
Map<String, List<String>> loadedMessages = readMessages(messageSection == null ? section(defaults, "messages") : messageSection);
Map<String, List<Effect>> loadedEffects = readEffects(effectSection == null ? section(defaults, "effects") : effectSection);
this.times = Map.copyOf(loadedTimes); this.times = Map.copyOf(loadedTimes);
this.messages = loadedMessages;
this.effects = loadedEffects;
this.loadedAt = Instant.now(); this.loadedAt = Instant.now();
} }
private YamlConfiguration readDefaults() throws IOException, InvalidConfigurationException {
YamlConfiguration defaults = new YamlConfiguration();
try(InputStream resource = Main.instance().getResource(FILE_NAME)) {
if(resource == null) throw new IOException("Missing bundled " + FILE_NAME);
defaults.load(new InputStreamReader(resource, StandardCharsets.UTF_8));
}
return defaults;
}
private static ConfigurationSection section(ConfigurationSection parent, String key) {
Object value = parent.get(key);
if(value == null) return null;
if(value instanceof ConfigurationSection section) return section;
throw new IllegalArgumentException(parent.getCurrentPath() + "." + key + " muss ein YAML-Abschnitt sein.");
}
private static Map<String, List<String>> readMessages(ConfigurationSection section) {
if(section == null) return Map.of();
Map<String, List<String>> messages = new HashMap<>();
for(Map.Entry<String, Object> entry : section.getValues(false).entrySet()) {
if(!(entry.getValue() instanceof List<?> values)) {
throw new IllegalArgumentException("messages." + entry.getKey() + " muss eine Liste von Texten sein.");
}
List<String> texts = new ArrayList<>();
for(Object value : values) {
if(!(value instanceof String text) || text.isBlank()) {
throw new IllegalArgumentException("messages." + entry.getKey() + " enthält einen ungültigen Text.");
}
texts.add(text);
}
messages.put(entry.getKey(), List.copyOf(texts));
}
return Map.copyOf(messages);
}
private static Map<String, List<Effect>> readEffects(ConfigurationSection section) {
if(section == null) return Map.of();
Map<String, List<Effect>> effects = new HashMap<>();
for(String groupName : section.getKeys(false)) {
ConfigurationSection group = section(section, groupName);
if(group == null) throw new IllegalArgumentException("effects." + groupName + " muss ein YAML-Abschnitt sein.");
List<Effect> definitions = new ArrayList<>();
for(String name : group.getKeys(false)) {
ConfigurationSection effect = section(group, name);
if(effect == null) throw new IllegalArgumentException("effects." + groupName + "." + name + " muss ein YAML-Abschnitt sein.");
Object minutes = effect.get("minutes-before-opening");
Object message = effect.get("message");
if(!(minutes instanceof Integer count) || count < 0) {
throw new IllegalArgumentException(effect.getCurrentPath() + ".minutes-before-opening muss eine nicht negative ganze Zahl sein.");
}
if(!(message instanceof String text) || text.isBlank()) {
throw new IllegalArgumentException(effect.getCurrentPath() + ".message muss ein nicht leerer Text sein.");
}
definitions.add(new Effect(name, count, text));
}
effects.put(groupName, List.copyOf(definitions));
}
return Map.copyOf(effects);
}
private static Optional<Instant> readTime(String key, Object configured) { private static Optional<Instant> readTime(String key, Object configured) {
if(configured == null) return Optional.empty(); if(configured == null) return Optional.empty();
if(!(configured instanceof String value)) { if(!(configured instanceof String value)) {
@@ -70,6 +156,14 @@ public class DimensionScheduler extends Appliance {
return this.times; return this.times;
} }
public List<String> getMessages(String name) {
return this.messages.getOrDefault(name, List.of());
}
public List<Effect> getEffects(String name) {
return this.effects.getOrDefault(name, List.of());
}
/** Wall-clock time of the last successful load, before consumers check the new times. */ /** Wall-clock time of the last successful load, before consumers check the new times. */
public Instant getLoadedAt() { public Instant getLoadedAt() {
return this.loadedAt; return this.loadedAt;
@@ -4,19 +4,51 @@
| Klasse | Aufgabe und Erstellung | | 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. | | `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. | | `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. | | `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 ```yaml
end-open-at: "2026-11-15T18:00:00+01:00" times:
outer-end-open-at: "2026-11-16T18:00:00+01:00" 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 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`. 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 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. `/endPrevent preventEnd`, `/endPrevent allowEnd` und `/endPrevent onlyInnerEnd` bleiben bestehen.
Commands und laufende zeitgesteuerte Übergänge verwenden `EndPrevent.setEndState()`. Commands und laufende zeitgesteuerte Übergänge verwenden `EndPrevent.setEndState()`.
Der gemeinsame Zustandswechsel aktualisiert Zustand und Weltgrenze, speichert Änderungen in 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 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. 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 ## 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 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 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 ü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 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. 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 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 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. 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 von EndPrevent gilt frühestens dessen Startzeitpunkt als Grenze.
Ein Rechtsklick mit Enderauge auf einen leeren, gesperrten Endportalrahmen wird für beide Hände 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. 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. Die bestehenden Meldungen beim Betreten gesperrter Portale und Gateways bleiben getrennt davon.
## Erweiterungen und Leistung ## Erweiterungen und Leistung
Die Originaltexte stehen ausschließlich in `EndOpeningEffects`. Zusätzliche Sounds oder Zusätzliche Sounds oder andere Effektarten können später zentral in `EndOpeningEffects.play(effect)`
andere Effekte können in `play(stage)` ergänzt werden und werden bei manueller sowie ergänzt werden und werden bei manueller sowie zeitgesteuerter Öffnung gleichermaßen ausgeführt.
zeitgesteuerter Öffnung gleichermaßen ausgeführt. Weitere Vorankündigungen erhalten einen Neue Ankündigungen benötigen nur einen weiteren YAML-Eintrag mit Minutenwert und Text.
neuen `Stage`-Eintrag mit Vorlaufzeit und Text; der Zeitplan berücksichtigt sie automatisch.
Es bleibt bei einem Task mit wenigen Zeitvergleichen pro Sekunde und einer kleinen 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 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 wird nur bei tatsächlichen Zustandsänderungen in `config.yml`; die Termin-YAML wird nach
ihrer erstmaligen Erstellung ausschließlich gelesen. Es gibt keine persistenten Ausführungsstatus. ihrer erstmaligen Erstellung ausschließlich gelesen. Es gibt keine persistenten Ausführungsstatus.
@@ -1,56 +1,45 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent; 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.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.concurrent.ThreadLocalRandom; 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 { final class EndOpeningEffects {
enum Stage { private @Nullable DimensionScheduler scheduler;
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 final Duration leadTime; void configure(DimensionScheduler scheduler) {
private final String message; this.scheduler = scheduler;
Stage(Duration leadTime, String message) {
this.leadTime = leadTime;
this.message = message;
}
Duration leadTime() {
return this.leadTime;
}
} }
private static final List<String> ACTIVATION_DENIED_MESSAGES = List.of( List<Effect> getConfiguredEffects() {
"Die Augen scheinen dich für einen kurzen Moment anzustarren. Dir läuft ein kalter Schauer über den Rücken ...", return this.scheduler == null ? List.of() : this.scheduler.getEffects("end-opening");
"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 ..."
);
void denyActivation(Player player) { void denyActivation(Player player) {
String message = ACTIVATION_DENIED_MESSAGES.get(ThreadLocalRandom.current().nextInt(ACTIVATION_DENIED_MESSAGES.size())); if(this.scheduler == null) return;
List<String> 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)); player.sendActionBar(Component.text(message, NamedTextColor.RED));
} }
void play(Stage stage) { void playOpening() {
Component message = Component.text(stage.message, stage == Stage.OPENED ? NamedTextColor.LIGHT_PURPLE : NamedTextColor.GRAY); 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)); Bukkit.getOnlinePlayers().forEach(player -> player.sendMessage(message));
} }
} }
@@ -1,26 +1,29 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent; 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;
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 eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.scheduler.BukkitTask; import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.time.Instant; 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.Optional;
import java.util.Set;
/** /**
* End-specific interpretation of the named times. Created and stopped by EndPrevent. * End-specific interpretation of the named times. Created and stopped by EndPrevent.
* DimensionScheduler only supplies configuration; this class owns the single polling task. * DimensionScheduler only supplies configuration; this class owns the single polling task.
*/ */
final class EndOpeningSchedule { final class EndOpeningSchedule {
private static final EnumSet<Stage> ANNOUNCEMENT_STAGES = EnumSet.complementOf(EnumSet.of(Stage.OPENED));
private final EndPrevent endPrevent; private final EndPrevent endPrevent;
private final DimensionScheduler scheduler; private final DimensionScheduler scheduler;
private final EndOpeningEffects effects; private final EndOpeningEffects effects;
private final EnumSet<Stage> handledAnnouncements = EnumSet.noneOf(Stage.class); private final Set<String> handledAnnouncements = new HashSet<>();
private List<Effect> previousEffects = List.of();
private @Nullable BukkitTask task; private @Nullable BukkitTask task;
private Optional<Instant> previousEndOpenAt = Optional.empty(); private Optional<Instant> previousEndOpenAt = Optional.empty();
private Optional<Instant> previousOuterEndOpenAt = Optional.empty(); private Optional<Instant> previousOuterEndOpenAt = Optional.empty();
@@ -50,15 +53,19 @@ final class EndOpeningSchedule {
private void checkOpeningTimes() { private void checkOpeningTimes() {
Optional<Instant> endOpenAt = this.scheduler.getTime("end-open-at"); Optional<Instant> endOpenAt = this.scheduler.getTime("end-open-at");
Optional<Instant> outerEndOpenAt = this.scheduler.getTime("outer-end-open-at"); Optional<Instant> outerEndOpenAt = this.scheduler.getTime("outer-end-open-at");
List<Effect> configuredEffects = this.effects.getConfiguredEffects();
Instant now = Instant.now(); Instant now = Instant.now();
boolean endTimeChanged = !endOpenAt.equals(this.previousEndOpenAt); boolean endTimeChanged = !endOpenAt.equals(this.previousEndOpenAt);
boolean restoreSchedule = !this.initialized || endTimeChanged boolean restoreSchedule = !this.initialized || endTimeChanged
|| !outerEndOpenAt.equals(this.previousOuterEndOpenAt); || !outerEndOpenAt.equals(this.previousOuterEndOpenAt);
Instant restoreAt = this.scheduler.getLoadedAt(); Instant restoreAt = this.scheduler.getLoadedAt();
if(!this.initialized && this.startedAt.isAfter(restoreAt)) restoreAt = this.startedAt; 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.previousEndOpenAt = endOpenAt;
this.previousOuterEndOpenAt = outerEndOpenAt; this.previousOuterEndOpenAt = outerEndOpenAt;
this.previousEffects = configuredEffects;
this.initialized = true; this.initialized = true;
if(endOpenAt.isEmpty() && outerEndOpenAt.isEmpty()) { if(endOpenAt.isEmpty() && outerEndOpenAt.isEmpty()) {
@@ -89,7 +96,7 @@ final class EndOpeningSchedule {
} }
this.previousScheduledState = scheduledState; this.previousScheduledState = scheduledState;
} }
this.announceDueStages(endOpenAt, now); this.announceDueEffects(endOpenAt, configuredEffects, now);
} }
private static EndPrevent.State stateAt(Instant at, Optional<Instant> endOpenAt, Optional<Instant> outerEndOpenAt) { private static EndPrevent.State stateAt(Instant at, Optional<Instant> endOpenAt, Optional<Instant> outerEndOpenAt) {
@@ -99,35 +106,38 @@ final class EndOpeningSchedule {
} }
/** A new opening time starts a new cycle; announcements already in the past are consumed. */ /** A new opening time starts a new cycle; announcements already in the past are consumed. */
private void resetAnnouncements(Optional<Instant> endOpenAt, Instant now) { private void resetAnnouncements(Optional<Instant> endOpenAt, List<Effect> configuredEffects, Instant cutoff) {
this.handledAnnouncements.clear(); this.handledAnnouncements.clear();
if(endOpenAt.isEmpty()) return; if(endOpenAt.isEmpty()) return;
for(Stage stage : ANNOUNCEMENT_STAGES) { for(Effect effect : configuredEffects) {
if(endOpenAt.get().minus(stage.leadTime()).isBefore(now)) this.handledAnnouncements.add(stage); if(effect.minutesBeforeOpening() > 0 && endOpenAt.get().minus(effect.leadTime()).isBefore(cutoff)) {
this.handledAnnouncements.add(effect.name());
}
} }
} }
private void announceDueStages(Optional<Instant> endOpenAt, Instant now) { private void announceDueEffects(Optional<Instant> endOpenAt, List<Effect> configuredEffects, Instant now) {
if(endOpenAt.isEmpty()) return; if(endOpenAt.isEmpty()) return;
if(!now.isBefore(endOpenAt.get())) { if(!now.isBefore(endOpenAt.get())) {
// The opening transition owns OPENED. Never replay teasers after opening is due. // Zero-minute effects belong to the opening transition, never to this polling loop.
this.handledAnnouncements.addAll(ANNOUNCEMENT_STAGES); for(Effect effect : configuredEffects) this.handledAnnouncements.add(effect.name());
return; return;
} }
Stage latestDue = null; List<Effect> latestDue = new ArrayList<>();
Instant latestDueAt = null; Instant latestDueAt = null;
for(Stage stage : ANNOUNCEMENT_STAGES) { for(Effect effect : configuredEffects) {
if(this.handledAnnouncements.contains(stage)) continue; if(effect.minutesBeforeOpening() == 0 || this.handledAnnouncements.contains(effect.name())) continue;
Instant dueAt = endOpenAt.get().minus(stage.leadTime()); Instant dueAt = endOpenAt.get().minus(effect.leadTime());
if(now.isBefore(dueAt)) continue; if(now.isBefore(dueAt)) continue;
this.handledAnnouncements.add(stage); this.handledAnnouncements.add(effect.name());
if(latestDueAt == null || dueAt.isAfter(latestDueAt)) { if(latestDueAt == null || dueAt.isAfter(latestDueAt)) {
latestDue = stage; latestDue.clear();
latestDueAt = dueAt; latestDueAt = dueAt;
} }
if(dueAt.equals(latestDueAt)) latestDue.add(effect);
} }
// Consume all crossed milestones, but announce only the latest while the End is closed. // 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);
} }
} }
@@ -41,6 +41,7 @@ public class EndPrevent extends Appliance {
.findFirst().orElse(null); .findFirst().orElse(null);
if(scheduler == null) return; if(scheduler == null) return;
this.openingEffects.configure(scheduler);
this.openingSchedule = new EndOpeningSchedule(this, scheduler, this.openingEffects); this.openingSchedule = new EndOpeningSchedule(this, scheduler, this.openingEffects);
this.openingSchedule.start(); this.openingSchedule.start();
} }
@@ -73,7 +74,7 @@ public class EndPrevent extends Appliance {
this.localConfig().set(this.endPreventKey, state.name()); this.localConfig().set(this.endPreventKey, state.name());
Configuration.saveChanges(); Configuration.saveChanges();
if(announceOpening && wasClosed && !this.isEndClosed()) { if(announceOpening && wasClosed && !this.isEndClosed()) {
this.openingEffects.play(EndOpeningEffects.Stage.OPENED); this.openingEffects.playOpening();
} }
} }
@@ -1,7 +1,34 @@
# Datum mit UTC-Offset, z. B. "2026-11-15T18:00:00+01:00" # Datum mit UTC-Offset in Anführungszeichen; null bedeutet: kein Termin.
# null bedeutet: kein Termin # Weitere frei benannte Zeitpunkte können hier ergänzt werden.
# Weitere Einträge mit frei wählbaren Namen können ergänzt werden. times:
# EndPrevent: Bis zu diesem Zeitpunkt bleibt das End geschlossen. end-open-at: "2026-11-08T19:16:34+01:00"
end-open-at: "2026-11-08T19:16:34+01:00" outer-end-open-at: null
# Optional: Nach der End-Öffnung nur die Hauptinsel bis zu diesem Zeitpunkt freigeben.
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!"