diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dff7af..ed504b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/OctoBus.lua b/OctoBus.lua index 53e8589..bfb58d4 100644 --- a/OctoBus.lua +++ b/OctoBus.lua @@ -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 = {} diff --git a/OctoBus.toc b/OctoBus.toc index 6d799e7..984d519 100644 --- a/OctoBus.toc +++ b/OctoBus.toc @@ -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 diff --git a/README.md b/README.md index c7eb24a..3357a74 100644 --- a/README.md +++ b/README.md @@ -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.