Files
Questie-X/Modules/Tracker/TrackerUtils.lua
T
Narcasung 0103932b9b fix(tracker): make the objective marker button usable at larger sizes
The button shared the quest gutter with the collapse button, the quest
item buttons and the zone header text, so anything past the default size
was clipped by the tracker edge or drawn on top of its neighbours.

Give it a gutter of its own instead. GetSuperTrackMarginReserve feeds
questMarginLeft, which every layout and width calculation already builds
on, and the quest item buttons and zone labels are shifted by the same
amount. The button then anchors flush left of the line and the tracker
widens to match, so nothing overlaps at any size. Raise the size cap to
70 now that it fits, and drop the hover tooltip.

Also stop relying on the SetSuperTrackedQuestID hook as the only source
of truth. It never fires while the player is a ghost -- the corpse arrow
takes over the marker -- and it has not fired yet on a login or reload,
which left the tracker with no idea what was tracked in both cases. The
client is now asked directly: the watch frame POI buttons flag their own
selection, the map pins say it through their art (the selected variant
sits half a texture above the normal one), and the quest log selection
answers for the login window when no pin is styled yet. Selection calls
are hooked for the same reason, so clicks repaint while dead, and the
requests are coalesced because callers select a quest log entry and
restore the previous one a line later.
2026-07-25 16:19:56 +02:00

1699 lines
66 KiB
Lua

