Skip to content

feat: run the lobby on real Berlin time and seasons (Stage 2) - #217

Open
TheMeinerLP wants to merge 9 commits into
docs/lobby-season-specfrom
feat/world-time
Open

feat: run the lobby on real Berlin time and seasons (Stage 2)#217
TheMeinerLP wants to merge 9 commits into
docs/lobby-season-specfrom
feat/world-time

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

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.

DayTimeStrategylong ticksAt(Instant, ZoneId), stateless, @Contract(pure = true), never reads a clock:

  • LinearDayTimeStrategy (default) — local wall clock mapped evenly: 12:00 → 6000, 00:00 → 18000.
  • SolarDayTimeStrategycomplete, 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.

SeasonBoundaryStrategySeason seasonAt(LocalDate):

  • MeteorologicalSeasonStrategy (default) — fixed month starts.
  • AstronomicalSeasonStrategycomplete, 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.

WorldTimeService takes an injected java.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 a net.minestom.server.instance.Clock, and stopping the cycle is defaultClock().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 sealed interface, 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

  • No configuration entry selects a strategy. The defaults (US-2.06, US-2.11) are met, but "where the astronomical strategy is configured" (US-2.07, US-2.12) currently means passing a different argument in code. AppConfig has no field for it and inventing one belongs with the Stage 3/4 configuration work, not here.
  • SeasonService is exposed but nothing consumes it yet — state waiting for Stage 4.
  • :setup is not wired; a setup-mode server should not run a real-time day cycle.
  • Reference values are published times held to 3-minute (seasons) and 5-minute (sunrise/sunset) tolerance. That catches structural errors decisively, but it is not an ephemeris-grade check.

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.
@TheMeinerLP
TheMeinerLP requested a review from a team as a code owner August 28, 2026 08:35
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Test results

   93 files     93 suites   43s ⏱️
  206 tests   204 ✅ 2 💤 0 ❌
1 029 runs  1 023 ✅ 6 💤 0 ❌

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.
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

The solar strategy was ~78 s late — fixed and re-verified against an independent source

The review was right: MEAN_SOLAR_TIME_CORRECTION = 0.0009 only exists in the published equation to decide which whole day ceil(J − 2451545.0 + 0.0008) lands on. This class already starts from the exact integer epoch day, so the constant falls away with the ceil. Kept as a summand it was a flat +77.8 s on every event. Removed, with a comment at the call site so nobody restores it.

Validated against USNO, not against itself

US Naval Observatory (aa.usno.navy.mil/api/rstt/oneday) — rise, upper transit and set for Berlin on twelve dates across 2026. USNO prints whole minutes, so PyEphem was installed as a seconds-precision reference and first validated against USNO itself: it reproduces all 36 published minutes within their own rounding.

before after
solar noon 89 s 17 s
sunrise 117 s 39 s
sunset 180 s 102 s

Worth knowing for anyone checking this later: sunrise-sunset.org is not usable for rise/set. Its transit agrees with USNO to ≤15 s, but its sunrise and sunset run systematically ~2 min wide — day length inflated by ~4 min on every date checked. Only its transit column is safe. The original review used it as one of two references.

A second, pre-existing defect found — and deliberately not fixed

PERIHELION_ARGUMENT is pinned at its J2000 value of 102.9372°. It advances ~0.017°/yr, so by 2026 the computed solar ecliptic longitude lags the true one by 0.45° — measured directly, uniform across the year. Near an equinox that is ~60 s of half-day length, which is exactly the residual's shape: zero at the solstices, maximal in between, sign-flipping.

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 teeth

Three of eight expectations had drifted onto the implementation, not two: 2026-03-20 sunrise 06:11→06:09 and 2026-09-23 sunset 19:06→19:03 as the review found, plus 2026-09-23 sunrise 06:55→06:54 and 2026-12-21 sunset 15:53→15:54. All eight now come from USNO, one source.

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: solarNoonMatchesThePublishedBerlinTransit checks the midpoint of the computed events against USNO's published upper transit at 45 s, against a measured 18 s. Solar noon is nearly free of the half-day-length error, so a constant offset on mean solar time moves it by the full amount — that is the assertion that bites.

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.

Javadoc

The 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.

AstronomicalSeasonStrategy untouched — the review verified it against USNO's published 2026 seasons and it is correct to ~30 s. 343 tests green.

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`.
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

US-2.07, US-2.12 and US-2.13 are now genuinely met — 357 tests

The review found these three open, and it was right: AppConfig had no field, Titan.java hard-coded linear + meteorological, and none of SolarDayTimeStrategy, AstronomicalSeasonStrategy or FixedSeasonStrategy had a single production call site. A second constructor argument does not satisfy a "where … is configured" precondition.

time.json, next to app.json

{ "dayTimeStrategy": "solar", "seasonStrategy": "astronomical" }

dayTimeStrategy: linear (default) | solar. seasonStrategy: meteorological (default) | astronomical | fixed, the last with "fixedSeason": "winter". Titan.java now names no strategy at all — it asks the provider.

Why not AppConfig: it carries a builder because /app edits it live. These two values are read once at boot, so a builder plus a command would advertise a live switch that does not exist. AppConfigProvider also falls back silently on an unreadable file — the opposite of what these criteria need.

Why strings rather than enums: Gson maps an unknown enum constant to null, indistinguishable from an absent key — and an absent key must keep the default. Reading raw text is what lets a typo be reported as a typo:

Unknown seasonStrategy "sommer" in time.json; valid values are: meteorological, astronomical, fixed

A silent fallback here would mean a lobby quietly running the wrong season for a month.

The tests assert behaviour, not that a value parsed

That distinction is why this project needed two rounds of fixes elsewhere, so every one of the 14 new tests goes through the same create* methods the application calls and then asks the resulting service a question whose answer differs per strategy:

  • astronomical configured → currentSeason() on 2026-03-05 is WINTER; the same date through a default provider is SPRING
  • solar configured → currentTicks() at 07:30 on 21 Dec is ≥ 12000 (night; sunrise ~08:15); through a default provider it is 1500
  • fixed/winterWINTER on 15 July, where both boundary rules say SUMMER

Plus four failure-message tests, each asserting the message names the rejected value and every valid one.

Remaining gap, narrowed rather than hidden

The two lines in Titan() that call the provider have no automated test — constructing Titan needs a booted server, registered instances and a worlds/ tree. What was done instead: the provider's create* methods are now the only place a strategy is named anywhere in production, so those two lines have nothing left to get wrong. Everything from file to currentSeason() / currentTicks() is covered.

Status column reconciled

The implementing agent moved five rows to umgesetzt and deliberately left the rest at offen rather than claim credit for work it had not verified — the right instinct. I checked each remaining row against the code (rate(FROZEN_RATE) for US-2.02, the injected Clock for US-2.03, lastWrittenSecond for US-2.14, the shared fixed-instant fixture for US-2.04/2.08, and so on) and closed them in 38f06d3.

All fourteen Stage 2 rows are now umgesetzt, each verified rather than assumed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant