From 39213dd4591a1b416884056ece5a1a7a885b2faf Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:31:55 +0200 Subject: [PATCH 1/9] feat(time): map real time onto Minecraft day time as a strategy Introduces DayTimeStrategy with the two implementations the spec asks for (US-2.05 to US-2.07): - LinearDayTimeStrategy spreads 24 real hours evenly over 24000 ticks so that 12:00 local is noon. It is the default because it delivers almost the whole benefit and has no astronomical calculation that could be quietly wrong. - SolarDayTimeStrategy lays the real sunrise and sunset for a position on the Minecraft day, anchoring sunrise at tick 0 and sunset at tick 12000. Sunrise and sunset come from the low-precision sunrise equation; the test holds them against the published Berlin times for the 2026 solstices and equinoxes. Its limits (mean refraction only, no delta T, polar fallback to the linear mapping) are named in the class javadoc rather than left for someone to discover. Both are stateless and pure: they are handed the instant and the zone and never read a clock, which is what makes the comparison in US-2.08 possible at all. TitanTime holds the one editorial zone (Europe/Berlin) and the update interval, so a redeployment to a differently configured host cannot move the lobby's calendar. --- common/build.gradle.kts | 1 + .../titan/common/time/DayTimeStrategy.java | 80 ++++++ .../common/time/LinearDayTimeStrategy.java | 81 ++++++ .../common/time/SolarDayTimeStrategy.java | 265 ++++++++++++++++++ .../titan/common/time/TitanTime.java | 75 +++++ .../titan/common/time/package-info.java | 13 + .../titan/common/time/FixedInstants.java | 142 ++++++++++ .../time/LinearDayTimeStrategyTest.java | 103 +++++++ .../common/time/SolarDayTimeStrategyTest.java | 176 ++++++++++++ 9 files changed, 936 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/DayTimeStrategy.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategy.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/TitanTime.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/package-info.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/FixedInstants.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategyTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategyTest.java diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 4b836698..0274b1e9 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 00000000..d42c9d11 --- /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 . + */ +package net.onelitefeather.titan.common.time; + +import org.jetbrains.annotations.Contract; + +import java.time.Instant; +import java.time.ZoneId; + +/** + * Maps an instant of the real world onto the day time of a Minecraft world. + * + *

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 00000000..d94864db --- /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 . + */ +package net.onelitefeather.titan.common.time; + +import org.jetbrains.annotations.Contract; + +import java.time.Instant; +import java.time.ZoneId; + +/** + * Spreads 24 real hours evenly over the {@value DayTimeStrategy#TICKS_PER_DAY} ticks of a Minecraft + * day, so that {@code 12:00} local time is noon in the world. + * + *

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/SolarDayTimeStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java new file mode 100644 index 00000000..14c68f97 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java @@ -0,0 +1,265 @@ +/** + * 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 . + */ +package net.onelitefeather.titan.common.time; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * Lays the real sunrise and sunset of a geographic position onto the Minecraft day, so that the + * lobby gets light late in December and stays bright until well past nine in June (US-2.07). + * + *

The mapping

