From 08d3491a51294461f95517a19f780d280b9442e6 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 15:26:48 +0200 Subject: [PATCH 1/5] docs(openspec): propose season-quoted-dates Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- .../season-quoted-dates/.openspec.yaml | 2 + .../changes/season-quoted-dates/design.md | 30 +++++++++++++ .../changes/season-quoted-dates/proposal.md | 38 +++++++++++++++++ .../specs/lobby-seasons/spec.md | 42 +++++++++++++++++++ openspec/changes/season-quoted-dates/tasks.md | 35 ++++++++++++++++ 5 files changed, 147 insertions(+) create mode 100644 openspec/changes/season-quoted-dates/.openspec.yaml create mode 100644 openspec/changes/season-quoted-dates/design.md create mode 100644 openspec/changes/season-quoted-dates/proposal.md create mode 100644 openspec/changes/season-quoted-dates/specs/lobby-seasons/spec.md create mode 100644 openspec/changes/season-quoted-dates/tasks.md diff --git a/openspec/changes/season-quoted-dates/.openspec.yaml b/openspec/changes/season-quoted-dates/.openspec.yaml new file mode 100644 index 00000000..3b4044e8 --- /dev/null +++ b/openspec/changes/season-quoted-dates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: olf-spec-driven +created: 2026-09-29 diff --git a/openspec/changes/season-quoted-dates/design.md b/openspec/changes/season-quoted-dates/design.md new file mode 100644 index 00000000..6e467639 --- /dev/null +++ b/openspec/changes/season-quoted-dates/design.md @@ -0,0 +1,30 @@ +# Design + +## Context + +Siehe proposal.md für Ursache und Umfang. `SeasonConfigReader` liest über die statische `Config`-Fassade; `Config` initialisiert sich einmal je JVM, deshalb lässt sich darüber keine echte YAML-Datei pro Test laden. `SeasonSettings` und `seasonIds()` (Erkennung über `Config.asProperties()`) bleiben unverändert. Der Change ist klein und berührt ein Modul; dieses Dokument hält nur die eine Entscheidung fest, die den Test erst möglich macht. + +## Goals / Non-Goals + +**Goals:** +- Ein Regressionstest lädt echte YAML wie die Produktion (SnakeYAML im Klassenpfad). +- Die Fehlermeldung für fehlendes `from`/`to` nennt den Quoting-Hinweis, beim Start und im Live-WARN. + +**Non-Goals:** +- Kein `Date`-Support, kein eigener YAML-Lader (siehe proposal.md, verworfen). +- Keine Änderung an Erkennung der Saison-Ids, Fensterlogik oder Neustart-Ablauf. + +## Decisions + +**D1: `SeasonConfigReader` bekommt eine `Configuration` per Konstruktor.** Produktion übergibt `Config.asConfiguration()` (lebt weiter, Live-Lesen und `Config.setProperty` in `SeasonModuleTest` bleiben gültig); der neue Test baut `Configuration.builder().load(datei).build()` aus einer YAML-Datei im `@TempDir`. Eingebaut geprüft: `Config.asConfiguration()` existiert in avaje-config 5.2 und hat `getOptional`, `getBool`, `getAs`, `asProperties`. Verworfen: `props.file`/Systemeigenschaft setzen (Singleton, nicht wiederholbar, bricht Unabhängigkeit). Test: Unit, ohne Server. SOLID: Abhängigkeit über Konstruktor statt statischem Singleton (DIP). + +**D2: Hinweis nur bei `from`/`to`.** `value(...)` hängt ` (quote date-times: from: "2026-12-01T00:00:00")` an „is required“, nur für die Felder `from` und `to`; `world` behält den bisherigen Text. Es entsteht kein neuer Prüfpfad: Die Saison wird weiter über ihre übrigen Schlüssel erkannt. Test: Unit auf Startfehler (`IllegalStateException`-Text) und auf die WARN-Zeile über einen aufgefangenen `ListAppender`. SOLID: keine neue Verantwortung, die Meldung bleibt in einer Methode (SRP). + +**D3: SnakeYAML nur im Test.** `testRuntimeOnly(libs.snakeyaml)` in `features/season`, Version aus dem Katalog (2.7, dieselbe wie in `common`; `features/navigator` macht es genauso). Produktion bleibt unberührt, da `apps/cloudnet` es schon transitiv liefert. Der Katalog-Eintrag hält Test und Produktion auf derselben Version. + +**Logging:** keine neue Zeile; die bestehende WARN-Zeile aus `readLive()` trägt den erweiterten Text (WARN, einmal je unverändertem Wert). Keine Metriken, keine Spans, keine Nutzertexte. + +## Risks / Trade-offs + +- [SnakeYAML im Test ändert das Verhalten bestehender Tests] → Sie nutzen `Config.setProperty` und laden keine Datei; `./gradlew :features:season:test` bestätigt das. +- [Betreiber bemerken die Doku nicht] → Die Fehlermeldung nennt selbst das Quoting; das ist der eigentliche Schutz, die Doku ergänzt ihn. diff --git a/openspec/changes/season-quoted-dates/proposal.md b/openspec/changes/season-quoted-dates/proposal.md new file mode 100644 index 00000000..b0ee6d2a --- /dev/null +++ b/openspec/changes/season-quoted-dates/proposal.md @@ -0,0 +1,38 @@ +# Proposal + +## Why + +Mit unquotiertem `from: 2026-09-29T13:56:00` bricht die Lobby den Start mit `seasons.halloween.from: is required` ab, obwohl der Wert dasteht. `avaje-config` 5.2 lädt YAML über SnakeYAML, sobald es im Klassenpfad liegt (in `apps/cloudnet` transitiv der Fall). SnakeYAML macht aus dem unquotierten Wert ein `java.util.Date`, und der Lader übernimmt nur Text, Zahlen und Wahrheitswerte: Der Schlüssel verschwindet still. README, `docs/lobby-modules.md` und die Vorlage `season.yaml` zeigen genau diese unquotierte Form. Die Tests haben es nicht gefunden, weil `features/season` kein SnakeYAML im Test-Klassenpfad hat und `Config.setProperty` benutzt. + +## What Changes + +- Datum-Zeit-Werte (`from`, `to`) MÜSSEN in der YAML-Datei in Anführungszeichen stehen; das wird dokumentiert statt still hingenommen. +- Fehlt `from` oder `to` bei einer Saison, die sonst Schlüssel hat, nennt die Startfehlermeldung den Schlüssel und den Hinweis auf Anführungszeichen, z. B. `seasons.halloween.from: is required (quote date-times: from: "2026-12-01T00:00:00")`. Derselbe Hinweis steht in der WARN-Zeile beim Live-Lesen. +- Alle Beispiele in README, `docs/lobby-modules.md` und `season.yaml` zeigen die quotierte Form. +- `features/season` bekommt SnakeYAML (`libs.snakeyaml`) im Test-Laufzeit-Klassenpfad; ein Regressionstest lädt echte YAML-Dateien (quotiert: gelesen; unquotiert: Startfehler mit Hinweis). +- Verworfen: `Date` selbst annehmen. `avaje-config` bietet dafür keinen Einhängepunkt, ein eigener YAML-Lader wäre ein zweiter Konfigurationsweg, und die Umrechnung von `Date` (UTC) auf die lokale Zeit in `seasons.zone` wäre fehleranfällig. + +## Capabilities + +### New Capabilities + +Keine. + +### Modified Capabilities + +- `lobby-seasons`: Die Anforderung „Ungültige Saison-Konfiguration verhindert den Start“ bekommt ein Szenario für einen unquotierten Datum-Zeit-Wert und verlangt den Quoting-Hinweis in der Fehlermeldung. + +**Abhängigkeit:** `openspec/specs/lobby-seasons` gibt es auf `main` noch nicht, weil der Change `seasonal-lobby-world` nicht archiviert ist. Ein `MODIFIED`-Delta lässt sich beim Archivieren nur auf eine vorhandene Anforderung anwenden. `seasonal-lobby-world` MUSS daher vor diesem Change archiviert werden (`/opsx:archive seasonal-lobby-world`); das ist die erste Aufgabe in `tasks.md`. + +## Impact + +- **Code**: `SeasonConfigReader` (Meldung in `value(...)` für `from`/`to`), `features/season/build.gradle.kts` (`testRuntimeOnly(libs.snakeyaml)`), neuer Test in `features/season`. +- **Abhängigkeiten**: keine neue Produktionsabhängigkeit; `libs.snakeyaml` (2.7, wie in `common` und `navigator`) nur im Test-Klassenpfad von `features/season`. +- **Doku**: README (Abschnitt „Seasonal lobby world“), `docs/lobby-modules.md` (Abschnitt „Saison-Welt“), Kommentar in `features/season/src/main/resources/titan/defaults/season.yaml`. +- **Nutzertexte**: keine; nur Log- und Fehlerzeilen für Betreiber (Englisch). +- **Betrieb**: Bestehende `application.yaml` mit unquotierten Werten funktionieren weiterhin nicht, melden aber jetzt den Grund; Betreiber setzen Anführungszeichen. +- **Berührt**: `seasonal-lobby-world` (Archivierung zuerst, siehe oben). + +## Delivery + +PR-Titel: `fix(season): explain that season date-times must be quoted` diff --git a/openspec/changes/season-quoted-dates/specs/lobby-seasons/spec.md b/openspec/changes/season-quoted-dates/specs/lobby-seasons/spec.md new file mode 100644 index 00000000..7de9daaf --- /dev/null +++ b/openspec/changes/season-quoted-dates/specs/lobby-seasons/spec.md @@ -0,0 +1,42 @@ +# Spec Delta + +## MODIFIED Requirements + +### 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. Datum-Zeit-Werte MÜSSEN in der YAML-Datei in Anführungszeichen stehen, weil ein unquotierter Wert vom Konfigurationslader nicht als Text geliefert wird und wie ein fehlender gilt. Fehlt `from` oder `to` einer Saison, die sonst Schlüssel hat, MUSS die Meldung (beim Start wie als Warnung zur Laufzeit) den Schlüssel nennen und darauf hinweisen, dass Datum-Zeit-Werte in Anführungszeichen stehen müssen. 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 +- **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 + +#### Scenario: Quotiertes Datum wird gelesen +- **WHEN** die YAML-Datei `from: "2026-12-01T00:00:00"` enthält +- **THEN** liest die Lobby den Wert als lokales Datum mit Uhrzeit und startet + +#### Scenario: Unquotiertes Datum +- **WHEN** die YAML-Datei `from: 2026-12-01T00:00:00` ohne Anführungszeichen enthält +- **THEN** startet die Lobby nicht, und die Meldung nennt `seasons.winter.from` sowie den Hinweis, Datum-Zeit-Werte in Anführungszeichen zu setzen + +#### Scenario: Unquotiertes Datum zur Laufzeit +- **WHEN** die Lobby läuft und ein unquotierter Wert eine aktivierte Saison ungültig macht +- **THEN** merkt die Lobby keinen Neustart vor, und die Warnung nennt den Schlüssel und den Hinweis auf Anführungszeichen diff --git a/openspec/changes/season-quoted-dates/tasks.md b/openspec/changes/season-quoted-dates/tasks.md new file mode 100644 index 00000000..ec5ca41b --- /dev/null +++ b/openspec/changes/season-quoted-dates/tasks.md @@ -0,0 +1,35 @@ +# Tasks + +## Execution Plan + +| Wave | Agent | Task IDs | Model | May Touch | Must Not Touch | +| ---- | ----- | -------- | ----- | --------- | -------------- | +| 1 | archive-first | 1.1 | sonnet | `openspec/**` | Produktionscode, Tests, Doku | +| 2 | season-fix | 2.1–2.4, 3.1–3.3 | sonnet | `features/season/**`, `README.md`, `docs/lobby-modules.md` | `core/**`, `common/**`, `runtime/**`, `apps/**`, andere `features/**`, `buildSrc/**` | +| 3 | verify | 4.1 | haiku | read-only | alles | +| 4 | pr | 5.1 | sonnet | Git/GitHub | Code | + +## 1. Voraussetzung + +- [x] 1.1 Prüfen, ob `openspec/specs/lobby-seasons/spec.md` auf `main` liegt; falls nicht, zuerst `seasonal-lobby-world` archivieren (`/opsx:archive seasonal-lobby-world`), sonst lässt sich das `MODIFIED`-Delta dieses Changes beim Archivieren nicht anwenden. Nachweis: `openspec list --specs` zeigt `lobby-seasons`. + +## 2. Regressionstest und Meldung (unit) + +- [ ] 2.1 `testRuntimeOnly(libs.snakeyaml)` in `features/season/build.gradle.kts` ergänzen; Nachweis: `./gradlew :features:season:test` bleibt grün (bestehende Tests unverändert) +- [ ] 2.2 Test zuerst (unit, F.I.R.S.T.): YAML-Datei im `@TempDir` schreiben, per `Configuration.builder().load(...)` laden und mit einem `SeasonConfigReader` lesen, der die `Configuration` übergeben bekommt; quotiert: Saison gelesen; unquotiert: `readAtStartup()` wirft und nennt `seasons..from` samt Quoting-Hinweis; `readLive()` ergibt leer und loggt den Hinweis einmal (aufgefangener `ListAppender`, kein Sleep, keine Systemzeit, globaler `Config`-Zustand unverändert); rot +- [ ] 2.3 `SeasonConfigReader` nimmt eine `Configuration` per Konstruktor (Produktion: `Config.asConfiguration()`), `SeasonSchedule` reicht sie durch; bestehende `SeasonConfigReaderTest`/`SeasonModuleTest` bleiben grün +- [ ] 2.4 Meldung für fehlendes `from`/`to` um `(quote date-times: from: "2026-12-01T00:00:00")` erweitern (nicht für `world`); Test aus 2.2 grün, `./gradlew :features:season:build` läuft durch + +## 3. Doku und Vorlage + +- [ ] 3.1 `README.md` (Abschnitt „Seasonal lobby world“): `from`/`to` quotiert zeigen und einen Satz ergänzen, dass Datum-Zeit-Werte in Anführungszeichen stehen müssen +- [ ] 3.2 `docs/lobby-modules.md` (Abschnitt „Saison-Welt“): dasselbe auf Deutsch +- [ ] 3.3 Kommentar in `features/season/src/main/resources/titan/defaults/season.yaml` mit quotierten Beispielen; Nachweis: `grep -rn "from: 20" README.md docs features/season/src/main` findet nur quotierte Werte, `./gradlew build` läuft durch, Commits je Typ (`fix(season): ...`) + +## 4. Abnahme + +- [ ] 4.1 Haiku-Review (read-only): Szenarien von `lobby-seasons` (quotiert, unquotiert, Laufzeit) dem jeweiligen Test zuordnen; F.I.R.S.T.-Check (`@TempDir`, keine Sleeps, keine Systemzeit, `Config` nicht verändert, Reihenfolge egal). Nachweis: Bericht ohne Lücken + +## 5. Pull Request + +- [ ] 5.1 Pull Request mit dem Titel `fix(season): explain that season date-times must be quoted` eröffnen (Titel und Beschreibung Englisch); Archiv-Commit `docs(openspec): archive season-quoted-dates`, falls das Archiv nicht mitgeliefert wird From 1898d7f86090f2e5eb14c4a4ab3e1e5bf2930b7d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 15:28:41 +0200 Subject: [PATCH 2/5] fix(season): explain that season date-times must be quoted An unquoted YAML date-time is resolved to a non-text type that avaje-config drops, so seasons..from/to read as missing. The message now says to quote them, at startup and in the live warning. The reader takes a Configuration so a test can load real YAML. Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- features/season/build.gradle.kts | 3 + .../feature/season/SeasonConfigReader.java | 37 ++++-- .../titan/feature/season/SeasonSchedule.java | 8 +- .../season/SeasonConfigReaderTest.java | 2 +- .../feature/season/SeasonYamlQuotingTest.java | 123 ++++++++++++++++++ 5 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonYamlQuotingTest.java diff --git a/features/season/build.gradle.kts b/features/season/build.gradle.kts index c779fa2d..65bea530 100644 --- a/features/season/build.gradle.kts +++ b/features/season/build.gradle.kts @@ -10,4 +10,7 @@ dependencies { // ListAppender, for capturing the column's WARN and INFO lines in its tests. testImplementation(libs.logback.classic) + + // Real YAML loading in the tests, as in production, where apps/cloudnet brings it transitively. + testRuntimeOnly(libs.snakeyaml) } 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 index acc6cd97..cbb7f911 100644 --- 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 @@ -15,7 +15,7 @@ */ package net.onelitefeather.titan.feature.season; -import io.avaje.config.Config; +import io.avaje.config.Configuration; import java.nio.file.Files; import java.nio.file.Path; import java.time.DateTimeException; @@ -32,7 +32,7 @@ import org.slf4j.LoggerFactory; /** - * Reads and validates {@code seasons.*} from {@link Config}. + * Reads and validates {@code seasons.*} from a {@link Configuration}. * *

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 @@ -53,12 +53,14 @@ String describe() { } } + private final Configuration config; 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) { + SeasonConfigReader(Configuration config, Path worldsDirectory, SeasonCalendar calendar) { + this.config = Objects.requireNonNull(config, "config"); this.worldsDirectory = Objects.requireNonNull(worldsDirectory, "worldsDirectory"); this.calendar = Objects.requireNonNull(calendar, "calendar"); } @@ -98,25 +100,26 @@ synchronized Optional readLive() { private SeasonConfig read(List problems) { ZoneId zone; try { - zone = Config.getAs(SeasonSettings.ZONE_KEY, ZoneId::of); + zone = this.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) + ")")); + problems.add(new Problem(SeasonSettings.ZONE_KEY, "invalid zone '" + this.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); - } + } else + if (this.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() { + private Set seasonIds() { Set ids = new TreeSet<>(); - for (String key : Config.asProperties().stringPropertyNames()) { + for (String key : this.config.asProperties().stringPropertyNames()) { if (key.startsWith(SeasonSettings.PREFIX)) { String rest = key.substring(SeasonSettings.PREFIX.length()); int dot = rest.indexOf('.'); @@ -154,7 +157,7 @@ private void checkWorld(String id, String world, List problems) { } } - private static LocalDateTime date(String id, String field, List problems) { + private LocalDateTime date(String id, String field, List problems) { String raw = value(id, field, problems); if (raw == null) { return null; @@ -167,15 +170,21 @@ private static LocalDateTime date(String id, String field, List problem } } - private static String value(String id, String field, List problems) { + private 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()); + Optional value = this.config.getOptional(key).map(String::strip).filter(raw -> !raw.isEmpty()); if (value.isEmpty()) { - problems.add(new Problem(key, "is required")); + problems.add(new Problem(key, "is required" + quotingHint(field))); } return value.orElse(null); } + // An unquoted YAML date-time is parsed to a non-text type that avaje-config drops, so it reads as missing. + private static String quotingHint(String field) { + boolean isDate = SeasonSettings.FROM_FIELD.equals(field) || SeasonSettings.TO_FIELD.equals(field); + return isDate ? " (quote date-times: " + field + ": \"2026-12-01T00:00:00\")" : ""; + } + private static String rootMessage(Throwable throwable) { Throwable cause = throwable; while (cause.getCause() != null) { 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 index e7790352..be03d74a 100644 --- 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 @@ -15,6 +15,8 @@ */ package net.onelitefeather.titan.feature.season; +import io.avaje.config.Config; +import io.avaje.config.Configuration; import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.nio.file.Path; @@ -52,9 +54,13 @@ record Unresolvable() implements Desired { } SeasonSchedule(Clock clock, Path worldsDirectory) { + this(clock, worldsDirectory, Config.asConfiguration()); + } + + SeasonSchedule(Clock clock, Path worldsDirectory, Configuration config) { this.clock = Objects.requireNonNull(clock, "clock"); this.calendar = new SeasonCalendar(); - this.reader = new SeasonConfigReader(worldsDirectory, this.calendar); + this.reader = new SeasonConfigReader(config, worldsDirectory, this.calendar); } /** 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 index c5b09104..0b0196ee 100644 --- 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 @@ -49,7 +49,7 @@ class SeasonConfigReaderTest { @BeforeEach void setUp() { this.originalZone = Config.get(SeasonSettings.ZONE_KEY); - this.reader = new SeasonConfigReader(this.worlds, new SeasonCalendar()); + this.reader = new SeasonConfigReader(Config.asConfiguration(), this.worlds, new SeasonCalendar()); this.logLines.start(); this.readerLogger.addAppender(this.logLines); } diff --git a/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonYamlQuotingTest.java b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonYamlQuotingTest.java new file mode 100644 index 00000000..b7fab265 --- /dev/null +++ b/features/season/src/test/java/net/onelitefeather/titan/feature/season/SeasonYamlQuotingTest.java @@ -0,0 +1,123 @@ +/** + * 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.Configuration; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +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; + +/** + * Loads real YAML through avaje-config with SnakeYAML on the classpath, as production does. That + * loader drops unquoted date-times, so the reader must say that they need quotes. + */ +class SeasonYamlQuotingTest { + + private static final String FROM_HINT = "(quote date-times: from: \"2026-12-01T00:00:00\")"; + private static final String TO_HINT = "(quote date-times: to: \"2026-12-01T00:00:00\")"; + + private final Logger readerLogger = (Logger) LoggerFactory.getLogger(SeasonConfigReader.class); + private final ListAppender logLines = new ListAppender<>(); + + @TempDir + Path directory; + + @BeforeEach + void setUp() { + this.logLines.start(); + this.readerLogger.addAppender(this.logLines); + } + + @AfterEach + void tearDown() { + this.readerLogger.detachAppender(this.logLines); + } + + private SeasonConfigReader readerFor(String from, String to) throws IOException { + Path worlds = Files.createDirectories(this.directory.resolve("worlds")); + Files.writeString(Files.createDirectories(worlds.resolve("winter")).resolve(SeasonSettings.MAP_FILE_NAME), "{}"); + Path file = this.directory.resolve("season.yaml"); + Files.writeString(file, """ + seasons: + zone: Europe/Berlin + winter: + world: winter + from: %s + to: %s + """.formatted(from, to)); + return new SeasonConfigReader(Configuration.builder().load(file.toFile()).build(), worlds, new SeasonCalendar()); + } + + private List warnings() { + return this.logLines.list.stream().filter(line -> line.getLevel() == Level.WARN).toList(); + } + + @DisplayName("Quoted date-times in YAML are read as the season window") + @Test + void quotedDateTimesAreRead() throws IOException { + SeasonConfigReader reader = readerFor("\"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), reader.readAtStartup().seasons()); + } + + @DisplayName("An unquoted from aborts startup naming seasons..from and the quoting hint") + @Test + void unquotedFromAbortsStartupWithHint() throws IOException { + SeasonConfigReader reader = readerFor("2026-12-01T00:00:00", "\"2027-01-07T00:00:00\""); + + String message = Assertions.assertThrows(IllegalStateException.class, reader::readAtStartup).getMessage(); + + Assertions.assertTrue(message.contains("seasons.winter.from") && message.contains(FROM_HINT), "was: " + message); + } + + @DisplayName("An unquoted to aborts startup naming seasons..to and the quoting hint") + @Test + void unquotedToAbortsStartupWithHint() throws IOException { + SeasonConfigReader reader = readerFor("\"2026-12-01T00:00:00\"", "2027-01-07T00:00:00"); + + String message = Assertions.assertThrows(IllegalStateException.class, reader::readAtStartup).getMessage(); + + Assertions.assertTrue(message.contains("seasons.winter.to") && message.contains(TO_HINT), "was: " + message); + } + + @DisplayName("Live, an unquoted date-time yields no configuration and warns once with the quoting hint") + @Test + void liveUnquotedDateTimeWarnsOnceWithHint() throws IOException { + SeasonConfigReader reader = readerFor("2026-12-01T00:00:00", "\"2027-01-07T00:00:00\""); + + boolean firstEmpty = reader.readLive().isEmpty(); + reader.readLive(); + + Assertions.assertTrue(firstEmpty, "an unquoted value counts as missing"); + Assertions.assertEquals(1, warnings().size(), "got: " + warnings()); + String message = warnings().getFirst().getFormattedMessage(); + Assertions.assertTrue(message.contains("seasons.winter.from") && message.contains(FROM_HINT), "was: " + message); + } +} From 414753d8b87ee938b012946846fc8ce2c7e11dac Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 15:28:55 +0200 Subject: [PATCH 3/5] docs(season): show quoted season date-times in examples Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- README.md | 7 +++++-- docs/lobby-modules.md | 7 +++++-- .../season/src/main/resources/titan/defaults/season.yaml | 5 +++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 15f0f290..e810bf46 100644 --- a/README.md +++ b/README.md @@ -346,11 +346,14 @@ example a winter map in December. To add one: 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 + 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 ``` +Date-times must be quoted (`from: "2026-12-01T00:00:00"`); the YAML loader drops an unquoted +value, and it is reported as missing. + 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 diff --git a/docs/lobby-modules.md b/docs/lobby-modules.md index 9e28c053..abc5c1b4 100644 --- a/docs/lobby-modules.md +++ b/docs/lobby-modules.md @@ -368,11 +368,14 @@ Eine Saisonwelt anlegen: 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 + 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 ``` +Datum-Zeit-Werte müssen in Anführungszeichen stehen (`from: "2026-12-01T00:00:00"`); der +YAML-Lader verwirft einen unquotierten Wert, er wird als fehlend gemeldet. + 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, diff --git a/features/season/src/main/resources/titan/defaults/season.yaml b/features/season/src/main/resources/titan/defaults/season.yaml index 892e19a4..84943ebd 100644 --- a/features/season/src/main/resources/titan/defaults/season.yaml +++ b/features/season/src/main/resources/titan/defaults/season.yaml @@ -5,6 +5,7 @@ seasons: 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 + # 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 + # Date-times must be quoted: an unquoted value is dropped by the YAML loader and reported as missing. From 1337dec219103fb66adfc28f623591ee95451fe3 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 15:28:56 +0200 Subject: [PATCH 4/5] docs(openspec): tick tasks of season-quoted-dates Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/season-quoted-dates/tasks.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openspec/changes/season-quoted-dates/tasks.md b/openspec/changes/season-quoted-dates/tasks.md index ec5ca41b..4792707d 100644 --- a/openspec/changes/season-quoted-dates/tasks.md +++ b/openspec/changes/season-quoted-dates/tasks.md @@ -15,16 +15,16 @@ ## 2. Regressionstest und Meldung (unit) -- [ ] 2.1 `testRuntimeOnly(libs.snakeyaml)` in `features/season/build.gradle.kts` ergänzen; Nachweis: `./gradlew :features:season:test` bleibt grün (bestehende Tests unverändert) -- [ ] 2.2 Test zuerst (unit, F.I.R.S.T.): YAML-Datei im `@TempDir` schreiben, per `Configuration.builder().load(...)` laden und mit einem `SeasonConfigReader` lesen, der die `Configuration` übergeben bekommt; quotiert: Saison gelesen; unquotiert: `readAtStartup()` wirft und nennt `seasons..from` samt Quoting-Hinweis; `readLive()` ergibt leer und loggt den Hinweis einmal (aufgefangener `ListAppender`, kein Sleep, keine Systemzeit, globaler `Config`-Zustand unverändert); rot -- [ ] 2.3 `SeasonConfigReader` nimmt eine `Configuration` per Konstruktor (Produktion: `Config.asConfiguration()`), `SeasonSchedule` reicht sie durch; bestehende `SeasonConfigReaderTest`/`SeasonModuleTest` bleiben grün -- [ ] 2.4 Meldung für fehlendes `from`/`to` um `(quote date-times: from: "2026-12-01T00:00:00")` erweitern (nicht für `world`); Test aus 2.2 grün, `./gradlew :features:season:build` läuft durch +- [x] 2.1 `testRuntimeOnly(libs.snakeyaml)` in `features/season/build.gradle.kts` ergänzen; Nachweis: `./gradlew :features:season:test` bleibt grün (bestehende Tests unverändert) +- [x] 2.2 Test zuerst (unit, F.I.R.S.T.): YAML-Datei im `@TempDir` schreiben, per `Configuration.builder().load(...)` laden und mit einem `SeasonConfigReader` lesen, der die `Configuration` übergeben bekommt; quotiert: Saison gelesen; unquotiert: `readAtStartup()` wirft und nennt `seasons..from` samt Quoting-Hinweis; `readLive()` ergibt leer und loggt den Hinweis einmal (aufgefangener `ListAppender`, kein Sleep, keine Systemzeit, globaler `Config`-Zustand unverändert); rot +- [x] 2.3 `SeasonConfigReader` nimmt eine `Configuration` per Konstruktor (Produktion: `Config.asConfiguration()`), `SeasonSchedule` reicht sie durch; bestehende `SeasonConfigReaderTest`/`SeasonModuleTest` bleiben grün +- [x] 2.4 Meldung für fehlendes `from`/`to` um `(quote date-times: from: "2026-12-01T00:00:00")` erweitern (nicht für `world`); Test aus 2.2 grün, `./gradlew :features:season:build` läuft durch ## 3. Doku und Vorlage -- [ ] 3.1 `README.md` (Abschnitt „Seasonal lobby world“): `from`/`to` quotiert zeigen und einen Satz ergänzen, dass Datum-Zeit-Werte in Anführungszeichen stehen müssen -- [ ] 3.2 `docs/lobby-modules.md` (Abschnitt „Saison-Welt“): dasselbe auf Deutsch -- [ ] 3.3 Kommentar in `features/season/src/main/resources/titan/defaults/season.yaml` mit quotierten Beispielen; Nachweis: `grep -rn "from: 20" README.md docs features/season/src/main` findet nur quotierte Werte, `./gradlew build` läuft durch, Commits je Typ (`fix(season): ...`) +- [x] 3.1 `README.md` (Abschnitt „Seasonal lobby world“): `from`/`to` quotiert zeigen und einen Satz ergänzen, dass Datum-Zeit-Werte in Anführungszeichen stehen müssen +- [x] 3.2 `docs/lobby-modules.md` (Abschnitt „Saison-Welt“): dasselbe auf Deutsch +- [x] 3.3 Kommentar in `features/season/src/main/resources/titan/defaults/season.yaml` mit quotierten Beispielen; Nachweis: `grep -rn "from: 20" README.md docs features/season/src/main` findet nur quotierte Werte, `./gradlew build` läuft durch, Commits je Typ (`fix(season): ...`) ## 4. Abnahme From 0990e02ac28e606e85c3ac667b821d8cc98b13c3 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Tue, 29 Sep 2026 15:35:35 +0200 Subject: [PATCH 5/5] docs(openspec): tick verification task of season-quoted-dates Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH --- openspec/changes/season-quoted-dates/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/season-quoted-dates/tasks.md b/openspec/changes/season-quoted-dates/tasks.md index 4792707d..de1b8069 100644 --- a/openspec/changes/season-quoted-dates/tasks.md +++ b/openspec/changes/season-quoted-dates/tasks.md @@ -28,7 +28,7 @@ ## 4. Abnahme -- [ ] 4.1 Haiku-Review (read-only): Szenarien von `lobby-seasons` (quotiert, unquotiert, Laufzeit) dem jeweiligen Test zuordnen; F.I.R.S.T.-Check (`@TempDir`, keine Sleeps, keine Systemzeit, `Config` nicht verändert, Reihenfolge egal). Nachweis: Bericht ohne Lücken +- [x] 4.1 Haiku-Review (read-only): Szenarien von `lobby-seasons` (quotiert, unquotiert, Laufzeit) dem jeweiligen Test zuordnen; F.I.R.S.T.-Check (`@TempDir`, keine Sleeps, keine Systemzeit, `Config` nicht verändert, Reihenfolge egal). Nachweis: Bericht ohne Lücken ## 5. Pull Request