Files
Octobus/OctoBus.lua
T

1234 lines
47 KiB
Lua

--[[
OctoBus
-------
Arrival timers for the zeppelins at the Orgrimmar towers (plus the Sparkwater Port boat).
A small icon on the minimap edge shows / hides a timer window; the window opens by itself
in Orgrimmar and Durotar.
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 in Orgrimmar / Durotar"
/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 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.
------------------------------------------------------------------------
local ROUTES = {
{ entry = 164871, name = "Undercity", kind = "zeppelin", x = 1318.1, y = -4658.0, period = 356.3, dwell = 63, anchor = 1791422404 },
{ entry = 175080, name = "Grom'gol", kind = "zeppelin", x = 1360.8, y = -4631.3, period = 303.5, dwell = 63, anchor = 1791422441 },
{ entry = 190552, name = "Kargath", kind = "zeppelin", x = 1206.1, y = -4158.7, period = 374.0, dwell = 63, anchor = 1791422484 },
{ entry = 190549, name = "Thunder Bluff", kind = "zeppelin", x = 1125.4, y = -4135.0, period = 567.0, dwell = 63, anchor = 1791422131 },
{ entry = 190550, name = "Sparkwater", kind = "boat", x = 876.1, y = -5204.1, period = 244.75, dwell = 43, anchor = 1791422323, boat = true },
-- 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. Measured on two rides
-- (8 Oct 2026): Ratchet arrivals 1791479926.67 and 1791480654.81 are 2 cycles apart
-- (364.07 s; legs 64 s dock + 114.8 s crossing + 64.2 s Booty Bay + 121.1 s back).
-- origin seeds the baseline so later arrivals keep refining the cycle.
{ entry = 20808, name = "Ratchet", kind = "boat", x = -1005.6, y = -3841.6, period = 364.07,
dwell = 64, anchor = 1791480654.81, origin = 1791479926.67, far = true },
}
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.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 {}
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()
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
-- "<place> <kind>": the place can be renamed with /ob name; in the window the kind is
-- shown in the same grey as the "docks" / "leaves" labels.
local function RouteName(r) return (DB.names[r.entry] or r.name) .. " " .. r.kind end
local function RowName(r) return (DB.names[r.entry] or r.name) .. " |cffaaaaaa" .. r.kind .. "|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
local function Visible(r) return (not r.boat) or (not CFG.hideBoat) end
-- confirmed[entry] = true once the transport has been SEEN docking/leaving this session
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
local function Predict(r)
local a, P = Anchor(r), Period(r)
if not a or not P or P <= 0 then return nil end
local e = EpochNow() - a
local ph = math.mod(e, P)
if ph < 0 then ph = ph + P end
local d = Dwell(r)
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 seenState = {} -- entry -> { atDock = bool, arrivedAt = epoch or nil }
local dllOk = false
local lastGood = 0
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
if k >= 1 and math.abs(resid) < 0.25 * P then
DB.period[e] = delta / 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) < 0.25 * P
end
if fits then
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
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
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
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)
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
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.92)
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)
-- 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")
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.kind = row:CreateFontString(nil, "OVERLAY")
row.kind:SetFont(font, 10, "OUTLINE")
row.kind:SetTextColor(0.667, 0.667, 0.667)
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()
if not this.route then return end
DB.bell[this.route.entry] = (not Watched(this.route)) or nil
if Watched(this.route) 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(RouteName(r), 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 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
GameTooltip:AddLine(confirmed[r.entry] and "Seen this session: exact." or "Not seen this session: estimate (~).", 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
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)
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
return "docked", 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
local P = Period(r)
local fly = sail - CFG.travel
while fly < 0 do
sail = sail + P
fly = fly + P
end
return fly, sail
end
local function RowText(r)
if r.far and not seenState[r.entry] then
local fly, sail = FlyBy(r)
if not fly then return RowName(r), nil, nil, "|cff888888not measured yet|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) ..
"|r |cff777777sails " .. FormatTime(sail) .. "|r"
end
local state, secs = Status(r)
local est = confirmed[r.entry] and "" or "|cff888888~|r"
local mark = ""
local nm = mark .. 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 == "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
local function Refresh()
local n = 0
local kindL, kindR = nil, nil
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if Visible(r) then
n = n + 1
local row = rows[n] or CreateRow(n)
row:Show()
row.route = r
local _, state, secs, txt = RowText(r)
local nm = DB.names[r.entry] or r.name
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)
row.kind:SetText(r.kind)
local nw, tw = row.name:GetStringWidth(), row.time:GetStringWidth()
if type(nw) ~= "number" then nw = 60 end
if type(tw) ~= "number" then tw = 60 end
local nameRight = 2 + 14 + 4 + nw
local timeLeft = (WIDTH - 8) - 20 - tw
if not kindL or nameRight > kindL then kindL = nameRight end
if not kindR or timeLeft < kindR then kindR = timeLeft end
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)
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
-- transport types in one column, centred between the longest place name and the
-- widest timer of all rows
if kindL and kindR then
local cx = math.floor((kindL + kindR) / 2 + 0.5)
for i = 1, n do
rows[i].kind:ClearAllPoints()
rows[i].kind:SetPoint("CENTER", rows[i], "LEFT", cx, 0)
end
end
for i = n + 1, table.getn(rows) do rows[i]:Hide() end
panel:SetHeight(TOP + n * ROW_H + 18)
if recOn then
panel.status:SetText("|cffff4444REC|r " .. (type(DB.rec) == "table" and table.getn(DB.rec) or 0))
else
panel.status:SetText(dllOk and "" or "no DLL: estimates only")
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()
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 areas: Orgrimmar and Durotar (zone or sub-zone name), plus anything added with /ob allow.
-- Names are compared without capitals, quotes or outer spaces. The window also stays
-- open while a far route's transport (the Ratchet boat) is in view: on its dock and for
-- the whole crossing, which runs through zones that are not on the list.
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 function InOrg()
if not CFG.zoneOnly then return true end
local z = Norm(GetZoneText())
local sz = Norm(GetSubZoneText())
if z == "orgrimmar" or sz == "orgrimmar" or z == "durotar" or sz == "durotar" then return true end
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
if ROUTES[i].far and seenState[ROUTES[i].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 in Orgrimmar and Durotar.", 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 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 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(RouteName(r) .. " arrives 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 limited to Orgrimmar/Durotar (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 (Orgrimmar and Durotar remain).")
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 == "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> (the zeppelin / boat part 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.lastCal = nil
DB.lastObs = {}
DB.pending = {}
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> | 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