From ac9e9c43b75f39f3bd59ed704984778abeb62a08 Mon Sep 17 00:00:00 2001 From: Phridgey Date: Thu, 8 Oct 2026 11:48:15 -0400 Subject: [PATCH] Add files via upload Initial version for release --- TurtleTransit/TurtleTransit.lua | 949 ++++++++++++++++++++++++++++++++ TurtleTransit/TurtleTransit.toc | 8 + 2 files changed, 957 insertions(+) create mode 100644 TurtleTransit/TurtleTransit.lua create mode 100644 TurtleTransit/TurtleTransit.toc diff --git a/TurtleTransit/TurtleTransit.lua b/TurtleTransit/TurtleTransit.lua new file mode 100644 index 0000000..c780c69 --- /dev/null +++ b/TurtleTransit/TurtleTransit.lua @@ -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 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 also auto-show in this zone or sub-zone + /tt name rename route number n (from /tt list) + /tt period 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 (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 (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 ") + 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 ") + 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 | sound|chat|flash|zone|boat|lock | where | shape | allow | resetpos | name | period | 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 diff --git a/TurtleTransit/TurtleTransit.toc b/TurtleTransit/TurtleTransit.toc new file mode 100644 index 0000000..21dcd7d --- /dev/null +++ b/TurtleTransit/TurtleTransit.toc @@ -0,0 +1,8 @@ +## Interface: 11200 +## Title: |cff33ffccTurtle|rTransit +## Notes: Zeppelin and boat arrival timers for Orgrimmar. Reads live positions from ZepSense.dll; minimap icon toggles the window. +## Author: You +## Version: 1.0 +## SavedVariables: TurtleTransitDB + +TurtleTransit.lua