Skip to content

Moonwatch Technical Reference

Mahtra edited this page Jul 28, 2026 · 1 revision

Moonwatch Technical Reference: Celestial Model and Methodology

Version: 4.5.0 Audience: maintainers, calibrators, and anyone porting the celestial math. Scope: this document explains how moonwatch predicts moon and sun events, why the model is shaped the way it is, how the constants were derived, and how the model is validated against observed data. For installation and day to day usage, see the user guide; for the full version history and the retired Firebase design, see the Design History.


1. Overview

Moonwatch predicts the rise and set times of the three DragonRealms moons (Katamba, Xibar, Yavash) and the sun, plus the full Elanthian calendar (year, month, day, anlas, rois, season, named time of day).

The design has one governing principle: predict everything from closed form math anchored to a known epoch, then self correct from observed events. There is no network dependency, no shared database, and no cold start penalty. A fresh launch is immediately accurate to within the model error, and every observed rise or set tightens the prediction further.

The codebase is organized by single responsibility:

Component Responsibility
DRTime Elanthian calendar math and sun position
Moons Moon position math (Bresenham tick prediction), lunar phase, observe-phrase map
MoonwatchInstance Game-instance awareness (Prime/Platinum/Fallen/Test)
MoonwatchMessaging Centralized user-facing output (info/debug/error)
MoonwatchAlias The self-aware, self-updating moon alias
MoonwatchOffsetManager Per body offset persistence and self correction
MoonwatchLogger CSV event logging for calibration
ServerResetTracker Shutdown and restart detection, phase shift logging
MoonwatchUI UserVars population, freshness flag, and the status window
Main loop Event dispatch and orchestration

2. The Elanthian time system

All math is built on the fixed unit relationships of the DR calendar:

Unit Definition Real seconds
rois (minute) base unit 60
anlas (hour) 30 rois 1,800
day 12 anlas 21,600
month 40 days 864,000
year 10 months (400 days) 8,640,000

So one real second equals one DR second, one real minute equals one rois, and one real day equals four DR days. A full DR year passes every 100 real days.

The 12 anlas, 10 months, and 7 year cycle names are fixed lookup arrays in DRTime. They carry no predictive logic; they are pure naming.

2.1 Calendar epoch

The calendar is anchored to a single Unix timestamp:

CALENDAR_EPOCH  = 1_688_607_948   # the instant DR year 446 began, in server_time
CALIBRATION_YEAR = 446

XMLData.server_time is the game server timestamp from the prompt XML tag. It is not local wall clock time, and every connected client receives the same value, which is what makes the model portable across players.

Given a server_time t, the calendar is pure integer arithmetic:

elapsed        = t + offset - CALENDAR_EPOCH
day_of_year    = (elapsed % 8_640_000) / 21_600     # 0..399
seconds_in_day = elapsed % 21_600                   # 0..21599
anlas          = seconds_in_day / 1_800             # 0..11
rois           = (seconds_in_day % 1_800) / 60      # 0..29

The epoch was calibrated in three passes. The first (2024-12) used a single in game TIME reading and was imprecise. The second (2025-02) shifted the epoch by -380s based on early sun drift and actually made the calendar worse. The third and current value (2026-03) was fixed by aligning 82 observed sun events to exact rois boundaries and cross checking against two positive in game TIME readings. Under the current epoch the calendar is accurate to plus or minus one rois.


3. Moon model

3.1 The physical picture

Each moon follows a fixed cycle: it rises, stays visible for a fixed duration, sets, stays hidden for the rest of the cycle, then rises again. Unlike the sun, the moon cycles show no seasonal variation. A year of data shows cycle length varying by only one to two seconds across all seasons, which is within measurement noise. This means each moon is fully described by three constants.

CONSTANTS = {
  'katamba' => { epoch: 1_771_558_850, cycle: 21_088.611, visible: 10_602 },
  'xibar'   => { epoch: 1_771_560_044, cycle: 20_848.143, visible: 10_482 },
  'yavash'  => { epoch: 1_771_555_118, cycle: 21_129.564, visible: 10_624 }
}
  • epoch is the server_time of a known rise (the phase anchor, the OLS intercept).
  • cycle is the full rise to rise period in seconds. As of v4.2 this is the OLS fractional period (e.g. 21088.611), not the rounded integer used before.
  • visible is the rise to set duration in seconds.

Hidden duration is cycle - visible and is not stored.

