diff --git a/app/src/main/java/net/onelitefeather/titan/app/Titan.java b/app/src/main/java/net/onelitefeather/titan/app/Titan.java
index 42a2391..b2a1e85 100644
--- a/app/src/main/java/net/onelitefeather/titan/app/Titan.java
+++ b/app/src/main/java/net/onelitefeather/titan/app/Titan.java
@@ -37,9 +37,14 @@
import net.onelitefeather.titan.common.event.EntityDismountEvent;
import net.onelitefeather.titan.common.helper.BlockHandlerHelper;
import net.onelitefeather.titan.common.map.MapProvider;
+import net.onelitefeather.titan.common.time.SeasonService;
+import net.onelitefeather.titan.common.time.TimeConfigProvider;
+import net.onelitefeather.titan.common.time.TitanTime;
+import net.onelitefeather.titan.common.time.WorldTimeService;
import net.onelitefeather.titan.common.utils.Cancelable;
import java.nio.file.Path;
+import java.time.Clock;
public final class Titan {
@@ -49,6 +54,8 @@ public final class Titan {
private final MapProvider mapProvider;
private final AppConfigProvider appConfigProvider;
private final NavigationHelper navigationHelper;
+ private final WorldTimeService worldTimeService;
+ private final SeasonService seasonService;
public Titan() {
MinecraftServer.getConnectionManager().setPlayerProvider(TitanPlayer::new);
@@ -59,6 +66,15 @@ public Titan() {
this.mapProvider = MapProvider.create(this.path, instance);
this.appConfigProvider = AppConfigProvider.create(this.path);
this.navigationHelper = NavigationHelper.instance(this.deliver);
+ // The lobby tells its time in Berlin, whatever zone the host machine is set to.
+ Clock clock = Clock.system(TitanTime.EDITORIAL_ZONE);
+ // Which mapping and which season boundaries run is a question for time.json, not for this
+ // file: it names no strategy at all (US-2.07, US-2.12, US-2.13). A value the file gets
+ // wrong stops the boot here rather than running a strategy nobody chose.
+ TimeConfigProvider timeConfigProvider = TimeConfigProvider.create(this.path, TitanTime.EDITORIAL_ZONE);
+ this.worldTimeService = timeConfigProvider.createWorldTimeService(clock);
+ this.seasonService = timeConfigProvider.createSeasonService(clock);
+ this.worldTimeService.bind(instance);
}
public void initialize() {
@@ -71,7 +87,25 @@ public void initialize() {
}
public void terminate() {
+ this.worldTimeService.unbind();
+ }
+
+ /**
+ * Returns the service that drives the lobby's day time from real time.
+ *
+ * @return the world time service
+ */
+ public WorldTimeService worldTimeService() {
+ return this.worldTimeService;
+ }
+ /**
+ * Returns the service that tells which season the lobby is in.
+ *
+ * @return the season service
+ */
+ public SeasonService seasonService() {
+ return this.seasonService;
}
private void initCommands() {
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index 4b83669..0274b1e 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -20,6 +20,7 @@ dependencies {
testImplementation(libs.cyano)
testImplementation(libs.aves)
testImplementation(libs.junit.api)
+ testImplementation(libs.junit.params)
testImplementation(libs.junit.platform.launcher)
testRuntimeOnly(libs.junit.engine)
}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/DayTimeStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/DayTimeStrategy.java
new file mode 100644
index 0000000..d42c9d1
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/DayTimeStrategy.java
@@ -0,0 +1,80 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
Implementations are stateless and pure: they are handed the instant instead of reading a clock + * themselves. The {@link java.time.Clock} lives in the calling {@link WorldTimeService} (US-2.03), + * which is what makes every mapping testable against fixed instants without waiting for real time. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public interface DayTimeStrategy { + + /** A full Minecraft day in ticks. */ + int TICKS_PER_DAY = 24_000; + + /** + * The tick at which the sun stands at its highest point in Minecraft. + * + *
Minecraft counts a day from sunrise: tick {@code 0} is daybreak, {@code 6000} noon,
+ * {@code 12000} nightfall and {@code 18000} midnight.
+ */
+ int NOON_TICK = 6_000;
+
+ /** The first tick of the Minecraft night; the day segment is {@code [0, DUSK_TICK)}. */
+ int DUSK_TICK = 12_000;
+
+ /**
+ * Returns the day time that applies at the given instant.
+ *
+ * @param instant the instant the day time applies to
+ * @param zone the time zone that is calculated against
+ * @return the day time in ticks, within {@code [0, }{@value #TICKS_PER_DAY}{@code )}
+ */
+ @Contract(pure = true)
+ long ticksAt(Instant instant, ZoneId zone);
+
+ /**
+ * Returns the linear mapping, the default of stage 2 (US-2.06).
+ *
+ * @return the shared, immutable linear strategy
+ */
+ @Contract(pure = true)
+ static DayTimeStrategy linear() {
+ return LinearDayTimeStrategy.instance();
+ }
+
+ /**
+ * Returns the solar mapping for Berlin (US-2.07).
+ *
+ * @return the shared, immutable solar strategy for Berlin
+ */
+ @Contract(pure = true)
+ static DayTimeStrategy solar() {
+ return SolarDayTimeStrategy.berlin();
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategy.java
new file mode 100644
index 0000000..d94864d
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategy.java
@@ -0,0 +1,81 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
This is the default of stage 2 (US-2.06). It delivers nearly the whole benefit of a real-time + * lobby and has no astronomical calculation that could be silently wrong. + * + *
The mapping reads the local wall clock, not the offset from UTC. That is deliberate: + * on the last Sunday in March the lobby jumps forward by 1000 ticks together with everybody's + * watch, + * and on the last Sunday in October it repeats the hour. Noon in the world is whenever a player in + * Berlin says it is noon (US-2.04, NFR-006). + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class LinearDayTimeStrategy implements DayTimeStrategy { + + private static final LinearDayTimeStrategy INSTANCE = new LinearDayTimeStrategy(); + + private static final int SECONDS_PER_DAY = 86_400; + + /** + * The wall clock second that Minecraft tick {@code 0} stands for. + * + *
Tick {@code 0} is daybreak, which the game presents as 06:00. + */ + private static final int DAYBREAK_SECOND_OF_DAY = 6 * 3_600; + + private LinearDayTimeStrategy() { + } + + /** + * Returns the shared instance. + * + *
The strategy carries no state, so a single instance serves every caller.
+ *
+ * @return the shared linear strategy
+ */
+ @Contract(pure = true)
+ public static LinearDayTimeStrategy instance() {
+ return INSTANCE;
+ }
+
+ @Override
+ @Contract(pure = true)
+ public long ticksAt(Instant instant, ZoneId zone) {
+ int secondOfDay = instant.atZone(zone).toLocalTime().toSecondOfDay();
+ long sinceDaybreak = Math.floorMod(secondOfDay - DAYBREAK_SECOND_OF_DAY, (long) SECONDS_PER_DAY);
+ return sinceDaybreak * TICKS_PER_DAY / SECONDS_PER_DAY;
+ }
+
+ @Override
+ public String toString() {
+ return "LinearDayTimeStrategy";
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/SeasonService.java b/common/src/main/java/net/onelitefeather/titan/common/time/SeasonService.java
new file mode 100644
index 0000000..eaaa206
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/SeasonService.java
@@ -0,0 +1,105 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
Like {@link WorldTimeService} it holds the {@link Clock} that the strategies deliberately do
+ * not, so that "which season is it" can be asked for any instant a test cares to name (US-2.03).
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public interface SeasonService {
+
+ /**
+ * Creates a service on the meteorological boundaries in the editorial zone — the stage 2
+ * default.
+ *
+ * @param clock the clock the current date is read from
+ * @return a new service
+ */
+ @Contract(pure = true, value = "_ -> new")
+ static SeasonService create(Clock clock) {
+ return create(clock, TitanTime.EDITORIAL_ZONE, SeasonBoundaryStrategy.meteorological());
+ }
+
+ /**
+ * Creates a service on the given boundaries in the editorial zone.
+ *
+ * @param clock the clock the current date is read from
+ * @param strategy the boundaries to apply
+ * @return a new service
+ */
+ @Contract(pure = true, value = "_, _ -> new")
+ static SeasonService create(Clock clock, SeasonBoundaryStrategy strategy) {
+ return create(clock, TitanTime.EDITORIAL_ZONE, strategy);
+ }
+
+ /**
+ * Creates a service on the given boundaries in the given zone.
+ *
+ * @param clock the clock the current date is read from
+ * @param zone the zone the date is read in
+ * @param strategy the boundaries to apply
+ * @return a new service
+ */
+ @Contract(pure = true, value = "_, _, _ -> new")
+ static SeasonService create(Clock clock, ZoneId zone, SeasonBoundaryStrategy strategy) {
+ return new TitanSeasonService(clock, zone, strategy);
+ }
+
+ /**
+ * Returns the season in effect at the clock's current instant.
+ *
+ * @return the current season
+ */
+ Season currentSeason();
+
+ /**
+ * Returns the date the clock's current instant falls on in this service's zone.
+ *
+ * @return the current date
+ */
+ LocalDate currentDate();
+
+ /**
+ * Returns the zone the date is read in.
+ *
+ * @return the zone
+ */
+ @Contract(pure = true)
+ ZoneId zone();
+
+ /**
+ * Returns the boundaries in use.
+ *
+ * @return the strategy
+ */
+ @Contract(pure = true)
+ SeasonBoundaryStrategy strategy();
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java
new file mode 100644
index 0000000..13201d4
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java
@@ -0,0 +1,292 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
Minecraft renders its own sunrise across ticks 23000…0 and its sunset across + * 12000…13000. Anchoring the real events at 0 and 12000 puts the visible transition within + * roughly 1000 ticks — about one real hour — of the astronomical event. Anchoring them in the + * middle + * of the rendered transition instead would be just as defensible; this class picks the segment + * boundaries because they keep day and night exactly half the tick range each. + * + *
| vs USNO (printed to the minute) | vs full-precision ephemeris | |
|---|---|---|
| solar noon | 41 s | 17 s |
| sunrise | 56 s | 39 s |
| sunset | 96 s | 102 s |
Up to 30 seconds of the USNO column is the table's own rounding, which is why the two columns + * differ; the ephemeris column is the honest one. So: solar noon inside 20 seconds, sunrise + * inside 40, sunset inside two minutes. One figure for all three would have to be the worst of + * them, so this class does not quote one. + * + *
Solar noon — the midpoint of the computed sunrise and sunset — is the accurate part because it + * depends only on the time scale and the equation of time. Sunrise and sunset carry the error of + * the half-day length on top of that, and that error is the larger one; see the third bullet below + * for where it comes from. One Minecraft tick is worth 3.6 real seconds in a 24-hour cycle, so all + * of this is visible in principle — this is a lighting effect, not an ephemeris. + * + *
Known limits, stated rather than hidden: + * + *
Exposed because a mapping that cannot be held against a published sunrise table is a
+ * mapping nobody can check.
+ *
+ * @param date the date to compute for
+ * @return the instant of sunrise, or {@code null} if the sun neither rises nor sets that date
+ */
+ @Contract(pure = true)
+ public @Nullable Instant sunrise(LocalDate date) {
+ SolarDay day = solarDay(date);
+ return day == null ? null : day.sunrise();
+ }
+
+ /**
+ * Returns the sunset of the given date at this position.
+ *
+ * @param date the date to compute for
+ * @return the instant of sunset, or {@code null} if the sun neither rises nor sets that date
+ */
+ @Contract(pure = true)
+ public @Nullable Instant sunset(LocalDate date) {
+ SolarDay day = solarDay(date);
+ return day == null ? null : day.sunset();
+ }
+
+ private static long dayTicks(Instant sunrise, Instant sunset, Instant instant) {
+ double progress = fraction(sunrise, sunset, instant);
+ return clamp((long) (progress * DUSK_TICK), 0L, DUSK_TICK - 1L);
+ }
+
+ private static long nightTicks(Instant sunset, Instant nextSunrise, Instant instant) {
+ double progress = fraction(sunset, nextSunrise, instant);
+ long night = TICKS_PER_DAY - (long) DUSK_TICK;
+ return clamp(DUSK_TICK + (long) (progress * night), DUSK_TICK, TICKS_PER_DAY - 1L);
+ }
+
+ private static double fraction(Instant from, Instant to, Instant instant) {
+ double span = to.toEpochMilli() - (double) from.toEpochMilli();
+ if (span <= 0.0) {
+ return 0.0;
+ }
+ return (instant.toEpochMilli() - (double) from.toEpochMilli()) / span;
+ }
+
+ private static long clamp(long value, long min, long max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ /**
+ * Solves the sunrise equation for one calendar date at this position.
+ *
+ * @param date the date, taken as the local date at this position's longitude
+ * @return the two solar events of that date, or {@code null} if the sun neither rises nor sets
+ */
+ private @Nullable SolarDay solarDay(LocalDate date) {
+ double days = date.toEpochDay() - J2000_EPOCH_DAY;
+ // Mean solar time at this longitude, in days since J2000.0. The published form writes this
+ // as ceil(J - 2451545.0 + 0.0008); the epoch day is already the exact whole day that ceil
+ // is there to produce, so both the ceil and the 0.0008 it rounds with fall away. See the
+ // class javadoc: keeping the 0.0008 as a summand would put every event 69 seconds late.
+ double meanSolarTime = days + (-this.longitude) / 360.0;
+ double meanAnomalyDegrees = (357.5291 + 0.98560028 * meanSolarTime) % 360.0;
+ double meanAnomaly = Math.toRadians(meanAnomalyDegrees);
+ double equationOfCentre = 1.9148 * Math.sin(meanAnomaly) + 0.0200 * Math.sin(2.0 * meanAnomaly) + 0.0003 * Math.sin(3.0 * meanAnomaly);
+ double eclipticLongitude = Math.toRadians(
+ (meanAnomalyDegrees + equationOfCentre + PERIHELION_ARGUMENT + 180.0) % 360.0);
+ double transit = J2000_JULIAN_DATE + meanSolarTime + 0.0053 * Math.sin(meanAnomaly) - 0.0069 * Math.sin(2.0 * eclipticLongitude);
+
+ double sinDeclination = Math.sin(eclipticLongitude) * Math.sin(Math.toRadians(EARTH_OBLIQUITY));
+ double cosDeclination = Math.cos(Math.asin(sinDeclination));
+ double latitudeRadians = Math.toRadians(this.latitude);
+ double cosHourAngle = (Math.sin(Math.toRadians(HORIZON_ALTITUDE)) - Math.sin(latitudeRadians) * sinDeclination) / (Math.cos(latitudeRadians) * cosDeclination);
+ if (Double.isNaN(cosHourAngle) || cosHourAngle > 1.0 || cosHourAngle < -1.0) {
+ return null;
+ }
+ double hourAngle = Math.toDegrees(Math.acos(cosHourAngle));
+ return new SolarDay(
+ fromJulianDate(transit - hourAngle / 360.0), fromJulianDate(transit + hourAngle / 360.0));
+ }
+
+ private static Instant fromJulianDate(double julianDate) {
+ return Instant.ofEpochMilli(Math.round((julianDate - UNIX_EPOCH_JULIAN_DATE) * MILLIS_PER_DAY));
+ }
+
+ @Override
+ public String toString() {
+ return "SolarDayTimeStrategy[latitude=" + this.latitude + ", longitude=" + this.longitude + ']';
+ }
+
+ /**
+ * The two solar events of one calendar date at one position.
+ *
+ * @param sunrise the moment the upper limb of the sun appears
+ * @param sunset the moment it disappears again
+ */
+ private record SolarDay(Instant sunrise, Instant sunset) {
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfig.java b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfig.java
new file mode 100644
index 0000000..0f0afae
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfig.java
@@ -0,0 +1,242 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see
This is the "where … is configured" the three acceptance criteria are written as: the calling + * code knows only {@link DayTimeStrategy} and {@link SeasonBoundaryStrategy}, and this file decides + * which implementation answers. + * + *
It follows the shape of {@link net.onelitefeather.titan.common.config.AppConfig} — a sealed + * interface with a package-private record behind it, loaded from JSON by a provider — minus the + * builder, the same way {@code PortalConfig} does. {@code AppConfig} has a builder because + * {@code /app} edits it while the server runs. These two values are read once, when the services + * are constructed at boot; a builder and a command would advertise a live switch that does not + * exist. + * + *
Read only when {@link #seasonStrategy()} is {@value #FIXED}; a value left here while
+ * another strategy runs is ignored rather than treated as an error, so that switching back and
+ * forth does not mean deleting the key each time.
+ *
+ * @return the season identifier, or {@code null} when the key is absent
+ */
+ @Contract(pure = true)
+ @Nullable
+ String fixedSeason();
+
+ /**
+ * Resolves {@link #dayTimeStrategy()} into the implementation that will answer.
+ *
+ * @return the linear strategy when the value is absent or {@value #LINEAR}, the solar strategy
+ * when it is {@value #SOLAR}
+ * @throws TimeConfigException if the value is present but not one of {@link
+ * #DAY_TIME_STRATEGIES}
+ */
+ @Contract(pure = true)
+ @NotNull
+ default DayTimeStrategy resolveDayTimeStrategy() {
+ String name = normalize(dayTimeStrategy());
+ if (name == null) {
+ return DayTimeStrategy.linear();
+ }
+ return switch (name) {
+ case LINEAR -> DayTimeStrategy.linear();
+ case SOLAR -> DayTimeStrategy.solar();
+ default ->
+ throw TimeConfigException.unknownValue("dayTimeStrategy", dayTimeStrategy(), DAY_TIME_STRATEGIES);
+ };
+ }
+
+ /**
+ * Resolves {@link #seasonStrategy()} into the implementation that will answer.
+ *
+ * @param zone the zone an astronomical boundary instant is resolved in; an equinox is an
+ * instant, not a date, so the zone decides which calendar day it lands on
+ * @return the meteorological strategy when the value is absent or {@value #METEOROLOGICAL}, the
+ * astronomical one when it is {@value #ASTRONOMICAL}, and a strategy pinned to
+ * {@link #fixedSeason()} when it is {@value #FIXED}
+ * @throws TimeConfigException if the value is present but not one of
+ * {@link #SEASON_STRATEGIES}, or if {@value #FIXED} is asked for
+ * without a usable {@link #fixedSeason()}
+ */
+ @Contract(pure = true)
+ @NotNull
+ default SeasonBoundaryStrategy resolveSeasonStrategy(@NotNull ZoneId zone) {
+ String name = normalize(seasonStrategy());
+ if (name == null) {
+ return SeasonBoundaryStrategy.meteorological();
+ }
+ return switch (name) {
+ case METEOROLOGICAL -> SeasonBoundaryStrategy.meteorological();
+ case ASTRONOMICAL -> SeasonBoundaryStrategy.astronomical(zone);
+ case FIXED -> SeasonBoundaryStrategy.fixed(resolveFixedSeason());
+ default ->
+ throw TimeConfigException.unknownValue("seasonStrategy", seasonStrategy(), SEASON_STRATEGIES);
+ };
+ }
+
+ /**
+ * Resolves {@link #fixedSeason()} into the season {@value #FIXED} pins the lobby to.
+ *
+ * @return the named season
+ * @throws TimeConfigException if the value is absent, or is not the identifier of a
+ * {@link Season}
+ */
+ @Contract(pure = true)
+ @NotNull
+ default Season resolveFixedSeason() {
+ String name = normalize(fixedSeason());
+ if (name == null) {
+ throw TimeConfigException.missingFixedSeason(seasonIds());
+ }
+ for (Season season : Season.values()) {
+ if (season.id().equals(name)) {
+ return season;
+ }
+ }
+ throw TimeConfigException.unknownValue("fixedSeason", fixedSeason(), seasonIds());
+ }
+
+ /**
+ * The identifiers every {@link Season} is written as in {@value #TIME_FILE_NAME}.
+ *
+ * @return the season identifiers in calendar order
+ */
+ @Contract(pure = true)
+ @NotNull
+ static List It is deliberately unchecked and deliberately not caught anywhere: a lobby that starts on a
+ * strategy nobody chose is worse than a lobby that does not start. The message always carries the
+ * value that was rejected and the values that would have been accepted, because the only person
+ * who ever reads it is looking at their own typo.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class TimeConfigException extends RuntimeException {
+
+ private TimeConfigException(String message) {
+ super(message);
+ }
+
+ private TimeConfigException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ /**
+ * Reports a value that is present but not one this lobby knows.
+ *
+ * @param key the configuration key the value was written under
+ * @param value the value as written in the file
+ * @param accepted the values that would have been accepted
+ * @return the exception to throw
+ */
+ @Contract(pure = true, value = "_, _, _ -> new")
+ @NotNull
+ static TimeConfigException unknownValue(@NotNull String key, @Nullable String value, @NotNull List The three values stay {@link String}s rather than enums on purpose. Gson maps an unknown enum
+ * constant to {@code null}, which is indistinguishable from an absent key — and an absent key is
+ * what keeps the default. Reading the raw text and resolving it in
+ * {@link TimeConfig#resolveDayTimeStrategy()} and {@link TimeConfig#resolveSeasonStrategy} is what
+ * lets a typo be reported as a typo.
+ *
+ * @param dayTimeStrategy the day time strategy name as written in the file
+ * @param seasonStrategy the season strategy name as written in the file
+ * @param fixedSeason the pinned season name as written in the file
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+record TimeConfigImpl(@Nullable String dayTimeStrategy, @Nullable String seasonStrategy,
+ @Nullable String fixedSeason) implements TimeConfig {
+
+ /** The stage 2 defaults, written out when no file exists yet (US-2.06, US-2.11). */
+ static final TimeConfigImpl DEFAULT = new TimeConfigImpl(TimeConfig.LINEAR, TimeConfig.METEOROLOGICAL, null);
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigProvider.java b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigProvider.java
new file mode 100644
index 0000000..b0dc413
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigProvider.java
@@ -0,0 +1,184 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see The point of the two {@code create…} methods is that they are the only place the strategies
+ * are chosen. The application asks this provider for its services and never names a strategy, so
+ * "which mapping runs" really is a configuration question and not a code question (US-2.07,
+ * US-2.12, US-2.13).
+ *
+ * Both values are resolved in the constructor rather than on first use. A misspelt strategy
+ * therefore stops the boot, at the point where an operator is still watching the log — not an hour
+ * later, and not silently never.
+ *
+ * The {@link Clock} is not part of the configuration and never becomes part of it: it is passed
+ * into {@link #createWorldTimeService(Clock)} and {@link #createSeasonService(Clock)} by the
+ * caller, which is what keeps every mapping testable against a fixed instant (US-2.03).
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class TimeConfigProvider {
+
+ private static final TypeToken The lobby has exactly one editorial time zone. Seasons, day time and the announced start of a
+ * seasonal event are all told in {@link #EDITORIAL_ZONE}, no matter where the process runs or which
+ * zone the operating system reports. Resolving it in one place keeps a redeployment to a different
+ * host from silently moving the lobby's calendar.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class TitanTime {
+
+ /**
+ * The zone the lobby's calendar and clock are told in.
+ *
+ * {@code Europe/Berlin} rather than a fixed offset: the zone carries the daylight saving
+ * rules, so {@code 12:00} stays noon across both transitions without anyone touching a
+ * configuration file (NFR-006).
+ */
+ public static final ZoneId EDITORIAL_ZONE = ZoneId.of("Europe/Berlin");
+
+ /**
+ * How often the day time is pushed to an instance.
+ *
+ * One Minecraft day is {@value DayTimeStrategy#TICKS_PER_DAY} ticks over 24 real hours, so a
+ * single tick of game time lasts 3.6 real seconds. Updating once per second is therefore
+ * already
+ * finer than the value can change, and updating per server tick would send twenty identical
+ * packets for every one that carries new information (NFR-008, US-2.14).
+ */
+ public static final Duration UPDATE_INTERVAL = Duration.ofSeconds(1);
+
+ /**
+ * Returns {@link #UPDATE_INTERVAL} counted in server ticks.
+ *
+ * Minestom's scheduler takes either a wall-clock duration or a number of ticks. Ticks are
+ * the
+ * better unit here: a lagging server should update its day time less often, not pile up work it
+ * cannot do, and a schedule expressed in ticks is what a test can advance by hand.
+ *
+ * @return the update interval in server ticks, at least one
+ */
+ public static int updateIntervalTicks() {
+ long ticks = UPDATE_INTERVAL.toMillis() / MinecraftServer.TICK_MS;
+ return (int) Math.max(1L, ticks);
+ }
+
+ private TitanTime() {
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/TitanWorldTimeService.java b/common/src/main/java/net/onelitefeather/titan/common/time/TitanWorldTimeService.java
new file mode 100644
index 0000000..b2dff1e
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/TitanWorldTimeService.java
@@ -0,0 +1,138 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see Minestom 26.1 replaced {@code Instance#setTimeRate(int)} with a per-dimension
+ * {@link net.minestom.server.instance.Clock}, and a dimension may have none at all — that is
+ * what
+ * {@link Instance#defaultClock()} returning {@code null} means. Such an instance has no day
+ * time
+ * to drive, so the service says so once instead of failing later with a silent no-op on every
+ * write.
+ *
+ * @param instance the instance to freeze
+ */
+ private static void freezeOwnCycle(Instance instance) {
+ net.minestom.server.instance.Clock worldClock = instance.defaultClock();
+ if (worldClock == null) {
+ LOGGER.warn("Instance {} (dimension {}) has no default clock; its day time cannot be driven", instance.getUuid(), instance.getDimensionName());
+ return;
+ }
+ worldClock.rate(FROZEN_RATE);
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/WorldTimeService.java b/common/src/main/java/net/onelitefeather/titan/common/time/WorldTimeService.java
new file mode 100644
index 0000000..82f8abb
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/WorldTimeService.java
@@ -0,0 +1,142 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see The service owns the two things a {@link DayTimeStrategy} deliberately does not: the
+ * {@link Clock} and the instance. The clock is injected rather than read from
+ * {@link java.time.Instant#now()} so that a test can stand on a fixed instant instead of waiting
+ * for
+ * one (US-2.03, NFR-007).
+ *
+ * {@link #bind(Instance)} switches Minestom's own cycle off before it sets anything, because two
+ * writers on the same value produce a lobby that flickers between them (US-2.02). From then on the
+ * time is pushed once per second — never per tick, which would be twenty packets for every one that
+ * carries a changed value (US-2.14, NFR-008).
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public interface WorldTimeService {
+
+ /**
+ * Creates a service on the linear mapping in the editorial zone — the stage 2 default.
+ *
+ * @param clock the clock the current instant is read from
+ * @return a new service
+ */
+ @Contract(pure = true, value = "_ -> new")
+ static WorldTimeService create(Clock clock) {
+ return create(clock, TitanTime.EDITORIAL_ZONE, DayTimeStrategy.linear());
+ }
+
+ /**
+ * Creates a service on the given mapping in the editorial zone.
+ *
+ * @param clock the clock the current instant is read from
+ * @param strategy the mapping from real time to day time
+ * @return a new service
+ */
+ @Contract(pure = true, value = "_, _ -> new")
+ static WorldTimeService create(Clock clock, DayTimeStrategy strategy) {
+ return create(clock, TitanTime.EDITORIAL_ZONE, strategy);
+ }
+
+ /**
+ * Creates a service on the given mapping in the given zone.
+ *
+ * @param clock the clock the current instant is read from
+ * @param zone the zone the mapping is calculated against
+ * @param strategy the mapping from real time to day time
+ * @return a new service
+ */
+ @Contract(pure = true, value = "_, _, _ -> new")
+ static WorldTimeService create(Clock clock, ZoneId zone, DayTimeStrategy strategy) {
+ return new TitanWorldTimeService(clock, zone, strategy);
+ }
+
+ /**
+ * Returns the day time that applies at the clock's current instant.
+ *
+ * @return the day time in ticks, within
+ * {@code [0, }{@value DayTimeStrategy#TICKS_PER_DAY}{@code )}
+ */
+ long currentTicks();
+
+ /**
+ * Returns the zone the mapping is calculated against.
+ *
+ * @return the zone
+ */
+ @Contract(pure = true)
+ ZoneId zone();
+
+ /**
+ * Returns the mapping in use.
+ *
+ * @return the strategy
+ */
+ @Contract(pure = true)
+ DayTimeStrategy strategy();
+
+ /**
+ * Takes the instance over: stops Minestom's own day cycle, writes the current time once, and
+ * schedules the repeating update.
+ *
+ * Binding a second instance replaces the first; the previous update task is cancelled.
+ *
+ * @param instance the instance whose day time this service drives
+ */
+ void bind(Instance instance);
+
+ /**
+ * Cancels the update task and releases the instance. The day time is left where it was, and the
+ * instance's own cycle stays off — resuming it is the caller's decision.
+ */
+ void unbind();
+
+ /**
+ * Writes the current day time to the bound instance.
+ *
+ * Called by the scheduled task, and directly by tests. It is a no-op when nothing is bound
+ * or
+ * when it already ran during the current wall-clock second, which is what keeps the promise of
+ * {@link TitanTime#UPDATE_INTERVAL} independent of how often anyone calls it.
+ *
+ * @return {@code true} if the time was written, {@code false} if the call was skipped
+ */
+ boolean update();
+
+ /**
+ * Returns the instance this service currently drives.
+ *
+ * @return the bound instance, or {@code null} if none is bound
+ */
+ @Contract(pure = true)
+ @Nullable
+ Instance boundInstance();
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/time/package-info.java
new file mode 100644
index 0000000..ca4758a
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * Real time to game time: the strategies that map a wall-clock instant onto a Minecraft day, the
+ * services that drive them from an injected {@link java.time.Clock}, and the editorial time zone
+ * they are told in.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+@NotNullByDefault
+package net.onelitefeather.titan.common.time;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategy.java
new file mode 100644
index 0000000..30fcd28
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategy.java
@@ -0,0 +1,235 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see A season starts on the calendar day its event falls on, read in the zone this strategy was
+ * built for: spring on the March equinox, summer on the June solstice, autumn on the September
+ * equinox, winter on the December solstice. The events move by up to a day and a half between
+ * years,
+ * which is exactly why they cannot be hard-coded as 20 March and 21 June.
+ *
+ * Known limits, stated rather than hidden:
+ *
+ * {@code A} is an amplitude in units of 10-5 days, {@code B} a phase in degrees
+ * and
+ * {@code C} an angular speed in degrees per Julian century.
+ */
+ private static final double[][] PERIODIC_TERMS = {{485, 324.96, 1934.136}, {203, 337.23, 32964.467}, {199, 342.08, 20.186}, {182, 27.85, 445267.112}, {156, 73.14, 45036.886}, {136, 171.52, 22518.443}, {77, 222.54, 65928.934}, {74, 296.72, 3034.906}, {70, 243.58, 9037.513}, {58, 119.81, 33718.147}, {52, 297.17, 150.678}, {50, 21.02, 2281.226}, {45, 247.54, 29929.562}, {44, 325.15, 31555.956}, {29, 60.93, 4443.417}, {18, 155.12, 67555.328}, {17, 288.79, 4562.452}, {16, 198.04, 62894.029}, {14, 199.76, 31436.921}, {12, 95.39, 14577.848}, {12, 287.11, 31931.756}, {12, 320.81, 34777.259}, {9, 227.73, 1222.114}, {8, 15.45, 16859.074},
+ };
+
+ /** Julian date of the Unix epoch. */
+ private static final double UNIX_EPOCH_JULIAN_DATE = 2_440_587.5;
+
+ /** Julian date of J2000.0. */
+ private static final double J2000_JULIAN_DATE = 2_451_545.0;
+
+ private static final double DAYS_PER_JULIAN_CENTURY = 36_525.0;
+
+ private static final double MILLIS_PER_DAY = 86_400_000.0;
+
+ private static final double SECONDS_PER_DAY = 86_400.0;
+
+ private final ZoneId zone;
+
+ private AstronomicalSeasonStrategy(ZoneId zone) {
+ this.zone = zone;
+ }
+
+ /**
+ * Returns the strategy that resolves the event instants in the given zone.
+ *
+ * @param zone the zone the calendar day of an event is read in
+ * @return an astronomical strategy for that zone
+ */
+ @Contract(pure = true, value = "_ -> new")
+ public static AstronomicalSeasonStrategy of(ZoneId zone) {
+ return new AstronomicalSeasonStrategy(zone);
+ }
+
+ @Override
+ @Contract(pure = true)
+ public Season seasonAt(LocalDate date) {
+ int year = date.getYear();
+ if (date.isBefore(eventDate(year, Event.MARCH_EQUINOX))) {
+ // Still in the winter that started in the previous December.
+ return Season.WINTER;
+ }
+ if (date.isBefore(eventDate(year, Event.JUNE_SOLSTICE))) {
+ return Season.SPRING;
+ }
+ if (date.isBefore(eventDate(year, Event.SEPTEMBER_EQUINOX))) {
+ return Season.SUMMER;
+ }
+ if (date.isBefore(eventDate(year, Event.DECEMBER_SOLSTICE))) {
+ return Season.AUTUMN;
+ }
+ return Season.WINTER;
+ }
+
+ /**
+ * Returns the calendar day, in this strategy's zone, that the given event of the given year
+ * falls
+ * on.
+ *
+ * @param year the calendar year
+ * @param event the event to locate
+ * @return the local date of the event
+ */
+ @Contract(pure = true)
+ public LocalDate eventDate(int year, Event event) {
+ return eventInstant(year, event).atZone(this.zone).toLocalDate();
+ }
+
+ /**
+ * Returns the instant of the given event of the given year.
+ *
+ * Exposed because an implementation that cannot be held against a published equinox table is
+ * one nobody can check.
+ *
+ * @param year the calendar year
+ * @param event the event to locate
+ * @return the instant of the event in UT
+ */
+ @Contract(pure = true)
+ public static Instant eventInstant(int year, Event event) {
+ double meanJulianDate = event.meanJulianDate(year);
+ double centuries = (meanJulianDate - J2000_JULIAN_DATE) / DAYS_PER_JULIAN_CENTURY;
+ double w = Math.toRadians(35999.373 * centuries - 2.47);
+ double lambdaCorrection = 1.0 + 0.0334 * Math.cos(w) + 0.0007 * Math.cos(2.0 * w);
+ double periodic = 0.0;
+ for (double[] term : PERIODIC_TERMS) {
+ periodic += term[0] * Math.cos(Math.toRadians(term[1] + term[2] * centuries));
+ }
+ // Dynamical time; shift to UT so that the calendar day is the one a clock in the zone shows.
+ double dynamical = meanJulianDate + (0.00001 * periodic) / lambdaCorrection;
+ double universal = dynamical - deltaTSeconds(year) / SECONDS_PER_DAY;
+ return Instant.ofEpochMilli(Math.round((universal - UNIX_EPOCH_JULIAN_DATE) * MILLIS_PER_DAY));
+ }
+
+ /**
+ * Espenak and Meeus' polynomial for the difference between dynamical time and UT, fitted to
+ * 2005…2050.
+ *
+ * @param year the calendar year
+ * @return ΔT in seconds
+ */
+ private static double deltaTSeconds(int year) {
+ double t = year - 2000.0;
+ return 62.92 + 0.32217 * t + 0.005589 * t * t;
+ }
+
+ /**
+ * Returns the zone the event instants are resolved in.
+ *
+ * @return the zone
+ */
+ @Contract(pure = true)
+ public ZoneId zone() {
+ return this.zone;
+ }
+
+ @Override
+ public String toString() {
+ return "AstronomicalSeasonStrategy[zone=" + this.zone + ']';
+ }
+
+ /**
+ * The four astronomical events that open a season.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+ public enum Event {
+
+ /** The moment spring begins, around 20 March. */
+ MARCH_EQUINOX(2451623.80984, 365242.37404, 0.05169, -0.00411, -0.00057),
+
+ /** The moment summer begins, around 21 June. */
+ JUNE_SOLSTICE(2451716.56767, 365241.62603, 0.00325, 0.00888, -0.00030),
+
+ /** The moment autumn begins, around 22 September. */
+ SEPTEMBER_EQUINOX(2451810.21715, 365242.01767, -0.11575, 0.00337, 0.00078),
+
+ /** The moment winter begins, around 21 December. */
+ DECEMBER_SOLSTICE(2451900.05952, 365242.74049, -0.06223, -0.00823, 0.00032);
+
+ private final double a0;
+ private final double a1;
+ private final double a2;
+ private final double a3;
+ private final double a4;
+
+ Event(double a0, double a1, double a2, double a3, double a4) {
+ this.a0 = a0;
+ this.a1 = a1;
+ this.a2 = a2;
+ this.a3 = a3;
+ this.a4 = a4;
+ }
+
+ /**
+ * Returns the mean Julian date of this event, before the periodic terms are applied.
+ *
+ * @param year the calendar year, meant for 1000…3000
+ * @return the mean Julian ephemeris date
+ */
+ @Contract(pure = true)
+ double meanJulianDate(int year) {
+ double y = (year - 2000) / 1000.0;
+ return this.a0 + this.a1 * y + this.a2 * y * y + this.a3 * y * y * y + this.a4 * y * y * y * y;
+ }
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategy.java
new file mode 100644
index 0000000..f257c04
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategy.java
@@ -0,0 +1,53 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see This is not only a test aid. It is the supported way to show a winter event in August without
+ * anyone reaching for the system clock: pin the season, look at the result, unpin it. That the
+ * preview path costs ten lines instead of a third branch in a conditional is the concrete payoff of
+ * building the boundaries as a strategy.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public record FixedSeasonStrategy(Season season) implements SeasonBoundaryStrategy {
+
+ /**
+ * Returns a strategy pinned to the given season.
+ *
+ * @param season the season to answer with
+ * @return the pinned strategy
+ */
+ @Contract(pure = true, value = "_ -> new")
+ public static FixedSeasonStrategy of(Season season) {
+ return new FixedSeasonStrategy(season);
+ }
+
+ @Override
+ @Contract(pure = true)
+ public Season seasonAt(LocalDate date) {
+ return this.season;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategy.java
new file mode 100644
index 0000000..df6e364
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategy.java
@@ -0,0 +1,74 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see This is the default of stage 2 (US-2.11). The boundaries fall on calendar days that never
+ * move,
+ * so nothing has to be computed and nothing can drift: a build team can be told "the winter world
+ * goes live on the first of December" and that is the whole rule. The astronomical boundaries
+ * differ
+ * by roughly three weeks — noticeable, but not a reason to pull an astronomical calculation into
+ * the
+ * default path.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class MeteorologicalSeasonStrategy implements SeasonBoundaryStrategy {
+
+ private static final MeteorologicalSeasonStrategy INSTANCE = new MeteorologicalSeasonStrategy();
+
+ private MeteorologicalSeasonStrategy() {
+ }
+
+ /**
+ * Returns the shared instance.
+ *
+ * The strategy carries no state, so a single instance serves every caller.
+ *
+ * @return the shared meteorological strategy
+ */
+ @Contract(pure = true)
+ public static MeteorologicalSeasonStrategy instance() {
+ return INSTANCE;
+ }
+
+ @Override
+ @Contract(pure = true)
+ public Season seasonAt(LocalDate date) {
+ return switch (Month.of(date.getMonthValue())) {
+ case MARCH, APRIL, MAY -> Season.SPRING;
+ case JUNE, JULY, AUGUST -> Season.SUMMER;
+ case SEPTEMBER, OCTOBER, NOVEMBER -> Season.AUTUMN;
+ case DECEMBER, JANUARY, FEBRUARY -> Season.WINTER;
+ };
+ }
+
+ @Override
+ public String toString() {
+ return "MeteorologicalSeasonStrategy";
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/season/Season.java b/common/src/main/java/net/onelitefeather/titan/common/time/season/Season.java
new file mode 100644
index 0000000..729bbf9
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/Season.java
@@ -0,0 +1,69 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see The enum is the state the rest of the lobby reads (US-2.09). Which dates it changes on is not
+ * its business — that is the job of a {@link SeasonBoundaryStrategy}, and the point of keeping the
+ * two apart is that a seasonal package can be previewed in August without touching the system
+ * clock.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public enum Season {
+
+ /** March to May under meteorological boundaries. */
+ SPRING,
+
+ /** June to August under meteorological boundaries. */
+ SUMMER,
+
+ /** September to November under meteorological boundaries. */
+ AUTUMN,
+
+ /** December to February under meteorological boundaries. */
+ WINTER;
+
+ /**
+ * Returns the lower-case identifier used in configuration files and world directory names.
+ *
+ * @return the identifier, for example {@code "winter"}
+ */
+ @Contract(pure = true)
+ public String id() {
+ return name().toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * Returns the season that follows this one.
+ *
+ * @return the next season in calendar order, wrapping from winter to spring
+ */
+ @Contract(pure = true)
+ public Season next() {
+ Season[] values = values();
+ return values[(ordinal() + 1) % values.length];
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/season/SeasonBoundaryStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/season/SeasonBoundaryStrategy.java
new file mode 100644
index 0000000..3d7b736
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/SeasonBoundaryStrategy.java
@@ -0,0 +1,80 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see Implementations are stateless and pure: they are handed the date instead of reading a clock
+ * themselves. The {@link java.time.Clock} lives in the calling service (US-2.03), which is what
+ * lets
+ * a test check December behaviour in August without changing the machine.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public interface SeasonBoundaryStrategy {
+
+ /**
+ * Returns the season in effect on the given date.
+ *
+ * @param date the date in the editorial time zone
+ * @return the season that applies that day
+ */
+ @Contract(pure = true)
+ Season seasonAt(LocalDate date);
+
+ /**
+ * Returns the meteorological boundaries, the default of stage 2 (US-2.11).
+ *
+ * @return the shared, immutable meteorological strategy
+ */
+ @Contract(pure = true)
+ static SeasonBoundaryStrategy meteorological() {
+ return MeteorologicalSeasonStrategy.instance();
+ }
+
+ /**
+ * Returns the astronomical boundaries for the given zone (US-2.12).
+ *
+ * An equinox is an instant, not a date, so the zone decides which calendar day it lands on.
+ *
+ * @param zone the zone the boundary instants are resolved in
+ * @return an astronomical strategy for that zone
+ */
+ @Contract(pure = true)
+ static SeasonBoundaryStrategy astronomical(ZoneId zone) {
+ return AstronomicalSeasonStrategy.of(zone);
+ }
+
+ /**
+ * Returns a strategy that always answers with the same season (US-2.13).
+ *
+ * @param season the season to pin
+ * @return a strategy that ignores the date
+ */
+ @Contract(pure = true)
+ static SeasonBoundaryStrategy fixed(Season season) {
+ return FixedSeasonStrategy.of(season);
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/season/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/time/season/package-info.java
new file mode 100644
index 0000000..520ebfe
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/package-info.java
@@ -0,0 +1,11 @@
+/**
+ * Which season a date falls into, and the interchangeable rules that decide it.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+@NotNullByDefault
+package net.onelitefeather.titan.common.time.season;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/DayTimeStrategyComparisonTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/DayTimeStrategyComparisonTest.java
new file mode 100644
index 0000000..f5bf8b7
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/time/DayTimeStrategyComparisonTest.java
@@ -0,0 +1,183 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see This is the test the strategy pattern exists for. A configuration flag would have made these
+ * two mappings two branches of one method, and two branches cannot be handed the same fixture and
+ * compared. Everything asserted here is a property both mappings owe the lobby regardless of how
+ * they compute it; where they legitimately differ,
+ * {@link #linearFollowsTheWallClockWhereSolarDoesNot()}
+ * pins the difference down instead of leaving it implied.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+class DayTimeStrategyComparisonTest {
+
+ static Stream The comparison between the linear and the solar mapping is only worth anything if both are
+ * asked the same questions, so the questions live here and not in either strategy's own test: both
+ * solstices, both equinoxes, both daylight saving transitions for {@code Europe/Berlin}, and an
+ * ordinary day with nothing special about it.
+ *
+ * Every instant is written as UTC. Local wall-clock time is ambiguous exactly on the two dates
+ * that matter most here — 02:30 exists twice on 25 October 2026 and not at all on 29 March 2026 —
+ * and an ambiguous fixture is a fixture that silently tests something else.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+final class FixedInstants {
+
+ static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
+
+ /** The March equinox of 2026; day and night are the same length. */
+ static final LocalDate MARCH_EQUINOX = LocalDate.of(2026, 3, 20);
+
+ /** The June solstice of 2026; the longest day of the year in Berlin. */
+ static final LocalDate JUNE_SOLSTICE = LocalDate.of(2026, 6, 21);
+
+ /** The September equinox of 2026. */
+ static final LocalDate SEPTEMBER_EQUINOX = LocalDate.of(2026, 9, 23);
+
+ /** The December solstice of 2026; the shortest day of the year in Berlin. */
+ static final LocalDate DECEMBER_SOLSTICE = LocalDate.of(2026, 12, 21);
+
+ /** The last Sunday in March 2026: 02:00 CET becomes 03:00 CEST, and an hour never happens. */
+ static final LocalDate DST_SPRING_FORWARD = LocalDate.of(2026, 3, 29);
+
+ /** The last Sunday in October 2026: 03:00 CEST becomes 02:00 CET, and an hour happens twice. */
+ static final LocalDate DST_FALL_BACK = LocalDate.of(2026, 10, 25);
+
+ /** A Friday in May with no astronomical or civil event attached to it. */
+ static final LocalDate ORDINARY_DAY = LocalDate.of(2026, 5, 15);
+
+ private FixedInstants() {
+ }
+
+ /**
+ * Returns the shared sample set.
+ *
+ * @return every fixed instant both strategies are checked against
+ */
+ static List The two mappings differ by design in where exactly they put a given minute; they do not
+ * get
+ * to disagree about whether the sun is up.
+ */
+ enum Phase {
+
+ /** The sun is up: the tick belongs to {@code [0, 12000)}. */
+ DAY,
+
+ /** The sun is down: the tick belongs to {@code [12000, 24000)}. */
+ NIGHT,
+
+ /** Near a transition, where the two mappings legitimately fall on different sides. */
+ UNSPECIFIED
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategyTest.java
new file mode 100644
index 0000000..554f048
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategyTest.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see USNO prints whole minutes, so each expectation carries up to 30 seconds of rounding of its
+ * own. The tolerances are set just above the largest residual actually measured against that
+ * source, not at a round number picked for comfort:
+ *
+ * The solar noon case is the sharp one. It is nearly free of the half-day-length error that
+ * dominates sunrise and sunset, so it pins the time scale directly: any constant offset bolted onto
+ * the mean solar time — the mistake this test exists to catch — moves it by the full amount and
+ * fails here long before the rise and set assertions notice.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+class SolarDayTimeStrategyTest {
+
+ private static final Duration EVENT_TOLERANCE = Duration.ofSeconds(120);
+
+ private static final Duration NOON_TOLERANCE = Duration.ofSeconds(45);
+
+ private final SolarDayTimeStrategy strategy = SolarDayTimeStrategy.berlin();
+
+ @ParameterizedTest(name = "{0}: sunrise {1}, sunset {2} Berlin local")
+ @CsvSource({
+ // USNO rise and set for Berlin, in local time; the two solstices and the two equinoxes
+ // of 2026.
+ "2026-03-20, 06:09, 18:19", "2026-06-21, 04:43, 21:33", "2026-09-23, 06:54, 19:03", "2026-12-21, 08:15, 15:54",
+ })
+ @DisplayName("Sunrise and sunset match the published Berlin times")
+ void sunriseAndSunsetMatchPublishedBerlinTimes(LocalDate date, LocalTime sunrise, LocalTime sunset) {
+ Instant expectedSunrise = LocalDateTime.of(date, sunrise).atZone(BERLIN).toInstant();
+ Instant expectedSunset = LocalDateTime.of(date, sunset).atZone(BERLIN).toInstant();
+
+ Instant actualSunrise = this.strategy.sunrise(date);
+ Instant actualSunset = this.strategy.sunset(date);
+
+ assertNotNull(actualSunrise);
+ assertNotNull(actualSunset);
+ assertWithin(expectedSunrise, actualSunrise, EVENT_TOLERANCE, "sunrise on " + date);
+ assertWithin(expectedSunset, actualSunset, EVENT_TOLERANCE, "sunset on " + date);
+ }
+
+ @ParameterizedTest(name = "{0}: solar noon {1} Berlin local")
+ @CsvSource({
+ // USNO upper transit of the sun over Berlin, in local time.
+ "2026-03-20, 12:14", "2026-06-21, 13:08", "2026-09-23, 12:59", "2026-12-21, 12:04",
+ })
+ @DisplayName("Solar noon matches the published Berlin upper transit to well under a minute")
+ void solarNoonMatchesThePublishedBerlinTransit(LocalDate date, LocalTime noon) {
+ Instant expected = LocalDateTime.of(date, noon).atZone(BERLIN).toInstant();
+
+ Instant sunrise = this.strategy.sunrise(date);
+ Instant sunset = this.strategy.sunset(date);
+ assertNotNull(sunrise);
+ assertNotNull(sunset);
+ // The mapping puts noon exactly halfway between the two events, so this is the transit the
+ // implementation actually believes in, not a separately computed one.
+ Instant actual = sunrise.plus(Duration.between(sunrise, sunset).dividedBy(2));
+
+ assertWithin(expected, actual, NOON_TOLERANCE, "solar noon on " + date);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @CsvSource({"2026-03-20", "2026-06-21", "2026-09-23", "2026-12-21", "2026-05-15"})
+ @DisplayName("Sunrise is the first tick of the day and sunset the first tick of the night")
+ void theSolarEventsAnchorTheSegments(LocalDate date) {
+ Instant sunrise = this.strategy.sunrise(date);
+ Instant sunset = this.strategy.sunset(date);
+ assertNotNull(sunrise);
+ assertNotNull(sunset);
+
+ assertEquals(0L, this.strategy.ticksAt(sunrise, BERLIN), "sunrise must be tick 0 on " + date);
+ assertEquals(DUSK_TICK, this.strategy.ticksAt(sunset, BERLIN), "sunset must be tick 12000 on " + date);
+ assertEquals(TICKS_PER_DAY - 1L, this.strategy.ticksAt(sunrise.minusMillis(1), BERLIN), "the millisecond before sunrise must still be the last tick of the night on " + date);
+ }
+
+ @Test
+ @DisplayName("The longest and the shortest day of the year come out that way")
+ void theSolsticesAreTheLongestAndShortestDay() {
+ Duration june = daylight(LocalDate.of(2026, 6, 21));
+ Duration december = daylight(LocalDate.of(2026, 12, 21));
+ Duration equinox = daylight(LocalDate.of(2026, 3, 20));
+
+ assertTrue(june.toMinutes() > 16 * 60, "Berlin sees over 16 hours of daylight in June, got " + june);
+ assertTrue(december.toMinutes() < 8 * 60, "Berlin sees under 8 hours of daylight in December, got " + december);
+ assertTrue(Math.abs(equinox.toMinutes() - 12 * 60) < 20, "an equinox is within twenty minutes of twelve hours, got " + equinox);
+ }
+
+ @Test
+ @DisplayName("Half the tick range is day and half is night, whatever the season")
+ void bothHalvesAlwaysGetHalfTheTickRange() {
+ for (LocalDate date : new LocalDate[]{LocalDate.of(2026, 6, 21), LocalDate.of(2026, 12, 21)}) {
+ Instant sunrise = this.strategy.sunrise(date);
+ Instant sunset = this.strategy.sunset(date);
+ assertNotNull(sunrise);
+ assertNotNull(sunset);
+
+ Instant middleOfDay = sunrise.plus(Duration.between(sunrise, sunset).dividedBy(2));
+ long ticks = this.strategy.ticksAt(middleOfDay, BERLIN);
+
+ assertTrue(Math.abs(ticks - 6000L) <= 1, "the middle of the daylight span must be Minecraft noon on " + date + ", got " + ticks);
+ }
+ }
+
+ @Test
+ @DisplayName("The mapping never runs backwards, not even across a daylight saving transition")
+ void theMappingIsStrictlyIncreasingInRealTime() {
+ // Both transitions plus the day in between, sampled every five minutes.
+ Instant cursor = LocalDateTime.of(2026, 10, 24, 0, 0).atZone(BERLIN).toInstant();
+ Instant end = LocalDateTime.of(2026, 10, 26, 0, 0).atZone(BERLIN).toInstant();
+ long previous = this.strategy.ticksAt(cursor, BERLIN);
+ int wraps = 0;
+
+ while (cursor.isBefore(end)) {
+ cursor = cursor.plus(Duration.ofMinutes(5));
+ long ticks = this.strategy.ticksAt(cursor, BERLIN);
+ if (ticks < previous) {
+ wraps++;
+ }
+ previous = ticks;
+ }
+
+ assertEquals(2, wraps, "over two days the mapping passes the end of the day exactly twice");
+ }
+
+ @Test
+ @DisplayName("Above the polar circle the mapping falls back to the linear one instead of guessing")
+ void thePolarFallbackIsTheLinearMapping() {
+ // Longyearbyen: the sun neither rises nor sets around the June solstice.
+ SolarDayTimeStrategy svalbard = SolarDayTimeStrategy.at(78.22, 15.65);
+ Instant midsummer = Instant.parse("2026-06-21T12:00:00Z");
+
+ assertEquals(DayTimeStrategy.linear().ticksAt(midsummer, BERLIN), svalbard.ticksAt(midsummer, BERLIN));
+ }
+
+ @Test
+ @DisplayName("A position off the globe is rejected at construction, not silently accepted")
+ void impossiblePositionsAreRejected() {
+ assertThrows(IllegalArgumentException.class, () -> SolarDayTimeStrategy.at(91.0, 0.0));
+ assertThrows(IllegalArgumentException.class, () -> SolarDayTimeStrategy.at(0.0, 181.0));
+ }
+
+ @Test
+ @DisplayName("The Berlin strategy is stateless and shared")
+ void theBerlinStrategyIsShared() {
+ assertSame(SolarDayTimeStrategy.berlin(), DayTimeStrategy.solar());
+ }
+
+ private Duration daylight(LocalDate date) {
+ Instant sunrise = this.strategy.sunrise(date);
+ Instant sunset = this.strategy.sunset(date);
+ assertNotNull(sunrise);
+ assertNotNull(sunset);
+ return Duration.between(sunrise, sunset);
+ }
+
+ private static void assertWithin(Instant expected, Instant actual, Duration tolerance, String what) {
+ Duration off = Duration.between(expected, actual).abs();
+ assertTrue(off.compareTo(tolerance) <= 0, what + ": expected around " + expected + " but was " + actual + " (" + off.toSeconds() + "s off, tolerance " + tolerance.toSeconds() + "s)");
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/TimeConfigProviderTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/TimeConfigProviderTest.java
new file mode 100644
index 0000000..655ec9f
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/time/TimeConfigProviderTest.java
@@ -0,0 +1,237 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see Every test here goes through the two {@code create…} methods the application itself calls, and
+ * then asks the resulting service a question whose answer differs per strategy. Asserting the
+ * parsed string, or the class of {@code service.strategy()}, would pass just as happily if nobody
+ * ever wired the provider into the lobby; a season that comes back as winter in March only comes
+ * back that way if the astronomical boundaries really ran.
+ *
+ * Two dates carry that weight:
+ *
+ * Minestom's {@code net.minestom.server.instance.Clock} is a sealed interface, so a test double
+ * for it cannot exist — which is fortunate, because the claim worth checking is that the instance's
+ * own day cycle actually stops (US-2.02), and only a real instance can answer that.
+ *
+ * Real time still never passes here: the service reads a {@link Clock} this test moves by hand.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+@ExtendWith(MicrotusExtension.class)
+class WorldTimeServiceIntegrationTest {
+
+ private static final Instant NOON = Instant.parse("2026-05-15T10:00:00Z");
+
+ @Test
+ @DisplayName("Binding stops Minestom's own day cycle and writes the current time")
+ void bindingStopsTheOwnCycleAndWritesTheTime(Env env) {
+ Instance instance = env.createFlatInstance();
+ instance.setTime(123L);
+ WorldTimeService service = WorldTimeService.create(Clock.fixed(NOON, BERLIN));
+
+ service.bind(instance);
+
+ assertNotNull(instance.defaultClock());
+ assertEquals(0.0f, instance.defaultClock().rate(), "Minestom must not advance the clock itself once the service drives it");
+ assertEquals(DayTimeStrategy.NOON_TICK, instance.getTime());
+ assertSame(instance, service.boundInstance());
+ }
+
+ @Test
+ @DisplayName("The frozen instance does not advance its time on its own, however long it ticks")
+ void theFrozenInstanceDoesNotAdvanceOnItsOwn(Env env) {
+ Instance instance = env.createFlatInstance();
+ WorldTimeService service = WorldTimeService.create(Clock.fixed(NOON, BERLIN));
+ service.bind(instance);
+
+ for (int tick = 0; tick < 40; tick++) {
+ env.tick();
+ }
+
+ assertEquals(DayTimeStrategy.NOON_TICK, instance.getTime(), "with a stopped rate and a fixed clock the day time must not move at all");
+ }
+
+ @Test
+ @DisplayName("The time is written at most once per second, however often update is called")
+ void theTimeIsWrittenAtMostOncePerSecond(Env env) {
+ AtomicReference The half of the service that talks to Minestom lives in
+ * {@link WorldTimeServiceIntegrationTest}, on a real instance rather than a stand-in — Minestom's
+ * {@code Clock} is a sealed interface and cannot be faked, so the only honest way to assert that
+ * the
+ * instance's own cycle really stops is to stop a real one.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+class WorldTimeServiceTest {
+
+ private static final Instant NOON = Instant.parse("2026-05-15T10:00:00Z");
+
+ @Test
+ @DisplayName("The current time comes from the injected clock, not from the machine")
+ void theCurrentTimeComesFromTheInjectedClock() {
+ WorldTimeService service = WorldTimeService.create(Clock.fixed(NOON, BERLIN));
+
+ assertEquals(DayTimeStrategy.NOON_TICK, service.currentTicks());
+ assertEquals(BERLIN, service.zone());
+ assertSame(DayTimeStrategy.linear(), service.strategy());
+ }
+
+ @Test
+ @DisplayName("The default is the linear mapping in the editorial zone")
+ void theDefaultIsLinearInTheEditorialZone() {
+ WorldTimeService service = WorldTimeService.create(Clock.systemUTC());
+
+ assertSame(DayTimeStrategy.linear(), service.strategy());
+ assertEquals(TitanTime.EDITORIAL_ZONE, service.zone());
+ }
+
+ @Test
+ @DisplayName("The mapping can be swapped without the caller changing")
+ void theMappingCanBeSwapped() {
+ WorldTimeService linear = WorldTimeService.create(Clock.fixed(NOON, BERLIN), DayTimeStrategy.linear());
+ WorldTimeService solar = WorldTimeService.create(Clock.fixed(NOON, BERLIN), DayTimeStrategy.solar());
+
+ assertSame(DayTimeStrategy.linear(), linear.strategy());
+ assertSame(DayTimeStrategy.solar(), solar.strategy());
+ assertEquals(DayTimeStrategy.linear().ticksAt(NOON, BERLIN), linear.currentTicks());
+ assertEquals(DayTimeStrategy.solar().ticksAt(NOON, BERLIN), solar.currentTicks());
+ }
+
+ @Test
+ @DisplayName("A different zone produces a different time from the same instant")
+ void theZoneIsHonoured() {
+ WorldTimeService berlin = WorldTimeService.create(Clock.fixed(NOON, BERLIN), BERLIN, DayTimeStrategy.linear());
+ WorldTimeService utc = WorldTimeService.create(Clock.fixed(NOON, ZoneOffset.UTC), ZoneOffset.UTC, DayTimeStrategy.linear());
+
+ assertEquals(6000L, berlin.currentTicks());
+ assertEquals(4000L, utc.currentTicks());
+ }
+
+ @Test
+ @DisplayName("The clock's own zone is ignored; the service uses the zone it was given")
+ void theClockZoneIsIrrelevant() {
+ WorldTimeService fromUtcClock = WorldTimeService.create(Clock.fixed(NOON, ZoneOffset.UTC), BERLIN, DayTimeStrategy.linear());
+
+ assertEquals(6000L, fromUtcClock.currentTicks());
+ }
+
+ @Test
+ @DisplayName("Without a bound instance an update does nothing")
+ void updateWithoutBindingDoesNothing() {
+ WorldTimeService service = WorldTimeService.create(Clock.fixed(NOON, BERLIN));
+
+ assertFalse(service.update());
+ assertNull(service.boundInstance());
+ }
+
+ @Test
+ @DisplayName("Unbinding without ever binding is harmless")
+ void unbindingWithoutBindingIsHarmless() {
+ WorldTimeService service = WorldTimeService.create(Clock.fixed(NOON, BERLIN));
+
+ service.unbind();
+
+ assertNull(service.boundInstance());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategyTest.java
new file mode 100644
index 0000000..ac2b94f
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategyTest.java
@@ -0,0 +1,130 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see The tolerance is three minutes: wide enough for the algorithm's own error and for a reference
+ * that is quoted to the minute, narrow enough that a wrong periodic term or a missing ΔT
+ * correction shows up rather than hiding inside it.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+class AstronomicalSeasonStrategyTest {
+
+ private static final Duration TOLERANCE = Duration.ofMinutes(3);
+
+ private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
+
+ private final SeasonBoundaryStrategy strategy = SeasonBoundaryStrategy.astronomical(BERLIN);
+
+ @ParameterizedTest(name = "{0} {1} is {2}")
+ @CsvSource({
+ // Published times, in UTC.
+ "2024, MARCH_EQUINOX, 2024-03-20T03:06:00Z", "2024, JUNE_SOLSTICE, 2024-06-20T20:51:00Z", "2024, SEPTEMBER_EQUINOX, 2024-09-22T12:44:00Z", "2024, DECEMBER_SOLSTICE, 2024-12-21T09:21:00Z", "2025, MARCH_EQUINOX, 2025-03-20T09:01:00Z", "2025, JUNE_SOLSTICE, 2025-06-21T02:42:00Z", "2025, SEPTEMBER_EQUINOX, 2025-09-22T18:19:00Z", "2025, DECEMBER_SOLSTICE, 2025-12-21T15:03:00Z", "2026, MARCH_EQUINOX, 2026-03-20T14:46:00Z", "2026, JUNE_SOLSTICE, 2026-06-21T08:25:00Z", "2026, SEPTEMBER_EQUINOX, 2026-09-23T00:05:00Z", "2026, DECEMBER_SOLSTICE, 2026-12-21T20:50:00Z",
+ })
+ @DisplayName("The event instants match the published equinox and solstice times")
+ void theEventInstantsMatchPublishedTimes(int year, Event event, Instant expected) {
+ Instant actual = AstronomicalSeasonStrategy.eventInstant(year, event);
+
+ Duration off = Duration.between(expected, actual).abs();
+ assertTrue(off.compareTo(TOLERANCE) <= 0, year + " " + event + ": expected around " + expected + " but was " + actual + " (" + off.toSeconds() + "s off)");
+ }
+
+ @ParameterizedTest(name = "{0} is {1}")
+ @CsvSource({"2026-01-10, WINTER", "2026-03-19, WINTER", "2026-03-20, SPRING", "2026-06-20, SPRING", "2026-06-21, SUMMER", "2026-09-22, SUMMER", "2026-09-23, AUTUMN", "2026-12-20, AUTUMN", "2026-12-21, WINTER", "2026-12-31, WINTER",
+ })
+ @DisplayName("A season starts on the calendar day of its event, read in the configured zone")
+ void aSeasonStartsOnTheDayOfItsEvent(LocalDate date, Season expected) {
+ assertEquals(expected, this.strategy.seasonAt(date));
+ }
+
+ @Test
+ @DisplayName("The events move between years, which is why they cannot be hard-coded dates")
+ void theEventsMoveBetweenYears() {
+ AstronomicalSeasonStrategy berlin = AstronomicalSeasonStrategy.of(BERLIN);
+
+ // The December solstice falls on the 21st in 2026 and on the 22nd in 2027.
+ assertEquals(LocalDate.of(2026, 12, 21), berlin.eventDate(2026, Event.DECEMBER_SOLSTICE));
+ assertEquals(LocalDate.of(2027, 12, 22), berlin.eventDate(2027, Event.DECEMBER_SOLSTICE));
+ // And the June solstice moves from the 21st to the 20th between 2026 and 2028.
+ assertEquals(LocalDate.of(2026, 6, 21), berlin.eventDate(2026, Event.JUNE_SOLSTICE));
+ assertEquals(LocalDate.of(2028, 6, 20), berlin.eventDate(2028, Event.JUNE_SOLSTICE));
+
+ assertEquals(Season.AUTUMN, this.strategy.seasonAt(LocalDate.of(2027, 12, 21)));
+ assertEquals(Season.WINTER, this.strategy.seasonAt(LocalDate.of(2027, 12, 22)));
+ }
+
+ @Test
+ @DisplayName("The zone decides which calendar day an event lands on")
+ void theZoneDecidesTheCalendarDay() {
+ // The 2026 September equinox is 00:05 UTC on the 23rd, which is 02:05 on the 23rd in Berlin
+ // but still the 22nd in New York.
+ AstronomicalSeasonStrategy newYork = AstronomicalSeasonStrategy.of(ZoneId.of("America/New_York"));
+
+ assertEquals(LocalDate.of(2026, 9, 23), AstronomicalSeasonStrategy.of(BERLIN).eventDate(2026, Event.SEPTEMBER_EQUINOX));
+ assertEquals(LocalDate.of(2026, 9, 22), newYork.eventDate(2026, Event.SEPTEMBER_EQUINOX));
+ }
+
+ @Test
+ @DisplayName("The astronomical boundaries lag the meteorological ones by about three weeks")
+ void theBoundariesLagTheMeteorologicalOnes() {
+ SeasonBoundaryStrategy meteorological = SeasonBoundaryStrategy.meteorological();
+ LocalDate midMarch = LocalDate.of(2026, 3, 10);
+
+ assertEquals(Season.SPRING, meteorological.seasonAt(midMarch));
+ assertEquals(Season.WINTER, this.strategy.seasonAt(midMarch));
+ }
+
+ @Test
+ @DisplayName("Every day of a year gets a season and the year runs through all four in order")
+ void everyDayOfTheYearGetsASeason() {
+ LocalDate cursor = LocalDate.of(2026, 1, 1);
+ Season previous = this.strategy.seasonAt(cursor);
+ int changes = 0;
+
+ while (cursor.getYear() == 2026) {
+ Season season = this.strategy.seasonAt(cursor);
+ if (season != previous) {
+ assertSame(previous.next(), season, "seasons must follow each other in calendar order on " + cursor);
+ changes++;
+ }
+ previous = season;
+ cursor = cursor.plusDays(1);
+ }
+
+ assertEquals(4, changes, "a calendar year that starts and ends in winter switches four times");
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategyTest.java
new file mode 100644
index 0000000..84f7ad6
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategyTest.java
@@ -0,0 +1,64 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see The calculation and its limits
+ *
+ * The instants come from Meeus, Astronomical Algorithms, chapter 27: a polynomial for the
+ * mean event plus the published table of 24 periodic terms. Checked against the 2025 and 2026
+ * events
+ * the result is within about twenty seconds. ΔT is subtracted with the Espenak/Meeus
+ * polynomial
+ * for 2005…2050, which turns the dynamical time the algorithm produces into UT.
+ *
+ *
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class AstronomicalSeasonStrategy implements SeasonBoundaryStrategy {
+
+ /**
+ * The periodic terms of Meeus chapter 27, as {@code {A, B, C}} triples.
+ *
+ * Where the expected times come from
+ *
+ * Every expected time below is the value the US Naval Observatory publishes for 52.520008° N,
+ * 13.404954° E — rise, set and upper transit — converted from UT to Berlin local time. One source,
+ * quoted rather than fitted: an expectation copied off this implementation's own output would make
+ * the test agree with whatever the implementation does, which is the one thing it must not do.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+class TimeConfigProviderTest {
+
+ /** Meteorologically spring, astronomically still winter. */
+ private static final LocalDate BEFORE_THE_MARCH_EQUINOX = LocalDate.of(2026, 3, 5);
+
+ /** Summer under both sets of boundaries, so a fixed winter can only come from the pin. */
+ private static final LocalDate MIDSUMMER = LocalDate.of(2026, 7, 15);
+
+ /** Before sunrise in Berlin on the shortest day, but well after the linear daybreak. */
+ private static final Instant DECEMBER_MORNING = LocalDateTime.of(FixedInstants.DECEMBER_SOLSTICE, LocalTime.of(7, 30)).atZone(BERLIN).toInstant();
+
+ /** What {@link LinearDayTimeStrategy} makes of 07:30: 90 minutes past daybreak. */
+ private static final long LINEAR_TICKS_AT_HALF_PAST_SEVEN = 1_500L;
+
+ @Test
+ @DisplayName("Without a file the lobby maps time linearly and reads meteorological seasons")
+ void defaultsToLinearAndMeteorological(@TempDir Path directory) {
+ TimeConfigProvider provider = TimeConfigProvider.create(directory, BERLIN);
+
+ assertEquals(LINEAR_TICKS_AT_HALF_PAST_SEVEN, provider.createWorldTimeService(at(DECEMBER_MORNING)).currentTicks(), "the linear mapping is already in its day at 07:30");
+ assertEquals(Season.SPRING, provider.createSeasonService(at(BEFORE_THE_MARCH_EQUINOX)).currentSeason(), "5 March is meteorological spring");
+ }
+
+ @Test
+ @DisplayName("Without a file the defaults are written out, keys and all")
+ void writesTheDefaultFile(@TempDir Path directory) throws IOException {
+ TimeConfigProvider.create(directory, BERLIN);
+
+ Path file = directory.resolve(TimeConfig.TIME_FILE_NAME);
+ assertTrue(Files.exists(file), "the default file is written so an operator can see the keys");
+ String written = Files.readString(file);
+ assertTrue(written.contains(TimeConfig.LINEAR), () -> "expected the linear default in " + written);
+ assertTrue(written.contains(TimeConfig.METEOROLOGICAL), () -> "expected the meteorological default in " + written);
+ }
+
+ @Test
+ @DisplayName("An empty object is an absent choice and keeps both defaults")
+ void emptyObjectKeepsTheDefaults(@TempDir Path directory) throws IOException {
+ write(directory, "{}");
+
+ TimeConfigProvider provider = TimeConfigProvider.create(directory, BERLIN);
+
+ assertEquals(LINEAR_TICKS_AT_HALF_PAST_SEVEN, provider.createWorldTimeService(at(DECEMBER_MORNING)).currentTicks());
+ assertEquals(Season.SPRING, provider.createSeasonService(at(BEFORE_THE_MARCH_EQUINOX)).currentSeason());
+ }
+
+ @Test
+ @DisplayName("With the solar mapping configured the December morning is still night (US-2.07)")
+ void solarMappingDrivesTheDayTime(@TempDir Path directory) throws IOException {
+ write(directory, "{\"dayTimeStrategy\": \"solar\"}");
+
+ long ticks = TimeConfigProvider.create(directory, BERLIN).createWorldTimeService(at(DECEMBER_MORNING)).currentTicks();
+
+ assertTrue(ticks >= DayTimeStrategy.DUSK_TICK, () -> "the sun has not risen in Berlin at 07:30 on 21 December, but the world says tick " + ticks);
+ // The same instant under the default, so the difference is the configuration and not the date.
+ assertEquals(LINEAR_TICKS_AT_HALF_PAST_SEVEN, TimeConfigProvider.create(Files.createTempDirectory(directory, "linear"), BERLIN).createWorldTimeService(at(DECEMBER_MORNING)).currentTicks());
+ }
+
+ @Test
+ @DisplayName("With the astronomical boundaries configured 5 March is still winter (US-2.12)")
+ void astronomicalBoundariesDriveTheSeason(@TempDir Path directory) throws IOException {
+ write(directory, "{\"seasonStrategy\": \"astronomical\"}");
+
+ Season season = TimeConfigProvider.create(directory, BERLIN).createSeasonService(at(BEFORE_THE_MARCH_EQUINOX)).currentSeason();
+
+ assertEquals(Season.WINTER, season, "the March equinox is on the 20th, so the 5th is still winter");
+ // The same date under the default, so the difference is the configuration and not the date.
+ assertEquals(Season.SPRING, TimeConfigProvider.create(Files.createTempDirectory(directory, "meteorological"), BERLIN).createSeasonService(at(BEFORE_THE_MARCH_EQUINOX)).currentSeason());
+ }
+
+ @Test
+ @DisplayName("With a season pinned the lobby answers winter in July (US-2.13)")
+ void fixedSeasonIgnoresTheCalendar(@TempDir Path directory) throws IOException {
+ write(directory, "{\"seasonStrategy\": \"fixed\", \"fixedSeason\": \"winter\"}");
+
+ Season season = TimeConfigProvider.create(directory, BERLIN).createSeasonService(at(MIDSUMMER)).currentSeason();
+
+ assertEquals(Season.WINTER, season, "a pinned season does not consult the date");
+ // Both boundary rules would say summer here, so the pin is the only thing that can answer winter.
+ assertEquals(Season.SUMMER, TimeConfigProvider.create(Files.createTempDirectory(directory, "meteorological"), BERLIN).createSeasonService(at(MIDSUMMER)).currentSeason());
+ }
+
+ @Test
+ @DisplayName("A pinned season survives a strategy switch and is ignored while it is not asked for")
+ void fixedSeasonIsIgnoredByTheOtherStrategies(@TempDir Path directory) throws IOException {
+ write(directory, "{\"seasonStrategy\": \"meteorological\", \"fixedSeason\": \"winter\"}");
+
+ assertEquals(Season.SUMMER, TimeConfigProvider.create(directory, BERLIN).createSeasonService(at(MIDSUMMER)).currentSeason());
+ }
+
+ @Test
+ @DisplayName("Strategy names are read case- and space-insensitively")
+ void namesAreNormalized(@TempDir Path directory) throws IOException {
+ write(directory, "{\"dayTimeStrategy\": \" Solar \", \"seasonStrategy\": \"FIXED\", \"fixedSeason\": \"Winter\"}");
+
+ TimeConfigProvider provider = TimeConfigProvider.create(directory, BERLIN);
+
+ assertTrue(provider.createWorldTimeService(at(DECEMBER_MORNING)).currentTicks() >= DayTimeStrategy.DUSK_TICK);
+ assertEquals(Season.WINTER, provider.createSeasonService(at(MIDSUMMER)).currentSeason());
+ }
+
+ @Test
+ @DisplayName("An unknown day time strategy stops the boot and names the value and the valid ones")
+ void unknownDayTimeStrategyFails(@TempDir Path directory) throws IOException {
+ write(directory, "{\"dayTimeStrategy\": \"astronomical\"}");
+
+ TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN));
+
+ assertMentions(exception, "dayTimeStrategy", "astronomical", TimeConfig.LINEAR, TimeConfig.SOLAR);
+ }
+
+ @Test
+ @DisplayName("An unknown season strategy stops the boot and names the value and the valid ones")
+ void unknownSeasonStrategyFails(@TempDir Path directory) throws IOException {
+ write(directory, "{\"seasonStrategy\": \"solar\"}");
+
+ TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN));
+
+ assertMentions(exception, "seasonStrategy", "solar", TimeConfig.METEOROLOGICAL, TimeConfig.ASTRONOMICAL, TimeConfig.FIXED);
+ }
+
+ @Test
+ @DisplayName("An unknown season name stops the boot and names the value and the four seasons")
+ void unknownFixedSeasonFails(@TempDir Path directory) throws IOException {
+ write(directory, "{\"seasonStrategy\": \"fixed\", \"fixedSeason\": \"wintre\"}");
+
+ TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN));
+
+ assertMentions(exception, "fixedSeason", "wintre", "spring", "summer", "autumn", "winter");
+ }
+
+ @Test
+ @DisplayName("A pinned season strategy without a season stops the boot")
+ void fixedWithoutSeasonFails(@TempDir Path directory) throws IOException {
+ write(directory, "{\"seasonStrategy\": \"fixed\"}");
+
+ TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN));
+
+ assertMentions(exception, "fixedSeason", TimeConfig.FIXED, "spring", "summer", "autumn", "winter");
+ }
+
+ @Test
+ @DisplayName("A blank value is a mistake, not an absent key")
+ void blankValueFails(@TempDir Path directory) throws IOException {
+ write(directory, "{\"seasonStrategy\": \" \"}");
+
+ assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN));
+ }
+
+ @Test
+ @DisplayName("A file that is not JSON stops the boot instead of running an unchosen strategy")
+ void malformedFileFails(@TempDir Path directory) throws IOException {
+ write(directory, "{\"seasonStrategy\": ");
+
+ TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN));
+
+ assertMentions(exception, TimeConfig.TIME_FILE_NAME);
+ }
+
+ private static void assertMentions(TimeConfigException exception, String... expected) {
+ String message = exception.getMessage();
+ for (String fragment : expected) {
+ assertTrue(message.contains(fragment), () -> "expected \"" + fragment + "\" in: " + message);
+ }
+ }
+
+ private static void write(Path directory, String json) throws IOException {
+ Files.writeString(directory.resolve(TimeConfig.TIME_FILE_NAME), json);
+ }
+
+ private static Clock at(Instant instant) {
+ return Clock.fixed(instant, BERLIN);
+ }
+
+ private static Clock at(LocalDate date) {
+ return at(date.atTime(LocalTime.NOON).atZone(BERLIN).toInstant());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceIntegrationTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceIntegrationTest.java
new file mode 100644
index 0000000..e7f05e7
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceIntegrationTest.java
@@ -0,0 +1,205 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see