diff --git a/app/src/main/java/net/onelitefeather/titan/app/Titan.java b/app/src/main/java/net/onelitefeather/titan/app/Titan.java index 42a2391..b2a1e85 100644 --- a/app/src/main/java/net/onelitefeather/titan/app/Titan.java +++ b/app/src/main/java/net/onelitefeather/titan/app/Titan.java @@ -37,9 +37,14 @@ import net.onelitefeather.titan.common.event.EntityDismountEvent; import net.onelitefeather.titan.common.helper.BlockHandlerHelper; import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.common.time.SeasonService; +import net.onelitefeather.titan.common.time.TimeConfigProvider; +import net.onelitefeather.titan.common.time.TitanTime; +import net.onelitefeather.titan.common.time.WorldTimeService; import net.onelitefeather.titan.common.utils.Cancelable; import java.nio.file.Path; +import java.time.Clock; public final class Titan { @@ -49,6 +54,8 @@ public final class Titan { private final MapProvider mapProvider; private final AppConfigProvider appConfigProvider; private final NavigationHelper navigationHelper; + private final WorldTimeService worldTimeService; + private final SeasonService seasonService; public Titan() { MinecraftServer.getConnectionManager().setPlayerProvider(TitanPlayer::new); @@ -59,6 +66,15 @@ public Titan() { this.mapProvider = MapProvider.create(this.path, instance); this.appConfigProvider = AppConfigProvider.create(this.path); this.navigationHelper = NavigationHelper.instance(this.deliver); + // The lobby tells its time in Berlin, whatever zone the host machine is set to. + Clock clock = Clock.system(TitanTime.EDITORIAL_ZONE); + // Which mapping and which season boundaries run is a question for time.json, not for this + // file: it names no strategy at all (US-2.07, US-2.12, US-2.13). A value the file gets + // wrong stops the boot here rather than running a strategy nobody chose. + TimeConfigProvider timeConfigProvider = TimeConfigProvider.create(this.path, TitanTime.EDITORIAL_ZONE); + this.worldTimeService = timeConfigProvider.createWorldTimeService(clock); + this.seasonService = timeConfigProvider.createSeasonService(clock); + this.worldTimeService.bind(instance); } public void initialize() { @@ -71,7 +87,25 @@ public void initialize() { } public void terminate() { + this.worldTimeService.unbind(); + } + + /** + * Returns the service that drives the lobby's day time from real time. + * + * @return the world time service + */ + public WorldTimeService worldTimeService() { + return this.worldTimeService; + } + /** + * Returns the service that tells which season the lobby is in. + * + * @return the season service + */ + public SeasonService seasonService() { + return this.seasonService; } private void initCommands() { diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 4b83669..0274b1e 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { testImplementation(libs.cyano) testImplementation(libs.aves) testImplementation(libs.junit.api) + testImplementation(libs.junit.params) testImplementation(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.engine) } diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/DayTimeStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/DayTimeStrategy.java new file mode 100644 index 0000000..d42c9d1 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/DayTimeStrategy.java @@ -0,0 +1,80 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..d94864d --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategy.java @@ -0,0 +1,81 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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/SeasonService.java b/common/src/main/java/net/onelitefeather/titan/common/time/SeasonService.java new file mode 100644 index 0000000..eaaa206 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/SeasonService.java @@ -0,0 +1,105 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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/SolarDayTimeStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java new file mode 100644 index 0000000..13201d4 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategy.java @@ -0,0 +1,292 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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). 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: + * + *

+ * + * @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; + + /** 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. The published form writes this + // as ceil(J - 2451545.0 + 0.0008); the epoch day is already the exact whole day that ceil + // is there to produce, so both the ceil and the 0.0008 it rounds with fall away. See the + // class javadoc: keeping the 0.0008 as a summand would put every event 69 seconds late. + double meanSolarTime = days + (-this.longitude) / 360.0; + double meanAnomalyDegrees = (357.5291 + 0.98560028 * meanSolarTime) % 360.0; + double meanAnomaly = Math.toRadians(meanAnomalyDegrees); + double equationOfCentre = 1.9148 * Math.sin(meanAnomaly) + 0.0200 * Math.sin(2.0 * meanAnomaly) + 0.0003 * Math.sin(3.0 * meanAnomaly); + double eclipticLongitude = Math.toRadians( + (meanAnomalyDegrees + equationOfCentre + PERIHELION_ARGUMENT + 180.0) % 360.0); + double transit = J2000_JULIAN_DATE + meanSolarTime + 0.0053 * Math.sin(meanAnomaly) - 0.0069 * Math.sin(2.0 * eclipticLongitude); + + double sinDeclination = Math.sin(eclipticLongitude) * Math.sin(Math.toRadians(EARTH_OBLIQUITY)); + double cosDeclination = Math.cos(Math.asin(sinDeclination)); + double latitudeRadians = Math.toRadians(this.latitude); + double cosHourAngle = (Math.sin(Math.toRadians(HORIZON_ALTITUDE)) - Math.sin(latitudeRadians) * sinDeclination) / (Math.cos(latitudeRadians) * cosDeclination); + if (Double.isNaN(cosHourAngle) || cosHourAngle > 1.0 || cosHourAngle < -1.0) { + return null; + } + double hourAngle = Math.toDegrees(Math.acos(cosHourAngle)); + return new SolarDay( + fromJulianDate(transit - hourAngle / 360.0), fromJulianDate(transit + hourAngle / 360.0)); + } + + private static Instant fromJulianDate(double julianDate) { + return Instant.ofEpochMilli(Math.round((julianDate - UNIX_EPOCH_JULIAN_DATE) * MILLIS_PER_DAY)); + } + + @Override + public String toString() { + return "SolarDayTimeStrategy[latitude=" + this.latitude + ", longitude=" + this.longitude + ']'; + } + + /** + * The two solar events of one calendar date at one position. + * + * @param sunrise the moment the upper limb of the sun appears + * @param sunset the moment it disappears again + */ + private record SolarDay(Instant sunrise, Instant sunset) { + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfig.java b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfig.java new file mode 100644 index 0000000..0f0afae --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfig.java @@ -0,0 +1,242 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * Which time and season strategy the lobby runs, as an operator can change it without a code + * change (US-2.07, US-2.12, US-2.13). + * + *

This is the "where … is configured" the three acceptance criteria are written as: the calling + * code knows only {@link DayTimeStrategy} and {@link SeasonBoundaryStrategy}, and this file decides + * which implementation answers. + * + *

It follows the shape of {@link net.onelitefeather.titan.common.config.AppConfig} — a sealed + * interface with a package-private record behind it, loaded from JSON by a provider — minus the + * builder, the same way {@code PortalConfig} does. {@code AppConfig} has a builder because + * {@code /app} edits it while the server runs. These two values are read once, when the services + * are constructed at boot; a builder and a command would advertise a live switch that does not + * exist. + * + *

Unknown values are a startup failure, not a fallback

+ * + * A misspelt strategy name that quietly fell back to the default would leave the lobby running the + * wrong season for up to a month, and nothing would look broken. So every value that is present + * but not recognised throws a {@link TimeConfigException} naming both the offending value and the + * accepted ones. An absent value is a different thing and keeps the stage 2 default: + * linear day time (US-2.06) and meteorological seasons (US-2.11). + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public sealed interface TimeConfig permits TimeConfigImpl { + + /** Name of the file the strategies are read from, next to {@code app.json}. */ + String TIME_FILE_NAME = "time.json"; + + /** {@link #dayTimeStrategy()} value selecting {@link LinearDayTimeStrategy} — the default. */ + String LINEAR = "linear"; + + /** {@link #dayTimeStrategy()} value selecting {@link SolarDayTimeStrategy} (US-2.07). */ + String SOLAR = "solar"; + + /** + * {@link #seasonStrategy()} value selecting the meteorological boundaries — the default. + */ + String METEOROLOGICAL = "meteorological"; + + /** {@link #seasonStrategy()} value selecting the astronomical boundaries (US-2.12). */ + String ASTRONOMICAL = "astronomical"; + + /** + * {@link #seasonStrategy()} value pinning the season named by {@link #fixedSeason()} + * (US-2.13). + */ + String FIXED = "fixed"; + + /** The accepted {@link #dayTimeStrategy()} values, in the order an error message lists them. */ + List DAY_TIME_STRATEGIES = List.of(LINEAR, SOLAR); + + /** The accepted {@link #seasonStrategy()} values, in the order an error message lists them. */ + List SEASON_STRATEGIES = List.of(METEOROLOGICAL, ASTRONOMICAL, FIXED); + + /** + * Returns the configuration used when no {@value #TIME_FILE_NAME} exists yet: the stage 2 + * defaults, spelled out rather than left empty. Writing this file out is what shows an operator + * both keys and the values they already have. + * + * @return the default configuration + */ + @Contract(pure = true) + static TimeConfig defaultConfig() { + return TimeConfigImpl.DEFAULT; + } + + /** + * Creates a configuration directly, for tests and for callers that assemble it in code. + * + * @param dayTimeStrategy the day time strategy name, or {@code null} for the default + * @param seasonStrategy the season strategy name, or {@code null} for the default + * @param fixedSeason the season name, read only when {@code seasonStrategy} is + * {@value #FIXED} + * @return a configuration with those values, still unresolved + */ + @Contract(pure = true, value = "_, _, _ -> new") + static TimeConfig of(@Nullable String dayTimeStrategy, @Nullable String seasonStrategy, @Nullable String fixedSeason) { + return new TimeConfigImpl(dayTimeStrategy, seasonStrategy, fixedSeason); + } + + /** + * The configured day time strategy, as written in the file and not yet validated. + * + * @return {@value #LINEAR}, {@value #SOLAR}, {@code null} when the key is absent, or whatever + * else the file says + */ + @Contract(pure = true) + @Nullable + String dayTimeStrategy(); + + /** + * The configured season strategy, as written in the file and not yet validated. + * + * @return {@value #METEOROLOGICAL}, {@value #ASTRONOMICAL}, {@value #FIXED}, {@code null} when + * the key is absent, or whatever else the file says + */ + @Contract(pure = true) + @Nullable + String seasonStrategy(); + + /** + * The season {@value #FIXED} pins the lobby to, as written in the file and not yet validated. + * + *

Read only when {@link #seasonStrategy()} is {@value #FIXED}; a value left here while + * another strategy runs is ignored rather than treated as an error, so that switching back and + * forth does not mean deleting the key each time. + * + * @return the season identifier, or {@code null} when the key is absent + */ + @Contract(pure = true) + @Nullable + String fixedSeason(); + + /** + * Resolves {@link #dayTimeStrategy()} into the implementation that will answer. + * + * @return the linear strategy when the value is absent or {@value #LINEAR}, the solar strategy + * when it is {@value #SOLAR} + * @throws TimeConfigException if the value is present but not one of {@link + * #DAY_TIME_STRATEGIES} + */ + @Contract(pure = true) + @NotNull + default DayTimeStrategy resolveDayTimeStrategy() { + String name = normalize(dayTimeStrategy()); + if (name == null) { + return DayTimeStrategy.linear(); + } + return switch (name) { + case LINEAR -> DayTimeStrategy.linear(); + case SOLAR -> DayTimeStrategy.solar(); + default -> + throw TimeConfigException.unknownValue("dayTimeStrategy", dayTimeStrategy(), DAY_TIME_STRATEGIES); + }; + } + + /** + * Resolves {@link #seasonStrategy()} into the implementation that will answer. + * + * @param zone the zone an astronomical boundary instant is resolved in; an equinox is an + * instant, not a date, so the zone decides which calendar day it lands on + * @return the meteorological strategy when the value is absent or {@value #METEOROLOGICAL}, the + * astronomical one when it is {@value #ASTRONOMICAL}, and a strategy pinned to + * {@link #fixedSeason()} when it is {@value #FIXED} + * @throws TimeConfigException if the value is present but not one of + * {@link #SEASON_STRATEGIES}, or if {@value #FIXED} is asked for + * without a usable {@link #fixedSeason()} + */ + @Contract(pure = true) + @NotNull + default SeasonBoundaryStrategy resolveSeasonStrategy(@NotNull ZoneId zone) { + String name = normalize(seasonStrategy()); + if (name == null) { + return SeasonBoundaryStrategy.meteorological(); + } + return switch (name) { + case METEOROLOGICAL -> SeasonBoundaryStrategy.meteorological(); + case ASTRONOMICAL -> SeasonBoundaryStrategy.astronomical(zone); + case FIXED -> SeasonBoundaryStrategy.fixed(resolveFixedSeason()); + default -> + throw TimeConfigException.unknownValue("seasonStrategy", seasonStrategy(), SEASON_STRATEGIES); + }; + } + + /** + * Resolves {@link #fixedSeason()} into the season {@value #FIXED} pins the lobby to. + * + * @return the named season + * @throws TimeConfigException if the value is absent, or is not the identifier of a + * {@link Season} + */ + @Contract(pure = true) + @NotNull + default Season resolveFixedSeason() { + String name = normalize(fixedSeason()); + if (name == null) { + throw TimeConfigException.missingFixedSeason(seasonIds()); + } + for (Season season : Season.values()) { + if (season.id().equals(name)) { + return season; + } + } + throw TimeConfigException.unknownValue("fixedSeason", fixedSeason(), seasonIds()); + } + + /** + * The identifiers every {@link Season} is written as in {@value #TIME_FILE_NAME}. + * + * @return the season identifiers in calendar order + */ + @Contract(pure = true) + @NotNull + static List seasonIds() { + return Arrays.stream(Season.values()).map(Season::id).toList(); + } + + /** + * Lower-cases and trims a configured value so that {@code "Solar"} and {@code " solar "} are + * the same value, while a blank string stays distinct from an absent key and still fails. + * + * @param value the value as written in the file + * @return the comparable form, or {@code null} if the key was absent + */ + @Contract(pure = true, value = "null -> null; !null -> !null") + private static String normalize(@Nullable String value) { + return value == null ? null : value.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigException.java b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigException.java new file mode 100644 index 0000000..9a49917 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigException.java @@ -0,0 +1,85 @@ +/** + * 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.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Thrown when {@value TimeConfig#TIME_FILE_NAME} cannot be turned into a pair of strategies. + * + *

It is deliberately unchecked and deliberately not caught anywhere: a lobby that starts on a + * strategy nobody chose is worse than a lobby that does not start. The message always carries the + * value that was rejected and the values that would have been accepted, because the only person + * who ever reads it is looking at their own typo. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class TimeConfigException extends RuntimeException { + + private TimeConfigException(String message) { + super(message); + } + + private TimeConfigException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Reports a value that is present but not one this lobby knows. + * + * @param key the configuration key the value was written under + * @param value the value as written in the file + * @param accepted the values that would have been accepted + * @return the exception to throw + */ + @Contract(pure = true, value = "_, _, _ -> new") + @NotNull + static TimeConfigException unknownValue(@NotNull String key, @Nullable String value, @NotNull List accepted) { + return new TimeConfigException("Unknown " + key + " \"" + value + "\" in " + TimeConfig.TIME_FILE_NAME + "; valid values are: " + String.join(", ", accepted)); + } + + /** + * Reports a {@value TimeConfig#FIXED} season strategy that never says which season. + * + * @param accepted the season identifiers that would have been accepted + * @return the exception to throw + */ + @Contract(pure = true, value = "_ -> new") + @NotNull + static TimeConfigException missingFixedSeason(@NotNull List accepted) { + return new TimeConfigException("seasonStrategy \"" + TimeConfig.FIXED + "\" in " + TimeConfig.TIME_FILE_NAME + " needs a fixedSeason; valid values are: " + String.join(", ", accepted)); + } + + /** + * Reports a file that is not readable as JSON at all. + * + * @param path the file that could not be read + * @param cause what the parser complained about + * @return the exception to throw + */ + @Contract(pure = true, value = "_, _ -> new") + @NotNull + static TimeConfigException unreadable(@NotNull Object path, @NotNull Throwable cause) { + return new TimeConfigException("Unable to read " + path + "; fix the file or delete it to fall back to the defaults", cause); + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigImpl.java b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigImpl.java new file mode 100644 index 0000000..b310c06 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigImpl.java @@ -0,0 +1,43 @@ +/** + * 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.Nullable; + +/** + * The JSON shape of {@value TimeConfig#TIME_FILE_NAME}. + * + *

The three values stay {@link String}s rather than enums on purpose. Gson maps an unknown enum + * constant to {@code null}, which is indistinguishable from an absent key — and an absent key is + * what keeps the default. Reading the raw text and resolving it in + * {@link TimeConfig#resolveDayTimeStrategy()} and {@link TimeConfig#resolveSeasonStrategy} is what + * lets a typo be reported as a typo. + * + * @param dayTimeStrategy the day time strategy name as written in the file + * @param seasonStrategy the season strategy name as written in the file + * @param fixedSeason the pinned season name as written in the file + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +record TimeConfigImpl(@Nullable String dayTimeStrategy, @Nullable String seasonStrategy, + @Nullable String fixedSeason) implements TimeConfig { + + /** The stage 2 defaults, written out when no file exists yet (US-2.06, US-2.11). */ + static final TimeConfigImpl DEFAULT = new TimeConfigImpl(TimeConfig.LINEAR, TimeConfig.METEOROLOGICAL, null); +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigProvider.java b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigProvider.java new file mode 100644 index 0000000..b0dc413 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/TimeConfigProvider.java @@ -0,0 +1,184 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.titan.common.time; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import net.onelitefeather.titan.common.time.season.SeasonBoundaryStrategy; +import net.theevilreaper.aves.file.ModernGsonFileHandler; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +import java.nio.file.Path; +import java.time.Clock; +import java.time.ZoneId; +import java.util.Optional; + +/** + * Loads {@value TimeConfig#TIME_FILE_NAME} and hands out the two services built from it. + * + *

The point of the two {@code create…} methods is that they are the only place the strategies + * are chosen. The application asks this provider for its services and never names a strategy, so + * "which mapping runs" really is a configuration question and not a code question (US-2.07, + * US-2.12, US-2.13). + * + *

Both values are resolved in the constructor rather than on first use. A misspelt strategy + * therefore stops the boot, at the point where an operator is still watching the log — not an hour + * later, and not silently never. + * + *

The {@link Clock} is not part of the configuration and never becomes part of it: it is passed + * into {@link #createWorldTimeService(Clock)} and {@link #createSeasonService(Clock)} by the + * caller, which is what keeps every mapping testable against a fixed instant (US-2.03). + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class TimeConfigProvider { + + private static final TypeToken TYPE = TypeToken.get(TimeConfigImpl.class); + + private final Path file; + private final ZoneId zone; + private final ModernGsonFileHandler fileHandler; + private final TimeConfig timeConfig; + private final DayTimeStrategy dayTimeStrategy; + private final SeasonBoundaryStrategy seasonStrategy; + + private TimeConfigProvider(Path path, ZoneId zone) { + this.file = path.resolve(TimeConfig.TIME_FILE_NAME); + this.zone = zone; + Gson gson = new Gson().newBuilder().setPrettyPrinting().create(); + this.fileHandler = new ModernGsonFileHandler(gson); + this.timeConfig = this.loadConfig(); + this.dayTimeStrategy = this.timeConfig.resolveDayTimeStrategy(); + this.seasonStrategy = this.timeConfig.resolveSeasonStrategy(zone); + } + + /** + * Creates a provider reading from the given directory, for the lobby's editorial zone. + * + * @param path the directory holding {@value TimeConfig#TIME_FILE_NAME} + * @return the provider, with both strategies already resolved + * @throws TimeConfigException if the file names a strategy or season this lobby does not know + */ + @Contract(value = "_ -> new") + @NotNull + public static TimeConfigProvider create(@NotNull Path path) { + return new TimeConfigProvider(path, TitanTime.EDITORIAL_ZONE); + } + + /** + * Creates a provider reading from the given directory, for the given zone. + * + * @param path the directory holding {@value TimeConfig#TIME_FILE_NAME} + * @param zone the zone the day time and the season boundaries are calculated against + * @return the provider, with both strategies already resolved + * @throws TimeConfigException if the file names a strategy or season this lobby does not know + */ + @Contract(value = "_, _ -> new") + @NotNull + public static TimeConfigProvider create(@NotNull Path path, @NotNull ZoneId zone) { + return new TimeConfigProvider(path, zone); + } + + /** + * Returns the loaded configuration, as written in the file. + * + * @return the time configuration + */ + @Contract(pure = true) + @NotNull + public TimeConfig getTimeConfig() { + return this.timeConfig; + } + + /** + * Returns the zone both services are built for. + * + * @return the zone + */ + @Contract(pure = true) + @NotNull + public ZoneId zone() { + return this.zone; + } + + /** + * Returns the configured mapping from real time to day time. + * + * @return the resolved day time strategy + */ + @Contract(pure = true) + @NotNull + public DayTimeStrategy dayTimeStrategy() { + return this.dayTimeStrategy; + } + + /** + * Returns the configured season boundaries. + * + * @return the resolved season boundary strategy + */ + @Contract(pure = true) + @NotNull + public SeasonBoundaryStrategy seasonStrategy() { + return this.seasonStrategy; + } + + /** + * Builds the world time service the configuration asks for. + * + * @param clock the clock the current instant is read from + * @return a service running the configured mapping in the configured zone + */ + @Contract(value = "_ -> new") + @NotNull + public WorldTimeService createWorldTimeService(@NotNull Clock clock) { + return WorldTimeService.create(clock, this.zone, this.dayTimeStrategy); + } + + /** + * Builds the season service the configuration asks for. + * + * @param clock the clock the current date is read from + * @return a service running the configured boundaries in the configured zone + */ + @Contract(value = "_ -> new") + @NotNull + public SeasonService createSeasonService(@NotNull Clock clock) { + return SeasonService.create(clock, this.zone, this.seasonStrategy); + } + + private TimeConfig loadConfig() { + Optional loaded; + try { + loaded = this.fileHandler.load(this.file, TYPE); + } catch (RuntimeException exception) { + // Unlike a missing file, a broken one is not a statement of intent. Falling back would + // run a strategy nobody chose, and a wrong season is invisible for weeks. + throw TimeConfigException.unreadable(this.file, exception); + } + if (loaded.isEmpty()) { + // No file yet: keep the stage 2 defaults and write them out, so the next operator to + // look sees both keys and their current values instead of an empty directory. + this.fileHandler.save(this.file, TimeConfigImpl.DEFAULT, TYPE); + return TimeConfig.defaultConfig(); + } + return loaded.get(); + } +} 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 0000000..0c27f33 --- /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/TitanTime.java b/common/src/main/java/net/onelitefeather/titan/common/time/TitanTime.java new file mode 100644 index 0000000..2fe4c60 --- /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/TitanWorldTimeService.java b/common/src/main/java/net/onelitefeather/titan/common/time/TitanWorldTimeService.java new file mode 100644 index 0000000..b2dff1e --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/TitanWorldTimeService.java @@ -0,0 +1,138 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..82f8abb --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/WorldTimeService.java @@ -0,0 +1,142 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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/main/java/net/onelitefeather/titan/common/time/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/time/package-info.java new file mode 100644 index 0000000..ca4758a --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/package-info.java @@ -0,0 +1,13 @@ +/** + * Real time to game time: the strategies that map a wall-clock instant onto a Minecraft day, the + * services that drive them from an injected {@link java.time.Clock}, and the editorial time zone + * they are told in. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +@NotNullByDefault +package net.onelitefeather.titan.common.time; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/common/src/main/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategy.java new file mode 100644 index 0000000..30fcd28 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategy.java @@ -0,0 +1,235 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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: + * + *

    + *
  • The polynomials are published for the years 1000 to 3000. Outside that range the result is + * an extrapolation and this class does not pretend otherwise. + *
  • The ΔT polynomial is fitted to 2005…2050. Past 2050 the correction degrades — it + * is a correction of about a minute on an event whose calendar day is what matters, so the + * season boundary survives long after the seconds stop being right. + *
  • An event falling within a minute of local midnight can land on either side of the day + * boundary. No season boundary the lobby cares about is that sharp; a seasonal package is + * switched by its configured window, not by this class. + *
+ * + * @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 0000000..f257c04 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategy.java @@ -0,0 +1,53 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..df6e364 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/MeteorologicalSeasonStrategy.java @@ -0,0 +1,74 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..729bbf9 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/Season.java @@ -0,0 +1,69 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..3d7b736 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/SeasonBoundaryStrategy.java @@ -0,0 +1,80 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..520ebfe --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/time/season/package-info.java @@ -0,0 +1,11 @@ +/** + * Which season a date falls into, and the interchangeable rules that decide it. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +@NotNullByDefault +package net.onelitefeather.titan.common.time.season; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/DayTimeStrategyComparisonTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/DayTimeStrategyComparisonTest.java new file mode 100644 index 0000000..f5bf8b7 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/DayTimeStrategyComparisonTest.java @@ -0,0 +1,183 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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/FixedInstants.java b/common/src/test/java/net/onelitefeather/titan/common/time/FixedInstants.java new file mode 100644 index 0000000..baac831 --- /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 0000000..554f048 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/LinearDayTimeStrategyTest.java @@ -0,0 +1,103 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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/SeasonServiceTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/SeasonServiceTest.java new file mode 100644 index 0000000..37c1613 --- /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/SolarDayTimeStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategyTest.java new file mode 100644 index 0000000..04fbe93 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/SolarDayTimeStrategyTest.java @@ -0,0 +1,215 @@ +/** + * 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. + * + *

Where the expected times come from

+ * + * Every expected time below is the value the US Naval Observatory publishes for 52.520008° N, + * 13.404954° E — rise, set and upper transit — converted from UT to Berlin local time. One source, + * quoted rather than fitted: an expectation copied off this implementation's own output would make + * the test agree with whatever the implementation does, which is the one thing it must not do. + * + *

USNO prints whole minutes, so each expectation carries up to 30 seconds of rounding of its + * own. The tolerances are set just above the largest residual actually measured against that + * source, not at a round number picked for comfort: + * + *

    + *
  • solar noon, {@link #NOON_TOLERANCE}: largest measured deviation 18 seconds. + *
  • sunrise and sunset, {@link #EVENT_TOLERANCE}: largest measured deviation 83 seconds, on the + * September sunset, which is where the equation's fixed J2000 perihelion argument costs the most + * half-day length. + *
+ * + *

The solar noon case is the sharp one. It is nearly free of the half-day-length error that + * dominates sunrise and sunset, so it pins the time scale directly: any constant offset bolted onto + * the mean solar time — the mistake this test exists to catch — moves it by the full amount and + * fails here long before the rise and set assertions notice. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +class SolarDayTimeStrategyTest { + + private static final Duration EVENT_TOLERANCE = Duration.ofSeconds(120); + + private static final Duration NOON_TOLERANCE = Duration.ofSeconds(45); + + private final SolarDayTimeStrategy strategy = SolarDayTimeStrategy.berlin(); + + @ParameterizedTest(name = "{0}: sunrise {1}, sunset {2} Berlin local") + @CsvSource({ + // USNO rise and set for Berlin, in local time; the two solstices and the two equinoxes + // of 2026. + "2026-03-20, 06:09, 18:19", "2026-06-21, 04:43, 21:33", "2026-09-23, 06:54, 19:03", "2026-12-21, 08:15, 15:54", + }) + @DisplayName("Sunrise and sunset match the published Berlin times") + void sunriseAndSunsetMatchPublishedBerlinTimes(LocalDate date, LocalTime sunrise, LocalTime sunset) { + Instant expectedSunrise = LocalDateTime.of(date, sunrise).atZone(BERLIN).toInstant(); + Instant expectedSunset = LocalDateTime.of(date, sunset).atZone(BERLIN).toInstant(); + + Instant actualSunrise = this.strategy.sunrise(date); + Instant actualSunset = this.strategy.sunset(date); + + assertNotNull(actualSunrise); + assertNotNull(actualSunset); + assertWithin(expectedSunrise, actualSunrise, EVENT_TOLERANCE, "sunrise on " + date); + assertWithin(expectedSunset, actualSunset, EVENT_TOLERANCE, "sunset on " + date); + } + + @ParameterizedTest(name = "{0}: solar noon {1} Berlin local") + @CsvSource({ + // USNO upper transit of the sun over Berlin, in local time. + "2026-03-20, 12:14", "2026-06-21, 13:08", "2026-09-23, 12:59", "2026-12-21, 12:04", + }) + @DisplayName("Solar noon matches the published Berlin upper transit to well under a minute") + void solarNoonMatchesThePublishedBerlinTransit(LocalDate date, LocalTime noon) { + Instant expected = LocalDateTime.of(date, noon).atZone(BERLIN).toInstant(); + + Instant sunrise = this.strategy.sunrise(date); + Instant sunset = this.strategy.sunset(date); + assertNotNull(sunrise); + assertNotNull(sunset); + // The mapping puts noon exactly halfway between the two events, so this is the transit the + // implementation actually believes in, not a separately computed one. + Instant actual = sunrise.plus(Duration.between(sunrise, sunset).dividedBy(2)); + + assertWithin(expected, actual, NOON_TOLERANCE, "solar noon on " + date); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({"2026-03-20", "2026-06-21", "2026-09-23", "2026-12-21", "2026-05-15"}) + @DisplayName("Sunrise is the first tick of the day and sunset the first tick of the night") + void theSolarEventsAnchorTheSegments(LocalDate date) { + Instant sunrise = this.strategy.sunrise(date); + Instant sunset = this.strategy.sunset(date); + assertNotNull(sunrise); + assertNotNull(sunset); + + assertEquals(0L, this.strategy.ticksAt(sunrise, BERLIN), "sunrise must be tick 0 on " + date); + assertEquals(DUSK_TICK, this.strategy.ticksAt(sunset, BERLIN), "sunset must be tick 12000 on " + date); + assertEquals(TICKS_PER_DAY - 1L, this.strategy.ticksAt(sunrise.minusMillis(1), BERLIN), "the millisecond before sunrise must still be the last tick of the night on " + date); + } + + @Test + @DisplayName("The longest and the shortest day of the year come out that way") + void theSolsticesAreTheLongestAndShortestDay() { + Duration june = daylight(LocalDate.of(2026, 6, 21)); + Duration december = daylight(LocalDate.of(2026, 12, 21)); + Duration equinox = daylight(LocalDate.of(2026, 3, 20)); + + assertTrue(june.toMinutes() > 16 * 60, "Berlin sees over 16 hours of daylight in June, got " + june); + assertTrue(december.toMinutes() < 8 * 60, "Berlin sees under 8 hours of daylight in December, got " + december); + assertTrue(Math.abs(equinox.toMinutes() - 12 * 60) < 20, "an equinox is within twenty minutes of twelve hours, got " + equinox); + } + + @Test + @DisplayName("Half the tick range is day and half is night, whatever the season") + void bothHalvesAlwaysGetHalfTheTickRange() { + for (LocalDate date : new LocalDate[]{LocalDate.of(2026, 6, 21), LocalDate.of(2026, 12, 21)}) { + Instant sunrise = this.strategy.sunrise(date); + Instant sunset = this.strategy.sunset(date); + assertNotNull(sunrise); + assertNotNull(sunset); + + Instant middleOfDay = sunrise.plus(Duration.between(sunrise, sunset).dividedBy(2)); + long ticks = this.strategy.ticksAt(middleOfDay, BERLIN); + + assertTrue(Math.abs(ticks - 6000L) <= 1, "the middle of the daylight span must be Minecraft noon on " + date + ", got " + ticks); + } + } + + @Test + @DisplayName("The mapping never runs backwards, not even across a daylight saving transition") + void theMappingIsStrictlyIncreasingInRealTime() { + // Both transitions plus the day in between, sampled every five minutes. + Instant cursor = LocalDateTime.of(2026, 10, 24, 0, 0).atZone(BERLIN).toInstant(); + Instant end = LocalDateTime.of(2026, 10, 26, 0, 0).atZone(BERLIN).toInstant(); + long previous = this.strategy.ticksAt(cursor, BERLIN); + int wraps = 0; + + while (cursor.isBefore(end)) { + cursor = cursor.plus(Duration.ofMinutes(5)); + long ticks = this.strategy.ticksAt(cursor, BERLIN); + if (ticks < previous) { + wraps++; + } + previous = ticks; + } + + assertEquals(2, wraps, "over two days the mapping passes the end of the day exactly twice"); + } + + @Test + @DisplayName("Above the polar circle the mapping falls back to the linear one instead of guessing") + void thePolarFallbackIsTheLinearMapping() { + // Longyearbyen: the sun neither rises nor sets around the June solstice. + SolarDayTimeStrategy svalbard = SolarDayTimeStrategy.at(78.22, 15.65); + Instant midsummer = Instant.parse("2026-06-21T12:00:00Z"); + + assertEquals(DayTimeStrategy.linear().ticksAt(midsummer, BERLIN), svalbard.ticksAt(midsummer, BERLIN)); + } + + @Test + @DisplayName("A position off the globe is rejected at construction, not silently accepted") + void impossiblePositionsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> SolarDayTimeStrategy.at(91.0, 0.0)); + assertThrows(IllegalArgumentException.class, () -> SolarDayTimeStrategy.at(0.0, 181.0)); + } + + @Test + @DisplayName("The Berlin strategy is stateless and shared") + void theBerlinStrategyIsShared() { + assertSame(SolarDayTimeStrategy.berlin(), DayTimeStrategy.solar()); + } + + private Duration daylight(LocalDate date) { + Instant sunrise = this.strategy.sunrise(date); + Instant sunset = this.strategy.sunset(date); + assertNotNull(sunrise); + assertNotNull(sunset); + return Duration.between(sunrise, sunset); + } + + private static void assertWithin(Instant expected, Instant actual, Duration tolerance, String what) { + Duration off = Duration.between(expected, actual).abs(); + assertTrue(off.compareTo(tolerance) <= 0, what + ": expected around " + expected + " but was " + actual + " (" + off.toSeconds() + "s off, tolerance " + tolerance.toSeconds() + "s)"); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/TimeConfigProviderTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/TimeConfigProviderTest.java new file mode 100644 index 0000000..655ec9f --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/TimeConfigProviderTest.java @@ -0,0 +1,237 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.titan.common.time; + +import net.onelitefeather.titan.common.time.season.Season; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import static net.onelitefeather.titan.common.time.FixedInstants.BERLIN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * That the configured strategy is the one that answers — not merely that the file was read. + * + *

Every test here goes through the two {@code create…} methods the application itself calls, and + * then asks the resulting service a question whose answer differs per strategy. Asserting the + * parsed string, or the class of {@code service.strategy()}, would pass just as happily if nobody + * ever wired the provider into the lobby; a season that comes back as winter in March only comes + * back that way if the astronomical boundaries really ran. + * + *

Two dates carry that weight: + * + *

    + *
  • 5 March 2026 — meteorological spring since the first of the month, astronomically still + * winter until the equinox on the 20th. + *
  • 21 December 2026, 07:30 in Berlin — the sun rises at about 08:15, so the solar mapping is + * still in the night half while the linear mapping is an hour and a half into its day. + *
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +class TimeConfigProviderTest { + + /** Meteorologically spring, astronomically still winter. */ + private static final LocalDate BEFORE_THE_MARCH_EQUINOX = LocalDate.of(2026, 3, 5); + + /** Summer under both sets of boundaries, so a fixed winter can only come from the pin. */ + private static final LocalDate MIDSUMMER = LocalDate.of(2026, 7, 15); + + /** Before sunrise in Berlin on the shortest day, but well after the linear daybreak. */ + private static final Instant DECEMBER_MORNING = LocalDateTime.of(FixedInstants.DECEMBER_SOLSTICE, LocalTime.of(7, 30)).atZone(BERLIN).toInstant(); + + /** What {@link LinearDayTimeStrategy} makes of 07:30: 90 minutes past daybreak. */ + private static final long LINEAR_TICKS_AT_HALF_PAST_SEVEN = 1_500L; + + @Test + @DisplayName("Without a file the lobby maps time linearly and reads meteorological seasons") + void defaultsToLinearAndMeteorological(@TempDir Path directory) { + TimeConfigProvider provider = TimeConfigProvider.create(directory, BERLIN); + + assertEquals(LINEAR_TICKS_AT_HALF_PAST_SEVEN, provider.createWorldTimeService(at(DECEMBER_MORNING)).currentTicks(), "the linear mapping is already in its day at 07:30"); + assertEquals(Season.SPRING, provider.createSeasonService(at(BEFORE_THE_MARCH_EQUINOX)).currentSeason(), "5 March is meteorological spring"); + } + + @Test + @DisplayName("Without a file the defaults are written out, keys and all") + void writesTheDefaultFile(@TempDir Path directory) throws IOException { + TimeConfigProvider.create(directory, BERLIN); + + Path file = directory.resolve(TimeConfig.TIME_FILE_NAME); + assertTrue(Files.exists(file), "the default file is written so an operator can see the keys"); + String written = Files.readString(file); + assertTrue(written.contains(TimeConfig.LINEAR), () -> "expected the linear default in " + written); + assertTrue(written.contains(TimeConfig.METEOROLOGICAL), () -> "expected the meteorological default in " + written); + } + + @Test + @DisplayName("An empty object is an absent choice and keeps both defaults") + void emptyObjectKeepsTheDefaults(@TempDir Path directory) throws IOException { + write(directory, "{}"); + + TimeConfigProvider provider = TimeConfigProvider.create(directory, BERLIN); + + assertEquals(LINEAR_TICKS_AT_HALF_PAST_SEVEN, provider.createWorldTimeService(at(DECEMBER_MORNING)).currentTicks()); + assertEquals(Season.SPRING, provider.createSeasonService(at(BEFORE_THE_MARCH_EQUINOX)).currentSeason()); + } + + @Test + @DisplayName("With the solar mapping configured the December morning is still night (US-2.07)") + void solarMappingDrivesTheDayTime(@TempDir Path directory) throws IOException { + write(directory, "{\"dayTimeStrategy\": \"solar\"}"); + + long ticks = TimeConfigProvider.create(directory, BERLIN).createWorldTimeService(at(DECEMBER_MORNING)).currentTicks(); + + assertTrue(ticks >= DayTimeStrategy.DUSK_TICK, () -> "the sun has not risen in Berlin at 07:30 on 21 December, but the world says tick " + ticks); + // The same instant under the default, so the difference is the configuration and not the date. + assertEquals(LINEAR_TICKS_AT_HALF_PAST_SEVEN, TimeConfigProvider.create(Files.createTempDirectory(directory, "linear"), BERLIN).createWorldTimeService(at(DECEMBER_MORNING)).currentTicks()); + } + + @Test + @DisplayName("With the astronomical boundaries configured 5 March is still winter (US-2.12)") + void astronomicalBoundariesDriveTheSeason(@TempDir Path directory) throws IOException { + write(directory, "{\"seasonStrategy\": \"astronomical\"}"); + + Season season = TimeConfigProvider.create(directory, BERLIN).createSeasonService(at(BEFORE_THE_MARCH_EQUINOX)).currentSeason(); + + assertEquals(Season.WINTER, season, "the March equinox is on the 20th, so the 5th is still winter"); + // The same date under the default, so the difference is the configuration and not the date. + assertEquals(Season.SPRING, TimeConfigProvider.create(Files.createTempDirectory(directory, "meteorological"), BERLIN).createSeasonService(at(BEFORE_THE_MARCH_EQUINOX)).currentSeason()); + } + + @Test + @DisplayName("With a season pinned the lobby answers winter in July (US-2.13)") + void fixedSeasonIgnoresTheCalendar(@TempDir Path directory) throws IOException { + write(directory, "{\"seasonStrategy\": \"fixed\", \"fixedSeason\": \"winter\"}"); + + Season season = TimeConfigProvider.create(directory, BERLIN).createSeasonService(at(MIDSUMMER)).currentSeason(); + + assertEquals(Season.WINTER, season, "a pinned season does not consult the date"); + // Both boundary rules would say summer here, so the pin is the only thing that can answer winter. + assertEquals(Season.SUMMER, TimeConfigProvider.create(Files.createTempDirectory(directory, "meteorological"), BERLIN).createSeasonService(at(MIDSUMMER)).currentSeason()); + } + + @Test + @DisplayName("A pinned season survives a strategy switch and is ignored while it is not asked for") + void fixedSeasonIsIgnoredByTheOtherStrategies(@TempDir Path directory) throws IOException { + write(directory, "{\"seasonStrategy\": \"meteorological\", \"fixedSeason\": \"winter\"}"); + + assertEquals(Season.SUMMER, TimeConfigProvider.create(directory, BERLIN).createSeasonService(at(MIDSUMMER)).currentSeason()); + } + + @Test + @DisplayName("Strategy names are read case- and space-insensitively") + void namesAreNormalized(@TempDir Path directory) throws IOException { + write(directory, "{\"dayTimeStrategy\": \" Solar \", \"seasonStrategy\": \"FIXED\", \"fixedSeason\": \"Winter\"}"); + + TimeConfigProvider provider = TimeConfigProvider.create(directory, BERLIN); + + assertTrue(provider.createWorldTimeService(at(DECEMBER_MORNING)).currentTicks() >= DayTimeStrategy.DUSK_TICK); + assertEquals(Season.WINTER, provider.createSeasonService(at(MIDSUMMER)).currentSeason()); + } + + @Test + @DisplayName("An unknown day time strategy stops the boot and names the value and the valid ones") + void unknownDayTimeStrategyFails(@TempDir Path directory) throws IOException { + write(directory, "{\"dayTimeStrategy\": \"astronomical\"}"); + + TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN)); + + assertMentions(exception, "dayTimeStrategy", "astronomical", TimeConfig.LINEAR, TimeConfig.SOLAR); + } + + @Test + @DisplayName("An unknown season strategy stops the boot and names the value and the valid ones") + void unknownSeasonStrategyFails(@TempDir Path directory) throws IOException { + write(directory, "{\"seasonStrategy\": \"solar\"}"); + + TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN)); + + assertMentions(exception, "seasonStrategy", "solar", TimeConfig.METEOROLOGICAL, TimeConfig.ASTRONOMICAL, TimeConfig.FIXED); + } + + @Test + @DisplayName("An unknown season name stops the boot and names the value and the four seasons") + void unknownFixedSeasonFails(@TempDir Path directory) throws IOException { + write(directory, "{\"seasonStrategy\": \"fixed\", \"fixedSeason\": \"wintre\"}"); + + TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN)); + + assertMentions(exception, "fixedSeason", "wintre", "spring", "summer", "autumn", "winter"); + } + + @Test + @DisplayName("A pinned season strategy without a season stops the boot") + void fixedWithoutSeasonFails(@TempDir Path directory) throws IOException { + write(directory, "{\"seasonStrategy\": \"fixed\"}"); + + TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN)); + + assertMentions(exception, "fixedSeason", TimeConfig.FIXED, "spring", "summer", "autumn", "winter"); + } + + @Test + @DisplayName("A blank value is a mistake, not an absent key") + void blankValueFails(@TempDir Path directory) throws IOException { + write(directory, "{\"seasonStrategy\": \" \"}"); + + assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN)); + } + + @Test + @DisplayName("A file that is not JSON stops the boot instead of running an unchosen strategy") + void malformedFileFails(@TempDir Path directory) throws IOException { + write(directory, "{\"seasonStrategy\": "); + + TimeConfigException exception = assertThrows(TimeConfigException.class, () -> TimeConfigProvider.create(directory, BERLIN)); + + assertMentions(exception, TimeConfig.TIME_FILE_NAME); + } + + private static void assertMentions(TimeConfigException exception, String... expected) { + String message = exception.getMessage(); + for (String fragment : expected) { + assertTrue(message.contains(fragment), () -> "expected \"" + fragment + "\" in: " + message); + } + } + + private static void write(Path directory, String json) throws IOException { + Files.writeString(directory.resolve(TimeConfig.TIME_FILE_NAME), json); + } + + private static Clock at(Instant instant) { + return Clock.fixed(instant, BERLIN); + } + + private static Clock at(LocalDate date) { + return at(date.atTime(LocalTime.NOON).atZone(BERLIN).toInstant()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceIntegrationTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceIntegrationTest.java new file mode 100644 index 0000000..e7f05e7 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/WorldTimeServiceIntegrationTest.java @@ -0,0 +1,205 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..e3789eb --- /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()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategyTest.java new file mode 100644 index 0000000..ac2b94f --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/season/AstronomicalSeasonStrategyTest.java @@ -0,0 +1,130 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..84f7ad6 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/time/season/FixedSeasonStrategyTest.java @@ -0,0 +1,64 @@ +/** + * Copyright (C) 2025 OneLiteFeather Network + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +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 0000000..cc67de1 --- /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 0000000..076ac45 --- /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()); + } +} diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md index d9cee2a..4cc32dd 100644 --- a/docs/spec-lobby-saison-events.md +++ b/docs/spec-lobby-saison-events.md @@ -155,20 +155,20 @@ 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.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 | -| US-2.06 | Als Betreiber möchte ich die lineare Abbildung als Standard, damit die Stufe ohne astronomische Berechnung nutzbar ist. | The Lobby shall ohne abweichende Konfiguration die lineare Abbildung verwenden. | `LinearDayTimeStrategy` | Must | offen | -| US-2.07 | Als Betreiber möchte ich später auf die astronomische Abbildung wechseln können, damit im Dezember spät hell wird. | Where die astronomische Strategie konfiguriert ist, shall die Lobby Sonnenauf- und -untergang für Berlin auf die Spielzeit abbilden. | `SolarDayTimeStrategy` | Could | offen | -| US-2.08 | Als Entwickler möchte ich beide Abbildungen gegen dieselben Testfälle prüfen, damit der Vergleich belastbar ist. | The Testsuite shall beide Strategien gegen denselben Satz fester Zeitpunkte prüfen. | Testfall je Strategie | Should | offen | -| US-2.09 | Als Spieler möchte ich, dass die Lobby die aktuelle Jahreszeit widerspiegelt, damit sie sich über das Jahr verändert. | The Lobby shall die aktuelle Jahreszeit aus dem Datum in `Europe/Berlin` ableiten und als Zustand bereitstellen. | `Season`-Enum | Must | offen | -| US-2.10 | Als Entwickler möchte ich die Jahreszeitgrenzen austauschen können, damit wir meteorologische und astronomische Grenzen vergleichen können. | The Jahreszeit-Ermittlung shall über eine austauschbare Strategie erfolgen. | `SeasonBoundaryStrategy` | Must | offen | -| US-2.11 | Als Betreiber möchte ich meteorologische Grenzen als Standard, weil sie auf feste Monatsanfänge fallen und keine Berechnung brauchen. | The Lobby shall ohne abweichende Konfiguration meteorologische Jahreszeitgrenzen verwenden (1.3., 1.6., 1.9., 1.12.). | `MeteorologicalSeasonStrategy` | Must | offen | -| US-2.12 | Als Betreiber möchte ich auf astronomische Grenzen wechseln können, damit die Jahreszeit zu den Sonnenwenden passt. | Where die astronomische Strategie konfiguriert ist, shall die Lobby die Jahreszeit anhand von Tagundnachtgleichen und Sonnenwenden bestimmen. | `AstronomicalSeasonStrategy` | Could | offen | -| US-2.13 | Als Betreiber möchte ich eine Jahreszeit fest vorgeben können, damit ein Event unabhängig vom Kalender laufen kann. | Where eine Jahreszeit fest konfiguriert ist, shall die Lobby diese verwenden und keine Strategie befragen. | `FixedSeasonStrategy` | Should | offen | -| US-2.14 | Als Betreiber möchte ich, dass die Zeitaktualisierung günstig ist, damit sie den Tick nicht belastet. | The Zeitsteuerung shall die Tageszeit höchstens einmal pro Sekunde aktualisieren. | Scheduler | Should | 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 | umgesetzt | +| 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 | umgesetzt | +| 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 | umgesetzt | +| 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 | umgesetzt | +| 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 | umgesetzt | +| US-2.06 | Als Betreiber möchte ich die lineare Abbildung als Standard, damit die Stufe ohne astronomische Berechnung nutzbar ist. | The Lobby shall ohne abweichende Konfiguration die lineare Abbildung verwenden. | `LinearDayTimeStrategy` | Must | umgesetzt | +| US-2.07 | Als Betreiber möchte ich später auf die astronomische Abbildung wechseln können, damit im Dezember spät hell wird. | Where die astronomische Strategie konfiguriert ist, shall die Lobby Sonnenauf- und -untergang für Berlin auf die Spielzeit abbilden. | `SolarDayTimeStrategy` | Could | umgesetzt | +| US-2.08 | Als Entwickler möchte ich beide Abbildungen gegen dieselben Testfälle prüfen, damit der Vergleich belastbar ist. | The Testsuite shall beide Strategien gegen denselben Satz fester Zeitpunkte prüfen. | Testfall je Strategie | Should | umgesetzt | +| US-2.09 | Als Spieler möchte ich, dass die Lobby die aktuelle Jahreszeit widerspiegelt, damit sie sich über das Jahr verändert. | The Lobby shall die aktuelle Jahreszeit aus dem Datum in `Europe/Berlin` ableiten und als Zustand bereitstellen. | `Season`-Enum | Must | umgesetzt | +| US-2.10 | Als Entwickler möchte ich die Jahreszeitgrenzen austauschen können, damit wir meteorologische und astronomische Grenzen vergleichen können. | The Jahreszeit-Ermittlung shall über eine austauschbare Strategie erfolgen. | `SeasonBoundaryStrategy` | Must | umgesetzt | +| US-2.11 | Als Betreiber möchte ich meteorologische Grenzen als Standard, weil sie auf feste Monatsanfänge fallen und keine Berechnung brauchen. | The Lobby shall ohne abweichende Konfiguration meteorologische Jahreszeitgrenzen verwenden (1.3., 1.6., 1.9., 1.12.). | `MeteorologicalSeasonStrategy` | Must | umgesetzt | +| US-2.12 | Als Betreiber möchte ich auf astronomische Grenzen wechseln können, damit die Jahreszeit zu den Sonnenwenden passt. | Where die astronomische Strategie konfiguriert ist, shall die Lobby die Jahreszeit anhand von Tagundnachtgleichen und Sonnenwenden bestimmen. | `AstronomicalSeasonStrategy` | Could | umgesetzt | +| US-2.13 | Als Betreiber möchte ich eine Jahreszeit fest vorgeben können, damit ein Event unabhängig vom Kalender laufen kann. | Where eine Jahreszeit fest konfiguriert ist, shall die Lobby diese verwenden und keine Strategie befragen. | `FixedSeasonStrategy` | Should | umgesetzt | +| US-2.14 | Als Betreiber möchte ich, dass die Zeitaktualisierung günstig ist, damit sie den Tick nicht belastet. | The Zeitsteuerung shall die Tageszeit höchstens einmal pro Sekunde aktualisieren. | Scheduler | Should | umgesetzt | ### Stufe 3 — Freigabe-Stufen @@ -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 { @@ -275,7 +275,7 @@ public interface DayTimeStrategy { | Ausprägung | Verhalten | Stufe | |---|---|---| | `LinearDayTimeStrategy` | 24 reale Stunden gleichmäßig auf 24 000 Ticks; 12:00 Uhr ergibt Mittag | **Standard**, Stufe 2 | -| `SolarDayTimeStrategy` | Sonnenauf- und -untergang für Berlin auf die Spielzeit gelegt; im Dezember spät hell | Could, später | +| `SolarDayTimeStrategy` | Sonnenauf- und -untergang für Berlin auf die Spielzeit gelegt; im Dezember spät hell | Could, über `time.json` wählbar | Die lineare Variante ist bewusst der Standard: Sie liefert den Nutzen fast vollständig und hat keinen Berechnungsfehler, den man übersehen könnte. Die @@ -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 { @@ -312,8 +312,8 @@ public interface SeasonBoundaryStrategy { | Ausprägung | Grenzen | Stufe | |---|---|---| | `MeteorologicalSeasonStrategy` | feste Monatsanfänge: 1.3., 1.6., 1.9., 1.12. | **Standard**, Stufe 2 | -| `AstronomicalSeasonStrategy` | Tagundnachtgleichen und Sonnenwenden (um den 20./21.) | Could, später | -| `FixedSeasonStrategy` | gibt immer dieselbe Jahreszeit zurück | Should — Tests, Vorschau, Events außerhalb des Kalenders | +| `AstronomicalSeasonStrategy` | Tagundnachtgleichen und Sonnenwenden (um den 20./21.) | Could, über `time.json` wählbar | +| `FixedSeasonStrategy` | gibt immer dieselbe Jahreszeit zurück | Should, über `time.json` wählbar — Tests, Vorschau, Events außerhalb des Kalenders | Meteorologisch ist der Standard, weil die Grenzen auf feste Kalendertage fallen und keine Berechnung brauchen. Der Unterschied zur astronomischen Variante @@ -323,6 +323,40 @@ ziehen. `FixedSeasonStrategy` ist nicht nur ein Testhilfsmittel: Sie ist der Weg, ein Winter-Event im August vorzuführen, ohne an der Systemuhr zu drehen. +### Welche Ausprägung läuft: `time.json` + +Die Strategie wählt nicht der Code, sondern die Konfiguration. Neben `app.json` +liegt `time.json`: + +```json +{ + "dayTimeStrategy": "linear", + "seasonStrategy": "meteorological" +} +``` + +| Schlüssel | Werte | Fehlt der Schlüssel | +|---|---|---| +| `dayTimeStrategy` | `linear`, `solar` | `linear` (US-2.06) | +| `seasonStrategy` | `meteorological`, `astronomical`, `fixed` | `meteorological` (US-2.11) | +| `fixedSeason` | `spring`, `summer`, `autumn`, `winter` | nur bei `seasonStrategy: fixed` nötig | + +Existiert die Datei nicht, schreibt die Lobby sie beim Start mit genau diesen +Standardwerten — so sieht der nächste Betreiber die Schlüssel, statt sie raten zu +müssen. Groß-/Kleinschreibung und Leerzeichen sind egal. + +**Ein unbekannter Wert bricht den Start ab.** Ein stiller Rückfall auf den +Standard wäre der schlimmere Fehler: Er sieht aus wie ein normaler Start und die +Lobby liefe bis zu einen Monat lang in der falschen Jahreszeit. Die Meldung nennt +den abgelehnten Wert und die zulässigen — `Unknown seasonStrategy "sommer" in +time.json; valid values are: meteorological, astronomical, fixed`. Dasselbe gilt +für eine Datei, die sich nicht als JSON lesen lässt. + +Die `Clock` steht ausdrücklich **nicht** in der Datei. Sie wird weiterhin von +`Titan` in `TimeConfigProvider#createWorldTimeService` bzw. +`#createSeasonService` hineingereicht (US-2.03); die Konfiguration entscheidet +nur, welche Strategie diese Uhr befragt. + ### Warum Strategy und nicht Konfigurationsschalter Ein `if (astronomisch) … else …` an der Abbildungsstelle hätte denselben Effekt