Files
Octobus/OctoBus.lua
T

1747 lines
71 KiB
Lua

--[[
OctoBus
-------
Arrival timers for the Horde zeppelins and boats: Orgrimmar to Undercity, Grom'gol, Kargath
and Thunder Bluff, Grom'gol to Undercity, Sparkwater to Revantusk, and Ratchet to Booty Bay.
Each row reads "start -> end" and times the transport at the start; right-click reverses
it. The window shows the routes for where you are (click its title for all of them) and
opens by itself at either end of any route. A small minimap icon shows / hides it.
HOW IT WORKS
Needs ZepSense.dll (listed in dlls.txt). The DLL adds one function to the client,
ZepTransports(), which returns the position of every zeppelin/boat the client has loaded.
This addon polls it, watches each transport arrive at / leave its dock, and keeps a
schedule: every route runs on a fixed cycle length, so one observed arrival predicts all
later ones, even while the transport is out of sight.
A "~" in front of a time means that time is still an estimate from stored data and has not
been confirmed by seeing the transport dock this session. After the transport is seen
docking (or leaving), the "~" disappears.
Click a row to put a bell on it: you then get a chat message before that transport arrives.
SLASH COMMANDS (/ob, also /transit and /zep)
/ob show / hide the window (works anywhere)
/ob list print all timers in chat
/ob alert <sec> bell alerts fire this many seconds before arrival (default 45)
/ob sound|chat|flash toggle that alert type
/ob zone toggle "auto-show only at route ends" (off = always show)
/ob all all routes / only routes for where you are (or click the title)
/ob boat show / hide the Sparkwater Port boat
/ob scale <n> window size, 0.6 to 2 (or drag the dots in the bottom-right corner)
/ob shape square|round|auto minimap icon placement (auto looks at your minimap)
/ob where print your zone / sub-zone and whether the window auto-shows
/ob allow <name> also auto-show in this zone or sub-zone
/ob disallow <name> take a place off that list
/ob name <n> <text> rename the far end of route number n (from /ob list)
/ob period <n> <sec> set a route's cycle length by hand
/ob calibrate optional: watch every transport on purpose (stand between the towers).
Not required: any arrival you see near the towers calibrates automatically.
/ob reset forget everything learned (back to built-in values)
/ob travel <sec> Ratchet boat: your travel time from Orgrimmar to the Ratchet dock
/ob debug show what the DLL is returning
/ob debug on|off record every transport the DLL reports to the saved file
]]
-- Stage 1: runs as soon as this file loads, so /ob always answers, even if the real
-- start-up below fails. If it fails, /ob prints the error message.
local OB_STAGE = "file loaded, start-up not finished"
SLASH_OCTOBUS1 = "/ob"
SLASH_OCTOBUS2 = "/transit"
SLASH_OCTOBUS3 = "/zep"
SlashCmdList["OCTOBUS"] = function(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccOctoBus:|r not running. " .. tostring(OB_STAGE))
end
local OB_OK, OB_ERR = pcall(function ()
local gfind = string.gmatch or string.gfind
------------------------------------------------------------------------
-- Routes. x,y = where the transport stands while docked (measured in-game).
-- period/anchor are starting values measured from a 25 minute log; anchor is the
-- time() of one observed arrival. Real observations replace them automatically.
------------------------------------------------------------------------
-- Zones (or sub-zones) of each end, for the "where you are" view: homeZ = the home end,
-- awayZ = the far end, planZ = places where the route is shown for planning (Ratchet from
-- Orgrimmar, as "fly").
local ORG = { "Orgrimmar", "Durotar" }
local ROUTES = {
-- Built-in starting values from Phridgey's sightings on 8 Oct 2026: cycle measured over
-- ~5 h (origin = first sighting, anchor = latest), stay at the dock, and where measured
-- on a ride, the far end (flipOffset = seconds after docking here that it docks there).
-- A new copy refines all of this from its own sightings.
-- period = trip length measured over 3 days (400-900 trips). bootPhase = seconds into
-- each trip at which it docks here, counted from the server's last restart (measured
-- 12 Oct 2026): with the server uptime this gives every timer without any sighting.
{ entry = 164871, homeZ = ORG, awayZ = { "Tirisfal Glades", "Undercity" },
name = "Undercity", kind = "zeppelin", x = 1318.1, y = -4658.0,
period = 356.2894, bootPhase = 211.9, dwell = 64.0, origin = 1791471571.34, anchor = 1791489385.64,
flipFrom = "Undercity", flipTo = "Orgrimmar", flipOffset = 191.1, flipDwell = 64.0 },
{ entry = 175080, homeZ = ORG, awayZ = { "Stranglethorn Vale" },
name = "Grom'gol", kind = "zeppelin", x = 1360.8, y = -4631.3,
period = 303.4377, bootPhase = 55.0, dwell = 64.1, origin = 1791471600.26, anchor = 1791489504.28,
flipFrom = "Grom'gol", flipTo = "Orgrimmar", flipOffset = 138.2, flipDwell = 64.1 },
{ entry = 190552, homeZ = ORG, awayZ = { "Badlands" },
name = "Kargath", kind = "zeppelin", x = 1206.1, y = -4158.7,
period = 373.9861, bootPhase = 227.4, dwell = 63.9, origin = 1791471857.57, anchor = 1791489438.99,
flipFrom = "Kargath", flipTo = "Orgrimmar", flipOffset = 198.0, flipDwell = 64.2 },
{ entry = 190549, homeZ = ORG, awayZ = { "Mulgore", "Thunder Bluff" },
name = "Thunder Bluff", kind = "zeppelin", x = 1125.4, y = -4135.0,
period = 566.9955, bootPhase = 334.5, dwell = 64.2, origin = 1791472003.14, anchor = 1791490140.28,
flipFrom = "Thunder Bluff", flipTo = "Orgrimmar", flipOffset = 275.8, flipDwell = 64.0 },
{ entry = 190550, homeZ = ORG, awayZ = { "The Hinterlands" },
name = "Sparkwater", kind = "boat", x = 876.1, y = -5204.1, boat = true,
period = 245.0354, bootPhase = 168.6, dwell = 44.1, origin = 1791471573.69, anchor = 1791489460.91,
flipFrom = "Revantusk", flipTo = "Sparkwater", flipOffset = 123.2, flipDwell = 44.0 },
-- Grom'gol <-> Undercity zeppelin, timed at the Grom'gol dock (the only end measured).
-- Seen leaving Grom'gol 1791480459.89 and arriving 1791486390.25: 18 cycles of 333.02 s
-- (assuming the usual 64 s stay); an earlier glimpse docked fits too. The Undercity end
-- is learned on the first ride from Grom'gol.
{ entry = 176495, homeZ = { "Stranglethorn Vale" }, awayZ = { "Tirisfal Glades", "Undercity" },
name = "Undercity", kind = "zeppelin", x = -12406.9, y = 211.8,
period = 333.0015, bootPhase = 57.2, dwell = 64.0, origin = 1791480395.89, anchor = 1791486390.25,
flipFrom = "Undercity", flipTo = "Grom'gol" },
-- Ratchet -> Booty Bay boat, timed at the Ratchet dock. Never visible from Orgrimmar, so
-- the row shows when to fly out ("fly") rather than when it docks. Ratchet arrivals
-- 1791479926.67 .. 1791490117.34 are 28 cycles apart (363.95 s). Booty Bay end seen on
-- two days' rides: docks 178.6 s after Ratchet, stays 64.2 s.
{ entry = 20808, homeZ = { "Ratchet" }, awayZ = { "Stranglethorn Vale" }, planZ = ORG,
name = "Ratchet", kind = "boat", x = -1005.6, y = -3841.6, far = true,
period = 364.0298, bootPhase = 234.0, dwell = 63.7, origin = 1791479926.67, anchor = 1791490117.34,
flipFrom = "Booty Bay", flipTo = "Ratchet", flipOffset = 178.6, flipDwell = 64.2 },
}
-- Right-click on a row reverses it: times for the far end (flipFrom, going to flipTo). Where the other end was never measured (flipOffset unset), it is assumed to
-- dock halfway through the cycle and to stay as long as at this end: a rough estimate.
local DOCK_RADIUS = 2.0
------------------------------------------------------------------------
-- Saved data
------------------------------------------------------------------------
-- The game loads saved variables AFTER an addon's files have run (replacing the global),
-- so the saved table is looked up on every access and its defaults are filled in
-- whenever it changes.
local G = getfenv(0)
local dbRef = nil
local function Ensure()
local t = G.OctoBusDB
if type(t) ~= "table" then
t = {}
G.OctoBusDB = t
end
if t ~= dbRef then
dbRef = t
t.anchor = t.anchor or {}
t.period = t.period or {}
t.names = t.names or {}
t.bell = t.bell or {}
t.origin = t.origin or {}
t.dwell = t.dwell or {}
t.lastObs = t.lastObs or {}
t.flip = t.flip or {}
t.farOff = t.farOff or {}
t.farDwell = t.farDwell or {}
t.cfg = t.cfg or {}
local c = t.cfg
if c.alert == nil then c.alert = 45 end
if c.chat == nil then c.chat = true end
if c.sound == nil then c.sound = false end
if c.flash == nil then c.flash = false end
if c.zoneOnly == nil then c.zoneOnly = true end
c.allow = c.allow or {}
if c.hideBoat == nil then c.hideBoat = false end
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 {}
t.pW0 = t.pW0 or {}
t.pP0 = t.pP0 or {}
t.pK = t.pK 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
c.sound = false
c.flash = false
end
end
return t
end
local DB = setmetatable({}, {
__index = function(_, k) return Ensure()[k] end,
__newindex = function(_, k, v) Ensure()[k] = v end,
})
local CFG = setmetatable({}, {
__index = function(_, k) return Ensure().cfg[k] end,
__newindex = function(_, k, v) Ensure().cfg[k] = v end,
})
Ensure()
-- One-time repair (1.3.4): restarts used to re-measure trip lengths from only a few
-- trips, throwing away days of evidence. Restore the long-term values (built in now),
-- count them as 700 trips of evidence, and recompute the restart offsets with them.
if not DB.fix134 then
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
local e = r.entry
if r.period then
DB.period[e] = r.period
DB.pP0[e] = r.period
DB.pW0[e] = 700
if DB.origin[e] and DB.anchor[e] then
DB.pK[e] = math.floor((DB.anchor[e] - DB.origin[e]) / r.period + 0.5)
end
if DB.boot and DB.anchor[e] and DB.anchor[e] > DB.boot then
local c = math.mod(DB.anchor[e] - DB.boot, r.period)
if c < 0 then c = c + r.period end
DB.bootPhase[e] = c
DB.bootPhaseBoot[e] = DB.boot
end
end
end
DB.fix134 = true
end
local function Say(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccOctoBus:|r " .. msg)
end
local function FormatTime(s)
s = math.floor(s + 0.5)
if s < 0 then s = 0 end
return string.format("%d:%02d", math.floor(s / 60), math.mod(s, 60))
end
-- Rows read "start -> end". flipTo is the home end (Orgrimmar, Sparkwater, Ratchet),
-- flipFrom the far end; /ob name renames the far end.
local function Away(r) return DB.names[r.entry] or r.flipFrom end
local function RouteName(r) return r.flipTo .. " -> " .. Away(r) end
local function Period(r) return DB.period[r.entry] or r.period end
local function Anchor(r) return DB.anchor[r.entry] or r.anchor end
local function Watched(r) return DB.bell[r.entry] == true end
-- Names are compared without capitals, quotes or outer spaces.
local function Norm(n)
n = string.lower(n or "")
n = string.gsub(n, "[\"']", "")
n = string.gsub(n, "^%s+", "")
n = string.gsub(n, "%s+$", "")
return n
end
local seenState = {} -- entry -> { atDock = bool, arrivedAt = epoch or nil }
-- Where the player is. LOC.z / LOC.sz = current zone / sub-zone; LOC.mz / LOC.msz = the
-- place the "where you are" view is built for: the current one, or the last place that
-- had routes (CFG.hub) when you are somewhere without any.
local LOC = { z = "", sz = "", mz = "", msz = "", key = nil }
local manualFlip = {} -- right-clicks in the "where you are" view; cleared on zone change
local function InListAt(t, z, sz)
if not t then return false end
for i = 1, table.getn(t) do
local a = Norm(t[i])
if a == z or a == sz then return true end
end
return false
end
local function AtHome(r) return InListAt(r.homeZ, LOC.mz, LOC.msz) end
local function AtAway(r) return InListAt(r.awayZ, LOC.mz, LOC.msz) end
local function AtPlan(r) return InListAt(r.planZ, LOC.mz, LOC.msz) end
local function UpdateLoc()
local z, sz = Norm(GetZoneText()), Norm(GetSubZoneText())
if z ~= LOC.key then LOC.key = z; manualFlip = {} end
LOC.z, LOC.sz = z, sz
local any = false
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if InListAt(r.homeZ, z, sz) or InListAt(r.awayZ, z, sz) or InListAt(r.planZ, z, sz) then any = true end
end
if any then
LOC.mz, LOC.msz = z, sz
CFG.hub = { z = z, sz = sz }
elseif CFG.hub then
LOC.mz, LOC.msz = CFG.hub.z, CFG.hub.sz
else
LOC.mz, LOC.msz = z, sz
end
end
-- Shown rows: "all routes" (CFG.full) or only the ones for where you are (either end,
-- a planning place, or the transport in view, e.g. while riding it).
local function Visible(r)
if r.boat and CFG.hideBoat then return false end
if CFG.full then return true end
return AtHome(r) or AtAway(r) or AtPlan(r) or seenState[r.entry] ~= nil
end
-- confirmed[entry] = true once the transport has been SEEN docking/leaving at its home end
-- this session, or "far" when it was synced from a sighting at the far end
local confirmed = {}
------------------------------------------------------------------------
-- Clock: epoch seconds with sub-second precision. time() only ticks whole seconds,
-- so remember the largest (time() - GetTime()) seen: that converges on the true offset.
------------------------------------------------------------------------
local clockC = nil
local function EpochNow()
local g = GetTime()
local c = time() - g
if (not clockC) or c > clockC then clockC = c end
return g + clockC
end
local function Dwell(r) return DB.dwell[r.entry] or r.dwell end
-- returns "docked", secondsUntilDeparture or "wait", secondsUntilArrival
-- Reversed? "All routes": as set with right-click (kept). "Where you are": starts from
-- your end automatically; a right-click reverses it until you change zone.
local function Flipped(r)
if not r.flipFrom then return false end
if CFG.full then return DB.flip[r.entry] == true end
local auto = AtAway(r) and not AtHome(r) and not AtPlan(r)
if manualFlip[r.entry] then return not auto end
return auto
end
local function FlipName(r) return Away(r) .. " -> " .. r.flipTo end
local function ShownName(r) if Flipped(r) then return FlipName(r) end return RouteName(r) end
local function StartName(r) if Flipped(r) then return Away(r) end return r.flipTo end
local RowName = ShownName
-- Each place has its own colour in the rows; renamed or unknown places stay white.
local PLACE_COLOR = {
["orgrimmar"] = "bf3f33", ["undercity"] = "8769bf", ["gromgol"] = "4ca53f",
["kargath"] = "a8733f", ["thunder bluff"] = "ae9959", ["sparkwater"] = "3399bf",
["revantusk"] = "268c78", ["revantusk village"] = "268c78", ["ratchet"] = "bfa526",
["booty bay"] = "bf6699",
}
local function Paint(n) return "|cff" .. (PLACE_COLOR[Norm(n)] or "ffffff") .. n .. "|r" end
local function ColoredName(r)
local a, b = r.flipTo, Away(r)
if Flipped(r) then a, b = b, a end
return Paint(a) .. " |cff777777->|r " .. Paint(b)
end
-- Far end timing: learned on a ride (DB.farOff / DB.farDwell), else built in, else the
-- rough "halfway through the cycle, same stay" assumption. FarKnown = not a guess.
local function FarKnown(r) return (DB.farOff[r.entry] or r.flipOffset) ~= nil end
local function FarOff(r, P) return DB.farOff[r.entry] or r.flipOffset or P / 2 end
local function FarDwell(r) return DB.farDwell[r.entry] or r.flipDwell or Dwell(r) end
-- otherEnd = true: the same schedule seen from the far end of the route
local function Predict(r, otherEnd)
local a, P = Anchor(r), Period(r)
if not a or not P or P <= 0 then return nil end
local d = Dwell(r)
if otherEnd then
a = a + FarOff(r, P)
d = FarDwell(r)
end
local e = EpochNow() - a
local ph = math.mod(e, P)
if ph < 0 then ph = ph + P end
if ph < d then return "docked", d - ph end
return "wait", P - ph
end
------------------------------------------------------------------------
-- Calibration. /ob calibrate: stay somewhere you can see the docks (between the two
-- towers). Every route needs one fresh arrival (two the very first time, because a
-- cycle length needs two arrivals to measure). Each new arrival is compared with the
-- oldest trusted one (the "origin"): the gap divided by the whole number of cycles in
-- it gives the cycle length, and the longer the gap, the more exact it gets.
------------------------------------------------------------------------
local cal = nil -- nil = not calibrating
-- Every arrival that refines a cycle length counts as a calibration for that route,
-- whether you ran /ob calibrate or just walked past the towers.
local function FormatSpan(sec)
if sec < 3600 then return string.format("%d min", math.floor(sec / 60 + 0.5)) end
if sec < 86400 then return string.format("%.0f h", sec / 3600) end
return string.format("%.1f days", sec / 86400)
end
-- footer text, and whether it should be highlighted: names the transport whose latest
-- sighting is the oldest, and how long ago that was. Orange after 5 days.
local function CalAgeText()
local oldR, oldT = nil, nil
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if Visible(r) then
local t = math.max(DB.lastObs[r.entry] or 0, DB.anchor[r.entry] or r.anchor or 0)
if not oldT or t < oldT then oldR, oldT = r, t end
end
end
if not oldR then return "", false end
if oldT <= 0 then return "oldest data: " .. RouteName(oldR) .. ", never seen", true end
local gone = time() - oldT
local ago
if gone < 120 then ago = "just now"
elseif gone < 3600 then ago = math.floor(gone / 60 + 0.5) .. " min ago"
elseif gone < 86400 then ago = string.format("%.0f", gone / 3600) .. " h ago"
else ago = math.floor(gone / 86400 + 0.5) .. " days ago" end
local stale = (gone >= 5 * 86400)
return "oldest data: " .. RouteName(oldR) .. ", seen " .. ago .. (stale and ", go see it" or ""), stale
end
local function CalFinish(complete)
if not cal then return end
Say(complete and "calibration complete." or "calibration stopped.")
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if cal.need[r.entry] then
local oldP = cal.oldP[r.entry]
local done = (cal.got[r.entry] or 0) >= cal.need[r.entry]
Say(" " .. RouteName(r) .. ": cycle " .. string.format("%.2f", Period(r)) .. "s" ..
(oldP and (math.abs(oldP - Period(r)) > 0.005 and (" (was " .. string.format("%.2f", oldP) .. ")") or " (unchanged)") or "") ..
(done and "" or " - not seen, still using the old value"))
end
end
if complete then
Say("done. The window footer tracks how fresh this is; it nags after 5 days.")
end
cal = nil
end
local function CalObs(r, rebased)
if not cal or not cal.need[r.entry] then return end
local e = r.entry
if rebased then
cal.need[e] = (cal.got[e] or 0) + 1 -- the old baseline did not fit: measure again
return
end
cal.got[e] = (cal.got[e] or 0) + 1
local all = true
for k, n in pairs(cal.need) do
if (cal.got[k] or 0) < n then all = false end
end
if all then CalFinish(true) end
end
local function CalStart()
cal = { need = {}, got = {}, oldP = {}, t0 = GetTime() }
local n = 0
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if Visible(r) and not r.far then
cal.need[r.entry] = DB.origin[r.entry] and 1 or 2
cal.oldP[r.entry] = Period(r)
n = n + 1
end
end
return n
end
local function CalCount()
local done, total = 0, 0
if not cal then return 0, 0 end
for k, n in pairs(cal.need) do
total = total + 1
if (cal.got[k] or 0) >= n then done = done + 1 end
end
return done, total
end
------------------------------------------------------------------------
-- Watching the DLL
------------------------------------------------------------------------
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 Phase(r) return DB.bootPhase[r.entry] or r.bootPhase end
local function OnBoot(boot)
local old = DB.boot or DB.bootPrev
DB.boot = boot
DB.bootPrev = nil
local restarted = old and math.abs(boot - old) > 300
if restarted then
-- the server restarted at 'boot': every route seen before then is off
DB.restartAt = boot
DB.restartRoute = "server uptime"
elseif DB.restartAt and DB.restartAt > boot then
-- same server run: a restart noticed earlier from a jump happened at 'boot'
DB.restartAt = boot
end
-- every route not seen since this restart is put at restart time + its offset (learned,
-- or built in), also on a new copy's very first login
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
local e = r.entry
local P = Period(r)
local c = Phase(r)
if c and P and P > 0 and (DB.seenAt[e] or 0) < boot then
DB.anchor[e] = boot + c
if not DB.origin[e] or DB.origin[e] < boot then
-- the stored schedule is from an earlier server run: start this run's
-- measurement here, keeping the trip length and the evidence behind it
DB.pW0[e] = (DB.pW0[e] or 700) + (DB.pK[e] or 0)
DB.pP0[e] = P
DB.pK[e] = 0
DB.origin[e] = DB.anchor[e]
DB.pending[e] = nil
DB.lastObs[e] = nil
end
DB.corrected[e] = boot
if restarted and DB.bootTheory then DB.seenAt[e] = boot end -- "~" instead of "?"
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
local P = Period(r)
local pstate, psecs = Predict(r)
local origin = DB.origin[e]
local rebased = false
if origin then
local delta = t - origin
local k = math.floor(delta / P + 0.5)
local resid = delta - k * P
-- 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
-- trip length = all evidence weighted by trips covered: earlier server runs
-- (pP0 over pW0 trips) plus this run (delta over k trips)
local W0 = DB.pW0[e] or 700 -- the built-in trip length: ~700 trips measured
local P0 = DB.pP0[e] or P
DB.period[e] = (P0 * W0 + delta) / (W0 + k)
DB.pK[e] = k
DB.lastObs[e] = time()
DB.pending[e] = nil
else
-- Does not fit the baseline. One odd sighting is not enough to throw a long
-- baseline away: keep it aside, and only start over when a second sighting
-- agrees with it (schedule really jumped, e.g. a server restart).
local pd = DB.pending[e]
local fits = false
if pd then
local d2 = t - pd
local k2 = math.floor(d2 / P + 0.5)
fits = k2 >= 1 and math.abs(d2 - k2 * P) < 15
end
if fits then
-- a new server run: keep the trip length and how much evidence is behind it
DB.pW0[e] = (DB.pW0[e] or 700) + (DB.pK[e] or 0)
DB.pP0[e] = Period(r)
DB.pK[e] = 0
DB.origin[e] = pd
DB.pending[e] = nil
DB.lastObs[e] = time()
rebased = true
else
DB.pending[e] = t
end
end
else
DB.origin[e] = t
end
-- how far off was the schedule?
if pstate and not confirmed[e] and CFG.chat and Watched(r) then
local off
if pstate == "docked" then off = Dwell(r) - psecs else off = -psecs end
if pstate == "wait" and psecs < P / 2 then off = psecs end
if math.abs(off) > 20 then
Say(RouteName(r) .. ": schedule re-synced from what I just saw (was off by about " ..
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)
end
local function OnDepart(r, t, st)
local e = r.entry
if st.arrivedAt then
local d = t - st.arrivedAt
if d > 20 and d < 150 then
DB.dwell[e] = DB.dwell[e] and (DB.dwell[e] * 0.6 + d * 0.4) or d
end
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)
CalObs(r, false)
end
end
confirmed[e] = true
end
------------------------------------------------------------------------
-- Recorder (/ob debug on): writes every transport position the DLL reports into
-- OctoBusDB.rec, one line per sample, so a trip can be analysed afterwards. A sample is
-- kept when the transport moved more than half a yard, or every 5 s while it stands
-- still. Zone changes and flight-path start / end are logged too. The game writes the
-- saved file on logout or /reload.
------------------------------------------------------------------------
local REC_MAX = 40000
local recOn = false
local recLast = {}
local function RecLine(txt)
local log = DB.rec
if type(log) ~= "table" then log = {}; DB.rec = log end
if table.getn(log) >= REC_MAX then return end
table.insert(log, txt)
end
local function Record(now, gid, ge, de, x, y, z)
local L = recLast[gid]
if L then
local dx, dy = x - L.x, y - L.y
if dx * dx + dy * dy < 0.25 and (now - L.t) < 5 then return end
end
recLast[gid] = { x = x, y = y, t = now }
RecLine(string.format("%.2f T %s %s %d %.2f %.2f %.2f", now, gid, ge, de, x, y, z))
end
local recZone, recTaxi = nil, nil
local function RecordPlayer(now)
local zn = (GetRealZoneText() or "") .. "/" .. (GetSubZoneText() or "")
if zn ~= recZone then
recZone = zn
RecLine(string.format("%.2f Z %s", now, zn))
end
local tx = UnitOnTaxi("player") and true or false
if tx ~= recTaxi then
recTaxi = tx
RecLine(string.format("%.2f F %s", now, tx and "start" or "end"))
end
end
------------------------------------------------------------------------
-- Far-end learning. A tracked transport that stands still for 20 s somewhere other than
-- its home dock is docked at the far end. Its arrival there (when it first came within
-- DOCK_RADIUS of where it stopped) is stored as seconds into the cycle, and its stay is
-- stored when it leaves. Only learned when the home-end schedule was confirmed this
-- session, so the offset is measured against a fresh schedule. Nothing is printed.
------------------------------------------------------------------------
local FAR_STILL = 20
local farW = {} -- entry -> { hist = {{t,x,y}...}, sx, sy, st, arr }
local function FarWatch(r, now, x, y, atDock)
local e = r.entry
if not r.flipFrom or atDock or (x == 0 and y == 0) then farW[e] = nil; return end
local w = farW[e]
if not w then w = { hist = {} }; farW[e] = w end
-- keep only samples where it moved, so the approach is still in the history after
-- 20 s standing still (polling is 10 per second while recording)
local hn = table.getn(w.hist)
local last = w.hist[hn]
if not last or math.abs(last[2] - x) >= 0.05 or math.abs(last[3] - y) >= 0.05 then
table.insert(w.hist, { now, x, y })
if hn + 1 > 120 then table.remove(w.hist, 1) end
end
if w.sx and math.abs(x - w.sx) < 0.3 and math.abs(y - w.sy) < 0.3 then
if not w.arr and (now - w.st) >= FAR_STILL then
-- docked: arrival = first recent sample within DOCK_RADIUS of the stop point
local arr, approached = w.st, false
for i = table.getn(w.hist), 1, -1 do
local h = w.hist[i]
local dx, dy = h[2] - w.sx, h[3] - w.sy
if math.sqrt(dx * dx + dy * dy) > DOCK_RADIUS then approached = true; break end
arr = h[1]
end
w.arr = arr
w.approached = approached
local P = Period(r)
-- Re-sync from the far end: the transport docks here farOff seconds after its
-- 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
-- only when it was seen coming in (not already docked when it came into view)
if approached and confirmed[e] == true and P and P > 0 then
local off = math.mod(arr - Anchor(r), P)
if off < 0 then off = off + P end
local old = DB.farOff[e]
if old and math.abs(old - off) < 20 then off = old * 0.5 + off * 0.5 end
DB.farOff[e] = off
w.learn = true
end
end
return
end
if w.arr and w.sx then
local dx, dy = x - w.sx, y - w.sy
if math.sqrt(dx * dx + dy * dy) <= DOCK_RADIUS then return end -- still easing out
local d = now - w.arr
-- the stay only needs this end's own arrival and departure, so it is learned
-- whenever both were seen, wherever the schedule came from
if w.approached and d > 20 and d < 150 then
local old = DB.farDwell[e]
DB.farDwell[e] = old and (old * 0.6 + d * 0.4) or d
end
-- 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
w.arr = nil
w.learn = nil
w.approached = nil
end
w.sx, w.sy, w.st = x, y, now
end
local function Poll()
if type(ZepTransports) ~= "function" then dllOk = false; return end
local ok, str = pcall(ZepTransports)
if not ok or type(str) ~= "string" then dllOk = false; return end
dllOk = true
lastGood = GetTime()
local now = EpochNow()
if recOn then RecordPlayer(now) end
local present = {}
for rec in gfind(str, "([^;]+);") do
local _, _, gid, tag, ge, de, x, y, z = string.find(rec, "^(%x+),(%x+),(%d+),(%d+),(%-?[%d%.]+),(%-?[%d%.]+),(%-?[%d%.]+)")
de = tonumber(de)
x = tonumber(x)
y = tonumber(y)
if de and x and y then
if recOn then Record(now, gid, ge, de, x, y, tonumber(z) or 0) end
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if r.entry == de and r.x then
present[de] = true
local dx, dy = x - r.x, y - r.y
local atDock = (math.sqrt(dx * dx + dy * dy) <= DOCK_RADIUS)
FarWatch(r, now, x, y, atDock)
local st = seenState[de]
if not st then
seenState[de] = { atDock = atDock }
if cal then cal.seenAny = true end
else
if atDock and not st.atDock then
st.atDock = true
st.arrivedAt = now
OnArrive(r, now)
elseif (not atDock) and st.atDock then
st.atDock = false
OnDepart(r, now, st)
st.arrivedAt = nil
end
end
end
end
end
end
-- forget transports that left view so the next sighting starts clean
for i = 1, table.getn(ROUTES) do
local e = ROUTES[i].entry
if seenState[e] and not present[e] then seenState[e] = nil end
if not present[e] then farW[e] = nil end
end
end
------------------------------------------------------------------------
-- Frames
------------------------------------------------------------------------
local font = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
local anchorTo = Minimap
-- flat dark panel with a thin border
local function CreateBackdrop(f)
f:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8X8",
edgeFile = "Interface\\Buttons\\WHITE8X8",
tile = false, tileSize = 0, edgeSize = 1,
insets = { left = 1, right = 1, top = 1, bottom = 1 },
})
f:SetBackdropColor(0.02, 0.02, 0.02, 0.75)
f:SetBackdropBorderColor(0.22, 0.22, 0.22, 1)
end
-- square or round minimap? "auto" looks at the minimap: the stock UI keeps it inside
-- MinimapCluster; a replacement UI that moves it elsewhere is assumed to have made it square.
local function MinimapIsSquare()
if CFG.shape == "square" then return true end
if CFG.shape == "round" then return false end
if GetMinimapShape then return GetMinimapShape() == "SQUARE" end
return Minimap:GetParent() ~= MinimapCluster
end
local ICON_HERE = "Interface\\Buttons\\UI-CheckBox-Check"
local ICON_AWAY = "Interface\\Buttons\\UI-GroupLoot-Pass-Up"
-- minimap icon: click = show / hide the window, drag = move it around the minimap edge
local btn = CreateFrame("Button", "OctoBusButton", Minimap)
btn:SetFrameStrata("MEDIUM")
btn:SetFrameLevel(Minimap:GetFrameLevel() + 8)
btn:SetWidth(31)
btn:SetHeight(31)
btn:RegisterForClicks("LeftButtonUp")
btn:RegisterForDrag("LeftButton")
btn.icon = btn:CreateTexture(nil, "ARTWORK")
btn.icon:SetWidth(20)
btn.icon:SetHeight(20)
btn.icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 7, -5)
btn.icon:SetTexture("Interface\\Icons\\INV_Misc_Map_01")
btn.border = btn:CreateTexture(nil, "OVERLAY")
btn.border:SetWidth(53)
btn.border:SetHeight(53)
btn.border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
btn.border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
local function PlaceButton()
local a = CFG.angle or (math.pi * 1.25) -- default: bottom-left
local dx, dy = math.cos(a), math.sin(a)
local w = (anchorTo:GetWidth() or 140) / 2
local h = (anchorTo:GetHeight() or 140) / 2
local x, y
if MinimapIsSquare() then
-- square minimap: run the icon along the outside of the edge
local m = math.max(math.abs(dx), math.abs(dy))
x, y = dx / m * (w + 2), dy / m * (h + 2)
else
x, y = dx * (w + 5), dy * (h + 5)
end
btn:ClearAllPoints()
btn:SetPoint("CENTER", anchorTo, "CENTER", x, y)
end
PlaceButton()
btn:SetScript("OnDragStart", function() this.dragging = true end)
btn:SetScript("OnDragStop", function() this.dragging = false end)
btn:SetScript("OnUpdate", function()
if not this.dragging then return end
local cx, cy = GetCursorPosition()
local sc = anchorTo:GetEffectiveScale()
local mx, my = anchorTo:GetCenter()
if not mx or not my then return end
CFG.angle = math.atan2(cy / sc - my, cx / sc - mx)
PlaceButton()
end)
-- the timer window
local WIDTH = 280
local ROW_H = 18
local TOP = 22
local panel = CreateFrame("Frame", "OctoBusPanel", UIParent)
panel:SetFrameStrata("MEDIUM")
panel:SetWidth(WIDTH)
panel:SetHeight(100)
panel:SetClampedToScreen(true)
panel:SetScale(CFG.scale or 1)
panel:SetMovable(true)
panel:EnableMouse(true)
CreateBackdrop(panel)
panel.title = panel:CreateFontString(nil, "OVERLAY")
panel.title:SetFont(font, 10, "OUTLINE")
panel.title:SetPoint("TOPLEFT", panel, "TOPLEFT", 8, -6)
panel.title:SetText("|cff33ffccTransports|r")
panel.status = panel:CreateFontString(nil, "OVERLAY")
panel.status:SetFont(font, 9, "OUTLINE")
panel.status:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -8, -7)
panel.status:SetTextColor(0.6, 0.6, 0.6)
panel.foot = panel:CreateFontString(nil, "OVERLAY")
panel.foot:SetFont(font, 9, "OUTLINE")
panel.foot:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 8, 4)
panel.foot:SetJustifyH("LEFT")
local function SavePos()
local x, y = panel:GetLeft(), panel:GetBottom()
if x and y then CFG.pos = { x = x, y = y } end
end
local function StartDrag()
if CFG.locked then return end
panel:StartMoving()
end
local function StopDrag()
panel:StopMovingOrSizing()
SavePos()
end
panel:RegisterForDrag("LeftButton")
panel:SetScript("OnDragStart", StartDrag)
panel:SetScript("OnDragStop", StopDrag)
-- click the title: switch between all routes and routes for where you are
local titleBtn = CreateFrame("Button", nil, panel)
titleBtn:SetPoint("TOPLEFT", panel, "TOPLEFT", 4, -2)
titleBtn:SetWidth(150)
titleBtn:SetHeight(16)
titleBtn:RegisterForClicks("LeftButtonUp")
titleBtn:RegisterForDrag("LeftButton")
titleBtn:SetScript("OnDragStart", StartDrag)
titleBtn:SetScript("OnDragStop", StopDrag)
titleBtn:SetScript("OnClick", function() CFG.full = not CFG.full end)
titleBtn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOP")
GameTooltip:AddLine(CFG.full and "Showing all routes" or "Showing routes for where you are", 1, 1, 1)
GameTooltip:AddLine("Click: " .. (CFG.full and "only routes for where you are" or "all routes") .. " (/ob all)", 0.2, 1, 0.8)
GameTooltip:Show()
end)
titleBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
-- Resize grip, bottom-right: drag to scale the whole window (0.6x to 2x). The top-left
-- corner stays where it is. Hidden while the window is locked.
local SCALE_MIN, SCALE_MAX = 0.6, 2.0
local grip = CreateFrame("Frame", nil, panel)
grip:SetWidth(14)
grip:SetHeight(14)
grip:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -1, 1)
grip:SetFrameLevel(panel:GetFrameLevel() + 10)
grip:EnableMouse(true)
grip.dots = {}
local DOTS = { {3, 3}, {7, 3}, {11, 3}, {3, 7}, {7, 7}, {3, 11} }
for i = 1, table.getn(DOTS) do
local d = grip:CreateTexture(nil, "OVERLAY")
d:SetTexture("Interface\\Buttons\\WHITE8X8")
d:SetWidth(2)
d:SetHeight(2)
d:SetPoint("BOTTOMRIGHT", grip, "BOTTOMRIGHT", -DOTS[i][1] + 1, DOTS[i][2] - 1)
d:SetVertexColor(0.5, 0.5, 0.5, 0.9)
grip.dots[i] = d
end
local function GripColor(c)
for i = 1, table.getn(grip.dots) do grip.dots[i]:SetVertexColor(c, c, c, 0.9) end
end
-- set a new scale, keeping the window's top-left corner on the same screen spot
local function ApplyScale(sc)
if sc < SCALE_MIN then sc = SCALE_MIN end
if sc > SCALE_MAX then sc = SCALE_MAX end
local oldEff = panel:GetEffectiveScale()
local l, t = panel:GetLeft(), panel:GetTop()
panel:SetScale(sc)
CFG.scale = sc
if l and t then
local newEff = panel:GetEffectiveScale()
panel:ClearAllPoints()
panel:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", l * oldEff / newEff, t * oldEff / newEff)
SavePos()
end
end
grip:SetScript("OnEnter", function() GripColor(1) end)
grip:SetScript("OnLeave", function() if not this.sizing then GripColor(0.5) end end)
grip:SetScript("OnMouseDown", function()
if CFG.locked then return end
this.sizing = true
end)
grip:SetScript("OnMouseUp", function()
this.sizing = false
GripColor(MouseIsOver and MouseIsOver(this) and 1 or 0.5)
SavePos()
end)
grip:SetScript("OnUpdate", function()
if not this.sizing then return end
local cx = GetCursorPosition() -- screen pixels
local l = panel:GetLeft()
if not l then return end
local leftPx = l * panel:GetEffectiveScale()
local wantEff = (cx - leftPx) / WIDTH -- window right edge follows the cursor
local parentEff = UIParent:GetEffectiveScale()
if wantEff > 0 and parentEff > 0 then ApplyScale(wantEff / parentEff) end
end)
if CFG.locked then grip:Hide() end
local rows = {}
local function CreateRow(i)
local row = CreateFrame("Button", nil, panel)
row:SetHeight(ROW_H)
row:SetPoint("TOPLEFT", panel, "TOPLEFT", 4, -(TOP + (i - 1) * ROW_H))
row:SetPoint("RIGHT", panel, "RIGHT", -4, 0)
row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
row:RegisterForDrag("LeftButton")
row:SetScript("OnDragStart", StartDrag)
row:SetScript("OnDragStop", StopDrag)
row.bg = row:CreateTexture(nil, "BACKGROUND")
row.bg:SetAllPoints(row)
row.bg:SetTexture("Interface\\Buttons\\WHITE8X8")
row.bg:SetVertexColor(0, 0, 0, 0)
row.icon = row:CreateTexture(nil, "ARTWORK")
row.icon:SetWidth(14)
row.icon:SetHeight(14)
row.icon:SetPoint("LEFT", row, "LEFT", 2, 0)
row.name = row:CreateFontString(nil, "OVERLAY")
row.name:SetFont(font, 10, "OUTLINE")
row.name:SetPoint("LEFT", row.icon, "RIGHT", 4, 0)
row.name:SetJustifyH("LEFT")
row.time = row:CreateFontString(nil, "OVERLAY")
row.time:SetFont(font, 10, "OUTLINE")
row.time:SetPoint("RIGHT", row, "RIGHT", -20, 0)
row.time:SetJustifyH("RIGHT")
row.bell = row:CreateTexture(nil, "OVERLAY")
row.bell:SetWidth(13)
row.bell:SetHeight(13)
row.bell:SetPoint("RIGHT", row, "RIGHT", -3, 0)
row.bell:SetTexture("Interface\\Icons\\INV_Misc_Bell_01")
row.bell:Hide()
row:SetScript("OnClick", function()
local r = this.route
if not r then return end
if arg1 == "RightButton" then
if r.flipFrom then
if CFG.full then DB.flip[r.entry] = (not Flipped(r)) or nil
else manualFlip[r.entry] = (not manualFlip[r.entry]) or nil end
local f = this:GetScript("OnEnter")
if f and MouseIsOver and MouseIsOver(this) then GameTooltip:Hide(); f() end
end
return
end
DB.bell[r.entry] = (not Watched(r)) or nil
if Watched(r) then this.bell:Show() else this.bell:Hide() end
end)
row:SetScript("OnEnter", function()
local r = this.route
if not r then return end
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:AddLine(ShownName(r) .. " " .. r.kind, 1, 1, 1)
GameTooltip:AddLine((Period(r) and ("Cycle " .. string.format("%.1f", Period(r)) .. "s, ") or "Cycle not measured, ") .. "stays " .. string.format("%.0f", Dwell(r)) .. "s (object " .. r.entry .. ")", 0.7, 0.7, 0.7)
if DB.origin[r.entry] and DB.anchor[r.entry] then
local span = DB.anchor[r.entry] - DB.origin[r.entry]
if span > 60 then
GameTooltip:AddLine("Cycle measured over " .. (span >= 86400 and string.format("%.1f days", span / 86400) or string.format("%.0f min", span / 60)) .. " of observations.", 0.7, 0.7, 0.7)
end
end
if Flipped(r) then
GameTooltip:AddLine("Times are for " .. Away(r) .. ": when it docks there and leaves for " .. r.flipTo .. ".", 0.7, 0.7, 0.7)
if FarKnown(r) then
GameTooltip:AddLine(Away(r) .. " end measured on a ride: docks there " .. string.format("%.0f", FarOff(r, Period(r))) .. "s into the cycle, stays " .. string.format("%.0f", FarDwell(r)) .. "s.", 0.7, 0.7, 0.7)
else
GameTooltip:AddLine(Away(r) .. " end not measured: assumed to dock halfway through the cycle, so treat it as rough. Ride it once (window open) to measure it.", 1, 0.6, 0.2)
end
GameTooltip:AddLine("Click: " .. (Watched(r) and "remove the bell (no chat alert)" or "add a bell: chat message " .. CFG.alert .. "s before it arrives at " .. Away(r)), 0.2, 1, 0.8)
elseif r.far then
local lo = DB.lastObs[r.entry] or DB.anchor[r.entry] or r.anchor
if r.periodGuess and not DB.period[r.entry] then
GameTooltip:AddLine("Cycle is a rough estimate until the boat is seen docking at Ratchet once more.", 1, 0.6, 0.2)
end
GameTooltip:AddLine("fly = when to leave Orgrimmar, sails = when it leaves the Ratchet dock.", 0.7, 0.7, 0.7)
GameTooltip:AddLine("Travel time to the dock: " .. CFG.travel .. "s (/ob travel <sec>).", 0.7, 0.7, 0.7)
if lo then
GameTooltip:AddLine("Last seen at Ratchet " .. FormatSpan(time() - lo) .. " ago. Always an estimate from Orgrimmar.", 0.7, 0.7, 0.7)
else
GameTooltip:AddLine("Not measured yet: ride it with /ob debug on.", 1, 0.6, 0.2)
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
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
GameTooltip:Show()
end)
row:SetScript("OnLeave", function() GameTooltip:Hide() end)
rows[i] = row
return row
end
-- returns state ("docked" / "wait" / nil), seconds (may be nil if unknown)
local function Status(r)
if Flipped(r) then return Predict(r, true) end
local ps, psecs = Predict(r)
local live = seenState[r.entry]
if live then
if live.atDock then
if ps == "docked" then return "docked", psecs end
-- Still at the dock after its scheduled departure: it is about to pull away.
return "departing", nil
else
if ps == "wait" then return "wait", psecs end
return "wait", nil
end
end
return ps, psecs
end
-- far routes (Ratchet): seconds until the next sailing you can still make, and until
-- you have to leave. Sailing = departure from the dock.
local function FlyBy(r)
local state, secs = Predict(r)
if not state then return nil end
local sail = secs
if state == "wait" then sail = secs + Dwell(r) end
-- Once the time to fly has passed, keep showing "fly now" for 10 s (about the
-- slack in the travel time) instead of jumping straight to the next sailing. After
-- that this boat can no longer be made, so move on to the next one.
local fly = sail - CFG.travel
if fly < -10 then
sail = sail + Period(r)
fly = sail - CFG.travel
end
return fly, sail
end
local function RowText(r)
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, 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, mark .. "|cffaaaaaafly|r " .. color .. FormatTime(fly) ..
"|r |cff777777sails " .. FormatTime(sail) .. "|r"
end
local state, secs = Status(r)
local est = Mark(r)
local nm = RowName(r)
if state == "docked" then
if secs then
return nm, state, secs, est .. "|cff55ff55leaves|r |cffffffff" .. FormatTime(secs) .. "|r"
end
return nm, state, nil, "|cff55ff55here now|r"
elseif state == "departing" then
return nm, state, nil, "|cffffd200departing|r"
elseif state == "wait" then
if secs then
local color = "|cffffffff"
if CFG.alert > 0 and secs <= CFG.alert then color = "|cffffaa33" end
return nm, state, secs, est .. "|cffaaaaaadocks|r " .. color .. FormatTime(secs) .. "|r"
end
return nm, state, nil, "|cffaaaaaaarriving...|r"
end
return nm, state, nil, "|cff888888--|r"
end
-- Rows are sorted by what happens soonest: the next arrival, departure or time to fly.
local function Refresh()
local list = {}
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if Visible(r) then
local _, state, secs, txt = RowText(r)
local key = secs or 100000
if state == "departing" or (state == "fly" and secs <= 0) then key = 0 end
table.insert(list, { r = r, state = state, secs = secs, txt = txt, key = key, i = i })
end
end
table.sort(list, function(x, y)
if x.key ~= y.key then return x.key < y.key end
return x.i < y.i
end)
local n = 0
for k = 1, table.getn(list) do
local e = list[k]
local r, state, secs, txt = e.r, e.state, e.secs, e.txt
do
n = n + 1
local row = rows[n] or CreateRow(n)
row:Show()
row.route = r
local nm = ColoredName(r)
if cal and cal.need[r.entry] then
if (cal.got[r.entry] or 0) >= cal.need[r.entry] then
nm = "|cff55ff55+|r " .. nm
else
nm = "|cffffaa33-|r " .. nm
end
end
row.name:SetText(nm)
row.time:SetText(txt)
if Watched(r) then row.bell:Show() else row.bell:Hide() end
if state == "docked" then
row.icon:SetTexture(ICON_HERE)
row.icon:SetVertexColor(0.3, 1, 0.3)
row.bg:SetVertexColor(0.1, 0.5, 0.1, 0.35)
elseif state == "departing" then
row.icon:SetTexture(ICON_HERE)
row.icon:SetVertexColor(1, 0.82, 0)
row.bg:SetVertexColor(0.5, 0.4, 0, 0.3)
else
row.icon:SetTexture(ICON_AWAY)
if secs and CFG.alert > 0 and secs <= CFG.alert then
row.icon:SetVertexColor(1, 0.7, 0.2)
else
row.icon:SetVertexColor(0.6, 0.6, 0.6)
end
row.bg:SetVertexColor(0, 0, 0, 0)
end
end
end
for i = n + 1, table.getn(rows) do rows[i]:Hide() end
panel:SetHeight(TOP + n * ROW_H + 18)
panel.title:SetText("|cff33ffccTransports|r" .. (CFG.full and " |cff777777- all|r" or ""))
if recOn then
panel.status:SetText("|cffff4444REC|r " .. (type(DB.rec) == "table" and table.getn(DB.rec) or 0))
else
panel.status:SetText("")
end
if cal then
local d, t = CalCount()
local inView = 0
for i = 1, table.getn(ROUTES) do
if seenState[ROUTES[i].entry] then inView = inView + 1 end
end
if inView > 0 then cal.seenAny = true end
if inView > 0 then
panel.foot:SetText("|cffffaa33calibrating " .. d .. "/" .. t .. ": " .. inView .. " in view, stay put|r")
elseif (not cal.seenAny) and (GetTime() - cal.t0) > 600 then
-- transports are out of range for part of every cycle, so "none in view" alone
-- means nothing; only after 10 minutes without a single sighting is it a hint
panel.foot:SetText("|cffff5555calibrating " .. d .. "/" .. t .. ": nothing seen in 10 min, move closer to the towers|r")
else
panel.foot:SetText("|cffffaa33calibrating " .. d .. "/" .. t .. ": waiting for arrivals|r")
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
local function PlacePanel()
panel:ClearAllPoints()
if CFG.pos then
panel:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", CFG.pos.x, CFG.pos.y)
else
panel:SetPoint("TOPRIGHT", anchorTo, "BOTTOMRIGHT", 0, -8)
end
end
-- Auto-show: in any zone or sub-zone at either end of a route (or a planning place),
-- in a place added with /ob allow, or while a tracked transport is in view.
local function InOrg()
UpdateLoc()
if not CFG.zoneOnly then return true end
local z, sz = LOC.z, LOC.sz
for i = 1, table.getn(CFG.allow) do
local a = Norm(CFG.allow[i])
if a ~= "" and (z == a or sz == a) then return true end
end
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if InListAt(r.homeZ, z, sz) or InListAt(r.awayZ, z, sz) or InListAt(r.planZ, z, sz) or seenState[r.entry] then return true end
end
return false
end
-- override: nil = automatic, "show" = forced open anywhere, "hide" = forced closed.
-- Crossing into or out of the auto-show area puts it back to automatic.
local override = nil
local lastArea = nil
local function ApplyVisibility()
local area = InOrg()
if area ~= lastArea then
lastArea = area
override = nil
end
local want
if override == "show" then want = true
elseif override == "hide" then want = false
else want = area end
if want then
if not panel:IsShown() then
PlacePanel()
Refresh()
panel:Show()
end
btn.icon:SetVertexColor(1, 1, 1)
else
if panel:IsShown() then panel:Hide() end
btn.icon:SetVertexColor(0.55, 0.55, 0.55)
end
end
local function ToggleShown()
if panel:IsShown() then override = "hide" else override = "show" end
ApplyVisibility()
end
btn:SetScript("OnClick", function() ToggleShown() end)
btn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_LEFT")
GameTooltip:AddLine("Transports", 0.2, 1, 0.8)
GameTooltip:AddLine("Click: " .. (panel:IsShown() and "hide" or "show") .. " the window (works anywhere)", 1, 1, 1)
GameTooltip:AddLine("Opens by itself at either end of any route.", 0.7, 0.7, 0.7)
GameTooltip:AddLine("Drag: move this icon around the minimap", 0.7, 0.7, 0.7)
GameTooltip:Show()
end)
btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
------------------------------------------------------------------------
-- Update loop
------------------------------------------------------------------------
local alerted = {}
local flashUntil = 0
local pollT, uiT = 0, 0
local function RunAlerts()
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if Visible(r) and r.far and not Flipped(r) then
local fly = FlyBy(r)
if fly and Watched(r) and CFG.alert > 0 and fly <= CFG.alert then
local stamp = math.floor(EpochNow() + fly + 0.5)
if alerted[r.entry] ~= stamp then
alerted[r.entry] = stamp
if CFG.chat then Say(RouteName(r) .. (fly <= 0 and ": fly to Ratchet now." or (": fly to Ratchet in " .. FormatTime(fly) .. "."))) end
if CFG.sound then PlaySound("RaidWarning") end
if CFG.flash then flashUntil = GetTime() + 8 end
end
end
elseif Visible(r) then
local state, secs = Status(r)
if state == "wait" and secs then
if Watched(r) then
if CFG.alert > 0 and secs <= CFG.alert then
local stamp = math.floor(EpochNow() + secs + 0.5)
if alerted[r.entry] ~= stamp then
alerted[r.entry] = stamp
if CFG.chat then
Say(ShownName(r) .. ": docks at " .. StartName(r) .. " in " .. FormatTime(secs) .. ".")
end
if CFG.sound then PlaySound("RaidWarning") end
if CFG.flash then flashUntil = GetTime() + 8 end
end
end
end
end
end
end
end
local driver = CreateFrame("Frame")
driver:SetScript("OnUpdate", function()
local now = GetTime()
if now < flashUntil then
btn:SetAlpha(0.55 + 0.45 * math.abs(math.sin(now * 5)))
else
btn:SetAlpha(1)
end
EpochNow()
if cal and (now - cal.t0) > 1500 then CalFinish(false) end
pollT = pollT + arg1
if pollT >= ((cal or recOn) and 0.1 or 0.5) then
pollT = 0
Poll()
end
uiT = uiT + arg1
if uiT >= 0.25 then
uiT = 0
ApplyVisibility()
RunAlerts()
if panel:IsShown() then Refresh() end
end
end)
ApplyVisibility()
------------------------------------------------------------------------
-- Slash commands
------------------------------------------------------------------------
local function Words(msg)
local t = {}
for w in gfind(msg or "", "%S+") do table.insert(t, w) end
return t
end
local function Toggle(name, key)
CFG[key] = not CFG[key]
Say(name .. " " .. (CFG[key] and "on" or "off") .. ".")
end
local function NthRoute(n)
n = tonumber(n)
if not n then return nil end
local c = 0
for i = 1, table.getn(ROUTES) do
c = c + 1
if c == n then return ROUTES[i] end
end
return nil
end
SlashCmdList["OCTOBUS"] = function(msg)
msg = msg or ""
local w = Words(msg)
local cmd = string.lower(w[1] or "")
if cmd == "" then
ToggleShown()
elseif cmd == "list" then
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
local a, _, _, b = RowText(r)
Say(i .. ". " .. a .. " " .. b .. (Period(r) and (" (cycle " .. string.format("%.1f", Period(r)) .. "s)") or ""))
end
elseif cmd == "alert" then
local s = tonumber(w[2])
if s and s >= 0 then
CFG.alert = s
Say("alerts " .. (s == 0 and "off" or (s .. " seconds before arrival")) .. ".")
else
Say("usage: /ob alert <seconds> (0 = off)")
end
elseif cmd == "sound" then Toggle("sound alert", "sound")
elseif cmd == "chat" then Toggle("chat alert", "chat")
elseif cmd == "flash" then Toggle("minimap icon flash", "flash")
elseif cmd == "zone" then Toggle("auto-show only at route ends (off = always show)", "zoneOnly")
elseif cmd == "where" then
Say("zone: \"" .. (GetZoneText() or "") .. "\" sub-zone: \"" .. (GetSubZoneText() or "") .. "\" auto-shows here: " .. tostring(InOrg()))
elseif cmd == "allow" then
local nm = ""
for i = 2, table.getn(w) do nm = nm .. (i > 2 and " " or "") .. w[i] end
if nm == "" then
Say("usage: /ob allow <zone or sub-zone name> (see /ob where). Current extras: " .. table.concat(CFG.allow, ", "))
else
nm = string.gsub(string.gsub(string.gsub(nm, "[\"']", ""), "^%s+", ""), "%s+$", "")
local dup = false
for i = 1, table.getn(CFG.allow) do
if Norm(CFG.allow[i]) == Norm(nm) then dup = true end
end
if dup then
Say("\"" .. nm .. "\" is already on the list.")
else
table.insert(CFG.allow, nm)
Say("added \"" .. nm .. "\" to the places where the window opens by itself.")
end
end
elseif cmd == "disallow" then
local nm = ""
for i = 2, table.getn(w) do nm = nm .. (i > 2 and " " or "") .. w[i] end
local keep, gone = {}, 0
for i = 1, table.getn(CFG.allow) do
if Norm(CFG.allow[i]) == Norm(nm) then gone = gone + 1 else table.insert(keep, CFG.allow[i]) end
end
CFG.allow = keep
if gone > 0 then
Say("removed \"" .. nm .. "\". Still on the list: " .. (table.getn(keep) > 0 and table.concat(keep, ", ") or "nothing"))
else
Say("\"" .. nm .. "\" is not on the list. On the list: " .. (table.getn(keep) > 0 and table.concat(keep, ", ") or "nothing"))
end
elseif cmd == "allowclear" then
CFG.allow = {}
Say("extra places cleared (route ends still open the window).")
elseif cmd == "shape" then
local v = string.lower(w[2] or "")
if v == "square" or v == "round" then
CFG.shape = v
elseif v == "auto" then
CFG.shape = nil
else
Say("usage: /ob shape square|round|auto (now: " .. (CFG.shape or "auto") .. ", looks " .. (MinimapIsSquare() and "square" or "round") .. ")")
return
end
PlaceButton()
Say("minimap icon placement: " .. (CFG.shape or "auto") .. ".")
elseif cmd == "all" then
CFG.full = not CFG.full
Say(CFG.full and "showing all routes." or "showing routes for where you are.")
elseif cmd == "boat" then
CFG.hideBoat = not CFG.hideBoat
Say("Sparkwater Port boat " .. (CFG.hideBoat and "hidden" or "shown") .. ".")
elseif cmd == "lock" then
Toggle("window locked", "locked")
if CFG.locked then grip:Hide() else grip:Show() end
elseif cmd == "scale" then
local v = tonumber(w[2])
if v then
ApplyScale(v)
Say("window scale " .. string.format("%.2f", CFG.scale) .. ".")
else
Say("usage: /ob scale <0.6 to 2> (now " .. string.format("%.2f", CFG.scale or 1) .. "; or drag the dots in the bottom-right corner)")
end
elseif cmd == "resetpos" then
CFG.pos = nil
CFG.scale = nil
panel:SetScale(1)
CFG.angle = nil
PlaceButton()
override = nil
panel:Hide()
ApplyVisibility()
Say("window and minimap icon moved back to their default spots, auto mode restored.")
elseif cmd == "name" then
local r = NthRoute(w[2])
local text = ""
for i = 3, table.getn(w) do text = text .. (i > 3 and " " or "") .. w[i] end
if r and text ~= "" then
DB.names[r.entry] = text
Say("route " .. w[2] .. " is now called: " .. RouteName(r))
else
Say("usage: /ob name <number from /ob list> <new place name> (renames the far end; the home end stays)")
end
elseif cmd == "period" then
local r = NthRoute(w[2])
local s = tonumber(w[3])
if r and s and s > 30 then
DB.period[r.entry] = s
Say(RouteName(r) .. ": cycle set to " .. s .. "s.")
else
Say("usage: /ob period <number from /ob list> <seconds>")
end
elseif cmd == "calibrate" or cmd == "cal" then
if w[2] == "stop" or w[2] == "cancel" then
if cal then CalFinish(false) else Say("not calibrating.") end
elseif not dllOk then
Say("calibration needs ZepSense.dll answering (see /ob debug).")
else
local n = CalStart()
override = "show"
ApplyVisibility()
Say("calibrating " .. n .. " transports. Stand where you can see the docks (between the two towers), do not /reload, and wait.")
Say("each transport needs " .. "1 fresh arrival (2 the first time). Rows turn + when done. /ob calibrate stop cancels.")
local seen = 0
for i = 1, table.getn(ROUTES) do
if seenState[ROUTES[i].entry] then seen = seen + 1 end
end
if seen == 0 then
Say("none of them are in view this second. That is normal: each transport is out of range for part of its trip. If the footer still says nothing has been seen after about 10 minutes, move closer to the towers.")
end
end
elseif cmd == "reset" then
DB.anchor = {}
DB.period = {}
DB.origin = {}
DB.dwell = {}
DB.farOff = {}
DB.farDwell = {}
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 = {}
DB.pW0 = {}
DB.pP0 = {}
DB.pK = {}
cal = nil
confirmed = {}
seenState = {}
Say("learned data cleared; using built-in values until each zeppelin is seen again.")
elseif cmd == "travel" then
local s = tonumber(w[2])
if s and s >= 0 then
CFG.travel = s
Say("travel time from Orgrimmar to the Ratchet dock: " .. s .. "s.")
else
Say("usage: /ob travel <seconds> (now " .. CFG.travel .. "s)")
end
elseif cmd == "debug" and (string.lower(w[2] or "") == "on" or string.lower(w[2] or "") == "off") then
if string.lower(w[2]) == "on" then
DB.rec = {}
recLast = {}
recZone, recTaxi = nil, nil
recOn = true
override = "show"
ApplyVisibility()
Say("recording every transport the DLL reports. /ob debug off to stop; log out or /reload to save it.")
else
recOn = false
local n = type(DB.rec) == "table" and table.getn(DB.rec) or 0
Say("recording stopped: " .. n .. " lines. Log out or /reload to write them to the saved file.")
end
elseif cmd == "debug" then
Say("DLL function present: " .. tostring(type(ZepTransports) == "function") ..
", answering: " .. tostring(dllOk))
if type(ZepTransports) == "function" then
local ok, str = pcall(ZepTransports)
str = tostring(str)
Say("raw (" .. string.len(str) .. " chars): " .. string.sub(str, 1, 180))
end
if recOn then Say("recording: on, " .. table.getn(DB.rec) .. " lines so far.") end
else
Say("/ob (window on/off) | list | alert <s> | all|sound|chat|flash|zone|boat|lock | scale <n> | where | shape <square|round|auto> | allow|disallow <name> | resetpos | name <n> <text> | period <n> <s> | travel <s> | calibrate | reset | debug [on|off]")
end
end
end)
if not OB_OK then
OB_STAGE = "start-up error: " .. tostring(OB_ERR)
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccOctoBus:|r |cffff5555" .. OB_STAGE .. "|r")
end