From ea499ac06cd6de17bfb7df18625d356f3cd31589 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 10:48:17 +0200 Subject: [PATCH 1/9] docs(openspec): propose realtime-lobby-daytime Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../realtime-lobby-daytime/.openspec.yaml | 2 + .../changes/realtime-lobby-daytime/design.md | 68 +++++++++++++++ .../realtime-lobby-daytime/proposal.md | 39 +++++++++ .../specs/lobby-daytime/spec.md | 84 +++++++++++++++++++ .../changes/realtime-lobby-daytime/tasks.md | 36 ++++++++ 5 files changed, 229 insertions(+) create mode 100644 openspec/changes/realtime-lobby-daytime/.openspec.yaml create mode 100644 openspec/changes/realtime-lobby-daytime/design.md create mode 100644 openspec/changes/realtime-lobby-daytime/proposal.md create mode 100644 openspec/changes/realtime-lobby-daytime/specs/lobby-daytime/spec.md create mode 100644 openspec/changes/realtime-lobby-daytime/tasks.md diff --git a/openspec/changes/realtime-lobby-daytime/.openspec.yaml b/openspec/changes/realtime-lobby-daytime/.openspec.yaml new file mode 100644 index 00000000..3b4044e8 --- /dev/null +++ b/openspec/changes/realtime-lobby-daytime/.openspec.yaml @@ -0,0 +1,2 @@ +schema: olf-spec-driven +created: 2026-09-29 diff --git a/openspec/changes/realtime-lobby-daytime/design.md b/openspec/changes/realtime-lobby-daytime/design.md new file mode 100644 index 00000000..55185d99 --- /dev/null +++ b/openspec/changes/realtime-lobby-daytime/design.md @@ -0,0 +1,68 @@ +# Design + +## Context + +Motivation: siehe proposal.md. Anforderungen: `specs/lobby-daytime`. + +Ist-Zustand: + +- `MapProvider` (`common`) setzt die Lobby-Instanz auf `setTime(6000)` und stoppt die Uhr mit `defaultClock().rate(0f)`. Die Uhr kann `null` sein. +- `PlatformBeans` stellt `Clock` (`Clock.systemUTC()`), `InstanceContainer` (auch als `Instance` auffindbar) und `Scheduler` als Beans bereit. Die Zeitzone steckt daher nicht in der `Clock`. +- Ein Column ist ein Verzeichnis unter `features/` mit `titan.column`; `settings.gradle.kts` erfasst es automatisch. Vorlage ist `features/tickle` (`@Singleton` mit `@PostConstruct`/`@PreDestroy`, Standardwerte in `titan/defaults/.yaml`, Werte über die `Config`-Fassade). +- `lobby-module-config` verlangt: ungültige Werte brechen den Start ab, kein stilles Ersetzen. + +## Goals / Non-Goals + +**Goals:** +- Lineare Echtzeit-Abbildung, abschaltbar, ohne Neustart konfigurierbar. +- Reine Rechenlogik ohne Server testbar. + +**Non-Goals:** +- Sonnenstand, Jahreszeiten, Strategie-Schnittstelle, eigene Konfigurationsklassen-Hierarchie (siehe proposal.md, Nicht-Ziele). +- Änderungen an `MapProvider` oder `runtime`. + +## Decisions + +### D1: Reine Abbildung `DayTimeMapping` + +Finale Klasse mit `long ticksAt(Instant, ZoneId)`: `floorMod(secondOfDay - 6h, 86400) * 24000 / 86400`. Ergebnis im Bereich [0, 24000). 06:00 → 0, 12:00 → 6000, 18:00 → 12000, 00:00 → 18000. `secondOfDay` kommt aus `instant.atZone(zone).toLocalTime()`, also folgt die Abbildung der Wanduhr inklusive Sommerzeit: Beim Vorstellen springt sie vorwärts, beim Zurückstellen wiederholt sie die Werte der doppelten Stunde. Sonst gibt es keinen Rückwärtssprung, und ein Neustart verschiebt nichts, weil nur `Instant` und Zone eingehen. +Built-in: `java.time` (`ZoneId`, `ZonedDateTime`), keine eigene Zeitzonen- oder Kalenderlogik. +Verworfen: eine Strategie-Schnittstelle. Es gibt genau eine Abbildung; eine Schnittstelle wäre totes Gerüst, bis ein zweiter Fall existiert. +SOLID: SRP. Test: Unit (`DayTimeMappingTest`): Ankerwerte, beide Berliner Umstellungen, Sonnenwenden, Bereich, Determinismus, kein Rückwärtssprung über das Vorstellen, genau ein Umlauf je 24 h. + +### D2: `DaytimeModule` als `@Singleton` + +Konstruktor: `Instance` (nicht `InstanceContainer`, `runtime` stellt nur `Instance` bereit), `Scheduler`, `Clock` (alles Plattform-Beans, siehe Kontext), `DayTimeMapping` wird direkt erzeugt. `@PostConstruct`: `defaultClock()` holen; bei `null` WARN loggen und ohne `rate(0f)` weitermachen, sonst `rate(0f)`. Danach eine wiederkehrende Aufgabe alle 20 Ticks über `Scheduler.buildTask(...).repeat(TaskSchedule.tick(20))` einplanen, außerdem sofort einmal ausführen, damit die Zeit ab dem Start stimmt. Jeder Lauf: `enabled ? mapping.ticksAt(clock.instant(), zone) : 6000` → `lobby.setTime(...)`. `@PreDestroy`: Aufgabe abbrechen (`Task.cancel()`). +Ein Takt von 20 Ticks erfüllt „höchstens einmal pro Sekunde“ ohne Sperre gegen häufigere Aufrufe: Die Methode ist nicht öffentlich aufrufbar, anders als in PR #217. +Built-in: Minestoms `Scheduler`. Verworfen: `java.util.concurrent`-Timer, weil der Scheduler auf dem Server-Takt läuft und beim Herunterfahren mit dem Server endet. +SOLID: SRP, DIP (Plattform-Beans über Konstruktor). Test: Integration (`DaytimeModuleTest`). + +### D3: Konfiguration `daytime.*`, jeden Lauf neu gelesen + +`titan/defaults/daytime.yaml` mit `daytime.enabled: true` und `daytime.zone: Europe/Berlin`. Beide Werte liest der Lauf jedes Mal über die `Config`-Fassade (`getBool`, `get`), sodass Live-Reload ohne Neustart wirkt, wie bei `tickle`. Die `Clock`-Bean ist UTC, deshalb braucht es die Zeitzone als eigenen Schlüssel. +Ungültige Zone: Beim Start wird sie einmal geparst, ein Fehler bricht den Start ab (Regel aus `lobby-module-config`, kein stilles Ersetzen). Zur Laufzeit hält eine ungültige Änderung die Lobby nicht an: Sie behält die zuletzt gültige Zone und loggt WARN mit dem Schlüssel `daytime.zone`. Ein WARN pro Lauf wäre Log-Spam, daher wird nur beim Wechsel des ungültigen Werts geloggt. +Verworfen: Rückfall auf die Mittags-Starre oder Berlin bei ungültigem Wert. Das würde die Konfiguration stillschweigend ersetzen und die Betreiberin nicht am Start scheitern lassen, wo sie es sofort sieht. +Built-in: `avaje-config` (schon im Stack), `ZoneId.of`. Keine eigenen Config-Klassen wie in PR #217. +SOLID: SRP. Test: Integration (Zonenwechsel, ungültige Zone beim Start und zur Laufzeit, WARN über einen aufgefangenen Appender). + +### D4: `MapProvider` bleibt, keine Startreihenfolge + +Der Mittags-Stand aus `MapProvider` ist der Rückfall für Varianten ohne dieses Modul. Das Modul überschreibt ihn innerhalb einer Sekunde, egal in welcher Reihenfolge die Beans entstehen, denn `setTime` und `rate(0f)` sind idempotent. Deshalb braucht es weder `@Order`-artige Abhängigkeiten noch eine Änderung an `MapProvider`. +Test: Integration (Zeit stimmt nach dem ersten Lauf, auch wenn zuvor `setTime(6000)` gesetzt war). + +### D5: Wartung und Tests ohne Warten + +`DaytimeModuleTest` nutzt `MicrotusExtension`/`Env`, eine einstellbare `Clock` und `env.tick()`. Der Test schaltet `daytime.*` über `Config.setProperty` und liest die Ticks der Instanz nach dem Tick, der den Lauf auslöst. `ColumnArchitectureTest` folgt der Vorlage aus `tickle`. Keine Sleeps, keine Systemzeit. + +## Risks / Trade-offs + +- [Kein Sonnenaufgang wie in der Realität, nur linear] → Bewusst; eine Sonnenstand-Abbildung wäre ein eigener `feat`-Change. +- [Zwei Schreiber auf die Instanzzeit: `MapProvider` und Modul] → Nur beim Start; das Modul gewinnt beim ersten Lauf (D4). +- [Zeitumstellung erzeugt einen sichtbaren Sprung um eine Stunde] → Gewollt wie die Wanduhr, festgelegt in der Spec. +- [Live-Reload hängt an `avaje-config`] → Gleicher Weg wie `tickle`; ohne Reload gilt der Wert vom Start. + +## Migration Plan + +1. Deploy: Nichts zu konfigurieren; Standard ist an, Zone Berlin. AOT-Cache der Lobby neu trainieren. +2. Abschalten: `daytime.enabled: false`, wirkt ohne Neustart. +3. Rollback: Revert des Squash-Commits; `MapProvider` friert die Lobby wieder auf Mittag. diff --git a/openspec/changes/realtime-lobby-daytime/proposal.md b/openspec/changes/realtime-lobby-daytime/proposal.md new file mode 100644 index 00000000..a6f51a76 --- /dev/null +++ b/openspec/changes/realtime-lobby-daytime/proposal.md @@ -0,0 +1,39 @@ +# Proposal + +## Why + +`MapProvider` friert die Lobby auf Mittag ein. Spieler sehen dadurch nie Tag und Nacht, und die Lobby wirkt statisch. PR #217 wollte das mit Jahreszeiten, Sonnenstand-Strategie und `time.json` lösen, ist aber veraltet und für den Nutzen zu groß. Dieser Change ersetzt ihn durch das Kleinste, das trägt: die Tageszeit der Lobby folgt der echten Uhrzeit. + +## What Changes + +- Neues Column-Modul `features/daytime`: setzt die Instanzzeit einmal pro Sekunde linear aus der Wanduhr einer konfigurierten Zeitzone (06:00 = Sonnenaufgang, 12:00 = Mittag, 18:00 = Sonnenuntergang, 00:00 = Mitternacht). +- Der Minestom-Zeitzyklus der Lobby-Instanz wird angehalten, damit nur die Wanduhr die Zeit bestimmt. +- Zwei Schlüssel unter `daytime.*`: `daytime.enabled` (Abschalter) und `daytime.zone` (Standard `Europe/Berlin`). Beide wirken ohne Neustart. +- Ist das Modul abgeschaltet, steht die Lobby wieder auf Mittag. +- `MapProvider` bleibt unverändert: Sein Mittags-Stand ist der Rückfall für Varianten ohne dieses Modul. +- Kein PR-Ballast: keine Strategie-Schnittstelle, kein Sonnenstand, keine Jahreszeiten, keine `time.json`. + +## Capabilities + +### New Capabilities + +- `lobby-daytime`: Die Tageszeit der Lobby folgt der echten Uhrzeit einer konfigurierbaren Zeitzone, lässt sich abschalten und verhält sich bei Zeitumstellungen nachvollziehbar. + +### Modified Capabilities + +_Keine._ Die Notiz `docs/spec-lobby-saison-events.md` bleibt unberührt. + +## Impact + +- **Code**: neues Modul `features/daytime` (wird von `settings.gradle.kts` automatisch erfasst), Standardwerte in `titan/defaults/daytime.yaml`. Kein bestehender Code ändert sich. +- **Abhängigkeiten**: keine neuen. `avaje-config` ist bereits im Katalog. +- **Tests**: Unit-Tests für die Zeitabbildung, ein Integrationstest mit Cyano-Env für das Modul, `ColumnArchitectureTest`. +- **Nutzertexte**: keine. +- **Betrieb**: neue optionale Schlüssel `daytime.enabled`, `daytime.zone`. Nichts zu migrieren. +- **Ersetzt**: PR #217 wird nach dem Merge dieses Changes als überholt geschlossen. + +## Delivery + +PR-Titel: `feat(daytime): follow the real wall clock in the lobby` + +Nicht-Ziele, jeweils eigener Change: Sonnenstand-Abbildung (späteres `feat`), Jahreszeiten, Aktualisierung von `docs/spec-lobby-saison-events.md`. diff --git a/openspec/changes/realtime-lobby-daytime/specs/lobby-daytime/spec.md b/openspec/changes/realtime-lobby-daytime/specs/lobby-daytime/spec.md new file mode 100644 index 00000000..b2369e8d --- /dev/null +++ b/openspec/changes/realtime-lobby-daytime/specs/lobby-daytime/spec.md @@ -0,0 +1,84 @@ +# Spec Delta + +## Purpose + +Legt fest, wie die Tageszeit der Lobby der echten Uhrzeit folgt: linear nach der Wanduhr einer konfigurierbaren Zeitzone, abschaltbar und ohne Neustart umstellbar. + +## ADDED Requirements + +### Requirement: Die Lobby folgt der Wanduhr der konfigurierten Zeitzone +Die Tageszeit der Lobby MUSS linear der lokalen Uhrzeit der konfigurierten Zeitzone entsprechen: 06:00 Uhr ist Sonnenaufgang, 12:00 Uhr Mittag, 18:00 Uhr Sonnenuntergang und 00:00 Uhr Mitternacht. Der Wert MUSS im Bereich eines Minecraft-Tages (0 bis unter 24000 Ticks) liegen und über 24 Stunden genau einmal umlaufen. Der Standard der Zeitzone MUSS `Europe/Berlin` sein. + +#### Scenario: Ankerzeiten +- **WHEN** es in der konfigurierten Zeitzone 06:00, 12:00, 18:00 bzw. 00:00 Uhr ist +- **THEN** steht die Instanzzeit auf 0, 6000, 12000 bzw. 18000 Ticks + +#### Scenario: Zwischenwert +- **WHEN** es in der konfigurierten Zeitzone 09:00 Uhr ist +- **THEN** steht die Instanzzeit auf 3000 Ticks + +#### Scenario: Andere Zeitzone +- **WHEN** die Zeitzone auf `Asia/Tokyo` steht und es dort 06:00 Uhr ist +- **THEN** steht die Instanzzeit auf 0 Ticks, unabhängig von der Uhrzeit in Berlin + +### Requirement: Der Minestom-Zeitzyklus ist angehalten +Die Lobby DARF die Zeit NICHT von selbst weiterlaufen lassen. Zwischen zwei Aktualisierungen MUSS die Instanzzeit konstant bleiben, damit ausschließlich die Wanduhr sie bestimmt. + +#### Scenario: Zeit läuft nicht von allein +- **WHEN** der Server Ticks verarbeitet, ohne dass eine Aktualisierung fällig ist +- **THEN** ändert sich die Instanzzeit nicht + +### Requirement: Die Zeit stammt aus der Uhr der Plattform +Die Lobby MUSS die Uhrzeit aus der Uhr beziehen, die die Plattform bereitstellt, nicht aus einer eigenen Zeitquelle. Wird diese Uhr ersetzt, MUSS die Instanzzeit dem ersetzten Wert folgen. + +#### Scenario: Ersetzte Uhr +- **WHEN** die bereitgestellte Uhr 18:00 Uhr in der konfigurierten Zeitzone anzeigt +- **THEN** steht die Instanzzeit nach der nächsten Aktualisierung auf 12000 Ticks + +### Requirement: Zeitumstellungen springen mit der Wanduhr +Bei Zeitumstellungen MUSS die Instanzzeit der lokalen Wanduhr folgen. Beim Vorstellen der Uhr (Frühjahr) MUSS die Zeit nach vorn springen, ohne dass sie sich rückwärts bewegt. Beim Zurückstellen (Herbst) wiederholt sich die Uhrzeit der doppelten Stunde, die Instanzzeit MUSS dabei ebenfalls dem Wanduhr-Wert dieser Stunde folgen. Außerhalb der Umstellung DARF die Zeit NICHT rückwärts springen, und ein Neustart DARF sie nicht verschieben. + +#### Scenario: Vorstellen der Uhr +- **WHEN** in `Europe/Berlin` die Uhr von 01:59:59 auf 03:00:00 Uhr vorgestellt wird +- **THEN** springt die Instanzzeit von dem Wert für 01:59:59 auf den Wert für 03:00:00 nach vorn und bewegt sich zu keinem Zeitpunkt rückwärts + +#### Scenario: Zurückstellen der Uhr +- **WHEN** in `Europe/Berlin` die Uhr von 02:59:59 Sommerzeit auf 02:00:00 Winterzeit zurückgestellt wird +- **THEN** entspricht die Instanzzeit danach dem Wert für 02:00:00 Uhr + +#### Scenario: Neustart verschiebt nichts +- **WHEN** die Lobby zur selben Uhrzeit einmal früher und einmal später gestartet wird +- **THEN** steht die Instanzzeit nach dem Start in beiden Fällen auf demselben Wert + +### Requirement: Aktualisierung höchstens einmal pro Sekunde +Die Lobby MUSS die Instanzzeit einmal pro Sekunde aktualisieren und DARF sie NICHT öfter setzen. + +#### Scenario: Takt +- **WHEN** die Lobby 100 Ticks lang läuft +- **THEN** wurde die Instanzzeit höchstens fünfmal gesetzt + +### Requirement: Abschalter für die Echtzeit +Steht `daytime.enabled` auf `false`, MUSS die Lobby innerhalb einer Sekunde auf Mittag (6000 Ticks) stehen und dort bleiben. Steht der Wert wieder auf `true`, MUSS die Lobby ohne Neustart der Wanduhr folgen. Der Standard MUSS `true` sein. + +#### Scenario: Abschalten +- **WHEN** die Lobby der Wanduhr folgt und `daytime.enabled` zur Laufzeit auf `false` gesetzt wird +- **THEN** steht die Instanzzeit nach der nächsten Aktualisierung auf 6000 Ticks + +#### Scenario: Wieder einschalten +- **WHEN** `daytime.enabled` von `false` zur Laufzeit auf `true` gesetzt wird +- **THEN** folgt die Instanzzeit nach der nächsten Aktualisierung wieder der Wanduhr + +### Requirement: Zeitzone ist ohne Neustart änderbar und wird geprüft +Die Zeitzone MUSS aus `daytime.zone` gelesen werden, und eine Änderung zur Laufzeit MUSS mit der nächsten Aktualisierung wirken. Ist die Zeitzone beim Start ungültig, MUSS die Lobby den Start abbrechen; die Fehlermeldung MUSS `daytime.zone` und den Grund nennen. Wird sie zur Laufzeit auf einen ungültigen Wert geändert, MUSS die Lobby die zuletzt gültige Zeitzone weiterverwenden und eine Warnung mit `daytime.zone` protokollieren. + +#### Scenario: Zeitzone wechselt zur Laufzeit +- **WHEN** `daytime.zone` von `Europe/Berlin` auf `America/New_York` geändert wird +- **THEN** folgt die Instanzzeit nach der nächsten Aktualisierung der Wanduhr in New York + +#### Scenario: Ungültige Zeitzone beim Start +- **WHEN** die Lobby mit `daytime.zone: Mars/Olympus` startet +- **THEN** bricht der Start ab, und die Fehlermeldung nennt `daytime.zone` + +#### Scenario: Ungültige Zeitzone zur Laufzeit +- **WHEN** `daytime.zone` zur Laufzeit auf `Mars/Olympus` geändert wird +- **THEN** bleibt die zuletzt gültige Zeitzone in Kraft, und das Log enthält eine Warnung mit `daytime.zone` diff --git a/openspec/changes/realtime-lobby-daytime/tasks.md b/openspec/changes/realtime-lobby-daytime/tasks.md new file mode 100644 index 00000000..721e0959 --- /dev/null +++ b/openspec/changes/realtime-lobby-daytime/tasks.md @@ -0,0 +1,36 @@ +# Tasks + +## Execution Plan + +Integrationszweig: `feat/daytime` von `origin/main`. Eine Welle, weil das Modul klein ist. Endet mit grünem `./gradlew build` und geprüftem Diff. + +| Wave | Agent | Task IDs | Model | May Touch | Must Not Touch | +| ---- | ----- | -------- | ----- | --------- | -------------- | +| 1 | daytime | 1.1–3.4 | sonnet | `features/daytime/**` | `common/**`, `runtime/**`, `core/**`, andere `features/**`, `docs/**` | +| 2 | verify | 4.1 | haiku | read-only | alles | + +## 1. Zeitabbildung (unit) + +- [ ] 1.1 Modul `features/daytime` mit `build.gradle.kts` (`titan.column`, `avaje-config`) und `package-info.java` (`@InjectModule`, `requires = {Instance.class, Scheduler.class, Clock.class}` wie bei `tickle`, denn `runtime` stellt nur `Instance` bereit) anlegen; `./gradlew :features:daytime:build` läuft durch +- [ ] 1.2 `DayTimeMappingTest` zuerst (unit): Ankerwerte, beide Berliner Umstellungen, Sonnenwenden, Bereich [0, 24000), Determinismus, kein Rückwärtssprung über das Vorstellen, genau ein Umlauf je 24 h; rot +- [ ] 1.3 `DayTimeMapping` implementieren, `DayTimeMappingTest` grün + +## 2. Konfiguration (unit) + +- [ ] 2.1 Test zuerst: `daytime.yaml` liefert `enabled=true` und `zone=Europe/Berlin`, ungültige Zone wird abgelehnt (unit, kein Server); rot +- [ ] 2.2 `titan/defaults/daytime.yaml` und die Zonen-Prüfung umsetzen, Test grün + +## 3. Modul (integration) + +- [ ] 3.1 `DaytimeModuleTest` zuerst (Cyano `Env`, einstellbare `Clock`, `env.tick()`): `rate` ist 0, Zeit folgt der Wanduhr, höchstens ein Setzen je 20 Ticks, `enabled=false` ergibt 6000, Umschalten live, Zonenwechsel live, ungültige Zone beim Start bricht ab, ungültige Zone zur Laufzeit behält die letzte und loggt WARN (aufgefangener Appender), `null`-Uhr loggt WARN; rot +- [ ] 3.2 `DaytimeModule` implementieren (`@Singleton`, `@PostConstruct` mit `rate(0f)` und 20-Tick-Aufgabe samt Sofortlauf, `@PreDestroy` bricht ab), Test grün +- [ ] 3.3 `ColumnArchitectureTest` nach dem Muster von `features/tickle` ergänzen und grün +- [ ] 3.4 `./gradlew build` läuft durch, Commits je Typ (`feat(daytime): ...`), keine Datei außerhalb von `features/daytime/**` geändert + +## 4. Abnahme + +- [ ] 4.1 Haiku-Review des Diffs gegen `specs/lobby-daytime` und F.I.R.S.T. (keine Sleeps, keine Systemzeit, keine Reihenfolgenabhängigkeit) + +## 5. Pull Request + +- [ ] 5.1 Pull Request mit dem Titel `feat(daytime): follow the real wall clock in the lobby` eröffnen (Titel und Beschreibung Englisch); nach dessen Merge PR #217 mit einem Kommentar schließen, der auf den neuen PR verweist (superseded); falls das Archiv nicht mitgeliefert wird, Archiv-Commit `docs(openspec): archive realtime-lobby-daytime` From febd5bb722b08369783bb1a1d0eaf72a33cb08e5 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 10:50:14 +0200 Subject: [PATCH 2/9] build(daytime): add the daytime column skeleton Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- features/daytime/build.gradle.kts | 12 +++++++++ .../titan/feature/daytime/package-info.java | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 features/daytime/build.gradle.kts create mode 100644 features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/package-info.java diff --git a/features/daytime/build.gradle.kts b/features/daytime/build.gradle.kts new file mode 100644 index 00000000..42a04173 --- /dev/null +++ b/features/daytime/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("titan.column") +} + +dependencies { + // DaytimeModule reads daytime.* 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) + + // ListAppender, for capturing the module's WARN lines in DaytimeModuleTest. + testImplementation(libs.logback.classic) +} diff --git a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/package-info.java b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/package-info.java new file mode 100644 index 00000000..dbf6d286 --- /dev/null +++ b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/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 daytime} column: the lobby's time of day follows the real wall clock. See + * {@code docs/lobby-modules.md}, "Wie eine Column Plattform-Beans bekommt", for how a column + * declares its platform dependencies. + */ +@InjectModule(name = "daytimeColumn", requires = {Instance.class, Scheduler.class, Clock.class}) +package net.onelitefeather.titan.feature.daytime; + +import io.avaje.inject.InjectModule; +import java.time.Clock; +import net.minestom.server.instance.Instance; +import net.minestom.server.timer.Scheduler; From 51111b28f4a55a78ce89a9ba4bb6b251841583b4 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 10:50:26 +0200 Subject: [PATCH 3/9] feat(daytime): map wall clock time to lobby ticks Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/feature/daytime/DayTimeMapping.java | 41 +++++ .../feature/daytime/DayTimeMappingTest.java | 156 ++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DayTimeMapping.java create mode 100644 features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DayTimeMappingTest.java diff --git a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DayTimeMapping.java b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DayTimeMapping.java new file mode 100644 index 00000000..c466fb7a --- /dev/null +++ b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DayTimeMapping.java @@ -0,0 +1,41 @@ +/** + * 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.daytime; + +import java.time.Instant; +import java.time.ZoneId; + +/** + * Maps a wall-clock time linearly onto a Minecraft day: 06:00 is tick 0 (sunrise), 12:00 tick + * 6000, 18:00 tick 12000 and 00:00 tick 18000. + * + *

