Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/lobby-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions features/season/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}.
*
* <p>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
Expand All @@ -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<String> 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");
}
Expand Down Expand Up @@ -98,25 +100,26 @@ synchronized Optional<SeasonConfig> readLive() {
private SeasonConfig read(List<Problem> 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<Season> 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<String> seasonIds() {
private Set<String> seasonIds() {
Set<String> 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('.');
Expand Down Expand Up @@ -154,7 +157,7 @@ private void checkWorld(String id, String world, List<Problem> problems) {
}
}

private static LocalDateTime date(String id, String field, List<Problem> problems) {
private LocalDateTime date(String id, String field, List<Problem> problems) {
String raw = value(id, field, problems);
if (raw == null) {
return null;
Expand All @@ -167,15 +170,21 @@ private static LocalDateTime date(String id, String field, List<Problem> problem
}
}

private static String value(String id, String field, List<Problem> problems) {
private String value(String id, String field, List<Problem> problems) {
String key = SeasonSettings.key(id, field);
Optional<String> value = Config.getOptional(key).map(String::strip).filter(raw -> !raw.isEmpty());
Optional<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ seasons:
zone: Europe/Berlin
# <id>:
# 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ILoggingEvent> 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<ILoggingEvent> 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.<id>.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.<id>.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);
}
}
2 changes: 2 additions & 0 deletions openspec/changes/season-quoted-dates/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: olf-spec-driven
created: 2026-09-29
30 changes: 30 additions & 0 deletions openspec/changes/season-quoted-dates/design.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading