v1.3.3: server restart detection, correction from server uptime, new accuracy marks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyQKCL92ZLwPi9qmYQtNdc
This commit is contained in:
Claude
2026-10-12 10:58:47 -04:00
parent b91e45fc17
commit 98a5fa03d5
4 changed files with 224 additions and 12 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 1.3.3 (12 October 2026)
### New
- **Server restarts are handled.** OctoBus asks the server how long it has been up (`.server info`, sent quietly when you log in; its reply is hidden) and so knows exactly when the server restarted. Routes not seen since a restart are marked with an orange `?`, and the footer says when the restart was.
- **Timers corrected from the restart time.** Each route docks a fixed number of seconds into its trip after a restart. OctoBus learns this once per route, and after a restart moves every route straight to the right time. Once this has been seen to hold across a restart, corrected routes lose the `?`.
- The marks now mean: no mark = seen since the last restart (accurate), `~` = an estimate, `?` = may be off after a restart. Before, `~` only meant "not seen since you logged in".
### Fixed
- A sighting up to about 1.5 minutes off the schedule was taken as normal and could bend the learned trip length. Only differences that drift can explain are used now (10 s plus half a second per hour since the last sighting).
## 1.3.2 (12 October 2026)
### New
+209 -10
View File
@@ -151,6 +151,10 @@ local OB_OK, OB_ERR = pcall(function ()
if c.locked == nil then c.locked = false end
if c.travel == nil then c.travel = 175 end
t.pending = t.pending or {}
t.seenAt = t.seenAt or {}
t.bootPhase = t.bootPhase or {}
t.bootPhaseBoot = t.bootPhaseBoot or {}
t.corrected = t.corrected or {}
if c.ver ~= 2 then -- v2: alerts are opt-in per route (bell) and chat-only by default
c.ver = 2
c.chat = true
@@ -414,6 +418,169 @@ local OB_OK, OB_ERR = pcall(function ()
local dllOk = false
local lastGood = 0
-- Server uptime. ".server info" tells how long the server has been up, so the restart
-- time is known exactly (DB.boot). Transports on this core run on server time, so each
-- route docks a fixed number of seconds into its cycle after a restart (DB.bootPhase).
-- Once that has been seen to hold across a restart (DB.bootTheory), every route is
-- corrected straight after a restart, before any of them is seen.
local uptimeAsked, uptimeHideUntil = 0, 0
local function QueryUptime()
if GetTime() - uptimeAsked < 120 then return end
uptimeAsked = GetTime()
uptimeHideUntil = GetTime() + 8
SendChatMessage(".server info", "SAY")
end
local function LearnPhase(r, anchor)
if not DB.boot or anchor < DB.boot then return end
local P = Period(r)
if not P or P <= 0 then return end
local e = r.entry
local c = math.mod(anchor - DB.boot, P)
if c < 0 then c = c + P end
local old = DB.bootPhase[e]
if old then
local d = c - old
if d > P / 2 then d = d - P elseif d < -P / 2 then d = d + P end
if math.abs(d) < 5 then
-- the same offset after a different restart: the theory holds
if DB.bootPhaseBoot[e] and math.abs(DB.bootPhaseBoot[e] - DB.boot) > 300 then DB.bootTheory = true end
c = math.mod(old + d * 0.5 + P, P)
end
end
DB.bootPhase[e] = c
DB.bootPhaseBoot[e] = DB.boot
end
local function OnBoot(boot)
local old = DB.boot or DB.bootPrev
DB.boot = boot
DB.bootPrev = nil
if old and math.abs(boot - old) > 300 then
-- the server restarted at 'boot': every route seen before then is off
DB.restartAt = boot
DB.restartRoute = "server uptime"
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
local e = r.entry
local P = Period(r)
if DB.bootPhase[e] and P and P > 0 and (DB.seenAt[e] or 0) < boot then
DB.anchor[e] = boot + DB.bootPhase[e]
DB.origin[e] = DB.anchor[e]
DB.pending[e] = nil
DB.lastObs[e] = nil
DB.corrected[e] = boot
if DB.bootTheory then DB.seenAt[e] = boot end -- corrected: "~" instead of "?"
end
end
else
-- same server run: a restart noticed earlier from a jump happened at 'boot'
if DB.restartAt and DB.restartAt > boot then DB.restartAt = boot end
end
-- learn offsets from routes already seen since this restart
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if (DB.seenAt[r.entry] or 0) > boot and DB.anchor[r.entry] then LearnPhase(r, DB.anchor[r.entry]) end
end
end
-- "Server uptime: 7 Hours 39 Minutes 39 Seconds" -> seconds
local function ParseUptime(msg)
local _, _, rest = string.find(msg or "", "^Server uptime: (.+)$")
if not rest then return nil end
local total, any = 0, false
for n, unit in gfind(rest, "(%d+) (%a+)") do
n = tonumber(n)
unit = string.lower(unit)
any = true
if string.find(unit, "^day") then total = total + n * 86400
elseif string.find(unit, "^hour") then total = total + n * 3600
elseif string.find(unit, "^min") then total = total + n * 60
elseif string.find(unit, "^sec") then total = total + n end
end
if any then return total end
return nil
end
do
local f = CreateFrame("Frame")
local askAt = nil
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:RegisterEvent("CHAT_MSG_SYSTEM")
f:SetScript("OnEvent", function()
if event == "PLAYER_ENTERING_WORLD" then
askAt = GetTime() + 4
else
local up = ParseUptime(arg1)
if up then OnBoot(time() - up) end
end
end)
f:SetScript("OnUpdate", function()
if askAt and GetTime() >= askAt then
askAt = nil
QueryUptime()
end
end)
-- hide the reply to our own query (a ".server info" you type yourself still shows)
local origChat = ChatFrame_OnEvent
ChatFrame_OnEvent = function(ev)
if ev == "CHAT_MSG_SYSTEM" and GetTime() < uptimeHideUntil and arg1 and
(string.find(arg1, "^Players online") or string.find(arg1, "^Server uptime") or string.find(arg1, "^Server Time")) then
return
end
return origChat(ev)
end
end
-- Server restart detection. A sighting that moves a route's schedule by far more than
-- drift can explain (10 s + 0.5 s per hour since it was last seen) means the server
-- restarted: every transport's schedule starts over. Routes not seen since are flagged
-- until they are seen again. newAnchor = the home arrival time this sighting implies.
local function NoteSighting(r, newAnchor)
local e = r.entry
local P = Period(r)
local old = DB.anchor[e]
local lastT = DB.seenAt[e] or DB.lastObs[e]
if old and P and P > 0 and lastT then
local d = math.mod(newAnchor - old, P)
if d < 0 then d = d + P end
if d > P / 2 then d = d - P end
if math.abs(d) > 10 + 0.5 * (time() - lastT) / 3600 then
if DB.boot and lastT < DB.boot then
-- explained by the restart we already know about (last seen before it)
if not DB.restartAt or DB.restartAt > DB.boot then DB.restartAt = DB.boot end
else
-- a restart we did not know about: mark it now and ask for the uptime
DB.restartAt = time()
DB.restartRoute = RouteName(r)
if DB.boot then -- that server run is over
DB.bootPrev = DB.boot
DB.boot = nil
end
QueryUptime()
end
end
end
DB.seenAt[e] = time()
DB.corrected[e] = nil
LearnPhase(r, newAnchor)
end
local function Stale(r)
return DB.restartAt ~= nil and (DB.seenAt[r.entry] or 0) < DB.restartAt
end
-- mark in front of a time: "" = seen since the current server restart (accurate),
-- "~" = an estimate (corrected from the restart time, or never seen), "?" = not seen
-- since a restart and not corrected (may be off by minutes)
local function Mark(r)
if Stale(r) then return "|cffffaa33?|r" end
local e = r.entry
if DB.corrected[e] or not DB.seenAt[e] then return "|cff888888~|r" end
if Flipped(r) and not FarKnown(r) then return "|cff888888~|r" end
return ""
end
local function OnArrive(r, t)
local e = r.entry
if not DB.origin[e] and r.origin then DB.origin[e] = r.origin end
@@ -425,7 +592,11 @@ local OB_OK, OB_ERR = pcall(function ()
local delta = t - origin
local k = math.floor(delta / P + 0.5)
local resid = delta - k * P
if k >= 1 and math.abs(resid) < 0.25 * P then
-- fits the long-term schedule if within what drift can explain since the last
-- fitting sighting; anything more is a restart (or a bad sighting)
local tol = 0.25 * P
if DB.lastObs[e] then tol = math.min(tol, 10 + 0.5 * (time() - DB.lastObs[e]) / 3600) end
if k >= 1 and math.abs(resid) < tol then
DB.period[e] = delta / k
DB.lastObs[e] = time()
DB.pending[e] = nil
@@ -438,7 +609,7 @@ local OB_OK, OB_ERR = pcall(function ()
if pd then
local d2 = t - pd
local k2 = math.floor(d2 / P + 0.5)
fits = k2 >= 1 and math.abs(d2 - k2 * P) < 0.25 * P
fits = k2 >= 1 and math.abs(d2 - k2 * P) < 15
end
if fits then
DB.origin[e] = pd
@@ -462,6 +633,7 @@ local OB_OK, OB_ERR = pcall(function ()
string.format("%d", math.floor(math.abs(off) + 0.5)) .. "s).")
end
end
NoteSighting(r, t)
DB.anchor[e] = t
confirmed[e] = true
CalObs(r, rebased)
@@ -477,6 +649,7 @@ local OB_OK, OB_ERR = pcall(function ()
else
-- never saw it arrive, but it leaves 'dwell' seconds after arriving
if not DB.origin[e] and r.origin then DB.origin[e] = r.origin end
NoteSighting(r, t - Dwell(r))
DB.anchor[e] = t - Dwell(r)
if not DB.origin[e] then
DB.origin[e] = t - Dwell(r)
@@ -564,6 +737,7 @@ local OB_OK, OB_ERR = pcall(function ()
-- home arrival, so its schedule is arrival - farOff. Only when the home end was
-- not seen this session (a home sighting is more direct and wins).
if approached and confirmed[e] ~= true and FarKnown(r) and P and P > 0 then
NoteSighting(r, arr - FarOff(r, P))
DB.anchor[e] = arr - FarOff(r, P)
confirmed[e] = "far"
end
@@ -592,6 +766,7 @@ local OB_OK, OB_ERR = pcall(function ()
-- never saw it dock here, but saw it leave: schedule = departure - stay - farOff
local P = Period(r)
if not w.approached and confirmed[e] ~= true and FarKnown(r) and P and P > 0 then
NoteSighting(r, now - FarDwell(r) - FarOff(r, P))
DB.anchor[e] = now - FarDwell(r) - FarOff(r, P)
confirmed[e] = "far"
end
@@ -950,10 +1125,21 @@ local OB_OK, OB_ERR = pcall(function ()
end
GameTooltip:AddLine("Click: " .. (Watched(r) and "remove the bell (no chat alert)" or "add a bell: chat message " .. CFG.alert .. "s before you need to fly"), 0.2, 1, 0.8)
else
GameTooltip:AddLine(confirmed[r.entry] == true and "Seen this session: exact."
or (confirmed[r.entry] == "far" and ("Synced at " .. Away(r) .. " this session.") or "Not seen this session: estimate (~)."), 0.7, 0.7, 0.7)
local e = r.entry
local how
if Stale(r) then how = "Not seen since the server restarted (?)."
elseif DB.corrected[e] then how = "Corrected from the server restart time, not seen since (~)."
elseif not DB.seenAt[e] then how = "Never seen: built-in estimate (~)."
else how = "Seen since the server restarted" .. (confirmed[e] == "far" and (", synced at " .. Away(r)) or "") ..
", " .. FormatSpan(time() - DB.seenAt[e]) .. " ago: accurate." end
GameTooltip:AddLine(how, 0.7, 0.7, 0.7)
GameTooltip:AddLine("Click: " .. (Watched(r) and "remove the bell (no chat alert)" or "add a bell: chat message " .. CFG.alert .. "s before it arrives"), 0.2, 1, 0.8)
end
if Stale(r) then
GameTooltip:AddLine("The server restarted " .. FormatSpan(time() - DB.restartAt) .. " ago (" ..
(DB.restartRoute == "server uptime" and "from the server uptime" or ("noticed at " .. tostring(DB.restartRoute))) ..
"). Not seen since, so this time may be off by up to 3 min.", 1, 0.67, 0.2, 1)
end
if r.flipFrom then
GameTooltip:AddLine("Right-click: reverse to " .. (Flipped(r) and RouteName(r) or FlipName(r)), 0.2, 1, 0.8)
end
@@ -1004,20 +1190,18 @@ local OB_OK, OB_ERR = pcall(function ()
if r.far and not seenState[r.entry] and not Flipped(r) and not AtHome(r) then
local fly, sail = FlyBy(r)
if not fly then return RowName(r), nil, nil, "|cff888888not measured yet|r" end
local mark = Mark(r)
if fly <= 0 then
return RowName(r), "fly", 0, "|cff888888~|r|cffff5555fly now|r |cff777777sails " .. FormatTime(sail) .. "|r"
return RowName(r), "fly", 0, mark .. "|cffff5555fly now|r |cff777777sails " .. FormatTime(sail) .. "|r"
end
local color = "|cffffffff"
if CFG.alert > 0 and fly <= CFG.alert then color = "|cffffaa33" end
return RowName(r), "fly", fly, "|cff888888~|r|cffaaaaaafly|r " .. color .. FormatTime(fly) ..
return RowName(r), "fly", fly, mark .. "|cffaaaaaafly|r " .. color .. FormatTime(fly) ..
"|r |cff777777sails " .. FormatTime(sail) .. "|r"
end
local state, secs = Status(r)
local est = confirmed[r.entry] and "" or "|cff888888~|r"
local est = Mark(r)
local nm = RowName(r)
if Flipped(r) then
if not (confirmed[r.entry] and FarKnown(r)) then est = "|cff888888~|r" end
end
if state == "docked" then
if secs then
return nm, state, secs, est .. "|cff55ff55leaves|r |cffffffff" .. FormatTime(secs) .. "|r"
@@ -1117,6 +1301,13 @@ local OB_OK, OB_ERR = pcall(function ()
end
else
local txt, stale = CalAgeText()
for i = 1, table.getn(ROUTES) do
if Visible(ROUTES[i]) and Stale(ROUTES[i]) then
txt = "server restarted " .. FormatSpan(time() - DB.restartAt) .. " ago: ? = not seen since"
stale = true
break
end
end
panel.foot:SetText((stale and "|cffffaa33" or "|cff777777") .. txt .. "" .. "|r")
end
end
@@ -1440,6 +1631,14 @@ local OB_OK, OB_ERR = pcall(function ()
DB.lastCal = nil
DB.lastObs = {}
DB.pending = {}
DB.seenAt = {}
DB.restartAt = nil
DB.restartRoute = nil
DB.boot = nil
DB.bootPhase = {}
DB.bootPhaseBoot = {}
DB.bootTheory = nil
DB.corrected = {}
cal = nil
confirmed = {}
seenState = {}
+1 -1
View File
@@ -2,7 +2,7 @@
## Title: |cff33ffccOcto|rBus
## Notes: Zeppelin and boat arrival timers for Orgrimmar. Reads live positions from ZepSense.dll; minimap icon toggles the window.
## Author: Phridgey
## Version: 1.3.2
## Version: 1.3.3
## SavedVariables: OctoBusDB
OctoBus.lua
+4 -1
View File
@@ -14,7 +14,9 @@ OctoBus needs one extra file, `ZepSense.dll`, which you install by hand in the g
|---|---|
| Green check | Transport is docked now. The timer counts down to departure ("leaves"). |
| Red X | Transport is away. The timer counts down to docking ("docks"). |
| `~` before a time | An estimate from stored data. It goes away once you see the transport dock or leave. |
| No mark before a time | Seen since the server last restarted: accurate to a few seconds. |
| `~` before a time | An estimate: worked out from the server restart time, or never seen yet. |
| `?` before a time (orange) | The server restarted and this route has not been seen since, so the time may be off by up to 3 minutes. It goes away when you see it. |
| `departing` (yellow) | The transport is still at the dock past its scheduled time and is about to leave. |
| `fly now` (red) | Ratchet boat: it is time to leave Orgrimmar for this sailing. It shows for 10 seconds, then the row moves on to the next sailing you can still make. |
| `fly 4:55 sails 6:40` | Ratchet boat only: leave Orgrimmar in 4:55 to be on the Ratchet dock before it sails in 6:40. If you can no longer make a sailing, it shows the next one. |
@@ -89,6 +91,7 @@ Each route runs on a fixed cycle. Once the addon sees one transport arrive, it c
- **`/ob calibrate`** (optional): watches every transport on purpose. Stand between the two towers where you can see the docks, do not `/reload`, and wait; rows are marked complete as each route is confirmed. A transport being out of view for a while is normal. If nothing at all has been seen after about 10 minutes, move closer to the towers. `/ob calibrate stop` cancels.
- Transports are not reported from deep inside the city (bank / auction house area), so calibrate near the towers.
- **Far ends are learned by riding.** When a transport stands still for 20 s away from its home dock, it is docked at the far end: the addon stores when it docked there (relative to its schedule) and how long it stayed. This needs the home end to have been seen earlier in the same session (for example, it leaving Orgrimmar with you on board), and the transport to be seen arriving. No recording is needed.
- **Server restarts.** A restart starts every transport's schedule over. When you log in, OctoBus quietly asks the server how long it has been up (`.server info`) and so knows exactly when it restarted. Each route docks a fixed number of seconds into its trip after a restart; OctoBus learns that once per route, from any sighting, and after later restarts corrects every route straight away from the restart time. Until that has been seen to hold across one restart, corrected routes keep the `?` mark. A sighting that jumps by more than drift can explain also counts as a restart.
- **Both ends keep the timers in sync.** Seeing a transport arrive at, or leave, either end of its route re-syncs that route. At the far end this uses the measured far-end timing; if the home end was already seen in the same session, a far-end arrival sharpens the far-end timing instead.
- **One odd sighting does not wipe a route's history.** A sighting that does not fit the schedule is set aside; the schedule only starts over (for example after a server restart) when a second sighting agrees with it.