From 5b5cd42291349a0b0def0ef74fe83f2c7a3cdede Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:23:18 +0200 Subject: [PATCH 01/17] docs(openspec): propose seasonal-lobby-world Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../seasonal-lobby-world/.openspec.yaml | 2 + .../changes/seasonal-lobby-world/design.md | 132 ++++++++++++++++++ .../changes/seasonal-lobby-world/proposal.md | 41 ++++++ .../specs/app-variants/spec.md | 29 ++++ .../specs/lobby-seasons/spec.md | 124 ++++++++++++++++ .../changes/seasonal-lobby-world/tasks.md | 50 +++++++ 6 files changed, 378 insertions(+) create mode 100644 openspec/changes/seasonal-lobby-world/.openspec.yaml create mode 100644 openspec/changes/seasonal-lobby-world/design.md create mode 100644 openspec/changes/seasonal-lobby-world/proposal.md create mode 100644 openspec/changes/seasonal-lobby-world/specs/app-variants/spec.md create mode 100644 openspec/changes/seasonal-lobby-world/specs/lobby-seasons/spec.md create mode 100644 openspec/changes/seasonal-lobby-world/tasks.md diff --git a/openspec/changes/seasonal-lobby-world/.openspec.yaml b/openspec/changes/seasonal-lobby-world/.openspec.yaml new file mode 100644 index 00000000..3b4044e8 --- /dev/null +++ b/openspec/changes/seasonal-lobby-world/.openspec.yaml @@ -0,0 +1,2 @@ +schema: olf-spec-driven +created: 2026-09-29 diff --git a/openspec/changes/seasonal-lobby-world/design.md b/openspec/changes/seasonal-lobby-world/design.md new file mode 100644 index 00000000..9a3642bd --- /dev/null +++ b/openspec/changes/seasonal-lobby-world/design.md @@ -0,0 +1,132 @@ +# Design + +## Context + +Motivation: siehe proposal.md. Anforderungen: `specs/lobby-seasons`, `specs/app-variants`. + +Ist-Zustand: + +- `PlatformBeans` (`runtime`, `@Factory`) erzeugt `InstanceContainer`, `MapProvider` (`MapProvider.create(Path.of(""), instance)`), `LobbySpawn`, `Clock`, `Scheduler` und mehr. `MapProvider` lädt die Map-Daten im Konstruktor (`loadMapData`), also bevor irgendeine Column startet. +- `MapPool` (`common`) listet die Verzeichnisse unter `worlds/`, die eine `map.json` haben, und wählt daraus `-DTITAN_LOBBY_MAP` (Standard `world`); gibt es genau eine Welt, nimmt es diese unabhängig vom Namen. Fehlt der Name, wirft `orElseThrow()` ohne Hinweistext. +- Eine Column hängt nur an `core` (`titan.column`), deklariert ihre Plattform-Typen per `@InjectModule(requires = ...)` und darf keine andere Column importieren. `runtime` deklariert nur `provides` und hat kein `requires` (`docs/lobby-modules.md`). +- `titan.app-variant` bindet **jede** Column unter `features/` ein, außer die Variante ruft `titanVariant { exclude(...) }` auf. `expectedModules` und die zusammengeführte `application.yaml` folgen dieser Auswahl; eine ausgeschlossene Column verliert auch ihre Standardwerte. +- `DaytimeModule` ist die kleinste Column und Vorlage: `@Singleton`, `@PostConstruct`/`@PreDestroy`, Scheduler-Task, `Clock` injiziert, Werte live über `Config`, ungültige Startwerte brechen ab. +- `StopCommand` (`features/admin`) ruft `MinecraftServer.stopCleanly()` auf einem eigenen Thread `titan-stop` auf und danach `System.exit(0)`; `stopCleanly()` darf nicht auf dem Tick-Thread laufen. Der Scheduler läuft auf dem `TickSchedulerThread`. +- Die Konfigurationsüberwachung (`config.watch.enabled`, Standard aus) aktualisiert die `Config`-Fassade; Module lesen live. + +Querbezug: Der offene Change `optional-extensions-bootstrap` (ungetrackt) ändert `apps/cloudnet/build.gradle.kts` und `apps/local/build.gradle.kts` (`platform("extensions")`/`platform("cloudnet")`, nur `cloudnet`) und führt in `VariantStartupCheck` den aktiven Bootstrap als erwartetes Modul ein. Dieser Change ändert dort nur `apps/local` (`exclude("season")`); die Zeilen widersprechen sich nicht, der Merge ist mechanisch. Er ändert `app-variants` nicht als Spec (`Modified Capabilities: Keine`), also kollidieren die Deltas nicht. Beide Changes lockern textlich „`local` hat dieselben Funktionen wie `cloudnet`“; das Delta hier formuliert nur die Saison-Ausnahme, und wer zweiter archiviert, gleicht den Wortlaut an. + +## Goals / Non-Goals + +**Goals:** +- Eine Saison ist eine Welt; die Lobby wechselt nur durch Neustart, nie zur Laufzeit. +- Ohne die Column oder ohne aktive Saison ändert sich nichts. +- Entscheidungslogik (Auswahl, Vormerkung, Stopp) ohne Server testbar. + +**Non-Goals:** +- Effekte auf der laufenden Welt (Blöcke, Displays, Sounds), Navigator-Icons, Chat-Präfix, Release-Stufen, `/season list`, Spielerhinweis, Saisonen in `local` (siehe proposal.md). +- Beheben der Doppelung mit `StopCommand` (siehe D4). + +## Decisions + +### D1: Column `features/season` nach dem Muster von `daytime` + +Standard-`titan.column`-Modul, `implementation(libs.avaje.config)` und `libs.slf4j.api` wie `daytime`. Pakete: `net.onelitefeather.titan.feature.season`. Klassen: + +- `SeasonSettings` (Schlüsselnamen), `Season` (Record: `id`, `world`, `from`, `to`, `enabled`), +- `SeasonCalendar` (rein: aus `List`, `Instant` und `ZoneId` die gewünschte Saison; Überlappungsregel), +- `SeasonConfigReader` (liest `seasons.*` aus `Config`, validiert und nennt qualifizierte Schlüssel), +- `RestartPolicy` (rein: gestartete Welt, gewünschte Welt, Spielerzahl → `Decision` `NONE`/`PENDING`/`STOP`), +- `SeasonModule` (`@Singleton`, Verdrahtung: Minutentask, Disconnect-Listener, Stopp). + +Standardwerte in `src/main/resources/titan/defaults/season.yaml`: + +```yaml +seasons: + # Zeitzone der Fenster (java.time-Id). Ungültig = Startabbruch. + zone: Europe/Berlin + # : + # world: winter # Verzeichnis unter worlds/, mit map.json + # from: 2026-12-01T00:00:00 # einschließlich, lokale Zeit + # to: 2027-01-07T00:00:00 # ausschließlich + # enabled: true # Abschalter, wirkt ohne Neustart der Konfiguration +``` + +Built-in: `java.time.LocalDateTime`/`ZoneId`, avaje-config. Verworfen: eigene Konfigurationsklassen mit Datei-Loader (nur `Config`-Fassade wie alle Columns); Jackson/JSON wie in PR #225 (kein Grund, wenn `application.yaml` reicht). +Kein Zugriff auf `daytime.zone`: Eine Column importiert keine andere, und das Lesen ihres Schlüssels koppelt still an eine fremde Einstellung, die in einer Variante ohne `daytime` fehlt. Deshalb eigene `seasons.zone`, Standard `Europe/Berlin` (Annahme, siehe unten). +Die Id `zone` ist reserviert (kollidiert mit `seasons.zone`) und bricht den Start ab. +SOLID: SRP (Kalender, Leser, Politik getrennt), DIP. Test: Unit für `SeasonCalendar`, `SeasonConfigReader`, `RestartPolicy`; Integration für `SeasonModule` (D5). + +### D2: Weltwahl beim Start über `LobbyWorldChoice` (Optional-Bean) — Spike nötig + +`core` bekommt + +```java +public interface LobbyWorldChoice { + Optional worldName(); // leer = Standardwelt +} +``` + +`PlatformBeans.mapProvider(InstanceContainer, Optional)` reicht `choice.flatMap(LobbyWorldChoice::worldName)` an `MapProvider.create(path, instance, worldName)` und `new MapPool(path, filter, worldName)`. Ein übergebener Name gilt immer (auch bei genau einer Welt) und wirft bei Fehlen mit klarem Text; `Optional.empty()` verhält sich wie heute. Das Ergebnis wird einmal beim Start berechnet und gespeichert: `SeasonModule` liest denselben Wert als „gestartete Welt“, sodass beide nie auseinanderlaufen. Die Auswahl nutzt `Clock` (Bean) und `Config`. + +**Risiko / Spike (vor Task 2.x, Ergebnis in D2 nachtragen):** `runtime` deklariert nur `provides` und wird im Modul-Graph vor jeder Column gebaut, die Plattform-Beans aus `runtime` per `requires` verlangt. `SeasonModule` verlangt `Scheduler`/`Clock`/`EventNode` aus `runtime`; `mapProvider(...)` in `runtime` verlangt `Optional` aus der Column. Ein `requires` von `runtime` auf `LobbyWorldChoice` wäre ein Zyklus auf Modulebene und würde `local` (ohne Column) brechen. Ohne `requires` sortiert Avaje `runtime` zuerst; dann ist der Optional zum Zeitpunkt von `mapProvider(...)` leer, ohne Fehler, und die Saison-Welt würde nie geladen. Zu prüfen: +1. Ob die generierte `PlatformBeans`-Fabrik `builder.getOptional(...)` nutzt und ob der Aufruf die Beans anderer Module sieht, die später gebaut werden (erwartet: nein). +2. Ob `@InjectModule`-Reihenfolge (`requires`/`provides`, `Provider`) die Reihenfolge so ändern kann, dass die Column-Bean vor `mapProvider` existiert, ohne dass `runtime` dafür eine harte Abhängigkeit deklariert. Da die Column `Scheduler`/`Clock` aus `runtime` braucht, ist das zirkulär. + +Vorbenannter Ausweichweg, falls 1. oder 2. scheitert (Abweichung von der Entscheidung „Optional-Bean“, deshalb hier ausdrücklich): `LobbyWorldChoice` wird per `java.util.ServiceLoader` aufgelöst, nicht über den Bean-Scope. `features/season` meldet `SeasonWorldChoice` (öffentlicher Konstruktor ohne Argumente, nutzt `Clock.systemUTC()` und `Config`, teilt `SeasonCalendar`/`SeasonConfigReader` mit dem Modul) über `META-INF/services` an; `PlatformBeans` ruft `ServiceLoader.load(LobbyWorldChoice.class).findFirst()`. Das ist dasselbe Muster wie `ServerBootstraps.select` in `optional-extensions-bootstrap` D2, und `shadowJar` führt Service-Dateien bereits zusammen (`mergeServiceFiles()`). Es gibt keinen statischen Zustand zwischen beiden: `SeasonModule` berechnet die Wahl beim Start selbst neu; liegen beide Berechnungen um eine Fenstergrenze, ist die nächste Minuten-Prüfung ein Neustart, der sich selbst heilt. +Test: Ein Integrationstest in `apps/cloudnet` baut den echten `BeanScope` (mit `Instance`-Bean aus Cyano-Env) und assertet, dass `MapProvider` bei aktiver Saison die Saison-Welt in einem `@TempDir`-`worlds/` lädt und ohne Saison die Standardwelt. Das ist der einzige Test, der den Spike absichert; ein Unit-Test mit Fakes würde das Verdrahtungsproblem nicht finden. +SOLID: DIP (`runtime` kennt nur die Schnittstelle), OCP (weitere Wahlquellen docken an). + +### D3: Zeitzone, Überlappung, Gültigkeit (Annahmen, vom Nutzer noch nicht bestätigt) + +- **Überlappung:** Die Saison mit dem früheren `from` gewinnt, bei Gleichstand die kleinere Id; Warnung beim Start mit beiden Ids. Verworfen: Startabbruch (eine Überlappung ist ein Konfigurationsfehler, aber kein Grund, die Lobby zu stoppen) und „letzte gewinnt“ (abhängig von der Reihenfolge der Schlüssel). +- **Zeitzone:** `seasons.zone`, Standard `Europe/Berlin` (siehe D1). +- **Ungültige Konfiguration:** Jede *aktivierte* Saison wird beim Start geprüft: `world`, `from`, `to` vorhanden und lesbar, `from < to`, `worlds//map.json` existiert. Fehler brechen den Start mit qualifiziertem Schlüssel und Grund ab (`lobby-module-config`), etwa über `IllegalStateException`, wie `DaytimeModule.start()` bei ungültiger Zone. Abgeschaltete Saisons werden nicht geprüft, damit ein noch unfertiger Weltbau geparkt werden kann. Zur Laufzeit gilt wie bei `lobby-module-config` keine erneute Validierung mit Abbruch: Ein live ungültig gewordener Wert, oder eine live aktivierte Saison ohne Welt, wird als WARN (einmal je Wert) geloggt und zählt als nicht aktiv. Sonst würde ein Vertipper im laufenden Betrieb einen Neustart auslösen, der beim nächsten Start abbricht, und CloudNet würde den Dienst in einer Schleife neu starten. +- **Fensterhälften:** `from` einschließlich, `to` ausschließlich, lokale Zeit in `seasons.zone`; bei Zeitumstellungen gilt die Wanduhr. + +Die `map.json`-Prüfung liest den Dateinamen als eigene Konstante in der Column, weil `MapEntry.MAP_FILE_NAME` in `common` liegt und eine Column nur an `core` hängt. Ein Test in `apps/cloudnet` (sieht beide) sichert die Gleichheit beider Werte. +Test: Unit (`SeasonCalendarTest`, `SeasonConfigReaderTest` mit `@TempDir`-`worlds/`, Zeit als `Instant`). + +### D4: Entscheidungen und Stopp, Minutentakt plus Disconnect + +`RestartPolicy.decide(startedWorld, desiredWorld, onlinePlayers)`: +- gleiche Welt → `NONE` (Vormerkung aufgehoben), +- Abweichung und `onlinePlayers > 0` → `PENDING`, +- Abweichung und `onlinePlayers == 0` → `STOP`. + +`SeasonModule` merkt sich nur `restartPendingSince` (`Instant`, `volatile`). Beim Übergang `NONE → PENDING/STOP` loggt es einmal INFO `Restart for season {} pending since {}` (bzw. für die Standardwelt `Restart to the default world pending since {}`), beim Übergang zurück INFO `Restart no longer needed`. Jede Prüfung liest die gewünschte Welt neu (D3, live). +Auslöser: (a) wiederkehrender Scheduler-Task, `TaskSchedule.minutes(1)` (Minestom-Scheduler statt eigenem Timer, damit er mit dem Server endet, wie bei `daytime`); (b) `PlayerDisconnectEvent` am `titan`-Node. Beim Disconnect zählt der Spieler noch als online; deshalb plant der Listener die Prüfung mit `scheduler.scheduleNextTick(...)`, statt sofort zu zählen. Ob Minestom den Spieler bis dahin aus dem `ConnectionManager` entfernt hat, prüft der erste Integrationstest ab (rot, wenn nicht; Ausweg: den ausscheidenden Spieler aus der Zählung ausnehmen). +Stopp: Eine kleine Nahtstelle `ServerStop` (spaltenintern, Standardimplementierung als `@Singleton`) startet wie `StopCommand` einen `titan-stop`-Thread mit `MinecraftServer.stopCleanly()` und `System.exit(0)` — auf dem Tick-Thread würde `stopCleanly()` auf sich selbst warten. Der Aufruf geschieht höchstens einmal (`AtomicBoolean`), damit Minutentakt und Disconnect nicht doppelt stoppen. Spielerzahl kommt aus einer ebenso spaltenintern deklarierten Schnittstelle `OnlinePlayers` (Standard: `ConnectionManager#getOnlinePlayerCount()`); Tests injizieren Fakes über den Konstruktor, statt den Server zu stoppen. +`StopCommand` bleibt unverändert (Non-Goal): Die Dopplung sind zehn Zeilen in zwei Columns, die sich nicht importieren dürfen. Eine gemeinsame Nahtstelle in `core` wäre ein eigener Change (`refactor`). +Akzeptierte Kompromisse (siehe auch Risiken): kein oberes Zeitlimit für das Warten; kleines Rennen zwischen Leer-Prüfung und Stopp, der Spieler landet dann wie bei jedem Stopp über den Proxy woanders. +Built-in: `Scheduler`, `ConnectionManager`, `MinecraftServer.stopCleanly()`. Verworfen: Kick mit Countdown und Weltwechsel zur Laufzeit (Instanz tauschen, Spieler umsetzen, Lichtberechnung, Entitäten, Zustand der übrigen Columns; alles, was der Neustart umgeht). +SOLID: SRP (`RestartPolicy` reine Funktion), DIP (`ServerStop`, `OnlinePlayers`). Test: Unit `RestartPolicyTest`; Integration `SeasonModuleTest` (Cyano-`Env`, `AdjustableClock` wie in `daytime`, `env.tick()`; gefälschtes `ServerStop` zählt Aufrufe). + +### D5: Einbindung nur in `cloudnet` + +`apps/local/build.gradle.kts` bekommt `titanVariant { exclude("season") }`; `apps/cloudnet` ändert sich nicht, weil `titan.app-variant` jede Column automatisch aufnimmt. Damit erwartet `cloudnet` `seasonColumn` im Startcheck, `local` nicht, und `local` liefert `season.yaml` nicht aus. Ein Starttest je Variante prüft das (`cloudnet`: `seasonColumn` geladen, `local`: nicht auf dem Klassenpfad). `@InjectModule(name = "seasonColumn", requires = {Scheduler.class, Clock.class, EventNode.class}, requiresString = {"net.minestom.server.event.EventNode:titan"})` nach dem Muster aus `docs/lobby-modules.md`; `ColumnArchitectureTest` wie bei `daytime`. +Verworfen: eine Gradle-Property `titan.season` zum Einschalten in `local` (`local` hat keinen Supervisor; ein Stopp würde die Entwicklungslobby einfach beenden). + +### D6: Logging, Metriken, Spans, Nutzertexte + +- Logs (SLF4J, Englisch): INFO beim Start `Lobby world {} (season {})` bzw. Standardwelt; WARN bei Überlappung `Seasons {} and {} overlap, {} wins`; INFO bei Vormerkung und Aufhebung (einmal je Übergang); INFO vor dem Stopp `Stopping lobby for season change`; WARN bei live ungültigem Wert (einmal je Wert). Nichts davon läuft pro Tick oder pro Spieler. +- Keine neuen Metriken oder Spans: Ein Saisonwechsel kommt einmal pro Zeitfenster vor; das Log genügt. Nutzertexte: keine, also kein i18n. + +## Risks / Trade-offs + +- [Optional-Bean wird zwischen `runtime` und Column nicht aufgelöst (D2)] → Spike vor der Umsetzung, Integrationstest mit echtem `BeanScope`, vorbenannter `ServiceLoader`-Weg. +- [Eine dauerhaft belegte Lobby verzögert den Saisonwechsel beliebig lang] → akzeptiert; `/stop` bleibt als Betreibermittel. +- [Rennen zwischen Leer-Prüfung und Stopp] → akzeptiert; der Spieler wird wie bei jedem Stopp über den Proxy umgeleitet. +- [Neustart-Schleife bei kaputter Welt nach Konfigurationsänderung] → live nur validierte Saisons lösen einen Neustart aus (D3); Startvalidierung bricht ab, statt eine kaputte Welt zu laden. +- [Fenstergrenze zwischen Weltwahl und `SeasonModule`-Start bei der `ServiceLoader`-Variante] → höchstens ein zusätzlicher Neustart bei leerer Lobby. +- [Zwei offene Changes ändern `apps/local/build.gradle.kts`] → andere Zeilen; der Hauptkontext prüft beim Mergen (siehe Kontext). + +## Migration Plan + +1. Saisonwelten (`worlds//` mit `map.json`) über den Setup-Server bauen und im Dienst-Template ablegen, dann `seasons..*` in `application.yaml` (oder Profil) eintragen. Ohne `seasons.*` bleibt alles wie heute. +2. `titan-cloudnet.jar` deployen (AOT-Cache neu trainieren). Die Lobby wechselt im Fenster beim ersten leeren Moment. +3. Zurück: `seasons..enabled: false` (wirkt mit der Konfigurationsüberwachung ohne Neustart der Konfiguration; die Lobby stoppt bei leerem Stand und startet in der Standardwelt) oder Revert des Squash-Commits. + +## Open Questions + +- Sollen `seasons.zone`, Überlappungsregel und Prüfumfang (D3) so bleiben? Sie sind als Annahmen gesetzt, ändern aber weder die Aufgabenliste noch die Struktur; eine Korrektur trifft `SeasonCalendar` und `SeasonConfigReader`. diff --git a/openspec/changes/seasonal-lobby-world/proposal.md b/openspec/changes/seasonal-lobby-world/proposal.md new file mode 100644 index 00000000..4142eaeb --- /dev/null +++ b/openspec/changes/seasonal-lobby-world/proposal.md @@ -0,0 +1,41 @@ +# Proposal + +## Why + +Die Lobby soll zu Events und Jahreszeiten anders aussehen (Winter, Halloween, ...). PR #225 ("feat: run seasons from configuration") wollte das mit JSON-definierten Effekten (Blöcke, Displays, Sounds, Navigator-Icons, Chat-Präfix) über einen Undo-Stapel auf der laufenden Welt lösen. Der PR hängt gestapelt an #219 und #216, die nur in den Branch `docs/lobby-season-spec` gemergt wurden, nie in `main`, und braucht FeatureGate/Togglz und Release-Stufen, die es auf `main` nicht gibt. Dieser Change ersetzt ihn durch das Kleinste, das trägt: Eine Saison ist eine eigene Welt, die das Build-Team baut, und die Lobby startet in Saisonzeit in dieser Welt neu. + +## What Changes + +- Neue Column `features/season` (nur `apps/cloudnet`): liest `seasons..*`, bestimmt die Welt der aktiven Saison und veranlasst einen Neustart, sobald sie von der gestarteten Welt abweicht. +- Eine Saison ist ein eigenes Weltverzeichnis unter `worlds/` mit eigener Map-Datei (Spawn), gebaut über den Setup-Server. Keine Effekte auf einzelnen Blöcken. +- Konfiguration je Saison: `seasons..world`, `.from`, `.to` (lokales Datum mit Uhrzeit), `.enabled` (Abschalter, live über die Konfigurationsüberwachung). +- Weltwahl beim Start: neue Schnittstelle `LobbyWorldChoice` in `core`; `PlatformBeans` reicht die gewählte Welt an `MapProvider`/`MapPool` weiter. Ohne Saison-Column oder ohne aktive Saison gilt wie heute `-DTITAN_LOBBY_MAP` bzw. `world`. +- Kein Umschalten zur Laufzeit: Jede Minute vergleicht die Column die gewünschte Welt mit der gestarteten. Weichen sie ab, ist ein Neustart vorgemerkt; nur bei leerer Lobby wird `MinecraftServer.stopCleanly()` aufgerufen (geprüft im Minutentakt und beim Verlassen eines Spielers). CloudNet startet den Dienst neu, die Weltwahl greift. Saisonende und Abschalter nutzen denselben Weg. +- **Akzeptierter Kompromiss:** Es gibt keine Obergrenze für das Warten. Eine dauerhaft belegte Lobby verzögert den Wechsel; Betreiber können das vorhandene `/stop` nutzen. +- Die Variante `local` schließt die Column aus (`titanVariant { exclude("season") }`), weil kein Supervisor sie neu startet. Sie verhält sich wie heute. + +## Capabilities + +### New Capabilities + +- `lobby-seasons`: Die Lobby startet in der Welt der aktiven Saison, merkt einen Neustart vor, sobald sich die gewünschte von der gestarteten Welt unterscheidet, und stoppt nur bei leerer Lobby. + +### Modified Capabilities + +- `app-variants`: Die Anforderungen „Je Betriebsumgebung eine startbare Variante“ und „Varianten verhalten sich wie die bisherige Lobby“ verlangen heute dieselben Columns und Funktionen in `cloudnet` und `local`; `season` gibt es nur in `cloudnet`. + +## Impact + +- **Code**: neues Modul `features/season` (von `settings.gradle.kts` automatisch erfasst); neue Schnittstelle in `core`; `PlatformBeans.mapProvider(...)` und `MapProvider`/`MapPool` in `common`/`runtime` nehmen einen optionalen Weltnamen entgegen; `apps/local/build.gradle.kts` schließt die Column aus. +- **Abhängigkeiten**: keine neuen (`avaje-config`, Minestom-`Scheduler`, `java.time.Clock` sind da). +- **Tests**: Unit-Tests für Auswahl und Entscheidung (ohne Server), Integrationstest mit Cyano-Env für Minutentakt und Disconnect, `ColumnArchitectureTest`, Starttest je Variante. +- **Nutzertexte**: keine; nur Log-Zeilen für Betreiber (Englisch). +- **Betrieb**: neue optionale Schlüssel `seasons.*`. Saisonwelten müssen vorab als `worlds//` mit Map-Datei auf dem Dienst liegen. Ein Saisonwechsel kostet einen Neustart der Lobby. +- **Ersetzt**: PR #225 wird nach dem Merge als überholt geschlossen. +- **Berührt**: der offene Change `optional-extensions-bootstrap` ändert ebenfalls `apps/*/build.gradle.kts` (andere Zeilen), siehe design.md. + +## Delivery + +PR-Titel: `feat(season): restart the lobby into a seasonal world during its window` + +Nicht-Ziele, jeweils eigener Change: Navigator-Icons, Chat-Präfix, Block-/Display-/Sound-Effekte, Release-Stufen/Vorschau-Recht, `/season list`, Spielerhinweis, Saisonen in der Variante `local`. diff --git a/openspec/changes/seasonal-lobby-world/specs/app-variants/spec.md b/openspec/changes/seasonal-lobby-world/specs/app-variants/spec.md new file mode 100644 index 00000000..48b82b49 --- /dev/null +++ b/openspec/changes/seasonal-lobby-world/specs/app-variants/spec.md @@ -0,0 +1,29 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Je Betriebsumgebung eine startbare Variante +Titan MUSS für jede Betriebsumgebung als eigene, allein startbare Variante gebaut werden: `cloudnet` für den Produktionsbetrieb und `local` für die Entwicklung. Jede Variante MUSS sich ohne weitere Dateien außer Welten und optionaler Konfiguration mit `java -jar` starten lassen. Die Variante `cloudnet` MUSS zusätzlich mit einem AOT-Cache ausgeliefert werden, der zu genau diesem Build passt. + +#### Scenario: Produktionsvariante startet +- **WHEN** ein Betreiber das Jar der Variante `cloudnet` mit dem zugehörigen AOT-Cache startet +- **THEN** startet die Lobby, und der AOT-Cache wird angenommen + +#### Scenario: Entwicklungsvariante startet +- **WHEN** ein Entwickler das Jar der Variante `local` ohne CloudNet startet +- **THEN** startet die Lobby mit denselben Funktionen wie die Variante `cloudnet`, außer denen einer Column, die nur `cloudnet` enthält + +### Requirement: Varianten verhalten sich wie die bisherige Lobby +Solange sich die Varianten nur im Namen unterscheiden, MÜSSEN beide Varianten dieselben Columns enthalten und sich für Spieler und Betreiber genauso verhalten wie die bisherige einzelne Lobby: gleiche Features, gleiche Konfigurationsschlüssel, gleiche Befehle, gleiche Texte. Eine Column, die einen Dienst-Supervisor zum Neustart braucht, wie `season`, ist nur in `cloudnet` enthalten; die Variante `local` DARF sie auslassen, und ihre Konfigurationsschlüssel gelten dort nicht. + +#### Scenario: Gleiches Verhalten nach dem Umbau +- **WHEN** ein Spieler die Lobby der Variante `cloudnet` betritt, den Navigator nutzt, sitzt, kitzelt und mit der Elytra fliegt +- **THEN** verhält sich jede Funktion wie vor dem Umbau + +#### Scenario: Bestehende Konfiguration gilt weiter +- **WHEN** ein Betreiber seine bisherige `application.yaml` unverändert neben das Jar der Variante legt +- **THEN** übernimmt die Lobby alle Werte daraus wie vorher + +#### Scenario: Saison-Column nur in der Produktionsvariante +- **WHEN** die Variante `local` mit einer `application.yaml` startet, die `seasons.*` enthält +- **THEN** lädt sie keine Saison-Column, wählt die Standardwelt und stoppt nie wegen einer Saison, während `cloudnet` die Column enthält und ihr Start sie als erwartete Column prüft diff --git a/openspec/changes/seasonal-lobby-world/specs/lobby-seasons/spec.md b/openspec/changes/seasonal-lobby-world/specs/lobby-seasons/spec.md new file mode 100644 index 00000000..8698bcff --- /dev/null +++ b/openspec/changes/seasonal-lobby-world/specs/lobby-seasons/spec.md @@ -0,0 +1,124 @@ +# Spec Delta + +## Purpose + +Legt fest, wie die Lobby in einem konfigurierten Zeitfenster in einer eigenen Saisonwelt läuft: Die Welt wird beim Start gewählt, ein nötiger Wechsel wird nur über einen Neustart bei leerer Lobby vollzogen, und ungültige Konfiguration verhindert den Start. + +## ADDED Requirements + +### Requirement: Die Welt wird beim Start nach dem aktiven Saisonfenster gewählt +Beim Start MUSS die Lobby die Welt der aktiven Saison laden. Eine Saison ist aktiv, wenn `seasons..enabled` wahr ist und die aktuelle Zeit der konfigurierten Zeitzone im Fenster von `seasons..from` (einschließlich) bis `seasons..to` (ausschließlich) liegt. Die Welt ist das Verzeichnis unter `worlds/`, das `seasons..world` nennt, mit seiner eigenen Map-Datei (Spawn). `seasons..enabled` MUSS standardmäßig wahr sein. Die Zeitzone MUSS über `seasons.zone` einstellbar sein und standardmäßig `Europe/Berlin` lauten. + +#### Scenario: Fenster ist aktiv +- **WHEN** die Saison `winter` auf die Welt `winter` zeigt, ihr Fenster die aktuelle Zeit enthält und die Lobby startet +- **THEN** lädt die Lobby `worlds/winter` und spawnt Spieler an dessen Map-Spawn + +#### Scenario: Fenster ist noch nicht erreicht oder vorbei +- **WHEN** die aktuelle Zeit vor `from` oder ab `to` einer Saison liegt und die Lobby startet +- **THEN** lädt die Lobby die Standardwelt (`-DTITAN_LOBBY_MAP`, sonst `world`) + +#### Scenario: Fensterende ist ausgeschlossen +- **WHEN** die aktuelle Zeit genau `to` einer Saison entspricht +- **THEN** gilt die Saison als nicht aktiv + +#### Scenario: Zeitzone bestimmt das Fenster +- **WHEN** `seasons.zone` auf `Asia/Tokyo` steht und `from` lokal in Tokio erreicht ist, in Berlin aber noch nicht +- **THEN** gilt die Saison als aktiv + +### Requirement: Ohne aktive Saison gilt das bisherige Verhalten +Ist keine Saison konfiguriert oder keine aktiv, MUSS die Lobby die Welt wie bisher wählen. Eine Konfiguration ohne `seasons.*` DARF sich für Spieler und Betreiber nicht verändern. + +#### Scenario: Keine Saison konfiguriert +- **WHEN** die Konfiguration keinen Abschnitt `seasons` enthält +- **THEN** lädt die Lobby die Standardwelt und merkt keinen Neustart vor + +### Requirement: Der Abschalter wirkt ohne Konfigurations-Neustart +`seasons..enabled` MUSS live gelesen werden, sodass eine Änderung über die Konfigurationsüberwachung ohne Neustart der Lobby wirkt. Eine abgeschaltete Saison MUSS bei der Bestimmung der gewünschten Welt so behandelt werden, als wäre sie nicht konfiguriert. + +#### Scenario: Abschalten während der Saison +- **WHEN** die Lobby in der Welt der Saison `winter` läuft und `seasons.winter.enabled` auf `false` gesetzt wird +- **THEN** ist die gewünschte Welt die Standardwelt, und ein Neustart ist vorgemerkt + +#### Scenario: Einschalten im Fenster +- **WHEN** die Lobby in der Standardwelt läuft und `seasons.winter.enabled` im aktiven Fenster auf `true` gesetzt wird +- **THEN** ist die gewünschte Welt die Welt von `winter`, und ein Neustart ist vorgemerkt + +### Requirement: Ein Neustart ist nur vorgemerkt, wenn gewünschte und gestartete Welt abweichen +Die Lobby MUSS die gewünschte Welt mindestens einmal pro Minute mit der Welt vergleichen, mit der sie gestartet ist. Weichen beide ab, MUSS ein Neustart vorgemerkt sein; das MUSS einmal pro Vormerkung mit der Saison und dem Zeitpunkt geloggt werden. Stimmen beide wieder überein, MUSS die Vormerkung aufgehoben werden. Die Lobby DARF die Welt zur Laufzeit NICHT wechseln. + +#### Scenario: Saisonbeginn erreicht +- **WHEN** die Lobby in der Standardwelt läuft und das Fenster der Saison `winter` beginnt +- **THEN** ist spätestens nach einer Minute ein Neustart für `winter` vorgemerkt, und die Meldung erscheint einmal + +#### Scenario: Saisonende erreicht +- **WHEN** die Lobby in der Welt von `winter` läuft und das Fenster endet +- **THEN** ist spätestens nach einer Minute ein Neustart vorgemerkt, mit der Standardwelt als Ziel + +#### Scenario: Vormerkung wird aufgehoben +- **WHEN** ein Neustart vorgemerkt ist und die gewünschte Welt wieder der gestarteten entspricht, etwa weil der Abschalter zurückgesetzt wurde +- **THEN** ist kein Neustart mehr vorgemerkt, und die Lobby stoppt nicht + +#### Scenario: Kein Neustart-Kreislauf +- **WHEN** die Lobby nach einem Neustart in der gewünschten Welt läuft +- **THEN** ist kein Neustart vorgemerkt + +#### Scenario: Meldung nicht wiederholt +- **WHEN** ein Neustart vorgemerkt bleibt und die Minuten-Prüfung mehrfach läuft +- **THEN** wird die Vormerkung nicht erneut geloggt + +### Requirement: Die Lobby stoppt nur, wenn sie leer ist +Ist ein Neustart vorgemerkt und sind keine Spieler online, MUSS die Lobby sauber stoppen, damit der Dienst-Supervisor sie neu startet. Die Prüfung MUSS im Minutentakt und nach jedem Verlassen eines Spielers laufen, wobei der ausscheidende Spieler nicht mehr mitzählt. Die Lobby DARF NICHT stoppen, solange ein Spieler online ist. Es gibt keine Obergrenze für das Warten. + +#### Scenario: Leere Lobby im Minutentakt +- **WHEN** ein Neustart vorgemerkt ist und beim Minutentakt kein Spieler online ist +- **THEN** stoppt die Lobby sauber + +#### Scenario: Letzter Spieler geht +- **WHEN** ein Neustart vorgemerkt ist und der letzte Spieler die Lobby verlässt +- **THEN** stoppt die Lobby, ohne den nächsten Minutentakt abzuwarten + +#### Scenario: Spieler online +- **WHEN** ein Neustart vorgemerkt ist und mindestens ein Spieler online ist +- **THEN** stoppt die Lobby nicht, auch nicht nach vielen Minutentakten + +#### Scenario: Ein Spieler geht, andere bleiben +- **WHEN** ein Neustart vorgemerkt ist und einer von zwei Spielern die Lobby verlässt +- **THEN** stoppt die Lobby nicht + +#### Scenario: Nichts vorgemerkt +- **WHEN** kein Neustart vorgemerkt ist und die Lobby leer ist +- **THEN** stoppt die Lobby nicht + +### Requirement: Überlappende Fenster ergeben genau eine Welt +Überlappen sich die Fenster aktiver Saisons, MUSS die Saison mit dem früheren `from` gelten; bei gleichem `from` die mit der alphabetisch kleineren Id. Die Lobby MUSS die Überlappung beim Start als Warnung mit beiden Ids loggen und darf deswegen nicht abbrechen. + +#### Scenario: Zwei Fenster überlappen +- **WHEN** `autumn` ab 1. Oktober und `halloween` ab 25. Oktober gelten und beide am 28. Oktober aktiv wären +- **THEN** ist `autumn` die gewünschte Saison, und beim Start steht eine Warnung mit `autumn` und `halloween` im Log + +### Requirement: Ungültige Saison-Konfiguration verhindert den Start +Ist eine aktivierte Saison ungültig, MUSS die Lobby den Start abbrechen, und die Fehlermeldung MUSS den vollständigen Schlüssel (`seasons..`) und den Grund nennen. Ungültig sind ein fehlender `world`-, `from`- oder `to`-Wert, ein Datum, das sich nicht lesen lässt, `from` nicht vor `to`, ein Weltverzeichnis, das unter `worlds/` fehlt, und ein Weltverzeichnis ohne Map-Datei. Auch eine ungültige `seasons.zone` MUSS den Start abbrechen. Eine abgeschaltete Saison DARF den Start nicht verhindern. Ein zur Laufzeit ungültig gewordener Wert DARF weder einen Neustart auslösen noch die Lobby stoppen; er MUSS als Warnung geloggt werden, und die Saison zählt bis zur Korrektur als nicht aktiv. + +#### Scenario: Welt existiert nicht +- **WHEN** `seasons.winter.world` auf `winter` zeigt und `worlds/winter` fehlt +- **THEN** startet die Lobby nicht und nennt `seasons.winter.world` und den Grund + +#### Scenario: Welt ohne Map-Datei +- **WHEN** `worlds/winter` existiert, enthält aber keine Map-Datei +- **THEN** startet die Lobby nicht und nennt `seasons.winter.world` und den Grund + +#### Scenario: Datum nicht lesbar +- **WHEN** `seasons.winter.from` den Wert `morgen` hat +- **THEN** startet die Lobby nicht und nennt `seasons.winter.from` + +#### Scenario: Fenster verkehrt herum +- **WHEN** `seasons.winter.from` nicht vor `seasons.winter.to` liegt +- **THEN** startet die Lobby nicht und nennt `seasons.winter.from` und `seasons.winter.to` + +#### Scenario: Abgeschaltete Saison mit fehlender Welt +- **WHEN** `seasons.winter.enabled` `false` ist und `worlds/winter` fehlt +- **THEN** startet die Lobby normal + +#### Scenario: Live aktivierte Saison ohne Welt +- **WHEN** die Lobby läuft und `seasons.winter.enabled` im aktiven Fenster auf `true` gesetzt wird, obwohl `worlds/winter` fehlt +- **THEN** merkt die Lobby keinen Neustart vor, loggt eine Warnung mit `seasons.winter.world` und läuft weiter diff --git a/openspec/changes/seasonal-lobby-world/tasks.md b/openspec/changes/seasonal-lobby-world/tasks.md new file mode 100644 index 00000000..97bde1bf --- /dev/null +++ b/openspec/changes/seasonal-lobby-world/tasks.md @@ -0,0 +1,50 @@ +# Tasks + +## Execution Plan + +Integrationszweig: `feat/season` von `origin/main`. Welle 1 ist ein Spike und entscheidet, ob D2 mit dem Optional-Bean oder mit dem `ServiceLoader`-Weg umgesetzt wird; Task 2.4 und 3.x richten sich nach dem Ergebnis. Jede Welle endet mit grünem `./gradlew build` und geprüften Diffs. Vor dem Abhaken einer Aufgabe läuft `./gradlew build`. + +| Wave | Agent | Task IDs | Model | May Touch | Must Not Touch | +| ---- | ----- | -------- | ----- | --------- | -------------- | +| 1 | spike | 1.1 | sonnet | Wegwerf-Branch, danach nur `openspec/changes/seasonal-lobby-world/design.md` (Ergebnis in D2) | Produktionscode im Integrationszweig | +| 2 | world-choice | 2.1–2.4 | sonnet | `core/src/main/**/module/**`, `common/src/**/map/**`, `runtime/src/main/**/bootstrap/PlatformBeans.java` | `features/**`, `apps/**`, `buildSrc/**` | +| 2 | season-column | 3.1–3.8 | sonnet | `features/season/**` | `core/**`, `common/**`, `runtime/**`, andere `features/**`, `apps/**` | +| 3 | variants-docs | 4.1–4.4 | sonnet | `apps/**`, `README.md`, `docs/lobby-modules.md` | `features/**`, `core/src/main/**`, `runtime/src/main/**` | +| 3 | verify | 4.5 | haiku | read-only | alles | +| 4 | pr | 5.1 | sonnet | Git/GitHub | Code | + +Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhandenes nutzen (`Config`-Fassade, Minestom-`Scheduler`, `java.time`, `ServiceLoader` nur im Ausweichweg), Java 25 ohne Preview (Records für `Season`/`Decision`, `switch` über die Entscheidung), keine Nutzertexte, SLF4J mit Parametern (Messages aus D6), keine neuen Metriken/Spans, Test zuerst, schlanke Kommentare nur fürs Warum, Conventional Commits `feat(season): …`. F.I.R.S.T.: `Clock` und `Scheduler` injiziert, `env.tick()` statt Warten, kein `Thread.sleep`, keine Systemzeit, `@TempDir` für `worlds/`, `Config`-Werte im Test setzen und im `@AfterEach` zurücksetzen, frische `Env`/Fixtures je Test, `ServerStop` und `OnlinePlayers` gefälscht (der Test stoppt nie den Server), Erfolg nur über Assertions, WARN/INFO über einen aufgefangenen Appender. + +## 1. Spike: Optional-Bean über Modulgrenzen (Welle 1) + +- [ ] 1.1 Auf einem Wegwerf-Branch eine Bean `LobbyWorldChoice` in eine Test-Column legen und `PlatformBeans.mapProvider(...)` einen `Optional` nehmen lassen; mit echtem `BeanScope.builder().build()` prüfen (a), ob der Optional gefüllt ist, und (b), ob `local` ohne die Column startet. Ergebnis (Optional geht / geht nicht, samt generiertem Code und Modulreihenfolge) in D2 eintragen und die Aufgaben 2.3/2.4 auf den gewählten Weg festlegen. Nachweis: D2 nennt das Ergebnis; der Wegwerf-Branch wird verworfen. + +## 2. Weltwahl in `core`, `common` und `runtime` (Welle 2) + +- [ ] 2.1 Charakterisierung zuerst (Unit): `MapPool` wählt heute `world` bei mehreren Welten, die einzige Welt bei genau einer und wirft ohne Treffer; Test pinnt das mit `@TempDir`-Verzeichnissen. Nachweis: Test grün vor der Änderung. +- [ ] 2.2 Test zuerst (Unit): `MapPool` mit übergebenem Weltnamen wählt genau diese Welt, auch bei genau einer Welt, und wirft mit dem Namen in der Meldung, wenn er fehlt; ohne Namen unverändert. Dann `MapPool`/`MapProvider.create(..., Optional)` umsetzen. Nachweis: Tests aus 2.1 und 2.2 grün. +- [ ] 2.3 `LobbyWorldChoice` in `core` anlegen (`Optional worldName()`). Nachweis: `./gradlew :core:build` grün. +- [ ] 2.4 Test zuerst (Integration, echter `BeanScope`, `@TempDir`-`worlds/`, Cyano-`Env`): Mit einer `LobbyWorldChoice`-Bean lädt `PlatformBeans.mapProvider(...)` deren Welt, ohne Bean die Standardwelt. Dann `PlatformBeans` nach dem Ergebnis von 1.1 umsetzen (Optional-Parameter oder `ServiceLoader`). Nachweis: Test grün; `apps/local` startet unverändert. + +## 3. Column `features/season` (Welle 2) + +- [ ] 3.1 Modul anlegen: `build.gradle.kts` (`titan.column`, `libs.avaje.config`, `libs.slf4j.api`, `testImplementation(libs.logback.classic)`), `package-info.java` mit `@InjectModule(name = "seasonColumn", requires = {Scheduler.class, Clock.class, EventNode.class}, requiresString = {"…EventNode<…Event>:titan"})`, `titan/defaults/season.yaml` (D1). Nachweis: `./gradlew :features:season:build` grün, `settings.gradle.kts` unverändert. +- [ ] 3.2 Test zuerst (Unit, `SeasonCalendarTest`): aktive Saison in `[from, to)`, `to` ausgeschlossen, keine Saison, abgeschaltete Saison ignoriert, Zeitzone verschiebt das Fenster, Überlappung: früheres `from` gewinnt, bei Gleichstand die kleinere Id; rot. Dann `Season` und `SeasonCalendar` umsetzen; grün. +- [ ] 3.3 Test zuerst (Unit, `SeasonConfigReaderTest`, `@TempDir`-`worlds/`): `season.yaml` liefert `zone=Europe/Berlin`; fehlender `world`/`from`/`to`, unlesbares Datum, `from >= to`, fehlendes Weltverzeichnis, fehlende `map.json`, ungültige Zone, reservierte Id `zone` brechen mit qualifiziertem Schlüssel und Grund ab; abgeschaltete Saison wird nicht geprüft; Überlappung loggt WARN mit beiden Ids (aufgefangener Appender); rot. Dann `SeasonSettings`/`SeasonConfigReader` umsetzen; grün. +- [ ] 3.4 Test zuerst (Unit, `RestartPolicyTest`): gleiche Welt → `NONE`; Abweichung mit Spielern → `PENDING`; Abweichung ohne Spieler → `STOP`; rot. Dann `RestartPolicy` umsetzen; grün. +- [ ] 3.5 Test zuerst (Integration, Cyano-`Env`, einstellbare `Clock`, gefälschte `ServerStop`/`OnlinePlayers`, `env.tick()`): Minutentakt merkt bei Abweichung vor und loggt einmal; kein erneutes Loggen bei weiteren Takten; Abschalter zurückgesetzt hebt auf; Stopp nur bei 0 Spielern im Minutentakt; `PlayerDisconnectEvent` des letzten Spielers stoppt im nächsten Tick ohne Minutentakt; ein Spieler geht, ein anderer bleibt → kein Stopp; nichts vorgemerkt und leer → kein Stopp; höchstens ein Stopp; live aktivierte Saison ohne Welt und live ungültiger Wert → WARN, keine Vormerkung; nach Neustart-Zustand (gestartete = gewünschte Welt) → keine Vormerkung; rot. Der Test klärt, ob der ausscheidende Spieler im nächsten Tick nicht mehr zählt (D4). +- [ ] 3.6 `SeasonModule` (`@Singleton`, `@PostConstruct` liest die Startwelt und plant den Minutentask samt `titan`-Listener, `@PreDestroy` bricht den Task ab und trennt den Listener), die Nahtstellen `ServerStop` (Standard: `titan-stop`-Thread mit `MinecraftServer.stopCleanly()` und `System.exit(0)`, höchstens einmal) und `OnlinePlayers` mit ihren Standardimplementierungen umsetzen; Test aus 3.5 grün. +- [ ] 3.7 Bei `ServiceLoader`-Weg (Ergebnis von 1.1): `SeasonWorldChoice` mit Eintrag unter `META-INF/services` ergänzen, Test zuerst (Unit): liefert die Welt der aktiven Saison, sonst leer. Bei Optional-Weg: `SeasonWorldChoice` als `@Singleton`, das `LobbyWorldChoice` implementiert und `provides` in der `package-info.java` ergänzt; derselbe Test. Nachweis: Test grün. +- [ ] 3.8 `ColumnArchitectureTest` nach dem Muster von `features/daytime` ergänzen. Nachweis: `./gradlew :features:season:build` grün, keine Datei außerhalb von `features/season/**` geändert. + +## 4. Varianten, Doku und Abnahme (Welle 3) + +- [ ] 4.1 Test zuerst (Integration, Starttest je Variante): `apps/cloudnet` lädt `seasonColumn` und erwartet sie im Startcheck; `apps/local` hat die Column nicht auf dem Klassenpfad und keine `seasons`-Standardwerte im Beispiel-`application.yaml`. Dann `apps/local/build.gradle.kts` um `titanVariant { exclude("season") }` mit einem Kommentar zum Grund (kein Supervisor) ergänzen. Nachweis: beide Starttests grün. +- [ ] 4.2 Test zuerst (Unit in `apps/cloudnet`): Die `map.json`-Konstante der Column entspricht `MapEntry.MAP_FILE_NAME`; rot bei Abweichung. Nachweis: Test grün. +- [ ] 4.3 Integrationstest in `apps/cloudnet` (echter `BeanScope`, `@TempDir`-`worlds/`, gesetzte `seasons.*`, feste `Clock`): Bei aktivem Fenster lädt die Lobby die Saison-Welt, danach vorgemerkter Neustart bei Fensterende, gefälschter Stopp. Nachweis: Test grün (deckt die Verdrahtung aus D2 ab). +- [ ] 4.4 README (Abschnitt Betrieb) und `docs/lobby-modules.md` (Tabelle der Columns, Hinweis „nur `cloudnet`“): Wie man eine Saisonwelt anlegt (Welt im Setup-Server bauen, `worlds//` mit `map.json` ablegen, `seasons..*` eintragen, Abschalter, Zeitzone), dass der Wechsel einen Neustart bei leerer Lobby braucht und ein belegter Dienst warten kann (`/stop` als Betreibermittel). Nachweis: Doku nennt Schlüssel, Beispiel und Kompromiss. +- [ ] 4.5 Verifikation (Haiku, read-only): Szenarien von `lobby-seasons` und `app-variants` Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, kein Schreiben ins echte Arbeitsverzeichnis, `Config`-Werte zurückgesetzt). Nachweis: Bericht ohne Lücken. + +## 5. Pull Request + +- [ ] 5.1 Pull Request vom Integrationszweig auf `main` unter dem Titel `feat(season): restart the lobby into a seasonal world during its window` öffnen (Titel und Beschreibung Englisch), mit dem Hinweis, dass er PR #225 ersetzt und die Abwägung Neustart statt Live-Wechsel enthält; nach dem Merge PR #225 mit einem Kommentar schließen, der auf den neuen PR verweist (superseded). Nachweis: PR-URL, CI grün. From 0fc94ee9a47bb46ba1910e9cf7c1b2fedbf8c45f Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:26:36 +0200 Subject: [PATCH 02/17] feat(season): select the lobby world by name Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/common/map/MapPool.java | 16 +++ .../titan/common/map/MapProvider.java | 16 ++- .../titan/common/map/MapPoolTest.java | 133 ++++++++++++++++++ 3 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 common/src/test/java/net/onelitefeather/titan/common/map/MapPoolTest.java diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/MapPool.java b/common/src/main/java/net/onelitefeather/titan/common/map/MapPool.java index aefa413a..015a2bc2 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/map/MapPool.java +++ b/common/src/main/java/net/onelitefeather/titan/common/map/MapPool.java @@ -28,6 +28,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Stream; @@ -42,14 +43,29 @@ public final class MapPool { private List referenceList; private MapEntry selectedMap; private final Function, List> filterMaps; + private final Optional worldName; public MapPool(@NotNull Path path, @NotNull Function, List> filterMaps) { + this(path, filterMaps, Optional.empty()); + } + + /** + * @param worldName the world to pick; when present it is the only acceptable choice, even if it + * is the sole world, otherwise the default lobby world rules apply + */ + public MapPool(@NotNull Path path, @NotNull Function, List> filterMaps, @NotNull Optional worldName) { this.filterMaps = filterMaps; + this.worldName = worldName; this.referenceList = loadMapsEntries(path); this.peekMap(); } private void peekMap() { + if (this.worldName.isPresent()) { + String name = this.worldName.get(); + this.selectedMap = this.referenceList.stream().filter(mapEntry -> mapEntry.path().getFileName().toString().equalsIgnoreCase(name)).findFirst().orElseThrow(() -> new IllegalArgumentException("The world '" + name + "' does not exist")); + return; + } Check.argCondition(this.referenceList.isEmpty(), "The map list is empty"); if (this.referenceList.size() == 1) { this.selectedMap = this.referenceList.getFirst(); diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java b/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java index 39173bbb..6d65efc9 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java +++ b/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java @@ -37,6 +37,7 @@ import java.util.Collections; import java.util.List; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -50,8 +51,8 @@ public final class MapProvider { private InstanceContainer instance; private LobbyMap activeLobby; - private MapProvider(@NotNull Path path, @NotNull InstanceContainer instance, Function, List> filterMaps) { - this.mapPool = new MapPool(path.resolve(MAP_PATH), filterMaps); + private MapProvider(@NotNull Path path, @NotNull InstanceContainer instance, Function, List> filterMaps, @NotNull Optional worldName) { + this.mapPool = new MapPool(path.resolve(MAP_PATH), filterMaps, worldName); this.instance = instance; // Relight each chunk as it loads so unexplored regions light up (anvil chunks otherwise // stay dark until a block update triggers a relight). @@ -62,9 +63,6 @@ private MapProvider(@NotNull Path path, @NotNull InstanceContainer instance, Fun this.loadMapData(); } - private MapProvider(@NotNull Path path, @NotNull InstanceContainer instance) { - this(path, instance, MapProvider::defaultFilter); - } private static List defaultFilter(Stream pathStream) { return pathStream.map(MapEntry::new).filter(MapEntry::hasMapFile).collect(Collectors.toList()); @@ -120,10 +118,14 @@ public InstanceContainer getInstance() { } public static MapProvider create(@NotNull Path path, @NotNull InstanceContainer instance) { - return new MapProvider(path, instance); + return new MapProvider(path, instance, MapProvider::defaultFilter, Optional.empty()); + } + + public static MapProvider create(@NotNull Path path, @NotNull InstanceContainer instance, @NotNull Optional worldName) { + return new MapProvider(path, instance, MapProvider::defaultFilter, worldName); } public static MapProvider create(@NotNull Path path, @NotNull InstanceContainer instance, Function, List> filterMaps) { - return new MapProvider(path, instance, filterMaps); + return new MapProvider(path, instance, filterMaps, Optional.empty()); } } diff --git a/common/src/test/java/net/onelitefeather/titan/common/map/MapPoolTest.java b/common/src/test/java/net/onelitefeather/titan/common/map/MapPoolTest.java new file mode 100644 index 00000000..ec4a9647 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/map/MapPoolTest.java @@ -0,0 +1,133 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The no-name cases rely on the default lobby world being called {@code world}, i.e. on + * {@code -DTITAN_LOBBY_MAP} being unset in the test JVM. + */ +class MapPoolTest { + + @TempDir + Path worlds; + + private void world(String name) throws IOException { + Path dir = Files.createDirectory(worlds.resolve(name)); + Files.writeString(dir.resolve(MapEntry.MAP_FILE_NAME), "{}"); + } + + private static List withMapFile(Stream paths) { + return paths.map(MapEntry::new).filter(MapEntry::hasMapFile).collect(Collectors.toList()); + } + + private MapPool pool() { + return new MapPool(worlds, MapPoolTest::withMapFile); + } + + private MapPool pool(String worldName) { + return new MapPool(worlds, MapPoolTest::withMapFile, Optional.of(worldName)); + } + + private static String selectedName(MapPool pool) { + return pool.getMapEntry().path().getFileName().toString(); + } + + @Test + void picksTheDefaultWorldWhenSeveralExist() throws IOException { + world("world"); + world("winter"); + + assertEquals("world", selectedName(pool()), "the default world wins among several"); + } + + @Test + void picksTheOnlyWorldWhateverItIsCalled() throws IOException { + world("winter"); + + assertEquals("winter", selectedName(pool()), "a single world is used without a name match"); + } + + @Test + void throwsWhenSeveralWorldsExistButNoneIsTheDefault() throws IOException { + world("winter"); + world("summer"); + + assertThrows(NoSuchElementException.class, this::pool); + } + + @Test + void throwsWhenNoWorldExists() { + assertThrows(IllegalArgumentException.class, this::pool); + } + + @Test + void picksTheNamedWorldAmongSeveral() throws IOException { + world("world"); + world("winter"); + + assertEquals("winter", selectedName(pool("winter")), "the named world beats the default"); + } + + @Test + void picksTheNamedWorldWhenItIsTheOnlyOne() throws IOException { + world("winter"); + + assertEquals("winter", selectedName(pool("winter"))); + } + + @Test + void throwsWithTheNameWhenTheNamedWorldIsMissing() throws IOException { + world("world"); + world("summer"); + + var exception = assertThrows(IllegalArgumentException.class, () -> pool("winter")); + + assertTrue(exception.getMessage().contains("winter"), "message names the missing world: " + exception.getMessage()); + } + + @Test + void throwsInsteadOfFallingBackWhenTheOnlyWorldIsNotTheNamedOne() throws IOException { + world("world"); + + assertThrows(IllegalArgumentException.class, () -> pool("winter")); + } + + @Test + void behavesAsWithoutANameWhenTheNameIsEmpty() throws IOException { + world("world"); + world("winter"); + + var pool = new MapPool(worlds, MapPoolTest::withMapFile, Optional.empty()); + + assertEquals("world", selectedName(pool)); + } +} From 16a06694a69743049178e813611838cf13079450 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:26:46 +0200 Subject: [PATCH 03/17] feat(season): add season column with calendar Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- features/season/build.gradle.kts | 13 ++ .../titan/feature/season/Season.java | 25 ++++ .../titan/feature/season/SeasonCalendar.java | 60 ++++++++ .../titan/feature/season/SeasonSettings.java | 43 ++++++ .../titan/feature/season/package-info.java | 27 ++++ .../main/resources/titan/defaults/season.yaml | 10 ++ .../feature/season/SeasonCalendarTest.java | 141 ++++++++++++++++++ 7 files changed, 319 insertions(+) create mode 100644 features/season/build.gradle.kts create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/Season.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonCalendar.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonSettings.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/package-info.java create mode 100644 features/season/src/main/resources/titan/defaults/season.yaml create mode 100644 features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonCalendarTest.java diff --git a/features/season/build.gradle.kts b/features/season/build.gradle.kts new file mode 100644 index 00000000..c779fa2d --- /dev/null +++ b/features/season/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("titan.column") +} + +dependencies { + // SeasonConfigReader reads seasons.* directly from the io.avaje.config.Config facade; + // titan.column does not pull it in because most columns don't need it. + implementation(libs.avaje.config) + implementation(libs.slf4j.api) + + // ListAppender, for capturing the column's WARN and INFO lines in its tests. + testImplementation(libs.logback.classic) +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/Season.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/Season.java new file mode 100644 index 00000000..0807e6ce --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/Season.java @@ -0,0 +1,25 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.time.LocalDateTime; + +/** + * One configured season: the lobby runs in {@code world} from {@code from} (inclusive) to + * {@code to} (exclusive), both local time in the configured zone. + */ +record Season(String id, String world, LocalDateTime from, LocalDateTime to, boolean enabled) { +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonCalendar.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonCalendar.java new file mode 100644 index 00000000..1933982f --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonCalendar.java @@ -0,0 +1,60 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * Picks the active season for a point in time. Pure: no configuration, no clock. + * + *

Overlapping windows resolve to the season with the earlier {@code from}, and on a tie to the + * smaller id, so the outcome never depends on the order the configuration lists them in. + */ +final class SeasonCalendar { + + /** Earlier start first, then smaller id. */ + private static final Comparator PRECEDENCE = Comparator.comparing(Season::from).thenComparing(Season::id); + + /** Two enabled seasons whose windows share time; {@code winner} takes precedence. */ + record Overlap(Season winner, Season loser) { + } + + Optional activeAt(List seasons, Instant now, ZoneId zone) { + LocalDateTime local = LocalDateTime.ofInstant(now, zone); + return seasons.stream().filter(Season::enabled).filter(season -> !local.isBefore(season.from()) && local.isBefore(season.to())).min(PRECEDENCE); + } + + List overlaps(List seasons) { + List enabled = seasons.stream().filter(Season::enabled).sorted(PRECEDENCE).toList(); + List overlaps = new ArrayList<>(); + for (int i = 0; i < enabled.size(); i++) { + for (int j = i + 1; j < enabled.size(); j++) { + Season winner = enabled.get(i); + Season loser = enabled.get(j); + if (loser.from().isBefore(winner.to())) { + overlaps.add(new Overlap(winner, loser)); + } + } + } + return overlaps; + } +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonSettings.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonSettings.java new file mode 100644 index 00000000..6418b640 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonSettings.java @@ -0,0 +1,43 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +/** + * The {@code season} module's configuration keys. + */ +final class SeasonSettings { + + static final String PREFIX = "seasons."; + static final String ZONE_KEY = "seasons.zone"; + /** Reserved: {@code seasons.zone} would collide with a season of that id. */ + static final String RESERVED_ID = "zone"; + + static final String WORLD_FIELD = "world"; + static final String FROM_FIELD = "from"; + static final String TO_FIELD = "to"; + static final String ENABLED_FIELD = "enabled"; + + static final String WORLDS_DIRECTORY = "worlds"; + // Kept here because a column sees only core; an apps test pins it to MapEntry.MAP_FILE_NAME. + static final String MAP_FILE_NAME = "map.json"; + + private SeasonSettings() { + } + + static String key(String id, String field) { + return PREFIX + id + "." + field; + } +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/package-info.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/package-info.java new file mode 100644 index 00000000..4051bab0 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/package-info.java @@ -0,0 +1,27 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The {@code season} column: in a configured window the lobby runs in its own world, switched by + * a restart while the lobby is empty. See {@code docs/lobby-modules.md}, "Wie eine Column + * Plattform-Beans bekommt", for how a column declares its platform dependencies. + */ +@InjectModule(name = "seasonColumn", requires = {Scheduler.class, Clock.class, EventNode.class}, requiresString = {"net.minestom.server.event.EventNode:titan"}) +package net.onelitefeather.titan.feature.season; + +import io.avaje.inject.InjectModule; +import java.time.Clock; +import net.minestom.server.event.EventNode; +import net.minestom.server.timer.Scheduler; diff --git a/features/season/src/main/resources/titan/defaults/season.yaml b/features/season/src/main/resources/titan/defaults/season.yaml new file mode 100644 index 00000000..892e19a4 --- /dev/null +++ b/features/season/src/main/resources/titan/defaults/season.yaml @@ -0,0 +1,10 @@ +# The "season" module: in a configured window the lobby runs in its own world, switched by a restart. +seasons: + # The time zone of the windows (a java.time zone id). An invalid value aborts startup. + # "zone" is reserved and cannot be used as a season id. + zone: Europe/Berlin + # : + # world: winter # directory under worlds/, with a map.json + # from: 2026-12-01T00:00:00 # inclusive, local time in seasons.zone + # to: 2027-01-07T00:00:00 # exclusive + # enabled: true # switch that takes effect without restarting the configuration diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonCalendarTest.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonCalendarTest.java new file mode 100644 index 00000000..3fbc7251 --- /dev/null +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonCalendarTest.java @@ -0,0 +1,141 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SeasonCalendarTest { + + private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + + private final SeasonCalendar calendar = new SeasonCalendar(); + + private static Season season(String id, String from, String to) { + return new Season(id, id, LocalDateTime.parse(from), LocalDateTime.parse(to), true); + } + + private static Season winter() { + return season("winter", "2026-12-01T00:00:00", "2027-01-07T00:00:00"); + } + + private Optional activeId(List seasons, String utcInstant, ZoneId zone) { + return this.calendar.activeAt(seasons, Instant.parse(utcInstant), zone).map(Season::id); + } + + @DisplayName("A season is active inside its window") + @Test + void seasonIsActiveInsideItsWindow() { + Assertions.assertEquals(Optional.of("winter"), activeId(List.of(winter()), "2026-12-24T12:00:00Z", BERLIN)); + } + + @DisplayName("The window includes its start") + @Test + void windowIncludesItsStart() { + // 00:00 Berlin (CET) is 23:00 UTC the day before. + Assertions.assertEquals(Optional.of("winter"), activeId(List.of(winter()), "2026-11-30T23:00:00Z", BERLIN)); + } + + @DisplayName("The window excludes its end") + @Test + void windowExcludesItsEnd() { + Assertions.assertEquals(Optional.empty(), activeId(List.of(winter()), "2027-01-06T23:00:00Z", BERLIN)); + } + + @DisplayName("Before the window no season is active") + @Test + void noSeasonBeforeTheWindow() { + Assertions.assertEquals(Optional.empty(), activeId(List.of(winter()), "2026-11-30T22:59:59Z", BERLIN)); + } + + @DisplayName("Without seasons nothing is active") + @Test + void noSeasonsMeansNoActiveSeason() { + Assertions.assertEquals(Optional.empty(), activeId(List.of(), "2026-12-24T12:00:00Z", BERLIN)); + } + + @DisplayName("A disabled season is ignored") + @Test + void disabledSeasonIsIgnored() { + Season disabled = new Season("winter", "winter", winter().from(), winter().to(), false); + + Assertions.assertEquals(Optional.empty(), activeId(List.of(disabled), "2026-12-24T12:00:00Z", BERLIN)); + } + + @DisplayName("The zone shifts the window") + @Test + void zoneShiftsTheWindow() { + List seasons = List.of(winter()); + // 2026-11-30T16:00Z is 01:00 in Tokyo on Dec 1, but still 17:00 on Nov 30 in Berlin. + String instant = "2026-11-30T16:00:00Z"; + + Assertions.assertEquals(Optional.of("winter"), activeId(seasons, instant, ZoneId.of("Asia/Tokyo")), "Tokyo has reached from"); + Assertions.assertEquals(Optional.empty(), activeId(seasons, instant, BERLIN), "Berlin has not"); + } + + @DisplayName("On overlap the earlier start wins") + @Test + void earlierStartWinsOnOverlap() { + Season autumn = season("autumn", "2026-10-01T00:00:00", "2026-11-15T00:00:00"); + Season halloween = season("halloween", "2026-10-25T00:00:00", "2026-11-02T00:00:00"); + + Assertions.assertEquals(Optional.of("autumn"), activeId(List.of(halloween, autumn), "2026-10-28T12:00:00Z", BERLIN)); + } + + @DisplayName("On overlap with the same start the smaller id wins") + @Test + void smallerIdWinsOnSameStart() { + Season b = season("b", "2026-10-01T00:00:00", "2026-11-15T00:00:00"); + Season a = season("a", "2026-10-01T00:00:00", "2026-11-02T00:00:00"); + + Assertions.assertEquals(Optional.of("a"), activeId(List.of(b, a), "2026-10-28T12:00:00Z", BERLIN)); + } + + @DisplayName("Overlapping enabled seasons are reported with the winner first") + @Test + void overlapsNameWinnerAndLoser() { + Season autumn = season("autumn", "2026-10-01T00:00:00", "2026-11-15T00:00:00"); + Season halloween = season("halloween", "2026-10-25T00:00:00", "2026-11-02T00:00:00"); + + List overlaps = this.calendar.overlaps(List.of(halloween, autumn)); + + Assertions.assertEquals(List.of(new SeasonCalendar.Overlap(autumn, halloween)), overlaps); + } + + @DisplayName("Windows that only touch do not overlap") + @Test + void touchingWindowsDoNotOverlap() { + Season first = season("first", "2026-10-01T00:00:00", "2026-11-01T00:00:00"); + Season second = season("second", "2026-11-01T00:00:00", "2026-12-01T00:00:00"); + + Assertions.assertEquals(List.of(), this.calendar.overlaps(List.of(first, second))); + } + + @DisplayName("A disabled season takes part in no overlap") + @Test + void disabledSeasonHasNoOverlap() { + Season disabled = new Season("autumn", "autumn", LocalDateTime.parse("2026-10-01T00:00:00"), LocalDateTime.parse("2026-11-15T00:00:00"), false); + Season halloween = season("halloween", "2026-10-25T00:00:00", "2026-11-02T00:00:00"); + + Assertions.assertEquals(List.of(), this.calendar.overlaps(List.of(disabled, halloween))); + } +} From ec504cc8d3dd685e1688926ffd6b59895a5607ec Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:27:50 +0200 Subject: [PATCH 04/17] feat(season): read and validate seasons from config Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../feature/season/SeasonConfigReader.java | 186 ++++++++++ .../season/SeasonConfigReaderTest.java | 335 ++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonConfigReader.java create mode 100644 features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonConfigReaderTest.java diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonConfigReader.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonConfigReader.java new file mode 100644 index 00000000..acc6cd97 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonConfigReader.java @@ -0,0 +1,186 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import io.avaje.config.Config; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Reads and validates {@code seasons.*} from {@link Config}. + * + *

At startup a problem in an enabled season aborts with the qualified key and the reason. Live + * the same problem only yields no configuration and one WARN per value: acting on a broken value + * would restart the lobby into a startup that then aborts, and the supervisor would loop. + */ +final class SeasonConfigReader { + + private static final Logger LOGGER = LoggerFactory.getLogger(SeasonConfigReader.class); + + /** The zone and every enabled, valid season; disabled seasons are left out unread. */ + record SeasonConfig(ZoneId zone, List seasons) { + } + + private record Problem(String key, String reason) { + + String describe() { + return this.key + ": " + this.reason; + } + } + + private final Path worldsDirectory; + private final SeasonCalendar calendar; + // Problems already warned about, so an unchanged broken value is not logged every minute. + private final Set warned = new HashSet<>(); + + SeasonConfigReader(Path worldsDirectory, SeasonCalendar calendar) { + this.worldsDirectory = Objects.requireNonNull(worldsDirectory, "worldsDirectory"); + this.calendar = Objects.requireNonNull(calendar, "calendar"); + } + + /** + * @throws IllegalStateException naming the first invalid key and the reason + */ + SeasonConfig readAtStartup() { + List problems = new ArrayList<>(); + SeasonConfig config = read(problems); + if (!problems.isEmpty()) { + throw new IllegalStateException(problems.getFirst().describe()); + } + for (SeasonCalendar.Overlap overlap : this.calendar.overlaps(config.seasons())) { + LOGGER.warn("Seasons {} and {} overlap, {} wins", overlap.winner().id(), overlap.loser().id(), overlap.winner().id()); + } + return config; + } + + /** + * @return the configuration, or empty while any enabled season or the zone is invalid + */ + synchronized Optional readLive() { + List problems = new ArrayList<>(); + SeasonConfig config = read(problems); + Set current = new HashSet<>(); + for (Problem problem : problems) { + current.add(problem.describe()); + if (this.warned.add(problem.describe())) { + LOGGER.warn("Invalid season configuration, no restart will be scheduled: {}", problem.describe()); + } + } + this.warned.retainAll(current); + return problems.isEmpty() ? Optional.of(config) : Optional.empty(); + } + + private SeasonConfig read(List problems) { + ZoneId zone; + try { + zone = Config.getAs(SeasonSettings.ZONE_KEY, ZoneId::of); + } catch (RuntimeException e) { + problems.add(new Problem(SeasonSettings.ZONE_KEY, "invalid zone '" + Config.get(SeasonSettings.ZONE_KEY, "") + "' (" + rootMessage(e) + ")")); + return new SeasonConfig(ZoneId.systemDefault(), List.of()); + } + List seasons = new ArrayList<>(); + for (String id : seasonIds()) { + if (SeasonSettings.RESERVED_ID.equals(id)) { + problems.add(new Problem(SeasonSettings.ZONE_KEY, "'" + id + "' is reserved and cannot be a season id")); + } else if (Config.getBool(SeasonSettings.key(id, SeasonSettings.ENABLED_FIELD), true)) { + readSeason(id, problems).ifPresent(seasons::add); + } + } + return new SeasonConfig(zone, List.copyOf(seasons)); + } + + private static Set seasonIds() { + Set ids = new TreeSet<>(); + for (String key : Config.asProperties().stringPropertyNames()) { + if (key.startsWith(SeasonSettings.PREFIX)) { + String rest = key.substring(SeasonSettings.PREFIX.length()); + int dot = rest.indexOf('.'); + if (dot > 0) { + ids.add(rest.substring(0, dot)); + } + } + } + return ids; + } + + private Optional readSeason(String id, List problems) { + int before = problems.size(); + String world = value(id, SeasonSettings.WORLD_FIELD, problems); + LocalDateTime from = date(id, SeasonSettings.FROM_FIELD, problems); + LocalDateTime to = date(id, SeasonSettings.TO_FIELD, problems); + if (world != null) { + checkWorld(id, world, problems); + } + if (from != null && to != null && !from.isBefore(to)) { + problems.add(new Problem(SeasonSettings.key(id, SeasonSettings.FROM_FIELD), "must be before " + SeasonSettings.key(id, SeasonSettings.TO_FIELD) + " (" + from + " is not before " + to + ")")); + } + return problems.size() == before ? Optional.of(new Season(id, world, from, to, true)) : Optional.empty(); + } + + private void checkWorld(String id, String world, List problems) { + String key = SeasonSettings.key(id, SeasonSettings.WORLD_FIELD); + Path directory = this.worldsDirectory.resolve(world).normalize(); + if (!this.worldsDirectory.normalize().equals(directory.getParent())) { + problems.add(new Problem(key, "'" + world + "' must be a directory name directly under " + SeasonSettings.WORLDS_DIRECTORY + "/")); + } else if (!Files.isDirectory(directory)) { + problems.add(new Problem(key, "world directory " + SeasonSettings.WORLDS_DIRECTORY + "/" + world + " does not exist")); + } else if (!Files.isRegularFile(directory.resolve(SeasonSettings.MAP_FILE_NAME))) { + problems.add(new Problem(key, SeasonSettings.WORLDS_DIRECTORY + "/" + world + " has no " + SeasonSettings.MAP_FILE_NAME)); + } + } + + private static LocalDateTime date(String id, String field, List problems) { + String raw = value(id, field, problems); + if (raw == null) { + return null; + } + try { + return LocalDateTime.parse(raw); + } catch (DateTimeException e) { + problems.add(new Problem(SeasonSettings.key(id, field), "'" + raw + "' is not a local date-time like 2026-12-01T00:00:00")); + return null; + } + } + + private static String value(String id, String field, List problems) { + String key = SeasonSettings.key(id, field); + Optional value = Config.getOptional(key).map(String::strip).filter(raw -> !raw.isEmpty()); + if (value.isEmpty()) { + problems.add(new Problem(key, "is required")); + } + return value.orElse(null); + } + + private static String rootMessage(Throwable throwable) { + Throwable cause = throwable; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return cause.getMessage(); + } +} diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonConfigReaderTest.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonConfigReaderTest.java new file mode 100644 index 00000000..c5b09104 --- /dev/null +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonConfigReaderTest.java @@ -0,0 +1,335 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.avaje.config.Config; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +class SeasonConfigReaderTest { + + private final Logger readerLogger = (Logger) LoggerFactory.getLogger(SeasonConfigReader.class); + private final ListAppender logLines = new ListAppender<>(); + + @TempDir + Path worlds; + + private String originalZone; + private SeasonConfigReader reader; + + @BeforeEach + void setUp() { + this.originalZone = Config.get(SeasonSettings.ZONE_KEY); + this.reader = new SeasonConfigReader(this.worlds, new SeasonCalendar()); + this.logLines.start(); + this.readerLogger.addAppender(this.logLines); + } + + @AfterEach + void tearDown() { + this.readerLogger.detachAppender(this.logLines); + Config.asProperties().stringPropertyNames().stream().filter(key -> key.startsWith(SeasonSettings.PREFIX)).forEach(Config::clearProperty); + Config.setProperty(SeasonSettings.ZONE_KEY, this.originalZone); + } + + private void createWorld(String name, boolean withMapFile) throws IOException { + Path directory = Files.createDirectories(this.worlds.resolve(name)); + if (withMapFile) { + Files.writeString(directory.resolve(SeasonSettings.MAP_FILE_NAME), "{}"); + } + } + + private static void configure(String id, String world, String from, String to) { + set(id, SeasonSettings.WORLD_FIELD, world); + set(id, SeasonSettings.FROM_FIELD, from); + set(id, SeasonSettings.TO_FIELD, to); + } + + private static void set(String id, String field, String value) { + Config.setProperty(SeasonSettings.key(id, field), value); + } + + private IllegalStateException startupFailure() { + return Assertions.assertThrows(IllegalStateException.class, this.reader::readAtStartup); + } + + private List warnings() { + return this.logLines.list.stream().filter(line -> line.getLevel() == Level.WARN).toList(); + } + + @DisplayName("The shipped defaults use Europe/Berlin as the zone") + @Test + void shippedDefaultZoneIsBerlin() { + Assertions.assertEquals(ZoneId.of("Europe/Berlin"), this.reader.readAtStartup().zone()); + } + + @DisplayName("Without any season the list is empty") + @Test + void noSeasonsConfigured() { + Assertions.assertEquals(List.of(), this.reader.readAtStartup().seasons()); + } + + @DisplayName("A configured season is read with its window and world") + @Test + void readsAConfiguredSeason() throws IOException { + createWorld("winter", true); + configure("winter", "winter", "2026-12-01T00:00:00", "2027-01-07T00:00:00"); + + Season expected = new Season("winter", "winter", LocalDateTime.parse("2026-12-01T00:00:00"), LocalDateTime.parse("2027-01-07T00:00:00"), true); + Assertions.assertEquals(List.of(expected), this.reader.readAtStartup().seasons()); + } + + @DisplayName("A season is enabled unless switched off") + @Test + void enabledDefaultsToTrue() throws IOException { + createWorld("winter", true); + configure("winter", "winter", "2026-12-01T00:00:00", "2027-01-07T00:00:00"); + + Assertions.assertTrue(this.reader.readAtStartup().seasons().getFirst().enabled()); + } + + @DisplayName("A missing world aborts startup and names seasons..world") + @Test + void missingWorldValueAbortsStartup() { + set("winter", SeasonSettings.FROM_FIELD, "2026-12-01T00:00:00"); + set("winter", SeasonSettings.TO_FIELD, "2027-01-07T00:00:00"); + + Assertions.assertTrue(startupFailure().getMessage().contains("seasons.winter.world")); + } + + @DisplayName("A missing from aborts startup and names seasons..from") + @Test + void missingFromAbortsStartup() throws IOException { + createWorld("winter", true); + set("winter", SeasonSettings.WORLD_FIELD, "winter"); + set("winter", SeasonSettings.TO_FIELD, "2027-01-07T00:00:00"); + + Assertions.assertTrue(startupFailure().getMessage().contains("seasons.winter.from")); + } + + @DisplayName("A missing to aborts startup and names seasons..to") + @Test + void missingToAbortsStartup() throws IOException { + createWorld("winter", true); + set("winter", SeasonSettings.WORLD_FIELD, "winter"); + set("winter", SeasonSettings.FROM_FIELD, "2026-12-01T00:00:00"); + + Assertions.assertTrue(startupFailure().getMessage().contains("seasons.winter.to")); + } + + @DisplayName("An unreadable date aborts startup and names the key and the value") + @Test + void unreadableDateAbortsStartup() throws IOException { + createWorld("winter", true); + configure("winter", "winter", "morgen", "2027-01-07T00:00:00"); + + String message = startupFailure().getMessage(); + + Assertions.assertTrue(message.contains("seasons.winter.from") && message.contains("morgen"), "was: " + message); + } + + @DisplayName("A window that is not from before to aborts startup and names both keys") + @Test + void reversedWindowAbortsStartup() throws IOException { + createWorld("winter", true); + configure("winter", "winter", "2027-01-07T00:00:00", "2026-12-01T00:00:00"); + + String message = startupFailure().getMessage(); + + Assertions.assertTrue(message.contains("seasons.winter.from") && message.contains("seasons.winter.to"), "was: " + message); + } + + @DisplayName("A window with from equal to to aborts startup") + @Test + void emptyWindowAbortsStartup() throws IOException { + createWorld("winter", true); + configure("winter", "winter", "2026-12-01T00:00:00", "2026-12-01T00:00:00"); + + Assertions.assertTrue(startupFailure().getMessage().contains("seasons.winter.from")); + } + + @DisplayName("A world directory that does not exist aborts startup and names seasons..world") + @Test + void missingWorldDirectoryAbortsStartup() { + configure("winter", "winter", "2026-12-01T00:00:00", "2027-01-07T00:00:00"); + + String message = startupFailure().getMessage(); + + Assertions.assertTrue(message.contains("seasons.winter.world") && message.contains("winter"), "was: " + message); + } + + @DisplayName("A world directory without map.json aborts startup and names seasons..world") + @Test + void worldWithoutMapFileAbortsStartup() throws IOException { + createWorld("winter", false); + configure("winter", "winter", "2026-12-01T00:00:00", "2027-01-07T00:00:00"); + + String message = startupFailure().getMessage(); + + Assertions.assertTrue(message.contains("seasons.winter.world") && message.contains(SeasonSettings.MAP_FILE_NAME), "was: " + message); + } + + @DisplayName("A world outside worlds/ aborts startup") + @Test + void worldPathEscapingTheWorldsDirectoryAbortsStartup() { + configure("winter", "../elsewhere", "2026-12-01T00:00:00", "2027-01-07T00:00:00"); + + Assertions.assertTrue(startupFailure().getMessage().contains("seasons.winter.world")); + } + + @DisplayName("An invalid zone aborts startup and names seasons.zone") + @Test + void invalidZoneAbortsStartup() { + Config.setProperty(SeasonSettings.ZONE_KEY, "Mars/Olympus"); + + String message = startupFailure().getMessage(); + + Assertions.assertTrue(message.contains(SeasonSettings.ZONE_KEY) && message.contains("Mars/Olympus"), "was: " + message); + } + + @DisplayName("The reserved id zone aborts startup and names seasons.zone") + @Test + void reservedIdAbortsStartup() { + set(SeasonSettings.RESERVED_ID, SeasonSettings.WORLD_FIELD, "winter"); + + Assertions.assertTrue(startupFailure().getMessage().contains(SeasonSettings.ZONE_KEY)); + } + + @DisplayName("A disabled season is not validated") + @Test + void disabledSeasonIsNotValidated() { + set("winter", SeasonSettings.WORLD_FIELD, "winter"); + set("winter", SeasonSettings.FROM_FIELD, "morgen"); + set("winter", SeasonSettings.ENABLED_FIELD, "false"); + + Assertions.assertEquals(List.of(), this.reader.readAtStartup().seasons()); + } + + @DisplayName("Overlapping seasons log one warning naming both ids and the winner") + @Test + void overlapLogsAWarningWithBothIds() throws IOException { + createWorld("autumn", true); + createWorld("halloween", true); + configure("autumn", "autumn", "2026-10-01T00:00:00", "2026-11-15T00:00:00"); + configure("halloween", "halloween", "2026-10-25T00:00:00", "2026-11-02T00:00:00"); + + this.reader.readAtStartup(); + + Assertions.assertEquals(1, warnings().size(), "got: " + warnings()); + Assertions.assertEquals("Seasons autumn and halloween overlap, autumn wins", warnings().getFirst().getFormattedMessage()); + } + + @DisplayName("Overlapping seasons do not abort startup") + @Test + void overlapDoesNotAbortStartup() throws IOException { + createWorld("autumn", true); + createWorld("halloween", true); + configure("autumn", "autumn", "2026-10-01T00:00:00", "2026-11-15T00:00:00"); + configure("halloween", "halloween", "2026-10-25T00:00:00", "2026-11-02T00:00:00"); + + Assertions.assertEquals(2, this.reader.readAtStartup().seasons().size()); + } + + @DisplayName("Live, a valid configuration is read like at startup") + @Test + void liveReadReturnsAValidConfiguration() throws IOException { + createWorld("winter", true); + configure("winter", "winter", "2026-12-01T00:00:00", "2027-01-07T00:00:00"); + + Optional config = this.reader.readLive(); + + Assertions.assertEquals(List.of("winter"), config.orElseThrow().seasons().stream().map(Season::id).toList()); + } + + @DisplayName("Live, an enabled season without a world is unusable and warns naming seasons..world") + @Test + void liveEnabledSeasonWithoutWorldWarns() { + configure("winter", "winter", "2026-12-01T00:00:00", "2027-01-07T00:00:00"); + + Optional config = this.reader.readLive(); + + Assertions.assertTrue(config.isEmpty(), "the whole configuration is unusable while a season is broken"); + Assertions.assertEquals(1, warnings().size(), "got: " + warnings()); + Assertions.assertTrue(warnings().getFirst().getFormattedMessage().contains("seasons.winter.world")); + } + + @DisplayName("Live, the same broken value warns once, a changed broken value warns again") + @Test + void liveWarningIsLoggedOncePerValue() throws IOException { + createWorld("winter", true); + configure("winter", "winter", "morgen", "2027-01-07T00:00:00"); + + this.reader.readLive(); + this.reader.readLive(); + Assertions.assertEquals(1, warnings().size(), "the same value must warn once"); + + set("winter", SeasonSettings.FROM_FIELD, "übermorgen"); + this.reader.readLive(); + Assertions.assertEquals(2, warnings().size(), "a different broken value must warn again"); + } + + @DisplayName("Live, a broken value that was fixed and broken again warns again") + @Test + void liveWarningRepeatsAfterTheValueWasFixed() throws IOException { + createWorld("winter", true); + configure("winter", "winter", "morgen", "2027-01-07T00:00:00"); + this.reader.readLive(); + + set("winter", SeasonSettings.FROM_FIELD, "2026-12-01T00:00:00"); + this.reader.readLive(); + set("winter", SeasonSettings.FROM_FIELD, "morgen"); + this.reader.readLive(); + + Assertions.assertEquals(2, warnings().size(), "got: " + warnings()); + } + + @DisplayName("Live, an invalid zone is unusable and warns naming seasons.zone") + @Test + void liveInvalidZoneWarns() { + Config.setProperty(SeasonSettings.ZONE_KEY, "Mars/Olympus"); + + Optional config = this.reader.readLive(); + + Assertions.assertTrue(config.isEmpty()); + Assertions.assertTrue(warnings().getFirst().getFormattedMessage().contains(SeasonSettings.ZONE_KEY)); + } + + @DisplayName("Live, a disabled broken season is ignored without a warning") + @Test + void liveDisabledBrokenSeasonIsSilent() { + set("winter", SeasonSettings.WORLD_FIELD, "winter"); + set("winter", SeasonSettings.ENABLED_FIELD, "false"); + + Assertions.assertTrue(this.reader.readLive().isPresent()); + Assertions.assertTrue(warnings().isEmpty(), "got: " + warnings()); + } +} From c76d16b94016d924d4bf9ad7d89e108af5cc96b5 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:31:17 +0200 Subject: [PATCH 05/17] feat(season): decide restarts from started and desired world Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/feature/season/RestartPolicy.java | 47 ++++++++++++++++ .../feature/season/RestartPolicyTest.java | 56 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/RestartPolicy.java create mode 100644 features/season/src/test/java/net/onelitefeather/titan/feature/season/RestartPolicyTest.java diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/RestartPolicy.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/RestartPolicy.java new file mode 100644 index 00000000..9167972b --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/RestartPolicy.java @@ -0,0 +1,47 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.util.Optional; + +/** + * Decides what a mismatch between the started and the desired world means for the running lobby. + * Pure: the lobby never switches worlds live, it only restarts while empty. + */ +final class RestartPolicy { + + enum Decision { + /** Both worlds match: nothing is pending. */ + NONE, + /** They differ, but players are online, so the lobby waits. */ + PENDING, + /** + * They differ and nobody is online: stop so the supervisor restarts into the desired world. + */ + STOP + } + + /** + * @param startedWorld the world the lobby started with; empty for the default world + * @param desiredWorld the world it should run in now; empty for the default world + */ + Decision decide(Optional startedWorld, Optional desiredWorld, int onlinePlayers) { + if (startedWorld.equals(desiredWorld)) { + return Decision.NONE; + } + return onlinePlayers > 0 ? Decision.PENDING : Decision.STOP; + } +} diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/RestartPolicyTest.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/RestartPolicyTest.java new file mode 100644 index 00000000..3c062de0 --- /dev/null +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/RestartPolicyTest.java @@ -0,0 +1,56 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RestartPolicyTest { + + private final RestartPolicy policy = new RestartPolicy(); + + @DisplayName("The same world needs no restart, even with an empty lobby") + @Test + void sameWorldNeedsNoRestart() { + Assertions.assertEquals(RestartPolicy.Decision.NONE, this.policy.decide(Optional.of("winter"), Optional.of("winter"), 0)); + } + + @DisplayName("The default world on both sides needs no restart") + @Test + void defaultWorldOnBothSidesNeedsNoRestart() { + Assertions.assertEquals(RestartPolicy.Decision.NONE, this.policy.decide(Optional.empty(), Optional.empty(), 0)); + } + + @DisplayName("A different world with players online is pending") + @Test + void differentWorldWithPlayersIsPending() { + Assertions.assertEquals(RestartPolicy.Decision.PENDING, this.policy.decide(Optional.empty(), Optional.of("winter"), 1)); + } + + @DisplayName("A different world with an empty lobby stops") + @Test + void differentWorldWithEmptyLobbyStops() { + Assertions.assertEquals(RestartPolicy.Decision.STOP, this.policy.decide(Optional.empty(), Optional.of("winter"), 0)); + } + + @DisplayName("Leaving a season for the default world also restarts") + @Test + void leavingASeasonRestartsIntoTheDefaultWorld() { + Assertions.assertEquals(RestartPolicy.Decision.STOP, this.policy.decide(Optional.of("winter"), Optional.empty(), 0)); + } +} From a700d88b397484d9612900021c74d0af14c59e7d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:31:17 +0200 Subject: [PATCH 06/17] feat(season): restart the lobby into the season world when empty Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../feature/season/CalendarStartedWorld.java | 46 +++ .../season/ConnectionOnlinePlayers.java | 44 +++ .../feature/season/MinecraftServerStop.java | 33 ++ .../titan/feature/season/OnlinePlayers.java | 23 ++ .../titan/feature/season/SeasonModule.java | 136 +++++++ .../titan/feature/season/SeasonSchedule.java | 71 ++++ .../titan/feature/season/ServerStop.java | 23 ++ .../titan/feature/season/StartedWorld.java | 29 ++ .../titan/feature/season/AdjustableClock.java | 59 +++ .../season/ColumnArchitectureTest.java | 39 ++ .../feature/season/SeasonModuleTest.java | 362 ++++++++++++++++++ 11 files changed, 865 insertions(+) create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/CalendarStartedWorld.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/ConnectionOnlinePlayers.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/MinecraftServerStop.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/OnlinePlayers.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonModule.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonSchedule.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/ServerStop.java create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/StartedWorld.java create mode 100644 features/season/src/test/java/net/onelitefeather/titan/feature/season/AdjustableClock.java create mode 100644 features/season/src/test/java/net/onelitefeather/titan/feature/season/ColumnArchitectureTest.java create mode 100644 features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonModuleTest.java diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/CalendarStartedWorld.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/CalendarStartedWorld.java new file mode 100644 index 00000000..6669ed90 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/CalendarStartedWorld.java @@ -0,0 +1,46 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import io.avaje.inject.Secondary; +import jakarta.inject.Singleton; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default {@link StartedWorld}: evaluates the calendar once when the column starts. Also the + * place where an invalid enabled season aborts startup. + */ +@Singleton +@Secondary +final class CalendarStartedWorld implements StartedWorld { + + private static final Logger LOGGER = LoggerFactory.getLogger(CalendarStartedWorld.class); + + private final Optional worldName; + + CalendarStartedWorld(SeasonSchedule schedule) { + Optional season = schedule.atStartup(); + this.worldName = season.map(Season::world); + season.ifPresentOrElse(active -> LOGGER.info("Lobby world {} (season {})", active.world(), active.id()), () -> LOGGER.info("Lobby world is the default world (no active season)")); + } + + @Override + public Optional worldName() { + return this.worldName; + } +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/ConnectionOnlinePlayers.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/ConnectionOnlinePlayers.java new file mode 100644 index 00000000..678a2b7e --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/ConnectionOnlinePlayers.java @@ -0,0 +1,44 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.util.function.Supplier; +import net.minestom.server.MinecraftServer; +import net.minestom.server.network.ConnectionManager; + +/** Default {@link OnlinePlayers}: the players in the play state. */ +@Singleton +final class ConnectionOnlinePlayers implements OnlinePlayers { + + private final Supplier connections; + + @Inject + ConnectionOnlinePlayers() { + // Looked up per call: the process may not exist yet when this bean is built. + this(MinecraftServer::getConnectionManager); + } + + ConnectionOnlinePlayers(Supplier connections) { + this.connections = connections; + } + + @Override + public int count() { + return this.connections.get().getOnlinePlayerCount(); + } +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/MinecraftServerStop.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/MinecraftServerStop.java new file mode 100644 index 00000000..04b895c6 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/MinecraftServerStop.java @@ -0,0 +1,33 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import jakarta.inject.Singleton; +import net.minestom.server.MinecraftServer; + +/** Default {@link ServerStop}, the same way {@code /stop} does it. */ +@Singleton +final class MinecraftServerStop implements ServerStop { + + @Override + public void stop() { + // Runs on a separate thread: stopCleanly() waits for the tick thread this is called from. + Thread.ofPlatform().name("titan-stop").start(() -> { + MinecraftServer.stopCleanly(); + System.exit(0); + }); + } +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/OnlinePlayers.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/OnlinePlayers.java new file mode 100644 index 00000000..f52ee7c0 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/OnlinePlayers.java @@ -0,0 +1,23 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +/** How many players are currently online. */ +@FunctionalInterface +interface OnlinePlayers { + + int count(); +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonModule.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonModule.java new file mode 100644 index 00000000..289e60b2 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonModule.java @@ -0,0 +1,136 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import io.avaje.inject.PostConstruct; +import io.avaje.inject.PreDestroy; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.time.Clock; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import net.minestom.server.ServerFlag; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.timer.Scheduler; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import net.onelitefeather.titan.core.module.FeatureNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Compares the world the lobby started with to the world the calendar wants now, once a minute + * and after every disconnect. On a mismatch a restart is pending; once the lobby is empty it + * stops, and the supervisor starts it in the desired world. The world is never switched live. + */ +@Singleton +final class SeasonModule { + + // A tick count, not TaskSchedule.minutes(): a duration schedule runs on the wall clock, out of reach of env.tick(). + static final int CHECK_INTERVAL_TICKS = ServerFlag.SERVER_TICKS_PER_SECOND * 60; + static final int EVENT_PRIORITY = 800; + + private static final Logger LOGGER = LoggerFactory.getLogger(SeasonModule.class); + private static final String ID = "season"; + + private final EventNode titan; + private final Scheduler scheduler; + private final Clock clock; + private final SeasonSchedule schedule; + private final StartedWorld startedWorld; + private final OnlinePlayers onlinePlayers; + private final ServerStop serverStop; + private final RestartPolicy policy = new RestartPolicy(); + private final AtomicBoolean stopRequested = new AtomicBoolean(); + // Written and read on the tick thread only; volatile for the tests that read it from outside. + private volatile Instant restartPendingSince; + private Optional started = Optional.empty(); + private FeatureNode node; + private Task task; + + SeasonModule(@Named(FeatureNode.TITAN_NODE) EventNode titan, Scheduler scheduler, Clock clock, SeasonSchedule schedule, StartedWorld startedWorld, OnlinePlayers onlinePlayers, ServerStop serverStop) { + this.titan = Objects.requireNonNull(titan, "titan"); + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.schedule = Objects.requireNonNull(schedule, "schedule"); + this.startedWorld = Objects.requireNonNull(startedWorld, "startedWorld"); + this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); + this.serverStop = Objects.requireNonNull(serverStop, "serverStop"); + } + + @PostConstruct + void start() { + this.started = this.startedWorld.worldName(); + // Checked next tick, not inside the event, so the count no longer includes the leaving player. + this.node = FeatureNode.attach(this.titan, ID, EVENT_PRIORITY).on(PlayerDisconnectEvent.class, event -> this.scheduler.scheduleNextTick(this::check)); + TaskSchedule interval = TaskSchedule.tick(CHECK_INTERVAL_TICKS); + this.task = this.scheduler.scheduleTask(this::check, interval, interval); + } + + @PreDestroy + void stop() { + this.task.cancel(); + this.node.close(); + } + + private void check() { + switch (this.schedule.now()) { + case SeasonSchedule.Desired.Chosen chosen -> apply(chosen.season()); + case SeasonSchedule.Desired.Unresolvable ignored -> { + // Already warned about by the reader; keep the current state and do nothing. + } + } + } + + private void apply(Optional desiredSeason) { + RestartPolicy.Decision decision = this.policy.decide(this.started, desiredSeason.map(Season::world), this.onlinePlayers.count()); + switch (decision) { + case NONE -> clearPending(); + case PENDING -> markPending(desiredSeason); + case STOP -> { + markPending(desiredSeason); + requestStop(); + } + } + } + + private void markPending(Optional desiredSeason) { + if (this.restartPendingSince != null) { + return; + } + this.restartPendingSince = this.clock.instant(); + desiredSeason.ifPresentOrElse(season -> LOGGER.info("Restart for season {} pending since {}", season.id(), this.restartPendingSince), () -> LOGGER.info("Restart to the default world pending since {}", this.restartPendingSince)); + } + + private void clearPending() { + if (this.restartPendingSince != null) { + this.restartPendingSince = null; + LOGGER.info("Restart no longer needed"); + } + } + + private void requestStop() { + // Both triggers can fire before the server is down; stop only once. + if (this.stopRequested.compareAndSet(false, true)) { + LOGGER.info("Stopping lobby for season change"); + this.serverStop.stop(); + } + } +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonSchedule.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonSchedule.java new file mode 100644 index 00000000..e7790352 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonSchedule.java @@ -0,0 +1,71 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.nio.file.Path; +import java.time.Clock; +import java.util.Objects; +import java.util.Optional; + +/** + * Answers "which season's world should the lobby run in now" from the configuration and the + * clock. Plain and free of server state, so anything that needs the answer, at startup or on a + * later check, can share it. + */ +@Singleton +final class SeasonSchedule { + + /** The outcome of a live check. */ + sealed interface Desired { + + /** The configuration is usable; empty means the default world. */ + record Chosen(Optional season) implements Desired { + } + + /** The configuration is currently invalid: it must trigger neither a restart nor a stop. */ + record Unresolvable() implements Desired { + } + } + + private final SeasonConfigReader reader; + private final SeasonCalendar calendar; + private final Clock clock; + + @Inject + SeasonSchedule(Clock clock) { + this(clock, Path.of(SeasonSettings.WORLDS_DIRECTORY)); + } + + SeasonSchedule(Clock clock, Path worldsDirectory) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.calendar = new SeasonCalendar(); + this.reader = new SeasonConfigReader(worldsDirectory, this.calendar); + } + + /** + * @throws IllegalStateException if an enabled season is invalid; startup must abort + */ + Optional atStartup() { + SeasonConfigReader.SeasonConfig config = this.reader.readAtStartup(); + return this.calendar.activeAt(config.seasons(), this.clock.instant(), config.zone()); + } + + Desired now() { + return this.reader.readLive().map(config -> new Desired.Chosen(this.calendar.activeAt(config.seasons(), this.clock.instant(), config.zone()))).orElseGet(Desired.Unresolvable::new); + } +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/ServerStop.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/ServerStop.java new file mode 100644 index 00000000..394a5600 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/ServerStop.java @@ -0,0 +1,23 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +/** Stops the server cleanly so the service supervisor starts it again. */ +@FunctionalInterface +interface ServerStop { + + void stop(); +} diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/StartedWorld.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/StartedWorld.java new file mode 100644 index 00000000..c52bea8b --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/StartedWorld.java @@ -0,0 +1,29 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.util.Optional; + +/** + * The world this lobby was started with. A seam so the choice made when the world was loaded can + * be supplied as is, instead of being computed a second time. + */ +@FunctionalInterface +interface StartedWorld { + + /** @return the world directory name; empty for the default world */ + Optional worldName(); +} diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/AdjustableClock.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/AdjustableClock.java new file mode 100644 index 00000000..6954b78f --- /dev/null +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/AdjustableClock.java @@ -0,0 +1,59 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +/** + * A test-only {@link Clock} whose {@link #instant()} is set by the test, so a test can drive + * {@link SeasonModule} without wall-clock time or {@link Thread#sleep(long)}. + */ +final class AdjustableClock extends Clock { + + private final ZoneId zone; + private Instant instant; + + AdjustableClock(Instant instant, ZoneId zone) { + this.instant = instant; + this.zone = zone; + } + + void advance(Duration duration) { + this.instant = this.instant.plus(duration); + } + + void set(Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return this.zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new AdjustableClock(this.instant, zone); + } + + @Override + public Instant instant() { + return this.instant; + } +} diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/ColumnArchitectureTest.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/ColumnArchitectureTest.java new file mode 100644 index 00000000..a35d8baf --- /dev/null +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/ColumnArchitectureTest.java @@ -0,0 +1,39 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import net.onelitefeather.titan.core.testfixtures.architecture.ColumnArchitectureRules; + +/** Applies the architecture rules every column shares to this column's own package. */ +@AnalyzeClasses(packages = "net.onelitefeather.titan.feature.season", importOptions = ImportOption.DoNotIncludeTests.class) +class ColumnArchitectureTest { + + @ArchTest + static final ArchRule featuresRegisterListenersOnlyThroughFeatureNode = ColumnArchitectureRules.FEATURES_REGISTER_LISTENERS_ONLY_THROUGH_FEATURE_NODE; + + @ArchTest + static final ArchRule classesWithPostConstructAreSingleton = ColumnArchitectureRules.CLASSES_WITH_POST_CONSTRUCT_ARE_SINGLETON; + + @ArchTest + static final ArchRule featureModulesDoNotUseBeanScope = ColumnArchitectureRules.FEATURE_MODULES_DO_NOT_USE_BEAN_SCOPE; + + ColumnArchitectureTest() { + } +} diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonModuleTest.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonModuleTest.java new file mode 100644 index 00000000..faa407ac --- /dev/null +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonModuleTest.java @@ -0,0 +1,362 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.avaje.config.Config; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.core.testfixtures.TestTitanNode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +@ExtendWith(MicrotusExtension.class) +class SeasonModuleTest { + + private static final Instant BEFORE_WINTER = Instant.parse("2026-11-15T12:00:00Z"); + private static final Instant IN_WINTER = Instant.parse("2026-12-10T12:00:00Z"); + + private final Logger moduleLogger = (Logger) LoggerFactory.getLogger(SeasonModule.class); + private final ListAppender moduleLines = new ListAppender<>(); + private final Logger readerLogger = (Logger) LoggerFactory.getLogger(SeasonConfigReader.class); + private final ListAppender readerLines = new ListAppender<>(); + + @TempDir + Path worlds; + + private String originalZone; + private Env env; + private TestTitanNode titan; + private AdjustableClock clock; + private CountingServerStop serverStop; + private int fakeOnline; + private SeasonModule module; + + /** Counts calls instead of stopping anything. */ + private static final class CountingServerStop implements ServerStop { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public void stop() { + this.calls.incrementAndGet(); + } + + int calls() { + return this.calls.get(); + } + } + + @BeforeEach + void setUp(Env env) throws IOException { + this.originalZone = Config.get(SeasonSettings.ZONE_KEY); + this.env = env; + this.titan = TestTitanNode.attach(env); + this.clock = new AdjustableClock(BEFORE_WINTER, ZoneOffset.UTC); + this.serverStop = new CountingServerStop(); + this.fakeOnline = 0; + this.moduleLines.start(); + this.moduleLogger.addAppender(this.moduleLines); + this.readerLines.start(); + this.readerLogger.addAppender(this.readerLines); + createWorld("winter"); + Config.setProperty("seasons.winter.world", "winter"); + Config.setProperty("seasons.winter.from", "2026-12-01T00:00:00"); + Config.setProperty("seasons.winter.to", "2027-01-07T00:00:00"); + } + + @AfterEach + void tearDown() { + if (this.module != null) { + this.module.stop(); + } + this.titan.close(); + this.moduleLogger.detachAppender(this.moduleLines); + this.readerLogger.detachAppender(this.readerLines); + Config.asProperties().stringPropertyNames().stream().filter(key -> key.startsWith(SeasonSettings.PREFIX)).forEach(Config::clearProperty); + Config.setProperty(SeasonSettings.ZONE_KEY, this.originalZone); + } + + private void createWorld(String name) throws IOException { + Files.writeString(Files.createDirectories(this.worlds.resolve(name)).resolve(SeasonSettings.MAP_FILE_NAME), "{}"); + } + + private void startModule(Optional startedWorld, OnlinePlayers players) { + SeasonSchedule schedule = new SeasonSchedule(this.clock, this.worlds); + this.module = new SeasonModule(this.titan.node(), this.env.process().scheduler(), this.clock, schedule, () -> startedWorld, players, this.serverStop); + this.module.start(); + } + + /** Starts in the default world with a settable, fake player count. */ + private void startInDefaultWorld() { + startModule(Optional.empty(), () -> this.fakeOnline); + } + + /** + * The order {@code PlayerConnection#disconnect()} uses in production: the connection manager + * forgets the player first, and the player entity is removed, firing the event, next tick. + * Env connections only flag themselves offline, so the steps are spelled out here. + */ + private void disconnect(Player player) { + this.env.process().connection().removePlayer(player.getPlayerConnection()); + player.scheduleNextTick(Entity::remove); + } + + private void tickMinutes(int minutes) { + for (int i = 0; i < minutes * SeasonModule.CHECK_INTERVAL_TICKS; i++) { + this.env.tick(); + } + } + + private List infoMessages() { + return this.moduleLines.list.stream().filter(line -> line.getLevel() == Level.INFO).map(ILoggingEvent::getFormattedMessage).toList(); + } + + private List readerWarnings() { + return this.readerLines.list.stream().filter(line -> line.getLevel() == Level.WARN).map(ILoggingEvent::getFormattedMessage).toList(); + } + + @DisplayName("A window that has begun makes a restart pending after a minute, logged once") + @Test + void windowBeginMakesARestartPendingAndLogsOnce() { + this.fakeOnline = 1; + startInDefaultWorld(); + + this.clock.set(IN_WINTER); + tickMinutes(1); + + Assertions.assertEquals(1, infoMessages().size(), "got: " + infoMessages()); + Assertions.assertEquals("Restart for season winter pending since " + IN_WINTER, infoMessages().getFirst()); + } + + @DisplayName("A pending restart is not logged again on later checks") + @Test + void pendingRestartIsNotLoggedAgain() { + this.fakeOnline = 1; + startInDefaultWorld(); + this.clock.set(IN_WINTER); + + tickMinutes(4); + + Assertions.assertEquals(1, infoMessages().size(), "got: " + infoMessages()); + } + + @DisplayName("A season ending makes a restart into the default world pending") + @Test + void windowEndMakesARestartIntoTheDefaultWorldPending() { + this.clock.set(IN_WINTER); + this.fakeOnline = 1; + startModule(Optional.of("winter"), () -> this.fakeOnline); + + this.clock.set(Instant.parse("2027-01-08T12:00:00Z")); + tickMinutes(1); + + Assertions.assertEquals(List.of("Restart to the default world pending since 2027-01-08T12:00:00Z"), infoMessages()); + } + + @DisplayName("Switching the season off again clears the pending restart and never stops") + @Test + void resettingTheSwitchClearsThePendingRestart() { + this.clock.set(IN_WINTER); + this.fakeOnline = 1; + startInDefaultWorld(); + tickMinutes(1); + + Config.setProperty("seasons.winter.enabled", "false"); + tickMinutes(1); + this.fakeOnline = 0; + tickMinutes(1); + + Assertions.assertTrue(infoMessages().contains("Restart no longer needed"), "got: " + infoMessages()); + Assertions.assertEquals(0, this.serverStop.calls(), "the lobby must not stop after the restart was withdrawn"); + } + + @DisplayName("An empty lobby stops at the minute check when a restart is due") + @Test + void emptyLobbyStopsAtTheMinuteCheck() { + startInDefaultWorld(); + this.clock.set(IN_WINTER); + + tickMinutes(1); + + Assertions.assertEquals(1, this.serverStop.calls()); + Assertions.assertTrue(infoMessages().contains("Stopping lobby for season change"), "got: " + infoMessages()); + } + + @DisplayName("The lobby does not stop while a player is online, however many checks pass") + @Test + void occupiedLobbyNeverStops() { + this.fakeOnline = 1; + startInDefaultWorld(); + this.clock.set(IN_WINTER); + + tickMinutes(5); + + Assertions.assertEquals(0, this.serverStop.calls()); + } + + @DisplayName("At most one stop is requested however many checks find the lobby empty") + @Test + void stopIsRequestedAtMostOnce() { + startInDefaultWorld(); + this.clock.set(IN_WINTER); + + tickMinutes(3); + + Assertions.assertEquals(1, this.serverStop.calls()); + } + + @DisplayName("An empty lobby with nothing pending does not stop") + @Test + void emptyLobbyWithNothingPendingDoesNotStop() { + startInDefaultWorld(); + + tickMinutes(3); + + Assertions.assertEquals(0, this.serverStop.calls()); + Assertions.assertEquals(List.of(), infoMessages()); + } + + @DisplayName("Running in the desired world after a restart schedules nothing") + @Test + void desiredWorldAfterRestartSchedulesNothing() { + this.clock.set(IN_WINTER); + startModule(Optional.of("winter"), () -> 0); + + tickMinutes(3); + + Assertions.assertEquals(0, this.serverStop.calls()); + Assertions.assertEquals(List.of(), infoMessages()); + } + + @DisplayName("The last player leaving stops the lobby on the next tick, without waiting for the minute check") + @Test + void lastPlayerLeavingStopsOnTheNextTick() { + Instance lobby = this.env.createFlatInstance(); + Player player = this.env.createPlayer(lobby); + startModule(Optional.empty(), new ConnectionOnlinePlayers(this.env.process()::connection)); + this.clock.set(IN_WINTER); + tickMinutes(1); + Assertions.assertEquals(0, this.serverStop.calls(), "the player is still online"); + + disconnect(player); + this.env.tick(); + this.env.tick(); + + Assertions.assertEquals(1, this.serverStop.calls()); + } + + @DisplayName("One of two players leaving does not stop the lobby") + @Test + void oneOfTwoPlayersLeavingDoesNotStop() { + Instance lobby = this.env.createFlatInstance(); + Player leaving = this.env.createPlayer(lobby); + this.env.createPlayer(lobby); + startModule(Optional.empty(), new ConnectionOnlinePlayers(this.env.process()::connection)); + this.clock.set(IN_WINTER); + tickMinutes(1); + + disconnect(leaving); + this.env.tick(); + this.env.tick(); + + Assertions.assertEquals(0, this.serverStop.calls()); + } + + @DisplayName("At the disconnect event the leaving player is already gone from the online count") + @Test + void leavingPlayerNoLongerCountsAtTheDisconnectEvent() { + Instance lobby = this.env.createFlatInstance(); + Player player = this.env.createPlayer(lobby); + ConnectionOnlinePlayers real = new ConnectionOnlinePlayers(this.env.process()::connection); + AtomicInteger countAtEvent = new AtomicInteger(-1); + this.env.process().eventHandler().addListener(net.minestom.server.event.player.PlayerDisconnectEvent.class, event -> countAtEvent.set(real.count())); + Assertions.assertEquals(1, real.count(), "precondition: the player counts while online"); + + disconnect(player); + for (int i = 0; i < 5 && countAtEvent.get() < 0; i++) { + this.env.tick(); + } + + Assertions.assertEquals(0, countAtEvent.get(), "Minestom removes the player from the connection manager before it fires the event"); + Assertions.assertEquals(0, real.count(), "and it stays gone on the next tick"); + } + + @DisplayName("A season enabled live without a world warns naming the key and schedules nothing") + @Test + void liveEnabledSeasonWithoutWorldWarnsAndSchedulesNothing() { + Config.setProperty("seasons.spring.world", "spring"); + Config.setProperty("seasons.spring.from", "2026-12-01T00:00:00"); + Config.setProperty("seasons.spring.to", "2027-01-07T00:00:00"); + Config.setProperty("seasons.spring.enabled", "false"); + startInDefaultWorld(); + this.clock.set(IN_WINTER); + + Config.setProperty("seasons.spring.enabled", "true"); + tickMinutes(2); + + Assertions.assertTrue(readerWarnings().stream().anyMatch(message -> message.contains("seasons.spring.world")), "got: " + readerWarnings()); + Assertions.assertEquals(0, this.serverStop.calls(), "an invalid configuration must never stop the lobby"); + Assertions.assertEquals(List.of(), infoMessages()); + } + + @DisplayName("A value that turns invalid live warns and neither schedules nor stops, even for the running season") + @Test + void liveInvalidValueSchedulesNothing() { + this.clock.set(IN_WINTER); + startModule(Optional.of("winter"), () -> 0); + + Config.setProperty("seasons.winter.from", "morgen"); + tickMinutes(2); + + Assertions.assertTrue(readerWarnings().stream().anyMatch(message -> message.contains("seasons.winter.from")), "got: " + readerWarnings()); + Assertions.assertEquals(0, this.serverStop.calls()); + Assertions.assertEquals(List.of(), infoMessages()); + } + + @DisplayName("Once stopped, the module checks nothing any more") + @Test + void stoppedModuleChecksNothing() { + startInDefaultWorld(); + this.module.stop(); + this.clock.set(IN_WINTER); + + tickMinutes(2); + + Assertions.assertEquals(0, this.serverStop.calls()); + this.module = null; + } +} From f09873f470f76cd1c084d364c8c8b02845e02672 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:37:27 +0200 Subject: [PATCH 07/17] docs(openspec): record world choice spike result for seasonal-lobby-world Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../changes/seasonal-lobby-world/design.md | 19 ++++++++----------- .../specs/lobby-seasons/spec.md | 2 +- .../changes/seasonal-lobby-world/tasks.md | 12 ++++++------ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/openspec/changes/seasonal-lobby-world/design.md b/openspec/changes/seasonal-lobby-world/design.md index 9a3642bd..4d520f39 100644 --- a/openspec/changes/seasonal-lobby-world/design.md +++ b/openspec/changes/seasonal-lobby-world/design.md @@ -57,7 +57,7 @@ Kein Zugriff auf `daytime.zone`: Eine Column importiert keine andere, und das Le Die Id `zone` ist reserviert (kollidiert mit `seasons.zone`) und bricht den Start ab. SOLID: SRP (Kalender, Leser, Politik getrennt), DIP. Test: Unit für `SeasonCalendar`, `SeasonConfigReader`, `RestartPolicy`; Integration für `SeasonModule` (D5). -### D2: Weltwahl beim Start über `LobbyWorldChoice` (Optional-Bean) — Spike nötig +### D2: Weltwahl beim Start über `LobbyWorldChoice` (ServiceLoader) `core` bekommt @@ -67,14 +67,11 @@ public interface LobbyWorldChoice { } ``` -`PlatformBeans.mapProvider(InstanceContainer, Optional)` reicht `choice.flatMap(LobbyWorldChoice::worldName)` an `MapProvider.create(path, instance, worldName)` und `new MapPool(path, filter, worldName)`. Ein übergebener Name gilt immer (auch bei genau einer Welt) und wirft bei Fehlen mit klarem Text; `Optional.empty()` verhält sich wie heute. Das Ergebnis wird einmal beim Start berechnet und gespeichert: `SeasonModule` liest denselben Wert als „gestartete Welt“, sodass beide nie auseinanderlaufen. Die Auswahl nutzt `Clock` (Bean) und `Config`. +`PlatformBeans.mapProvider(InstanceContainer)` löst die Wahl über `ServiceLoader.load(LobbyWorldChoice.class).findFirst().flatMap(LobbyWorldChoice::worldName)` auf und reicht das Ergebnis an `MapProvider.create(path, instance, worldName)` und `new MapPool(path, filter, worldName)`. Ein übergebener Name gilt immer (auch bei genau einer Welt) und wirft bei Fehlen mit klarem Text; `Optional.empty()` verhält sich wie heute. `features/season` meldet `SeasonWorldChoice` (öffentlicher Konstruktor ohne Argumente, `Clock.systemUTC()` und `Config`, teilt `SeasonSchedule`/`SeasonConfigReader` mit dem Modul) über `META-INF/services` an; `shadowJar` führt Service-Dateien bereits zusammen (`mergeServiceFiles()`). Das ist dasselbe Muster wie `ServerBootstraps.select` in `optional-extensions-bootstrap` D2. -**Risiko / Spike (vor Task 2.x, Ergebnis in D2 nachtragen):** `runtime` deklariert nur `provides` und wird im Modul-Graph vor jeder Column gebaut, die Plattform-Beans aus `runtime` per `requires` verlangt. `SeasonModule` verlangt `Scheduler`/`Clock`/`EventNode` aus `runtime`; `mapProvider(...)` in `runtime` verlangt `Optional` aus der Column. Ein `requires` von `runtime` auf `LobbyWorldChoice` wäre ein Zyklus auf Modulebene und würde `local` (ohne Column) brechen. Ohne `requires` sortiert Avaje `runtime` zuerst; dann ist der Optional zum Zeitpunkt von `mapProvider(...)` leer, ohne Fehler, und die Saison-Welt würde nie geladen. Zu prüfen: -1. Ob die generierte `PlatformBeans`-Fabrik `builder.getOptional(...)` nutzt und ob der Aufruf die Beans anderer Module sieht, die später gebaut werden (erwartet: nein). -2. Ob `@InjectModule`-Reihenfolge (`requires`/`provides`, `Provider`) die Reihenfolge so ändern kann, dass die Column-Bean vor `mapProvider` existiert, ohne dass `runtime` dafür eine harte Abhängigkeit deklariert. Da die Column `Scheduler`/`Clock` aus `runtime` braucht, ist das zirkulär. - -Vorbenannter Ausweichweg, falls 1. oder 2. scheitert (Abweichung von der Entscheidung „Optional-Bean“, deshalb hier ausdrücklich): `LobbyWorldChoice` wird per `java.util.ServiceLoader` aufgelöst, nicht über den Bean-Scope. `features/season` meldet `SeasonWorldChoice` (öffentlicher Konstruktor ohne Argumente, nutzt `Clock.systemUTC()` und `Config`, teilt `SeasonCalendar`/`SeasonConfigReader` mit dem Modul) über `META-INF/services` an; `PlatformBeans` ruft `ServiceLoader.load(LobbyWorldChoice.class).findFirst()`. Das ist dasselbe Muster wie `ServerBootstraps.select` in `optional-extensions-bootstrap` D2, und `shadowJar` führt Service-Dateien bereits zusammen (`mergeServiceFiles()`). Es gibt keinen statischen Zustand zwischen beiden: `SeasonModule` berechnet die Wahl beim Start selbst neu; liegen beide Berechnungen um eine Fenstergrenze, ist die nächste Minuten-Prüfung ein Neustart, der sich selbst heilt. -Test: Ein Integrationstest in `apps/cloudnet` baut den echten `BeanScope` (mit `Instance`-Bean aus Cyano-Env) und assertet, dass `MapProvider` bei aktiver Saison die Saison-Welt in einem `@TempDir`-`worlds/` lädt und ohne Saison die Standardwelt. Das ist der einzige Test, der den Spike absichert; ein Unit-Test mit Fakes würde das Verdrahtungsproblem nicht finden. +**Spike-Ergebnis:** Eine `LobbyWorldChoice`-Bean aus der Saison-Column, die `Scheduler`/`Clock`/`EventNode` aus `runtime` verlangt, ist beim Bau von `mapProvider(...)` in `runtime` immer leer, weil Avaje den Modul-Deadlock zugunsten von `runtime` auflöst. Sie funktioniert nur aus einem eigenen Gradle-Modul ohne `requires`, und das erzwänge Column-zu-Column-Importe, die `lobby-modules` verbietet. Deshalb ist der `ServiceLoader` der gewählte Weg und kein Ausweichweg; eine Optional-Bean gibt es nicht. +Es gibt keinen statischen Zustand zwischen Wahl und Modul: `SeasonModule` berechnet die gestartete Welt beim Start selbst neu (`CalendarStartedWorld`); liegen beide Berechnungen um eine Fenstergrenze, ist die nächste Prüfung ein Neustart, der sich selbst heilt. +Test: Unit für die Auflösung in `PlatformBeans` (mit und ohne Wahl) und für `SeasonWorldChoice`; ein Integrationstest mit echtem `BeanScope` in `apps/cloudnet` (Task 4.3) sichert die Verdrahtung. SOLID: DIP (`runtime` kennt nur die Schnittstelle), OCP (weitere Wahlquellen docken an). ### D3: Zeitzone, Überlappung, Gültigkeit (Annahmen, vom Nutzer noch nicht bestätigt) @@ -95,7 +92,7 @@ Test: Unit (`SeasonCalendarTest`, `SeasonConfigReaderTest` mit `@TempDir`-`world - Abweichung und `onlinePlayers == 0` → `STOP`. `SeasonModule` merkt sich nur `restartPendingSince` (`Instant`, `volatile`). Beim Übergang `NONE → PENDING/STOP` loggt es einmal INFO `Restart for season {} pending since {}` (bzw. für die Standardwelt `Restart to the default world pending since {}`), beim Übergang zurück INFO `Restart no longer needed`. Jede Prüfung liest die gewünschte Welt neu (D3, live). -Auslöser: (a) wiederkehrender Scheduler-Task, `TaskSchedule.minutes(1)` (Minestom-Scheduler statt eigenem Timer, damit er mit dem Server endet, wie bei `daytime`); (b) `PlayerDisconnectEvent` am `titan`-Node. Beim Disconnect zählt der Spieler noch als online; deshalb plant der Listener die Prüfung mit `scheduler.scheduleNextTick(...)`, statt sofort zu zählen. Ob Minestom den Spieler bis dahin aus dem `ConnectionManager` entfernt hat, prüft der erste Integrationstest ab (rot, wenn nicht; Ausweg: den ausscheidenden Spieler aus der Zählung ausnehmen). +Auslöser: (a) wiederkehrender Scheduler-Task alle 1200 Ticks (`TaskSchedule.tick(1200)`, nicht `minutes(1)`: Dauer-Schedules laufen nach der Wanduhr und lassen sich nicht mit `env.tick()` treiben; Minestom-Scheduler statt eigenem Timer, damit er mit dem Server endet, wie bei `daytime`); (b) `PlayerDisconnectEvent` am `titan`-Node. Befund: Beim `PlayerDisconnectEvent` ist der ausscheidende Spieler bereits aus dem `ConnectionManager` entfernt. Der Listener plant die Prüfung trotzdem mit `scheduler.scheduleNextTick(...)`; das ist harmlos und bleibt. Stopp: Eine kleine Nahtstelle `ServerStop` (spaltenintern, Standardimplementierung als `@Singleton`) startet wie `StopCommand` einen `titan-stop`-Thread mit `MinecraftServer.stopCleanly()` und `System.exit(0)` — auf dem Tick-Thread würde `stopCleanly()` auf sich selbst warten. Der Aufruf geschieht höchstens einmal (`AtomicBoolean`), damit Minutentakt und Disconnect nicht doppelt stoppen. Spielerzahl kommt aus einer ebenso spaltenintern deklarierten Schnittstelle `OnlinePlayers` (Standard: `ConnectionManager#getOnlinePlayerCount()`); Tests injizieren Fakes über den Konstruktor, statt den Server zu stoppen. `StopCommand` bleibt unverändert (Non-Goal): Die Dopplung sind zehn Zeilen in zwei Columns, die sich nicht importieren dürfen. Eine gemeinsame Nahtstelle in `core` wäre ein eigener Change (`refactor`). Akzeptierte Kompromisse (siehe auch Risiken): kein oberes Zeitlimit für das Warten; kleines Rennen zwischen Leer-Prüfung und Stopp, der Spieler landet dann wie bei jedem Stopp über den Proxy woanders. @@ -114,11 +111,11 @@ Verworfen: eine Gradle-Property `titan.season` zum Einschalten in `local` (`loca ## Risks / Trade-offs -- [Optional-Bean wird zwischen `runtime` und Column nicht aufgelöst (D2)] → Spike vor der Umsetzung, Integrationstest mit echtem `BeanScope`, vorbenannter `ServiceLoader`-Weg. +- [Bean-Auflösung zwischen `runtime` und Column scheitert (D2)] → Spike-Ergebnis: `ServiceLoader` statt Bean; Integrationstest mit echtem `BeanScope`. - [Eine dauerhaft belegte Lobby verzögert den Saisonwechsel beliebig lang] → akzeptiert; `/stop` bleibt als Betreibermittel. - [Rennen zwischen Leer-Prüfung und Stopp] → akzeptiert; der Spieler wird wie bei jedem Stopp über den Proxy umgeleitet. - [Neustart-Schleife bei kaputter Welt nach Konfigurationsänderung] → live nur validierte Saisons lösen einen Neustart aus (D3); Startvalidierung bricht ab, statt eine kaputte Welt zu laden. -- [Fenstergrenze zwischen Weltwahl und `SeasonModule`-Start bei der `ServiceLoader`-Variante] → höchstens ein zusätzlicher Neustart bei leerer Lobby. +- [Fenstergrenze zwischen Weltwahl und `SeasonModule`-Start ] → höchstens ein zusätzlicher Neustart bei leerer Lobby. - [Zwei offene Changes ändern `apps/local/build.gradle.kts`] → andere Zeilen; der Hauptkontext prüft beim Mergen (siehe Kontext). ## Migration Plan diff --git a/openspec/changes/seasonal-lobby-world/specs/lobby-seasons/spec.md b/openspec/changes/seasonal-lobby-world/specs/lobby-seasons/spec.md index 8698bcff..c2994a00 100644 --- a/openspec/changes/seasonal-lobby-world/specs/lobby-seasons/spec.md +++ b/openspec/changes/seasonal-lobby-world/specs/lobby-seasons/spec.md @@ -97,7 +97,7 @@ Ist ein Neustart vorgemerkt und sind keine Spieler online, MUSS die Lobby sauber - **THEN** ist `autumn` die gewünschte Saison, und beim Start steht eine Warnung mit `autumn` und `halloween` im Log ### Requirement: Ungültige Saison-Konfiguration verhindert den Start -Ist eine aktivierte Saison ungültig, MUSS die Lobby den Start abbrechen, und die Fehlermeldung MUSS den vollständigen Schlüssel (`seasons..`) und den Grund nennen. Ungültig sind ein fehlender `world`-, `from`- oder `to`-Wert, ein Datum, das sich nicht lesen lässt, `from` nicht vor `to`, ein Weltverzeichnis, das unter `worlds/` fehlt, und ein Weltverzeichnis ohne Map-Datei. Auch eine ungültige `seasons.zone` MUSS den Start abbrechen. Eine abgeschaltete Saison DARF den Start nicht verhindern. Ein zur Laufzeit ungültig gewordener Wert DARF weder einen Neustart auslösen noch die Lobby stoppen; er MUSS als Warnung geloggt werden, und die Saison zählt bis zur Korrektur als nicht aktiv. +Ist eine aktivierte Saison ungültig, MUSS die Lobby den Start abbrechen, und die Fehlermeldung MUSS den vollständigen Schlüssel (`seasons..`) und den Grund nennen. Ungültig sind ein fehlender `world`-, `from`- oder `to`-Wert, ein Datum, das sich nicht lesen lässt, `from` nicht vor `to`, ein Weltverzeichnis, das unter `worlds/` fehlt, und ein Weltverzeichnis ohne Map-Datei. Auch eine ungültige `seasons.zone` MUSS den Start abbrechen. Eine abgeschaltete Saison DARF den Start nicht verhindern. Ein zur Laufzeit ungültig gewordener Wert DARF weder einen Neustart auslösen noch die Lobby stoppen; er MUSS als Warnung geloggt werden, und die Lobby behält bis zur Korrektur ihren Zustand: kein Neustart wird vorgemerkt, und es gibt keinen Stopp. #### Scenario: Welt existiert nicht - **WHEN** `seasons.winter.world` auf `winter` zeigt und `worlds/winter` fehlt diff --git a/openspec/changes/seasonal-lobby-world/tasks.md b/openspec/changes/seasonal-lobby-world/tasks.md index 97bde1bf..ae98734e 100644 --- a/openspec/changes/seasonal-lobby-world/tasks.md +++ b/openspec/changes/seasonal-lobby-world/tasks.md @@ -2,7 +2,7 @@ ## Execution Plan -Integrationszweig: `feat/season` von `origin/main`. Welle 1 ist ein Spike und entscheidet, ob D2 mit dem Optional-Bean oder mit dem `ServiceLoader`-Weg umgesetzt wird; Task 2.4 und 3.x richten sich nach dem Ergebnis. Jede Welle endet mit grünem `./gradlew build` und geprüften Diffs. Vor dem Abhaken einer Aufgabe läuft `./gradlew build`. +Integrationszweig: `feat/season` von `origin/main`. Welle 1 (Spike) ist abgeschlossen: D2 wird mit dem `ServiceLoader` umgesetzt. Jede Welle endet mit grünem `./gradlew build` und geprüften Diffs. Vor dem Abhaken einer Aufgabe läuft `./gradlew build`. | Wave | Agent | Task IDs | Model | May Touch | Must Not Touch | | ---- | ----- | -------- | ----- | --------- | -------------- | @@ -13,18 +13,18 @@ Integrationszweig: `feat/season` von `origin/main`. Welle 1 ist ein Spike und en | 3 | verify | 4.5 | haiku | read-only | alles | | 4 | pr | 5.1 | sonnet | Git/GitHub | Code | -Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhandenes nutzen (`Config`-Fassade, Minestom-`Scheduler`, `java.time`, `ServiceLoader` nur im Ausweichweg), Java 25 ohne Preview (Records für `Season`/`Decision`, `switch` über die Entscheidung), keine Nutzertexte, SLF4J mit Parametern (Messages aus D6), keine neuen Metriken/Spans, Test zuerst, schlanke Kommentare nur fürs Warum, Conventional Commits `feat(season): …`. F.I.R.S.T.: `Clock` und `Scheduler` injiziert, `env.tick()` statt Warten, kein `Thread.sleep`, keine Systemzeit, `@TempDir` für `worlds/`, `Config`-Werte im Test setzen und im `@AfterEach` zurücksetzen, frische `Env`/Fixtures je Test, `ServerStop` und `OnlinePlayers` gefälscht (der Test stoppt nie den Server), Erfolg nur über Assertions, WARN/INFO über einen aufgefangenen Appender. +Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhandenes nutzen (`Config`-Fassade, Minestom-`Scheduler`, `java.time`, `ServiceLoader` für die Weltwahl), Java 25 ohne Preview (Records für `Season`/`Decision`, `switch` über die Entscheidung), keine Nutzertexte, SLF4J mit Parametern (Messages aus D6), keine neuen Metriken/Spans, Test zuerst, schlanke Kommentare nur fürs Warum, Conventional Commits `feat(season): …`. F.I.R.S.T.: `Clock` und `Scheduler` injiziert, `env.tick()` statt Warten, kein `Thread.sleep`, keine Systemzeit, `@TempDir` für `worlds/`, `Config`-Werte im Test setzen und im `@AfterEach` zurücksetzen, frische `Env`/Fixtures je Test, `ServerStop` und `OnlinePlayers` gefälscht (der Test stoppt nie den Server), Erfolg nur über Assertions, WARN/INFO über einen aufgefangenen Appender. ## 1. Spike: Optional-Bean über Modulgrenzen (Welle 1) -- [ ] 1.1 Auf einem Wegwerf-Branch eine Bean `LobbyWorldChoice` in eine Test-Column legen und `PlatformBeans.mapProvider(...)` einen `Optional` nehmen lassen; mit echtem `BeanScope.builder().build()` prüfen (a), ob der Optional gefüllt ist, und (b), ob `local` ohne die Column startet. Ergebnis (Optional geht / geht nicht, samt generiertem Code und Modulreihenfolge) in D2 eintragen und die Aufgaben 2.3/2.4 auf den gewählten Weg festlegen. Nachweis: D2 nennt das Ergebnis; der Wegwerf-Branch wird verworfen. +- [x] 1.1 Auf einem Wegwerf-Branch eine Bean `LobbyWorldChoice` in eine Test-Column legen und `PlatformBeans.mapProvider(...)` einen `Optional` nehmen lassen; mit echtem `BeanScope.builder().build()` prüfen (a), ob der Optional gefüllt ist, und (b), ob `local` ohne die Column startet. Ergebnis (Optional geht / geht nicht, samt generiertem Code und Modulreihenfolge) in D2 eintragen und die Aufgaben 2.3/2.4 auf den gewählten Weg festlegen. Nachweis: D2 nennt das Ergebnis; der Wegwerf-Branch wird verworfen. ## 2. Weltwahl in `core`, `common` und `runtime` (Welle 2) - [ ] 2.1 Charakterisierung zuerst (Unit): `MapPool` wählt heute `world` bei mehreren Welten, die einzige Welt bei genau einer und wirft ohne Treffer; Test pinnt das mit `@TempDir`-Verzeichnissen. Nachweis: Test grün vor der Änderung. - [ ] 2.2 Test zuerst (Unit): `MapPool` mit übergebenem Weltnamen wählt genau diese Welt, auch bei genau einer Welt, und wirft mit dem Namen in der Meldung, wenn er fehlt; ohne Namen unverändert. Dann `MapPool`/`MapProvider.create(..., Optional)` umsetzen. Nachweis: Tests aus 2.1 und 2.2 grün. -- [ ] 2.3 `LobbyWorldChoice` in `core` anlegen (`Optional worldName()`). Nachweis: `./gradlew :core:build` grün. -- [ ] 2.4 Test zuerst (Integration, echter `BeanScope`, `@TempDir`-`worlds/`, Cyano-`Env`): Mit einer `LobbyWorldChoice`-Bean lädt `PlatformBeans.mapProvider(...)` deren Welt, ohne Bean die Standardwelt. Dann `PlatformBeans` nach dem Ergebnis von 1.1 umsetzen (Optional-Parameter oder `ServiceLoader`). Nachweis: Test grün; `apps/local` startet unverändert. +- [ ] 2.3 `LobbyWorldChoice` in `core` anlegen (`Optional worldName()`; Implementierungen über `ServiceLoader`, öffentlicher Konstruktor ohne Argumente). Nachweis: `./gradlew :core:build` grün. +- [ ] 2.4 Test zuerst (Unit): Die Auflösung in `PlatformBeans.mapProvider(...)` wählt mit einer `LobbyWorldChoice` deren Welt, ohne Wahl die Standardwelt (Auflösung als Hilfsmethode über eine `Iterable`, damit kein echter Service-Eintrag nötig ist); mehrere Implementierungen brechen den Start mit klarer Meldung ab. Dann `PlatformBeans` auf `ServiceLoader.load(LobbyWorldChoice.class)` umstellen. Nachweis: Test grün; `apps/local` startet unverändert. ## 3. Column `features/season` (Welle 2) @@ -34,7 +34,7 @@ Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhanden - [ ] 3.4 Test zuerst (Unit, `RestartPolicyTest`): gleiche Welt → `NONE`; Abweichung mit Spielern → `PENDING`; Abweichung ohne Spieler → `STOP`; rot. Dann `RestartPolicy` umsetzen; grün. - [ ] 3.5 Test zuerst (Integration, Cyano-`Env`, einstellbare `Clock`, gefälschte `ServerStop`/`OnlinePlayers`, `env.tick()`): Minutentakt merkt bei Abweichung vor und loggt einmal; kein erneutes Loggen bei weiteren Takten; Abschalter zurückgesetzt hebt auf; Stopp nur bei 0 Spielern im Minutentakt; `PlayerDisconnectEvent` des letzten Spielers stoppt im nächsten Tick ohne Minutentakt; ein Spieler geht, ein anderer bleibt → kein Stopp; nichts vorgemerkt und leer → kein Stopp; höchstens ein Stopp; live aktivierte Saison ohne Welt und live ungültiger Wert → WARN, keine Vormerkung; nach Neustart-Zustand (gestartete = gewünschte Welt) → keine Vormerkung; rot. Der Test klärt, ob der ausscheidende Spieler im nächsten Tick nicht mehr zählt (D4). - [ ] 3.6 `SeasonModule` (`@Singleton`, `@PostConstruct` liest die Startwelt und plant den Minutentask samt `titan`-Listener, `@PreDestroy` bricht den Task ab und trennt den Listener), die Nahtstellen `ServerStop` (Standard: `titan-stop`-Thread mit `MinecraftServer.stopCleanly()` und `System.exit(0)`, höchstens einmal) und `OnlinePlayers` mit ihren Standardimplementierungen umsetzen; Test aus 3.5 grün. -- [ ] 3.7 Bei `ServiceLoader`-Weg (Ergebnis von 1.1): `SeasonWorldChoice` mit Eintrag unter `META-INF/services` ergänzen, Test zuerst (Unit): liefert die Welt der aktiven Saison, sonst leer. Bei Optional-Weg: `SeasonWorldChoice` als `@Singleton`, das `LobbyWorldChoice` implementiert und `provides` in der `package-info.java` ergänzt; derselbe Test. Nachweis: Test grün. +- [ ] 3.7 `SeasonWorldChoice` (öffentlich, Konstruktor ohne Argumente mit `Clock.systemUTC()`; paketinterner Konstruktor mit `Clock` für Tests) mit Eintrag unter `META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice` ergänzen. Test zuerst (Unit): liefert die Welt der aktiven Saison, sonst leer; ungültige aktivierte Saison bricht ab wie die Column. Nachweis: Test grün; Service-Datei im `shadowJar` von `apps/cloudnet` vorhanden. - [ ] 3.8 `ColumnArchitectureTest` nach dem Muster von `features/daytime` ergänzen. Nachweis: `./gradlew :features:season:build` grün, keine Datei außerhalb von `features/season/**` geändert. ## 4. Varianten, Doku und Abnahme (Welle 3) From 42f443800a3a111316905184e1662a658f66df68 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:38:29 +0200 Subject: [PATCH 08/17] feat(season): resolve the lobby world through a service-loaded choice Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/core/module/LobbyWorldChoice.java | 29 +++++ .../runtime/bootstrap/PlatformBeans.java | 26 +++- .../bootstrap/LobbyWorldResolutionTest.java | 113 ++++++++++++++++++ 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/net/onelitefeather/titan/core/module/LobbyWorldChoice.java create mode 100644 runtime/src/test/java/net/onelitefeather/titan/runtime/bootstrap/LobbyWorldResolutionTest.java diff --git a/core/src/main/java/net/onelitefeather/titan/core/module/LobbyWorldChoice.java b/core/src/main/java/net/onelitefeather/titan/core/module/LobbyWorldChoice.java new file mode 100644 index 00000000..9912ed18 --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/module/LobbyWorldChoice.java @@ -0,0 +1,29 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.module; + +import java.util.Optional; + +/** + * Names the world directory the lobby loads at startup. Implementations are found through + * {@link java.util.ServiceLoader}, not as beans: the choice is needed before any feature module + * exists, and a bean would come out empty. An implementation needs a public no-arg constructor. + */ +public interface LobbyWorldChoice { + + /** @return the directory name under {@code worlds/}; empty for the default world */ + Optional worldName(); +} diff --git a/runtime/src/main/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeans.java b/runtime/src/main/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeans.java index cf86d555..20156999 100644 --- a/runtime/src/main/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeans.java +++ b/runtime/src/main/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeans.java @@ -20,6 +20,11 @@ import jakarta.inject.Named; import java.nio.file.Path; import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.stream.Collectors; import net.minestom.server.MinecraftServer; import net.minestom.server.command.CommandManager; import net.minestom.server.event.Event; @@ -30,6 +35,7 @@ import net.onelitefeather.titan.api.deliver.Deliver; import net.onelitefeather.titan.core.module.FeatureNode; import net.onelitefeather.titan.core.module.LobbySpawn; +import net.onelitefeather.titan.core.module.LobbyWorldChoice; import net.onelitefeather.titan.common.deliver.DeliverProvider; import net.onelitefeather.titan.runtime.feature.ConfigFeatureFlags; import net.onelitefeather.titan.core.feature.FeatureFlags; @@ -54,10 +60,26 @@ public InstanceContainer instanceContainer() { return instance; } - /** Loads map data from {@code worlds/} relative to the working directory. */ + /** + * Loads map data from {@code worlds/} relative to the working directory, in the world a + * {@link LobbyWorldChoice} names. Looked up through the service loader, not as a bean: a + * column's bean is built after this one and would always be missing here. + */ @Bean public MapProvider mapProvider(InstanceContainer instance) { - return MapProvider.create(Path.of(""), instance); + return loadMapProvider(Path.of(""), instance, ServiceLoader.load(LobbyWorldChoice.class)); + } + + static MapProvider loadMapProvider(Path base, InstanceContainer instance, Iterable choices) { + List found = new ArrayList<>(); + choices.forEach(found::add); + if (found.size() > 1) { + // Which world wins must not depend on classpath order. + String names = found.stream().map(choice -> choice.getClass().getName()).collect(Collectors.joining(", ")); + throw new IllegalStateException("More than one LobbyWorldChoice found: " + names); + } + Optional worldName = found.stream().findFirst().flatMap(LobbyWorldChoice::worldName); + return MapProvider.create(base, instance, worldName); } @Bean diff --git a/runtime/src/test/java/net/onelitefeather/titan/runtime/bootstrap/LobbyWorldResolutionTest.java b/runtime/src/test/java/net/onelitefeather/titan/runtime/bootstrap/LobbyWorldResolutionTest.java new file mode 100644 index 00000000..6140a0ef --- /dev/null +++ b/runtime/src/test/java/net/onelitefeather/titan/runtime/bootstrap/LobbyWorldResolutionTest.java @@ -0,0 +1,113 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.runtime.bootstrap; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import net.minestom.server.instance.InstanceContainer; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.core.module.LobbyWorldChoice; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers how {@link PlatformBeans} turns the {@link LobbyWorldChoice} implementations found by the + * service loader into the world the {@link MapProvider} loads. Choices are handed in directly, so + * no real service file is needed. + */ +@ExtendWith(MicrotusExtension.class) +class LobbyWorldResolutionTest { + + @TempDir + Path base; + + private void createWorld(String name) throws IOException { + Path directory = Files.createDirectories(this.base.resolve("worlds").resolve(name)); + Files.writeString(directory.resolve("map.json"), "{\"name\":\"" + name + "\"}"); + } + + private static LobbyWorldChoice choosing(String world) { + return () -> Optional.of(world); + } + + private MapProvider load(Env env, List choices) { + InstanceContainer instance = env.process().instance().createInstanceContainer(); + return PlatformBeans.loadMapProvider(this.base, instance, choices); + } + + @DisplayName("A choice selects its world even though another one is the default") + @Test + void choiceSelectsItsWorld(Env env) throws IOException { + createWorld("world"); + createWorld("winter"); + + MapProvider provider = load(env, List.of(choosing("winter"))); + + Assertions.assertEquals("winter", provider.getActiveLobby().name(), "the chosen world must be the active lobby"); + } + + @DisplayName("Without a choice the default world is loaded") + @Test + void noChoiceLoadsTheDefaultWorld(Env env) throws IOException { + createWorld("world"); + createWorld("winter"); + + MapProvider provider = load(env, List.of()); + + Assertions.assertEquals("world", provider.getActiveLobby().name(), "no choice must keep the default world"); + } + + @DisplayName("A choice without a world loads the default world") + @Test + void emptyChoiceLoadsTheDefaultWorld(Env env) throws IOException { + createWorld("world"); + createWorld("winter"); + + MapProvider provider = load(env, List.of(Optional::empty)); + + Assertions.assertEquals("world", provider.getActiveLobby().name(), "an empty choice means the default world"); + } + + @DisplayName("A chosen world that does not exist aborts the load and names it") + @Test + void missingChosenWorldAbortsWithItsName(Env env) throws IOException { + createWorld("world"); + + RuntimeException failure = Assertions.assertThrows(RuntimeException.class, () -> load(env, List.of(choosing("winter")))); + + Assertions.assertTrue(failure.getMessage().contains("winter"), "the message must name the missing world, was: " + failure.getMessage()); + } + + @DisplayName("More than one choice aborts startup and names all of them") + @Test + void ambiguousChoicesAbortStartup(Env env) throws IOException { + createWorld("world"); + LobbyWorldChoice first = choosing("a"); + LobbyWorldChoice second = choosing("b"); + + IllegalStateException failure = Assertions.assertThrows(IllegalStateException.class, () -> load(env, List.of(first, second))); + + Assertions.assertTrue(failure.getMessage().contains(first.getClass().getName()), "the message must name each implementation, was: " + failure.getMessage()); + } +} From 78a2a67d71b2bf401d6e0ab5b4b73c9cf12d91d6 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:40:53 +0200 Subject: [PATCH 09/17] feat(season): register the season world choice for the service loader Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../feature/season/SeasonWorldChoice.java | 44 +++++++ ...feather.titan.core.module.LobbyWorldChoice | 1 + .../feature/season/SeasonWorldChoiceTest.java | 107 ++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonWorldChoice.java create mode 100644 features/season/src/main/resources/META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice create mode 100644 features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonWorldChoiceTest.java diff --git a/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonWorldChoice.java b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonWorldChoice.java new file mode 100644 index 00000000..021d5b05 --- /dev/null +++ b/features/season/src/main/java/net/onelitefeather/titan/feature/season/SeasonWorldChoice.java @@ -0,0 +1,44 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.nio.file.Path; +import java.time.Clock; +import java.util.Optional; +import net.onelitefeather.titan.core.module.LobbyWorldChoice; + +/** + * Tells the platform which world to load when a season's window is open. Public with a no-arg + * constructor only because the service loader instantiates it before any bean exists; an invalid + * enabled season aborts startup here just as it does in the column. + */ +public final class SeasonWorldChoice implements LobbyWorldChoice { + + private final SeasonSchedule schedule; + + public SeasonWorldChoice() { + this(Clock.systemUTC(), Path.of(SeasonSettings.WORLDS_DIRECTORY)); + } + + SeasonWorldChoice(Clock clock, Path worldsDirectory) { + this.schedule = new SeasonSchedule(clock, worldsDirectory); + } + + @Override + public Optional worldName() { + return this.schedule.atStartup().map(Season::world); + } +} diff --git a/features/season/src/main/resources/META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice b/features/season/src/main/resources/META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice new file mode 100644 index 00000000..a002c3b5 --- /dev/null +++ b/features/season/src/main/resources/META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice @@ -0,0 +1 @@ +net.onelitefeather.titan.feature.season.SeasonWorldChoice diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonWorldChoiceTest.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonWorldChoiceTest.java new file mode 100644 index 00000000..d7049a80 --- /dev/null +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonWorldChoiceTest.java @@ -0,0 +1,107 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import io.avaje.config.Config; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; +import java.util.ServiceLoader; +import net.onelitefeather.titan.core.module.LobbyWorldChoice; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SeasonWorldChoiceTest { + + private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + + @TempDir + Path worlds; + + private String originalZone; + + @BeforeEach + void setUp() { + this.originalZone = Config.get(SeasonSettings.ZONE_KEY); + } + + @AfterEach + void tearDown() { + Config.asProperties().stringPropertyNames().stream().filter(key -> key.startsWith(SeasonSettings.PREFIX)).forEach(Config::clearProperty); + Config.setProperty(SeasonSettings.ZONE_KEY, this.originalZone); + } + + private SeasonWorldChoice choiceAt(String utcInstant) { + return new SeasonWorldChoice(new AdjustableClock(Instant.parse(utcInstant), BERLIN), this.worlds); + } + + private void configureWinter(boolean withWorld) throws IOException { + if (withWorld) { + Path directory = Files.createDirectories(this.worlds.resolve("winter-map")); + Files.writeString(directory.resolve(SeasonSettings.MAP_FILE_NAME), "{}"); + } + Config.setProperty(SeasonSettings.key("winter", SeasonSettings.WORLD_FIELD), "winter-map"); + Config.setProperty(SeasonSettings.key("winter", SeasonSettings.FROM_FIELD), "2026-12-01T00:00:00"); + Config.setProperty(SeasonSettings.key("winter", SeasonSettings.TO_FIELD), "2027-01-07T00:00:00"); + } + + @DisplayName("The world of the season active now is chosen") + @Test + void activeSeasonsWorldIsChosen() throws IOException { + configureWinter(true); + + Assertions.assertEquals(Optional.of("winter-map"), choiceAt("2026-12-24T12:00:00Z").worldName()); + } + + @DisplayName("Outside every window the default world is left to the platform") + @Test + void noActiveSeasonChoosesNothing() throws IOException { + configureWinter(true); + + Assertions.assertEquals(Optional.empty(), choiceAt("2026-06-01T12:00:00Z").worldName()); + } + + @DisplayName("Without any season configured nothing is chosen") + @Test + void noSeasonsChoosesNothing() { + Assertions.assertEquals(Optional.empty(), choiceAt("2026-12-24T12:00:00Z").worldName()); + } + + @DisplayName("An invalid enabled season aborts startup, as the column does") + @Test + void invalidEnabledSeasonAbortsStartup() throws IOException { + configureWinter(false); + + IllegalStateException failure = Assertions.assertThrows(IllegalStateException.class, () -> choiceAt("2026-12-24T12:00:00Z").worldName()); + + Assertions.assertTrue(failure.getMessage().contains("seasons.winter.world"), "the message must name the key, was: " + failure.getMessage()); + } + + @DisplayName("The service loader finds the choice") + @Test + void serviceLoaderFindsTheChoice() { + boolean found = ServiceLoader.load(LobbyWorldChoice.class).stream().anyMatch(provider -> provider.type() == SeasonWorldChoice.class); + + Assertions.assertTrue(found, "META-INF/services must register SeasonWorldChoice"); + } +} From ed859dace807a434fd05ae9ff0ffdde39da45e24 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:40:57 +0200 Subject: [PATCH 10/17] docs(openspec): tick world choice tasks of seasonal-lobby-world Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/seasonal-lobby-world/tasks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openspec/changes/seasonal-lobby-world/tasks.md b/openspec/changes/seasonal-lobby-world/tasks.md index ae98734e..733ad785 100644 --- a/openspec/changes/seasonal-lobby-world/tasks.md +++ b/openspec/changes/seasonal-lobby-world/tasks.md @@ -23,8 +23,8 @@ Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhanden - [ ] 2.1 Charakterisierung zuerst (Unit): `MapPool` wählt heute `world` bei mehreren Welten, die einzige Welt bei genau einer und wirft ohne Treffer; Test pinnt das mit `@TempDir`-Verzeichnissen. Nachweis: Test grün vor der Änderung. - [ ] 2.2 Test zuerst (Unit): `MapPool` mit übergebenem Weltnamen wählt genau diese Welt, auch bei genau einer Welt, und wirft mit dem Namen in der Meldung, wenn er fehlt; ohne Namen unverändert. Dann `MapPool`/`MapProvider.create(..., Optional)` umsetzen. Nachweis: Tests aus 2.1 und 2.2 grün. -- [ ] 2.3 `LobbyWorldChoice` in `core` anlegen (`Optional worldName()`; Implementierungen über `ServiceLoader`, öffentlicher Konstruktor ohne Argumente). Nachweis: `./gradlew :core:build` grün. -- [ ] 2.4 Test zuerst (Unit): Die Auflösung in `PlatformBeans.mapProvider(...)` wählt mit einer `LobbyWorldChoice` deren Welt, ohne Wahl die Standardwelt (Auflösung als Hilfsmethode über eine `Iterable`, damit kein echter Service-Eintrag nötig ist); mehrere Implementierungen brechen den Start mit klarer Meldung ab. Dann `PlatformBeans` auf `ServiceLoader.load(LobbyWorldChoice.class)` umstellen. Nachweis: Test grün; `apps/local` startet unverändert. +- [x] 2.3 `LobbyWorldChoice` in `core` anlegen (`Optional worldName()`; Implementierungen über `ServiceLoader`, öffentlicher Konstruktor ohne Argumente). Nachweis: `./gradlew :core:build` grün. +- [x] 2.4 Test zuerst (Unit): Die Auflösung in `PlatformBeans.mapProvider(...)` wählt mit einer `LobbyWorldChoice` deren Welt, ohne Wahl die Standardwelt (Auflösung als Hilfsmethode über eine `Iterable`, damit kein echter Service-Eintrag nötig ist); mehrere Implementierungen brechen den Start mit klarer Meldung ab. Dann `PlatformBeans` auf `ServiceLoader.load(LobbyWorldChoice.class)` umstellen. Nachweis: Test grün; `apps/local` startet unverändert. ## 3. Column `features/season` (Welle 2) @@ -34,7 +34,7 @@ Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhanden - [ ] 3.4 Test zuerst (Unit, `RestartPolicyTest`): gleiche Welt → `NONE`; Abweichung mit Spielern → `PENDING`; Abweichung ohne Spieler → `STOP`; rot. Dann `RestartPolicy` umsetzen; grün. - [ ] 3.5 Test zuerst (Integration, Cyano-`Env`, einstellbare `Clock`, gefälschte `ServerStop`/`OnlinePlayers`, `env.tick()`): Minutentakt merkt bei Abweichung vor und loggt einmal; kein erneutes Loggen bei weiteren Takten; Abschalter zurückgesetzt hebt auf; Stopp nur bei 0 Spielern im Minutentakt; `PlayerDisconnectEvent` des letzten Spielers stoppt im nächsten Tick ohne Minutentakt; ein Spieler geht, ein anderer bleibt → kein Stopp; nichts vorgemerkt und leer → kein Stopp; höchstens ein Stopp; live aktivierte Saison ohne Welt und live ungültiger Wert → WARN, keine Vormerkung; nach Neustart-Zustand (gestartete = gewünschte Welt) → keine Vormerkung; rot. Der Test klärt, ob der ausscheidende Spieler im nächsten Tick nicht mehr zählt (D4). - [ ] 3.6 `SeasonModule` (`@Singleton`, `@PostConstruct` liest die Startwelt und plant den Minutentask samt `titan`-Listener, `@PreDestroy` bricht den Task ab und trennt den Listener), die Nahtstellen `ServerStop` (Standard: `titan-stop`-Thread mit `MinecraftServer.stopCleanly()` und `System.exit(0)`, höchstens einmal) und `OnlinePlayers` mit ihren Standardimplementierungen umsetzen; Test aus 3.5 grün. -- [ ] 3.7 `SeasonWorldChoice` (öffentlich, Konstruktor ohne Argumente mit `Clock.systemUTC()`; paketinterner Konstruktor mit `Clock` für Tests) mit Eintrag unter `META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice` ergänzen. Test zuerst (Unit): liefert die Welt der aktiven Saison, sonst leer; ungültige aktivierte Saison bricht ab wie die Column. Nachweis: Test grün; Service-Datei im `shadowJar` von `apps/cloudnet` vorhanden. +- [x] 3.7 `SeasonWorldChoice` (öffentlich, Konstruktor ohne Argumente mit `Clock.systemUTC()`; paketinterner Konstruktor mit `Clock` für Tests) mit Eintrag unter `META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice` ergänzen. Test zuerst (Unit): liefert die Welt der aktiven Saison, sonst leer; ungültige aktivierte Saison bricht ab wie die Column. Nachweis: Test grün; Service-Datei im `shadowJar` von `apps/cloudnet` vorhanden. - [ ] 3.8 `ColumnArchitectureTest` nach dem Muster von `features/daytime` ergänzen. Nachweis: `./gradlew :features:season:build` grün, keine Datei außerhalb von `features/season/**` geändert. ## 4. Varianten, Doku und Abnahme (Welle 3) From 98cd686f13044091ff5d0626c16ddcad7b082041 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:41:26 +0200 Subject: [PATCH 11/17] docs(openspec): tick implemented tasks of seasonal-lobby-world Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/seasonal-lobby-world/tasks.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/openspec/changes/seasonal-lobby-world/tasks.md b/openspec/changes/seasonal-lobby-world/tasks.md index 733ad785..df71db51 100644 --- a/openspec/changes/seasonal-lobby-world/tasks.md +++ b/openspec/changes/seasonal-lobby-world/tasks.md @@ -21,21 +21,21 @@ Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhanden ## 2. Weltwahl in `core`, `common` und `runtime` (Welle 2) -- [ ] 2.1 Charakterisierung zuerst (Unit): `MapPool` wählt heute `world` bei mehreren Welten, die einzige Welt bei genau einer und wirft ohne Treffer; Test pinnt das mit `@TempDir`-Verzeichnissen. Nachweis: Test grün vor der Änderung. -- [ ] 2.2 Test zuerst (Unit): `MapPool` mit übergebenem Weltnamen wählt genau diese Welt, auch bei genau einer Welt, und wirft mit dem Namen in der Meldung, wenn er fehlt; ohne Namen unverändert. Dann `MapPool`/`MapProvider.create(..., Optional)` umsetzen. Nachweis: Tests aus 2.1 und 2.2 grün. +- [x] 2.1 Charakterisierung zuerst (Unit): `MapPool` wählt heute `world` bei mehreren Welten, die einzige Welt bei genau einer und wirft ohne Treffer; Test pinnt das mit `@TempDir`-Verzeichnissen. Nachweis: Test grün vor der Änderung. +- [x] 2.2 Test zuerst (Unit): `MapPool` mit übergebenem Weltnamen wählt genau diese Welt, auch bei genau einer Welt, und wirft mit dem Namen in der Meldung, wenn er fehlt; ohne Namen unverändert. Dann `MapPool`/`MapProvider.create(..., Optional)` umsetzen. Nachweis: Tests aus 2.1 und 2.2 grün. - [x] 2.3 `LobbyWorldChoice` in `core` anlegen (`Optional worldName()`; Implementierungen über `ServiceLoader`, öffentlicher Konstruktor ohne Argumente). Nachweis: `./gradlew :core:build` grün. - [x] 2.4 Test zuerst (Unit): Die Auflösung in `PlatformBeans.mapProvider(...)` wählt mit einer `LobbyWorldChoice` deren Welt, ohne Wahl die Standardwelt (Auflösung als Hilfsmethode über eine `Iterable`, damit kein echter Service-Eintrag nötig ist); mehrere Implementierungen brechen den Start mit klarer Meldung ab. Dann `PlatformBeans` auf `ServiceLoader.load(LobbyWorldChoice.class)` umstellen. Nachweis: Test grün; `apps/local` startet unverändert. ## 3. Column `features/season` (Welle 2) -- [ ] 3.1 Modul anlegen: `build.gradle.kts` (`titan.column`, `libs.avaje.config`, `libs.slf4j.api`, `testImplementation(libs.logback.classic)`), `package-info.java` mit `@InjectModule(name = "seasonColumn", requires = {Scheduler.class, Clock.class, EventNode.class}, requiresString = {"…EventNode<…Event>:titan"})`, `titan/defaults/season.yaml` (D1). Nachweis: `./gradlew :features:season:build` grün, `settings.gradle.kts` unverändert. -- [ ] 3.2 Test zuerst (Unit, `SeasonCalendarTest`): aktive Saison in `[from, to)`, `to` ausgeschlossen, keine Saison, abgeschaltete Saison ignoriert, Zeitzone verschiebt das Fenster, Überlappung: früheres `from` gewinnt, bei Gleichstand die kleinere Id; rot. Dann `Season` und `SeasonCalendar` umsetzen; grün. -- [ ] 3.3 Test zuerst (Unit, `SeasonConfigReaderTest`, `@TempDir`-`worlds/`): `season.yaml` liefert `zone=Europe/Berlin`; fehlender `world`/`from`/`to`, unlesbares Datum, `from >= to`, fehlendes Weltverzeichnis, fehlende `map.json`, ungültige Zone, reservierte Id `zone` brechen mit qualifiziertem Schlüssel und Grund ab; abgeschaltete Saison wird nicht geprüft; Überlappung loggt WARN mit beiden Ids (aufgefangener Appender); rot. Dann `SeasonSettings`/`SeasonConfigReader` umsetzen; grün. -- [ ] 3.4 Test zuerst (Unit, `RestartPolicyTest`): gleiche Welt → `NONE`; Abweichung mit Spielern → `PENDING`; Abweichung ohne Spieler → `STOP`; rot. Dann `RestartPolicy` umsetzen; grün. -- [ ] 3.5 Test zuerst (Integration, Cyano-`Env`, einstellbare `Clock`, gefälschte `ServerStop`/`OnlinePlayers`, `env.tick()`): Minutentakt merkt bei Abweichung vor und loggt einmal; kein erneutes Loggen bei weiteren Takten; Abschalter zurückgesetzt hebt auf; Stopp nur bei 0 Spielern im Minutentakt; `PlayerDisconnectEvent` des letzten Spielers stoppt im nächsten Tick ohne Minutentakt; ein Spieler geht, ein anderer bleibt → kein Stopp; nichts vorgemerkt und leer → kein Stopp; höchstens ein Stopp; live aktivierte Saison ohne Welt und live ungültiger Wert → WARN, keine Vormerkung; nach Neustart-Zustand (gestartete = gewünschte Welt) → keine Vormerkung; rot. Der Test klärt, ob der ausscheidende Spieler im nächsten Tick nicht mehr zählt (D4). -- [ ] 3.6 `SeasonModule` (`@Singleton`, `@PostConstruct` liest die Startwelt und plant den Minutentask samt `titan`-Listener, `@PreDestroy` bricht den Task ab und trennt den Listener), die Nahtstellen `ServerStop` (Standard: `titan-stop`-Thread mit `MinecraftServer.stopCleanly()` und `System.exit(0)`, höchstens einmal) und `OnlinePlayers` mit ihren Standardimplementierungen umsetzen; Test aus 3.5 grün. +- [x] 3.1 Modul anlegen: `build.gradle.kts` (`titan.column`, `libs.avaje.config`, `libs.slf4j.api`, `testImplementation(libs.logback.classic)`), `package-info.java` mit `@InjectModule(name = "seasonColumn", requires = {Scheduler.class, Clock.class, EventNode.class}, requiresString = {"…EventNode<…Event>:titan"})`, `titan/defaults/season.yaml` (D1). Nachweis: `./gradlew :features:season:build` grün, `settings.gradle.kts` unverändert. +- [x] 3.2 Test zuerst (Unit, `SeasonCalendarTest`): aktive Saison in `[from, to)`, `to` ausgeschlossen, keine Saison, abgeschaltete Saison ignoriert, Zeitzone verschiebt das Fenster, Überlappung: früheres `from` gewinnt, bei Gleichstand die kleinere Id; rot. Dann `Season` und `SeasonCalendar` umsetzen; grün. +- [x] 3.3 Test zuerst (Unit, `SeasonConfigReaderTest`, `@TempDir`-`worlds/`): `season.yaml` liefert `zone=Europe/Berlin`; fehlender `world`/`from`/`to`, unlesbares Datum, `from >= to`, fehlendes Weltverzeichnis, fehlende `map.json`, ungültige Zone, reservierte Id `zone` brechen mit qualifiziertem Schlüssel und Grund ab; abgeschaltete Saison wird nicht geprüft; Überlappung loggt WARN mit beiden Ids (aufgefangener Appender); rot. Dann `SeasonSettings`/`SeasonConfigReader` umsetzen; grün. +- [x] 3.4 Test zuerst (Unit, `RestartPolicyTest`): gleiche Welt → `NONE`; Abweichung mit Spielern → `PENDING`; Abweichung ohne Spieler → `STOP`; rot. Dann `RestartPolicy` umsetzen; grün. +- [x] 3.5 Test zuerst (Integration, Cyano-`Env`, einstellbare `Clock`, gefälschte `ServerStop`/`OnlinePlayers`, `env.tick()`): Minutentakt merkt bei Abweichung vor und loggt einmal; kein erneutes Loggen bei weiteren Takten; Abschalter zurückgesetzt hebt auf; Stopp nur bei 0 Spielern im Minutentakt; `PlayerDisconnectEvent` des letzten Spielers stoppt im nächsten Tick ohne Minutentakt; ein Spieler geht, ein anderer bleibt → kein Stopp; nichts vorgemerkt und leer → kein Stopp; höchstens ein Stopp; live aktivierte Saison ohne Welt und live ungültiger Wert → WARN, keine Vormerkung; nach Neustart-Zustand (gestartete = gewünschte Welt) → keine Vormerkung; rot. Der Test klärt, ob der ausscheidende Spieler im nächsten Tick nicht mehr zählt (D4). +- [x] 3.6 `SeasonModule` (`@Singleton`, `@PostConstruct` liest die Startwelt und plant den Minutentask samt `titan`-Listener, `@PreDestroy` bricht den Task ab und trennt den Listener), die Nahtstellen `ServerStop` (Standard: `titan-stop`-Thread mit `MinecraftServer.stopCleanly()` und `System.exit(0)`, höchstens einmal) und `OnlinePlayers` mit ihren Standardimplementierungen umsetzen; Test aus 3.5 grün. - [x] 3.7 `SeasonWorldChoice` (öffentlich, Konstruktor ohne Argumente mit `Clock.systemUTC()`; paketinterner Konstruktor mit `Clock` für Tests) mit Eintrag unter `META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice` ergänzen. Test zuerst (Unit): liefert die Welt der aktiven Saison, sonst leer; ungültige aktivierte Saison bricht ab wie die Column. Nachweis: Test grün; Service-Datei im `shadowJar` von `apps/cloudnet` vorhanden. -- [ ] 3.8 `ColumnArchitectureTest` nach dem Muster von `features/daytime` ergänzen. Nachweis: `./gradlew :features:season:build` grün, keine Datei außerhalb von `features/season/**` geändert. +- [x] 3.8 `ColumnArchitectureTest` nach dem Muster von `features/daytime` ergänzen. Nachweis: `./gradlew :features:season:build` grün, keine Datei außerhalb von `features/season/**` geändert. ## 4. Varianten, Doku und Abnahme (Welle 3) From 3af848758a08f2ff20096aeee4074dc2a6b39707 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:43:12 +0200 Subject: [PATCH 12/17] feat(season): keep the season column out of the local variant Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/apps/cloudnet/VariantStartTest.java | 20 +++++++++++ apps/local/build.gradle.kts | 5 +++ .../titan/apps/local/VariantStartTest.java | 36 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/VariantStartTest.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/VariantStartTest.java index 8db8bfc1..4a261d2d 100644 --- a/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/VariantStartTest.java +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/VariantStartTest.java @@ -16,6 +16,8 @@ package net.onelitefeather.titan.apps.cloudnet; import io.avaje.inject.BeanScope; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import net.minestom.testing.Env; @@ -70,6 +72,24 @@ void variantPropertiesListsLuckpermsPlatform() { Assertions.assertTrue(descriptor.modules().contains("luckpermsPlatform"), "expected modules must include luckpermsPlatform, were: " + descriptor.modules()); } + @DisplayName("variant.properties lists seasonColumn and the season column is loaded") + @Test + void seasonColumnIsExpectedAndLoaded() { + ClassLoader loader = getClass().getClassLoader(); + VariantDescriptor descriptor = VariantDescriptor.fromClasspath(loader).orElseThrow(() -> new AssertionError("this variant must ship META-INF/titan/variant.properties")); + + Assertions.assertTrue(descriptor.modules().contains("seasonColumn"), "expected modules must include seasonColumn, were: " + descriptor.modules()); + Assertions.assertTrue(LoadedModules.discover(loader).contains("seasonColumn"), "the season column must be on the classpath and load"); + } + + @DisplayName("The shipped application.yaml carries the seasons defaults") + @Test + void applicationYamlCarriesTheSeasonsDefaults() throws IOException { + String yaml = new String(getClass().getClassLoader().getResourceAsStream("application.yaml").readAllBytes(), StandardCharsets.UTF_8); + + Assertions.assertTrue(yaml.contains("seasons:"), "the merged application.yaml must contain the seasons defaults"); + } + @DisplayName("An additionally expected but missing column aborts startup, naming it") @Test void anAdditionallyExpectedButMissingColumnAbortsNamingIt() { diff --git a/apps/local/build.gradle.kts b/apps/local/build.gradle.kts index a4609f8c..a0a5f127 100644 --- a/apps/local/build.gradle.kts +++ b/apps/local/build.gradle.kts @@ -4,6 +4,11 @@ plugins { id("titan.app-variant") } +// No supervisor restarts a standalone dev server, so the season column's restart-into-world cannot work here. +titanVariant { + exclude("season") +} + // Off by default - a developer builds with LuckPerms only when testing permissions locally, via // ./gradlew :apps:local:build -Ptitan.luckperms. if (providers.gradleProperty("titan.luckperms").isPresent) { diff --git a/apps/local/src/test/java/net/onelitefeather/titan/apps/local/VariantStartTest.java b/apps/local/src/test/java/net/onelitefeather/titan/apps/local/VariantStartTest.java index 0dc99b5f..739e400c 100644 --- a/apps/local/src/test/java/net/onelitefeather/titan/apps/local/VariantStartTest.java +++ b/apps/local/src/test/java/net/onelitefeather/titan/apps/local/VariantStartTest.java @@ -16,12 +16,18 @@ package net.onelitefeather.titan.apps.local; import io.avaje.inject.BeanScope; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; import net.onelitefeather.titan.common.map.MapProvider; import net.onelitefeather.titan.core.feature.FeatureFlags; +import net.onelitefeather.titan.core.module.LobbyWorldChoice; import net.onelitefeather.titan.core.permission.PermissionService; import net.onelitefeather.titan.runtime.variant.LoadedModules; import net.onelitefeather.titan.runtime.variant.VariantDescriptor; @@ -79,6 +85,36 @@ void theActiveServiceIsDenyAllWithoutTheSwitch(Env env) { } } + @DisplayName("The season column is neither expected nor on the classpath") + @Test + void seasonColumnIsNotPartOfThisVariant() { + ClassLoader loader = getClass().getClassLoader(); + VariantDescriptor descriptor = VariantDescriptor.fromClasspath(loader).orElseThrow(() -> new AssertionError("this variant must ship META-INF/titan/variant.properties")); + + Assertions.assertFalse(descriptor.modules().contains("seasonColumn"), "expected modules must not include seasonColumn, were: " + descriptor.modules()); + Assertions.assertFalse(LoadedModules.discover(loader).contains("seasonColumn"), "the season column must not load in this variant"); + Assertions.assertThrows(ClassNotFoundException.class, () -> Class.forName("net.onelitefeather.titan.feature.season.SeasonWorldChoice", false, loader), "the season classes must not be on the classpath"); + } + + @DisplayName("No LobbyWorldChoice service entry is on the classpath") + @Test + void noLobbyWorldChoiceServiceEntry() throws IOException { + List entries = Collections.list(getClass().getClassLoader().getResources("META-INF/services/" + LobbyWorldChoice.class.getName())); + + Assertions.assertTrue(entries.isEmpty(), "this variant must not register a LobbyWorldChoice, found: " + entries); + } + + @DisplayName("The shipped application.yaml has no seasons defaults") + @Test + void applicationYamlHasNoSeasonsDefaults() throws IOException { + for (URL url : Collections.list(getClass().getClassLoader().getResources("application.yaml"))) { + try (InputStream in = url.openStream()) { + String yaml = new String(in.readAllBytes(), StandardCharsets.UTF_8); + Assertions.assertFalse(yaml.contains("seasons:"), "no seasons defaults expected in " + url); + } + } + } + @DisplayName("An additionally expected but missing column aborts startup, naming it") @Test void anAdditionallyExpectedButMissingColumnAbortsNamingIt() { From bf453df5839f3df75a30873918693e6d0dc48f1d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:44:27 +0200 Subject: [PATCH 13/17] feat(season): cover the season wiring in the cloudnet variant Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- apps/cloudnet/build.gradle.kts | 3 + .../titan/feature/season/SeasonChoices.java | 31 +++ .../feature/season/SeasonMapFileNameTest.java | 34 ++++ .../season/SeasonRestartWiringTest.java | 180 ++++++++++++++++++ .../bootstrap/SeasonLobbyWorldTest.java | 102 ++++++++++ 5 files changed, 350 insertions(+) create mode 100644 apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonChoices.java create mode 100644 apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonMapFileNameTest.java create mode 100644 apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonRestartWiringTest.java create mode 100644 apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/SeasonLobbyWorldTest.java diff --git a/apps/cloudnet/build.gradle.kts b/apps/cloudnet/build.gradle.kts index b70078b9..4d16b197 100644 --- a/apps/cloudnet/build.gradle.kts +++ b/apps/cloudnet/build.gradle.kts @@ -20,6 +20,9 @@ dependencies { testImplementation(libs.mockito) testImplementation(libs.avaje.inject) testImplementation(libs.avaje.config) + // The season wiring test asserts on captured log lines. + testImplementation(libs.slf4j.api) + testImplementation(libs.logback.classic) // TestTitanNode - see the example feature's own test. testImplementation(testFixtures(project(":core"))) testImplementation(libs.junit.api) diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonChoices.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonChoices.java new file mode 100644 index 00000000..01509a75 --- /dev/null +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonChoices.java @@ -0,0 +1,31 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import java.nio.file.Path; +import java.time.Clock; +import net.onelitefeather.titan.core.module.LobbyWorldChoice; + +/** Reaches the column's package-private test constructor from another package. */ +public final class SeasonChoices { + + private SeasonChoices() { + } + + public static LobbyWorldChoice at(Clock clock, Path worldsDirectory) { + return new SeasonWorldChoice(clock, worldsDirectory); + } +} diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonMapFileNameTest.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonMapFileNameTest.java new file mode 100644 index 00000000..8df77765 --- /dev/null +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonMapFileNameTest.java @@ -0,0 +1,34 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import net.onelitefeather.titan.common.map.MapEntry; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Lives in the column's package name because {@code SeasonSettings} is package-private; a column + * sees only core, so this is the one place that can compare it with {@code common}. + */ +class SeasonMapFileNameTest { + + @DisplayName("The column's map file name equals MapEntry.MAP_FILE_NAME") + @Test + void mapFileNameMatchesMapEntry() { + Assertions.assertEquals(MapEntry.MAP_FILE_NAME, SeasonSettings.MAP_FILE_NAME, "the season column would validate a file the map loader never reads"); + } +} diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonRestartWiringTest.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonRestartWiringTest.java new file mode 100644 index 00000000..4121bb40 --- /dev/null +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonRestartWiringTest.java @@ -0,0 +1,180 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.season; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.avaje.config.Config; +import io.avaje.inject.BeanScope; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.timer.Scheduler; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.core.feature.FeatureFlags; +import net.onelitefeather.titan.core.module.FeatureNode; +import net.onelitefeather.titan.core.permission.PermissionService; +import net.onelitefeather.titan.core.testfixtures.TestTitanNode; +import net.onelitefeather.titan.platform.luckperms.LuckPermsPermissionService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; + +/** + * Builds the real Avaje Inject {@link BeanScope} of the {@code cloudnet} variant and drives the + * season column through it: the started world comes from the window, and once the window ends the + * restart is pending and, with nobody online, the (fake) stop is requested. The clock, the + * {@code worlds/} directory, the scheduler, the player count and the stop are the only things + * replaced. Lives in the column's package name because its seams are package-private. + */ +@ExtendWith(MicrotusExtension.class) +@Timeout(30) +class SeasonRestartWiringTest { + + private static final Instant IN_WINTER = Instant.parse("2026-12-24T12:00:00Z"); + private static final Instant AFTER_WINTER = Instant.parse("2027-01-07T12:00:00Z"); + + private final Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + private final ListAppender moduleLines = new ListAppender<>(); + + @TempDir + Path worlds; + + private String originalZone; + private Env env; + private TestTitanNode titan; + private AdjustableClock clock; + private AtomicInteger stops; + private int online; + private BeanScope scope; + + private static final class AdjustableClock extends Clock { + + private Instant instant; + + AdjustableClock(Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return this.instant; + } + } + + @BeforeEach + void setUp(Env env) throws IOException { + this.originalZone = Config.get("seasons.zone"); + this.env = env; + this.titan = TestTitanNode.attach(env); + this.clock = new AdjustableClock(IN_WINTER); + this.stops = new AtomicInteger(); + this.online = 0; + this.moduleLines.start(); + this.rootLogger.addAppender(this.moduleLines); + Files.writeString(Files.createDirectories(this.worlds.resolve("winter-map")).resolve("map.json"), "{}"); + Config.setProperty("seasons.winter.world", "winter-map"); + Config.setProperty("seasons.winter.from", "2026-12-01T00:00:00"); + Config.setProperty("seasons.winter.to", "2027-01-07T00:00:00"); + } + + @AfterEach + void tearDown() { + if (this.scope != null) { + this.scope.close(); + } + this.titan.close(); + this.rootLogger.detachAppender(this.moduleLines); + Config.asProperties().stringPropertyNames().stream().filter(key -> key.startsWith("seasons.")).forEach(Config::clearProperty); + Config.setProperty("seasons.zone", this.originalZone); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void startScope() { + ServerStop stop = this.stops::incrementAndGet; + OnlinePlayers players = () -> this.online; + // Named mock, not the plain mock(Type) overload - see docs/lobby-modules.md, "Permission-Plattform". + this.scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).bean(Clock.class, this.clock).bean(Scheduler.class, this.env.process().scheduler()).bean(FeatureNode.TITAN_NODE, EventNode.class, (EventNode) this.titan.node()).bean(SeasonSchedule.class, new SeasonSchedule(this.clock, this.worlds)).bean(ServerStop.class, stop).bean(OnlinePlayers.class, players).build(); + } + + private void tickOneMinute() { + for (int i = 0; i < SeasonModule.CHECK_INTERVAL_TICKS; i++) { + this.env.tick(); + } + } + + private boolean logged(String fragment) { + return this.moduleLines.list.stream().anyMatch(line -> line.getFormattedMessage().contains(fragment)); + } + + @DisplayName("The lobby starts in the season world while its window is open") + @Test + void lobbyStartsInTheSeasonWorld() { + startScope(); + + Assertions.assertTrue(logged("Lobby world winter-map (season winter)"), "the started world must be the season world, got: " + this.moduleLines.list); + } + + @DisplayName("When the window ends with players online the restart is pending, not stopped") + @Test + void windowEndWithPlayersMakesTheRestartPending() { + this.online = 1; + startScope(); + + this.clock.instant = AFTER_WINTER; + tickOneMinute(); + + Assertions.assertTrue(logged("Restart to the default world pending"), "a restart to the default world must be pending, got: " + this.moduleLines.list); + Assertions.assertEquals(0, this.stops.get(), "a busy lobby must not be stopped"); + } + + @DisplayName("When the window ends and nobody is online the server is stopped") + @Test + void windowEndWithoutPlayersStopsTheServer() { + startScope(); + + this.clock.instant = AFTER_WINTER; + tickOneMinute(); + + Assertions.assertEquals(1, this.stops.get(), "an empty lobby must request exactly one stop"); + } +} diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/SeasonLobbyWorldTest.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/SeasonLobbyWorldTest.java new file mode 100644 index 00000000..922f0f16 --- /dev/null +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/SeasonLobbyWorldTest.java @@ -0,0 +1,102 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.runtime.bootstrap; + +import io.avaje.config.Config; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.ServiceLoader; +import net.minestom.server.instance.InstanceContainer; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.core.module.LobbyWorldChoice; +import net.onelitefeather.titan.feature.season.SeasonChoices; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +/** + * The wiring between the season column and the map loader (design D2): the season choice feeds + * {@link PlatformBeans}, and the service loader finds it. The choice is built with a fixed clock + * because the one the service loader instantiates reads the system time. + */ +@ExtendWith(MicrotusExtension.class) +class SeasonLobbyWorldTest { + + private static final Clock IN_WINTER = Clock.fixed(Instant.parse("2026-12-24T12:00:00Z"), ZoneOffset.UTC); + private static final Clock IN_SUMMER = Clock.fixed(Instant.parse("2026-06-01T12:00:00Z"), ZoneOffset.UTC); + + @TempDir + Path base; + + private String originalZone; + + @BeforeEach + void setUp() throws IOException { + this.originalZone = Config.get("seasons.zone"); + createWorld("world"); + createWorld("winter-map"); + Config.setProperty("seasons.winter.world", "winter-map"); + Config.setProperty("seasons.winter.from", "2026-12-01T00:00:00"); + Config.setProperty("seasons.winter.to", "2027-01-07T00:00:00"); + } + + @AfterEach + void tearDown() { + Config.asProperties().stringPropertyNames().stream().filter(key -> key.startsWith("seasons.")).forEach(Config::clearProperty); + Config.setProperty("seasons.zone", this.originalZone); + } + + private void createWorld(String name) throws IOException { + Path directory = Files.createDirectories(this.base.resolve("worlds").resolve(name)); + Files.writeString(directory.resolve("map.json"), "{\"name\":\"" + name + "\"}"); + } + + private MapProvider load(Env env, Clock clock) { + InstanceContainer instance = env.process().instance().createInstanceContainer(); + return PlatformBeans.loadMapProvider(this.base, instance, List.of(SeasonChoices.at(clock, this.base.resolve("worlds")))); + } + + @DisplayName("Inside the window the lobby loads the season world") + @Test + void insideTheWindowTheSeasonWorldIsLoaded(Env env) { + Assertions.assertEquals("winter-map", load(env, IN_WINTER).getActiveLobby().name(), "the season world must be the active lobby"); + } + + @DisplayName("Outside the window the lobby loads the default world") + @Test + void outsideTheWindowTheDefaultWorldIsLoaded(Env env) { + Assertions.assertEquals("world", load(env, IN_SUMMER).getActiveLobby().name(), "no active season must keep the default world"); + } + + @DisplayName("The service loader finds exactly the season choice on the cloudnet classpath") + @Test + void serviceLoaderFindsTheSeasonChoice() { + List found = ServiceLoader.load(LobbyWorldChoice.class).stream().map(provider -> provider.type().getSimpleName()).toList(); + + Assertions.assertEquals(List.of("SeasonWorldChoice"), found, "the merged service file must list the season choice once"); + } +} From 5d17a29fb6f44898bab1837ded5761abc7120ffd Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:44:43 +0200 Subject: [PATCH 14/17] docs(season): document adding a season world Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- README.md | 25 +++++++++++++++++++++++++ docs/lobby-modules.md | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7fb1fd7a..15f0f290 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,31 @@ the new value took effect, with no restart of any kind appearing in the log. **R the previous jar and restore `flags.properties` - a leftover `features.*` section or `config.watch.*` setting in `application.yaml` does not affect the previous jar. +### Seasonal lobby world + +The `cloudnet` variant can run the lobby in a season-specific world during a time window, for +example a winter map in December. To add one: + +1. Build the world in the setup server. +2. Put it in `worlds//` with its `map.json`, next to `worlds/world/`. +3. Configure it in `application.yaml`: + + ```yaml + seasons: + zone: Europe/Berlin # default; time zone of the windows + winter: # any id ("zone" is reserved) + world: winter # directory under worlds/ + from: 2026-12-01T00:00:00 # inclusive, local time in seasons.zone + to: 2027-01-07T00:00:00 # exclusive + enabled: true # kill switch, no restart of the configuration needed + ``` + +An invalid enabled season aborts startup with the key and the reason. Switching worlds needs a +restart: once a minute (and after every disconnect) the lobby compares its started world with the +one the calendar wants, and stops only when nobody is online so the supervisor starts it again. A +busy service therefore waits; an operator can end it sooner with `/stop`. `apps/local` does not +include this feature. + ## Development ### Building from Source diff --git a/docs/lobby-modules.md b/docs/lobby-modules.md index 4198ce43..fbe3aed0 100644 --- a/docs/lobby-modules.md +++ b/docs/lobby-modules.md @@ -58,8 +58,9 @@ apps/local ────┼─▶ runtime ───────────── `titan.app-variant` (Convention-Plugin in `buildSrc`) hängt eine Variante standardmäßig an `runtime` und an **jede** Column unter `features/*`. Eine Variante lässt einzelne Columns über `titanVariant { exclude("") }` weg - z. B. in `apps/local/build.gradle.kts`, falls eine -Entwicklungsvariante künftig eine Column nicht mitbringen soll. Heute enthalten `apps/cloudnet` -und `apps/local` dieselben Columns. Die Liste der Columns einer Variante steht damit an genau +Entwicklungsvariante künftig eine Column nicht mitbringen soll. `apps/local` lässt heute nur +`season` weg (kein Supervisor startet einen lokalen Server neu, s. "Saison-Welt" unten); sonst +enthalten beide Varianten dieselben Columns. Die Liste der Columns einer Variante steht damit an genau einer Stelle (dem Verzeichnis-Scan in `settings.gradle.kts`), nicht pro Variante gepflegt. `titan.app-variant` erzeugt außerdem: @@ -337,6 +338,36 @@ sie nicht - die shipped Defaults kommen aus der klassenpfad-`application.yaml` i Rangfolge aus `lobby-module-config` (Shipped-Default < `application.yaml` < Profil < externe Datei < Umgebungsvariable < System-Property) bleibt dadurch unverändert gültig. +## Saison-Welt (`season`, nur `cloudnet`) + +Die Column `season` lässt die Lobby in einem Zeitfenster in einer eigenen Welt laufen. Sie steckt +nur in `apps/cloudnet`, weil der Wechsel ein Neustart ist und nur ein Supervisor (CloudNet) den +Dienst danach wieder startet. Die Weltwahl beim Start läuft über `LobbyWorldChoice` (`core`), die +`PlatformBeans` per `ServiceLoader` findet; `season` trägt sich dafür unter +`META-INF/services/net.onelitefeather.titan.core.module.LobbyWorldChoice` ein. + +Eine Saisonwelt anlegen: + +1. Die Welt im Setup-Server bauen und speichern. +2. `worlds//` mit der `map.json` neben `worlds/world/` ablegen. +3. In `application.yaml` eintragen: + + ```yaml + seasons: + zone: Europe/Berlin # Standard; Zeitzone der Fenster + winter: # beliebige Id ("zone" ist reserviert) + world: winter # Verzeichnis unter worlds/ + from: 2026-12-01T00:00:00 # inklusiv, lokale Zeit in seasons.zone + to: 2027-01-07T00:00:00 # exklusiv + enabled: true # Abschalter, wirkt ohne Neustart der Konfiguration + ``` + +Ein ungültiger, aktivierter Eintrag (fehlender Schlüssel, Datum, Welt oder `map.json`) bricht den +Start mit dem Schlüssel und dem Grund ab. Der Wechsel selbst braucht einen Neustart: Die Column +prüft jede Minute und nach jedem Verlassen, ob die gestartete von der gewünschten Welt abweicht, +und stoppt die Lobby erst, wenn kein Spieler mehr online ist. Ein belegter Dienst kann daher +länger warten; ein Betreiber kann ihn mit `/stop` sofort beenden. + ## Erwartete Columns einer Variante `titan.app-variant` schreibt beim Bauen die Avaje-Modulnamen aller in eine Variante eingebundenen @@ -597,6 +628,7 @@ als `provides`): | `tickle` | `EventNode.class`, `Clock.class` | `EventNode:titan` | - | | `elytra` | `EventNode.class`, `Scheduler.class` | `EventNode:titan` | `LobbyItem.class` | | `hotbar` | `EventNode.class` | `EventNode:titan` | `LobbyItems.class` | +| `season` (nur `cloudnet`) | `Scheduler.class`, `Clock.class`, `EventNode.class` | `EventNode:titan` | - | `EventNode` ist in jeder Zeile der einzige `requiresString`-Eintrag, weil es der einzige qualifizierte, generische Plattform-Typ ist (Frage 1 oben); die anderen Typen sind weder generisch From 9f66bd4713a35b8340fc930fad63789fb5030978 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:46:25 +0200 Subject: [PATCH 15/17] docs(openspec): tick variant and docs tasks of seasonal-lobby-world Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/seasonal-lobby-world/tasks.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openspec/changes/seasonal-lobby-world/tasks.md b/openspec/changes/seasonal-lobby-world/tasks.md index df71db51..ce083628 100644 --- a/openspec/changes/seasonal-lobby-world/tasks.md +++ b/openspec/changes/seasonal-lobby-world/tasks.md @@ -39,10 +39,10 @@ Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhanden ## 4. Varianten, Doku und Abnahme (Welle 3) -- [ ] 4.1 Test zuerst (Integration, Starttest je Variante): `apps/cloudnet` lädt `seasonColumn` und erwartet sie im Startcheck; `apps/local` hat die Column nicht auf dem Klassenpfad und keine `seasons`-Standardwerte im Beispiel-`application.yaml`. Dann `apps/local/build.gradle.kts` um `titanVariant { exclude("season") }` mit einem Kommentar zum Grund (kein Supervisor) ergänzen. Nachweis: beide Starttests grün. -- [ ] 4.2 Test zuerst (Unit in `apps/cloudnet`): Die `map.json`-Konstante der Column entspricht `MapEntry.MAP_FILE_NAME`; rot bei Abweichung. Nachweis: Test grün. -- [ ] 4.3 Integrationstest in `apps/cloudnet` (echter `BeanScope`, `@TempDir`-`worlds/`, gesetzte `seasons.*`, feste `Clock`): Bei aktivem Fenster lädt die Lobby die Saison-Welt, danach vorgemerkter Neustart bei Fensterende, gefälschter Stopp. Nachweis: Test grün (deckt die Verdrahtung aus D2 ab). -- [ ] 4.4 README (Abschnitt Betrieb) und `docs/lobby-modules.md` (Tabelle der Columns, Hinweis „nur `cloudnet`“): Wie man eine Saisonwelt anlegt (Welt im Setup-Server bauen, `worlds//` mit `map.json` ablegen, `seasons..*` eintragen, Abschalter, Zeitzone), dass der Wechsel einen Neustart bei leerer Lobby braucht und ein belegter Dienst warten kann (`/stop` als Betreibermittel). Nachweis: Doku nennt Schlüssel, Beispiel und Kompromiss. +- [x] 4.1 Test zuerst (Integration, Starttest je Variante): `apps/cloudnet` lädt `seasonColumn` und erwartet sie im Startcheck; `apps/local` hat die Column nicht auf dem Klassenpfad und keine `seasons`-Standardwerte im Beispiel-`application.yaml`. Dann `apps/local/build.gradle.kts` um `titanVariant { exclude("season") }` mit einem Kommentar zum Grund (kein Supervisor) ergänzen. Nachweis: beide Starttests grün. +- [x] 4.2 Test zuerst (Unit in `apps/cloudnet`): Die `map.json`-Konstante der Column entspricht `MapEntry.MAP_FILE_NAME`; rot bei Abweichung. Nachweis: Test grün. +- [x] 4.3 Integrationstest in `apps/cloudnet` (echter `BeanScope`, `@TempDir`-`worlds/`, gesetzte `seasons.*`, feste `Clock`): Bei aktivem Fenster lädt die Lobby die Saison-Welt, danach vorgemerkter Neustart bei Fensterende, gefälschter Stopp. Nachweis: Test grün (deckt die Verdrahtung aus D2 ab). +- [x] 4.4 README (Abschnitt Betrieb) und `docs/lobby-modules.md` (Tabelle der Columns, Hinweis „nur `cloudnet`“): Wie man eine Saisonwelt anlegt (Welt im Setup-Server bauen, `worlds//` mit `map.json` ablegen, `seasons..*` eintragen, Abschalter, Zeitzone), dass der Wechsel einen Neustart bei leerer Lobby braucht und ein belegter Dienst warten kann (`/stop` als Betreibermittel). Nachweis: Doku nennt Schlüssel, Beispiel und Kompromiss. - [ ] 4.5 Verifikation (Haiku, read-only): Szenarien von `lobby-seasons` und `app-variants` Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, kein Schreiben ins echte Arbeitsverzeichnis, `Config`-Werte zurückgesetzt). Nachweis: Bericht ohne Lücken. ## 5. Pull Request From f4652106a940aa0a57586d4a39740a5c8c94086e Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:52:41 +0200 Subject: [PATCH 16/17] test(season): cover enabling a season live inside its window Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/feature/season/SeasonModuleTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonModuleTest.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonModuleTest.java index faa407ac..dd5bb369 100644 --- a/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonModuleTest.java +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonModuleTest.java @@ -333,6 +333,23 @@ void liveEnabledSeasonWithoutWorldWarnsAndSchedulesNothing() { Assertions.assertEquals(List.of(), infoMessages()); } + @DisplayName("Enabling a season live inside its window makes a restart into its world pending") + @Test + void liveEnabledSeasonInsideItsWindowMakesARestartPending() { + Config.setProperty("seasons.winter.enabled", "false"); + this.clock.set(IN_WINTER); + this.fakeOnline = 1; + startInDefaultWorld(); + tickMinutes(1); + Assertions.assertEquals(List.of(), infoMessages(), "precondition: a disabled season schedules nothing"); + + Config.setProperty("seasons.winter.enabled", "true"); + tickMinutes(1); + + Assertions.assertEquals(List.of("Restart for season winter pending since " + IN_WINTER), infoMessages()); + Assertions.assertEquals(0, this.serverStop.calls(), "a player is online, so the pending restart must not stop the lobby"); + } + @DisplayName("A value that turns invalid live warns and neither schedules nor stops, even for the running season") @Test void liveInvalidValueSchedulesNothing() { From 2c68de8a5cb5810c9edef2fee1fb4ad17dcef6f7 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 12:52:50 +0200 Subject: [PATCH 17/17] docs(openspec): tick verification task of seasonal-lobby-world Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/seasonal-lobby-world/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/seasonal-lobby-world/tasks.md b/openspec/changes/seasonal-lobby-world/tasks.md index ce083628..abbb7dfe 100644 --- a/openspec/changes/seasonal-lobby-world/tasks.md +++ b/openspec/changes/seasonal-lobby-world/tasks.md @@ -43,7 +43,7 @@ Jeder Agent-Prompt nennt die Regeln, die für seinen Task gelten: erst Vorhanden - [x] 4.2 Test zuerst (Unit in `apps/cloudnet`): Die `map.json`-Konstante der Column entspricht `MapEntry.MAP_FILE_NAME`; rot bei Abweichung. Nachweis: Test grün. - [x] 4.3 Integrationstest in `apps/cloudnet` (echter `BeanScope`, `@TempDir`-`worlds/`, gesetzte `seasons.*`, feste `Clock`): Bei aktivem Fenster lädt die Lobby die Saison-Welt, danach vorgemerkter Neustart bei Fensterende, gefälschter Stopp. Nachweis: Test grün (deckt die Verdrahtung aus D2 ab). - [x] 4.4 README (Abschnitt Betrieb) und `docs/lobby-modules.md` (Tabelle der Columns, Hinweis „nur `cloudnet`“): Wie man eine Saisonwelt anlegt (Welt im Setup-Server bauen, `worlds//` mit `map.json` ablegen, `seasons..*` eintragen, Abschalter, Zeitzone), dass der Wechsel einen Neustart bei leerer Lobby braucht und ein belegter Dienst warten kann (`/stop` als Betreibermittel). Nachweis: Doku nennt Schlüssel, Beispiel und Kompromiss. -- [ ] 4.5 Verifikation (Haiku, read-only): Szenarien von `lobby-seasons` und `app-variants` Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, kein Schreiben ins echte Arbeitsverzeichnis, `Config`-Werte zurückgesetzt). Nachweis: Bericht ohne Lücken. +- [x] 4.5 Verifikation (Haiku, read-only): Szenarien von `lobby-seasons` und `app-variants` Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, kein Schreiben ins echte Arbeitsverzeichnis, `Config`-Werte zurückgesetzt). Nachweis: Bericht ohne Lücken. ## 5. Pull Request