Files
2026-06-13 10:45:32 -05:00

485 lines
20 KiB
Lua

---@class AvailableQuests
local AvailableQuests = QuestieLoader:CreateModule("AvailableQuests")
---@type ThreadLib
local ThreadLib = QuestieLoader:ImportModule("ThreadLib")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
---@type ZoneDB
local ZoneDB = QuestieLoader:ImportModule("ZoneDB")
---@type QuestiePlayer
local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer")
---@type QuestieMap
local QuestieMap = QuestieLoader:ImportModule("QuestieMap")
---@type QuestieTooltips
local QuestieTooltips = QuestieLoader:ImportModule("QuestieTooltips")
---@type QuestieCorrections
local QuestieCorrections = QuestieLoader:ImportModule("QuestieCorrections")
---@type QuestieQuestBlacklist
local QuestieQuestBlacklist = QuestieLoader:ImportModule("QuestieQuestBlacklist")
---@type IsleOfQuelDanas
local IsleOfQuelDanas = QuestieLoader:ImportModule("IsleOfQuelDanas")
---@type QuestieLib
local QuestieLib = QuestieLoader:ImportModule("QuestieLib")
local GetQuestGreenRange = GetQuestGreenRange
local yield = coroutine.yield
local tinsert = table.insert
local NewThread = ThreadLib.ThreadSimple
local QUESTS_PER_YIELD = 24
--- Used to keep track of the active timer for CalculateAndDrawAll
---@type Ticker|nil
local timer
-- Keep track of all available quests to unload undoable when abandoning a quest
local availableQuests = {}
local unavailableQuestLogged = {}
local dungeons = ZoneDB:GetDungeons()
local _CalculateAvailableQuests, _DrawChildQuests, _AddStarter, _DrawAvailableQuest, _GetQuestIcon, _GetIconScaleForAvailable, _HasProperDistanceToAlreadyAddedSpawns
local function _GetFirstWaypointCoord(waypoints)
if waypoints and waypoints[1] and waypoints[1][1] and waypoints[1][1][1] and waypoints[1][1][2] then
return waypoints[1][1]
end
return nil
end
---@param callback function | nil
function AvailableQuests.CalculateAndDrawAll(callback)
Questie:Debug(Questie.DEBUG_INFO, "[AvailableQuests.CalculateAndDrawAll]")
--? Cancel the previously running timer to not have multiple running at the same time
if timer then
timer:Cancel()
end
timer = ThreadLib.Thread(_CalculateAvailableQuests, 0, "Error in AvailableQuests.CalculateAndDrawAll", callback)
end
--Draw a single available quest, it is used by the CalculateAndDrawAll function.
---@param quest Quest
function AvailableQuests.DrawAvailableQuest(quest) -- prevent recursion
--? Some quests can be started by both an NPC and a GameObject
if not quest or not quest.Starts then
return
end
if quest.Starts["GameObject"] then
local gameObjects = quest.Starts["GameObject"]
for i = 1, table.getn(gameObjects) do
local objId = gameObjects[i]
local obj = QuestieDB:GetObject(objId)
if obj and obj.id then
_AddStarter(obj, quest, "o_" .. obj.id)
end
end
end
if quest.Starts["NPC"] then
local npcs = quest.Starts["NPC"]
for i = 1, table.getn(npcs) do
local starterId = npcs[i]
local npc = QuestieDB:GetNPC(starterId)
if npc and npc.id then
_AddStarter(npc, quest, "m_" .. npc.id)
else
-- Ascension: sometimes quest starters are GameObjects but end up in the NPC starts list
local obj = QuestieDB:GetObject(starterId)
if obj and obj.id then
_AddStarter(obj, quest, "o_" .. obj.id)
end
end
end
end
end
function AvailableQuests.UnloadUndoable()
local questId, _ = next(availableQuests)
while questId do
-- Only unload if the quest is no longer doable AND is not in the live quest log.
-- The currentQuestlog guard prevents removing frames for completed-but-logged quests
-- (e.g. Quest 8325) where IsDoable returns false but the quest is still accepted.
if (not QuestieDB.IsDoable(questId)) and not QuestiePlayer.currentQuestlog[questId] then
QuestieMap:UnloadQuestFrames(questId)
end
questId, _ = next(availableQuests, questId)
end
end
_CalculateAvailableQuests = function()
-- Localize the variables for speeeeed
local debugEnabled = Questie.db.profile.debugEnabled
local questData = QuestieDB.QuestPointers or QuestieDB.questData
local playerLevel = QuestiePlayer.GetPlayerLevel()
local minLevel = playerLevel - GetQuestGreenRange("player")
local maxLevel = playerLevel
if Questie.db.profile.lowLevelStyle == Questie.LOWLEVEL_RANGE then
minLevel = Questie.db.profile.minLevelFilter
maxLevel = Questie.db.profile.maxLevelFilter
elseif Questie.db.profile.lowLevelStyle == Questie.LOWLEVEL_OFFSET then
minLevel = playerLevel - Questie.db.profile.manualLevelOffset
end
local completedQuests = Questie.db.char.complete
local showRepeatableQuests = Questie.db.profile.showRepeatableQuests
-- Hide repeatable available quests (e.g. the Ascension Callboard '!') below level 60,
-- where they aren't relevant yet. (#10)
local hideRepeatableBelowMax = Questie.db.profile.hideRepeatableBelowMaxLevel and playerLevel < 60
local showDungeonQuests = Questie.db.profile.showDungeonQuests
local showRaidQuests = Questie.db.profile.showRaidQuests
local showPvPQuests = Questie.db.profile.showPvPQuests
local showAQWarEffortQuests = Questie.db.profile.showAQWarEffortQuests
local autoBlacklist = QuestieDB.autoBlacklist
local hiddenQuests = QuestieCorrections.hiddenQuests
local hidden = Questie.db.char.hidden
local currentQuestlog = QuestiePlayer.currentQuestlog
local currentIsleOfQuelDanasQuests = IsleOfQuelDanas.quests[Questie.db.profile.isleOfQuelDanasPhase] or {}
local aqWarEffortQuests = QuestieQuestBlacklist.AQWarEffortQuests
QuestieDB.activeChildQuests = {} -- Reset here so we don't need to keep track in the quest event system
local activeChildQuests = QuestieDB.activeChildQuests
-- We create a local function here to improve readability but use the localized variables above.
-- The order of checks is important here to bring the speed to a max
local function _DrawQuestIfAvailable(questId)
if (autoBlacklist[questId] or -- Don't show autoBlacklist quests marked as such by IsDoable
completedQuests[questId] or -- Don't show completed quests
hiddenQuests[questId] or -- Don't show blacklisted quests
hidden[questId] or -- Don't show quests hidden by the player
activeChildQuests[questId] -- We already drew this quest in a previous loop iteration
) then
return
end
if currentQuestlog[questId] then
_DrawChildQuests(questId, currentQuestlog, completedQuests)
if QuestieDB.IsComplete(questId) ~= -1 then -- The quest in the quest log is not failed, so we don't show it as available
QuestieMap:UnloadQuestFramesByDataType(questId, "available")
QuestieTooltips:RemoveQuest(questId)
availableQuests[questId] = nil
return
end
end
if (
((not showRepeatableQuests) and QuestieDB.IsRepeatable(questId)) or -- Don't show repeatable quests if option is disabled
(hideRepeatableBelowMax and (QuestieDB.IsRepeatable(questId) or QuestieDB.IsBoardQuest(questId))) or -- Hide repeatable / Callboard quests below level 60 (#10)
((not showPvPQuests) and QuestieDB.IsPvPQuest(questId)) or -- Don't show PvP quests if option is disabled
((not showDungeonQuests) and QuestieDB.IsDungeonQuest(questId)) or -- Don't show dungeon quests if option is disabled
((not showRaidQuests) and QuestieDB.IsRaidQuest(questId)) or -- Don't show raid quests if option is disabled
((not showAQWarEffortQuests) and aqWarEffortQuests[questId]) or -- Don't show AQ War Effort quests if the option disabled
(Questie.IsClassic and currentIsleOfQuelDanasQuests[questId]) or -- Don't show Isle of Quel'Danas quests for Era/HC/SoX
(Questie.IsSoD and QuestieDB.IsRuneAndShouldBeHidden(questId)) -- Don't show SoD Rune quests with the option disabled
) then
return
end
if (
(not QuestieDB.IsLevelRequirementsFulfilled(questId, minLevel, maxLevel, playerLevel)) or
(not QuestieDB.IsDoable(questId, debugEnabled))
) then
--If the quests are not within level range we want to unload them
--(This is for when people level up or change settings etc)
if availableQuests[questId] then
QuestieMap:UnloadQuestFrames(questId)
QuestieTooltips:RemoveQuest(questId)
end
return
end
availableQuests[questId] = true
if QuestieMap.questIdFrames[questId] then
-- We already drew this quest so we might need to update the icon (config changed/level up)
-- Note: GetFramesForQuest returns string-keyed table, so iterate with pairs not numeric index.
local frames = QuestieMap:GetFramesForQuest(questId)
for _, frame in pairs(frames) do
if frame and frame.data and frame.data.QuestData then
local newIcon = _GetQuestIcon(frame.data.QuestData)
if newIcon ~= frame.data.Icon then
frame:UpdateTexture(Questie.usedIcons[newIcon])
end
end
end
return
end
_DrawAvailableQuest(questId)
end
local questCount = 0
-- 1) Base Questie DB (compiled pointers)
local questId, _ = next(questData)
while questId do
_DrawQuestIfAvailable(questId)
questCount = questCount + 1
if questCount > QUESTS_PER_YIELD then
questCount = 0
yield()
end
questId, _ = next(questData, questId)
end
-- 2) Plugin/Legacy/Ascension override quests (not present in QuestPointers)
if type(QuestieDB.questDataOverrides) == "table" then
local qid, _ = next(QuestieDB.questDataOverrides)
while qid do
if type(qid) == "number" then
_DrawQuestIfAvailable(qid)
questCount = questCount + 1
if questCount > QUESTS_PER_YIELD then
questCount = 0
yield()
end
end
qid, _ = next(QuestieDB.questDataOverrides, qid)
end
end
end
--- Mark all child quests as active when the parent quest is in the quest log
---@param questId number
---@param currentQuestlog table<number, boolean>
---@param completedQuests table<number, boolean>
_DrawChildQuests = function(questId, currentQuestlog, completedQuests)
local childQuests = QuestieDB.QueryQuestSingle(questId, "childQuests")
if (not childQuests) then
return
end
local childQuestId, _ = next(childQuests or {})
while childQuestId do
if (not completedQuests[childQuestId]) and (not currentQuestlog[childQuestId]) then
local childQuestExclusiveTo = QuestieDB.QueryQuestSingle(childQuestId, "exclusiveTo")
local blockedByExclusiveTo = false
local i = 1
local exclusiveTo = childQuestExclusiveTo or {}
while exclusiveTo[i] do
local exclusiveToQuestId = exclusiveTo[i]
if QuestiePlayer.currentQuestlog[exclusiveToQuestId] or completedQuests[exclusiveToQuestId] then
blockedByExclusiveTo = true
break
end
i = i + 1
end
if (not blockedByExclusiveTo) then
QuestieDB.activeChildQuests[childQuestId] = true
availableQuests[childQuestId] = true
-- Draw them right away and skip all other irrelevant checks
_DrawAvailableQuest(childQuestId)
end
end
childQuestId, _ = next(childQuests, childQuestId)
end
end
---@param questId number
_DrawAvailableQuest = function(questId)
NewThread(function()
local quest = QuestieDB.GetQuest(questId)
if not quest then
if not unavailableQuestLogged[questId] then
unavailableQuestLogged[questId] = true
Questie:Debug(Questie.DEBUG_INFO, "[AvailableQuests] Skipping unavailable quest during draw:", questId)
end
return
end
if (not quest.tagInfoWasCached) then
QuestieDB.GetQuestTagInfo(questId) -- cache to load in the tooltip
quest.tagInfoWasCached = true
end
unavailableQuestLogged[questId] = nil
AvailableQuests.DrawAvailableQuest(quest)
end, 0)
end
---@param quest Quest
_GetQuestIcon = function(quest)
if Questie.IsSoD == true and QuestieDB.IsSoDRuneQuest(quest.Id) then
return Questie.ICON_TYPE_SODRUNE
elseif QuestieDB.IsActiveEventQuest(quest.Id) then
return Questie.ICON_TYPE_EVENTQUEST
end
if QuestieDB.IsPvPQuest(quest.Id) then
return Questie.ICON_TYPE_PVPQUEST
end
-- Ascension level scaling: treat the quest level used for trivial\/difficulty logic as the effective scaled level
local playerLevel = QuestiePlayer.GetPlayerLevel()
local effectiveQuestLevel, effectiveRequiredLevel = QuestieLib.GetTbcLevel(quest.Id, playerLevel)
if effectiveRequiredLevel and effectiveRequiredLevel > playerLevel then
return Questie.ICON_TYPE_AVAILABLE_GRAY
end
if quest.IsRepeatable then
return Questie.ICON_TYPE_REPEATABLE
end
if QuestieLib:IsQuestTrivialScaled(quest.Id, effectiveQuestLevel) then
return Questie.ICON_TYPE_AVAILABLE_GRAY
end
return Questie.ICON_TYPE_AVAILABLE
end
---@param starter table Either an object or an NPC
---@param quest Quest
---@param tooltipKey string the tooltip key. For objects it's "o_<ID>", for NPCs it's "m_<ID>"
_AddStarter = function(starter, quest, tooltipKey)
if (not starter) then
return
end
QuestieTooltips:RegisterQuestStartTooltip(quest.Id, starter.name, starter.id, tooltipKey)
local starterIcons = {}
local starterLocs = {}
local zoneId, spawns = next(starter.spawns or {})
while zoneId do
local alreadyAddedSpawns = {}
local waypointStart = starter.waypoints and _GetFirstWaypointCoord(starter.waypoints[zoneId])
if spawns and not waypointStart then
local spawnIndex = 1
while spawns[spawnIndex] do
local coords = spawns[spawnIndex]
if table.getn(spawns) == 1 or _HasProperDistanceToAlreadyAddedSpawns(coords, alreadyAddedSpawns) then
local data = {
Id = quest.Id,
Icon = _GetQuestIcon(quest),
GetIconScale = _GetIconScaleForAvailable,
IconScale = _GetIconScaleForAvailable(),
Type = "available",
QuestData = quest,
Name = starter.name,
IsObjectiveNote = false,
}
if (coords[1] == -1 or coords[2] == -1) then
local dungeonLocation = ZoneDB:GetDungeonLocation(zoneId)
if dungeonLocation then
local i = 1
while dungeonLocation[i] do
local value = dungeonLocation[i]
QuestieMap:DrawWorldIcon(data, value[1], value[2], value[3])
i = i + 1
end
end
else
local icon = QuestieMap:DrawWorldIcon(data, zoneId, coords[1], coords[2])
if starter.waypoints then
-- This is only relevant for waypoint drawing
starterIcons[zoneId] = icon
if not starterLocs[zoneId] then
starterLocs[zoneId] = { coords[1], coords[2] }
end
end
tinsert(alreadyAddedSpawns, coords)
end
end
spawnIndex = spawnIndex + 1
end
end
zoneId, spawns = next(starter.spawns, zoneId)
end
-- Only for NPCs since objects do not move
if starter.waypoints then
local zone, waypoints = next(starter.waypoints or {})
while zone do
local waypointStart = _GetFirstWaypointCoord(waypoints)
if not dungeons[zone] and waypointStart then
if not starterIcons[zone] then
local data = {
Id = quest.Id,
Icon = _GetQuestIcon(quest),
GetIconScale = _GetIconScaleForAvailable,
IconScale = _GetIconScaleForAvailable(),
Type = "available",
QuestData = quest,
Name = starter.name,
IsObjectiveNote = false,
}
starterIcons[zone] = QuestieMap:DrawWorldIcon(data, zone, waypointStart[1], waypointStart[2])
starterLocs[zone] = { waypointStart[1], waypointStart[2] }
end
if starterIcons[zone] then
QuestieMap:DrawWaypoints(starterIcons[zone], waypoints, zone)
end
end
zone, waypoints = next(starter.waypoints, zone)
end
end
end
_HasProperDistanceToAlreadyAddedSpawns = function(coords, alreadyAddedSpawns)
local idx, alreadyAdded = next(alreadyAddedSpawns)
while idx do
local distance = QuestieLib.GetSpawnDistance(alreadyAdded, coords)
-- 29 seems like a good distance. The "Undying Laborer" in Westfall shows both spawns for the "Horn of Lordaeron" rune
if distance < 29 then
return false
end
idx, alreadyAdded = next(alreadyAddedSpawns, idx)
end
return true
end
_GetIconScaleForAvailable = function()
return Questie.db.profile.availableScale or 1.3
end
-- Periodic cleanup to ensure completed quest icons are removed
-- This is needed because sometimes QuestieMap:UnloadQuestFrames doesn't fully clean up
-- or icons are redrawn by race conditions
local cleanupTimer
local function StartPeriodicCleanup()
if cleanupTimer then
cleanupTimer:Cancel()
end
-- Check every 5 seconds
cleanupTimer = C_Timer.NewTicker(5, function()
-- Only run if Questie isn't busy
if QuestieMap._mapDrawQueue and table.getn(QuestieMap._mapDrawQueue) == 0 and
QuestieMap._minimapDrawQueue and table.getn(QuestieMap._minimapDrawQueue) == 0 then
local completedQuests = Questie.db.char.complete
if not completedQuests then return end
local questId, frameList = next(QuestieMap.questIdFrames)
while questId do
if completedQuests[questId] and not QuestiePlayer.currentQuestlog[questId] then
-- This quest is complete but still has frames on the map
Questie:Debug(Questie.DEBUG_INFO, "[AvailableQuests] Cleanup: Removing lingering frames for completed quest:", questId)
QuestieMap:UnloadQuestFrames(questId)
QuestieTooltips:RemoveQuest(questId)
end
questId, frameList = next(QuestieMap.questIdFrames, questId)
end
end
end)
end
-- Start the cleanup timer
StartPeriodicCleanup()