-
Notifications
You must be signed in to change notification settings - Fork 191
Moonwatch Design History
This is a historical design record and changelog, not a description of current behavior. It captures how
moonwatch.licevolved from a Firebase-dependent script into a self-contained offline engine, including the full version history back to v2.0. Several early sections (the "Math First, Firebase Second" design, Firebase writes, the collaborative sync model, the Firebase data format and guards) describe the Firebase era, which was removed in v4.0 -- there is no longer any network dependency, shared database, orcorrectargument. For how the current engine works, see the Technical Reference; for installation and usage, see the user guide. Where a section below is superseded, it is retained for provenance.
This document originally described the redesign of moonwatch.lic to use
epoch-based mathematical calculations for moon position tracking, replacing the
previous Firebase-dependent approach. That redesign is complete and has since
gone considerably further (see the Changelog).
Span: December 2024 - July 2026 (v2.0 through v4.5.0) Current status: offline closed-form engine; Firebase fully removed in v4.0.
The original moonwatch.lic had several limitations:
-
Inaccurate constants - Duration values were rounded to whole minutes:
Settings['rise']['katamba'] = 174 * 60 # 10,440 sec (actual: 10,485 sec) Settings['set']['katamba'] = 177 * 60 # 10,620 sec (actual: 10,605 sec)
-
Firebase dependency - Required Firebase data on startup; couldn't work offline
-
Frequent polling - Read from Firebase every few minutes, causing unnecessary network traffic
-
Cold start problem - If no one had observed a moon event recently, predictions were stale
Analyzed 2024 moon event logs to derive precise constants:
| Moon | Full Cycle (sec) | Visible Duration (sec) | Hidden Duration (sec) |
|---|---|---|---|
| Katamba | 21,084 | 10,608 | 10,476 |
| Xibar | 20,852 | 10,482 | 10,370 |
| Yavash | 21,133 | 10,632 | 10,501 |
Compared with Genie4's TimeTracker plugin which uses epoch-based calculations:
// TimeCalc.cs constants (very close to our analysis)
private const long _katambaCycle = 21088;
private const long _xibarCycle = 20848;
private const long _yavashCycle = 21130;Key insight: TimeTracker calculates moon position mathematically from a known epoch point, making it accurate immediately without external data.
Primary: Calculate moon positions using epoch + cycle constants Secondary: Use Firebase as a correction/sync layer (hourly)
Benefits:
- Accurate immediately on startup
- Works offline
- Reduces Firebase reads from every 4-10 min to hourly
- Self-correcting when events are observed
The timer field must remain in minutes because 6+ scripts depend on it:
dependency.licgate.liccombat-trainer.licautocontingency.licmm.lic
New fields added for second-precision:
-
visible(boolean) -
timer_seconds(integer)
To prevent 100 players writing to Firebase simultaneously when they all see the same moon event:
-
shareargument ormoonwatch_sharesetting required - Random 0-5 second delay before write
- Check-before-write: skip if event already recorded within 60 seconds
Firebase sync can show small discrepancies (e.g., "+10s") even when you're sharing data. This is expected because:
Firebase aggregates data from multiple players. When you sync on startup, you're comparing your epoch-based calculation against what someone else observed and uploaded.
Sources of variance between observers:
| Source | Explanation | Status |
|---|---|---|
| Network latency | Game sends moon event text at slightly different times per client | Inherent |
| Timestamp capture |
XMLData.server_time comes from the previous prompt, not the exact event moment |
Fixed in v2.8 - now refreshes timestamp via time command |
| Constant drift | Epoch values have inherent error; small drift accumulates over days/weeks | Mitigated by self-correction |
Example timeline:
14:05:00 - Script starts, syncs from Firebase
- Firebase has data from ANOTHER player
- Their observation differs from our model by 10s
- We adjust: "xibar offset synced from Firebase: +10s"
16:15:25 - WE observe "Xibar sets"
- No correction needed (model already aligned from sync)
- We upload to Firebase for others
Key insight: Small discrepancies (under 60s) are normal variance between observers. Large discrepancies (hundreds of seconds) indicate the epoch constants need recalibration.
# Re-centered 2026-05-20 from 3,755 events over 89 days (2 characters)
MOON_CONSTANTS = {
'katamba' => {
epoch: 1_771_558_712, # Re-centered: 1771558797 - 85s (recent offset mean)
cycle: 21_089, # OLS: 21088.67s, quantized 21060/21120 387/369 (n=756)
visible: 10_602 # Observed: 10560/10620 131/350 (n=481), mean 10603.7
},
'xibar' => {
epoch: 1_771_560_091, # Re-centered: 1771560080 + 11s (recent offset mean)
cycle: 20_848, # OLS: 20848.19s, quantized 20820/20880 412/377 (n=789)
visible: 10_482 # Observed: 10440/10500 153/359 (n=512), mean 10482.1
},
'yavash' => {
epoch: 1_771_554_968, # Re-centered: 1771555053 - 85s (recent offset mean)
cycle: 21_130, # OLS: 21129.63s, quantized 21120/21180 613/129 (n=742)
visible: 10_624 # Observed: 10620/10680 454/30 (n=484), mean 10623.7
}
}.freezeNote: The original Genie epochs (49623621, etc.) did not align with Lich's XMLData.server_time. These epochs were recalibrated from Firebase events on 2024-12-24 to match Lich's time system.
def calculate_moon_position(moon, game_time = nil)
game_time ||= XMLData.server_time rescue Time.now.to_i
constants = MOON_CONSTANTS[moon]
offset = @moon_offsets[moon] || 0
adjusted_time = game_time + offset
position = (adjusted_time - constants[:epoch]) % constants[:cycle]
is_visible = position < constants[:visible]
if is_visible
seconds_until_set = constants[:visible] - position
{ visible: true, seconds: seconds_until_set.to_i, event: 'set' }
else
seconds_until_rise = constants[:cycle] - position
{ visible: false, seconds: seconds_until_rise.to_i, event: 'rise' }
end
endWhen a moon event is observed in game, compare prediction to reality:
def moon_change(moon, is_up)
game_time = XMLData.server_time rescue Time.now.to_i
predicted = calculate_moon_position(moon, game_time)
position = (game_time + offset - constants[:epoch]) % constants[:cycle]
if predicted[:visible] != is_up
# We were completely wrong about visibility
discrepancy = predicted[:seconds]
$moon_offsets[moon] += discrepancy
CharSettings["#{moon}_offset"] = $moon_offsets[moon]
else
# Drift detection: prediction matched, but timing was slightly off
drift = is_up ? position : (position - constants[:visible])
# Correct any drift - observations are ground truth
if drift != 0
$moon_offsets[moon] -= drift
CharSettings["#{moon}_offset"] = $moon_offsets[moon]
end
# Auto-reset if offset grows too large
if $moon_offsets[moon].abs > 1800
$moon_offsets[moon] = 0
CharSettings["#{moon}_offset"] = 0
end
end
endThe original correction logic only fixed predictions when visibility was wrong at event time. This missed a common case: if we predicted the rise 2 minutes early, at the actual event time our model shows the moon as already up, so predicted[:visible] == is_up == true and no correction happened.
Example scenario:
- Model predicts Yavash rises at 14:00:00
- User sees "Yavash slowly rises" at 14:02:00
- At 14:02:00, model says: visible=true (moon has been "up" for 2 minutes)
- Old logic:
predicted[:visible] == is_up→ no correction - New logic: calculates drift = 120 seconds, applies negative correction
Drift calculation:
- Rise event: drift = current position in cycle (should be 0)
- Set event: drift = current position - visible_duration (should be 0)
Correction:
- All observed drift is corrected immediately (v2.10)
- Positive drift = predicted early, negative drift = predicted late
- Observations are ground truth in pure-observation model; no threshold needed
Offsets are stored in CharSettings (per-character, not shared):
$moon_offsets = {
'katamba' => CharSettings['katamba_offset'] || 0,
'xibar' => CharSettings['xibar_offset'] || 0,
'yavash' => CharSettings['yavash_offset'] || 0
}
$sun_offset = CharSettings['sun_offset'] || 0Benefits of per-character storage:
- Quilsilgas's corrections don't affect Mahtra
- New alts start fresh with offset=0
- Bad Firebase sync on one character doesn't corrupt others
- Each character's observations are independent
Superseded. The threshold shown here is the original v2.6 value. The current code uses 10,500s (moon) and 10,800s (sun), about half a cycle and half a day respectively, which indicates an epoch mismatch from a different game instance rather than ordinary drift (see
MoonwatchOffsetManagerconstants and technical reference section 5.1). Since v4.2 the fractional cycle constants remove the systematic drift described below, so offsets no longer grow over time and the auto-reset is now only an epoch-mismatch guard.
If an offset grows beyond ±1800 seconds (30 minutes), it's automatically reset to 0:
if $moon_offsets[moon].abs > 1800
$moon_offsets[moon] = 0
CharSettings["#{moon}_offset"] = 0
endWhy offsets grow: If cycle constants aren't perfectly accurate, each cycle introduces a small drift that accumulates in the offset. For example, if the true cycle differs by 2 seconds from our constant, after 100 days the offset would grow to ~700 seconds.
The 1800s threshold catches runaway offsets before they cause major prediction errors while allowing legitimate drift accumulation.
The Moons module is defined by moonwatch and can be used by other scripts:
module Moons
CONSTANTS = { ... } # Moon epoch, cycle, visible duration
def self.calculate_position(moon, game_time, offset = 0)
# Returns: { visible: bool, seconds: int, event: 'rise'|'set' }
end
def self.format_duration(seconds)
# Returns: "1h 30m" or "45m 30s" or "30s"
end
endmoonwatch exports the following globals for use by external scripts:
| Global | Type | Description |
|---|---|---|
$moon_offsets |
Hash | Per-moon offset corrections: {'katamba' => N, 'xibar' => N, 'yavash' => N}
|
The $moon_offsets hash is a direct reference to MoonwatchOffsetManager#moon_offsets, so it automatically reflects any runtime corrections made by moonwatch.
moonpredict.lic uses this module and requires moonwatch to be running:
unless defined?(Moons) && defined?($moon_offsets)
respond "ERROR: moonwatch.lic must be running first."
exit
end
# Use shared module and offsets
data = Moons.calculate_position('katamba', game_time, $moon_offsets['katamba'])if $share_moon_data
sleep(rand * 5) # Random delay to spread writes
existing = get_all_moon_data&.dig(moon[0])
if existing.nil? || existing['e'] != (is_up ? RISE : SET) || (game_time - existing['t']).abs > 60
update_moon_data(moon[0], 't' => game_time, 'e' => is_up ? RISE : SET)
end
endDate: December 2024 Status: Implemented
When syncing offsets from Firebase, two guards prevent bad corrections:
Stale data (older than 1 hour) is ignored. This prevents bad corrections when:
- Constants change: Old Firebase events were calibrated to previous cycle values. After updating constants, position calculations at old timestamps differ, causing spurious ~21000s corrections (full cycle length).
- Data gaps: If no one has been online, Firebase may hold very old events that don't reflect current accuracy.
Our own direct observations always take precedence over Firebase data. If we've observed a moon/sun event more recently than Firebase's timestamp, we skip the Firebase sync for that body.
# Track when we last directly observed each moon/sun event
$last_observed ||= {}
def moon_change(moon, is_up)
game_time = XMLData.server_time
$last_observed[moon] = game_time # Record our observation
# ... rest of correction logic
end
def sync_offsets_from_firebase
%w[katamba xibar yavash].each do |moon|
data = firebase_data[moon[0]]
# Skip stale data (> 1 hour old)
next if (current_time - data['t']).abs > 3600
# Prefer our own observations over Firebase
local_obs = $last_observed[moon]
next if local_obs && local_obs >= data['t']
# ... proceed with sync only if Firebase is newer
end
endWhy this matters: Direct observations are more reliable than Firebase because:
- No network latency or sync delays
- No risk of stale data from other clients with different constants
- Corrections are based on events we actually witnessed
Symptom of stale data corruption: CSV drift values near full cycle lengths (20866, 21090, 21145) indicate Firebase synced from old events after constants changed.
All timestamps stored in Firebase use XMLData.server_time (game time), NOT Unix epoch time. This ensures consistency since all clients receive the same server_time from the game, regardless of local clock differences.
# Moon data (keyed by first letter: 'k', 'x', 'y')
{ 'k' => { 't' => 49623621, 'e' => 1 } } # Katamba rose at server_time 49623621
# 't' = server_time when event occurred
# 'e' = event type (1 = RISE, 0 = SET)
# Sun data (keyed by 's')
{ 's' => { 'r' => 49623000, 's' => 49634000 } }
# 'r' = server_time of last sunrise
# 's' = server_time of last sunsetWhen reading sun data for display, convert to Unix time:
server_time_offset = XMLData.server_time_offset rescue 0
set_time = Time.at(sun_data['s'] + server_time_offset).localtime{
# Backward compatible (minutes)
'timer' => 45,
'pretty' => "katamba is up for 45 minutes",
'short' => "[k]+(45)",
# New: second precision
'visible' => true, # Boolean: is moon currently up?
'timer_seconds' => 2705 # Seconds until next event
}# Existing code still works:
if UserVars.moons['katamba']['timer'] > 5
# Moon has at least 5 minutes...
end
# New second-precision usage:
if UserVars.moons['katamba']['visible']
seconds_left = UserVars.moons['katamba']['timer_seconds']
endPre-v4.0. This section describes the Firebase-era argument set. The
correct/nocorrectarguments and the entire Firebase sharing path were removed in v4.0 (see technical reference section 9 and the v4.0 changelog). The current arguments are debug/nodebug, window/nowindow, log/nolog, alias, and reset; there is no sharing argument.
All toggle arguments persist to CharSettings and remain active for future runs until explicitly disabled.
| Argument | Description |
|---|---|
debug |
Enable debug output (persistent) |
nodebug |
Disable debug output |
alias |
Add a moon alias to display status |
window |
Enable moon status window (persistent) |
nowindow |
Disable moon status window |
correct |
Enable sharing observed events to Firebase (persistent) |
nocorrect |
Disable sharing moon events to Firebase |
log |
Enable moon event logging for calibration (persistent, per-character) |
nolog |
Disable moon event logging |
reset |
Reset all moon offsets to zero and exit |
The debug, correct, and window arguments work as persistent toggles:
# Enable/disable debug permanently when arg is passed
if args.debug
CharSettings['moon_debug'] = true
echo("moonwatch: Debug mode enabled (persistent).")
end
if args.nodebug
CharSettings['moon_debug'] = false
echo("moonwatch: Debug mode disabled.")
end
$debug_mode_mm = CharSettings['moon_debug'] || UserVars.moon_debugStorage locations (all per-character):
-
CharSettings['moon_debug']- Debug mode toggle -
CharSettings['moon_correct']- Firebase sharing toggle -
CharSettings['moon_window']- Moon window toggle -
CharSettings['moon_logging']- Event logging toggle -
CharSettings['katamba_offset']- Katamba offset correction -
CharSettings['xibar_offset']- Xibar offset correction -
CharSettings['yavash_offset']- Yavash offset correction -
CharSettings['sun_offset']- Sun offset correction
Can also be configured via (for backward compatibility):
-
UserVars.moon_debug = true- Enable debug mode -
UserVars.moonwatch_share = true- Enable Firebase sharing - YAML:
moonwatch_share: true
Pre-v4.0. This table reflects the Firebase era. Since v4.0 there is no Firebase and no
correctargument, and the sun is computed locally, so every feature below (moons, window, self-correction, sun tracking) works identically on all instances. The "Running without Firebase" startup message no longer exists.
The script works on all DragonRealms instances (Prime, Platinum, Fallen, Test). Historic (Firebase-era) capability matrix:
| Feature | DR Prime | Other Instances |
|---|---|---|
| Moon calculations | ✓ | ✓ |
| Moon window | ✓ | ✓ |
| Self-correction on events | ✓ | ✓ |
| Firebase sync (removed v4.0) | ✓ | ✗ |
| Sun tracking (now local, all instances) | ✓ | ✗ |
correct argument (removed v4.0) |
✓ | Ignored |
In the Firebase era, non-Prime instances showed a startup message that Firebase was unavailable. That message and the dependency are gone as of v4.0.
When toggling settings, confirmation messages are shown:
moonwatch: Debug mode enabled (persistent).
moonwatch: Debug mode disabled.
moonwatch: Firebase sharing enabled (persistent).
moonwatch: Firebase sharing disabled.
moonwatch: Moon window enabled (persistent).
moonwatch: Moon window disabled.
moonwatch: game=DR firebase_available=true
moonwatch: current server_time=1766525245
moonwatch: current server_time_offset=-31440445
moonwatch: stored offsets: katamba=0, xibar=0, yavash=0
moonwatch: moon_change katamba:true at 1766525245
moonwatch: katamba offset corrected by +2705s (total: 2705s)
moonwatch: shared katamba rise to Firebase
moonwatch: xibar offset synced from Firebase: +1830s (total: 1830s)
moonwatch: skipped Firebase write - yavash already recorded
moonwatch: Resetting all moon offsets to zero...
moonwatch: Offsets reset. Restart moonwatch to re-sync.
--- Lich: moonwatch has exited.
| Feature | Reason |
|---|---|
check_for_new_moons() |
No longer needed - calculations work immediately |
Settings['rise'] / Settings['set']
|
Replaced by MOON_CONSTANTS
|
| Frequent Firebase polling | Reduced to hourly sync |
| File | Changes |
|---|---|
moonwatch.lic |
Complete rewrite of moon tracking logic, Moons module |
moonpredict.lic |
Uses shared Moons module, requires moonwatch running |
These scripts still work unchanged due to backward-compatible timer field:
dependency.licgate.liccombat-trainer.licautocontingency.licmm.lic
Uses XMLData.server_time - the game server timestamp from the <prompt time="..."> XML tag.
Important: XMLData.server_time is NOT Unix epoch time. It is a game-specific timestamp that all connected clients receive from the server. This makes it ideal for cross-client synchronization since all players see the same value regardless of local clock differences.
To convert between time systems:
-
server_time → Unix:
server_time + XMLData.server_time_offset -
Unix → server_time:
unix_time - XMLData.server_time_offset
Falls back to Time.now.to_i if unavailable (though this loses cross-client consistency).
Problem: Moon and sun events are "push" events from the server - they arrive as ambient text (e.g., "Katamba slowly rises") without an accompanying prompt. Since XMLData.server_time only updates when a prompt is received, it could be minutes stale if the player has been idle.
Solution (v2.8): When a moon/sun event is detected, immediately issue a lightweight command (time) to force a fresh prompt before capturing the timestamp.
Removed in v4.0: XML log analysis revealed that moon and sun events DO trigger prompts
with fresh server_time — the v2.8 assumption was wrong. The time command received the
same timestamp as the event's own prompt (zero jitter), making it redundant. Event handlers
now use XMLData.server_time directly. See v4.0 changelog for full analysis.
-
UserVars.moons['katamba']['timer']returns integer minutes -
UserVars.moons['katamba']['timer_seconds']returns seconds -
UserVars.moons['katamba']['visible']returns boolean - Moon predictions accurate on fresh start (no Firebase)
- Self-correction when moon events observed
-
UserVars.sun['timer']returns integer minutes -
UserVars.sun['timer_seconds']returns seconds -
UserVars.sun['day']/UserVars.sun['night']return booleans -
UserVars.sun['season']returns season string -
UserVars.sun['time_of_day']returns time period string -
UserVars.calendar['date_string']returns formatted date - Sun predictions accurate on fresh start (no Firebase)
- Self-correction when sun events observed
- Variable day length works (shorter in winter, longer in summer)
- Firebase reads reduced to hourly
- Firebase writes only with
correctenabled - Write deduplication prevents storms
-
debug/nodebugtoggle persists to CharSettings -
correct/nocorrecttoggle persists to CharSettings -
window/nowindowtoggle persists to CharSettings - Toggle confirmation messages display correctly
-
resetcommand clears both moon AND sun offsets
If moon visibility doesn't match what you see in-game:
-
Reset offsets:
;moonwatch reset(resets and exits) -
Restart:
;moonwatchor;moonwatch debug correct - Wait for sync: On startup, offsets sync from Firebase (if available)
- Observe events: Offsets auto-correct when you see moon rise/set messages
The moon window cache is cleared on script startup. If showing stale data:
- Kill and restart:
;kill moonwatchthen;moonwatch window - The cache is now always cleared on startup to prevent stale display
Stream window XML format: The window uses clean XML without \r\n for Profanity compatibility:
_respond("<clearStream id=\"moonWindow\"/>")
_respond("<pushStream id=\"moonWindow\"/>#{new_message}<popStream/>")Note: The window only updates when the minute value changes (timer is in minutes). A 60-second periodic refresh ensures the window re-emits XML even when no game text is flowing (e.g., after a disconnect/reconnect or during idle periods).
The moon alias created by ;moonwatch alias must use escaped interpolation so values are evaluated at invocation time, not creation time:
# Wrong - evaluates immediately when alias is created
%{eq respond("#{UserVars.moons['katamba']['pretty']} ...")}
# Correct - escapes # so interpolation happens when alias is invoked
%{eq respond("\#{UserVars.moons['katamba']['pretty']} ...")}If your alias shows static values, run ;moonwatch alias again to recreate it.
There are two types of corrections:
1. Visibility mismatch (major correction): When the model is completely wrong about visibility, corrections are positive:
- If we predicted hidden but moon rose: add
predicted[:seconds]to shift position to 0 - If we predicted visible but moon set: add
predicted[:seconds]to shift position to visible_duration
2. Drift correction (minor correction): When visibility matched but timing was slightly off:
- Calculate current position in cycle when event occurs
- For rise events: drift = position (should be 0 at rise)
- For set events: drift = position - visible_duration (should be 0 at set)
- Apply
offset -= driftwhich works for both directions:- Positive drift (predicted early): offset decreases, shifting prediction later
- Negative drift (predicted late): offset increases, shifting prediction earlier
All observed drift is corrected immediately (v2.10). In the pure-observation model, every event we process is freshly witnessed, so there's no "stale event" concern that would justify ignoring large drifts.
Date: December 2024 Status: Implemented
Sun tracking has been added using the same epoch-based approach as moons, ported from Genie4's TimeTracker plugin.
A new DRTime module provides full DragonRealms calendar calculations:
module DRTime
SECONDS_PER_DAY = 21600 # 6 real hours = 1 DR day
DAYS_PER_YEAR = 400 # 10 months × 40 days
# Calibrated 2024-12-26 from in-game TIME command
# At server_time 1766732083: Year 455, Month 1, Day 17, Anlas 10 (Phelim's Vigil)
CALENDAR_EPOCH = 1_688_607_583
CALIBRATION_YEAR = 446
# Calculate sun rise/set for a day (0-399)
# Uses quantized sinusoidal formula derived from observed data (2026-03)
# See "Sun Formula Derivation (v2.13)" section below for full details
def self.sun_times_for_day(day_of_year)
theta = 2 * Math::PI * (day_of_year + SUN_PHASE_SHIFT) / DAYS_PER_YEAR.to_f
smooth_day_length = SUN_DAY_LENGTH_BASE - SUN_DAY_LENGTH_AMPLITUDE * Math.sin(theta)
day_length = (smooth_day_length / SUN_QUANTUM).floor * SUN_QUANTUM
night_length = SECONDS_PER_DAY - day_length
rise_seconds = night_length / 2
set_seconds = rise_seconds + day_length
{ rise: rise_seconds, set: set_seconds }
end
def self.calculate_date(game_time, offset = 0)
# Returns: year, month, day, day_of_year, anlas, rois, seconds_in_day
end
def self.calculate_sun_position(game_time, offset = 0)
# Returns: { visible: bool, seconds: int, event: 'rise'|'set' }
end
# Returns season directly (no lookup array needed)
def self.season(day_of_year)
case day_of_year
when 0...50 then 'winter'
when 50...150 then 'spring'
when 150...250 then 'summer'
when 250...350 then 'fall'
else 'winter' # days 350-399
end
end
# Returns time period directly (no lookup array needed)
def self.time_of_day(seconds_in_day, day_of_year)
# Returns: 'night', 'dawn', 'midday', 'dusk', etc. (15 periods)
# Uses if/elsif chain based on sun position
end
endWhen sunrise/sunset is observed in-game, the script verifies its prediction. Like moons, it handles both visibility mismatch and drift detection:
def sun_change(is_up)
predicted = calculate_sun_position(game_time)
date = DRTime.calculate_date(game_time, $sun_offset)
sun_times = DRTime.sun_times_for_day(date[:day_of_year])
if predicted[:visible] != is_up
# Major correction: visibility was wrong
discrepancy = predicted[:seconds]
$sun_offset += discrepancy
CharSettings['sun_offset'] = $sun_offset
else
# Drift detection: visibility matched but timing was slightly off
drift = is_up ? (date[:seconds_in_day] - sun_times[:rise])
: (date[:seconds_in_day] - sun_times[:set])
# Correct any drift - observations are ground truth
if drift != 0
$sun_offset -= drift
CharSettings['sun_offset'] = $sun_offset
end
# Auto-reset if offset grows too large
if $sun_offset.abs > 1800
$sun_offset = 0
CharSettings['sun_offset'] = 0
end
end
end{
# Backward compatible
'day' => true, # Is it daytime?
'night' => false, # Is it nighttime?
'timer' => 45, # Minutes until next event
# New fields
'visible' => true, # Alias for 'day'
'timer_seconds' => 2705, # Seconds until next event
'season' => 'summer', # Current season
'time_of_day' => 'mid-afternoon', # Descriptive time period
'pretty' => 'sun sets in 45 minutes (mid-afternoon)',
'short' => '[S]+(45)'
}{
'year' => 455,
'month' => 1,
'day' => 17,
'day_of_year' => 16, # 0-399
'anlas' => 10, # 0-11 (hours)
'rois' => 18, # 0-29 (minutes)
'season' => 'winter',
'time_of_day' => 'evening',
'year_name' => 'Year of the Silver Unicorn',
'month_name' => 'Akroeg the Ram',
'anlas_name' => "Phelim's Vigil",
'date_string' => '455-01-17 10:18'
}| Day Range | Season |
|---|---|
| 0-49 | Winter |
| 50-149 | Spring |
| 150-249 | Summer |
| 250-349 | Fall |
| 350-399 | Winter |
15 descriptive periods based on sun position:
- night
- approaching sunrise
- dawn
- early morning
- mid-morning
- late morning
- midday
- early afternoon
- mid-afternoon
- late afternoon
- dusk
- sunset
- early evening
- evening
- late evening
The sun timing formula was reverse-engineered through three iterations, culminating in the discovery that the game quantizes sunrise and sunset independently to rois boundaries.
From Elanthipedia Talk:Elanthian_time:
f(day) = 30min × sin(2πday/400 + X×π/2) + 75min
Where X=1 for sunrise, X=3 for sunset. This simplifies to:
- Sunrise shift:
sin(π(d+100)/200) = cos(2πd/400) - Sunset shift:
sin(π(d+300)/200) = -cos(2πd/400)
Applied with base and amplitude:
-
Sunrise(d) = 5400 + 1800 × cos(2πd/400)seconds -
Sunset(d) = 16200 - 1800 × cos(2πd/400)seconds
Analysis of 82 observed sun events (with corrected CALENDAR_EPOCH) revealed:
| Finding | Value |
|---|---|
| Rise/set times are multiples of | 60 seconds (1 rois) -- not 120s |
| rise + set always equals | 21600s (perfectly symmetric around midday) |
| Rise step between consecutive days | Always exactly 0 or 60 seconds |
| Each rois level lasts | 2-4 days (sinusoidal dwell pattern) |
| Day-length steps of 120s | Consequence of independent 60s rise + 60s set steps |
rise_rois = round(90 + 29.51 × cos(2πd/400))
set_rois = 360 - rise_rois
Constants (defined in DRTime module):
| Constant | Value | Purpose |
|---|---|---|
SUN_RISE_BASE_ROIS |
90 | Equinox sunrise (rois after midnight) |
SUN_RISE_AMPLITUDE_ROIS |
29.51 | Seasonal swing (confirmed from solstice data) |
ROIS_PER_DAY |
360 | Total rois in one DR day |
Amplitude derivation (v3.5): Winter solstice data (days 385-399, day 0) resolved the amplitude definitively. Day 398 shows day_length=7320 (rise_rois=119), day 399 shows day_length=7200 (rise_rois=120). Both characters confirm identical values.
The transition from rise_rois=119 to 120 at exactly day 399 constrains B tightly:
-
round(90 + B × cos(2π×399/400)) >= 119.5requires B >= 29.504 -
round(90 + B × cos(2π×398/400)) < 119.5requires B < 29.515 - Therefore B in [29.504, 29.515), and 29.51 is the natural choice
This rules out both A=29 (predicted day_length=7320 at solstice, observed 7200) and A=30 (predicted day_length=7200 at day 389, observed 7320).
| Season | Day | Rise Rois | Day Length | Real Time |
|---|---|---|---|---|
| Winter solstice | 0 | 120 | 7200s | 2.0h |
| Spring equinox | 100 | 90 | 10800s | 3.0h |
| Summer solstice | 200 | 60 | 14400s | 4.0h |
| Fall equinox | 300 | 90 | 10800s | 3.0h |
Summer solstice values are predicted (no observations in days 1-239 yet). Winter solstice values are confirmed from days 399 and 0.
def self.sun_times_for_day(day_of_year)
theta = 2 * Math::PI * day_of_year / DAYS_PER_YEAR.to_f
smooth_rise_rois = SUN_RISE_BASE_ROIS + SUN_RISE_AMPLITUDE_ROIS * Math.cos(theta)
rise_rois = smooth_rise_rois.round
set_rois = ROIS_PER_DAY - rise_rois
{ rise: rise_rois * SECONDS_PER_ROIS, set: set_rois * SECONDS_PER_ROIS }
end| Version | Model | Accuracy |
|---|---|---|
| v2.12 |
day_length = BASE - AMP × sin(2π(d+PHASE)/400) (smooth) |
~60% |
| v2.13 |
day_length = floor(smooth / 120) × 120 (120s buckets) |
~73% |
| v3.1 |
rise_rois = round(90 + 29 × cos(2πd/400)) (rois-level) |
~82% |
| v3.5 |
rise_rois = round(90 + 29.51 × cos(2πd/400)) (solstice-confirmed) |
~79% |
The v3.5 accuracy is slightly lower than v3.1's reported 82% because the dataset now includes 150 observations (days 240-399, day 0) vs 82 (days 240-320). The new data covers the winter solstice region where the model performs well, but the larger dataset includes more equinox-transition days where accuracy is inherently lower.
Accuracy by day range (150 observations, 2 characters):
| Day Range | Season | Accuracy |
|---|---|---|
| 240-260 | Late summer | 60% (12/20) |
| 261-300 | Fall equinox | 51% (19/37) |
| 301-340 | Mid-fall | 95% (36/38) |
| 341-399 | Winter | 94% (51/54) |
| 0 | Solstice | 100% (1/1) |
The ~79% accuracy is a hard mathematical limit of the cosine model, not a tuning problem. No values of (base, amplitude, phase) can satisfy all observations:
- Day 376 requires B >= 29.577
- Day 241 requires B < 29.387
- Day 298 requires B < 15.918
These constraints are mutually exclusive. The game's actual sun function is close to a cosine but not identical -- likely integer arithmetic, a lookup table, or a slightly different curve. The error pattern is systematic:
- Days 241-298 (fall equinox): model predicts rise_rois 1 too low (all +1 misses)
- Days 310-376 (winter approach): scattered -1 misses
- Days 322-399 (deep winter): ~95% accuracy (flat cosine region)
All misses are exactly +/-1 rise_rois (60 seconds). The offset correction system corrects each error immediately upon observing the next sunrise/sunset event.
Moon comparison: Moon constants achieve 100% interval prediction accuracy (all observed intervals land on exactly one of two quantized values). The difference is that moon cycles are constant, while the sun formula involves day-dependent calculations where the game's exact quantization doesn't match pure cosine rounding.
With debug enabled:
moonwatch: sun visible=false timer=160m (evening)
moonwatch: calendar: 455-01-17 10:18 Phelim's Vigil
moonwatch: season=winter Year of the Silver Unicorn
The CALENDAR_EPOCH has been calibrated through three methods:
Method 1 -- TIME command (2024-12, approximate): Derived epoch 1,688,607,583 from a single "Rois ~15" reading. Imprecise.
Method 2 -- Sun event recalibration (2025-02, counterproductive): Shifted -380s to 1,688,607,203 based on early sun drift analysis. This actually increased calendar error from 7 to 14 rois.
Method 3 -- Sun rois alignment + TIME verification (2026-03, definitive): 82 sun events all land on exact rois (60s) boundaries with epoch 1,688,607,948. Two "positive" in-game TIME readings confirm the calendar is accurate to +-1 rois.
# Verification at server_time 1773308339:
# Game: "You're positive it's 7 roisaen before Hodierna's Blessing" = anlas 3, rois 23
# Epoch gives: anlas 3, rois 23.18 -> floor 23 ✓
CALENDAR_EPOCH = 1_688_607_948Date: January 2025 Status: Reimplemented with raw intervals
Moon event logging now captures raw interval measurements instead of model-relative drift. This provides clean data for constant recalibration without offset contamination.
;moonwatch log # Enable for this character
;moonwatch nolog # Disable
Logging is per-character and persists across sessions.
Location: $lich_dir/data/moonwatch_events_<CharName>.csv
Columns:
| Column | Description |
|---|---|
| server_time | XMLData.server_time when event observed |
| moon | katamba, xibar, or yavash |
| event | rise or set |
| day_of_year | 0-399 |
| season | winter, spring, summer, fall |
| last_rise | server_time of previous rise for this moon |
| last_set | server_time of previous set for this moon |
| cycle_interval | Rise->rise or set->set interval (full cycle measurement) |
| visible_dur | Rise->set interval (how long moon was up) |
| hidden_dur | Set->rise interval (how long moon was down) |
The previous logging system recorded drift (difference between prediction and observation). The problem: drift values were contaminated by accumulated offset corrections, making them unreliable for constant recalibration.
Raw intervals are absolute measurements that directly tell us:
-
cycle_interval= actual cycle length (compare to our constant) -
visible_dur= actual visible duration -
hidden_dur= actual hidden duration
After ~100 real days (one Elanthian year of 400 DR days):
require 'csv'
data = CSV.read('moonwatch_events_Quilsilgas.csv', headers: true)
%w[katamba xibar yavash].each do |moon|
cycles = data.select { |r| r['moon'] == moon && r['cycle_interval'].to_s != '' }
# Overall average cycle length
avg = cycles.map { |r| r['cycle_interval'].to_i }.sum.to_f / cycles.size
puts "#{moon}: avg_cycle=#{avg.round(1)}s (n=#{cycles.size})"
# By season (check for seasonal variation)
cycles.group_by { |r| r['season'] }.each do |season, rows|
s_avg = rows.map { |r| r['cycle_interval'].to_i }.sum.to_f / rows.size
puts " #{season}: #{s_avg.round(1)}s (n=#{rows.size})"
end
endDate: January 2025 Status: Implemented
Sun event logging captures raw interval measurements to validate the CALENDAR_EPOCH and sinusoidal day length formula.
Location: $lich_dir/data/sunwatch_events_<CharName>.csv
Columns:
| Column | Description |
|---|---|
| server_time | XMLData.server_time when event observed |
| event | rise or set |
| day_of_year | 0-399 |
| season | winter, spring, summer, fall |
| last_rise | server_time of previous sunrise |
| last_set | server_time of previous sunset |
| day_interval | Rise->rise or set->set interval (should be ~21600s) |
| day_length | Rise->set interval (daylight duration, varies by season) |
| night_length | Set->rise interval (night duration, varies by season) |
| expected_day_length | Model's predicted day length for this day_of_year |
| drift | Observed day_length - expected_day_length |
- CALENDAR_EPOCH accuracy: If day_interval consistently differs from 21600s, the epoch may need adjustment
- Sinusoidal formula: If drift values show systematic bias by season, the day length formula needs tuning
- SECONDS_PER_DAY: If day_interval drifts over time, the constant may be wrong
require 'csv'
data = CSV.read('sunwatch_events_Quilsilgas.csv', headers: true)
# Check day interval consistency (should be ~21600)
intervals = data.map { |r| r['day_interval'].to_i }.reject(&:zero?)
avg_interval = intervals.sum.to_f / intervals.size
puts "avg day_interval: #{avg_interval.round(1)}s (expected: 21600s)"
# Check drift by season
data.select { |r| r['drift'].to_s != '' }.group_by { |r| r['season'] }.each do |season, rows|
avg_drift = rows.map { |r| r['drift'].to_i }.sum.to_f / rows.size
puts "#{season}: avg_drift=#{avg_drift.round(1)}s (n=#{rows.size})"
endDate: December 2024 Status: Implemented
Analyzed moondata.2024 (1200+ events per moon over a full year) to validate and refine moon constants.
Unlike sun day length, moon cycles show no seasonal variation. Cycle times vary by only ~1-2s across all seasons - well within measurement noise.
| Moon | Duration | Old Value | Observed | New Value | Change |
|---|---|---|---|---|---|
| Katamba | visible | 10605s | 10603s | 10605s | - |
| Katamba | hidden | 10485s | 10486s | 10485s | - |
| Katamba | cycle | 21090s | 21089s | 21090s | - |
| Xibar | visible | 10485s | 10482s | 10485s | - |
| Xibar | hidden | 10365s | 10384s | 10381s | +16s |
| Xibar | cycle | 20850s | 20866s | 20866s | +16s |
| Yavash | visible | 10621s | 10623s | 10621s | - |
| Yavash | hidden | 10499s | 10524s | 10524s | +25s |
| Yavash | cycle | 21120s | 21147s | 21145s | +25s |
# Calibrated 2025-01-03 from moonwatch_events CSV analysis
CONSTANTS = {
'katamba' => { cycle: 21_084, visible: 10_608 }, # Was 21090/10605
'xibar' => { cycle: 20_852, visible: 10_482 }, # Was 20866/10485
'yavash' => { cycle: 21_133, visible: 10_632 } # Was 21145/10621
}Day-of-year analysis (50-day buckets) shows consistent cycles:
Katamba: cycle varies by ~1s across all seasons - CONSTANT
Xibar: cycle varies by ~2s across all seasons - CONSTANT
Yavash: cycle varies by ~1s across all seasons - CONSTANT
The analysis was performed with scripts/custom/analyze_moondata.rb:
- Parses ISO timestamps from moondata.2024
- Calculates DR day_of_year for each event
- Computes intervals between consecutive rise/set events
- Groups by season and day-of-year buckets
- Compares observed intervals to expected constants
Date: January 2025 Status: Implemented
Re-analyzed constants using 443 events from moonwatch_events_Quilsilgas.csv (days 53-135, spring season).
| Moon | Metric | v2.4 Value | Observed | v2.7 Value | Change |
|---|---|---|---|---|---|
| Katamba | cycle | 21,084s | 21,089.7s | 21,090s | +6s |
| Katamba | visible | 10,608s | 10,601.4s | 10,601s | -7s |
| Katamba | hidden | 10,476s | 10,485.7s | 10,489s | +13s |
| Xibar | cycle | 20,852s | 20,849.4s | 20,852s | - |
| Xibar | visible | 10,482s | 10,481.0s | 10,482s | - |
| Xibar | hidden | 10,370s | 10,367.1s | 10,370s | - |
| Yavash | cycle | 21,133s | 21,129.3s | 21,129s | -4s |
| Yavash | visible | 10,632s | 10,622.9s | 10,623s | -9s |
| Yavash | hidden | 10,501s | 10,505.5s | 10,506s | +5s |
# Calibrated 2025-01-25 from moonwatch_events CSV analysis (n=443)
CONSTANTS = {
'katamba' => { cycle: 21_090, visible: 10_601 }, # Was 21084/10608
'xibar' => { cycle: 20_852, visible: 10_482 }, # No change
'yavash' => { cycle: 21_129, visible: 10_623 } # Was 21133/10632
}Standard deviations (~20-30s) reflect timing noise from XMLData.server_time capture. This is expected and handled by the self-correction mechanism.
| Moon | Cycle StdDev | Visible StdDev | Hidden StdDev | n (cycles) |
|---|---|---|---|---|
| Katamba | 29.9s | 27.6s | 26.0s | 107 |
| Xibar | 30.1s | 27.9s | 24.5s | 100 |
| Yavash | 21.7s | 12.8s | 17.2s | 110 |
Date: February 2026 Status: Implemented
Analysis of CSV data with second-level timestamp precision revealed that all observed moon intervals are exact multiples of 60 seconds:
Xibar cycles: 20820, 20880, 20820, 20880... (alternating)
Katamba cycles: 21120, 21060, 21120, 21060... (alternating)
Yavash cycles: 21120, 21180, 21120, 21180... (alternating)
Verification:
- 20820 / 60 = 347 ✓
- 20880 / 60 = 348 ✓
- 21060 / 60 = 351 ✓
- 21120 / 60 = 352 ✓
- 21180 / 60 = 353 ✓
The game server checks moon positions on a 60-second tick cycle. Moon rise/set events only fire on these tick boundaries. This quantization is inherent to the game engine, not our measurement system.
- Timestamp precision is NOT the bottleneck - We have second-level accuracy (v2.8), but the game only fires events every 60s
- Constants can only be verified to +-30s - The "true" cycle lies somewhere in a 60s band
- Bimodal distributions are expected - Alternating values (e.g., 20820/20880) are normal, not measurement error
- Use midpoint values - Set constants to the midpoint of the observed 60s band for best average accuracy
Based on 68 events (days 240-251, summer/fall transition):
| Moon | Metric | v2.7 Value | Observed Band | v2.9 Value (midpoint) |
|---|---|---|---|---|
| Katamba | cycle | 21,090 | 21060/21120 | 21,090 (no change) |
| Katamba | visible | 10,601 | 10620 only | 10,620 |
| Xibar | cycle | 20,852 | 20820/20880 | 20,850 |
| Xibar | visible | 10,482 | 10440/10500 | 10,470 |
| Yavash | cycle | 21,129 | 21120/21180 | 21,150 |
| Yavash | visible | 10,623 | 10620/10680 | 10,650 |
Based on 334 events from 2 characters (days 240-271, summer/fall):
Key insight: Midpoint works for 50/50 distributions, but for skewed distributions use weighted average to minimize drift.
| Moon | Metric | v2.11 Value | Distribution | v2.12 Value |
|---|---|---|---|---|
| Katamba | cycle | 21,090 | 21060 (50%) / 21120 (50%) | 21,090 (no change) |
| Katamba | visible | 10,590 | 10560 (22%) / 10620 (78%) | 10,590 (no change) |
| Xibar | cycle | 20,850 | 20820 (56%) / 20880 (44%) | 20,850 (no change) |
| Xibar | visible | 10,470 | 10440 (27%) / 10500 (73%) | 10,484 (+14s) |
| Yavash | cycle | 21,120 | 21120 (85%) / 21180 (15%) | 21,129 (+9s) |
| Yavash | visible | 10,620 | 10620 (91%) / 10680 (9%) | 10,625 (+5s) |
Methodology:
- 50/50 splits -> midpoint (e.g., Katamba cycle: (21060+21120)/2 = 21090)
- Skewed splits -> weighted average (e.g., Xibar visible: 0.27×10440 + 0.73×10500 = 10484)
With the old constants (e.g., xibar cycle=20852), the model predicted to the second, but actual events only fired on 60s boundaries. This caused:
- Systematic +-30s "error" each cycle as events snapped to tick boundaries
- Offset chasing - the self-correction logic constantly adjusted offsets
- Drift accumulation - if constants were off-center, offsets grew in one direction
By centering constants at the midpoint of observed bands, the +-30s jitter becomes symmetric around zero rather than accumulating.
-
Sun tracking - Could apply same epoch-based approach to sun rise/set✓ Implemented - Offset sharing - Could sync offsets via Firebase instead of raw events
-
Constant validation - Debug mode reports drift to help refine constants over time✓ Logging reimplemented with raw intervals -
Time conversion commands - Add
/timecommand for date conversion (like TimeTracker) -
Seasonal moon analysis - Use collected CSV data to determine if moon periods vary by season✓ Analyzed - cycles are constant -
Per-character settings - Store offsets per-character instead of per-game✓ Implemented (v2.6) -
Auto-reset runaway offsets - Reset offsets that grow too large✓ Implemented (v2.6, threshold 1800s; later raised to 10,500s moon / 10,800s sun as an epoch-mismatch guard) -
Self-updating moon constants (v3) - Eliminate manual recalibration entirelySuperseded by v4.0 — split ratio analysis proved constants are the game's exact integer values; no recalibration needed - Server reset tracking - Document and analyze moon phase shifts after monthly resets
-
Sun amplitude confirmation - Observe winter/summer solstice to finalize amplitude✓ Confirmed: B=29.51 (v3.5) - Spring/summer sun data - Collect days 1-239 to complete the full-year truth table and potentially reverse-engineer the game's exact sun algorithm
-
Sun lookup table - Build a 400-day day_of_year to rise_rois mapping from observed events, replacing the cosine formula for observed days✓ Implemented (v4.1.0). 400-entry rise and set tables embedded inDRTime, cosine kept as a fallback; set stored independently rather than derived as 360 - rise.
Date: March 2026 Status: Investigation phase
Analysis of moon data around the monthly server reset revealed phase shifts:
| Moon | Pre-Reset Offset | Post-Reset Offset | Phase Shift |
|---|---|---|---|
| Katamba | 60s | 60s | 0s (no change) |
| Xibar | 90s | 163s | +73s (later) |
| Yavash | -24s | -35s | -11s (earlier) |
Key finding: Xibar showed a significant +73s phase shift after the server reset, beyond normal +-30s tick noise. This confirms speculation that server resets can affect moon phases.
- Are phase shifts consistent across resets? (always same direction/magnitude)
- Are they random? (different each reset)
- Are they correlated? (all moons shift together, or independent)
- Does shutdown duration matter?
- Is server_time continuous across resets?
| Resets | Confidence | Timeline |
|---|---|---|
| 1 | Anecdotal | Current |
| 3 | Preliminary pattern | 3 months |
| 5 | Moderate confidence | 5 months |
| 10+ | High confidence | 10+ months |
1. Shutdown detection (SHUTDOWN_PATTERN):
- Pattern:
/DragonRealms will be shutting down/i - Triggered in main event loop
- Calls
reset_tracker.log_shutdown(game_time, current_offsets)
2. Gap detection (ServerResetTracker.check_for_restart):
- Threshold:
GAP_THRESHOLD_SECONDS = 21480(max cycle + 5min buffer) - Called in both
MOON_SET_PATTERNandMOON_RISE_PATTERNhandlers - Returns
trueif gap exceeds threshold, triggering phase analysis
3. Reset log file:
Location: $lich_dir/data/server_resets_<CharName>.csv
| Column | Description |
|---|---|
| event_type | 'shutdown', 'restart_detected', or 'phase_shift' |
| server_time | XMLData.server_time |
| timestamp | Human-readable timestamp |
| katamba_offset | Offset at event time |
| xibar_offset | Offset at event time |
| yavash_offset | Offset at event time |
| katamba_shift | Phase shift (phase_shift events only) |
| xibar_shift | Phase shift (phase_shift events only) |
| yavash_shift | Phase shift (phase_shift events only) |
| gap_seconds | Time since last event (restart_detected only) |
| trigger_moon | Moon that triggered detection (restart_detected only) |
4. Post-restart analysis (ServerResetTracker.analyze_phase_shifts):
- Compares pre-shutdown offsets to current offsets
- Logs phase_shift event to CSV
- Alerts user for significant shifts (>60s)
-
Add shutdown detection hook✓ v2.14 -
Add gap detection in moon_change✓ v2.14 -
Create server_resets CSV logging✓ v2.14 -
Add pre/post offset comparison✓ v2.14 - Document each reset as it occurs (ongoing)
- After 3 resets, analyze for patterns (pending data)
-
MoonwatchInstance. Explicit game-instance awareness. The moon and sun periods are game-code constants (cross-validated against the DR client's hard-coded sidereal periods to ~0.05s), identical on every instance; offsets are already scoped per"#{XMLData.game}:#{XMLData.name}"inCharSettings, so each instance/character self-calibrates independently. Non-Prime instances (Platinum/ Fallen/Test) print a one-time startup notice. This supersedes the old Prime-only refuse-and-exit guard. -
Self-aware, self-updating
moonalias (MoonwatchAlias). The alias body is gated onScript.running?('moonwatch'), so it reports "moonwatch is not running" instead of echoing stale data (crash-proof; does not depend on exit cleanup). On startup,resyncreads the alias service DB (data/alias.db3, tableglobal) and silently upgrades an existing moonwatchmoonalias to the current body if it differs. It never creates an alias the user did not ask for and never overwrites an unrelatedmoonalias. -
UserVars.*['running']freshness flag.MoonwatchUI.set_runningmarks moon/sun/calendar data live at startup and stale on a clean exit (viabefore_dying), non-destructively. - Retired the crowdsource layer for good (the offline engine had already replaced it in v4.0) and folded in the v4.4.2 observe work below, making the upstream script a superset of the prior personal fork so the fork could be retired.
The v4.4.0 observe parser matched only ^The <colour> moon <M> is <phase>., so it
captured just the waxing-crescent form and silently dropped the other seven (e.g.
full is "forms a perfect circle in the heavens"). Capture is now scoped to the
observe response (via the "You scan the skies" line) and matches any phase clause.
Wordings are mapped to phase names by Moons::OBSERVED_PHASE_MAP; four are mapped
and model-validated so far (waxing crescent, waxing gibbous, full, waning
crescent), and unmapped wordings are logged raw so the remaining four phases can
be added as they are seen. log_phase_observation reports mapped-vs-model
MATCH/MISMATCH/unmapped in debug.
Adds each moon's current phase and an approximate next-phase countdown to the
startup debug summary, and exposes next_phase / next_phase_seconds via
UserVars. Phase buckets last roughly a real day.
While ;moonwatch log is on, the script sends observe <moon> on each rise
broadcast and logs the game's phase wording against the computed phase, building
the phrase-to-phase map automatically. Rise broadcasts only reach a client that is
outdoors, so it self-gates to observable moments; the 60s dedup prevents a manual
observe and the auto-observe from double-logging.
Adds each moon's lunar phase (new / waxing crescent / first quarter / waxing
gibbous / full / waning gibbous / third quarter / waning crescent) and a
sky-position t (0..1 across the current arc). Phase is computed from the DR
client's own sidereal orbital periods and calendar skew (kept self-contained in
Moons.phase, not fed from the calibrated CALENDAR_EPOCH). It is model-only
(no passive phase broadcast to correct against) but validated against the in-game
observe <moon> verb.
The server_resets CSV only ever held shutdown rows, never restart_detected
or phase_shift, even with moonwatch on autostart. Two compounding bugs:
-
@last_moon_eventswas in-memory only. A server reset relaunches the script via autostart, so the fresh process started with all last-event times nil. The first post-restart event hitreturn false unless last_eventand the gap was never seen. Gap detection could only fire within one continuous run, which a server reset prevents. Fix: persistlast_moon_eventstoCharSettings(load on init, save on each event), mirroring howpre_shutdown_offsetsalready survived restarts. -
Phase shift was measured before the correction that reveals it.
analyze_phase_shiftsran beforecorrect_moon_offsetusing pre-correction offsets, which after a restart still equal the persisted pre-shutdown values, so every shift computed as zero. Fix: replaced it with a per-moonrecord_phase_shift, called AFTER the correction, logging (corrected offset minus pre-shutdown offset) for that moon. Each moon logs once on its first post-restart event; the snapshot clears after all three re-observe.
Also removed the dead update_event_time method (flagged in the earlier audit).
Added ServerResetTracker spec coverage (persistence across a simulated restart,
per-moon shift math, snapshot clearing). 123 examples, 0 failures.
No user action required; this only affects diagnostic logging.
The integer cycle constants differed from the game's true period by up to half a second (e.g. katamba 21089 vs true 21088.611, +0.39s/cycle), which accumulated as phase drift and required periodic epoch re-centering (v3.3 through v4.0.1). The cycle constants are now the OLS slope (katamba 21088.611, xibar 20848.143, yavash 21129.564) and the epochs are the OLS intercepts, fit from ~1,900 rise events over ~1.2 DR years across 2 characters.
- No more drift. Replaying ~850 real events per moon from a zero offset keeps the offset within about 20s with no upward trend (was reaching ~85s in 6 weeks). Periodic re-centering is no longer needed.
-
Implementation.
cycleis now a Float.calculate_positionandcorrect_moon_offsetfloor the cycle-count division,nearest_tickfloors so it accepts fractional input, and the drift correction is rounded so persisted offsets stay integer second counts. - Auto-reset threshold (this also records the previously-undocumented change the audit flagged): the moon/sun offset auto-reset is 10,500s / 10,800s (about half a cycle / half a day), an epoch-mismatch guard, not the original v2.6 1800s value.
Action required: run ;moonwatch reset once per character after updating; the
moon epochs were re-anchored, so stored offsets from before v4.2 are stale.
The seasonal cosine (rise_rois = round(90 + 29.51 * cos(2*pi*d/400))) hit its
mathematical ceiling at about 69% exact-rois match (mean error ~26s). The game's
sun function is close to but not a true cosine, so no (base, amplitude, phase)
fit does better. The cosine is now only a fallback. sun_times_for_day reads two
400-entry empirical tables (SUN_RISE_ROIS_TABLE, SUN_SET_ROIS_TABLE) keyed by
day_of_year, built from about 1,900 observed sun events across 2 characters
(~1.2 DR years).
- About 100% exact-rois cold-start accuracy (was ~69%); the roughly 30% of days the cosine missed by 1 or 2 rois are now exact.
- Rise and set stored independently. rise_rois + set_rois is 360 on about 84% of days but 358 or 361 on the rest, so set is no longer derived as 360 - rise.
- Gap days interpolated. Rise gaps (27, 123, 124, 167, 366) and the set gaps have no observations; they are linearly interpolated from neighbors.
-
Storage: the tables are embedded constants in the
DRTimemodule (so they ship and version with the script). A range guard sends out-of-range or negative days to the cosine fallback instead of indexing from the array end. - Regenerate from
sunwatch_events_*.csvas more data accumulates.
Implements the path documented in the technical reference section 4.4, and resolves Future Considerations item 12.
Offsets had drifted ~85s for Katamba and Yavash due to integer cycle rounding (~0.13s/cycle accumulation). Xibar had drifted -11s.
| Moon | Old Epoch | Center | New Epoch | Delta |
|---|---|---|---|---|
| Katamba | 1,771,558,797 | +85s | 1,771,558,712 | -85s |
| Xibar | 1,771,560,080 | -11s | 1,771,560,091 | +11s |
| Yavash | 1,771,555,053 | +85s | 1,771,554,968 | -85s |
Cycle and visible constants validated -- no changes needed. All Bresenham split ratios match theory within 2.8% (n=481-789 per constant).
Sun model: Spring equinox data (days 50-99, 51.1% accuracy) confirms the v3.5 model ceiling. Brute-force search over 630 formula variants found nothing above 80.1%. The ~20% equinox misses are inherent to cosine models and are handled by the offset correction system.
Two bugs prevented phase_shift analysis:
-
log_restartpassednilfor offsets, so restart_detected CSV rows had blank offset columns. Now passes all current offsets. -
@pre_shutdown_offsetswas in-memory only. If the script restarted between shutdown and first post-restart moon event (which always happens), the pre-shutdown data was lost. Now persisted toCharSettings.
After disconnect/reconnect, the moon window stayed stale because update_window
only emitted XML when the display string changed. Now the cache is invalidated
every 60 seconds, ensuring at least one re-emit per minute regardless of game
traffic.
Action required: Run ;moonwatch reset on each character after updating.
Problem: The pre-v4.0 prediction time_until_set = visible - position always used the
integer constant (e.g., 10602 for katamba visible) as if the next event would happen at
exactly that offset. But the game quantizes events to 60s tick boundaries, so the actual
interval is either 10560 or 10620 — never 10602. Every prediction was wrong by up to 42s.
Solution: Compute the exact 60s tick boundary the game will fire on using Bresenham-style integer arithmetic:
true_set = e_eff + n * cycle + visible
tick_set = nearest_tick(true_set) # round to nearest 60s
time_until_set = tick_set - game_time # countdown to actual tickThis predicts the correct quantized event time rather than an impossible average.
New module methods:
-
Moons::TICK_DURATION = 60— server tick interval constant -
Moons.nearest_tick(true_time)— rounds to nearest 60s boundary
Split ratio analysis proved that (P mod 60) / 60 matches the observed long/short interval
distribution for all six constants (3 moons × cycle + visible):
| Constant | Value | P mod 60 | Expected long% | Observed long% | n |
|---|---|---|---|---|---|
| katamba cycle | 21089 | 29 | 48.3% | 47.7% | 348 |
| xibar cycle | 20848 | 28 | 46.7% | 46.8% | 380 |
| yavash cycle | 21130 | 10 | 16.7% | 17.3% | 393 |
| katamba visible | 10602 | 42 | 70.0% | 70.9% | 213 |
| xibar visible | 10482 | 42 | 70.0% | 69.9% | 236 |
| yavash visible | 10624 | 4 | 6.7% | 6.9% | 217 |
Implication: The game uses integer-second arithmetic (1990s codebase). The Bresenham alternation pattern (e.g., 21060/21120 for katamba cycle) is deterministic — each cycle is floor(P/60) or ceil(P/60) ticks, depending on accumulated phase. No manual recalibration needed; the v3 self-updating constants design is superseded.
Problem: Pre-v4.0, the offset correction fired on every observed event, applying a drift correction of 18-42s. This caused a ±30s sawtooth oscillation in offsets — the correction chased the difference between the integer constant and the quantized interval.
Solution: Only correct when the Bresenham tick prediction was wrong (event fired on a different 60s tick than predicted). When the prediction was correct, the offset stays unchanged.
tick_error = (game_time - predicted_tick).abs
if tick_error <= TICK_DURATION / 2
# Tick prediction correct — no correction needed
else
# Tick prediction wrong — realign via standard drift correction
endExpected behavior: Offsets stay constant for long stretches (~100+ cycles between corrections). Corrections only fire when accumulated drift from integer constant rounding pushes a tick prediction across a boundary. Server resets trigger the existing mismatch branch for immediate recalibration.
Discovery: XML log analysis confirmed that moon and sun events trigger server prompts
with fresh server_time values. The v2.8 refresh_server_time function (which issued a
time command after each event) was redundant — it received the same timestamp as the
event's own prompt, while adding unnecessary game command overhead.
Xibar slowly rises above the horizon.
You sense an overall increase in Lunar mana.
<prompt time="1776646970">></prompt> ← event's own prompt (exact tick boundary)
It has been 456 years, 76 days since... ← unnecessary time command output
<prompt time="1776646970">></prompt> ← same timestamp, zero jitter
Analysis of measurement jitter: The mod-60 clustering previously attributed to
refresh_server_time jitter (mod 21/22/50 for moons, mod 48/11 for sun) was actually
measuring the tick boundaries themselves. The time command added 0-1s of jitter at most,
not the 1-30s previously assumed. Transitions between mod classes correlated exactly with
server resets (March 4: mod 21→22, April 8: mod 22→50).
Key finding: Moon and sun events fire on different 60s tick grids. Post-April-8: moon ticks at server_time mod 60 = 50, sun ticks at mod 11. This is expected — moon events are aligned to moon epochs, sun events are aligned to calendar rois boundaries (CALENDAR_EPOCH mod 60 = 48, matching pre-reset sun events).
Event handlers now use XMLData.server_time directly. The refresh_server_time function
has been removed.
Action required: Run ;moonwatch reset on each character after updating.
Bug: The v3.5 re-centering applied epoch += center_offset but the correct
formula is epoch -= center_offset (since position = (t + offset - epoch) % cycle,
subtracting center from epoch absorbs the positive offset bias).
Symptom: After resetting offsets to 0, they immediately jumped to ~153 (Yavash), ~92 (Katamba), ~-66 (Xibar) on the first observed event. The sawtooth oscillated around these wrong centers instead of around 0.
Not a server reset: The April 8 server shutdown captured offsets katamba=117, xibar=-50, yavash=157 -- these are the wrong-sign epoch bias, not a phase shift from the reset.
| Moon | v3.5.0 Epoch (wrong) | v3.5.1 Epoch (correct) | Delta |
|---|---|---|---|
| Katamba | 1,771,558,907 | 1,771,558,797 | -110s |
| Xibar | 1,771,560,014 | 1,771,560,080 | +66s |
| Yavash | 1,771,555,185 | 1,771,555,053 | -132s |
Each delta is exactly -2 * center_offset (correcting the +center that should have been -center).
Action required: Run ;moonwatch reset on each character after updating
to clear the accumulated wrong-sign offsets.
Data: 150 sun observations + 1,700 moon events across days 240-399 and day 0 (summer through winter solstice), from 2 characters (Quilsilgas + Ytterby).
Key observation: Winter solstice data definitively resolved the amplitude:
- Days 385-398: day_length=7320s (rise_rois=119)
- Day 399: day_length=7200s (rise_rois=120)
- Day 0: day_length=7200s (rise_rois=120)
- Both characters confirm identical values
Constraint derivation: The day 398->399 transition pins the amplitude:
-
round(90 + B × cos(2π×399/400)) >= 119.5requires B >= 29.504 -
round(90 + B × cos(2π×398/400)) < 119.5requires B < 29.515 - B in [29.504, 29.515), so B = 29.51
This resolves all prior "carried forward" amplitude notes:
- A=29 too low: predicted day_length=7320 at day 0, observed 7200
- A=30 too high: predicted day_length=7200 at day 389, observed 7320
- A=29.5 almost works but round(119.4964) = 119 at day 399, not 120
| Constant | Old | New | Source |
|---|---|---|---|
SUN_RISE_AMPLITUDE_ROIS |
29 | 29.51 | Solstice constraint [29.504, 29.515) |
Model ceiling analysis: Proved that no values of (base, amplitude, phase) can satisfy all 150 observations simultaneously. The game's sun function is not a pure cosine. Accuracy by region:
- Fall equinox (days 240-300): ~55% -- model predicts transitions 1 day too late
- Winter approach (days 301-399): ~95% -- flat cosine region, few transitions
- All misses are exactly +/-1 rise_rois (60 seconds), handled by offset correction
Summer solstice prediction (unobserved, days 1-239 not yet logged):
- Days 199-201: rise_rois=60, day_length=14400s (~4.0h)
- Reliable prediction given tight amplitude constraint
OLS regression on 144-148 unique rises (164-166 cycles) confirms v3.4 cycle values:
| Moon | v3.4 OLS Cycle | v3.5 OLS Cycle | Integer | Drift/Cycle |
|---|---|---|---|---|
| Katamba | 21,088.68 | 21,088.67 | 21,089 | -0.28s |
| Xibar | 20,848.19 | 20,848.19 | 20,848 | +0.19s |
| Yavash | 21,129.64 | 21,129.63 | 21,130 | -0.37s |
Fractional cycles unchanged to <0.01s. Residual std ~17.5s for all three moons. Moon interval prediction accuracy: 100% (all observed intervals land on exactly one of two quantized values per moon).
The v3.4 OLS epochs were optimized for fractional cycle slopes. Since the script uses integer cycle constants, the sub-second rounding error accumulates as offset drift. Analysis of 164 cycles showed offsets had drifted 50-80s from center, producing asymmetric sawtooth patterns (e.g., Yavash cycling 41-91 instead of +-25).
Root cause: Integer cycle constant differs from true fractional cycle by 0.19-0.37s. Each cycle, the offset grows by this amount. Over 164 cycles, Yavash accumulated +66s, Katamba +55s, Xibar -33s.
Fix: Re-anchor epochs to center the sawtooth at 0 based on current offsets.
The correct formula is epoch_new = epoch_OLS - center_offset, because
position = (t + offset - epoch) % cycle -- subtracting the center from
the epoch absorbs it, making offset=0 the new center.
v3.5.0 had a sign error (epoch += center instead of epoch -= center),
which doubled the bias instead of eliminating it. Fixed in v3.5.1.
| Moon | OLS Epoch | Center | Correct Epoch | Delta | New Offset Range |
|---|---|---|---|---|---|
| Katamba | 1,771,558,852 | +55s | 1,771,558,797 | -55s | [-29, +29] |
| Xibar | 1,771,560,047 | -33s | 1,771,560,080 | +33s | [-28, +28] |
| Yavash | 1,771,555,119 | +66s | 1,771,555,053 | -66s | [-25, +25] |
Note: The sawtooth will slowly drift again (~0.2-0.4s/cycle from integer rounding). At this rate, re-centering every ~6 months keeps offsets within +-50s. Re-centered again in v4.0.1 (May 2026) after 6 weeks of drift to ~85s.
57 restart_detected events logged across 17 characters, but 0 shutdown events captured (no character observed the shutdown announcement). Without pre/post offset comparison, phase shifts cannot be measured. The only prior data point remains the manually analyzed March 4 reset (Xibar +73s, Katamba 0s, Yavash -11s). Need 3-5 resets with both shutdown and restart captured to draw conclusions.
Method: OLS regression on all rise timestamps with gap-corrected indices, extended dataset from v3.3 (155 cycles vs 126). Xibar included for the first time -- it was skipped in v3.3 due to near-zero drift at the time, but had since accumulated +2.8s/event drift from its stale epoch.
Data: 1,599 moon events + 596 sun events across days 240-392 (summer through winter). 134-140 unique rise timestamps per moon over 155 cycles, confirmed identical across both characters (Quilsilgas + Ytterby).
| Moon | Param | Old | New | Regression Value | Method |
|---|---|---|---|---|---|
| Xibar | epoch | 1,771,539,275 | 1,771,560,047 | OLS intercept | Re-anchored (+20,772s -- ~1 full cycle off) |
| Xibar | visible | 10,481 | 10,482 | 10,481.8 | Mean of 224 rise-to-set pairs |
| Yavash | epoch | 1,771,555,097 | 1,771,555,119 | OLS intercept | Re-anchored (+22s) |
| Yavash | visible | 10,623 | 10,624 | 10,623.8 | Mean of 204 rise-to-set pairs |
| Katamba | epoch | 1,771,558,832 | 1,771,558,852 | OLS intercept | Re-anchored (+20s, centers offset range from [1,55] toward [-19,35]) |
No cycle constant changes -- all three confirmed at current values:
| Moon | OLS Cycle | Integer | Drift/Cycle | Interval Distribution |
|---|---|---|---|---|
| Katamba | 21,088.68 | 21,089 | -0.24s | 21060 x172 / 21120 x154 |
| Xibar | 20,848.19 | 20,848 | +0.15s | 20820 x194 / 20880 x167 |
| Yavash | 21,129.64 | 21,130 | -0.32s | 21120 x271 / 21180 x57 |
Residual analysis (all three moons, 155 cycles):
| Moon | Residual Std | Residual Range | Drift (first 10 vs last 10) |
|---|---|---|---|
| Katamba | 17.6s | [-30, +30] | +9.1s |
| Xibar | 17.4s | [-29, +31] | -4.6s |
| Yavash | 17.4s | [-30, +30] | +2.4s |
Root cause of Xibar drift: The v3.3 regression re-anchored Katamba and Yavash epochs but skipped Xibar. Over time, the stale epoch accumulated ~1 full cycle of error (20,772s), which the offset system absorbed -- explaining why Xibar offsets consistently hovered at 18-77s while Katamba and Yavash stayed near 0.
Sun amplitude: Resolved in v3.5. See v3.5 changelog entry.
Method: OLS regression on all rise timestamps with gap-corrected indices, cross-validated across both characters (Quilsilgas + Ytterby). The regression minimizes cumulative drift across the full observation window, unlike the previous weighted-average approach which was sensitive to local noise.
Data: 1,977 total events (moon + sun) across days 240-363 (summer through winter). 112 unique rise timestamps per moon over 126 cycles, confirmed identical across both characters to within measurement noise (residual std ~17.5s from rois quantization).
| Moon | Param | Old | New | Regression Value | Method |
|---|---|---|---|---|---|
| Katamba | cycle | 21,088 | 21,089 | 21,088.67s | OLS on 112 rises, 126 cycles |
| Katamba | visible | 10,601 | 10,602 | 10,601.7s | Mean of 105 rise-to-set pairs |
| Katamba | epoch | 1,771,537,811 | 1,771,558,832 | OLS intercept | Re-anchored to new cycle |
| Yavash | cycle | 21,132 | 21,130 | 21,129.62s | OLS on 112 rises, 126 cycles |
| Yavash | visible | 10,625 | 10,623 | 10,623.5s | Mean of 104 rise-to-set pairs |
| Yavash | epoch | 1,771,533,944 | 1,771,555,097 | OLS intercept | Re-anchored to new cycle |
| Xibar | - | - | - | 20,848.x | No change needed (near-zero drift) |
Residual analysis (with new integer constants):
| Moon | Cycle | Rise Residual Std | Rise Residual Range | Drift (first 10 vs last 10) |
|---|---|---|---|---|
| Katamba | 21,089 | 21.4s | [-45, +49] | -35.5s over 126 cycles |
| Yavash | 21,130 | 22.0s | [-45, +44] | -41.0s over 126 cycles |
Both moons show mild negative drift with the rounded-up integer cycle, which is expected since the true fractional cycles (21,088.67 and 21,129.62) are below the integer values. This drift rate (~0.3s/cycle) is well within the 60s rois quantization band and far better than the previous constants:
- Yavash old (21,132): was drifting +5.8s/event, offset climbed from ~64 to ~279
- Katamba old (21,088): was drifting -0.55s/event, offset walked from 0 to -55
Interval distributions confirm the game's rois quantization pattern:
| Moon | Low Interval | High Interval | Split | Implied Fractional Cycle |
|---|---|---|---|---|
| Katamba | 21,060s x54 | 21,120s x50 | 52/48 | ~21,089 |
| Yavash | 21,120s x82 | 21,180s x18 | 82/18 | ~21,131 |
Sun amplitude: Resolved in v3.5. See v3.5 changelog entry.
Analysis: 1,191 moon events across 679 hours (2 characters) revealed systematic offset drift:
| Moon | Offset Drift | Drift/Cycle | Action |
|---|---|---|---|
| Katamba | -25s / 679h | -0.21s/cycle | No change (negligible) |
| Xibar | +44s / 679h | +0.38s/cycle | No change (tolerable) |
| Yavash | +111s / 679h | +0.96s/cycle | 21131 -> 21132 |
Yavash cycle distribution: 21120 (83%) / 21180 (17%), weighted avg 21130.3. The +0.96s/cycle drift means predictions arrive ~1s early each cycle, accumulating to 100+ seconds of offset over a few weeks. Changing to 21132 reduces drift to ~0.04s/cycle (negligible).
Sun amplitude: Resolved in v3.5. B=29.51, constrained to [29.504, 29.515) by winter solstice data. See v3.5 changelog entry.
Discovery: Analysis of 82 sun events revealed that all sunrise/sunset times land on exact rois (60-second) boundaries when the epoch is shifted +745s. The previous epoch had accumulated 799s of calendar error -- it reported rois 26 when the game said rois 12.
Verification: Two "positive" (exact) in-game TIME readings at prompt-tagged server times:
- server_time 1773308202: game says rois 20, epoch gives 20.9 -> floor 20 ✓
- server_time 1773308339: game says rois 23, epoch gives 23.18 -> floor 23 ✓
Root cause: The original epoch was derived from a single approximate TIME reading ("Rois ~15"), then a -380s "recalibration" in 2025-02 made it worse (moved from 7 to 14 rois error). The sun event data (82 observations all aligning to rois boundaries) provided a much stronger calibration signal than any single TIME reading.
Discovery: The game does NOT quantize day lengths to 120-second buckets. It quantizes sunrise and sunset independently to individual rois (60s). The apparent 120s day-length steps are a consequence of both rise and set each stepping by 60s in the same direction.
Key findings from sun event analysis:
- All rise/set times are exact multiples of 60s (with corrected epoch)
- rise_seconds + set_seconds = 21600 always (perfect midday symmetry)
- Rise step sizes: always exactly 0 or 60 seconds between consecutive days
- Each rois level lasts 2-4 days (sinusoidal dwell pattern)
New formula:
rise_rois = round(90 + 29 × cos(2πd/400))
set_rois = 360 - rise_rois
rise_seconds = rise_rois × 60
set_seconds = set_rois × 60Old constants removed: SUN_DAY_LENGTH_BASE, SUN_DAY_LENGTH_AMPLITUDE, SUN_PHASE_SHIFT, SUN_QUANTUM
New constants: SUN_RISE_BASE_ROIS = 90, SUN_RISE_AMPLITUDE_ROIS = 29, ROIS_PER_DAY = 360
Accuracy: 82% exact rois match (66/81 observed days). Offset correction handles the rest. Amplitude resolved in v3.5: B=29.51. See v3.5 changelog entry.
| Moon | Param | Old | New | Method |
|---|---|---|---|---|
| Katamba | cycle | 21090 | 21088 | Weighted avg 21087.4, +139s drift/483h |
| Katamba | visible | 10590 | 10601 | Weighted avg 10601.1 (32/68 split) |
| Xibar | cycle | 20850 | 20848 | Weighted avg 20847.9, +253s drift/483h |
| Xibar | visible | 10484 | 10481 | Weighted avg 10480.6 (32/68 split) |
| Yavash | cycle | 21129 | 21131 | Weighted avg 21130.9, -71s drift/483h |
| Yavash | visible | 10625 | 10625 | Stable (no change) |
Drift correction method: The net offset drift over 483 hours reveals whether a constant is slightly too high (positive drift = predicted early) or too low (negative drift = predicted late). The corrected values eliminate ~1-3s/cycle systematic drift.
- Server reset tracking scaffolding: Implemented comprehensive reset detection and logging
-
Shutdown detection: New
SHUTDOWN_PATTERNcatches "DragonRealms will be shutting down" messages -
Gap detection:
ServerResetTrackerclass detects restarts via event gaps exceeding cycle + 5min buffer -
CSV logging: New
server_resets_<CharName>.csvcaptures:-
shutdown: Pre-reset offset snapshot when shutdown announced -
restart_detected: When gap indicates missed events (with gap duration, trigger moon) -
phase_shift: Post-restart offset comparison showing shifts per moon
-
- Phase shift alerts: Significant shifts (>60s) generate user-visible messages
- Pre/post comparison: Offsets captured at shutdown are compared to post-restart values
-
SOLID architecture:
ServerResetTrackerhas single responsibility, follows existing patterns
- Sun formula overhaul: Reverse-engineered game formula from Elanthipedia reference and observed data
- Quantization discovery: Identified 120-second day-length steps (later found to be a consequence of independent 60s rise/set quantization -- see v3.1)
-
Sun constants (removed in v3.1):
SUN_DAY_LENGTH_BASE,SUN_DAY_LENGTH_AMPLITUDE,SUN_PHASE_SHIFT,SUN_QUANTUM - Claimed accuracy: 96.3% on 54 days, but re-evaluation on 78 days showed ~73% with quantized predictions
-
Server reset analysis: Documented March 4 reset impact on moon phases
- Xibar shifted +73s (significant)
- Katamba unchanged
- Yavash shifted -11s (within noise)
-
Moon quantization analysis: Investigated applying sun-style quantization to moons
- Finding: Moon quantization is non-deterministic (depends on cumulative phase)
- Conclusion: Current weighted-average + self-correction approach is optimal
- Expanded dataset analysis: Recalibrated from 334 moon events (2 characters, days 240-271)
- Weighted average methodology: For non-50/50 distributions, use weighted average instead of midpoint or dominant value
-
Xibar visible duration fix: Changed 10470 -> 10484 (+14s)
- Distribution: 10440 (27%), 10500 (73%)
- Weighted avg: 0.27×10440 + 0.73×10500 = 10484
-
Yavash cycle fix: Changed 21120 -> 21129 (+9s)
- Distribution: 21120 (85%), 21180 (15%)
- Weighted avg: 0.85×21120 + 0.15×21180 = 21129
- v2.11 used dominant value which caused slow +9s/cycle drift
-
Yavash visible duration fix: Changed 10620 -> 10625 (+5s)
- Distribution: 10620 (91%), 10680 (9%)
- Weighted avg: 0.91×10620 + 0.09×10680 = 10625
- Katamba unchanged: Both cycle (21060/21120) and visible (10560/10620) are true 50/50, midpoint remains optimal
- Xibar cycle unchanged: 20820/20880 at 56/44 is close enough to 50/50, midpoint 20850 remains optimal
- Yavash cycle constant fix: Changed from 21150 -> 21120
- v2.9 assumed 50/50 distribution between 21120/21180 (midpoint theory)
- Actual distribution from 32 observations: 78% at 21120, 22% at 21180
- Using midpoint caused +16.8s net drift per cycle, accumulating to 300-450s over time
- Now using dominant value (21120) -- expect occasional -60s correction when 21180 occurs
- Katamba and Xibar remain at midpoints (their distributions are truly ~50/50)
- Removed drift threshold: Previously, only drifts under 360s (6 minutes) were corrected; larger drifts were ignored
- The threshold was a holdover from Firebase sync era to protect against stale cross-client data
- In pure-observation model, every event is freshly witnessed - no stale event concern
- Now all observed drift is corrected immediately, regardless of magnitude
- This fixes cases where large prediction errors (e.g., 828s) went uncorrected
-
Moon constant recalibration (from pre-20250220 CSV data):
- Katamba: visible 10620->10590 (observed 10560/10620, midpoint 10590)
- Yavash: visible 10650->10620 (observed 10620 only)
- 60-second server tick discovery: Identified that the game server fires moon events on 60s tick boundaries
- All observed intervals are exact multiples of 60s (e.g., 20820/20880 for xibar, never 20850)
- This explains why offsets were oscillating/drifting despite second-level timestamp precision
-
Constant recalibration: Set all constants to midpoints of observed 60s bands:
- Katamba: visible 10601->10620 (cycle unchanged at 21090)
- Xibar: cycle 20852->20850, visible 10482->10470
- Yavash: cycle 21129->21150, visible 10623->10650
- Midpoint values center the +-30s tick jitter around zero, preventing systematic drift
-
Timestamp refresh on events: Moon/sun event handlers now issue a
timecommand to force a freshXMLData.server_timebefore capturing the timestamp - Previously, timestamps could be minutes stale if the player was idle (prompts only arrive with commands)
- Now timestamps are accurate to within the command round-trip latency (~100-500ms)
- This should significantly reduce the 20-30s standard deviation in calibration measurements
-
v4.0 note: XML log analysis proved the initial assumption was wrong — events DO trigger prompts with fresh timestamps. The
timecommand was redundant (zero jitter). Removed in v4.0.
-
Sun event logging: New
sunwatch_events_<CharName>.csvcaptures sunrise/sunset intervals - Logs
day_interval,day_length,night_length,expected_day_length, anddrift - Validates CALENDAR_EPOCH accuracy and sinusoidal day length formula
-
Constant recalibration: Updated from 443 observations (days 53-135)
- Katamba: cycle 21084->21090, visible 10608->10601
- Yavash: cycle 21133->21129, visible 10632->10623
- Xibar: unchanged (within 3s of observed)
-
Per-character storage: All offsets now stored in
CharSettingsinstead ofGameSettings - Auto-reset: Offsets exceeding +-1800s are automatically reset to 0
-
Raw interval logging: CSV now logs
cycle_interval,visible_dur,hidden_durinstead of model-relativedrift -
Logging toggle: New
log/nologarguments (per-character, persistent) -
Code cleanup: Removed redundant
SEASONSandTIMES_OF_DAYarrays; methods now return strings directly
- Firebase data guards: staleness check and local observation preference
- Constant calibration from moondata.2024 analysis
- Initial moon event logging (with drift column)
- Drift detection for timing corrections
- Sun tracking and DRTime module
- Epoch-based moon calculations, replacing Firebase-dependent approach