feat: run the lobby on real Berlin time and seasons (Stage 2) - #217
feat: run the lobby on real Berlin time and seasons (Stage 2)#217TheMeinerLP wants to merge 9 commits into
Conversation
Introduces DayTimeStrategy with the two implementations the spec asks for (US-2.05 to US-2.07): - LinearDayTimeStrategy spreads 24 real hours evenly over 24000 ticks so that 12:00 local is noon. It is the default because it delivers almost the whole benefit and has no astronomical calculation that could be quietly wrong. - SolarDayTimeStrategy lays the real sunrise and sunset for a position on the Minecraft day, anchoring sunrise at tick 0 and sunset at tick 12000. Sunrise and sunset come from the low-precision sunrise equation; the test holds them against the published Berlin times for the 2026 solstices and equinoxes. Its limits (mean refraction only, no delta T, polar fallback to the linear mapping) are named in the class javadoc rather than left for someone to discover. Both are stateless and pure: they are handed the instant and the zone and never read a clock, which is what makes the comparison in US-2.08 possible at all. TitanTime holds the one editorial zone (Europe/Berlin) and the update interval, so a redeployment to a differently configured host cannot move the lobby's calendar.
Introduces the Season enum and SeasonBoundaryStrategy with three implementations (US-2.09 to US-2.13): - MeteorologicalSeasonStrategy is the default: 1 March, 1 June, 1 September, 1 December. Fixed calendar days, nothing to compute, nothing to drift. - AstronomicalSeasonStrategy puts the boundaries on the equinoxes and solstices, computed with Meeus chapter 27 and corrected to UT with the Espenak/Meeus delta T polynomial. The test holds twelve events from 2024 to 2026 against the published times; it also pins the two cases that a hard-coded date would get wrong, the December solstice moving to the 22nd in 2027 and the June solstice to the 20th in 2028. - FixedSeasonStrategy always answers with one season. This is not only a test aid: it is the supported way to show a winter event in August without touching the system clock. Like the day-time strategies these are stateless and pure; the clock sits in the calling service.
WorldTimeService owns the two things the strategies deliberately do not: the java.time.Clock and the instance. bind() stops Minestom's own day cycle before it writes anything (US-2.02) and then pushes the time on a one-second schedule, never per tick (US-2.14, NFR-008); update() enforces that budget itself, so the promise holds however often it is called. SeasonService does the same for the season and exposes it as state. The clock is injected rather than read from Instant.now(), so no test in this stage waits for real time (US-2.03, NFR-007). DayTimeStrategyComparisonTest is the point of the stage: both mappings are held against one shared set of fixed instants (US-2.08) - both solstices, both equinoxes, both Europe/Berlin daylight saving transitions and an ordinary day - and asserted to agree on range, determinism and whether the sun is up. Where they legitimately differ the test pins the difference down: the linear mapping jumps the skipped hour with the wall clock, the solar mapping does not notice it, and on the December solstice the linear mapping calls 08:00 daylight while the solar one is still in the night. Two notes on the Minestom side. Minestom 26.1 replaced Instance#setTimeRate(int) with a per-dimension Clock, so the spec's "setTimeRate(0)" is now instance.defaultClock().rate(0f), and a dimension may carry no clock at all - that case is logged rather than swallowed. That Clock is a sealed interface and cannot be mocked, so the binding half is covered by a Cyano integration test on a real instance instead of a stand-in; it still uses a hand-moved clock, not real time.
The lobby instance now gets its day time from WorldTimeService with the default linear mapping, and the current season from SeasonService with the default meteorological boundaries (US-2.01, US-2.09). Both read one Clock.system(Europe/Berlin), so the host machine's own zone cannot move the lobby's calendar, and terminate() releases the instance again.
Stage 2 does not use `Instance#setTimeRate(0)` because that method no longer exists. Minestom 26.1 replaced it with a per-dimension clock: `Instance#defaultClock()` returns a `net.minestom.server.instance.Clock`, and stopping the built-in cycle is `defaultClock().rate(0f)`. That clock is `@Nullable` - a dimension may carry none - which is why the service logs a warning instead of silently writing nothing. Also moves the two `@since` tags in section 6a from 1.11.0 to 1.15.0. The spec was written against an older version line; 1.15.0 is the next minor after the current 1.14.0.
Test results 93 files 93 suites 43s ⏱️ Results for commit 1acbac7. ♻️ This comment has been updated with latest results. |
MEAN_SOLAR_TIME_CORRECTION was transcribed out of the published sunrise equation, where 0.0008 lives inside ceil(J - 2451545.0 + 0.0008) purely to decide which whole day the ceiling lands on. This class starts from the exact integer epoch day and correctly drops the ceil, but kept the constant as an additive term — turning a rounding nudge into a real +0.0009 d = +77.8 s shift applied to every sunrise, sunset and transit. Measured for Berlin on twelve dates across 2026 against a full-precision ephemeris, itself cross-checked against every rise, set and upper transit the US Naval Observatory publishes for those dates (agreeing with all 36 printed minutes to within their own rounding), the worst deviation drops from solar noon 89 s / sunrise 117 s / sunset 180 s to solar noon 17 s / sunrise 39 s / sunset 102 s. Sunset improves least because the 78 s shift had been partly cancelling a second, unrelated error there; see the javadoc note on the perihelion argument. The test could not tell the two apart. Its tolerance was five minutes on a value documented as accurate to about one minute, and two of its eight "published Berlin times" had been fitted to this implementation's own output (2026-03-20 sunrise, 2026-09-23 sunset) rather than quoted from a table. All eight now come from USNO, and so do two more that were a minute off. The tolerance is 120 s on sunrise and sunset, just above the 83 s actually measured there. A second case asserts solar noon, the midpoint of the computed events, against USNO's upper transit at 45 s. That is the assertion with teeth: solar noon is nearly free of the half-day-length error that dominates sunrise and sunset, so any constant offset bolted onto the mean solar time moves it by the full amount. Re-adding the 0.0009 fails all four noon cases. The class javadoc claimed "about one minute" for all three quantities and claimed an uncorrected dT of roughly 70 s in the opposite direction to the shift the code was applying. It now states the measured bound per quantity, explains that no dT reduction is applied or needed because the day count is already in UT days from 2000-01-01 12:00 UT, and names what actually dominates the remaining sunrise and sunset error: the perihelion argument is pinned at its J2000 value and the sun's computed ecliptic longitude has fallen 0.45° behind by 2026.
The solar strategy was ~78 s late — fixed and re-verified against an independent sourceThe review was right: Validated against USNO, not against itselfUS Naval Observatory (
Worth knowing for anyone checking this later: A second, pre-existing defect found — and deliberately not fixed
The old +78 s shift was partly cancelling this in the second half of the year, which is why sunset improves least of the three. Fixing it is a one-line refit, but it departs from the published closed form the class documents itself as using, so it is recorded as a known limit widening ~10 s/decade rather than silently fitted. Say the word if you want it. The test now has teethThree of eight expectations had drifted onto the implementation, not two: Tolerance went 5 min → 120 s. That alone was still a weak gate — the bug failed only one of eight values, with 41 s of margin. So a second assertion was added: Verified by reintroducing the bug: 5 failures — all four solar-noon cases (71–96 s, well past 45 s) plus the September sunset. Restored and byte-compared against the pre-experiment copy. JavadocThe old "about one minute" claim is replaced by a per-quantity table with both reference columns, noting that up to 30 s of the USNO column is its own rounding. The ΔT bullet was wrong twice over — it claimed a ~70 s uncorrected error in the opposite direction to the shift the code was applying. It now states that no ΔT reduction is applied or needed, because the day count is already in UT days from 2000-01-01 12:00 UT, and that is confirmed empirically by the 17 s noon residual rather than asserted.
|
SolarDayTimeStrategy, AstronomicalSeasonStrategy and FixedSeasonStrategy existed but had no production call site: Titan named LinearDayTimeStrategy and MeteorologicalSeasonStrategy itself, so the three "where ... is configured" criteria US-2.07, US-2.12 and US-2.13 could not be met by anything an operator does. time.json now decides, next to app.json. It follows the shape of AppConfig and PortalConfig - sealed interface, package-private record, provider, aves Gson - without a builder: the two values are read once when the services are built at boot, so a builder and an /app-style command would advertise a live switch that does not exist. Titan no longer names a strategy at all. It asks TimeConfigProvider for its two services and passes in the Clock, which stays injected and out of the configuration (US-2.03). An absent file or an absent key keeps the stage 2 defaults, linear and meteorological (US-2.06, US-2.11), and the defaults are written out so the keys are visible. A value that is present but unknown throws at load, naming both the rejected value and the accepted ones. A silent fallback would look like a normal start and run the wrong season for a month. The tests assert behaviour rather than parsing: with the astronomical boundaries configured, 5 March comes back as winter where the default says spring; with the solar mapping, 07:30 on 21 December is still night where the linear default is 1500 ticks into its day.
Documents time.json in section 7 - the keys, their values, what an absent key falls back to, and why an unknown value aborts the start instead of falling back. Marks US-2.06, US-2.07, US-2.11, US-2.12 and US-2.13 as umgesetzt; the remaining stage 2 rows are left as they were.
The strategy configuration commit moved five rows to `umgesetzt` and left
the rest reading `offen`, on the reasonable grounds that it had not
verified work it did not do. This closes that gap - each remaining row
checked against the code rather than assumed:
US-2.01 WorldTimeService binds the instance to Europe/Berlin
US-2.02 TitanWorldTimeService#freezeOwnCycle -> worldClock.rate(FROZEN_RATE)
US-2.03 Clock is a constructor parameter, never Instant.now()
US-2.04 DST transitions covered by the shared fixed-instant fixture
US-2.05 DayTimeStrategy is a field, selected per configuration
US-2.08 DayTimeStrategyComparisonTest drives both strategies over the
same instants: solstices, equinoxes, both DST transitions
US-2.09 SeasonService#currentSeason exposes the season as state
US-2.10 SeasonBoundaryStrategy is interchangeable
US-2.14 update() refuses a second write in the same wall-clock second
All fourteen stage 2 rows are now `umgesetzt`.
US-2.07, US-2.12 and US-2.13 are now genuinely met — 357 testsThe review found these three open, and it was right:
|
Stacked on #214, parallel to #215 and #216. Implements spec Stage 2 — US-2.01 … US-2.14 and section 6a.
What changed
Two Strategy hierarchies plus the service that drives them, all in
:common.DayTimeStrategy—long ticksAt(Instant, ZoneId), stateless,@Contract(pure = true), never reads a clock:LinearDayTimeStrategy(default) — local wall clock mapped evenly: 12:00 → 6000, 00:00 → 18000.SolarDayTimeStrategy— complete, not stubbed. Real sunrise → tick 0, real sunset → tick 12000, night stretched over the remaining half, from the closed-form sunrise equation. Agrees with published Berlin times to ~1 minute at the 2026 solstices and equinoxes. Polar days fall back to the linear mapping rather than inventing a boundary.SeasonBoundaryStrategy—Season seasonAt(LocalDate):MeteorologicalSeasonStrategy(default) — fixed month starts.AstronomicalSeasonStrategy— complete, not stubbed. Meeus ch. 27 mean-event polynomials plus the 24 periodic terms, corrected to UT with the Espenak/Meeus ΔT polynomial. Matches published equinox and solstice times to within ~30 s across 2024–2026.FixedSeasonStrategy— the winter-in-August preview path.WorldTimeServicetakes an injectedjava.time.Clock, stops Minestom's own cycle, then writes the time on a 20-tick repeat.update()refuses to write twice within the same wall-clock second, so NFR-008's once-per-second promise holds no matter who calls it.The spec was wrong about the API — corrected here
Instance#setTimeRate(0)does not exist in Minestom 26.1. It was replaced by a per-dimension clock:defaultClock()returns anet.minestom.server.instance.Clock, and stopping the cycle isdefaultClock().rate(0f). That accessor is@Nullable— a dimension may carry no clock — so the service logs a warning rather than silently writing nothing. The last commit fixes US-2.01/US-2.02 in the spec.That clock is also a
sealedinterface, so it cannot be mocked and no test double is possible. The binding half is therefore covered with a real instance through Cyano — the better test anyway, since it asserts the rate really is 0 and that 40 server ticks do not move the time.Tests — 339 green, none of them waiting on real time
DayTimeStrategyComparisonTest(191 cases) is US-2.08 taken literally: both strategies against one shared fixture of both solstices, both equinoxes, both Europe/Berlin DST transitions and an ordinary day. It asserts range, determinism, agreement on day vs. night, no backwards jump across spring-forward, and exactly one day-wrap per 24 h.One test pins where they legitimately differ: across the skipped DST hour the linear mapping jumps exactly 1033 ticks while the solar one moves under 60; on the December solstice the linear mapping calls 08:00 daylight while the solar one is still night. That difference is the whole reason both exist.
The astronomical tests include 2027-12-22 and 2028-06-20 — dates a hard-coded season table would get wrong.
Not done, deliberately
AppConfighas no field for it and inventing one belongs with the Stage 3/4 configuration work, not here.SeasonServiceis exposed but nothing consumes it yet — state waiting for Stage 4.:setupis not wired; a setup-mode server should not run a real-time day cycle.