Put addon at repo root for launcher installs; ship DLL as a release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyQKCL92ZLwPi9qmYQtNdc
This commit is contained in:
Phridgey
2026-10-08 12:16:52 -04:00
co-authored by Claude Opus 5.5
parent f8208701c8
commit e7ba87f9fc
5 changed files with 13 additions and 4 deletions
+949
View File
@@ -0,0 +1,949 @@
--[[
TurtleTransit
-------------
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 (/tt, also /transit and /zep)
/tt show / hide the window (works anywhere)
/tt list print all timers in chat
/tt alert <sec> bell alerts fire this many seconds before arrival (default 45)
/tt sound|chat|flash toggle that alert type
/tt zone toggle "auto-show only in Orgrimmar / Durotar"
/tt boat show / hide the Sparkwater Port boat
/tt shape square|round|auto minimap icon placement (auto looks at your minimap)
/tt where print your zone / sub-zone and whether the window auto-shows
/tt allow <name> also auto-show in this zone or sub-zone
/tt name <n> <text> rename route number n (from /tt list)
/tt period <n> <sec> set a route's cycle length by hand
/tt calibrate optional: watch every transport on purpose (stand between the towers).
Not required: any arrival you see near the towers calibrates automatically.
/tt reset forget everything learned (back to built-in values)
/tt debug show what the DLL is returning
]]
-- Stage 1: runs as soon as this file loads, so /tt always answers, even if the real
-- start-up below fails. If it fails, /tt prints the error message.
local TT_STAGE = "file loaded, start-up not finished"
SLASH_TURTLETRANSIT1 = "/tt"
SLASH_TURTLETRANSIT2 = "/transit"
SLASH_TURTLETRANSIT3 = "/zep"
SlashCmdList["TURTLETRANSIT"] = function(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccTurtleTransit:|r not running. " .. tostring(TT_STAGE))
end
local TT_OK, TT_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", x = 1318.1, y = -4658.0, period = 356.3, dwell = 63, anchor = 1791422404 },
{ entry = 175080, name = "Grom'gol", x = 1360.8, y = -4631.3, period = 303.5, dwell = 63, anchor = 1791422441 },
{ entry = 190552, name = "Kargath", x = 1206.1, y = -4158.7, period = 374.0, dwell = 63, anchor = 1791422484 },
{ entry = 190549, name = "Thunder Bluff", x = 1125.4, y = -4135.0, period = 567.0, dwell = 63, anchor = 1791422131 },
{ entry = 190550, name = "Sparkwater boat", x = 876.1, y = -5204.1, period = 244.75, dwell = 43, anchor = 1791422323, boat = 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.TurtleTransitDB
if type(t) ~= "table" then
t = {}
G.TurtleTransitDB = 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.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("|cff33ffccTurtleTransit:|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
local function RouteName(r) return DB.names[r.entry] or r.name 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. /tt 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 /tt 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
local function CalAgeText()
local newestOld, shortest, have, total = nil, nil, 0, 0
local now = time()
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if Visible(r) then
total = total + 1
local t = DB.lastObs[r.entry]
local o = DB.origin[r.entry]
if t and o then
have = have + 1
if not newestOld or t < newestOld then newestOld = t end -- longest since seen
local span = EpochNow() - o
if not shortest or span < shortest then shortest = span end -- thinnest baseline
end
end
end
if have == 0 then return "no baseline yet (visit the towers)", true end
if have < total then return "baseline: " .. have .. " of " .. total .. " transports", true end
local gone = now - newestOld
local seen
if gone < 3600 then seen = "seen just now"
elseif gone < 86400 then seen = "last seen " .. string.format("%.0f", gone / 3600) .. " h ago"
else seen = "last seen " .. string.format("%d", math.floor(gone / 86400 + 0.5)) .. "d ago" end
local stale = (gone >= 5 * 86400)
if stale then seen = seen .. ", visit the towers" end
return "baseline established: " .. FormatSpan(shortest) .. " (" .. seen .. ")", 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) 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
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()
else
DB.origin[e] = t -- schedule jumped (server restart?): start a new baseline
rebased = true
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
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()
local present = {}
for rec in gfind(str, "([^;]+);") do
local _, _, tag, ge, de, x, y = string.find(rec, "^%x+,(%x+),(%d+),(%d+),(%-?[%d%.]+),(%-?[%d%.]+),")
de = tonumber(de)
x = tonumber(x)
y = tonumber(y)
if de and x and y then
for i = 1, table.getn(ROUTES) do
local r = ROUTES[i]
if r.entry == de 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", "TurtleTransitButton", 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 = 246
local ROW_H = 18
local TOP = 22
local panel = CreateFrame("Frame", "TurtleTransitPanel", UIParent)
panel:SetFrameStrata("MEDIUM")
panel:SetWidth(WIDTH)
panel:SetHeight(100)
panel:SetClampedToScreen(true)
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)
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.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("Cycle " .. string.format("%.1f", Period(r)) .. "s, 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
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)
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
local function RowText(r)
local state, secs = Status(r)
local est = confirmed[r.entry] and "" or "|cff888888~|r"
local mark = ""
local nm = mark .. RouteName(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
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 nm, state, secs, txt = RowText(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)
row.name:SetWidth(WIDTH - 8 - 18 - 4 - 92 - 18)
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
for i = n + 1, table.getn(rows) do rows[i]:Hide() end
panel:SetHeight(TOP + n * ROW_H + 18)
panel.status:SetText(dllOk and "" or "no DLL: estimates only")
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 /tt allow.
local function InOrg()
if not CFG.zoneOnly then return true end
local z = GetZoneText() or ""
local sz = GetSubZoneText() or ""
if z == "Orgrimmar" or sz == "Orgrimmar" or z == "Durotar" or sz == "Durotar" then return true end
for i = 1, table.getn(CFG.allow) do
if z == CFG.allow[i] or sz == CFG.allow[i] 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) 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 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["TURTLETRANSIT"] = 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 .. " (cycle " .. string.format("%.1f", Period(r)) .. "s)")
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: /tt 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: /tt allow <zone or sub-zone name> (see /tt where). Current extras: " .. table.concat(CFG.allow, ", "))
else
table.insert(CFG.allow, nm)
Say("added \"" .. nm .. "\" to the places where the window opens by itself.")
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: /tt 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")
elseif cmd == "resetpos" then
CFG.pos = nil
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: " .. text)
else
Say("usage: /tt name <number from /tt list> <new name>")
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: /tt period <number from /tt 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 /tt 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. /tt 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 = {}
cal = nil
confirmed = {}
seenState = {}
Say("learned data cleared; using built-in values until each zeppelin is seen again.")
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
else
Say("/tt (window on/off) | list | alert <s> | sound|chat|flash|zone|boat|lock | where | shape <square|round|auto> | allow <name> | resetpos | name <n> <text> | period <n> <s> | calibrate | reset | debug")
end
end
end)
if not TT_OK then
TT_STAGE = "start-up error: " .. tostring(TT_ERR)
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccTurtleTransit:|r |cffff5555" .. TT_STAGE .. "|r")
end