Refactor End dimension access management and enhance announcement system

This commit is contained in:
besiece committed 2026-11-08 19:23:45 +01:00
1 parent 02d396db68
commit 45685c0c13
6 files changed
+223 -36

No files matched your search

@@ -2,17 +2,13 @@
## Zuständigkeiten und Lebenszyklus
- `DimensionScheduler` wird als Appliance automatisch geladen. `onEnable()` legt
`plugins/spawn/dimension-schedule.yml` nur dann aus der mitgelieferten Vorlage an,
wenn die Datei fehlt, und liest alle benannten Zeitpunkte ein. Danach wird sie nur gelesen.
- `DimensionScheduleCommand` stellt `/dimensionSchedule reload` für Konsole und OPs bereit.
Ein fehlerhafter Reload behält alle bisherigen Zeitpunkte.
- `EndPrevent.onEnable()` erstellt eine `EndOpeningSchedule`, sofern die
`DimensionScheduler`-Appliance vorhanden ist. `EndPrevent.onDisable()` stoppt sie.
- `EndOpeningSchedule` übernimmt die End-Freigabe. Ihr einziger Bukkit-Task startet
im nächsten Tick, nachdem alle Appliances initialisiert wurden, und prüft danach alle
20 Ticks (bei normaler Tickrate einmal pro Sekunde) die echte Uhrzeit. Die Prüfung liest
nur die geladenen Zeitpunkte im Arbeitsspeicher und läuft auf dem Server-Hauptthread.
| 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. |
| `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. |
## Öffnungszeiten
@@ -29,16 +25,55 @@ Ein zukünftiger End-Termin hat Vorrang vor einem bereits vergangenen Outer-End-
Sind beide Einträge leer oder `null`, erfolgt keine automatische Zustandsänderung.
Weitere Namen können in der YAML ergänzt werden; nur die End-Klasse interpretiert diese zwei Namen.
Beim Start wird der zur aktuellen Uhrzeit passende Zustand angewandt, auch bei vergangenen
Terminen. Geänderte Öffnungszeiten werden nach einem erfolgreichen Reload spätestens bei
der nächsten Prüfung berücksichtigt. Entfernte Termine heben den zuletzt gesetzten Zustand
nicht auf; die Steuerung bleibt dann vollständig manuell.
## Manuelle Commands
## Gemeinsamer Zustandswechsel und Commands
`/endPrevent preventEnd`, `/endPrevent allowEnd` und `/endPrevent onlyInnerEnd` bleiben bestehen.
Sie werden nicht bei jeder Prüfung überschrieben: Erst beim nächsten zeitgesteuerten
Zustandswechsel oder bei geänderten End-Terminen greift der Zeitplan erneut ein.
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.
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.
Beim Start und bei geänderten Öffnungszeiten verwendet der Zeitplan `restoreEndState()`.
Diese Methode führt denselben Zustandswechsel mit Speicherung aus, stellt den aktuellen
Zustand aber ohne nachträgliche Öffnungseffekte her. Entfernte Termine heben den zuletzt
gesetzten Zustand nicht auf; die Steuerung bleibt dann vollständig manuell.
Manuelle Commands werden nicht bei jeder Prüfung überschrieben: Erst beim nächsten
zeitgesteuerten Zustandswechsel oder bei geänderten End-Terminen greift der Zeitplan erneut ein.
Ein Reload mit unveränderten End-Terminen setzt einen manuellen Zustand nicht zurück.
Automatische Änderungen gelten nur zur Laufzeit und schreiben keine Datei; manuelle
Commands speichern den Zustand weiterhin wie bisher in `config.yml`.
## 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.
Während einer manuellen Freigabe fällige Teaser werden still als behandelt markiert.
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,
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.
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,
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.
@@ -0,0 +1,56 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
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(). */
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 final Duration leadTime;
private final String message;
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(
"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 ..."
);
void denyActivation(Player player) {
String message = ACTIVATION_DENIED_MESSAGES.get(ThreadLocalRandom.current().nextInt(ACTIVATION_DENIED_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);
Bukkit.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
@@ -1,12 +1,14 @@
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.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.Optional;
/**
@@ -14,16 +16,21 @@ import java.util.Optional;
* 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 @Nullable BukkitTask task;
private Optional<Instant> previousEndOpenAt = Optional.empty();
private Optional<Instant> previousOuterEndOpenAt = Optional.empty();
private @Nullable EndPrevent.State previousScheduledState;
private boolean initialized;
EndOpeningSchedule(EndPrevent endPrevent, DimensionScheduler scheduler) {
EndOpeningSchedule(EndPrevent endPrevent, DimensionScheduler scheduler, EndOpeningEffects effects) {
this.endPrevent = endPrevent;
this.scheduler = scheduler;
this.effects = effects;
}
void start() {
@@ -41,17 +48,20 @@ 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");
boolean timesChanged = !endOpenAt.equals(this.previousEndOpenAt)
Instant now = Instant.now();
boolean endTimeChanged = !endOpenAt.equals(this.previousEndOpenAt);
boolean restoreSchedule = !this.initialized || endTimeChanged
|| !outerEndOpenAt.equals(this.previousOuterEndOpenAt);
if(!this.initialized || endTimeChanged) this.resetAnnouncements(endOpenAt, now);
this.previousEndOpenAt = endOpenAt;
this.previousOuterEndOpenAt = outerEndOpenAt;
this.initialized = true;
if(endOpenAt.isEmpty() && outerEndOpenAt.isEmpty()) {
this.previousScheduledState = null;
return;
}
Instant now = Instant.now();
EndPrevent.State scheduledState;
if(endOpenAt.isPresent() && now.isBefore(endOpenAt.get())) {
scheduledState = EndPrevent.State.CLOSED;
@@ -63,14 +73,52 @@ final class EndOpeningSchedule {
// Remember the schedule's state, not the current manual state: commands remain effective
// until the next scheduled transition or a change to the relevant opening times.
if(timesChanged || scheduledState != this.previousScheduledState) {
if(restoreSchedule || scheduledState != this.previousScheduledState) {
if(this.endPrevent.getEndPreventState() != scheduledState) {
this.endPrevent.applyEndState(scheduledState);
if(restoreSchedule) {
this.endPrevent.restoreEndState(scheduledState);
} else {
this.endPrevent.setEndState(scheduledState);
}
Main.logger().info("End opening schedule applied state " + scheduledState
+ " (end-open-at=" + endOpenAt.orElse(null)
+ ", outer-end-open-at=" + outerEndOpenAt.orElse(null) + ")");
}
this.previousScheduledState = scheduledState;
}
this.announceDueStages(endOpenAt, now);
}
/** A new opening time starts a new cycle; announcements already in the past are consumed. */
private void resetAnnouncements(Optional<Instant> endOpenAt, Instant now) {
this.handledAnnouncements.clear();
if(endOpenAt.isEmpty()) return;
for(Stage stage : ANNOUNCEMENT_STAGES) {
if(endOpenAt.get().minus(stage.leadTime()).isBefore(now)) this.handledAnnouncements.add(stage);
}
}
private void announceDueStages(Optional<Instant> endOpenAt, 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);
return;
}
Stage latestDue = null;
Instant latestDueAt = null;
for(Stage stage : ANNOUNCEMENT_STAGES) {
if(this.handledAnnouncements.contains(stage)) continue;
Instant dueAt = endOpenAt.get().minus(stage.leadTime());
if(now.isBefore(dueAt)) continue;
this.handledAnnouncements.add(stage);
if(latestDueAt == null || dueAt.isAfter(latestDueAt)) {
latestDue = stage;
latestDueAt = dueAt;
}
}
// Consume all crossed milestones, but announce only the latest while the End is closed.
if(latestDue != null && this.endPrevent.isEndClosed()) this.effects.play(latestDue);
}
}
@@ -15,6 +15,7 @@ import java.util.List;
public class EndPrevent extends Appliance {
private final String endPreventKey = "endPrevent";
private final EndOpeningEffects openingEffects = new EndOpeningEffects();
private State endPreventState;
private @Nullable EndOpeningSchedule openingSchedule;
private final World endWorld = Bukkit.getWorlds().stream().filter(world -> world.getEnvironment().equals(World.Environment.THE_END)).findFirst().orElseThrow();
@@ -40,7 +41,7 @@ public class EndPrevent extends Appliance {
.findFirst().orElse(null);
if(scheduler == null) return;
this.openingSchedule = new EndOpeningSchedule(this, scheduler);
this.openingSchedule = new EndOpeningSchedule(this, scheduler, this.openingEffects);
this.openingSchedule.start();
}
@@ -55,15 +56,29 @@ public class EndPrevent extends Appliance {
}
public void setEndState(State state) {
this.localConfig().set(this.endPreventKey, state.name());
Configuration.saveChanges();
this.applyEndState(state);
this.changeEndState(state, true);
}
/** Applies a runtime state; only manual commands persist it through setEndState(). */
void applyEndState(State state) {
/** Restores a schedule on startup or after changed times without replaying past effects. */
void restoreEndState(State state) {
this.changeEndState(state, false);
}
/** Shared by commands, live scheduled transitions and silent schedule restoration. */
private void changeEndState(State state, boolean announceOpening) {
if(this.endPreventState == state) return;
boolean wasClosed = this.isEndClosed();
this.endPreventState = state;
this.updateEndBorder();
this.localConfig().set(this.endPreventKey, state.name());
Configuration.saveChanges();
if(announceOpening && wasClosed && !this.isEndClosed()) {
this.openingEffects.play(EndOpeningEffects.Stage.OPENED);
}
}
EndOpeningEffects effects() {
return this.openingEffects;
}
public boolean isEndClosed() {
@@ -4,28 +4,55 @@ import com.destroystokyo.paper.event.player.PlayerTeleportEndGatewayEvent;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.data.type.EndPortalFrame;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerPortalEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
class EndPreventListener extends ApplianceListener<EndPrevent> {
private final Map<UUID, Integer> lastDeniedActivationTick = new HashMap<>();
@EventHandler
public void onEnderEyeInteraction(PlayerInteractEvent event) {
if(event.getAction() != Action.RIGHT_CLICK_BLOCK) return;
if(event.getClickedBlock() == null) return;
if(!event.getClickedBlock().getType().equals(Material.END_PORTAL_FRAME)) return;
if(!(event.getClickedBlock().getBlockData() instanceof EndPortalFrame frame) || frame.hasEye()) return;
if(event.getItem() == null) return;
if(!event.getItem().getType().equals(Material.ENDER_EYE)) return;
if(!this.getAppliance().isEndClosed()) return;
this.cancelIfClosed(event, event.getPlayer());
// Cancel either hand, but show only one flavour text if both hands fire in the same tick.
event.setCancelled(true);
Player player = event.getPlayer();
int currentTick = Bukkit.getCurrentTick();
Integer previousTick = this.lastDeniedActivationTick.put(player.getUniqueId(), currentTick);
if(previousTick != null && previousTick == currentTick) return;
this.getAppliance().effects().denyActivation(player);
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
this.lastDeniedActivationTick.remove(event.getPlayer().getUniqueId());
}
@EventHandler
public void onPlayerPortal(PlayerPortalEvent event) {
if(!event.getTo().getWorld().getEnvironment().equals(World.Environment.THE_END)) return;
Location destination = event.getTo();
if(destination == null || destination.getWorld() == null) return;
if(!destination.getWorld().getEnvironment().equals(World.Environment.THE_END)) return;
this.cancelIfClosed(event, event.getPlayer());
}
@@ -38,7 +65,13 @@ class EndPreventListener extends ApplianceListener<EndPrevent> {
private void cancelIfClosed(Cancellable event, Player p) {
if(!this.getAppliance().isEndClosed()) return;
event.setCancelled(true);
p.sendActionBar(Component.text("Das End ist nicht freigeschaltet!", NamedTextColor.RED));
int currentTick = Bukkit.getCurrentTick();
Integer previousTick = this.lastDeniedActivationTick.put(p.getUniqueId(), currentTick);
if(previousTick != null && previousTick == currentTick) return;
this.getAppliance().effects().denyActivation(p);
/*event.setCancelled(true);
p.sendActionBar(Component.text("Das End ist nicht freigeschaltet!", NamedTextColor.RED));*/
}
private void cancelIfOnlyInner(Cancellable event, Player p) {
@@ -2,6 +2,6 @@
# 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-08T18:55:34+01:00"
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