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/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/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.
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);
+ }
+}
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..de1b8069
--- /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)
+
+- [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
+
+- [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
+
+- [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
+
+- [ ] 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