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.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<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();
@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<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));
}
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.messages = loadedMessages;
this.effects = loadedEffects;
this.loadedAt = Instant.now();
}
private YamlConfiguration readDefaults() throws IOException, InvalidConfigurationException {
YamlConfiguration defaults = new YamlConfiguration();
try(InputStream resource = Main.instance().getResource(FILE_NAME)) {
if(resource == null) throw new IOException("Missing bundled " + FILE_NAME);
defaults.load(new InputStreamReader(resource, StandardCharsets.UTF_8));
}
return defaults;
}
private static ConfigurationSection section(ConfigurationSection parent, String key) {
Object value = parent.get(key);
if(value == null) return null;
if(value instanceof ConfigurationSection section) return section;
throw new IllegalArgumentException(parent.getCurrentPath() + "." + key + " muss ein YAML-Abschnitt sein.");
}
private static Map<String, 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) {
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<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. */
public Instant getLoadedAt() {
return this.loadedAt;
@@ -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.
@@ -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<String> 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<Effect> 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<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));
}
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));
}
}
@@ -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<Stage> ANNOUNCEMENT_STAGES = EnumSet.complementOf(EnumSet.of(Stage.OPENED));
private final EndPrevent endPrevent;
private final DimensionScheduler scheduler;
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 Optional<Instant> previousEndOpenAt = Optional.empty();
private Optional<Instant> previousOuterEndOpenAt = Optional.empty();
@@ -50,15 +53,19 @@ final class EndOpeningSchedule {
private void checkOpeningTimes() {
Optional<Instant> endOpenAt = this.scheduler.getTime("end-open-at");
Optional<Instant> outerEndOpenAt = this.scheduler.getTime("outer-end-open-at");
List<Effect> configuredEffects = this.effects.getConfiguredEffects();
Instant now = Instant.now();
boolean endTimeChanged = !endOpenAt.equals(this.previousEndOpenAt);
boolean restoreSchedule = !this.initialized || endTimeChanged
|| !outerEndOpenAt.equals(this.previousOuterEndOpenAt);
Instant restoreAt = this.scheduler.getLoadedAt();
if(!this.initialized && this.startedAt.isAfter(restoreAt)) restoreAt = this.startedAt;
if(!this.initialized || endTimeChanged) 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<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. */
private void resetAnnouncements(Optional<Instant> endOpenAt, Instant now) {
private void resetAnnouncements(Optional<Instant> endOpenAt, List<Effect> 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<Instant> endOpenAt, Instant now) {
private void announceDueEffects(Optional<Instant> endOpenAt, List<Effect> 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<Effect> 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);
}
}
@@ -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();
}
}
@@ -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!"