+ * + * Real sunrise becomes tick {@code 0}, real sunset becomes tick {@value DayTimeStrategy#DUSK_TICK}, + * and the night in between two days is stretched over the remaining half of the tick range. Both + * halves are linear in themselves, so the mapping is continuous in real time and strictly + * increasing — unlike {@link LinearDayTimeStrategy} it does not follow the wall clock and therefore + * neither skips nor repeats an hour when daylight saving changes. + * + *

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. + * + *

The calculation and its limits

+ * + * Sunrise and sunset come from the low-precision sunrise equation in its published closed form (the + * form the NOAA solar calculator is derived from). Checked against the Berlin values for the 2026 + * solstices and equinoxes it agrees to within about one minute; the equation's own stated bound is + * a + * few minutes. One Minecraft tick is worth 3.6 real seconds in a 24-hour cycle, so the error is + * visible in principle — this is a lighting effect, not an ephemeris. + * + *

Known limits, stated rather than hidden: + * + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class SolarDayTimeStrategy implements DayTimeStrategy { + + /** Latitude of Berlin in degrees, north positive. */ + public static final double BERLIN_LATITUDE = 52.520008; + + /** Longitude of Berlin in degrees, east positive. */ + public static final double BERLIN_LONGITUDE = 13.404954; + + private static final SolarDayTimeStrategy BERLIN = new SolarDayTimeStrategy(BERLIN_LATITUDE, BERLIN_LONGITUDE); + + /** Epoch day of 2000-01-01, the date J2000.0 falls on. */ + private static final double J2000_EPOCH_DAY = 10_957.0; + + /** 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; + + /** Leap second and clock correction of the sunrise equation, in days. */ + private static final double MEAN_SOLAR_TIME_CORRECTION = 0.0009; + + /** Obliquity of the ecliptic in degrees. */ + private static final double EARTH_OBLIQUITY = 23.4397; + + /** Argument of perihelion of the Earth in degrees. */ + private static final double PERIHELION_ARGUMENT = 102.9372; + + /** + * Altitude of the centre of the solar disc when its upper limb touches the horizon, degrees. + */ + private static final double HORIZON_ALTITUDE = -0.833; + + private static final double MILLIS_PER_DAY = 86_400_000.0; + + private final double latitude; + private final double longitude; + + private SolarDayTimeStrategy(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + + /** + * Returns the strategy for Berlin, the position the lobby is written for. + * + * @return the shared solar strategy for Berlin + */ + @Contract(pure = true) + public static SolarDayTimeStrategy berlin() { + return BERLIN; + } + + /** + * Returns a strategy for an arbitrary position on Earth. + * + * @param latitude the latitude in degrees, north positive, within {@code [-90, 90]} + * @param longitude the longitude in degrees, east positive, within {@code [-180, 180]} + * @return a solar strategy for that position + * @throws IllegalArgumentException if either coordinate is outside its range + */ + @Contract(pure = true, value = "_, _ -> new") + public static SolarDayTimeStrategy at(double latitude, double longitude) { + if (!(latitude >= -90.0 && latitude <= 90.0)) { + throw new IllegalArgumentException("latitude out of range: " + latitude); + } + if (!(longitude >= -180.0 && longitude <= 180.0)) { + throw new IllegalArgumentException("longitude out of range: " + longitude); + } + return new SolarDayTimeStrategy(latitude, longitude); + } + + @Override + @Contract(pure = true) + public long ticksAt(Instant instant, ZoneId zone) { + LocalDate date = instant.atZone(zone).toLocalDate(); + SolarDay yesterday = solarDay(date.minusDays(1)); + SolarDay today = solarDay(date); + SolarDay tomorrow = solarDay(date.plusDays(1)); + if (yesterday == null || today == null || tomorrow == null) { + // No sunrise or no sunset in the surrounding days; see the class javadoc. + return LinearDayTimeStrategy.instance().ticksAt(instant, zone); + } + + // Alternating boundaries: sunrise, sunset, sunrise, ... An instant that falls into an + // even-indexed gap is between a sunrise and a sunset and therefore belongs to the day half. + Instant[] boundaries = {yesterday.sunrise(), yesterday.sunset(), today.sunrise(), today.sunset(), tomorrow.sunrise(), tomorrow.sunset(), + }; + for (int i = 0; i < boundaries.length - 1; i++) { + if (instant.isBefore(boundaries[i]) || !instant.isBefore(boundaries[i + 1])) { + continue; + } + return i % 2 == 0 ? dayTicks(boundaries[i], boundaries[i + 1], instant) : nightTicks(boundaries[i], boundaries[i + 1], instant); + } + // The instant lies outside the three computed days, which only a zone that disagrees wildly + // with the position can produce. Falling back keeps the result defined. + return LinearDayTimeStrategy.instance().ticksAt(instant, zone); + } + + /** + * Returns the sunrise of the given date at this position. + * + *

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. + double meanSolarTime = days + MEAN_SOLAR_TIME_CORRECTION + (-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/TitanTime.java b/common/src/main/java/net/onelitefeather/titan/common/time/TitanTime.java new file mode 100644 index 00000000..2fe4c609 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/TitanTime.java @@ -0,0 +1,75 @@ +/** + * 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 . + */ +package net.onelitefeather.titan.common.time; + +import net.minestom.server.MinecraftServer; + +import java.time.Duration; +import java.time.ZoneId; + +/** + * The constants that every part of the time handling agrees on. + * + *

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/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/time/package-info.java new file mode 100644 index 00000000..ca4758a2 --- /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/test/java/net/onelitefeather/titan/common/time/FixedInstants.java b/common/src/test/java/net/onelitefeather/titan/common/time/FixedInstants.java new file mode 100644 index 00000000..baac831f --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/FixedInstants.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 . + */ +package net.onelitefeather.titan.common.time; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; + +/** + * The one set of fixed instants every day-time strategy is measured against (US-2.08). + * + *

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 all() { + List samples = new ArrayList<>(); + for (LocalDate date : List.of( + MARCH_EQUINOX, JUNE_SOLSTICE, SEPTEMBER_EQUINOX, DECEMBER_SOLSTICE, ORDINARY_DAY)) { + samples.add(local(date, "midnight", 0, 0, Phase.NIGHT)); + samples.add(local(date, "morning", 6, 0, Phase.UNSPECIFIED)); + samples.add(local(date, "noon", 12, 0, Phase.DAY)); + samples.add(local(date, "evening", 18, 0, Phase.UNSPECIFIED)); + } + + // Daylight saving, spring: the local clock jumps from 01:59:59 CET to 03:00:00 CEST. + samples.add(utc(DST_SPRING_FORWARD, "before spring forward", 0, 59, Phase.UNSPECIFIED)); + samples.add(utc(DST_SPRING_FORWARD, "after spring forward", 1, 1, Phase.UNSPECIFIED)); + samples.add(utc(DST_SPRING_FORWARD, "noon after spring forward", 10, 0, Phase.DAY)); + + // Daylight saving, autumn: 02:30 local happens twice, first as CEST then as CET. + samples.add(utc(DST_FALL_BACK, "first pass of the repeated hour", 0, 30, Phase.NIGHT)); + samples.add(utc(DST_FALL_BACK, "second pass of the repeated hour", 1, 30, Phase.NIGHT)); + samples.add(utc(DST_FALL_BACK, "noon after fall back", 11, 0, Phase.DAY)); + + return List.copyOf(samples); + } + + private static Sample local(LocalDate date, String label, int hour, int minute, Phase phase) { + Instant instant = LocalDateTime.of(date, java.time.LocalTime.of(hour, minute)).atZone(BERLIN).toInstant(); + return new Sample(date + " " + label, instant, phase); + } + + private static Sample utc(LocalDate date, String label, int hour, int minute, Phase phase) { + Instant instant = LocalDateTime.of(date, java.time.LocalTime.of(hour, minute)).toInstant(ZoneOffset.UTC); + return new Sample(date + " " + label, instant, phase); + } + + /** + * One point in the shared sample set. + * + * @param label what the point is, for the test report + * @param instant the instant itself + * @param phase what every strategy has to agree on at that instant + */ + record Sample(String label, Instant instant, Phase phase) { + + @Override + public String toString() { + return this.label; + } + } + + /** + * What both strategies must agree on at a sample point. + * + *

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 00000000..554f048e --- /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 . + */ +package net.onelitefeather.titan.common.time; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneOffset; + +import static net.onelitefeather.titan.common.time.FixedInstants.BERLIN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * The linear mapping's own behaviour: the wall clock, and nothing but the wall clock. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +class LinearDayTimeStrategyTest { + + private final DayTimeStrategy strategy = DayTimeStrategy.linear(); + + @ParameterizedTest(name = "{0}:{1} local is tick {2}") + @CsvSource({"6, 0, 0", "12, 0, 6000", "18, 0, 12000", "0, 0, 18000", "9, 0, 3000", "6, 1, 16", + }) + @DisplayName("Local wall clock maps evenly onto the Minecraft day, noon at noon") + void wallClockMapsEvenlyOntoTheDay(int hour, int minute, long expected) { + Instant instant = LocalDateTime.of(LocalDate.of(2026, 5, 15), LocalTime.of(hour, minute)).atZone(BERLIN).toInstant(); + + assertEquals(expected, this.strategy.ticksAt(instant, BERLIN)); + } + + @Test + @DisplayName("Noon is noon in winter and in summer, so daylight saving cannot shift the lobby") + void noonIsNoonOnBothSidesOfDaylightSaving() { + Instant winterNoon = LocalDateTime.of(2026, 1, 15, 12, 0).atZone(BERLIN).toInstant(); + Instant summerNoon = LocalDateTime.of(2026, 7, 15, 12, 0).atZone(BERLIN).toInstant(); + + assertEquals(DayTimeStrategy.NOON_TICK, this.strategy.ticksAt(winterNoon, BERLIN)); + assertEquals(DayTimeStrategy.NOON_TICK, this.strategy.ticksAt(summerNoon, BERLIN)); + // The two instants are one UTC hour apart; only the zone makes them the same game time. + assertNotEquals(this.strategy.ticksAt(winterNoon, ZoneOffset.UTC), this.strategy.ticksAt(summerNoon, ZoneOffset.UTC)); + } + + @Test + @DisplayName("The skipped hour in March is skipped in the lobby as well") + void theSpringForwardSkipsAnHourOfGameTime() { + // 01:59 CET, then two real minutes later 03:01 CEST. + long before = this.strategy.ticksAt(Instant.parse("2026-03-29T00:59:00Z"), BERLIN); + long after = this.strategy.ticksAt(Instant.parse("2026-03-29T01:01:00Z"), BERLIN); + + assertEquals(19983, before); + assertEquals(21016, after); + } + + @Test + @DisplayName("The repeated hour in October is repeated in the lobby as well") + void theFallBackRepeatsAnHourOfGameTime() { + // 02:30 CEST and, an hour of real time later, 02:30 CET. + long firstPass = this.strategy.ticksAt(Instant.parse("2026-10-25T00:30:00Z"), BERLIN); + long secondPass = this.strategy.ticksAt(Instant.parse("2026-10-25T01:30:00Z"), BERLIN); + + assertEquals(firstPass, secondPass); + } + + @Test + @DisplayName("The zone handed in decides the result") + void theZoneDecides() { + Instant instant = Instant.parse("2026-05-15T10:00:00Z"); + + assertEquals(DayTimeStrategy.NOON_TICK, this.strategy.ticksAt(instant, BERLIN)); + assertEquals(4000, this.strategy.ticksAt(instant, ZoneOffset.UTC)); + } + + @Test + @DisplayName("The strategy is stateless and shared") + void theStrategyIsShared() { + assertSame(LinearDayTimeStrategy.instance(), DayTimeStrategy.linear()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategyTest.java new file mode 100644 index 00000000..8ba0f79c --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategyTest.java @@ -0,0 +1,176 @@ +/** + * 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 . + */ +package net.onelitefeather.titan.common.time; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import static net.onelitefeather.titan.common.time.DayTimeStrategy.DUSK_TICK; +import static net.onelitefeather.titan.common.time.DayTimeStrategy.TICKS_PER_DAY; +import static net.onelitefeather.titan.common.time.FixedInstants.BERLIN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The solar mapping's own behaviour, and the evidence that its astronomy is right. + * + *

The sunrise and sunset assertions are held against the published Berlin times with a tolerance + * of five minutes. That is wide enough to absorb both the low-precision equation's own error and + * the + * fact that published tables round to the minute, and narrow enough that a sign error, a wrong + * longitude convention or a lost daylight saving offset — the mistakes that actually happen here — + * fail the test by an hour or more. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +class SolarDayTimeStrategyTest { + + private static final Duration TOLERANCE = Duration.ofMinutes(5); + + private final SolarDayTimeStrategy strategy = SolarDayTimeStrategy.berlin(); + + @ParameterizedTest(name = "{0}: sunrise {1}, sunset {2} Berlin local") + @CsvSource({ + // Published Berlin times; the two solstices and the two equinoxes of 2026. + "2026-03-20, 06:11, 18:19", "2026-06-21, 04:43, 21:33", "2026-09-23, 06:55, 19:06", "2026-12-21, 08:15, 15:53", + }) + @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, "sunrise on " + date); + assertWithin(expectedSunset, actualSunset, "sunset 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, 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)"); + } +} From 580f9bec8685351be47fff161d03eb117524f53b Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:32:04 +0200 Subject: [PATCH 2/9] feat(season): decide the season through an interchangeable boundary rule Introduces the Season enum and SeasonBoundaryStrategy with three implementations (US-2.09 to US-2.13): - MeteorologicalSeasonStrategy is the default: 1 March, 1 June, 1 September, 1 December. Fixed calendar days, nothing to compute, nothing to drift. - AstronomicalSeasonStrategy puts the boundaries on the equinoxes and solstices, computed with Meeus chapter 27 and corrected to UT with the Espenak/Meeus delta T polynomial. The test holds twelve events from 2024 to 2026 against the published times; it also pins the two cases that a hard-coded date would get wrong, the December solstice moving to the 22nd in 2027 and the June solstice to the 20th in 2028. - FixedSeasonStrategy always answers with one season. This is not only a test aid: it is the supported way to show a winter event in August without touching the system clock. Like the day-time strategies these are stateless and pure; the clock sits in the calling service. --- .../season/AstronomicalSeasonStrategy.java | 235 ++++++++++++++++++ .../time/season/FixedSeasonStrategy.java | 53 ++++ .../season/MeteorologicalSeasonStrategy.java | 74 ++++++ .../titan/common/time/season/Season.java | 69 +++++ .../time/season/SeasonBoundaryStrategy.java | 80 ++++++ .../common/time/season/package-info.java | 11 + .../AstronomicalSeasonStrategyTest.java | 130 ++++++++++ .../time/season/FixedSeasonStrategyTest.java | 64 +++++ .../MeteorologicalSeasonStrategyTest.java | 79 ++++++ .../titan/common/time/season/SeasonTest.java | 50 ++++ 10 files changed, 845 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategy.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategy.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategy.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/season/Season.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/season/SeasonBoundaryStrategy.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/season/package-info.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategyTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategyTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategyTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/season/SeasonTest.java 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 00000000..30fcd284 --- /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 . + */ +package net.onelitefeather.titan.common.time.season; + +import org.jetbrains.annotations.Contract; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * Puts the season boundaries on the equinoxes and solstices (US-2.12). + * + *

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. + * + *

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. + * + *

Known limits, stated rather than hidden: + * + *

+ * + * @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. + * + *

{@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 00000000..f257c043 --- /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 . + */ +package net.onelitefeather.titan.common.time.season; + +import org.jetbrains.annotations.Contract; + +import java.time.LocalDate; + +/** + * Always answers with the same season, whatever the date (US-2.13). + * + *

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 00000000..df6e3647 --- /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 . + */ +package net.onelitefeather.titan.common.time.season; + +import org.jetbrains.annotations.Contract; + +import java.time.LocalDate; +import java.time.Month; + +/** + * Puts the season boundaries on the fixed month starts 1 March, 1 June, 1 September and 1 December. + * + *

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 00000000..729bbf99 --- /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 . + */ +package net.onelitefeather.titan.common.time.season; + +import org.jetbrains.annotations.Contract; + +import java.util.Locale; + +/** + * The four seasons of the year, in calendar order starting with spring. + * + *

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 00000000..3d7b7369 --- /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 . + */ +package net.onelitefeather.titan.common.time.season; + +import org.jetbrains.annotations.Contract; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * Decides which season applies on a date. + * + *

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 00000000..520ebfe2 --- /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/season/AstronomicalSeasonStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategyTest.java new file mode 100644 index 00000000..ac2b94f3 --- /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 . + */ +package net.onelitefeather.titan.common.time.season; + +import net.onelitefeather.titan.common.time.season.AstronomicalSeasonStrategy.Event; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The astronomical boundaries, held against published equinox and solstice times. + * + *

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 00000000..84f7ad6e --- /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 . + */ +package net.onelitefeather.titan.common.time.season; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The pinned season: the preview path, and the reason the boundaries are a strategy at all. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +class FixedSeasonStrategyTest { + + @ParameterizedTest(name = "pinned to {0}") + @EnumSource(Season.class) + @DisplayName("The pinned season is returned whatever the date") + void thePinnedSeasonIgnoresTheDate(Season season) { + SeasonBoundaryStrategy strategy = SeasonBoundaryStrategy.fixed(season); + + assertEquals(season, strategy.seasonAt(LocalDate.of(2026, 1, 1))); + assertEquals(season, strategy.seasonAt(LocalDate.of(2026, 8, 15))); + assertEquals(season, strategy.seasonAt(LocalDate.of(2026, 12, 31))); + assertEquals(season, strategy.seasonAt(LocalDate.of(1970, 1, 1))); + } + + @Test + @DisplayName("A winter event can be shown in August without touching the system clock") + void aWinterEventCanBeShownInAugust() { + SeasonBoundaryStrategy preview = SeasonBoundaryStrategy.fixed(Season.WINTER); + + assertEquals(Season.SUMMER, SeasonBoundaryStrategy.meteorological().seasonAt(LocalDate.of(2026, 8, 15))); + assertEquals(Season.WINTER, preview.seasonAt(LocalDate.of(2026, 8, 15))); + } + + @Test + @DisplayName("Two strategies pinned to the same season are equal") + void pinnedStrategiesAreValues() { + assertEquals(FixedSeasonStrategy.of(Season.WINTER), FixedSeasonStrategy.of(Season.WINTER)); + assertEquals(Season.WINTER, FixedSeasonStrategy.of(Season.WINTER).season()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategyTest.java new file mode 100644 index 00000000..cc67de19 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategyTest.java @@ -0,0 +1,79 @@ +/** + * 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 . + */ +package net.onelitefeather.titan.common.time.season; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * The meteorological boundaries, checked on the day before and the day of every switch. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +class MeteorologicalSeasonStrategyTest { + + private final SeasonBoundaryStrategy strategy = SeasonBoundaryStrategy.meteorological(); + + @ParameterizedTest(name = "{0} is {1}") + @CsvSource({"2026-01-01, WINTER", "2026-02-28, WINTER", "2026-03-01, SPRING", "2026-05-31, SPRING", "2026-06-01, SUMMER", "2026-08-31, SUMMER", "2026-09-01, AUTUMN", "2026-11-30, AUTUMN", "2026-12-01, WINTER", "2026-12-31, WINTER", + }) + @DisplayName("The seasons change on the first of March, June, September and December") + void theSeasonsChangeOnFixedMonthStarts(LocalDate date, Season expected) { + assertEquals(expected, this.strategy.seasonAt(date)); + } + + @Test + @DisplayName("A leap day is still winter") + void aLeapDayIsStillWinter() { + assertEquals(Season.WINTER, this.strategy.seasonAt(LocalDate.of(2028, 2, 29))); + } + + @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"); + } + + @Test + @DisplayName("The strategy is stateless and shared") + void theStrategyIsShared() { + assertSame(MeteorologicalSeasonStrategy.instance(), SeasonBoundaryStrategy.meteorological()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/season/SeasonTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/season/SeasonTest.java new file mode 100644 index 00000000..076ac452 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/season/SeasonTest.java @@ -0,0 +1,50 @@ +/** + * 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 . + */ +package net.onelitefeather.titan.common.time.season; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The season enum itself: the identifier configuration and world directories use, and the order. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +class SeasonTest { + + @Test + @DisplayName("The identifier is the lower-case name and does not depend on the default locale") + void theIdentifierIsLocaleIndependent() { + assertEquals("spring", Season.SPRING.id()); + assertEquals("summer", Season.SUMMER.id()); + assertEquals("autumn", Season.AUTUMN.id()); + assertEquals("winter", Season.WINTER.id()); + } + + @Test + @DisplayName("The seasons follow each other in calendar order and wrap") + void theSeasonsWrap() { + assertEquals(Season.SUMMER, Season.SPRING.next()); + assertEquals(Season.AUTUMN, Season.SUMMER.next()); + assertEquals(Season.WINTER, Season.AUTUMN.next()); + assertEquals(Season.SPRING, Season.WINTER.next()); + } +} From 8378b7904a3e1814727bc5ccb514e2d05aae4353 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:32:16 +0200 Subject: [PATCH 3/9] feat(time): drive the instance day time from an injected clock WorldTimeService owns the two things the strategies deliberately do not: the java.time.Clock and the instance. bind() stops Minestom's own day cycle before it writes anything (US-2.02) and then pushes the time on a one-second schedule, never per tick (US-2.14, NFR-008); update() enforces that budget itself, so the promise holds however often it is called. SeasonService does the same for the season and exposes it as state. The clock is injected rather than read from Instant.now(), so no test in this stage waits for real time (US-2.03, NFR-007). DayTimeStrategyComparisonTest is the point of the stage: both mappings are held against one shared set of fixed instants (US-2.08) - both solstices, both equinoxes, both Europe/Berlin daylight saving transitions and an ordinary day - and asserted to agree on range, determinism and whether the sun is up. Where they legitimately differ the test pins the difference down: the linear mapping jumps the skipped hour with the wall clock, the solar mapping does not notice it, and on the December solstice the linear mapping calls 08:00 daylight while the solar one is still in the night. Two notes on the Minestom side. Minestom 26.1 replaced Instance#setTimeRate(int) with a per-dimension Clock, so the spec's "setTimeRate(0)" is now instance.defaultClock().rate(0f), and a dimension may carry no clock at all - that case is logged rather than swallowed. That Clock is a sealed interface and cannot be mocked, so the binding half is covered by a Cyano integration test on a real instance instead of a stand-in; it still uses a hand-moved clock, not real time. --- .../titan/common/time/SeasonService.java | 105 +++++++++ .../titan/common/time/TitanSeasonService.java | 69 ++++++ .../common/time/TitanWorldTimeService.java | 138 ++++++++++++ .../titan/common/time/WorldTimeService.java | 142 ++++++++++++ .../time/DayTimeStrategyComparisonTest.java | 183 ++++++++++++++++ .../titan/common/time/SeasonServiceTest.java | 102 +++++++++ .../time/WorldTimeServiceIntegrationTest.java | 205 ++++++++++++++++++ .../common/time/WorldTimeServiceTest.java | 116 ++++++++++ 8 files changed, 1060 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/SeasonService.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/TitanSeasonService.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/TitanWorldTimeService.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/time/WorldTimeService.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/DayTimeStrategyComparisonTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/SeasonServiceTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceIntegrationTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceTest.java 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 00000000..eaaa2062 --- /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 . + */ +package net.onelitefeather.titan.common.time; + +import net.onelitefeather.titan.common.time.season.Season; +import net.onelitefeather.titan.common.time.season.SeasonBoundaryStrategy; +import org.jetbrains.annotations.Contract; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * Provides the season the lobby is currently in as read-only state (US-2.09). + * + *

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/TitanSeasonService.java b/common/src/main/java/net/onelitefeather/titan/common/time/TitanSeasonService.java new file mode 100644 index 00000000..0c27f33e --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/TitanSeasonService.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 . + */ +package net.onelitefeather.titan.common.time; + +import net.onelitefeather.titan.common.time.season.Season; +import net.onelitefeather.titan.common.time.season.SeasonBoundaryStrategy; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * The default {@link SeasonService}; reached through {@link SeasonService#create(Clock)}. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +final class TitanSeasonService implements SeasonService { + + private final Clock clock; + private final ZoneId zone; + private final SeasonBoundaryStrategy strategy; + + TitanSeasonService(Clock clock, ZoneId zone, SeasonBoundaryStrategy strategy) { + this.clock = clock; + this.zone = zone; + this.strategy = strategy; + } + + @Override + public Season currentSeason() { + return this.strategy.seasonAt(currentDate()); + } + + @Override + public LocalDate currentDate() { + return this.clock.instant().atZone(this.zone).toLocalDate(); + } + + @Override + public ZoneId zone() { + return this.zone; + } + + @Override + public SeasonBoundaryStrategy strategy() { + return this.strategy; + } + + @Override + public String toString() { + return "TitanSeasonService[zone=" + this.zone + ", strategy=" + this.strategy + ']'; + } +} 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 00000000..b2dff1ea --- /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 . + */ +package net.onelitefeather.titan.common.time; + +import net.minestom.server.instance.Instance; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Clock; +import java.time.ZoneId; + +/** + * The default {@link WorldTimeService}; reached through {@link WorldTimeService#create(Clock)}. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +final class TitanWorldTimeService implements WorldTimeService { + + private static final Logger LOGGER = LoggerFactory.getLogger(TitanWorldTimeService.class); + + /** Minestom's rate for "the clock does not advance on its own". */ + private static final float FROZEN_RATE = 0.0f; + + private final Clock clock; + private final ZoneId zone; + private final DayTimeStrategy strategy; + + private @Nullable Instance instance; + private @Nullable Task task; + + /** The wall-clock second the last write happened in; {@link Long#MIN_VALUE} for "never". */ + private long lastWrittenSecond = Long.MIN_VALUE; + + TitanWorldTimeService(Clock clock, ZoneId zone, DayTimeStrategy strategy) { + this.clock = clock; + this.zone = zone; + this.strategy = strategy; + } + + @Override + public long currentTicks() { + return this.strategy.ticksAt(this.clock.instant(), this.zone); + } + + @Override + public ZoneId zone() { + return this.zone; + } + + @Override + public DayTimeStrategy strategy() { + return this.strategy; + } + + @Override + public void bind(Instance instance) { + unbind(); + this.instance = instance; + freezeOwnCycle(instance); + this.lastWrittenSecond = Long.MIN_VALUE; + update(); + TaskSchedule interval = TaskSchedule.tick(TitanTime.updateIntervalTicks()); + this.task = instance.scheduler().buildTask(this::update).delay(interval).repeat(interval).schedule(); + LOGGER.info("Driving day time of instance {} from {} in {} ({} update interval)", instance.getUuid(), this.strategy, this.zone, TitanTime.UPDATE_INTERVAL); + } + + @Override + public void unbind() { + Task running = this.task; + if (running != null) { + running.cancel(); + } + this.task = null; + this.instance = null; + this.lastWrittenSecond = Long.MIN_VALUE; + } + + @Override + public boolean update() { + Instance target = this.instance; + if (target == null) { + return false; + } + long second = this.clock.instant().getEpochSecond(); + if (second == this.lastWrittenSecond) { + return false; + } + this.lastWrittenSecond = second; + target.setTime(currentTicks()); + return true; + } + + @Override + public @Nullable Instance boundInstance() { + return this.instance; + } + + /** + * Switches off the day cycle the instance runs by itself. + * + *

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 00000000..82f8abb6 --- /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 . + */ +package net.onelitefeather.titan.common.time; + +import net.minestom.server.instance.Instance; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; + +import java.time.Clock; +import java.time.ZoneId; + +/** + * Drives the day time of an instance from real time (US-2.01). + * + *

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/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 00000000..f5bf8b7e --- /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 . + */ +package net.onelitefeather.titan.common.time; + +import net.onelitefeather.titan.common.time.FixedInstants.Phase; +import net.onelitefeather.titan.common.time.FixedInstants.Sample; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.stream.Stream; + +import static net.onelitefeather.titan.common.time.DayTimeStrategy.DUSK_TICK; +import static net.onelitefeather.titan.common.time.DayTimeStrategy.TICKS_PER_DAY; +import static net.onelitefeather.titan.common.time.FixedInstants.BERLIN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Holds both day-time strategies against the same set of fixed instants (US-2.08). + * + *

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 strategies() { + return Stream.of(DayTimeStrategy.linear(), DayTimeStrategy.solar()); + } + + static Stream strategiesAndSamples() { + List samples = FixedInstants.all(); + return strategies().flatMap(strategy -> samples.stream().map(sample -> Arguments.of(strategy, sample))); + } + + static Stream strategiesAndPhasedSamples() { + List samples = FixedInstants.all().stream().filter(sample -> sample.phase() != Phase.UNSPECIFIED).toList(); + return strategies().flatMap(strategy -> samples.stream().map(sample -> Arguments.of(strategy, sample))); + } + + @ParameterizedTest(name = "{0} at {1}") + @MethodSource("strategiesAndSamples") + @DisplayName("Every strategy stays inside one Minecraft day at every fixed instant") + void everyStrategyStaysInsideOneMinecraftDay(DayTimeStrategy strategy, Sample sample) { + long ticks = strategy.ticksAt(sample.instant(), BERLIN); + + assertTrue(ticks >= 0, () -> strategy + " returned a negative tick at " + sample + ": " + ticks); + assertTrue(ticks < TICKS_PER_DAY, () -> strategy + " returned a tick past the end of the day at " + sample + ": " + ticks); + } + + @ParameterizedTest(name = "{0} at {1}") + @MethodSource("strategiesAndSamples") + @DisplayName("Every strategy answers the same instant with the same tick") + void everyStrategyIsDeterministic(DayTimeStrategy strategy, Sample sample) { + long first = strategy.ticksAt(sample.instant(), BERLIN); + long second = strategy.ticksAt(sample.instant(), BERLIN); + + assertEquals(first, second, () -> strategy + " is not deterministic at " + sample); + } + + @ParameterizedTest(name = "{0} at {1}") + @MethodSource("strategiesAndPhasedSamples") + @DisplayName("Every strategy agrees on whether the sun is up") + void everyStrategyAgreesOnDayAndNight(DayTimeStrategy strategy, Sample sample) { + long ticks = strategy.ticksAt(sample.instant(), BERLIN); + + if (sample.phase() == Phase.DAY) { + assertTrue(ticks < DUSK_TICK, () -> strategy + " puts " + sample + " into the night half: " + ticks); + } else { + assertTrue(ticks >= DUSK_TICK, () -> strategy + " puts " + sample + " into the day half: " + ticks); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("strategies") + @DisplayName("Every strategy survives the spring forward without leaving the day") + void everyStrategySurvivesTheSpringForward(DayTimeStrategy strategy) { + // Local time jumps from 01:59 CET straight to 03:01 CEST; two real minutes pass. + Instant before = Instant.parse("2026-03-29T00:59:00Z"); + Instant after = Instant.parse("2026-03-29T01:01:00Z"); + + long ticksBefore = strategy.ticksAt(before, BERLIN); + long ticksAfter = strategy.ticksAt(after, BERLIN); + + assertTrue(ticksAfter > ticksBefore, () -> strategy + " went backwards across the spring forward: " + ticksBefore + " -> " + ticksAfter); + assertTrue(ticksAfter < TICKS_PER_DAY && ticksBefore >= 0, () -> strategy + " left the day across the spring forward"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("strategies") + @DisplayName("Every strategy is driven by the injected clock, never by the machine clock") + void everyStrategyIsDrivenByTheInjectedClock(DayTimeStrategy strategy) { + Instant instant = Instant.parse("2026-12-21T14:00:00Z"); + WorldTimeService service = WorldTimeService.create(Clock.fixed(instant, BERLIN), BERLIN, strategy); + + assertEquals(strategy.ticksAt(instant, BERLIN), service.currentTicks()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("strategies") + @DisplayName("Every strategy advances over the course of an ordinary day") + void everyStrategyAdvancesOverTheDay(DayTimeStrategy strategy) { + Instant start = Instant.parse("2026-05-15T04:00:00Z"); + long previous = strategy.ticksAt(start, BERLIN); + int wrapCount = 0; + + for (int minute = 10; minute <= 24 * 60; minute += 10) { + long ticks = strategy.ticksAt(start.plus(Duration.ofMinutes(minute)), BERLIN); + if (ticks < previous) { + wrapCount++; + } + previous = ticks; + } + + int wraps = wrapCount; + assertEquals(1, wraps, () -> strategy + " passed the end of the day " + wraps + " times over 24 real hours"); + } + + @ParameterizedTest(name = "{0} at {1}") + @MethodSource("strategiesAndSamples") + @DisplayName("No strategy needs real time to pass") + void noStrategyNeedsRealTime(DayTimeStrategy strategy, Sample sample) { + Clock frozen = Clock.fixed(sample.instant(), BERLIN); + WorldTimeService service = WorldTimeService.create(frozen, BERLIN, strategy); + + assertEquals(service.currentTicks(), service.currentTicks()); + assertEquals(strategy.ticksAt(sample.instant(), BERLIN), service.currentTicks()); + } + + @Test + @DisplayName("Where the two mappings differ: the linear one follows the wall clock, the solar one the sun") + void linearFollowsTheWallClockWhereSolarDoesNot() { + DayTimeStrategy linear = DayTimeStrategy.linear(); + DayTimeStrategy solar = DayTimeStrategy.solar(); + + // Two minutes of real time across the spring forward, during which the wall clock gains an + // hour. The linear mapping is a function of the wall clock and jumps with it; the solar + // mapping is a function of the instant and does not notice. + Instant before = Instant.parse("2026-03-29T00:59:00Z"); + Instant after = Instant.parse("2026-03-29T01:01:00Z"); + + long linearJump = linear.ticksAt(after, BERLIN) - linear.ticksAt(before, BERLIN); + long solarJump = solar.ticksAt(after, BERLIN) - solar.ticksAt(before, BERLIN); + + // One hour is 1000 ticks, two real minutes are another 33. + assertEquals(1033, linearJump, "the linear mapping must jump the skipped hour with the wall clock"); + assertTrue(solarJump < 60, "the solar mapping must not notice the wall clock at all, but moved " + solarJump); + + // And in December the difference is the point of the whole exercise: at eight in the morning + // Berlin has not seen the sun yet, but the linear mapping has been in daylight for two hours. + Instant decemberMorning = Instant.parse("2026-12-21T07:00:00Z"); + assertTrue(linear.ticksAt(decemberMorning, BERLIN) < DUSK_TICK, "the linear mapping calls 08:00 in December daytime"); + assertTrue(solar.ticksAt(decemberMorning, BERLIN) >= DUSK_TICK, "the solar mapping must still be in the night at 08:00 on the December solstice"); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/SeasonServiceTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/SeasonServiceTest.java new file mode 100644 index 00000000..37c16138 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/SeasonServiceTest.java @@ -0,0 +1,102 @@ +/** + * 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 . + */ +package net.onelitefeather.titan.common.time; + +import net.onelitefeather.titan.common.time.season.Season; +import net.onelitefeather.titan.common.time.season.SeasonBoundaryStrategy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; + +import static net.onelitefeather.titan.common.time.FixedInstants.BERLIN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * The service that provides the current season as state, driven by an injected clock. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +class SeasonServiceTest { + + /** A hot afternoon in the middle of August. */ + private static final Instant AUGUST = Instant.parse("2026-08-15T12:00:00Z"); + + @Test + @DisplayName("The default is the meteorological boundaries in the editorial zone") + void theDefaultIsMeteorologicalInTheEditorialZone() { + SeasonService service = SeasonService.create(Clock.fixed(AUGUST, BERLIN)); + + assertSame(SeasonBoundaryStrategy.meteorological(), service.strategy()); + assertEquals(TitanTime.EDITORIAL_ZONE, service.zone()); + assertEquals(Season.SUMMER, service.currentSeason()); + assertEquals(LocalDate.of(2026, 8, 15), service.currentDate()); + } + + @Test + @DisplayName("Winter can be tested in summer, because the clock and the boundaries are both injected") + void winterCanBeTestedInSummer() { + SeasonService service = SeasonService.create(Clock.fixed(AUGUST, BERLIN), SeasonBoundaryStrategy.fixed(Season.WINTER)); + + assertEquals(Season.WINTER, service.currentSeason()); + assertEquals(LocalDate.of(2026, 8, 15), service.currentDate(), "pinning the season must not move the calendar"); + } + + @Test + @DisplayName("A December clock gives winter without anyone waiting for December") + void aDecemberClockGivesWinter() { + Instant december = Instant.parse("2026-12-05T09:00:00Z"); + SeasonService service = SeasonService.create(Clock.fixed(december, BERLIN)); + + assertEquals(Season.WINTER, service.currentSeason()); + } + + @Test + @DisplayName("The zone decides the date, and with it the season on a boundary night") + void theZoneDecidesTheDate() { + // 23:30 UTC on the last day of February is already the first of March in Berlin. + Instant boundaryNight = Instant.parse("2026-02-28T23:30:00Z"); + + SeasonService berlin = SeasonService.create(Clock.fixed(boundaryNight, BERLIN)); + SeasonService utc = SeasonService.create( + Clock.fixed(boundaryNight, ZoneOffset.UTC), ZoneOffset.UTC, SeasonBoundaryStrategy.meteorological()); + + assertEquals(LocalDate.of(2026, 3, 1), berlin.currentDate()); + assertEquals(Season.SPRING, berlin.currentSeason()); + assertEquals(LocalDate.of(2026, 2, 28), utc.currentDate()); + assertEquals(Season.WINTER, utc.currentSeason()); + } + + @Test + @DisplayName("The astronomical boundaries can be swapped in without touching the caller") + void theAstronomicalBoundariesCanBeSwappedIn() { + Instant midMarch = Instant.parse("2026-03-10T12:00:00Z"); + + SeasonService meteorological = SeasonService.create(Clock.fixed(midMarch, BERLIN)); + SeasonService astronomical = SeasonService.create( + Clock.fixed(midMarch, BERLIN), SeasonBoundaryStrategy.astronomical(BERLIN)); + + assertEquals(Season.SPRING, meteorological.currentSeason()); + assertEquals(Season.WINTER, astronomical.currentSeason()); + } +} 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 00000000..e7f05e7b --- /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 . + */ +package net.onelitefeather.titan.common.time; + +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.concurrent.atomic.AtomicReference; + +import static net.onelitefeather.titan.common.time.FixedInstants.BERLIN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The half of {@link WorldTimeService} that touches Minestom, on a real instance. + * + *

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 now = new AtomicReference<>(NOON); + Instance instance = env.createFlatInstance(); + WorldTimeService service = WorldTimeService.create(movableClock(now), BERLIN, DayTimeStrategy.linear()); + + service.bind(instance); + assertEquals(DayTimeStrategy.NOON_TICK, instance.getTime()); + + // Twenty server ticks inside the same wall-clock second. A marker written between the calls + // has to survive: if the service wrote per tick it would be overwritten immediately. + for (int tick = 0; tick < 19; tick++) { + now.set(now.get().plus(Duration.ofMillis(50))); + instance.setTime(4711L); + assertFalse(service.update(), "an update inside the same second must be skipped"); + assertEquals(4711L, instance.getTime()); + } + + now.set(NOON.plusSeconds(1)); + assertTrue(service.update(), "the first update in a new second must go through"); + assertEquals(DayTimeStrategy.NOON_TICK, instance.getTime()); + } + + @Test + @DisplayName("A moving clock moves the lobby's day time with it") + void aMovingClockMovesTheDayTime(Env env) { + AtomicReference now = new AtomicReference<>(NOON); + Instance instance = env.createFlatInstance(); + WorldTimeService service = WorldTimeService.create(movableClock(now), BERLIN, DayTimeStrategy.linear()); + + service.bind(instance); + assertEquals(6000L, instance.getTime()); + + now.set(NOON.plus(Duration.ofHours(6))); + assertTrue(service.update()); + assertEquals(12000L, instance.getTime(), "six real hours later the lobby must be at nightfall"); + + now.set(NOON.plus(Duration.ofHours(12))); + assertTrue(service.update()); + assertEquals(18000L, instance.getTime(), "twelve real hours later the lobby must be at midnight"); + } + + @Test + @DisplayName("The scheduled task keeps the time up to date without anyone calling update") + void theScheduledTaskKeepsTheTimeUpToDate(Env env) { + AtomicReference now = new AtomicReference<>(NOON); + Instance instance = env.createFlatInstance(); + WorldTimeService service = WorldTimeService.create(movableClock(now), BERLIN, DayTimeStrategy.linear()); + + service.bind(instance); + now.set(NOON.plus(Duration.ofHours(6))); + + // The repeat interval is one second, which Minestom's scheduler counts in server ticks. + for (int tick = 0; tick < 25; tick++) { + env.tick(); + } + + assertEquals(12000L, instance.getTime()); + } + + @Test + @DisplayName("Unbinding stops the task and leaves the day time where it was") + void unbindingStopsTheTask(Env env) { + AtomicReference now = new AtomicReference<>(NOON); + Instance instance = env.createFlatInstance(); + WorldTimeService service = WorldTimeService.create(movableClock(now), BERLIN, DayTimeStrategy.linear()); + + service.bind(instance); + service.unbind(); + now.set(NOON.plus(Duration.ofHours(6))); + for (int tick = 0; tick < 25; tick++) { + env.tick(); + } + + assertNull(service.boundInstance()); + assertFalse(service.update()); + assertEquals(6000L, instance.getTime(), "an unbound service must not keep writing"); + } + + @Test + @DisplayName("Binding a second instance releases the first") + void bindingASecondInstanceReleasesTheFirst(Env env) { + AtomicReference now = new AtomicReference<>(NOON); + Instance first = env.createFlatInstance(); + Instance second = env.createFlatInstance(); + WorldTimeService service = WorldTimeService.create(movableClock(now), BERLIN, DayTimeStrategy.linear()); + + service.bind(first); + service.bind(second); + now.set(NOON.plus(Duration.ofHours(6))); + for (int tick = 0; tick < 25; tick++) { + env.tick(); + } + + assertSame(second, service.boundInstance()); + assertEquals(6000L, first.getTime(), "the released instance must not be written to any more"); + assertEquals(12000L, second.getTime()); + } + + private static Clock movableClock(AtomicReference now) { + return new Clock() { + @Override + public ZoneId getZone() { + return BERLIN; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now.get(); + } + }; + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceTest.java new file mode 100644 index 00000000..e3789ebd --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceTest.java @@ -0,0 +1,116 @@ +/** + * 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 . + */ +package net.onelitefeather.titan.common.time; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static net.onelitefeather.titan.common.time.FixedInstants.BERLIN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * What the service decides before any instance is involved: which clock, which zone, which mapping. + * + *

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()); + } +} From 4857c7d69246abd28512a763987608c31f7aaadc Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:32:23 +0200 Subject: [PATCH 4/9] feat(app): run the lobby on real Berlin time The lobby instance now gets its day time from WorldTimeService with the default linear mapping, and the current season from SeasonService with the default meteorological boundaries (US-2.01, US-2.09). Both read one Clock.system(Europe/Berlin), so the host machine's own zone cannot move the lobby's calendar, and terminate() releases the instance again. --- .../net/onelitefeather/titan/app/Titan.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 42a23911..5bb1401d 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,13 @@ 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.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 +53,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 +65,11 @@ 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); + this.worldTimeService = WorldTimeService.create(clock); + this.seasonService = SeasonService.create(clock); + this.worldTimeService.bind(instance); } public void initialize() { @@ -71,7 +82,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() { From 28ca3fb09f0052310bf18b7fcbfc63140293fb9e Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:34:34 +0200 Subject: [PATCH 5/9] docs: correct the Minestom time API the spec names Stage 2 does not use `Instance#setTimeRate(0)` because that method no longer exists. Minestom 26.1 replaced it with a per-dimension clock: `Instance#defaultClock()` returns a `net.minestom.server.instance.Clock`, and stopping the built-in cycle is `defaultClock().rate(0f)`. That clock is `@Nullable` - a dimension may carry none - which is why the service logs a warning instead of silently writing nothing. Also moves the two `@since` tags in section 6a from 1.11.0 to 1.15.0. The spec was written against an older version line; 1.15.0 is the next minor after the current 1.14.0. --- docs/spec-lobby-saison-events.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md index d9cee2af..558b576f 100644 --- a/docs/spec-lobby-saison-events.md +++ b/docs/spec-lobby-saison-events.md @@ -155,8 +155,8 @@ Abschnitt 6a. | ID | Story | Akzeptanzkriterium (EARS) | Schnittstelle | Priorität | Status | |---|---|---|---|---|---| -| US-2.01 | Als Spieler möchte ich, dass die Lobby-Tageszeit meiner echten Tageszeit entspricht, damit sich die Welt lebendig anfühlt. | While die Lobby läuft, shall die Tageszeit der Instanz der aktuellen Uhrzeit in `Europe/Berlin` entsprechen. | `Instance#setTime`, `setTimeRate(0)` | Must | offen | -| US-2.02 | Als Entwickler möchte ich, dass der eingebaute Tageszyklus abgeschaltet ist, damit unsere Zeitsteuerung nicht gegen Minestom arbeitet. | The Lobby shall die Zeitrate der Instanz auf 0 setzen und die Zeit ausschließlich selbst setzen. | `Instance#setTimeRate` | Must | offen | +| US-2.01 | Als Spieler möchte ich, dass die Lobby-Tageszeit meiner echten Tageszeit entspricht, damit sich die Welt lebendig anfühlt. | While die Lobby läuft, shall die Tageszeit der Instanz der aktuellen Uhrzeit in `Europe/Berlin` entsprechen. | `Instance#setTime`, `Instance#defaultClock()` | Must | offen | +| US-2.02 | Als Entwickler möchte ich, dass der eingebaute Tageszyklus abgeschaltet ist, damit unsere Zeitsteuerung nicht gegen Minestom arbeitet. | The Lobby shall die Zeitrate der Instanz auf 0 setzen und die Zeit ausschließlich selbst setzen. | `Instance#defaultClock().rate(0f)` | Must | offen | | US-2.03 | Als Entwickler möchte ich die Zeitquelle austauschen können, damit Tests deterministisch sind. | The Zeitsteuerung shall ihre Zeit aus einer injizierten `java.time.Clock` beziehen und nicht aus `Instant.now()`. | `java.time.Clock` | Must | offen | | US-2.04 | Als Betreiber möchte ich, dass Sommerzeit korrekt behandelt wird, damit im Oktober nichts um eine Stunde verrutscht. | When die Sommerzeitumstellung in `Europe/Berlin` stattfindet, shall die Lobby-Tageszeit ohne Neustart korrekt weiterlaufen. | `ZoneId.of("Europe/Berlin")` | Must | offen | | US-2.05 | Als Entwickler möchte ich die Uhrzeit-Abbildung austauschen können, damit wir lineare und astronomische Variante vergleichen können, ohne den Aufrufcode zu ändern. | The Zeitsteuerung shall die Abbildung von Realzeit auf Spielzeit über eine austauschbare Strategie beziehen. | `DayTimeStrategy` | Must | offen | @@ -255,7 +255,7 @@ gegeneinander testen, und die aufwendigere kommt später — ohne Umbau. * * @author TheMeinerLP * @version 1.0.0 - * @since 1.11.0 + * @since 1.15.0 */ public interface DayTimeStrategy { @@ -296,7 +296,7 @@ injizierte `Clock` ist, braucht kein Test reale Zeit. * * @author TheMeinerLP * @version 1.0.0 - * @since 1.11.0 + * @since 1.15.0 */ public interface SeasonBoundaryStrategy { From 1acbac70471d89635b5d4ff79882244196432b49 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 12:16:03 +0200 Subject: [PATCH 6/9] fix(time): drop the 0.0009 d nudge that put every solar event 78 s late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEAN_SOLAR_TIME_CORRECTION was transcribed out of the published sunrise equation, where 0.0008 lives inside ceil(J - 2451545.0 + 0.0008) purely to decide which whole day the ceiling lands on. This class starts from the exact integer epoch day and correctly drops the ceil, but kept the constant as an additive term — turning a rounding nudge into a real +0.0009 d = +77.8 s shift applied to every sunrise, sunset and transit. Measured for Berlin on twelve dates across 2026 against a full-precision ephemeris, itself cross-checked against every rise, set and upper transit the US Naval Observatory publishes for those dates (agreeing with all 36 printed minutes to within their own rounding), the worst deviation drops from solar noon 89 s / sunrise 117 s / sunset 180 s to solar noon 17 s / sunrise 39 s / sunset 102 s. Sunset improves least because the 78 s shift had been partly cancelling a second, unrelated error there; see the javadoc note on the perihelion argument. The test could not tell the two apart. Its tolerance was five minutes on a value documented as accurate to about one minute, and two of its eight "published Berlin times" had been fitted to this implementation's own output (2026-03-20 sunrise, 2026-09-23 sunset) rather than quoted from a table. All eight now come from USNO, and so do two more that were a minute off. The tolerance is 120 s on sunrise and sunset, just above the 83 s actually measured there. A second case asserts solar noon, the midpoint of the computed events, against USNO's upper transit at 45 s. That is the assertion with teeth: solar noon is nearly free of the half-day-length error that dominates sunrise and sunset, so any constant offset bolted onto the mean solar time moves it by the full amount. Re-adding the 0.0009 fails all four noon cases. The class javadoc claimed "about one minute" for all three quantities and claimed an uncorrected dT of roughly 70 s in the opposite direction to the shift the code was applying. It now states the measured bound per quantity, explains that no dT reduction is applied or needed because the day count is already in UT days from 2000-01-01 12:00 UT, and names what actually dominates the remaining sunrise and sunset error: the perihelion argument is pinned at its J2000 value and the sun's computed ecliptic longitude has fallen 0.45° behind by 2026. --- .../common/time/SolarDayTimeStrategy.java | 51 +++++++++++---- .../common/time/SolarDayTimeStrategyTest.java | 65 +++++++++++++++---- 2 files changed, 91 insertions(+), 25 deletions(-) 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 index 14c68f97..13201d41 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java +++ b/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java @@ -45,19 +45,46 @@ *

The calculation and its limits

* * Sunrise and sunset come from the low-precision sunrise equation in its published closed form (the - * form the NOAA solar calculator is derived from). Checked against the Berlin values for the 2026 - * solstices and equinoxes it agrees to within about one minute; the equation's own stated bound is - * a - * few minutes. One Minecraft tick is worth 3.6 real seconds in a 24-hour cycle, so the error is - * visible in principle — this is a lighting effect, not an ephemeris. + * form the NOAA solar calculator is derived from). Measured for Berlin on twelve dates spread + * across 2026, against the rise, set and upper-transit times the US Naval Observatory publishes and + * against a full-precision ephemeris that reproduces every one of those published minutes: + * + * + * + * + * + * + * + *
Largest deviation over the twelve dates
vs USNO (printed to the minute)vs full-precision ephemeris
solar noon41 s17 s
sunrise56 s39 s
sunset96 s102 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: * *