---@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 -- questId -> the header the client filed it under in the quest log. In 3.3.5 those headers are -- isHeader=true entries sitting above the quests they cover. Built in one pass and kept, because -- the tracker asks per quest and redraws often -- walking the log once per quest is quadratic. local questLogHeaders = {} local function BuildQuestLogHeaders() local headers = {} local header for i = 1, (GetNumQuestLogEntries and GetNumQuestLogEntries() or 0) do local title, _, _, isHeader, _, _, _, logId = GetQuestLogTitle(i) if isHeader then if title and title ~= "" then header = title end elseif logId and header then headers[logId] = header end end questLogHeaders = headers end -- Returns the header title string, or nil if the quest is not in the log under one. local function GetQuestLogZoneName(questId) if not questLogHeaders[questId] then -- Asked about a quest the last pass did not see, so the log has moved on since. BuildQuestLogHeaders() end return questLogHeaders[questId] 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 -- A server can file quests under categories of its own -- Ascension's "Ascension Main -- Quest" -- and those exist nowhere in the zone tables, so the quest data points at a zone -- instead: for anything the Learner recorded, whichever zone it was picked up in. A header -- that does not resolve to an area is one of those categories, and the client's own -- grouping is the only thing that knows about it. local logHeader = GetQuestLogZoneName(questId) if logHeader and logHeader ~= "" then local headerAreaId = GetAreaIdByZoneName(logHeader) if (not headerAreaId) or headerAreaId == 0 then return logHeader end end 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 -- One walk of the quest log for the whole draw, so the per-quest zone lookups below are reads. BuildQuestLogHeaders() -- 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