-
Notifications
You must be signed in to change notification settings - Fork 191
Moonwatch Technical Reference
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.
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 |
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.
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.
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 }
}-
epochis the server_time of a known rise (the phase anchor, the OLS intercept). -
cycleis 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. -
visibleis 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.
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
endVisibility 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.
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:
- The OLS slope and the raw interval mean must agree.
- 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.
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.
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).
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 crescentThree things to know:
-
It uses the client's own sidereal periods and calendar skew, NOT our synodic
cycleorCALENDAR_EPOCH. The client'sday_of_yearruns 1-2 days off our calibrated calendar (ourCALENDAR_EPOCHand the client's skew differ by ~1 DR day plus 48 s), so the phase math is kept self-contained inMoons.phaseto reproduce the client's output. Do NOT feeddate[: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. Theobserveline is parsed and, when;moonwatch logis on, logged tomoonphase_events_<char>.csvas 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.
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
observeneeds, 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.
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.
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.
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 }
endThe 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.
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.
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.
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.
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.
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.
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.
Server restarts can shift moon phase. ServerResetTracker handles this two ways:
-
Shutdown announcement. When the game prints the shutdown warning, the
tracker snapshots the current offsets to
CharSettingsso they survive the script restart, and logs ashutdownrow. -
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 arestart_detectedrow, and for each moon, on its first post-restart event, aphase_shiftrow 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.
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.
To re calibrate moons from a season or more of data:
- For each moon, collect all
cycle_intervalvalues, filter to within 30 percent of the current constant, and take the mean. That is the new period. - Confirm the long tick fraction matches
(period mod 60) / 60. - Set
epochto 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.
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) andMoons.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 viaMoons::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 bymoonpredict.lic. -
UserVars.moons(per moon now also'phase','phase_index','next_phase','next_phase_seconds','t'),UserVars.sun, andUserVars.calendar. Each of the three carries a'running'boolean (v4.5):MoonwatchUI.set_runningmarks the data live at startup and, viabefore_dying, stale on a clean exit, without deleting any keys existing consumers read.
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.
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.
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.
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).
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.
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).
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.