---@class TrackerUtils
local TrackerUtils = QuestieLoader:ImportModule("TrackerUtils")
-------------------------
--Import QuestieTracker modules.
-------------------------
---@type QuestieTracker
local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker")
---@type TrackerLinePool
local TrackerLinePool = QuestieLoader:ImportModule("TrackerLinePool")
---@type TrackerFadeTicker
local TrackerFadeTicker = QuestieLoader:ImportModule("TrackerFadeTicker")
---@type QuestieCombatQueue
local QuestieCombatQueue = QuestieLoader:ImportModule("QuestieCombatQueue")
-------------------------
--Import Questie modules.
-------------------------
---@type QuestiePlayer
local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
---@type QuestieMap
local QuestieMap = QuestieLoader:ImportModule("QuestieMap")
---@type QuestieCoords
local QuestieCoords = QuestieLoader:ImportModule("QuestieCoords")
---@type ZoneDB
local ZoneDB = QuestieLoader:ImportModule("ZoneDB")
---@type QuestieArrow
local QuestieArrow = QuestieLoader:ImportModule("QuestieArrow")
---@type l10n
local l10n = QuestieLoader:ImportModule("l10n")
--- COMPATIBILITY ---
local C_Timer = QuestieCompat.C_Timer
local C_Map = QuestieCompat.C_Map
local GetQuestLogTitle = QuestieCompat.GetQuestLogTitle
local GetQuestLogIndexByID = QuestieCompat.GetQuestLogIndexByID
local WorldMapFrame = QuestieCompat.WorldMapFrame
local tinsert = table.insert
local objectiveFlashTicker
local zoneCache = {}
local questProximityTimer
local questZoneProximityTimer
local bindTruthTable = {
['left'] = function(button)
return "LeftButton" == button
end,
['right'] = function(button)
return "RightButton" == button
end,
['shiftleft'] = function(button)
return "LeftButton" == button and IsShiftKeyDown()
end,
['shiftright'] = function(button)
return "RightButton" == button and IsShiftKeyDown()
end,
['ctrlleft'] = function(button)
return "LeftButton" == button and IsControlKeyDown()
end,
['ctrlright'] = function(button)
return "RightButton" == button and IsControlKeyDown()
end,
['altleft'] = function(button)
return "LeftButton" == button and IsAltKeyDown()
end,
['altright'] = function(button)
return "RightButton" == button and IsAltKeyDown()
end,
['disabled'] = function() return false end,
}
local _QuestLogScrollBar = QuestLogScrollFrameScrollBar or QuestLogListScrollFrame.ScrollBar or
QuestLogListScrollFrameScrollBar
---@param quest table The table provided by QuestieDB.GetQuest(questId)
function TrackerUtils:ShowQuestLog(quest)
local questFrame = QuestLogExFrame or ClassicQuestLog or QuestLogFrame
local questLogIndex = (GetQuestLogIndexByID and GetQuestLogIndexByID(quest.Id)) or 0
if not questLogIndex or questLogIndex <= 0 then
-- Quest is not in the Quest Log (completed/auto-completed and then disappeared)
return
end
SelectQuestLogEntry(questLogIndex)
-- Scroll to the quest in the quest log (only if scrolling is available)
if _QuestLogScrollBar and _QuestLogScrollBar.GetValueStep then
local scrollSteps = _QuestLogScrollBar:GetValueStep() or 0
if scrollSteps > 0 then
_QuestLogScrollBar:SetValue(questLogIndex * scrollSteps - scrollSteps * 3)
end
end
if not questFrame:IsShown() then
if not InCombatLockdown() then
ShowUIPanel(questFrame)
if (QuestLogEx) then
QuestLogEx:Maximize()
end
else
Questie:Print(l10n("Can't open Quest Log while in combat. Open it manually."))
end
end
QuestLog_UpdateQuestDetails()
QuestLog_Update()
end
---@param title string The name of the WayPoint
---@param zone number The zone ID number
---@param x number X coordinate
---@param y number Y coordinate
function TrackerUtils:SetTomTomTarget(title, zone, x, y)
if QuestieArrow and QuestieArrow.SetTarget and title and zone and x and y then
QuestieArrow:SetTarget(title, zone, x, y)
end
if TomTom and TomTom.AddWaypoint then
if Questie.db.char._tom_waypoint and TomTom.RemoveWaypoint then -- remove old waypoint
TomTom:RemoveWaypoint(Questie.db.char._tom_waypoint)
end
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if QuestieCompat.Is335 then
Questie.db.char._tom_waypoint = QuestieCompat.TomTom_AddWaypoint(title, uiMapId, x, y)
else
Questie.db.char._tom_waypoint = TomTom:AddWaypoint(uiMapId, x / 100, y / 100, { title = title, crazy = true })
end
end
end
---@param objective table The table provided by QuestieDB.GetQuest(questId).Objectives[objective]
function TrackerUtils:ShowObjectiveOnMap(objective)
local spawn, zone = QuestieMap:GetNearestSpawn(objective)
if spawn then
WorldMapFrame:Show()
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
WorldMapFrame:SetMapID(uiMapId)
TrackerUtils:FlashObjective(objective)
end
end
---@param quest table The table provided by QuestieDB.GetQuest(questId)
function TrackerUtils:ShowFinisherOnMap(quest)
local spawn, zone = QuestieMap:GetNearestQuestSpawn(quest)
if spawn then
WorldMapFrame:Show()
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
WorldMapFrame:SetMapID(uiMapId)
TrackerUtils:FlashFinisher(quest)
end
end
---@param objective table The table provided by QuestieDB.GetQuest(questId).Objectives[objective]
function TrackerUtils:FlashObjective(objective)
if next(objective.AlreadySpawned) then
local toFlash = {}
-- ugly code
for _, framelist in pairs(QuestieMap.questIdFrames) do
for _, frameName in pairs(framelist) do
local icon = _G[frameName]
if not icon.miniMapIcon then
-- todo: move into frame.session
if icon:IsShown() then
icon._hidden_by_flash = true
icon:Hide()
if icon.data.lineFrames then
for _, line in pairs(icon.data.lineFrames) do
if line:IsShown() then
line._hidden_by_flash = true
line:Hide()
end
end
end
end
end
end
end
for _, spawn in pairs(objective.AlreadySpawned) do
if spawn.mapRefs then
for _, frame in pairs(spawn.mapRefs) do
tinsert(toFlash, frame)
if frame._hidden_by_flash then
frame:Show()
end
-- todo: move into frame.session
frame._hidden_by_flash = nil
frame._size = frame:GetWidth()
end
end
end
local flashW = 1
local flashB = true
local flashDone = 0
objectiveFlashTicker = C_Timer.NewTicker(0.1, function()
for _, frame in pairs(toFlash) do
frame:SetWidth(frame._size + flashW)
frame:SetHeight(frame._size + flashW)
end
if flashB then
if flashW < 10 then
flashW = flashW + (16 - flashW) / 2 + 0.06
if flashW >= 9.5 then
flashB = false
end
end
else
if flashW > 0 then
flashW = flashW - 2
--flashW = (flashW + (-flashW) / 3) - 0.06
if flashW < 1 then
--flashW = 0
flashB = true
-- ugly code
if flashDone > 0 then
C_Timer.After(0.1, function()
objectiveFlashTicker:Cancel()
for _, frame in pairs(toFlash) do
frame:SetWidth(frame._size)
frame:SetHeight(frame._size)
frame._size = nil
end
end)
C_Timer.After(0.5, function()
for _, framelist in pairs(QuestieMap.questIdFrames) do
for _, frameName in pairs(framelist) do
local icon = _G[frameName]
if icon._hidden_by_flash then
icon._hidden_by_flash = nil
icon:Show()
if icon.data.lineFrames then
for _, line in pairs(icon.data.lineFrames) do
if line._hidden_by_flash then
line._hidden_by_flash = nil
line:Show()
end
end
end
end
end
end
end)
end
flashDone = flashDone + 1
end
end
end
end)
end
end
---@param quest table The table provided by QuestieDB.GetQuest(questId)
function TrackerUtils:FlashFinisher(quest)
local toFlash = {}
-- ugly code
for questId, framelist in pairs(QuestieMap.questIdFrames) do
if questId ~= quest.Id then
for _, frameName in pairs(framelist) do
local icon = _G[frameName]
if not icon.miniMapIcon then
-- todo: move into frame.session
if icon:IsShown() then
icon._hidden_by_flash = true
icon:Hide()
if icon.data.lineFrames then
for _, line in pairs(icon.data.lineFrames) do
if line:IsShown() then
line._hidden_by_flash = true
line:Hide()
end
end
end
end
end
end
else
for _, frameName in pairs(framelist) do
local icon = _G[frameName]
if not icon.miniMapIcon then
icon._size = icon:GetWidth()
tinsert(toFlash, icon)
end
end
end
end
local flashW = 1
local flashB = true
local flashDone = 0
objectiveFlashTicker = C_Timer.NewTicker(0.1, function()
for _, frame in pairs(toFlash) do
frame:SetWidth(frame._size + flashW)
frame:SetHeight(frame._size + flashW)
end
if flashB then
if flashW < 10 then
flashW = flashW + (16 - flashW) / 2 + 0.06
if flashW >= 9.5 then
flashB = false
end
end
else
if flashW > 0 then
flashW = flashW - 2
--flashW = (flashW + (-flashW) / 3) - 0.06
if flashW < 1 then
--flashW = 0
flashB = true
-- ugly code
if flashDone > 0 then
C_Timer.After(0.1, function()
objectiveFlashTicker:Cancel()
for _, frame in pairs(toFlash) do
frame:SetWidth(frame._size)
frame:SetHeight(frame._size)
frame._size = nil
end
end)
C_Timer.After(0.5, function()
for _, framelist in pairs(QuestieMap.questIdFrames) do
for _, frameName in pairs(framelist) do
local icon = _G[frameName]
if icon._hidden_by_flash then
icon._hidden_by_flash = nil
icon:Show()
if icon.data.lineFrames then
for _, line in pairs(icon.data.lineFrames) do
if line._hidden_by_flash then
line._hidden_by_flash = nil
line:Show()
end
end
end
end
end
end
end)
end
flashDone = flashDone + 1
end
end
end
end)
end
---@param bind string
---@param button string
---@return string bind The input keybind string
---@return string button The input button string
---@return string bindTruthTable.bind Returns matched bind string
---@return function|boolean bindTruthTable.bind.button Returns button function or false if there is no keybind set
function TrackerUtils:IsBindTrue(bind, button)
return bind and button and bindTruthTable[bind] and bindTruthTable[bind](button)
end
---@param itemId number
---@return boolean
function TrackerUtils:IsQuestItemUsable(itemId)
if itemId and (GetItemSpell(itemId) or IsEquippableItem(itemId)) then
return true
end
return false
end
---@param quest table Quest Table
---@return string|nil completionText Quest Completion text string or nil
function TrackerUtils:GetCompletionText(quest)
local completionText
if GetQuestLogCompletionText then
local questIndex = GetQuestLogIndexByID(quest.Id)
completionText = GetQuestLogCompletionText(questIndex)
end
if completionText then
return completionText
elseif quest.Description and quest.Description[1] then
return quest.Description[1]:gsub("%.", "")
else
-- Fallback/custom quests (e.g. server quests not in QuestieDB) have no
-- top-level Description array. Without this, indexing quest.Description[1]
-- throws and aborts the whole tracker render for that quest via the
-- caller's pcall, freezing its line on the last successful render.
return nil
end
end
---@param zoneId number Zone ID number
---@return string @Zone Name (Localized) or "Unknown Zone"
local function GetZoneNameByIDFallback(zoneId)
if zoneCache[zoneId] then
return zoneCache[zoneId]
end
if zoneId <= 0 or type(zoneId) ~= "number" then
return "Unknown Zone"
end
for _, zone in pairs(l10n.zoneLookup) do
if zone[zoneId] then
zoneCache[zoneId] = zone[zoneId]
return zoneCache[zoneId]
end
end
-- Ascension can use custom UiMapIds for zones/sub-zones (e.g. 1238 Northshire Valley).
-- Those won't exist in l10n.zoneLookup (which is AreaId-based), so fallback to UiMapData / mapInfo.
local uiMapData = QuestieCompat and QuestieCompat.UiMapData and QuestieCompat.UiMapData[zoneId]
if uiMapData and uiMapData.name then
zoneCache[zoneId] = uiMapData.name
return zoneCache[zoneId]
end
if C_Map and C_Map.GetMapInfo then
local mapInfo = C_Map.GetMapInfo(zoneId)
if mapInfo and mapInfo.name then
zoneCache[zoneId] = mapInfo.name
return zoneCache[zoneId]
end
end
Questie:Debug(Questie.DEBUG_CRITICAL, "[GetZoneNameByIDFallback]: Unable to find a zone name for zoneId", zoneId)
return "Unknown Zone"
end
---@param zoneId number Zone ID number
---@return string @Zone Name (Localized)
function TrackerUtils:GetZoneNameByID(zoneId)
if zoneCache[zoneId] then
return zoneCache[zoneId]
end
if C_Map and C_Map.GetAreaInfo(zoneId) then
zoneCache[zoneId] = C_Map.GetAreaInfo(zoneId)
else
zoneCache[zoneId] = GetZoneNameByIDFallback(zoneId)
end
return zoneCache[zoneId]
end
---@param catId number Catagory ID number
---@return string CatagoryName Catagory Name (Localized) or "Unknown Category"
function TrackerUtils:GetCategoryNameByID(catId)
if zoneCache[catId] then
return zoneCache[catId]
end
if type(catId) == "number" and catId < 0 and type(l10n.questCategoryLookup[catId]) == "string" then
zoneCache[catId] = l10n.questCategoryLookup[catId]
return zoneCache[catId]
end
return "Unknown Category"
end
function TrackerUtils:UnFocus()
-- reset HideIcons to match savedvariable state
if (not Questie.db.char.TrackerFocus) then
return
end
for questId in pairs(QuestiePlayer.currentQuestlog) do
local quest = QuestieDB.GetQuest(questId)
if quest then
quest.FadeIcons = nil
if next(quest.Objectives) then
if Questie.db.char.TrackerHiddenQuests[quest.Id] then
quest.HideIcons = true
quest.FadeIcons = nil
else
quest.HideIcons = nil
quest.FadeIcons = nil
end
for _, objective in pairs(quest.Objectives) do
if Questie.db.char.TrackerHiddenObjectives[tostring(questId) .. " " .. tostring(objective.Index)] then
objective.HideIcons = true
objective.FadeIcons = nil
else
objective.HideIcons = nil
objective.FadeIcons = nil
end
end
for _, objective in pairs(quest.SpecialObjectives) do
if Questie.db.char.TrackerHiddenObjectives[tostring(questId) .. " " .. tostring(objective.Index)] then
objective.HideIcons = true
objective.FadeIcons = nil
else
objective.HideIcons = nil
objective.FadeIcons = nil
end
end
end
end
end
Questie.db.char.TrackerFocus = nil
end
---@param questId number Quest ID number
---@param objectiveIndex number Objective Index number
function TrackerUtils:FocusObjective(questId, objectiveIndex)
if Questie.db.char.TrackerFocus and (type(Questie.db.char.TrackerFocus) ~= "string" or Questie.db.char.TrackerFocus ~= tostring(questId) .. " " .. tostring(objectiveIndex)) then
TrackerUtils:UnFocus()
end
Questie.db.char.TrackerFocus = tostring(questId) .. " " .. tostring(objectiveIndex)
for questLogQuestId in pairs(QuestiePlayer.currentQuestlog) do
local quest = QuestieDB.GetQuest(questLogQuestId)
if quest and next(quest.Objectives) then
if questLogQuestId == questId then
quest.HideIcons = nil
quest.FadeIcons = nil
for _, objective in pairs(quest.Objectives) do
if objective.Index == objectiveIndex then
objective.HideIcons = nil
objective.FadeIcons = nil
else
objective.FadeIcons = true
end
end
for _, objective in pairs(quest.SpecialObjectives) do
if objective.Index == objectiveIndex then
objective.HideIcons = nil
objective.FadeIcons = nil
else
objective.FadeIcons = true
end
end
else
quest.FadeIcons = true
end
end
end
end
---@param questId number Quest ID number
function TrackerUtils:FocusQuest(questId)
if Questie.db.char.TrackerFocus and (type(Questie.db.char.TrackerFocus) ~= "number" or Questie.db.char.TrackerFocus ~= questId) then
TrackerUtils:UnFocus()
end
Questie.db.char.TrackerFocus = questId
for questLogQuestId in pairs(QuestiePlayer.currentQuestlog) do
local quest = QuestieDB.GetQuest(questLogQuestId)
if quest then
if questLogQuestId == questId then
quest.HideIcons = nil
quest.FadeIcons = nil
else
quest.FadeIcons = true
end
end
end
end
---@return table|nil position Returns Players current X/Y coordinates or nil if a Players postion can't be determined
local function _GetWorldPlayerPosition()
-- Turns coords into 'world' coords so it can be compared with any coords in another zone
local mapPosition, mapID = QuestieCoords.GetPlayerMapPosition()
if (not mapPosition) or (not mapPosition.x) then
return nil
end
local worldPosition = select(2, C_Map.GetWorldPosFromMapPos(mapID, mapPosition))
local position = {
x = worldPosition.x,
y = worldPosition.y
}
return position
end
---@param x1 number Current Position X
---@param y1 number Current Position Y
---@param x2 number Previous Position X
---@param y2 number Previous Position Y
---@return number Distance @Distance between Current and Previous X/Y coordinates
local function _GetDistance(x1, y1, x2, y2)
return math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
end
---@param questId number Quest ID number
---@return number|nil closestDistance Returns X/Y coordinates to closest Objective or nil if nothing is found
local function _GetDistanceToClosestObjective(questId)
-- main function for proximity sorting
local player = _GetWorldPlayerPosition()
if (not player) then
return nil
end
local coordinates = {}
local quest = QuestieDB.GetQuest(questId)
if (not quest) then
return nil
end
local spawn, zone, name = QuestieMap:GetNearestQuestSpawn(quest)
if (not spawn) or (not zone) or (not name) then
return nil
end
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if not uiMapId then
return nil
end
local _, worldPosition = C_Map.GetWorldPosFromMapPos(uiMapId, {
x = spawn[1] / 100,
y = spawn[2] / 100
})
tinsert(coordinates, {
x = worldPosition.x,
y = worldPosition.y
})
if (not coordinates) then
return nil
end
local closestDistance
for _, _ in pairs(coordinates) do
local distance = _GetDistance(player.x, player.y, worldPosition.x, worldPosition.y)
if (not closestDistance) or (distance < closestDistance) then
closestDistance = distance
end
end
return closestDistance
end
---@param uiMapId number Continent ID number
---@return string Continent Returns Continent Name or "UNKNOW"
local function _GetContinent(uiMapId)
if (not uiMapId) then
return
end
local useUiMapId = uiMapId
local mapInfo = C_Map.GetMapInfo(useUiMapId)
while mapInfo and mapInfo.mapType ~= 2 and mapInfo.parentMapID ~= useUiMapId do
useUiMapId = mapInfo.parentMapID
mapInfo = C_Map.GetMapInfo(useUiMapId)
end
if mapInfo ~= nil then
return mapInfo.name
else
return "UNKNOWN"
end
end
---@return table sortedQuestIds Table with sorted Quest ID's by Sort Type
---@return table questDetails Table with raw quest table from QuestiePlayer.currentQuestLog, percentage completed value per quest, and a "translated" zoneName
-- Private cache of fallback quest objects for quests not in QuestieDB.
-- Intentionally NOT stored in QuestiePlayer.currentQuestlog so arrow/map/other modules
-- don't try to call DB-only methods on them.
TrackerUtils._fallbackQuests = TrackerUtils._fallbackQuests or {}
-- Reverse-lookup: given a localized zone name string, find the area ID from l10n.zoneLookup.
local function GetAreaIdByZoneName(zoneName)
return l10n:GetAreaIdByLocalName(zoneName)
end
-- Walk the quest log to find the zone header for a given questId.
-- In 3.3.5, zone names appear as isHeader=true entries above their quests.
-- Returns the header title string, or nil if not found.
local function GetQuestLogZoneName(questId)
local targetIndex = nil
local total = GetNumQuestLogEntries and GetNumQuestLogEntries() or 0
for i = 1, total do
local _, _, _, isHeader, _, _, _, logId = GetQuestLogTitle(i)
if not isHeader and logId == questId then
targetIndex = i
break
end
end
if not targetIndex then return nil end
for i = targetIndex, 1, -1 do
local title, _, _, isHeader = GetQuestLogTitle(i)
if isHeader and title and title ~= "" then
return title
end
end
return nil
end
local function _GetZoneName(zoneOrSort, questId, zoneNameOverride)
if zoneNameOverride and zoneNameOverride ~= "" then
return zoneNameOverride
end
if not zoneOrSort then return "Unknown Zone" end
local zoneName
local sortObj = Questie.db.profile.trackerSortObjectives
if sortObj == "byZone" or sortObj == "byZoneComplete" or sortObj == "byZoneCompleteReversed" or sortObj == "byZonePlayerProximity" or sortObj == "byZonePlayerProximityReversed" then
if (zoneOrSort) > 0 then
zoneName = TrackerUtils:GetZoneNameByID(zoneOrSort)
if not zoneName or zoneName == "Unknown Zone" then
local logZone = GetQuestLogZoneName(questId)
if logZone then
zoneName = logZone
end
end
elseif (zoneOrSort) < 0 then
zoneName = TrackerUtils:GetCategoryNameByID(zoneOrSort)
else
-- zoneOrSort == 0: try quest log header as last resort before "Unknown Zone"
local logZone = GetQuestLogZoneName(questId)
if logZone then
zoneName = logZone
else
zoneName = "Unknown Zone"
Questie:Debug(Questie.DEBUG_CRITICAL, "[TrackerUtils:_GetZoneName] zoneOrSort", zoneOrSort, "of quest",
questId, "is not in the Database!")
end
end
else
if sortObj == "byComplete" then
zoneName = "Quests (By %% Complete)"
elseif sortObj == "byCompleteReversed" then
zoneName = "Quests (By %% Complete Reversed)"
elseif sortObj == "byLevel" then
zoneName = "Quests (By Level)"
elseif sortObj == "byLevelReversed" then
zoneName = "Quests (By Level Reversed)"
elseif sortObj == "byProximity" then
zoneName = "Quests (By Proximity)"
elseif sortObj == "byProximityReversed" then
zoneName = "Quests (By Proximity Reversed)"
end
end
return zoneName
end
-- Returns nil if the quest is not currently in the quest log.
function TrackerUtils:BuildFallbackQuest(questId)
for i = 1, GetNumQuestLogEntries() do
local title, level, _, isHeader, _, isComplete, _, logQuestId = GetQuestLogTitle(i)
if not isHeader and logQuestId == questId then
-- Parse objectives from the leaderboard
local objectives = {}
local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(i) or 0
for j = 1, numObj do
local text, _, finished = GetQuestLogLeaderBoard(j, i)
if text then
-- Parse "Description: X/Y" or just "Description"
local collected, needed = string.match(text, ":.-(%d+)/(%d+)%s*$")
collected = tonumber(collected) or (finished and 1 or 0)
needed = tonumber(needed) or 1
objectives[j] = {
text = text,
Needed = needed,
Collected = collected,
Finished = finished or (collected >= needed),
Type = "fallback",
}
end
end
-- Walk backwards from i in the quest log to find the zone header.
-- This is the canonical 3.3.5 method: zone headers sit above their quests.
local zoneText = nil
for h = i, 1, -1 do
local hTitle, _, _, hIsHeader = GetQuestLogTitle(h)
if hIsHeader and hTitle and hTitle ~= "" then
zoneText = hTitle
break
end
end
local zoneId = (zoneText and GetAreaIdByZoneName(zoneText)) or 0
local zoneNameOverride = nil
if zoneText and (not zoneId or zoneId == 0) then
zoneNameOverride = zoneText
end
local quest = {
Id = questId,
name = title or ("Quest " .. questId),
level = level or 0,
zoneOrSort = zoneId,
zoneName = zoneText,
zoneNameOverride = zoneNameOverride,
Objectives = objectives,
SpecialObjectives = {},
isFallback = true,
}
-- IsComplete must be a method (called as quest:IsComplete())
quest.IsComplete = function(self)
return (isComplete == 1 or (QuestiePlayer.currentQuestlog[questId] and IsQuestFlaggedCompleted and IsQuestFlaggedCompleted(questId))) and 1 or 0
end
return quest
end
end
return nil
end
function TrackerUtils:GetSortedQuestIds()
local sortedQuestIds = {}
local questDetails = {}
local sortObj = Questie.db.profile.trackerSortObjectives
-- Update quest objectives
for questId, quest in pairs(QuestiePlayer.currentQuestlog) do
local qid = tonumber(questId) or questId
-- Defensive: sometimes other code (or saved vars) leaves the questId as the value instead of a quest table.
if type(quest) ~= "table" then
quest = QuestieDB.GetQuest(qid)
if quest then
QuestiePlayer.currentQuestlog[qid] = quest
end
end
-- Fallback for quests not in QuestieDB (e.g. custom server quests).
-- Two cases: QuestLogCache already put a partial object in currentQuestlog
-- (has _isLogFallback=true, isComplete=bool, but NO IsComplete method),
-- or the slot is missing entirely. Both need a working IsComplete method.
if type(quest) ~= "table" or type(quest.IsComplete) ~= "function" then
if type(quest) == "table" and quest._isLogFallback then
-- Patch IsComplete method onto the existing QuestLogCache object so
-- QuestieMap and other modules that read currentQuestlog also get it.
-- Also ensure fields QuestieMap iterates exist to avoid pairs(nil) crashes.
local capturedId = qid
quest.IsComplete = function(self)
for i = 1, GetNumQuestLogEntries() do
local _, _, _, isHeader, _, isCompleteFlag, _, logId = GetQuestLogTitle(i)
if not isHeader and logId == capturedId then
return (isCompleteFlag == 1 or isCompleteFlag == true) and 1 or 0
end
end
return 0
end
if not quest.Objectives then quest.Objectives = {} end
if not quest.SpecialObjectives then quest.SpecialObjectives = {} end
if not quest.ExtraObjectives then quest.ExtraObjectives = {} end
-- Use the quest log header walk (canonical 3.3.5 zone resolution)
-- Also run if zoneName is set but zoneOrSort is still 0 (e.g. GetAreaIdByZoneName
-- returned 0 for a valid zone name like "Sunstrider Isle", leaving zoneOrSort wrong).
if not quest.zoneName or quest.zoneName == "" or quest.zoneOrSort == 0 then
local logZone = GetQuestLogZoneName(capturedId)
if logZone then
quest.zoneName = logZone
local areaId = GetAreaIdByZoneName(logZone)
if areaId and areaId > 0 then
quest.zoneOrSort = areaId
else
quest.zoneOrSort = 0
quest.zoneNameOverride = logZone
end
end
end
QuestiePlayer.currentQuestlog[qid] = quest
else
-- No object at all — build one from the log
local fallback = TrackerUtils._fallbackQuests[qid]
-- Re-build if cached without zone info (e.g. was built before log was ready)
if fallback and not fallback.zoneName then
TrackerUtils._fallbackQuests[qid] = nil
fallback = nil
end
if not fallback then
fallback = TrackerUtils:BuildFallbackQuest(qid)
if fallback then
TrackerUtils._fallbackQuests[qid] = fallback
end
end
if fallback then
quest = fallback
end
end
end
if type(quest) == "table" and quest.IsComplete and quest.Objectives then
-- Insert Quest Ids into sortedQuestIds table
tinsert(sortedQuestIds, qid)
-- Create questDetails table keys and insert values
questDetails[qid] = {}
questDetails[qid].quest = quest
questDetails[qid].zoneName = _GetZoneName(quest.zoneOrSort, qid, quest.zoneNameOverride)
if quest:IsComplete() == 1 or (not next(quest.Objectives)) then
questDetails[qid].questCompletePercent = 1
else
local percent = 0
local count = 0
for _, Objective in pairs(quest.Objectives) do
local needed = Objective and Objective.Needed
local collected = Objective and Objective.Collected
if type(needed) == "number" and needed > 0 and type(collected) == "number" then
percent = percent + (collected / needed)
count = count + 1
end
end
if count > 0 then
percent = percent / count
else
percent = 0
end
questDetails[qid].questCompletePercent = percent
end
end
end
-- Quests and objectives sort
if sortObj == "byComplete" or sortObj == "byCompleteReversed" then
table.sort(sortedQuestIds, function(a, b)
local vA, vB = questDetails[a].questCompletePercent, questDetails[b].questCompletePercent
if vA == vB then
local qA = questDetails[a].quest
local qB = questDetails[b].quest
return qA and qB and qA.level < qB.level
end
if sortObj == "byComplete" then
return vB < vA
else
return vB > vA
end
end)
elseif sortObj == "byLevel" or sortObj == "byLevelReversed" then
table.sort(sortedQuestIds, function(a, b)
local qA = questDetails[a].quest
local qB = questDetails[b].quest
if sortObj == "byLevel" then
return qA and qB and qA.level < qB.level
else
return qA and qB and qA.level > qB.level
end
end)
elseif sortObj == "byZone" then
table.sort(sortedQuestIds, function(a, b)
local qA = questDetails[a].quest
local qB = questDetails[b].quest
local qAZone = questDetails[a].zoneName
local qBZone = questDetails[b].zoneName
-- Sort by Zone then by Level to mimic QuestLog sorting
if qAZone == qBZone then
return qA.level < qB.level
else
if qAZone ~= nil and qBZone ~= nil then
return qAZone < qBZone
else
return qAZone and qBZone
end
end
end)
elseif sortObj == "byZoneComplete" or sortObj == "byZoneCompleteReversed" then
table.sort(sortedQuestIds, function(a, b)
local qAZone = questDetails[a].zoneName
local qBZone = questDetails[b].zoneName
-- Sort by Zone first, then by % Complete within each zone
-- (byZoneComplete: completes at top, 0% at bottom; reversed: opposite)
if qAZone == qBZone then
local vA, vB = questDetails[a].questCompletePercent, questDetails[b].questCompletePercent
if vA == vB then
local qA = questDetails[a].quest
local qB = questDetails[b].quest
return qA and qB and qA.level < qB.level
end
if sortObj == "byZoneComplete" then
return vA > vB
else
return vA < vB
end
else
if qAZone ~= nil and qBZone ~= nil then
return qAZone < qBZone
else
return qAZone and qBZone
end
end
end)
elseif sortObj == "byZonePlayerProximity" or sortObj == "byZonePlayerProximityReversed" then
local toSort = {}
local continent = _GetContinent(C_Map.GetBestMapForUnit("player"))
for _, questId in pairs(sortedQuestIds) do
local sortData = {}
sortData.questId = questId
sortData.distance = _GetDistanceToClosestObjective(questId)
sortData.q = questDetails[questId].quest
local _, zone, _ = QuestieMap:GetNearestQuestSpawn(sortData.q)
sortData.zone = zone
sortData.continent = _GetContinent(ZoneDB:GetUiMapIdByAreaId(zone))
toSort[questId] = sortData
end
local sorter = function(a, b)
local qAZone = questDetails[a].zoneName
local qBZone = questDetails[b].zoneName
-- If same Zone as Player then sort by Proximity
if qAZone == qBZone then
a = toSort[a]
b = toSort[b]
if ((continent == a.continent) and (continent == b.continent)) or ((continent ~= a.continent) and (continent ~= b.continent)) then
if a.distance == b.distance then
-- Same distance then sort by Level
return a.q and b.q and a.q.level < b.q.level
end
if not a.distance and b.distance then
return false
elseif a.distance and not b.distance then
return true
end
return a.distance < b.distance
elseif (continent == a.continent) and (continent ~= b.continent) then
return true
elseif (continent ~= a.continent) and (continent == b.continent) then
return false
end
else
-- Sort by Zone
if qAZone ~= nil and qBZone ~= nil then
return qAZone < qBZone
else
return qAZone and qBZone
end
end
end
local sorterReversed = function(a, b)
local qAZone = questDetails[a].zoneName
local qBZone = questDetails[b].zoneName
-- If same Zone as Player then sort by Proximity
if qAZone == qBZone then
a = toSort[a]
b = toSort[b]
if ((continent == a.continent) and (continent == b.continent)) or ((continent ~= a.continent) and (continent ~= b.continent)) then
if a.distance == b.distance then
-- Same distance then sort by Level
return a.q and b.q and a.q.level > b.q.level
end
if not a.distance and b.distance then
return true
elseif a.distance and not b.distance then
return false
end
return a.distance > b.distance
elseif (continent == a.continent) and (continent ~= b.continent) then
return false
elseif (continent ~= a.continent) and (continent == b.continent) then
return true
end
else
-- Sort by Zone
if qAZone ~= nil and qBZone ~= nil then
return qAZone < qBZone
else
return qAZone and qBZone
end
end
end
if sortObj == "byZonePlayerProximity" then
table.sort(sortedQuestIds, sorter)
else
table.sort(sortedQuestIds, sorterReversed)
end
if not questZoneProximityTimer and not IsInInstance() then
-- Check location often and update if you've moved
Questie:Debug(Questie.DEBUG_DEVELOP, "[TrackerUtils:GetSortedQuestIds] - Zone Proximity Timer Started!")
local playerPosition
questZoneProximityTimer = C_Timer.NewTicker(5.0, function()
if IsInInstance() and questZoneProximityTimer then
Questie:Debug(Questie.DEBUG_DEVELOP,
"[TrackerUtils:GetSortedQuestIds] - Zone Proximity Timer Stoped!")
questZoneProximityTimer:Cancel()
questZoneProximityTimer = nil
else
local position = _GetWorldPlayerPosition()
if position then
local distance = playerPosition and
_GetDistance(position.x, position.y, playerPosition.x, playerPosition.y)
if not distance or distance > 0.01 then -- Position has changed
Questie:Debug(Questie.DEBUG_SPAM,
"[TrackerUtils:GetSortedQuestIds] - Zone Proximity Timer Updated!")
playerPosition = position
local orderCopy = {}
for index, val in pairs(sortedQuestIds) do
orderCopy[index] = val
end
if sortObj == "byZonePlayerProximity" then
table.sort(sortedQuestIds, sorter)
else
table.sort(sortedQuestIds, sorterReversed)
end
for index, val in pairs(sortedQuestIds) do
if orderCopy[index] ~= val then -- The order has changed
break
end
end
QuestieCombatQueue:Queue(function()
TrackerUtils.FilterProximityTimer = true
QuestieTracker:Update()
end)
end
end
end
end)
end
elseif sortObj == "byProximity" or sortObj == "byProximityReversed" then
local toSort = {}
local continent = _GetContinent(C_Map.GetBestMapForUnit("player"))
for _, questId in pairs(sortedQuestIds) do
local sortData = {}
sortData.questId = questId
sortData.distance = _GetDistanceToClosestObjective(questId)
sortData.q = questDetails[questId].quest
local _, zone, _ = QuestieMap:GetNearestQuestSpawn(sortData.q)
sortData.zone = zone
sortData.continent = _GetContinent(ZoneDB:GetUiMapIdByAreaId(zone))
toSort[questId] = sortData
end
local sorter = function(a, b)
a = toSort[a]
b = toSort[b]
if ((continent == a.continent) and (continent == b.continent)) or ((continent ~= a.continent) and (continent ~= b.continent)) then
if a.distance == b.distance then
return a.q and b.q and a.q.level < b.q.level
end
if not a.distance and b.distance then
return false
elseif a.distance and not b.distance then
return true
end
return a.distance < b.distance
elseif (continent == a.continent) and (continent ~= b.continent) then
return true
elseif (continent ~= a.continent) and (continent == b.continent) then
return false
end
end
local sorterReversed = function(a, b)
a = toSort[a]
b = toSort[b]
if ((continent == a.continent) and (continent == b.continent)) or ((continent ~= a.continent) and (continent ~= b.continent)) then
if a.distance == b.distance then
return a.q and b.q and a.q.level > b.q.level
end
if not a.distance and b.distance then
return true
elseif a.distance and not b.distance then
return false
end
return a.distance > b.distance
elseif (continent == a.continent) and (continent ~= b.continent) then
return false
elseif (continent ~= a.continent) and (continent == b.continent) then
return true
end
end
if sortObj == "byProximity" then
table.sort(sortedQuestIds, sorter)
else
table.sort(sortedQuestIds, sorterReversed)
end
if not questProximityTimer and not IsInInstance() then
-- Check location often and update if you've moved
Questie:Debug(Questie.DEBUG_DEVELOP, "[TrackerUtils:GetSortedQuestIds] - Proximity Timer Started!")
local playerPosition
questProximityTimer = C_Timer.NewTicker(5.0, function()
if IsInInstance() and questProximityTimer then
Questie:Debug(Questie.DEBUG_DEVELOP, "[TrackerUtils:GetSortedQuestIds] - Proximity Timer Stoped!")
questProximityTimer:Cancel()
questProximityTimer = nil
else
local position = _GetWorldPlayerPosition()
if position then
local distance = playerPosition and
_GetDistance(position.x, position.y, playerPosition.x, playerPosition.y)
if not distance or distance > 0.01 then -- Position has changed
Questie:Debug(Questie.DEBUG_SPAM,
"[TrackerUtils:GetSortedQuestIds] - Proximity Timer Updated!")
playerPosition = position
local orderCopy = {}
for index, val in pairs(sortedQuestIds) do
orderCopy[index] = val
end
if sortObj == "byProximity" then
table.sort(sortedQuestIds, sorter)
else
table.sort(sortedQuestIds, sorterReversed)
end
for index, val in pairs(sortedQuestIds) do
if orderCopy[index] ~= val then -- The order has changed
break
end
end
QuestieCombatQueue:Queue(function()
TrackerUtils.FilterProximityTimer = true
QuestieTracker:Update()
end)
end
end
end
end)
end
end
if (sortObj ~= strmatch(sortObj, "byProximity.*")) and questProximityTimer and questProximityTimer ~= nil then
Questie:Debug(Questie.DEBUG_DEVELOP, "[TrackerUtils:GetSortedQuestIds] - Proximity Timer Stoped!")
questProximityTimer:Cancel()
TrackerUtils.FilterProximityTimer = nil
questProximityTimer = nil
end
if (sortObj ~= strmatch(sortObj, "byZonePlayerProximity.*")) and questZoneProximityTimer and questZoneProximityTimer ~= nil then
Questie:Debug(Questie.DEBUG_DEVELOP, "[TrackerUtils:GetSortedQuestIds] - Zone Proximity Timer Stoped!")
questZoneProximityTimer:Cancel()
TrackerUtils.FilterProximityTimer = nil
questZoneProximityTimer = nil
end
return sortedQuestIds, questDetails
end
-- Ascension's 3.3.5 client backports the retail floating objective marker ("SuperTracker").
-- Supertracking is a slave of the quest log selection: the world map and the Blizzard watch frame
-- both funnel through SelectQuestLogEntry -> SuperTrackerUtil.SetToBestSuperTrackingType. Calling
-- C_SuperTrack.SetSuperTrackedQuestID directly only moves the marker until the next map interaction
-- stomps it, so we click the same POI frames the client itself clicks.
local superTrackedQuestId
local superTrackHooked
local superTrackRefreshing
local superTrackRefreshPending
local superTrackEventFrame
-- Refreshing rebuilds the map's POI frames, which can land back in the very hooks that asked for the
-- refresh, so this is the only way the buttons are ever repainted. Requests are also coalesced:
-- callers like TrackerQuestTimers select a quest log entry and immediately restore the previous one,
-- so reading the selection on the first of those calls would catch a state the client is about to
-- undo. Waiting a tick means the burst has settled.
local function RefreshSuperTrackButtons()
if superTrackRefreshing or superTrackRefreshPending then
return
end
superTrackRefreshPending = true
C_Timer.After(0.05, function()
superTrackRefreshPending = false
superTrackRefreshing = true
TrackerLinePool.UpdateSuperTrackButtons()
superTrackRefreshing = false
end)
end
function TrackerUtils:IsSuperTrackAvailable()
return (C_SuperTrack ~= nil) and ((WorldMapFrame_SelectQuestFrame ~= nil) or (WatchFrameQuestPOI_OnClick ~= nil))
end
-- Most paths that change the supertracked quest end up in SetSuperTrackedQuestID -- our own button,
-- world map pins, the map quest list, the Blizzard tracker, and the automatic re-pick that happens
-- when the map switches zone -- so hooking it stands in for the GetSuperTrackedQuestID getter this
-- client dropped. It goes quiet while the player is a ghost, though: the corpse arrow takes the
-- marker over, so no quest is ever handed to it even though the map keeps selecting one. The quest
-- selection itself is therefore hooked as well, and that is what keeps the buttons honest while
-- dead. Caching what we last set would go stale the moment the player changed it by other means.
function TrackerUtils:InitSuperTrackHook()
if superTrackHooked or (not C_SuperTrack) then
return
end
superTrackHooked = true
hooksecurefunc(C_SuperTrack, "SetSuperTrackedQuestID", function(questId)
superTrackedQuestId = questId
RefreshSuperTrackButtons()
end)
if C_SuperTrack.ClearSuperTracker then
hooksecurefunc(C_SuperTrack, "ClearSuperTracker", function()
superTrackedQuestId = nil
RefreshSuperTrackButtons()
end)
end
if WorldMapFrame_SelectQuestFrame then
hooksecurefunc("WorldMapFrame_SelectQuestFrame", RefreshSuperTrackButtons)
end
if WatchFrameQuestPOI_OnClick then
hooksecurefunc("WatchFrameQuestPOI_OnClick", RefreshSuperTrackButtons)
end
-- Everything that supertracks a quest goes through the quest log selection, map or no map, alive
-- or dead -- including opening a quest in the quest log window, which no other hook here sees.
if SelectQuestLogEntry then
hooksecurefunc("SelectQuestLogEntry", RefreshSuperTrackButtons)
end
-- Nothing at all fires on a login or a reload: the map has not been touched, so the hooks above
-- stay silent and the tracker draws before the client has styled its pins. These events are the
-- only prompt to go back and look.
superTrackEventFrame = CreateFrame("Frame")
superTrackEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
superTrackEventFrame:RegisterEvent("PLAYER_UNGHOST")
superTrackEventFrame:RegisterEvent("PLAYER_ALIVE")
superTrackEventFrame:RegisterEvent("PLAYER_DEAD")
superTrackEventFrame:SetScript("OnEvent", function()
RefreshSuperTrackButtons()
-- The client fills in its POI frames a moment after entering the world, so the immediate
-- pass above can still come up empty.
C_Timer.After(2, RefreshSuperTrackButtons)
end)
end
-- The hook only hears about the changes the client itself makes, and while the player is a ghost the
-- floating marker is disabled: selecting a quest then never reaches SetSuperTrackedQuestID, so the
-- hook reports nothing after a login or a reload in that state and goes stale after any click. The
-- client's own frames still know. Watch frame POI buttons say so outright, and the world map's quest
-- frames say it through their art: the atlases stack the selected (yellow) variant of a cell half a
-- texture above the normal one, so a pin drawn from the upper half is the supertracked one.
local function FindSelectedQuestId()
for i = 1, 30 do
local firstInRow = _G["poiWatchFrameLines" .. i .. "_1"]
if not firstInRow then
break
end
for j = 1, 5 do
local poiButton = (j == 1) and firstInRow or _G["poiWatchFrameLines" .. i .. "_" .. j]
if not poiButton then
break
end
if poiButton.isSelected and poiButton.questId then
return poiButton.questId
end
end
end
for i = 1, 25 do
local questFrame = _G["WorldMapQuestFrame" .. i]
if not questFrame then
break
end
local pin = questFrame.ownPOI or questFrame.poiIcon
local pinTexture = pin and pin.GetNormalTexture and pin:GetNormalTexture()
if pinTexture and questFrame.questId then
local _, topY = pinTexture:GetTexCoord()
if topY and topY < 0.5 then
return questFrame.questId
end
end
end
-- Right after a login or a reload no pin is styled at all: the client marks them the first time
-- the world map is opened. The quest log selection is what it reads when it gets there, so it
-- answers for the gap in between.
local selection = GetQuestLogSelection and GetQuestLogSelection()
if selection and selection > 0 then
-- GetQuestIDFromLogIndex, not the raw API: GetQuestLogTitle is the compat wrapper here,
-- which normalises the client's 9 return values down to 8.
local questId = QuestieCompat.GetQuestIDFromLogIndex(selection)
if questId and questId ~= 0 then
return questId
end
end
return nil
end
-- The client is asked before the hook: the hook cannot see a ghost's selection changes at all, so
-- its value is the fallback for when no POI frame exists to read (another zone, or frames not built
-- yet), not the source of truth.
function TrackerUtils:GetSuperTrackedQuestId()
return FindSelectedQuestId() or superTrackedQuestId
end
-- The world map builds its quest POI frames lazily, so right after login -- or after a zone change
-- with the map still closed -- there is nothing to match a quest against and every button would
-- hide itself. The client can build them without the map being shown, and doing so does not disturb
-- which quest is currently supertracked. Skipped while the map is open so we never fight the player.
function TrackerUtils:PrimeSuperTrackFrames()
if WorldMapFrame and WorldMapFrame:IsShown() then
return
end
if WorldMapFrame_UpdateQuests then
WorldMapFrame_UpdateQuests()
end
end
---@return table|nil frame, function|nil clickHandler
local function GetSuperTrackFrame(questId)
if (not questId) or questId == 0 then
return nil
end
-- Blizzard watch frame POI buttons carry the quest id directly. Questie empties the Blizzard
-- watch list (see QuestieTracker:AQW_Insert) so only a handful of quests ever have one. Both
-- scans below stop at the first gap: these frames are created in order, so a missing index
-- means there are no further ones and this runs once per quest line per redraw.
if WatchFrameQuestPOI_OnClick then
for i = 1, 30 do
local firstInRow = _G["poiWatchFrameLines" .. i .. "_1"]
if not firstInRow then
break
end
for j = 1, 5 do
local poiButton = (j == 1) and firstInRow or _G["poiWatchFrameLines" .. i .. "_" .. j]
if not poiButton then
break
end
if poiButton.questId == questId then
return poiButton, WatchFrameQuestPOI_OnClick
end
end
end
end
-- World map quest frames cover every quest with a POI on the currently viewed map, which is the
-- wider net of the two. Pass the WorldMapQuestFrame itself and never its poiIcon --
-- WorldMapFrame_SelectQuestFrame indexes questFrame.poiIcon and errors on the POI frame.
if WorldMapFrame_SelectQuestFrame then
if not _G["WorldMapQuestFrame1"] then
TrackerUtils:PrimeSuperTrackFrames()
end
for i = 1, 25 do
local questFrame = _G["WorldMapQuestFrame" .. i]
if not questFrame then
break
end
if questFrame.questId == questId then
return questFrame, WorldMapFrame_SelectQuestFrame
end
end
end
return nil
end
-- A quest can only be supertracked while it has a POI frame, so quests in another zone or without
-- map coordinates simply have no button.
function TrackerUtils:CanSuperTrackQuest(questId)
return GetSuperTrackFrame(questId) ~= nil
end
-- The map pin the client draws for this quest. Copying its texture coordinates is what keeps the
-- tracker button identical to the pin: the number, the "?" shown for completed quests and the
-- black-on-yellow selected variant all follow along without us mapping atlas cells by hand. The
-- number is also the pin's position among quests that actually have a POI, which is not the same as
-- the quest frame index -- another reason to read it from the client instead of deriving it.
---@return table|nil
function TrackerUtils:GetSuperTrackPin(questId)
if (not questId) or questId == 0 or (not WorldMapFrame_SelectQuestFrame) then
return nil
end
if not _G["WorldMapQuestFrame1"] then
TrackerUtils:PrimeSuperTrackFrames()
end
for i = 1, 25 do
local questFrame = _G["WorldMapQuestFrame" .. i]
if not questFrame then
break
end
if questFrame.questId == questId then
-- ownPOI is the pin drawn in the map's quest list, poiIcon the one on the map itself.
-- The list version is the one we mirror: it keeps the circular background on completed
-- quests, where the map version draws a bare "?".
return questFrame.ownPOI or questFrame.poiIcon
end
end
return nil
end
function TrackerUtils:SetSuperTrackedQuest(questId)
local frame, clickHandler = GetSuperTrackFrame(questId)
if not frame then
return false
end
clickHandler(frame)
-- Refresh here rather than leaning on the SetSuperTrackedQuestID hook: it stays silent while the
-- player is a ghost, which would leave the button we just clicked looking untouched.
RefreshSuperTrackButtons()
return true
end
function TrackerUtils:IsVoiceOverLoaded()
-- Require not just that the VoiceOver addons are loaded, but that the runtime
-- structure we index actually exists. Some VoiceOver builds (e.g. on Elune) expose
-- a different QuestOverlayUI shape where questPlayButtons is absent, which previously
-- crashed UpdateVoiceOverPlayButtons / SetAllPlayButtonAlpha with "attempt to index
-- field 'questPlayButtons' (a nil value)". All play-button call sites gate on this
-- function, so verifying the table here makes them all safe.
if IsAddOnLoaded("AI_VoiceOver") and IsAddOnLoaded("AI_VoiceOverData_Vanilla")
and VoiceOver and VoiceOver.QuestOverlayUI and VoiceOver.QuestOverlayUI.questPlayButtons then
return true
end
return false
end
function TrackerUtils:ShowVoiceOverPlayButtons()
if self:IsVoiceOverLoaded() then
if Questie.db.char.isTrackerExpanded then
if IsShiftKeyDown() and MouseIsOver(Questie_BaseFrame) then
if Questie_BaseFrame.isSizing == true or Questie_BaseFrame.isMoving == true then
Questie:Debug(Questie.DEBUG_SPAM, "[TrackerUtils:ShowVoiceOverPlayButtons]")
else
Questie:Debug(Questie.DEBUG_INFO, "[TrackerUtils:ShowVoiceOverPlayButtons]")
end
end
if IsShiftKeyDown() then
if MouseIsOver(Questie_BaseFrame) then
TrackerLinePool.SetAllPlayButtonAlpha(1)
TrackerFadeTicker.Fade()
if not Questie.db.profile.trackerFadeMinMaxButtons then
TrackerLinePool.SetAllExpandQuestAlpha(0)
end
if not Questie.db.profile.trackerFadeQuestItemButtons then
TrackerLinePool.SetAllItemButtonAlpha(0)
end
end
else
if MouseIsOver(Questie_BaseFrame) then
TrackerLinePool.SetAllPlayButtonAlpha(0)
TrackerFadeTicker.Unfade()
else
TrackerLinePool.SetAllPlayButtonAlpha(0)
TrackerFadeTicker.Fade()
end
if not Questie.db.profile.trackerFadeMinMaxButtons then
TrackerLinePool.SetAllExpandQuestAlpha(1)
end
if not Questie.db.profile.trackerFadeQuestItemButtons then
TrackerLinePool.SetAllItemButtonAlpha(1)
end
end
end
end
end
function TrackerUtils:UpdateVoiceOverPlayButtons()
if self:IsVoiceOverLoaded() then
if Questie_BaseFrame.isSizing == true or Questie_BaseFrame.isMoving == true then
Questie:Debug(Questie.DEBUG_SPAM, "[TrackerUtils:UpdateVoiceOverPlayButtons]")
else
Questie:Debug(Questie.DEBUG_INFO, "[TrackerUtils:UpdateVoiceOverPlayButtons]")
end
for i = 1, 75 do
local title, level, questTag, isHeader, isCollapsed, isComplete, isDaily, questId = GetQuestLogTitle(i)
if title and questId and (not isHeader) then
if not VoiceOver.QuestOverlayUI.questPlayButtons[questId] then
VoiceOver.QuestOverlayUI:CreatePlayButton(questId)
table.insert(VoiceOver.QuestOverlayUI.displayedButtons,
VoiceOver.QuestOverlayUI.questPlayButtons[questId])
end
end
end
end
end
---@return number|nil itemId The ID of the nearest usable quest item
function TrackerUtils:GetNearestQuestItemId()
local questIds = QuestiePlayer.currentQuestlog
local bestItemId = nil
local minDistance = 999999
local playerPos = _GetWorldPlayerPosition()
if not playerPos then return nil end
for questId in pairs(questIds) do
local quest = QuestieDB.GetQuest(questId)
if quest then
local items = {}
if quest.sourceItemId and quest.sourceItemId ~= 0 then
table.insert(items, quest.sourceItemId)
end
if type(quest.requiredSourceItems) == "table" then
for _, itemId in pairs(quest.requiredSourceItems) do
if itemId and itemId ~= 0 then
table.insert(items, itemId)
end
end
end
local foundItemId = nil
for i = 1, table.getn(items) do
local itemId = items[i]
-- Check if item is in bags and is a quest item (class 12)
if GetItemCount(itemId) > 0 and QuestieDB.QueryItemSingle(itemId, "class") == 12 then
foundItemId = itemId
break
end
end
if foundItemId then
local distance = _GetDistanceToClosestObjective(questId)
if distance and distance < minDistance then
minDistance = distance
bestItemId = foundItemId
end
end
end
end
return bestItemId
end
--- Logic to use the nearest quest item (Fallback for non-secure usage or manual calls)
function TrackerUtils:UseNearestQuestItem()
local itemId = self:GetNearestQuestItemId()
if itemId then
local itemName = GetItemInfo(itemId)
if itemName then
UseItemByName(itemName)
end
end
end
--- Toggle the Questie Options window
function TrackerUtils:ToggleOptions()
local QuestieOptions = QuestieLoader:ImportModule("QuestieOptions")
if QuestieOptions and QuestieOptions.OpenConfigWindow then
QuestieOptions:OpenConfigWindow()
end
end
--- Toggle the Questie Tracker
function TrackerUtils:ToggleTracker()
local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker")
if QuestieTracker and QuestieTracker.Toggle then
QuestieTracker:Toggle()
end
end
--- Toggle the Questie Journey window
function TrackerUtils:ToggleJourney()
local QuestieJourney = QuestieLoader:ImportModule("QuestieJourney")
if QuestieJourney and QuestieJourney.ToggleJourneyWindow then
QuestieJourney:ToggleJourneyWindow()
end
end