Only the instant and the zone go in, so the result is the same after a restart, and it + * follows the wall clock through daylight-saving changes. + */ +final class DayTimeMapping { + + static final long TICKS_PER_DAY = 24000L; + static final long NOON_TICKS = 6000L; + + private static final long SECONDS_PER_DAY = 86400L; + private static final long SUNRISE_SECOND_OF_DAY = 6L * 3600L; + + long ticksAt(Instant instant, ZoneId zone) { + long secondOfDay = instant.atZone(zone).toLocalTime().toSecondOfDay(); + long secondsSinceSunrise = Math.floorMod(secondOfDay - SUNRISE_SECOND_OF_DAY, SECONDS_PER_DAY); + return secondsSinceSunrise * TICKS_PER_DAY / SECONDS_PER_DAY; + } +} diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DayTimeMappingTest.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DayTimeMappingTest.java new file mode 100644 index 00000000..03bf4703 --- /dev/null +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DayTimeMappingTest.java @@ -0,0 +1,156 @@ +/** + * 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.daytime; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DayTimeMappingTest { + + private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + private static final ZoneId TOKYO = ZoneId.of("Asia/Tokyo"); + private static final long TICKS_PER_DAY = 24000L; + + private final DayTimeMapping mapping = new DayTimeMapping(); + + private long ticksInBerlin(String isoInstant) { + return this.mapping.ticksAt(Instant.parse(isoInstant), BERLIN); + } + + @DisplayName("06:00 is sunrise at tick 0") + @Test + void sunriseIsTickZero() { + Assertions.assertEquals(0L, ticksInBerlin("2026-01-15T05:00:00Z"), "06:00 CET"); + } + + @DisplayName("12:00 is noon at tick 6000") + @Test + void noonIsTick6000() { + Assertions.assertEquals(6000L, ticksInBerlin("2026-01-15T11:00:00Z"), "12:00 CET"); + } + + @DisplayName("18:00 is sunset at tick 12000") + @Test + void sunsetIsTick12000() { + Assertions.assertEquals(12000L, ticksInBerlin("2026-01-15T17:00:00Z"), "18:00 CET"); + } + + @DisplayName("00:00 is midnight at tick 18000") + @Test + void midnightIsTick18000() { + Assertions.assertEquals(18000L, ticksInBerlin("2026-01-14T23:00:00Z"), "00:00 CET"); + } + + @DisplayName("09:00 is tick 3000") + @Test + void nineOClockIsTick3000() { + Assertions.assertEquals(3000L, ticksInBerlin("2026-01-15T08:00:00Z"), "09:00 CET"); + } + + @DisplayName("The zone decides: 06:00 in Tokyo is tick 0 whatever time it is in Berlin") + @Test + void tokyoSunriseIsTickZero() { + Instant tokyoSix = Instant.parse("2026-01-14T21:00:00Z"); + + Assertions.assertEquals(0L, this.mapping.ticksAt(tokyoSix, TOKYO), "06:00 in Tokyo"); + Assertions.assertNotEquals(0L, this.mapping.ticksAt(tokyoSix, BERLIN), "it is 22:00 in Berlin then, so the zone must matter"); + } + + @DisplayName("Spring forward: the wall clock jumps from 01:59:59 to 03:00:00 and the ticks jump forward with it") + @Test + void springForwardJumpsForward() { + Assertions.assertEquals(19999L, ticksInBerlin("2026-03-29T00:59:59Z"), "01:59:59 CET"); + Assertions.assertEquals(21000L, ticksInBerlin("2026-03-29T01:00:00Z"), "03:00:00 CEST"); + } + + @DisplayName("Fall back: the repeated 02:00 hour maps to the same ticks as the first one") + @Test + void fallBackRepeatsTheWallClockValue() { + Assertions.assertEquals(20000L, ticksInBerlin("2026-10-25T00:00:00Z"), "02:00 CEST, before the change"); + Assertions.assertEquals(20000L, ticksInBerlin("2026-10-25T01:00:00Z"), "02:00 CET, after the change"); + } + + @DisplayName("Summer solstice: 12:00 Berlin time is tick 6000") + @Test + void summerSolsticeNoon() { + Assertions.assertEquals(6000L, ticksInBerlin("2026-06-21T10:00:00Z"), "12:00 CEST"); + } + + @DisplayName("Winter solstice: 12:00 Berlin time is tick 6000") + @Test + void winterSolsticeNoon() { + Assertions.assertEquals(6000L, ticksInBerlin("2026-12-21T11:00:00Z"), "12:00 CET"); + } + + @DisplayName("Every sampled moment of the year stays in [0, 24000)") + @Test + void resultStaysWithinOneMinecraftDay() { + Instant start = Instant.parse("2026-01-01T00:00:00Z"); + for (int minute = 0; minute < 366 * 24 * 60; minute += 7) { + long ticks = this.mapping.ticksAt(start.plus(minute, ChronoUnit.MINUTES), BERLIN); + + Assertions.assertTrue(ticks >= 0 && ticks < TICKS_PER_DAY, "ticks must be in [0, 24000) but were " + ticks + " at minute " + minute); + } + } + + @DisplayName("The same instant and zone always give the same ticks") + @Test + void mappingIsDeterministic() { + Instant instant = Instant.parse("2026-05-05T12:34:56Z"); + + Assertions.assertEquals(this.mapping.ticksAt(instant, BERLIN), new DayTimeMapping().ticksAt(instant, BERLIN), "two mappings must agree"); + } + + @DisplayName("Across the spring-forward night the ticks never move backwards") + @Test + void neverMovesBackwardsAcrossSpringForward() { + List samples = sampleEveryMinute(Instant.parse("2026-03-28T22:00:00Z"), 4 * 60); + + Assertions.assertEquals(0, stepsBackwards(samples), "no backwards step between 23:00 and 04:00"); + } + + @DisplayName("Over 24 hours the ticks wrap around exactly once") + @Test + void wrapsExactlyOncePerDay() { + List samples = sampleEveryMinute(Instant.parse("2026-01-15T00:00:00Z"), 24 * 60); + + Assertions.assertEquals(1, stepsBackwards(samples), "one sunrise per 24 hours"); + } + + private List sampleEveryMinute(Instant start, int minutes) { + List samples = new ArrayList<>(); + for (int minute = 0; minute <= minutes; minute++) { + samples.add(this.mapping.ticksAt(start.plus(minute, ChronoUnit.MINUTES), BERLIN)); + } + return samples; + } + + private static int stepsBackwards(List samples) { + int steps = 0; + for (int i = 1; i < samples.size(); i++) { + if (samples.get(i) < samples.get(i - 1)) { + steps++; + } + } + return steps; + } +} From 5f71973985e732e454bfb4b13b9dd98f3ed654b7 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 10:50:41 +0200 Subject: [PATCH 4/9] feat(daytime): add daytime settings and defaults Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../feature/daytime/DaytimeSettings.java | 37 +++++++++++ .../resources/titan/defaults/daytime.yaml | 7 +++ .../feature/daytime/DaytimeSettingsTest.java | 62 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeSettings.java create mode 100644 features/daytime/src/main/resources/titan/defaults/daytime.yaml create mode 100644 features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeSettingsTest.java diff --git a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeSettings.java b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeSettings.java new file mode 100644 index 00000000..4bffe904 --- /dev/null +++ b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeSettings.java @@ -0,0 +1,37 @@ +/** + * 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.daytime; + +import java.time.ZoneId; + +/** + * The {@code daytime} module's configuration keys and the pure parsing of its zone value. + */ +final class DaytimeSettings { + + static final String ENABLED_KEY = "daytime.enabled"; + static final String ZONE_KEY = "daytime.zone"; + + private DaytimeSettings() { + } + + /** + * @throws java.time.DateTimeException if {@code raw} is not a known zone id + */ + static ZoneId zone(String raw) { + return ZoneId.of(raw); + } +} diff --git a/features/daytime/src/main/resources/titan/defaults/daytime.yaml b/features/daytime/src/main/resources/titan/defaults/daytime.yaml new file mode 100644 index 00000000..36ef21fb --- /dev/null +++ b/features/daytime/src/main/resources/titan/defaults/daytime.yaml @@ -0,0 +1,7 @@ +# The "daytime" module: the lobby's time of day follows the real wall clock. +daytime: + # When false, the lobby stays at midday instead. Takes effect without a restart. + enabled: true + # The time zone whose wall clock the lobby follows (a java.time zone id, e.g. Europe/Berlin). + # An invalid value aborts startup; changed to an invalid value at runtime, the last valid zone stays. + zone: Europe/Berlin diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeSettingsTest.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeSettingsTest.java new file mode 100644 index 00000000..62ce7358 --- /dev/null +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeSettingsTest.java @@ -0,0 +1,62 @@ +/** + * 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.daytime; + +import io.avaje.config.Config; +import io.avaje.config.Configuration; +import java.time.DateTimeException; +import java.time.ZoneId; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DaytimeSettingsTest { + + @DisplayName("The shipped defaults enable the module") + @Test + void shippedDefaultEnablesTheModule() { + Assertions.assertTrue(Config.getBool(DaytimeSettings.ENABLED_KEY), "daytime.enabled must default to true"); + } + + @DisplayName("The shipped defaults use Europe/Berlin") + @Test + void shippedDefaultZoneIsBerlin() { + Assertions.assertEquals(ZoneId.of("Europe/Berlin"), Config.getAs(DaytimeSettings.ZONE_KEY, DaytimeSettings::zone), "daytime.zone must default to Europe/Berlin"); + } + + @DisplayName("A valid zone id is parsed") + @Test + void validZoneIsParsed() { + Assertions.assertEquals(ZoneId.of("Asia/Tokyo"), DaytimeSettings.zone("Asia/Tokyo")); + } + + @DisplayName("An unknown zone id is rejected") + @Test + void unknownZoneIsRejected() { + Assertions.assertThrows(DateTimeException.class, () -> DaytimeSettings.zone("Mars/Olympus")); + } + + @DisplayName("Config.getAs names the key and keeps the offending zone in the cause") + @Test + void configGetAsNamesTheKeyForAnInvalidZone() { + Configuration configuration = Configuration.builder().put(DaytimeSettings.ZONE_KEY, "Mars/Olympus").build(); + + IllegalStateException thrown = Assertions.assertThrows(IllegalStateException.class, () -> configuration.getAs(DaytimeSettings.ZONE_KEY, DaytimeSettings::zone)); + + Assertions.assertTrue(thrown.getMessage().contains(DaytimeSettings.ZONE_KEY), "the message must name " + DaytimeSettings.ZONE_KEY + ", was: " + thrown.getMessage()); + Assertions.assertTrue(thrown.getCause().getMessage().contains("Mars/Olympus"), "the cause must keep the offending value, was: " + thrown.getCause().getMessage()); + } +} From 2377dd936ffb4230f61ec9d8601c77a10af1d92b Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 10:52:12 +0200 Subject: [PATCH 5/9] feat(daytime): drive lobby time from the wall clock Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- features/daytime/build.gradle.kts | 1 + .../titan/feature/daytime/DaytimeModule.java | 105 +++++++ .../feature/daytime/AdjustableClock.java | 71 +++++ .../feature/daytime/DaytimeModuleTest.java | 281 ++++++++++++++++++ 4 files changed, 458 insertions(+) create mode 100644 features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java create mode 100644 features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/AdjustableClock.java create mode 100644 features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java diff --git a/features/daytime/build.gradle.kts b/features/daytime/build.gradle.kts index 42a04173..3f3302a0 100644 --- a/features/daytime/build.gradle.kts +++ b/features/daytime/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { // DaytimeModule reads daytime.* 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 module's WARN lines in DaytimeModuleTest. testImplementation(libs.logback.classic) diff --git a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java new file mode 100644 index 00000000..181ab7f9 --- /dev/null +++ b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java @@ -0,0 +1,105 @@ +/** + * 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.daytime; + +import io.avaje.config.Config; +import io.avaje.inject.PostConstruct; +import io.avaje.inject.PreDestroy; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import java.time.Clock; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.util.Objects; +import net.minestom.server.instance.Instance; +import net.minestom.server.timer.Scheduler; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Sets the lobby's time of day once per second from the wall clock of the configured zone, and + * holds it at midday while {@code daytime.enabled} is {@code false}. + */ +@Singleton +public final class DaytimeModule { + + private static final Logger LOGGER = LoggerFactory.getLogger(DaytimeModule.class); + private static final TaskSchedule UPDATE_INTERVAL = TaskSchedule.tick(20); + + private final Instance lobby; + private final Scheduler scheduler; + private final Clock clock; + private final DayTimeMapping mapping = new DayTimeMapping(); + private ZoneId zone; + private String rejectedZone; + private Task task; + + @Inject + public DaytimeModule(Instance lobby, Scheduler scheduler, Clock clock) { + this.lobby = Objects.requireNonNull(lobby, "lobby"); + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @PostConstruct + void start() { + // Aborts startup on an invalid zone; later changes fall back to this last valid one. + this.zone = Config.getAs(DaytimeSettings.ZONE_KEY, DaytimeSettings::zone); + freezeMinestomClock(); + // The first run sets the time now, then once per second; MapProvider's midday is only a fallback. + update(); + this.task = this.scheduler.scheduleTask(this::update, UPDATE_INTERVAL, UPDATE_INTERVAL); + } + + @PreDestroy + void stop() { + this.task.cancel(); + } + + private void freezeMinestomClock() { + var minestomClock = this.lobby.defaultClock(); + if (minestomClock == null) { + LOGGER.warn("The lobby instance has no default clock; its time may keep running between updates"); + } else { + minestomClock.rate(0f); + } + } + + private void update() { + long ticks = Config.getBool(DaytimeSettings.ENABLED_KEY) ? this.mapping.ticksAt(this.clock.instant(), currentZone()) : DayTimeMapping.NOON_TICKS; + this.lobby.setTime(ticks); + } + + private ZoneId currentZone() { + String raw = Config.get(DaytimeSettings.ZONE_KEY); + try { + this.zone = DaytimeSettings.zone(raw); + this.rejectedZone = null; + } catch (DateTimeException e) { + warnOnceAbout(raw, e); + } + return this.zone; + } + + private void warnOnceAbout(String rejected, DateTimeException reason) { + if (!rejected.equals(this.rejectedZone)) { + this.rejectedZone = rejected; + LOGGER.warn("Ignoring invalid {} '{}' ({}), keeping zone {}", DaytimeSettings.ZONE_KEY, rejected, reason.getMessage(), this.zone); + } + } +} diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/AdjustableClock.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/AdjustableClock.java new file mode 100644 index 00000000..e70fc10a --- /dev/null +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/AdjustableClock.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.daytime; + +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 and which counts how often + * it was read, so a test can drive {@link DaytimeModule} without wall-clock time or + * {@link Thread#sleep(long)}. + */ +final class AdjustableClock extends Clock { + + private final ZoneId zone; + private Instant instant; + private int reads; + + AdjustableClock(Instant instant, ZoneId zone) { + this.instant = instant; + this.zone = zone; + } + + void set(Instant instant) { + this.instant = instant; + } + + /** + * Moves this clock's current time forward by {@code duration}. + * + * @param duration the amount of time to advance by + */ + void advance(Duration duration) { + this.instant = this.instant.plus(duration); + } + + int reads() { + return this.reads; + } + + @Override + public ZoneId getZone() { + return this.zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new AdjustableClock(this.instant, zone); + } + + @Override + public Instant instant() { + this.reads++; + return this.instant; + } +} diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java new file mode 100644 index 00000000..ce0064ba --- /dev/null +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java @@ -0,0 +1,281 @@ +/** + * 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.daytime; + +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.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +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.mockito.Mockito; +import org.slf4j.LoggerFactory; + +@ExtendWith(MicrotusExtension.class) +class DaytimeModuleTest { + + /** 09:00 in Europe/Berlin (CET), so tick 3000. */ + private static final Instant NINE_IN_BERLIN = Instant.parse("2026-01-15T08:00:00Z"); + private static final long BERLIN_NINE_TICKS = 3000L; + private static final int RUN_INTERVAL_TICKS = 20; + + private final Logger moduleLogger = (Logger) LoggerFactory.getLogger(DaytimeModule.class); + private final ListAppender logLines = new ListAppender<>(); + + private String originalEnabled; + private String originalZone; + private Env env; + private Instance lobby; + private AdjustableClock clock; + private DaytimeModule module; + + @BeforeEach + void setUp(Env env) { + this.originalEnabled = Config.get(DaytimeSettings.ENABLED_KEY); + this.originalZone = Config.get(DaytimeSettings.ZONE_KEY); + this.env = env; + this.lobby = env.createFlatInstance(); + this.clock = new AdjustableClock(NINE_IN_BERLIN, ZoneOffset.UTC); + this.logLines.start(); + this.moduleLogger.addAppender(this.logLines); + } + + @AfterEach + void tearDown() { + if (this.module != null) { + this.module.stop(); + } + this.moduleLogger.detachAppender(this.logLines); + Config.setProperty(DaytimeSettings.ENABLED_KEY, this.originalEnabled); + Config.setProperty(DaytimeSettings.ZONE_KEY, this.originalZone); + } + + private void startModule() { + this.module = new DaytimeModule(this.lobby, this.env.process().scheduler(), this.clock); + this.module.start(); + } + + private void tick(int ticks) { + for (int i = 0; i < ticks; i++) { + this.env.tick(); + } + } + + private List warnings() { + return this.logLines.list.stream().filter(line -> line.getLevel() == Level.WARN).toList(); + } + + @DisplayName("Starting freezes the Minestom clock") + @Test + void startFreezesTheMinestomClock() { + this.lobby.defaultClock().rate(1f); + + startModule(); + + Assertions.assertEquals(0f, this.lobby.defaultClock().rate(), "only the wall clock may move the lobby time"); + } + + @DisplayName("The time is set right at start, overwriting an earlier midday freeze") + @Test + void timeIsSetImmediatelyAtStart() { + this.lobby.setTime(6000L); + + startModule(); + + Assertions.assertEquals(BERLIN_NINE_TICKS, this.lobby.getTime(), "09:00 in Berlin, without waiting for a tick"); + } + + @DisplayName("The time follows the platform clock after the next update") + @Test + void timeFollowsTheReplacedClock() { + startModule(); + + this.clock.advance(Duration.ofHours(9)); + tick(RUN_INTERVAL_TICKS); + + Assertions.assertEquals(12000L, this.lobby.getTime(), "18:00 in Berlin"); + } + + @DisplayName("The time does not move between two updates") + @Test + void timeStaysConstantBetweenUpdates() { + startModule(); + + this.clock.advance(Duration.ofHours(1)); + tick(RUN_INTERVAL_TICKS - 1); + + Assertions.assertEquals(BERLIN_NINE_TICKS, this.lobby.getTime(), "no update is due before 20 ticks have passed"); + } + + @DisplayName("Over 100 ticks the clock is read at most five times after the immediate first run") + @Test + void updatesAtMostOncePerSecond() { + startModule(); + int readsAfterStart = this.clock.reads(); + + tick(100); + + int scheduledRuns = this.clock.reads() - readsAfterStart; + Assertions.assertTrue(scheduledRuns <= 5, "at most 5 updates in 100 ticks, but there were " + scheduledRuns); + Assertions.assertTrue(scheduledRuns > 0, "the scheduled task must run at all"); + } + + @DisplayName("Disabled: the lobby goes to midday within the next update") + @Test + void disablingSetsMidday() { + startModule(); + + Config.setProperty(DaytimeSettings.ENABLED_KEY, "false"); + tick(RUN_INTERVAL_TICKS); + + Assertions.assertEquals(6000L, this.lobby.getTime(), "midday while daytime.enabled is false"); + } + + @DisplayName("Enabled again: the lobby follows the wall clock without a restart") + @Test + void reEnablingFollowsTheWallClockAgain() { + startModule(); + Config.setProperty(DaytimeSettings.ENABLED_KEY, "false"); + tick(RUN_INTERVAL_TICKS); + + Config.setProperty(DaytimeSettings.ENABLED_KEY, "true"); + tick(RUN_INTERVAL_TICKS); + + Assertions.assertEquals(BERLIN_NINE_TICKS, this.lobby.getTime(), "09:00 in Berlin again"); + } + + @DisplayName("Changing daytime.zone takes effect with the next update") + @Test + void zoneChangeAppliesLive() { + startModule(); + + Config.setProperty(DaytimeSettings.ZONE_KEY, "America/New_York"); + tick(RUN_INTERVAL_TICKS); + + Assertions.assertEquals(21000L, this.lobby.getTime(), "03:00 in New York while it is 09:00 in Berlin"); + } + + @DisplayName("An invalid zone at start aborts the startup and names daytime.zone") + @Test + void invalidZoneAtStartAbortsStartup() { + Config.setProperty(DaytimeSettings.ZONE_KEY, "Mars/Olympus"); + + IllegalStateException thrown = Assertions.assertThrows(IllegalStateException.class, () -> new DaytimeModule(this.lobby, this.env.process().scheduler(), this.clock).start()); + + Assertions.assertTrue(thrown.getMessage().contains(DaytimeSettings.ZONE_KEY), "the message must name " + DaytimeSettings.ZONE_KEY + ", was: " + thrown.getMessage()); + Assertions.assertTrue(thrown.getCause().getMessage().contains("Mars/Olympus"), "the reason must name the offending zone, was: " + thrown.getCause().getMessage()); + } + + @DisplayName("An invalid zone at runtime keeps the last valid zone") + @Test + void invalidZoneAtRuntimeKeepsTheLastValidZone() { + startModule(); + + Config.setProperty(DaytimeSettings.ZONE_KEY, "Mars/Olympus"); + this.clock.advance(Duration.ofHours(1)); + tick(RUN_INTERVAL_TICKS); + + Assertions.assertEquals(4000L, this.lobby.getTime(), "10:00 in Berlin, the zone that was valid before"); + } + + @DisplayName("An invalid zone at runtime logs a warning naming daytime.zone") + @Test + void invalidZoneAtRuntimeLogsAWarning() { + startModule(); + + Config.setProperty(DaytimeSettings.ZONE_KEY, "Mars/Olympus"); + tick(RUN_INTERVAL_TICKS); + + Assertions.assertEquals(1, warnings().size(), "exactly one warning for the changed value"); + String message = warnings().get(0).getFormattedMessage(); + Assertions.assertTrue(message.contains(DaytimeSettings.ZONE_KEY) && message.contains("Mars/Olympus"), "the warning must name the key and the value, was: " + message); + } + + @DisplayName("The same invalid zone is warned about only once, a different invalid one again") + @Test + void invalidZoneWarningIsLoggedOnlyWhenTheValueChanges() { + startModule(); + + Config.setProperty(DaytimeSettings.ZONE_KEY, "Mars/Olympus"); + tick(3 * RUN_INTERVAL_TICKS); + Assertions.assertEquals(1, warnings().size(), "three runs with the same invalid value must warn once"); + + Config.setProperty(DaytimeSettings.ZONE_KEY, "Mars/Phobos"); + tick(RUN_INTERVAL_TICKS); + Assertions.assertEquals(2, warnings().size(), "a different invalid value must warn again"); + } + + @DisplayName("A valid zone at runtime produces no warning") + @Test + void validRuntimeChangesDoNotWarn() { + startModule(); + + Config.setProperty(DaytimeSettings.ZONE_KEY, "Asia/Tokyo"); + tick(RUN_INTERVAL_TICKS); + + Assertions.assertTrue(warnings().isEmpty(), "no warning expected, got: " + warnings()); + } + + @DisplayName("A lobby without a default clock logs a warning and still follows the wall clock") + @Test + void missingDefaultClockLogsAWarning() { + Instance clocklessLobby = Mockito.mock(Instance.class); + this.module = new DaytimeModule(clocklessLobby, this.env.process().scheduler(), this.clock); + + this.module.start(); + + Assertions.assertEquals(1, warnings().size(), "one warning for the missing clock"); + Mockito.verify(clocklessLobby).setTime(BERLIN_NINE_TICKS); + } + + @DisplayName("Restarting at the same wall clock time gives the same lobby time") + @Test + void restartDoesNotShiftTheTime() { + startModule(); + long firstStart = this.lobby.getTime(); + this.module.stop(); + + Instance restartedLobby = this.env.createFlatInstance(); + this.module = new DaytimeModule(restartedLobby, this.env.process().scheduler(), new AdjustableClock(NINE_IN_BERLIN, ZoneOffset.UTC)); + this.module.start(); + + Assertions.assertEquals(firstStart, restartedLobby.getTime(), "same instant and zone, same ticks"); + } + + @DisplayName("Once stopped, the lobby time is no longer updated") + @Test + void stopCancelsTheTask() { + startModule(); + this.module.stop(); + + this.clock.advance(Duration.ofHours(3)); + tick(2 * RUN_INTERVAL_TICKS); + + Assertions.assertEquals(BERLIN_NINE_TICKS, this.lobby.getTime(), "a stopped module must not touch the lobby time"); + } +} From 36da4c879449e3f9ac8f30019dd7dfd2728ab3d4 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 10:52:12 +0200 Subject: [PATCH 6/9] test(daytime): add the column architecture test Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../daytime/ColumnArchitectureTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/ColumnArchitectureTest.java diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/ColumnArchitectureTest.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/ColumnArchitectureTest.java new file mode 100644 index 00000000..92029cce --- /dev/null +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/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.daytime; + +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.daytime", 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() { + } +} From aea2a859a7148be5c9153a95405b84e7d0c7a465 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 10:58:14 +0200 Subject: [PATCH 7/9] fix(daytime): keep the last zone when daytime.zone is blank at runtime Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/feature/daytime/DaytimeModule.java | 17 +++++++++++------ .../feature/daytime/DaytimeModuleTest.java | 13 +++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java index 181ab7f9..6ac0f7b9 100644 --- a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java +++ b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java @@ -45,8 +45,9 @@ public final class DaytimeModule { private final Scheduler scheduler; private final Clock clock; private final DayTimeMapping mapping = new DayTimeMapping(); - private ZoneId zone; - private String rejectedZone; + // Written by start() and by the scheduler thread in update(). + private volatile ZoneId zone; + private volatile String rejectedZone; private Task task; @Inject @@ -86,20 +87,24 @@ private void update() { } private ZoneId currentZone() { - String raw = Config.get(DaytimeSettings.ZONE_KEY); + String raw = Config.get(DaytimeSettings.ZONE_KEY, this.zone.getId()); + if (raw.isBlank()) { + warnOnceAbout(raw, "value is blank"); + return this.zone; + } try { this.zone = DaytimeSettings.zone(raw); this.rejectedZone = null; } catch (DateTimeException e) { - warnOnceAbout(raw, e); + warnOnceAbout(raw, e.getMessage()); } return this.zone; } - private void warnOnceAbout(String rejected, DateTimeException reason) { + private void warnOnceAbout(String rejected, String reason) { if (!rejected.equals(this.rejectedZone)) { this.rejectedZone = rejected; - LOGGER.warn("Ignoring invalid {} '{}' ({}), keeping zone {}", DaytimeSettings.ZONE_KEY, rejected, reason.getMessage(), this.zone); + LOGGER.warn("Ignoring invalid {} '{}' ({}), keeping zone {}", DaytimeSettings.ZONE_KEY, rejected, reason, this.zone); } } } diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java index ce0064ba..e409701c 100644 --- a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java @@ -216,6 +216,19 @@ void invalidZoneAtRuntimeLogsAWarning() { Assertions.assertTrue(message.contains(DaytimeSettings.ZONE_KEY) && message.contains("Mars/Olympus"), "the warning must name the key and the value, was: " + message); } + @DisplayName("A blank zone at runtime keeps the last valid zone and warns once") + @Test + void blankZoneAtRuntimeKeepsTheLastValidZone() { + startModule(); + + Config.setProperty(DaytimeSettings.ZONE_KEY, ""); + this.clock.advance(Duration.ofHours(1)); + tick(3 * RUN_INTERVAL_TICKS); + + Assertions.assertEquals(4000L, this.lobby.getTime(), "10:00 in Berlin, the zone that was valid before"); + Assertions.assertEquals(1, warnings().size(), "one warning for the blank value, got: " + warnings()); + } + @DisplayName("The same invalid zone is warned about only once, a different invalid one again") @Test void invalidZoneWarningIsLoggedOnlyWhenTheValueChanges() { From a39dd0a2669f51ba9322e6a1a70f105733eb3295 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 10:59:59 +0200 Subject: [PATCH 8/9] refactor(daytime): use zoneid directly and tidy the module Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../titan/feature/daytime/DaytimeModule.java | 10 ++++--- .../feature/daytime/DaytimeSettings.java | 11 +------- .../feature/daytime/AdjustableClock.java | 4 --- .../feature/daytime/DaytimeModuleTest.java | 1 + .../feature/daytime/DaytimeSettingsTest.java | 27 +------------------ 5 files changed, 10 insertions(+), 43 deletions(-) diff --git a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java index 6ac0f7b9..1a90f941 100644 --- a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java +++ b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeModule.java @@ -60,7 +60,7 @@ public DaytimeModule(Instance lobby, Scheduler scheduler, Clock clock) { @PostConstruct void start() { // Aborts startup on an invalid zone; later changes fall back to this last valid one. - this.zone = Config.getAs(DaytimeSettings.ZONE_KEY, DaytimeSettings::zone); + this.zone = Config.getAs(DaytimeSettings.ZONE_KEY, ZoneId::of); freezeMinestomClock(); // The first run sets the time now, then once per second; MapProvider's midday is only a fallback. update(); @@ -82,10 +82,14 @@ private void freezeMinestomClock() { } private void update() { - long ticks = Config.getBool(DaytimeSettings.ENABLED_KEY) ? this.mapping.ticksAt(this.clock.instant(), currentZone()) : DayTimeMapping.NOON_TICKS; + long ticks = enabled() ? this.mapping.ticksAt(this.clock.instant(), currentZone()) : DayTimeMapping.NOON_TICKS; this.lobby.setTime(ticks); } + private static boolean enabled() { + return Config.getBool(DaytimeSettings.ENABLED_KEY); + } + private ZoneId currentZone() { String raw = Config.get(DaytimeSettings.ZONE_KEY, this.zone.getId()); if (raw.isBlank()) { @@ -93,7 +97,7 @@ private ZoneId currentZone() { return this.zone; } try { - this.zone = DaytimeSettings.zone(raw); + this.zone = ZoneId.of(raw); this.rejectedZone = null; } catch (DateTimeException e) { warnOnceAbout(raw, e.getMessage()); diff --git a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeSettings.java b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeSettings.java index 4bffe904..7b1c41fd 100644 --- a/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeSettings.java +++ b/features/daytime/src/main/java/net/onelitefeather/titan/feature/daytime/DaytimeSettings.java @@ -15,10 +15,8 @@ */ package net.onelitefeather.titan.feature.daytime; -import java.time.ZoneId; - /** - * The {@code daytime} module's configuration keys and the pure parsing of its zone value. + * The {@code daytime} module's configuration keys. */ final class DaytimeSettings { @@ -27,11 +25,4 @@ final class DaytimeSettings { private DaytimeSettings() { } - - /** - * @throws java.time.DateTimeException if {@code raw} is not a known zone id - */ - static ZoneId zone(String raw) { - return ZoneId.of(raw); - } } diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/AdjustableClock.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/AdjustableClock.java index e70fc10a..ff197cdc 100644 --- a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/AdjustableClock.java +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/AdjustableClock.java @@ -36,10 +36,6 @@ final class AdjustableClock extends Clock { this.zone = zone; } - void set(Instant instant) { - this.instant = instant; - } - /** * Moves this clock's current time forward by {@code duration}. * diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java index e409701c..559baaea 100644 --- a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeModuleTest.java @@ -257,6 +257,7 @@ void validRuntimeChangesDoNotWarn() { @DisplayName("A lobby without a default clock logs a warning and still follows the wall clock") @Test void missingDefaultClockLogsAWarning() { + // Env instances always carry a default clock, so the null-clock case needs a mock. Instance clocklessLobby = Mockito.mock(Instance.class); this.module = new DaytimeModule(clocklessLobby, this.env.process().scheduler(), this.clock); diff --git a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeSettingsTest.java b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeSettingsTest.java index 62ce7358..fca4b37e 100644 --- a/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeSettingsTest.java +++ b/features/daytime/src/test/java/net/onelitefeather/titan/feature/daytime/DaytimeSettingsTest.java @@ -16,8 +16,6 @@ package net.onelitefeather.titan.feature.daytime; import io.avaje.config.Config; -import io.avaje.config.Configuration; -import java.time.DateTimeException; import java.time.ZoneId; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -34,29 +32,6 @@ void shippedDefaultEnablesTheModule() { @DisplayName("The shipped defaults use Europe/Berlin") @Test void shippedDefaultZoneIsBerlin() { - Assertions.assertEquals(ZoneId.of("Europe/Berlin"), Config.getAs(DaytimeSettings.ZONE_KEY, DaytimeSettings::zone), "daytime.zone must default to Europe/Berlin"); - } - - @DisplayName("A valid zone id is parsed") - @Test - void validZoneIsParsed() { - Assertions.assertEquals(ZoneId.of("Asia/Tokyo"), DaytimeSettings.zone("Asia/Tokyo")); - } - - @DisplayName("An unknown zone id is rejected") - @Test - void unknownZoneIsRejected() { - Assertions.assertThrows(DateTimeException.class, () -> DaytimeSettings.zone("Mars/Olympus")); - } - - @DisplayName("Config.getAs names the key and keeps the offending zone in the cause") - @Test - void configGetAsNamesTheKeyForAnInvalidZone() { - Configuration configuration = Configuration.builder().put(DaytimeSettings.ZONE_KEY, "Mars/Olympus").build(); - - IllegalStateException thrown = Assertions.assertThrows(IllegalStateException.class, () -> configuration.getAs(DaytimeSettings.ZONE_KEY, DaytimeSettings::zone)); - - Assertions.assertTrue(thrown.getMessage().contains(DaytimeSettings.ZONE_KEY), "the message must name " + DaytimeSettings.ZONE_KEY + ", was: " + thrown.getMessage()); - Assertions.assertTrue(thrown.getCause().getMessage().contains("Mars/Olympus"), "the cause must keep the offending value, was: " + thrown.getCause().getMessage()); + Assertions.assertEquals(ZoneId.of("Europe/Berlin"), Config.getAs(DaytimeSettings.ZONE_KEY, ZoneId::of), "daytime.zone must default to Europe/Berlin"); } } From f7014e9715273d79316c2b5c1ae6667e2a2f670c Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 11:01:56 +0200 Subject: [PATCH 9/9] docs(openspec): tick realtime-lobby-daytime implementation tasks Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../changes/realtime-lobby-daytime/tasks.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/openspec/changes/realtime-lobby-daytime/tasks.md b/openspec/changes/realtime-lobby-daytime/tasks.md index 721e0959..cf70f427 100644 --- a/openspec/changes/realtime-lobby-daytime/tasks.md +++ b/openspec/changes/realtime-lobby-daytime/tasks.md @@ -11,25 +11,25 @@ Integrationszweig: `feat/daytime` von `origin/main`. Eine Welle, weil das Modul ## 1. Zeitabbildung (unit) -- [ ] 1.1 Modul `features/daytime` mit `build.gradle.kts` (`titan.column`, `avaje-config`) und `package-info.java` (`@InjectModule`, `requires = {Instance.class, Scheduler.class, Clock.class}` wie bei `tickle`, denn `runtime` stellt nur `Instance` bereit) anlegen; `./gradlew :features:daytime:build` läuft durch -- [ ] 1.2 `DayTimeMappingTest` zuerst (unit): Ankerwerte, beide Berliner Umstellungen, Sonnenwenden, Bereich [0, 24000), Determinismus, kein Rückwärtssprung über das Vorstellen, genau ein Umlauf je 24 h; rot -- [ ] 1.3 `DayTimeMapping` implementieren, `DayTimeMappingTest` grün +- [x] 1.1 Modul `features/daytime` mit `build.gradle.kts` (`titan.column`, `avaje-config`) und `package-info.java` (`@InjectModule`, `requires = {Instance.class, Scheduler.class, Clock.class}` wie bei `tickle`, denn `runtime` stellt nur `Instance` bereit) anlegen; `./gradlew :features:daytime:build` läuft durch +- [x] 1.2 `DayTimeMappingTest` zuerst (unit): Ankerwerte, beide Berliner Umstellungen, Sonnenwenden, Bereich [0, 24000), Determinismus, kein Rückwärtssprung über das Vorstellen, genau ein Umlauf je 24 h; rot +- [x] 1.3 `DayTimeMapping` implementieren, `DayTimeMappingTest` grün ## 2. Konfiguration (unit) -- [ ] 2.1 Test zuerst: `daytime.yaml` liefert `enabled=true` und `zone=Europe/Berlin`, ungültige Zone wird abgelehnt (unit, kein Server); rot -- [ ] 2.2 `titan/defaults/daytime.yaml` und die Zonen-Prüfung umsetzen, Test grün +- [x] 2.1 Test zuerst: `daytime.yaml` liefert `enabled=true` und `zone=Europe/Berlin`, ungültige Zone wird abgelehnt (unit, kein Server); rot +- [x] 2.2 `titan/defaults/daytime.yaml` und die Zonen-Prüfung umsetzen, Test grün ## 3. Modul (integration) -- [ ] 3.1 `DaytimeModuleTest` zuerst (Cyano `Env`, einstellbare `Clock`, `env.tick()`): `rate` ist 0, Zeit folgt der Wanduhr, höchstens ein Setzen je 20 Ticks, `enabled=false` ergibt 6000, Umschalten live, Zonenwechsel live, ungültige Zone beim Start bricht ab, ungültige Zone zur Laufzeit behält die letzte und loggt WARN (aufgefangener Appender), `null`-Uhr loggt WARN; rot -- [ ] 3.2 `DaytimeModule` implementieren (`@Singleton`, `@PostConstruct` mit `rate(0f)` und 20-Tick-Aufgabe samt Sofortlauf, `@PreDestroy` bricht ab), Test grün -- [ ] 3.3 `ColumnArchitectureTest` nach dem Muster von `features/tickle` ergänzen und grün -- [ ] 3.4 `./gradlew build` läuft durch, Commits je Typ (`feat(daytime): ...`), keine Datei außerhalb von `features/daytime/**` geändert +- [x] 3.1 `DaytimeModuleTest` zuerst (Cyano `Env`, einstellbare `Clock`, `env.tick()`): `rate` ist 0, Zeit folgt der Wanduhr, höchstens ein Setzen je 20 Ticks, `enabled=false` ergibt 6000, Umschalten live, Zonenwechsel live, ungültige Zone beim Start bricht ab, ungültige Zone zur Laufzeit behält die letzte und loggt WARN (aufgefangener Appender), `null`-Uhr loggt WARN; rot +- [x] 3.2 `DaytimeModule` implementieren (`@Singleton`, `@PostConstruct` mit `rate(0f)` und 20-Tick-Aufgabe samt Sofortlauf, `@PreDestroy` bricht ab), Test grün +- [x] 3.3 `ColumnArchitectureTest` nach dem Muster von `features/tickle` ergänzen und grün +- [x] 3.4 `./gradlew build` läuft durch, Commits je Typ (`feat(daytime): ...`), keine Datei außerhalb von `features/daytime/**` geändert ## 4. Abnahme -- [ ] 4.1 Haiku-Review des Diffs gegen `specs/lobby-daytime` und F.I.R.S.T. (keine Sleeps, keine Systemzeit, keine Reihenfolgenabhängigkeit) +- [x] 4.1 Haiku-Review des Diffs gegen `specs/lobby-daytime` und F.I.R.S.T. (keine Sleeps, keine Systemzeit, keine Reihenfolgenabhängigkeit) ## 5. Pull Request