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 31fcddc..8bea12c 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 @@ -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. 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 new file mode 100644 index 0000000..8383c63 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java @@ -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 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)); + } +} 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 2706589..90fd372 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,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 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 @Nullable BukkitTask task; private Optional previousEndOpenAt = Optional.empty(); private Optional 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 endOpenAt = this.scheduler.getTime("end-open-at"); Optional 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 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 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); } } 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 495322a..71eeb46 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 @@ -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() { diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventListener.java index 1cbb294..5bb6b3c 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventListener.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventListener.java @@ -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 { + private final Map 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 { 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) { diff --git a/common/src/main/resources/dimension-schedule.yml b/common/src/main/resources/dimension-schedule.yml index 6c74b79..8c90575 100644 --- a/common/src/main/resources/dimension-schedule.yml +++ b/common/src/main/resources/dimension-schedule.yml @@ -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