diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleCommand.java index 778d969..5225736 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleCommand.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleCommand.java @@ -1,14 +1,13 @@ package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.configuration.InvalidConfigurationException; import org.jetbrains.annotations.NotNull; import java.io.IOException; +import java.time.Instant; import java.util.List; class DimensionScheduleCommand extends ApplianceCommand { @@ -28,7 +27,12 @@ class DimensionScheduleCommand extends ApplianceCommand { } catch(IOException | InvalidConfigurationException | IllegalArgumentException e) { throw new Error("dimension-schedule.yml konnte nicht neu eingelesen werden: " + e.getMessage()); } - sender.sendMessage(Component.text("dimension-schedule.yml wurde neu eingelesen.", NamedTextColor.GREEN)); + Instant now = Instant.now(); + sender.sendMessage(DimensionScheduleText.message("Die Öffnungszeiten, Texte und Effekte wurden neu eingelesen.")); + sender.sendMessage(DimensionScheduleText.message("End-Termin: " + + DimensionScheduleText.appointment(this.getAppliance().getTime("end-open-at"), now))); + sender.sendMessage(DimensionScheduleText.message("Nether-Termin: " + + DimensionScheduleText.appointment(this.getAppliance().getTime("nether-open-at"), now))); } @Override diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleText.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleText.java new file mode 100644 index 0000000..79bc2d5 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/dimensionScheduler/DimensionScheduleText.java @@ -0,0 +1,48 @@ +package eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.Optional; + +/** Shared appearance and German time formatting for dimension messages and commands. */ +public final class DimensionScheduleText { + private static final TextColor END_GREEN = TextColor.color(0x80A71A); + private static final DateTimeFormatter DATE = DateTimeFormatter + .ofPattern("dd.MM.yyyy 'um' HH:mm:ss 'Uhr' z", Locale.GERMAN) + .withZone(ZoneId.of("Europe/Berlin")); + + private DimensionScheduleText() { + } + + public static Component message(String text) { + return Component.text(text, END_GREEN); + } + + /** Round up partial minutes so a pending opening never reads as zero minutes away. */ + public static String remaining(Instant openingAt, Instant now) { + Duration duration = Duration.between(now, openingAt); + if(duration.isNegative() || duration.isZero()) return "0 Tagen, 0 Stunden und 0 Minuten"; + long minutes = duration.toMinutes(); + if(!duration.minusMinutes(minutes).isZero()) minutes++; + long days = minutes / (24 * 60); + long hours = minutes / 60 % 24; + long minutePart = minutes % 60; + return days + (days == 1 ? " Tag, " : " Tagen, ") + + hours + (hours == 1 ? " Stunde und " : " Stunden und ") + + minutePart + (minutePart == 1 ? " Minute" : " Minuten"); + } + + public static String appointment(Optional openingAt, Instant now) { + if(openingAt.isEmpty()) return "nicht festgelegt"; + Instant time = openingAt.get(); + return DATE.format(time) + (time.isAfter(now) + ? " (in " + remaining(time, now) + ")" + : " (Termin bereits erreicht)"); + } +} 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 183c29f..b19d03f 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 @@ -5,10 +5,12 @@ | Klasse | Aufgabe und Erstellung | | --- | --- | | `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 und zeigt nur dem Ausführenden den End- und Nether-Termin. Bei Fehlern bleiben alle bisherigen Werte erhalten. | +| `DimensionScheduleText` | Einheitliches End-Grün sowie deutsche Formatierung von Restzeiten und Terminen. Absolute Zeiten werden in `Europe/Berlin` mit Zeitzone ausgegeben. | | `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` | Verwendet die geladenen Text- und Effektgruppen. Wählt Aktivierungstexte zufällig aus und gibt Effekte über `play(effect)` aus. | +| `EndCommand` | Öffentlicher Informationsbefehl `/end`, von EndPrevent registriert. Zeigt die Restzeit oder einen passenden Hinweis zum tatsächlichen Zustand. | ## YAML und dynamische Effekte @@ -16,6 +18,7 @@ times: end-open-at: "2026-11-15T18:00:00+01:00" outer-end-open-at: "2026-11-16T18:00:00+01:00" + nether-open-at: null messages: end-activation-denied: @@ -56,10 +59,20 @@ Outer-End-Termin ist die Hauptinsel sofort verfügbar und das Outer End öffnet Ein zukünftiger End-Termin hat Vorrang vor einem bereits vergangenen Outer-End-Termin. 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. +`nether-open-at` wird beim Reload als Nether-Termin angezeigt. Die automatische Nether-Steuerung +ist weiterhin nicht angebunden. ## Gemeinsamer Zustandswechsel und Commands +`/end` ist für alle Spieler und die Konsole verfügbar. Bei geschlossenem End mit zukünftigem +Termin lautet die Antwort beispielsweise: „Das End öffnet in 2 Tagen, 3 Stunden und 4 Minuten.“ +Angebrochene Minuten werden aufgerundet. Ist das End schon geöffnet, kein Termin gesetzt oder +ein vergangener Termin bei weiterhin geschlossenem End vorhanden, wird dies entsprechend gemeldet. +Der Informationsbefehl ändert keinen Zustand und schreibt keine Datei. + `/endPrevent preventEnd`, `/endPrevent allowEnd` und `/endPrevent onlyInnerEnd` bleiben bestehen. +`/endPrevent` ohne Argument zeigt den Zustand. Status und Bestätigungen sind auf Deutsch in +End-Grün formuliert; ungültige Argumente zeigen die Verwendung statt einer Erfolgsmeldung. 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` alle Effekte mit @@ -75,6 +88,9 @@ 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. +Die Reload-Bestätigung zeigt End- und Nether-Termin als Datum mit Uhrzeit und Berliner Zeitzone, +bei zukünftigen Terminen zusätzlich die Restzeit. Fehlende und vergangene Termine sind gekennzeichnet. +Diese Zusammenfassung wird ausschließlich an den ausführenden CommandSender gesendet. ## Ankündigungen und Portalversuche @@ -98,9 +114,12 @@ fällig, wird sie regulär ausgeführt. Dasselbe gilt für eine in diesem Zeitra 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 zufälligen konfigurierten Text im Chat, -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. +abgebrochen. Aktivierungs- und Betretversuche gesperrter Endportale verwenden dieselbe Sperrprüfung +und einen gemeinsamen Nachrichten-Cooldown: Nur der auslösende Spieler erhält einen zufälligen +konfigurierten Text im Chat, höchstens einmal alle drei echten Sekunden. Versuche während des +Cooldowns werden weiterhin abgebrochen; sie verlängern den Nachrichten-Cooldown nicht. +Die Prüfung nutzt `System.nanoTime()`, benötigt keinen zusätzlichen Task und entfernt +Spieler-Einträge beim Verlassen. Die Gateway-Meldung für das gesperrte Outer End bleibt getrennt davon. Alle Flavourtexte und zeitgesteuerten Chat-Effekte verwenden einheitlich End-Grün (`#80A71A`). ## Erweiterungen und Leistung diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndCommand.java new file mode 100644 index 0000000..a372ec8 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndCommand.java @@ -0,0 +1,53 @@ +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.DimensionScheduleText; +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.NotNull; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** Public, read-only information command; the existing management command remains separate. */ +class EndCommand extends ApplianceCommand { + public EndCommand() { + super("end"); + } + + @Override + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if(args.length != 0) throw new Error("Verwendung: /end"); + EndPrevent end = this.getAppliance(); + if(!end.isEndClosed()) { + sender.sendMessage(DimensionScheduleText.message(end.isOnlyInner() + ? "Das End ist bereits geöffnet. Das Outer End ist noch gesperrt." + : "Das End ist bereits vollständig geöffnet.")); + return; + } + + Optional openingAt = Main.instance().getAppliances().stream() + .filter(DimensionScheduler.class::isInstance) + .map(DimensionScheduler.class::cast) + .findFirst() + .flatMap(scheduler -> scheduler.getTime("end-open-at")); + Instant now = Instant.now(); + String message; + if(openingAt.isEmpty()) { + message = "Für das End ist noch kein Öffnungstermin festgelegt."; + } else if(!openingAt.get().isAfter(now)) { + message = "Der Öffnungstermin ist bereits erreicht. Das End ist derzeit noch geschlossen."; + } else { + message = "Das End öffnet in " + DimensionScheduleText.remaining(openingAt.get(), now) + "."; + } + sender.sendMessage(DimensionScheduleText.message(message)); + } + + @Override + public @NotNull List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + return List.of(); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java index 185f6ee..6c8b740 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndOpeningEffects.java @@ -2,8 +2,8 @@ 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 eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.TextColor; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.jetbrains.annotations.Nullable; @@ -13,7 +13,6 @@ import java.util.concurrent.ThreadLocalRandom; /** Presents YAML-defined End effects. Add sounds or other effects in play(). */ final class EndOpeningEffects { - private static final TextColor END_GREEN = TextColor.color(0x80A71A); private @Nullable DimensionScheduler scheduler; void configure(DimensionScheduler scheduler) { @@ -29,7 +28,7 @@ final class EndOpeningEffects { List messages = this.scheduler.getMessages("end-activation-denied"); if(messages.isEmpty()) return; String message = messages.get(ThreadLocalRandom.current().nextInt(messages.size())); - player.sendMessage(Component.text(message, END_GREEN)); + player.sendMessage(DimensionScheduleText.message(message)); } void playOpening() { @@ -39,7 +38,7 @@ final class EndOpeningEffects { } void play(Effect effect) { - Component message = Component.text(effect.message(), END_GREEN); + Component message = DimensionScheduleText.message(effect.message()); Bukkit.getOnlinePlayers().forEach(player -> player.sendMessage(message)); } } 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 2aa522b..317202d 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 @@ -101,6 +101,6 @@ public class EndPrevent extends Appliance { @Override protected @NotNull List> commands() { - return List.of(new EndPreventCommand()); + return List.of(new EndPreventCommand(), new EndCommand()); } } diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventCommand.java index 0787e81..54f778b 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventCommand.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/endPrevent/EndPreventCommand.java @@ -1,12 +1,10 @@ package eu.mhsl.craftattack.spawn.common.appliances.tooling.endPrevent; +import eu.mhsl.craftattack.spawn.common.appliances.tooling.dimensionScheduler.DimensionScheduleText; import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.Map; @@ -23,19 +21,24 @@ class EndPreventCommand extends ApplianceCommand { } @Override - protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception { - if(args.length == 1 && this.arguments.containsKey(args[0])) { - this.getAppliance().setEndState(this.arguments.get(args[0])); - sender.sendMessage(Component.text("Setting updated!", NamedTextColor.GREEN)); + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if(args.length > 1 || (args.length == 1 && !this.arguments.containsKey(args[0]))) { + throw new Error("Verwendung: /endPrevent [preventEnd|allowEnd|onlyInnerEnd]"); } - sender.sendMessage(Component.text( - String.format("The End is now on '%s'!", this.getAppliance().getEndPreventState().name()), - NamedTextColor.GOLD - )); + if(args.length == 1) { + this.getAppliance().setEndState(this.arguments.get(args[0])); + } + String message = switch(this.getAppliance().getEndPreventState()) { + case CLOSED -> "Das End ist geschlossen."; + case OPEN -> "Das End ist vollständig geöffnet."; + case NO_OUTER -> "Die Hauptinsel des Ends ist geöffnet. Das Outer End ist gesperrt."; + }; + sender.sendMessage(DimensionScheduleText.message(message)); } @Override - public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { - return this.arguments.keySet().stream().toList(); + public @NotNull List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if(args.length != 1) return List.of(); + return this.tabCompleteReducer(this.arguments.keySet().stream().toList(), args); } } 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 18a48f6..54aa4b6 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,7 +4,6 @@ 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; @@ -20,9 +19,11 @@ import org.bukkit.event.player.PlayerQuitEvent; import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; class EndPreventListener extends ApplianceListener { - private final Map lastDeniedTick = new HashMap<>(); + private static final long MESSAGE_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3); + private final Map lastDeniedMessageAt = new HashMap<>(); @EventHandler public void onEnderEyeInteraction(PlayerInteractEvent event) { @@ -38,7 +39,7 @@ class EndPreventListener extends ApplianceListener { @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { - this.lastDeniedTick.remove(event.getPlayer().getUniqueId()); + this.lastDeniedMessageAt.remove(event.getPlayer().getUniqueId()); } @EventHandler @@ -59,10 +60,11 @@ class EndPreventListener extends ApplianceListener { if(!this.getAppliance().isEndClosed()) return; event.setCancelled(true); - // Cancel every attempt, but show only one flavour text per player and server tick. - int currentTick = Bukkit.getCurrentTick(); - Integer previousTick = this.lastDeniedTick.put(player.getUniqueId(), currentTick); - if(previousTick != null && previousTick == currentTick) return; + // Always block access. Only messages are throttled, using elapsed real time independent of TPS. + long now = System.nanoTime(); + Long previousMessageAt = this.lastDeniedMessageAt.get(player.getUniqueId()); + if(previousMessageAt != null && now - previousMessageAt < MESSAGE_COOLDOWN_NANOS) return; + this.lastDeniedMessageAt.put(player.getUniqueId(), now); this.getAppliance().effects().denyActivation(player); } diff --git a/common/src/main/resources/dimension-schedule.yml b/common/src/main/resources/dimension-schedule.yml index e7e0a7d..3ea15bc 100644 --- a/common/src/main/resources/dimension-schedule.yml +++ b/common/src/main/resources/dimension-schedule.yml @@ -3,6 +3,7 @@ times: end-open-at: "2026-11-08T19:16:34+01:00" outer-end-open-at: null + nether-open-at: null messages: end-activation-denied: