Files
Questie-X/Modules/Tracker/TrackerUtils.lua
T
Narcasung 6aafd2c112 feat(tracker): add objective marker button to quest lines
Ascension's client backports retail's floating objective marker. This adds
a button to each tracker quest line that points the marker at that quest,
mirroring the quest pin the world map draws for it.

Supertracking is a slave of the map's quest selection: both the map and the
Blizzard watch frame funnel through SelectQuestLogEntry, and calling
C_SuperTrack.SetSuperTrackedQuestID directly only moves the marker until the
next map interaction stomps it. So the button clicks the same POI frame the
client clicks -- the watch frame button when one exists, otherwise the map's
quest frame.

The client dropped GetSuperTrackedQuestID, so the current quest is read by
hooking SetSuperTrackedQuestID instead. Every path ends up there, including
the automatic re-pick on zone change, so the highlight cannot fall out of
sync with tracking changed outside the addon. Caching what we last set would
have gone stale the moment the player used the map.

The button mirrors the pin's own textures rather than picking atlas cells, so
the digit, the "?" completed quests use and the selected variant all follow
whatever the client draws. Quests with no pin get no button, and the map's
POI frames are built on demand so buttons appear without opening the map.

Adds trackerShowSuperTrackButton and trackerSuperTrackButtonSize.
2026-07-25 01:50:03 +02:00

1584 lines
61 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
function TrackerUtils:IsSuperTrackAvailable()
return (C_SuperTrack ~= nil) and ((WorldMapFrame_SelectQuestFrame ~= nil) or (WatchFrameQuestPOI_OnClick ~= nil))
end
-- Every path that changes the supertracked quest ends 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. Hooking it is the only way to know what is supertracked,
-- since this client dropped the GetSuperTrackedQuestID getter. Caching what we last set would go
-- stale the moment the player changed it by any other means.
function TrackerUtils:InitSuperTrackHook()
if superTrackHooked or (not C_SuperTrack) then
return
end
superTrackHooked = true
hooksecurefunc(C_SuperTrack, "SetSuperTrackedQuestID", function(questId)
superTrackedQuestId = questId
TrackerLinePool.UpdateSuperTrackButtons()
end)
if C_SuperTrack.ClearSuperTracker then
hooksecurefunc(C_SuperTrack, "ClearSuperTracker", function()
superTrackedQuestId = nil
TrackerLinePool.UpdateSuperTrackButtons()
end)
end
end
function TrackerUtils:GetSuperTrackedQuestId()
return 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)
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