Using the fractional period matters. A rounded integer cycle differs from the true period by up to half a second, which accumulates as phase drift: the old integer katamba cycle of 21089 drifted +0.39s per cycle, reaching about 85s in six weeks and forcing periodic epoch re-centering. The fractional period removes that drift, so per-character offsets stay near zero indefinitely (verified by replaying ~850 real events per moon from a zero offset: final offset stayed within about 20s with no upward trend). The Bresenham step below floors n because cycle is a Float.

3.2 Server tick quantization and the Bresenham insight

The DragonRealms server is a 1990s integer arithmetic codebase. It does not fire moon events at the exact mathematical instant; it fires them on 60 second server tick boundaries. Even with second precision timestamps, every observed interval is a multiple of 60. Katamba never rises 21,089 seconds after its last rise; it rises after either 21,060 seconds or 21,120 seconds, because the true period of 21,088.6 must be quantized to the nearest tick.

This produces a deterministic pattern. If the true period is P, then each cycle is either floor(P / 60) ticks or ceil(P / 60) ticks long, and the long cycles appear with frequency (P mod 60) / 60. This is exactly the error diffusion pattern of Bresenham line drawing: the fractional remainder accumulates and periodically tips an extra tick.

The pre 4.0 model used a single average period, which was always wrong by up to 42 seconds and produced a plus or minus 30 second sawtooth in the offset. The 4.0 model instead computes the exact tick boundary the game will fire on:

def self.nearest_tick(true_time)
  ((true_time + 30) / 60).floor * 60   # floor: true_time is fractional
end

def self.calculate_position(moon, game_time, offset = 0)
  c        = CONSTANTS.fetch(moon)
  e_eff    = c[:epoch] - offset
  elapsed  = game_time - e_eff
  n        = (elapsed / c[:cycle]).floor   # floor: cycle is a Float
  position = elapsed % c[:cycle]

  if position < c[:visible]            # currently up
    tick_set = nearest_tick(e_eff + n * c[:cycle] + c[:visible])
    { visible: true,  seconds: [tick_set - game_time, 0].max, event: 'set' }
  else                                 # currently down
    tick_rise = nearest_tick(e_eff + (n + 1) * c[:cycle])
    { visible: false, seconds: [tick_rise - game_time, 0].max, event: 'rise' }
  end
end

Visibility is decided by raw modular position; the countdown is the distance to the quantized tick. This is what makes moonwatch land on the exact second the game will fire, not an average that is perpetually a little wrong.

3.3 Constant derivation

The period (cycle) is the OLS slope of rise time against gap-corrected cycle index, and epoch is the OLS intercept (a real rise time). The period is validated two independent ways:

  1. The OLS slope and the raw interval mean must agree.
  2. The observed long/short tick split must match (P mod 60) / 60.

The v4.2 values were fit from about 1,900 rise events over ~1.2 DR years across two characters. Because cycle is the true fractional period and epoch is the OLS intercept, a freshly launched script starts with the offset near zero and the phase does not drift, so the periodic epoch re-centering that integer cycles required (v3.4 through v4.0.1) is no longer needed.

3.4 Validation (2026-06 dataset)

The model was re validated against an independent 5,029 event dataset spanning roughly 119 real days (1.19 DR years) across two characters. The data was taken entirely after the 2026-05-20 re centering, so it is a true out of sample test.

Raw interval means versus the shipped constants:

Moon cycle const cycle observed visible const visible observed
katamba 21089 21089.38 10602 10603.29
xibar 20848 20848.63 10482 10482.38
yavash 21130 21130.00 10624 10623.25

Every integer constant is correct to within about one second by raw interval mean. The Bresenham split ratio prediction matches the observed long tick fraction within 2.5 percent on all six values, for example yavash cycle predicts 16.7 percent long ticks and observes 16.7 percent.

v4.2 update. The table above validates the integer constants by raw interval mean. v4.2 ships the OLS slope instead (katamba 21088.611, xibar 20848.143, yavash 21129.564). The OLS slope is the more reliable period estimate: it runs about 0.4 to 0.8s below the raw interval mean because the raw mean is slightly inflated by gap handling and the outlier filter, while the long-baseline slope cancels that. The integer constants were close enough that the difference only showed up as the slow drift section 3.3 describes; the fractional period removes it. Replaying ~850 real events per moon from a zero offset with the OLS constants keeps the offset within about 20s with no trend, confirming it is drift free.

3.5 External cross-validation against the DR client (2026-07)

The moon period constants were independently confirmed against the DragonRealms client's own moon code (the "Saga" client), which hard-codes each moon's sidereal (orbital) period in roisaen: katamba 14847, xibar 9983, yavash 16171. Our cycle is the synodic (rise-to-rise) period, and the two are related by the 360-roisaen day beat:

cycle_seconds = 60 * 360 * P / (P + 360)     # P = sidereal period in roisaen

Evaluated on the client's integers this gives 21088.66 / 20848.19 / 21129.61 s, matching our shipped OLS cycle constants (21088.611 / 20848.143 / 21129.564) to about 0.046 s on all three moons. Two fully independent derivations (our OLS fit from ~1900 observed rises; the developers' hard-coded integers) agreeing to 46 milliseconds is strong external validation.

Inverting the relation (P_implied = 360 * cycle / (21600 - cycle)) puts our OLS-implied sidereal period within ~1.6 roisaen of the developers' integer, while the raw interval mean (which v4.2 deliberately did NOT ship, section 4.2) lands 13-21 roisaen off. So the client's constants independently vindicate the v4.2 decision to ship the OLS slope over the raw mean.

Visible durations cross-validate too. The client marks a moon "up" when its combined orbital+daily sky angle is <= 180 over integer degrees; because that range spans 181 of 360 integer degrees, the visible fraction is 181/360 of the cycle, i.e. cycle * 181/360 = 10602.9 / 10482.0 / 10623.5 s. That matches our observed visible constants (10602 / 10482 / 10624) to under a second. (A flat "half the cycle" or a flat 180-roisaen window would be off by ~1 to 5 minutes; the client uses neither.)

The client is pure open-loop math off a fixed epoch and never reads the game's rise/set broadcasts back (it parses and stores them but the display ignores them). So it gets durations and periods right but cannot correct an absolute tick-phase offset. Our closed-loop self-correction (section 5.1) is the genuine advantage: it matches the client when the client is right and self-heals when the client's fixed epoch would drift (resets, epoch error).

3.6 Lunar phase (v4.3)

v4.3 adds each moon's lunar phase (new, waxing crescent, first quarter, waxing gibbous, full, waning gibbous, third quarter, waning crescent). This is ported verbatim from the DR client's own phase computation:

orbital_angle = ((unix_minute % P_sidereal) * 360) / P_sidereal
day_of_year   = ((unix_minute + 80_895) / 360) % 400          # 80895 = 4_853_700 / 60
phase_angle   = (orbital_angle + (day_of_year * 360) / 400) % 360
index         = ((phase_angle * 8) / 360) % 8                 # 0 = new .. 7 = waning crescent

Three things to know:

  • It uses the client's own sidereal periods and calendar skew, NOT our synodic cycle or CALENDAR_EPOCH. The client's day_of_year runs 1-2 days off our calibrated calendar (our CALENDAR_EPOCH and the client's skew differ by ~1 DR day plus 48 s), so the phase math is kept self-contained in Moons.phase to reproduce the client's output. Do NOT feed date[:day_of_year] into it.
  • It is model-only and NOT self-corrected. Unlike rise/set there is no passive phase broadcast, so it cannot flow through the offset manager. The risk that would normally carry (a fixed-epoch model with no correction) is mitigated by validation, below.
  • It is validated, not blind. DR exposes phase via the observe <moon> verb ("The black moon Katamba is a growing crescent of light" = waxing crescent). Two live observations (Katamba and Yavash) both matched the computed phase, and Elanthipedia confirms each moon cycles through exactly eight phases. The observe line is parsed and, when ;moonwatch log is on, logged to moonphase_events_<char>.csv as observed-vs-computed, so the phrase-to-phase mapping and any needed correction can be derived from real data over time.

The phase angle advances at (360/P + 360/400 per day), giving a synthetic lunar month of roughly 9.35 real days per moon, distinct from the ~5.86 h rise/set synodic cycle. A phase bucket (45 degrees) therefore lasts roughly one real day: measured dwell is ~28 h for katamba (9.2-day full cycle), ~19 h for xibar (6.4 days), and ~30 h for yavash (10.0 days), with occasional half-length buckets clipped by the day-of-year term.

3.7 Auto-observe at rise (v4.4)

Phase has no passive broadcast, so to collect ground truth without manual effort v4.4 auto-observes each moon when it rises, but only while ;moonwatch log is enabled. On a MOON_RISE_PATTERN the main loop issues put "observe <moon>"; the reply is caught by MOON_OBSERVE_PATTERN and logged like any manual observe.

Two properties make rise the right trigger:

  • It self-gates to observable moments. The "slowly rises" atmospheric only reaches a client that is outdoors under open sky, which is exactly the condition observe needs, so the auto-observe almost always returns a phase line.
  • It samples densely enough. A moon rises about every 5.86 h (~4x/day) while its phase changes only about once a day, so every phase bucket gets 3-5 samples and all eight phases of a moon are captured within one full cycle (about a week). The logger's 60 s dedup prevents a manual observe and the auto observe from double-logging the same event.

Caveats, accepted by design: observe carries roundtime (20 s, observed) and trains skills (Astrology/Perception/Scholarship, observed), and it fires on every rise with no busy/combat gating (the chosen behavior), so it is deliberately kept behind the log flag rather than always on. If it is ever made always-on, add roundtime gating first.

3.8 Observe wordings and the phase-name map (v4.4.2)

DR phrases each phase with a different sentence, and only some use "... is ...". The v4.4.0 parser matched only ^The <colour> moon <M> is <phase>., so it captured just the waxing-crescent form and silently dropped the other seven. This was found by reading the game logs: auto-observe was firing correctly (the log shows observe <moon> at each rise and a phase line returned), but e.g. a full moon returns "The moon Katamba forms a perfect circle in the heavens" -- no colour, verb "forms", no "is" -- which the pattern rejected.

v4.4.2 rewrote capture to scope on the You scan the skies line and match any ^The ... moon <M> <clause>. line, so every phase phrasing is captured. Clause -> phase mapping is Moons::OBSERVED_PHASE_MAP. Mapping and validation were done by mining the character log archive (2022-2026) and cross-checking each logged wording against the computed phase at its timestamp:

DR observe wording (clause after "moon ") phase validation
"is a growing crescent of light" waxing crescent model match 4/4 timestamped
"has nearly turned its full face upon Elanthia" waxing gibbous model match 7/7 timestamped
"forms a perfect circle in the heavens" full model match 6/6 timestamped
"has waned to a narrow crescent of light" waning crescent wording unambiguous (1 sample, untimestamped)

Zero mismatches across all timestamped samples, which is strong external validation of the phase model at three distinct phases (plus a fourth by wording).

Still unmapped: new, first quarter, waning gibbous, third quarter. The log archive did not contain their wordings (Quilsilgas has only ~93 observes on record and never during those phases), so they must be captured live. Non-phase observe outputs also exist ("looks down from above", "turns up fruitless" -- low-skill or failed sightings that reveal no phase); the parser logs any unmapped clause raw so these and the four missing phases can be classified and added to the map from moonphase_events_<char>.csv. Because the phase model has no drift (period is validated to <1 s), each newly observed phase that matches further confirms the whole model.


4. Sun model

The sun is harder than the moons because its rise and set times vary seasonally, and the underlying game function is not a clean closed form.

4.1 The shipped model: empirical table, cosine fallback

As of v4.1 the sun is read from two empirical lookup tables keyed by day of year (SUN_RISE_ROIS_TABLE, SUN_SET_ROIS_TABLE), each holding the modal observed rois for that day. Rise and set are stored independently (see Finding 2 below). The seasonal cosine is retained only as a fallback for any day not covered by the tables (they span all 400 days, so it is a safety net):

def self.sun_times_for_day(day_of_year)
  in_range  = day_of_year.is_a?(Integer) && day_of_year >= 0 && day_of_year < 400
  rise_rois = in_range ? SUN_RISE_ROIS_TABLE[day_of_year] : nil
  set_rois  = in_range ? SUN_SET_ROIS_TABLE[day_of_year]  : nil

  if rise_rois.nil? || set_rois.nil?            # cosine fallback
    theta     = 2 * Math::PI * day_of_year / 400.0
    rise_rois = (90 + 29.51 * Math.cos(theta)).round
    set_rois  = 360 - rise_rois
  end
  { rise: rise_rois * 60, set: set_rois * 60 }
end

The day length runs from about 7,200s (2.0 real hours) at the winter solstice to about 14,400s (4.0 real hours) at the summer solstice, with 10,800s (3.0 real hours) at the equinoxes. The fallback cosine uses base 90 rois and amplitude 29.51 rois; section 4.2 explains why that cosine is only an approximation and why the table supersedes it.

4.2 What the full year dataset revealed

When the sun formula was first derived, observations only covered part of the year (roughly days 240 to 399 plus day 0). The 2026-06 dataset is the first with full year coverage: 1,866 sun events covering 397 of 400 days of year, including the previously unobserved summer solstice. Re analyzing against this data overturned three assumptions that are stated in the older design notes and in some code comments. They are recorded here so they are not re introduced.

Finding 1: sun events do not fire on 60 second ticks. Unlike the moons, the sun fires at its exact astronomical second. Only 39 percent of observed rise events (368 of 937) land on a 60 second boundary. For example day of year 50 rises at 6,623s and day 150 at 4,123s, neither a multiple of 60. The earlier claim that sun times are always multiples of one rois was an artifact of the small early sample. Because the shipped model rounds rise to a whole rois, it is comparing a rounded value against an unrounded event, which structurally caps its accuracy.

Finding 2: rise plus set is not always 360 rois. The symmetry assumption (rise and set perfectly mirrored around midday) holds on only 86.5 percent of days. On the remaining 13.5 percent the sum is 358, meaning the whole cycle is shifted one rois earlier. So rise and set are better treated as independent quantities than as a mirror pair.

Finding 3: the curve is not a cosine. The empirical rise time is asymmetric about the solstice: rise[d] differs from rise[400 - d] on 99 of 199 day pairs. Because a cosine is symmetric by construction, no cosine can fit. A brute force search over base, amplitude, and phase tops out at 72.3 percent rois accuracy (base 90, amplitude 29.25, phase 0.5). Re tuning the amplitude is therefore a dead end; the limit is the model shape, not the constant. The shipped 29.51 amplitude is fine within that limit.

Finding 4: the function is stable year over year. Across all 76 days observed in both DR years of the dataset, the rise time in rois was identical (delta of zero). Day of year is therefore a valid, stable key. There is no multi year drift to model.

4.3 Accuracy of the shipped model

Measured against the full year dataset (rise events):

Model Rois exact match Mean absolute error
Cosine 90/29.51 (now fallback only) 69.2 percent 25.9 s
Best possible cosine (90, 29.25, phase 0.5) 72.3 percent n/a
Empirical lookup by day of year (shipped v4.1) 100 percent sub rois

The shipped table stores the modal observed rois per day. Because the function is deterministic and stable year over year, an in sample 100 percent rois match translates to near 100 percent going forward. Validated again on the v4.1 build data (about 1,900 events, 2 characters, ~1.2 DR years): the cosine scored 69.5 percent exact rois on this set, the table 100 percent.

4.4 Implemented in v4.1

The empirical lookup landed in v4.1. Two 400 entry tables keyed by day of year (rise and set, stored independently because they are not symmetric) replace the cosine as the primary model. The five rise gap days (27, 123, 124, 167, 366) and the set gap days are linearly interpolated from neighbors. This raises cold start sun accuracy from about 70 percent to about 100 percent. Regenerate the tables from sunwatch_events_*.csv per section 7.1 as more data accumulates.

The practical caveat still holds: the offset self correction (section 5.2) snaps the sun to within one rois after the first observed sunrise or sunset, and the user facing timer is displayed in whole minutes, so the table is mostly a correctness and cold start win rather than a visible change in steady state play.


5. Self correction

The model is the prior; observed events are ground truth. Each celestial body carries a per character offset, persisted in CharSettings, that nudges the model into agreement with reality.

5.1 Moon correction (tick aware)

When a moon event is observed, the manager first checks visibility:

  • Visibility mismatch (model said down, moon rose, or vice versa): a full correction of predicted[:seconds] is applied. This is the cold start, server reset, or large drift case.
  • Visibility correct: the manager checks whether the Bresenham prediction landed on the right tick. If the event fired within 30 seconds of the predicted tick, the prediction was correct and the offset is left unchanged. Only when the tick was genuinely wrong is a drift correction applied.

This tick aware logic is the key 4.0 improvement. The pre 4.0 model corrected on every event, which injected a plus or minus 30 second oscillation even when the prediction was already perfect. Leaving a correct prediction alone eliminates that sawtooth entirely.

If an offset ever exceeds the threshold of 10,500 seconds (about half a cycle), it is assumed to be an epoch mismatch from a different game instance and is auto reset to zero.

5.2 Sun correction (drift)

The sun uses simpler logic because there is no tick quantization to exploit. On a visibility mismatch it applies a full correction; otherwise it computes the drift between the observed seconds in day and the expected rise or set time and corrects by that amount. The sun offset auto resets if it exceeds 10,800 seconds (half a day).

Because the cosine model has a systematic plus or minus one rois error, the sun offset typically carries a small standing correction that flips sign as the season transitions. This is expected and harmless. The empirical lookup table would remove most of it.


6. Server reset tracking

Server restarts can shift moon phase. ServerResetTracker handles this two ways:

  1. Shutdown announcement. When the game prints the shutdown warning, the tracker snapshots the current offsets to CharSettings so they survive the script restart, and logs a shutdown row.
  2. Gap detection. During event processing, if the time since the last event for a moon exceeds the maximum cycle plus a five minute buffer (21,180 + 300 seconds), a restart is inferred. The last-event times are persisted to CharSettings, so the gap is still detected after the script itself restarts (a server reset relaunches it via autostart). The tracker logs a restart_detected row, and for each moon, on its first post-restart event, a phase_shift row whose shift is the moon's offset AFTER its first post-restart correction minus the offset captured at shutdown. Shifts larger than 60 seconds raise a user visible alert.

This logging was historically inert: before v4.2.1 the last-event times lived only in memory, so the fresh post-restart process started blank and never saw the gap, and the phase shift was computed before the correction that reveals it (always zero). Both were fixed in v4.2.1, so reset logs now populate. The earlier manual analysis (one March 2026 reset) showed shifts of roughly -45 to +61 rois with no systematic pattern, and the backtest confirms self correction absorbs them within a median of a few observed events. Resets remain a logging and diagnostics concern, not a model constant concern.


7. Data collection methodology

Calibration data is collected by MoonwatchLogger, enabled per character with ;moonwatch log. It records raw intervals, not model relative drift, because drift values are contaminated by accumulated offset corrections and are useless for re deriving constants. Raw intervals are absolute measurements.

Four CSV files are written to the Lich data directory. The first three are written by MoonwatchLogger; the fourth by ServerResetTracker (section 6).

moonwatch_events_<char>.csv: one row per moon event. Columns: server_time,moon,event,day_of_year,season,last_rise,last_set,cycle_interval,visible_dur,hidden_dur,offset,predicted_seconds.

sunwatch_events_<char>.csv: one row per sun event. Columns: server_time,event,day_of_year,season,last_rise,last_set,day_interval,day_length,night_length,expected_day_length,drift,sun_offset,predicted_seconds.

moonphase_events_<char>.csv: one row per observe <moon> phase readout (v4.3+). Columns: server_time,moon,day_of_year,observed_phase,computed_index,computed_name,phase_angle,orbital_angle. The free-form observed_phase is RFC4180-escaped by the csv_field helper (quoted, with embedded quotes doubled, only when it contains a comma, quote, or newline) so game wording with commas never shifts later columns.

server_resets_<char>.csv: one row per reset-tracking event. Columns: event_type,server_time,timestamp,katamba_offset,xibar_offset,yavash_offset,katamba_shift,xibar_shift,yavash_shift,gap_seconds,trigger_moon. event_type is shutdown, restart_detected, or phase_shift.

The three interval loggers deduplicate within 60 seconds and reject interval outliers (outside 0.5 to 1.5 times expected for moons, 10,800 to 32,400 seconds for the sun day interval) so a missed event or a reset spanning interval does not poison the data. Phase observations dedup within 60 seconds per moon.

7.1 Re deriving constants

To re calibrate moons from a season or more of data:

  1. For each moon, collect all cycle_interval values, filter to within 30 percent of the current constant, and take the mean. That is the new period.
  2. Confirm the long tick fraction matches (period mod 60) / 60.
  3. Set epoch to an observed rise time minus the recent offset mean so new launches start near zero offset.

To re derive the sun, recover the true seconds in day for each event from (server_time - CALENDAR_EPOCH) % 21600, group by day of year, and take the median. That median table is the empirical model. Do not assume rise plus set is 360; measure each independently.


8. Exported interface

For consumers, see the user guide. For the record, the model exposes:

  • Moons.calculate_position(moon, game_time, offset) (now also returns :t, the 0..1 progress across the current above- or below-horizon arc) and Moons.format_duration.
  • Moons.phase(moon, game_time) returning { index, name, orbital_angle, phase_angle, next_index, next_name, seconds_to_next } (v4.3; next-phase fields added in v4.4.1).
  • Moons.observed_phase_name(observed_phrase) mapping an observed DR phase clause to a phase name via Moons::OBSERVED_PHASE_MAP (nil if unmapped).
  • DRTime.calculate_date, sun_times_for_day, calculate_sun_position, season, time_of_day, and the naming helpers.
  • The global $moon_offsets, a live reference to the offset manager hash, used by moonpredict.lic.
  • UserVars.moons (per moon now also 'phase', 'phase_index', 'next_phase', 'next_phase_seconds', 't'), UserVars.sun, and UserVars.calendar. Each of the three carries a 'running' boolean (v4.5): MoonwatchUI.set_running marks the data live at startup and, via before_dying, stale on a clean exit, without deleting any keys existing consumers read.

8a. Instance awareness (v4.5)

MoonwatchInstance centralizes game-instance reasoning. The moon and sun periods are game-code constants (section 3.5 cross-validates them against the DR client's own hard-coded sidereal periods to ~0.05s), so they are identical on every instance -- Prime (DR), Platinum (DRX), Fallen (DRF), Test (DRT). No per-instance constants are maintained.

Isolation is free: correction offsets live in CharSettings, which Lich already scopes per "#{XMLData.game}:#{XMLData.name}", so each instance and character self-calibrates its own offsets with no cross-instance bleed. prime?(game) returns game == 'DR'; calibration_notice(game) returns nil on Prime and otherwise a one-time startup line noting that the absolute phase self-calibrates from observed events. This supersedes the old Prime-only refuse-and-exit guard, since the engine works everywhere.

8b. The moon alias (v4.5)

MoonwatchAlias builds and maintains the optional global moon alias. The alias body is Ruby run via Lich eq, gated on Script.running?('moonwatch') (true regardless of the folder the script runs from, since Script's @name strips the path and extension): when running it prints the three pretty strings, otherwise it tells the user to start moonwatch. This is crash-proof -- it never depends on exit cleanup.

The core alias service stores --global aliases in a SQLite DB (data/alias.db3, table global) and UPSERTs on re-add. On startup resync reads that DB: if a moon alias exists, is one of ours (ours? checks for our markers UserVars.moons / Script.running?('moonwatch')), and differs from the current body, it silently re-installs the current body via the alias service. It never creates a moon alias that is not already present and never overwrites an unrelated user alias. existing_target is read-only and fully rescued (missing DB, table, or gem -> treated as "no alias"), so the coupling to the alias service's storage cannot break moonwatch.


9. Architecture changes from the legacy script

The legacy upstream script was Firebase dependent: it loaded shared moon data over the network on a timer, used whole minute duration constants (for example 174 times 60 for katamba rise), and required a correct argument to run a shared "moonbot" that wrote observations back to Firebase.

Version 4.0 removes all of it. There is no network call, no shared database, and no correct argument. Prediction is local closed form math; correction is local from the player's own observations. This makes the script accurate offline, immune to shared data corruption, free of write storm coordination problems, and functional on every game instance rather than only where Firebase is reachable.

The correct and nocorrect arguments and the entire Firebase code path were dropped outright rather than kept as no ops. See the user guide changelog.


10. External consumers and a web dashboard (planned, not built)

Nothing here is implemented yet; this records the design so it can be picked up later. In-session consumers should just read UserVars (section 8); this section is about consumers outside the Lich process (a website, a phone widget, a Discord bot, another machine).

10.1 Two architectures

A. JSON snapshot feed (moonwatch publishes). moonwatch writes data/moonwatch_state_<char>.json on change with the full derived state (every UserVars.moons/sun/calendar field); a static web server serves it (angua already runs Docker for the Elanthipedia mirror, so this is a small static mount); the page polls with fetch(). Gives the self-corrected offsets and the empirical sun table for free, but is only as fresh as moonwatch running and the file being reachable.

B. Client-side compute (no backend). Because the whole model is deterministic from the wall clock, a static HTML+JS page can compute every moon's phase, rise/set timer, and the calendar itself from Date.now() -- exactly how the DR client (Saga) does it. No moonbot, no JSON, no server, no Lich running; always live, never stale. The moon math is ~40 lines (the three sidereal periods, the epoch skew, the phase buckets -- the validated constants in section 3).

A: JSON feed B: client-side compute
Backend web server + moonwatch running none (static page)
Freshness depends on moonwatch/file always live
Self-corrected offsets yes (per character) no (raw model, ~seconds off)
Sun times free (table shipped) must port the two 400-entry tables
Moon phase/timers free trivial to port

For a moon dashboard, B is preferred (self-contained, bulletproof). A only wins if you want the sun (whose accuracy lives in the 400-entry lookup tables) or the exact per-character corrected values. A hybrid is possible: client-side model for moons, overlay the JSON feed when moonwatch is up.

The fastest path when this is picked up: port the validated moon model (and optionally the sun tables) to a standalone JS module and drop it in a static page.

10.1a Constraint: no npm, minimize JavaScript

Maintainer preference: no Node/npm tooling on the laptop, and ideally no JavaScript at all. Neither is a real obstacle -- npm is the Node build ecosystem and is never required to make a web page. Two no-npm paths:

A2. Ruby-rendered static HTML, zero JavaScript (preferred). A small Ruby script on angua (reusing Moons/DRTime, or a standalone port) computes the full state plus derived events and writes moondash.html with the values baked into the markup. The page uses <meta http-equiv="refresh" content="60"> so the browser reloads and picks up fresh numbers; nothing runs in the browser. View it over the SSHFS mount or from the existing Docker/nginx. Keeps all logic in Ruby, puts nothing on the laptop. Tradeoff: countdowns advance in ~60 s steps (per reload) rather than smoothly. The renderer can be a standalone cron/loop script, or moonwatch can emit the file (throttled to on-change, not every 0.1 s loop).

B2. Single static .html with inline vanilla <script>. One self-contained file, ~40 lines of plain browser JS computing from Date.now(); double-click to open, smooth countdowns, no server, no dependencies, still no npm. Rejected only if the goal is zero JavaScript, not merely zero tooling.

Important distinction: npm (the Node package manager + build ecosystem: node_modules, webpack, TypeScript, frameworks) is entirely separate from JavaScript the language, which is built into every browser. A hand-written <script> in a single .html run from file:// needs no npm, no Node, no build step, no network, and installs nothing. The maintainer's constraint is "no npm anywhere," which every option here satisfies; the softer preference is to minimize JavaScript-the-language.

C2. Hybrid: Ruby logic + minimal vanilla-JS countdown (preferred). Keeps the self-sufficient single-file feel and smooth live countdowns while pushing all real logic into Ruby. The Ruby renderer bakes the absolute event times into the HTML (e.g. next full per moon, next all-up, next all-down as ISO timestamps or data attributes); the only JavaScript is a ~5-line loop that does target - now each second to tick the displayed countdowns. No moon math in JS, no npm, no dependencies. This is the sweet spot for "liked the self-sufficient page, dislike JS as a language."

Decision: prefer C2 (Ruby computes, baked into HTML, tiny JS countdown only). Fall back to A2 (zero JS, <meta refresh>, 60 s-step countdowns) if even the 5-line countdown is unwanted, or B2 (all client-side JS) if a fully backend-free single file that recomputes the model in-browser is preferred. Pure HTML/CSS cannot compute time-based moon state, so the logic lives in Ruby (A2/C2) or JS (B2); the browser has no other native scripting language (WASM needs a toolchain and still needs JS glue for the DOM).

10.2 Derived-event queries (all doable)

The dashboard wishlist -- "next time all three moons are up together", "next time all three are down", "next full moon for each", and so on -- is all derivable from the model, because each is a "next time condition X holds" query. up/down and phase are deterministic functions of time, so any such query is a forward scan (single-moon periodic events like "next full" also have a closed form). These are cheap: minute-resolution scans over a day or two find the conjunctions.

Sample values computed from the model (illustrative, from one run):

  • All three up together: ~10.6% of the time; next window soon (order of an hour or two out), lasting ~1-2 hours.
  • All three down together: ~9.7% of the time; windows are short (~25 min).
  • Next full moon per moon: within days (each moon's full recurs on its ~9.2 / 6.4 / 10.0 real-day phase cycle -- katamba / xibar / yavash).

Other queries in the same family, all feasible the same way: next rise/set of a given moon (already exposed as timers), next time exactly two are up, next new moon per moon, next occurrence of any named phase, next time a given moon is both full and above the horizon, etc. A generic helper of the form next_time(predicate, horizon) scanning the deterministic state covers all of them; bound the horizon (a few days for conjunctions, ~two weeks for a specific phase) and report if nothing is found in range.

Clone this wiki locally