Files
Questie-X/Modules/Quest/QuestEventHandler.lua
T
Xurkon 24ed0f48b3 fix(arrow): Sunstrider zoneId 3431, rotation CW, collection distance, native map pins
- zoneId detection: accept 3430 OR 3431 OR uiMapId 1241 (4 locations)
- SetRotation is CW-positive: rotAngle=relative (was -relative)
- Collection functions: override target->1241 when player on Sunstrider
- NPC 15281: spawn zone 1241 (not 3430) for correct coord space
- _ResolveMapUiMapId: removed 1241->1941 redirect
- zoneDB: added areaIdToUiMapId[1241]=1241
- Updated CHANGELOG, README, docs
2026-05-21 19:22:40 -05:00

808 lines
35 KiB
Lua

---@class QuestEventHandler
local QuestEventHandler = QuestieLoader:CreateModule("QuestEventHandler")
---@class QuestEventHandlerPrivate
local _QuestEventHandler = QuestEventHandler.private
local _QuestLogUpdateQueue = {} -- Helper module
local questLogUpdateQueue = {} -- The actual queue
---@type QuestEventHandlerPrivate
QuestEventHandler.private = QuestEventHandler.private or {}
---@type QuestLogCache
local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache")
---@type QuestieQuest
local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest")
---@type QuestieJourney
local QuestieJourney = QuestieLoader:ImportModule("QuestieJourney")
---@type QuestieNameplate
local QuestieNameplate = QuestieLoader:ImportModule("QuestieNameplate")
---@type QuestieLib
local QuestieLib = QuestieLoader:ImportModule("QuestieLib")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
---@type QuestieAnnounce
local QuestieAnnounce = QuestieLoader:ImportModule("QuestieAnnounce")
---@type IsleOfQuelDanas
local IsleOfQuelDanas = QuestieLoader:ImportModule("IsleOfQuelDanas")
---@type QuestieCombatQueue
local QuestieCombatQueue = QuestieLoader:ImportModule("QuestieCombatQueue")
---@type QuestieTracker
local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker")
---@type l10n
local l10n = QuestieLoader:ImportModule("l10n")
---@type QuestiePlayer
local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer")
--- COMPATIBILITY ---
local C_Timer = QuestieCompat.C_Timer
local GetQuestLogTitle = QuestieCompat.GetQuestLogTitle
local GetItemInfo = QuestieCompat.GetItemInfo
local tableRemove = table.remove
local QUEST_LOG_STATES = {
QUEST_ACCEPTED = "QUEST_ACCEPTED",
QUEST_TURNED_IN = "QUEST_TURNED_IN",
QUEST_REMOVED = "QUEST_REMOVED",
QUEST_ABANDONED = "QUEST_ABANDONED"
}
local eventFrame = CreateFrame("Frame", "QuestieQuestEventFrame")
local questLog = {}
local questLogUpdateQueueSize = 1
local skipNextUQLCEvent = false
local doFullQuestLogScan = false
local deletedQuestItem = false
-- Debounce state for BAG_UPDATE_DELAYED: prevents hammering UpdateAllQuests on every bag slot change.
-- Also schedules a follow-up scan to catch server-side quest-log counter lag (loot bot batch loots).
local _bagUpdateDebounceTimer = nil
local _bagUpdateFollowUpTimer = nil
-- Periodic quest state verification timer.
-- Ascension server events (QUEST_LOG_UPDATE, UNIT_QUEST_LOG_CHANGED) can be
-- unreliable or delayed, causing stale quest.isComplete / quest.WasComplete flags
-- to persist across reload/reaccept cycles. This timer forces a full quest log
-- reconciliation every 30 seconds so pins and arrows stay accurate.
local _periodicRefreshTimer = nil
local PERIODIC_REFRESH_SECONDS = 30
--- Registers all events that are required for questing (accepting, removing, objective updates, ...)
function QuestEventHandler:RegisterEvents()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] RegisterEvents")
eventFrame:RegisterEvent("QUEST_ACCEPTED")
eventFrame:RegisterEvent("QUEST_TURNED_IN")
eventFrame:RegisterEvent("QUEST_REMOVED")
eventFrame:RegisterEvent("QUEST_LOG_UPDATE")
eventFrame:RegisterEvent("QUEST_WATCH_UPDATE")
eventFrame:RegisterEvent("UNIT_QUEST_LOG_CHANGED")
eventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
eventFrame:RegisterEvent("NEW_RECIPE_LEARNED") -- Spell objectives; Runes in SoD count as recipes because "Engraving" is a profession?
--eventFrame:RegisterEvent("SPELLS_CHANGED") -- Spell objectives
eventFrame:RegisterEvent("BAG_UPDATE_DELAYED") -- Catch quest item loots that bypass QUEST_WATCH_UPDATE (e.g. autoloot bots)
eventFrame:RegisterEvent("PLAYER_INTERACTION_MANAGER_FRAME_HIDE")
eventFrame:RegisterEvent("CHAT_MSG_COMBAT_FACTION_CHANGE")
eventFrame:SetScript("OnEvent", _QuestEventHandler.OnEvent)
-- Start periodic quest state verification timer.
-- On Ascension, QUEST_LOG_UPDATE events can be unreliable. This timer forces
-- a full reconciliation every 30 seconds, catching stale isComplete/WasComplete
-- flags and ensuring objective spawnLists stay populated for active quests.
if not _periodicRefreshTimer then
_periodicRefreshTimer = C_Timer.NewTicker(PERIODIC_REFRESH_SECONDS, function()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] Periodic refresh: forcing full quest log scan")
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
end)
end
-- StaticPopup dialog hooks. Deleteing Quest items do not always trigger a Quest Log Update.
hooksecurefunc("StaticPopup_Show", function(...)
-- Hook StaticPopup_Show. If we find the "DELETE_ITEM" dialog, check for Quest Items and notify the player.
local which, text_arg1 = ...
if which == "DELETE_ITEM" then
local quest
local questName
local foundQuestItem = false
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] StaticPopup_Show: Item Name: ", text_arg1)
if deletedQuestItem == true then
deletedQuestItem = false
end
for questLogIndex = 1, 75 do
local title, level, questTag, isHeader, isCollapsed, isComplete, isDaily, questId = GetQuestLogTitle(
questLogIndex)
if title and questId and (not isHeader) then
quest = QuestieDB.GetQuest(questId)
if quest then
local info = StaticPopupDialogs[which]
local sourceItemId, soureItemName, sourceItemType, soureClassID
local reqSourceItemId, reqSoureItemName, reqSourceItemType, reqSoureClassID
if quest.sourceItemId then
sourceItemId = quest.sourceItemId
if sourceItemId then
soureItemName, _, _, _, _, sourceItemType, _, _, _, _, _, soureClassID = GetItemInfo(
sourceItemId)
end
end
if quest.requiredSourceItems then
reqSourceItemId = quest.requiredSourceItems[1]
if reqSourceItemId then
reqSoureItemName, _, _, _, _, reqSourceItemType, _, _, _, _, _, reqSoureClassID =
GetItemInfo(reqSourceItemId)
end
end
if sourceItemId and soureItemName and sourceItemType and soureClassID and (sourceItemType == "Quest" or soureClassID == 12) and QuestieDB.QueryItemSingle(sourceItemId, "class") == 12 and text_arg1 == soureItemName then
questName = quest.name
foundQuestItem = true
break
elseif reqSourceItemId and reqSoureItemName and reqSourceItemType and reqSoureClassID and (reqSourceItemType == "Quest" or reqSoureClassID == 12) and QuestieDB.QueryItemSingle(reqSourceItemId, "class") == 12 and text_arg1 == reqSoureItemName then
questName = quest.name
foundQuestItem = true
break
else
if quest.Objectives and #quest.Objectives > 0 then
for _, objective in pairs(quest.Objectives) do
if text_arg1 == objective.Description then
questName = quest.name
foundQuestItem = true
break
end
end
end
end
end
end
end
if foundQuestItem and quest and questName then
local frame, text
for i = 1, STATICPOPUP_NUMDIALOGS do
frame = _G["StaticPopup" .. i]
if (frame:IsShown()) and ((frame.text.text_arg1 == text_arg1) or (string.find(frame.text:GetText(), text_arg1))) then
text = _G[frame:GetName() .. "Text"]
break
end
end
if frame ~= nil and text ~= nil then
local updateText = l10n(
"Quest Item %%s might be needed for the quest %%s. \n\nAre you sure you want to delete this?")
text:SetFormattedText(updateText, text_arg1, questName)
text.text_arg1 = updateText
StaticPopup_Resize(frame, which)
deletedQuestItem = true
Questie:Debug(Questie.DEBUG_DEVELOP,
"[QuestieQuest] StaticPopup_Show: Quest Item Detected. Updating Static Popup.")
end
end
end
end)
hooksecurefunc("DeleteCursorItem", function()
-- Hook DeleteCursorItem so we know when the player clicks the Accept button
-- FIX: Added InCombatLockdown guard and pcall to prevent tainting secure execution paths.
if InCombatLockdown() then return end
if deletedQuestItem then
Questie:Debug(Questie.DEBUG_DEVELOP,
"[QuestieQuest] DeleteCursorItem: Quest Item deleted. Update all quests.")
C_Timer.After(0.25, function()
pcall(function()
_QuestEventHandler:UpdateAllQuests()
end)
deletedQuestItem = false
end)
end
end)
_QuestEventHandler:InitQuestLog()
end
--- On Login mark all quests in the quest log with QUEST_ACCEPTED state
function _QuestEventHandler:InitQuestLog()
-- Fill the QuestLogCache for first time
local cacheMiss, changes = QuestLogCache.CheckForChanges(nil)
if cacheMiss and (not Questie.started) then
Questie:Debug(Questie.DEBUG_INFO, "[QuestEventHandler:InitQuestLog] Cache miss during init, retrying in 0.5s...")
C_Timer.After(0.5, function()
_QuestEventHandler:InitQuestLog()
end)
return
end
for questId, _ in pairs(changes) do
questLog[questId] = {
state = QUEST_LOG_STATES.QUEST_ACCEPTED
}
QuestieLib:CacheItemNames(questId)
end
end
--- Fires when a quest is accepted in anyway.
---@param questLogIndex number
---@param questId number
function _QuestEventHandler:QuestAccepted(questLogIndex, questId)
questId = questId or select(8, GetQuestLogTitle(questLogIndex))
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_ACCEPTED", questLogIndex, questId)
if questLog[questId] and questLog[questId].timer then
-- We had a QUEST_REMOVED event which started this timer and now it was accepted again.
-- So the quest was abandoned before, because QUEST_TURNED_IN would have run before QUEST_ACCEPTED.
questLog[questId].timer:Cancel()
questLog[questId].timer = nil
QuestieCombatQueue:Queue(function()
_QuestEventHandler:MarkQuestAsAbandoned(questId)
end)
end
-- If the quest was already in questLog as QUEST_TURNED_IN (e.g. Ebonhold Call Board repeatable
-- quests that vanish without a proper QUEST_REMOVED event), clean up before re-accepting so the
-- tracker doesn't show it as already complete.
-- NOTE: Must check BEFORE wiping questLog[questId] below, otherwise the state check is always false.
local wasTurnedIn = questLog[questId] and questLog[questId].state == QUEST_LOG_STATES.QUEST_TURNED_IN
if wasTurnedIn then
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "re-accepted after auto-complete, clearing stale state")
QuestLogCache.RemoveQuest(questId)
QuestieQuest:CompleteQuest(questId) -- clears per-quest data
QuestieJourney:CompleteQuest(questId)
QuestieAnnounce:CompletedQuest(questId)
QuestieTracker:RemoveQuest(questId)
end
questLog[questId] = {}
-- Timed quests do not need a full Quest Log Update.
-- TODO: Add achievement timers later.
local questTimers = GetQuestTimers(questId)
if type(questTimers) == "number" then
skipNextUQLCEvent = false
else
skipNextUQLCEvent = true
end
QuestieCombatQueue:Queue(function()
QuestieLib:CacheItemNames(questId)
_QuestEventHandler:HandleQuestAccepted(questId)
QuestieTracker:Update()
end)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_ACCEPTED - skipNextUQLCEvent - ", skipNextUQLCEvent)
end
---@param questId number
---@return boolean true @if the function was successful, false otherwise
function _QuestEventHandler:HandleQuestAccepted(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestEventHandler] HandleQuestAccepted - questId:", questId)
local idx = QuestieCompat.GetQuestLogIndexByID(questId)
if not idx then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestEventHandler] HandleQuestAccepted - NO quest log index yet, retrying for questId:", questId)
_QuestLogUpdateQueue:Insert(function()
return _QuestEventHandler:HandleQuestAccepted(questId)
end)
return false
end
-- We first check the quest objectives and retry in the next QLU event if they are not correct yet
local cacheMiss, changes = QuestLogCache.CheckForChanges({ [questId] = true })
if cacheMiss then
-- if cacheMiss, no need to check changes as only 1 questId
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestEventHandler] HandleQuestAccepted - CACHE MISS for questId:", questId, "- retrying later")
_QuestLogUpdateQueue:Insert(function()
return _QuestEventHandler:HandleQuestAccepted(questId)
end)
return false
end
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestEventHandler] HandleQuestAccepted - cache ready, calling AcceptQuest for questId:", questId)
questLog[questId].state = QUEST_LOG_STATES.QUEST_ACCEPTED
QuestieQuest:SetObjectivesDirty(questId)
QuestieJourney:AcceptQuest(questId)
QuestieAnnounce:AcceptedQuest(questId)
local isLastIslePhase = Questie.db.global.isleOfQuelDanasPhase == IsleOfQuelDanas.MAX_ISLE_OF_QUEL_DANAS_PHASES
if Questie.IsWotlk and (not isLastIslePhase) and IsleOfQuelDanas.CheckForActivePhase(questId) then
QuestieQuest:SmoothReset()
else
QuestieQuest:AcceptQuest(questId)
end
QuestieCompat.C_Timer.After(0.2, function()
QuestieTracker:Update()
end)
return true
end
--- Fires when a quest is turned in
---@param questId number
---@param xpReward number
---@param moneyReward number
function _QuestEventHandler:QuestTurnedIn(questId, xpReward, moneyReward)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_TURNED_IN", xpReward, moneyReward, questId)
-- Block UpdateQuest from redrawing objective pins while completion is in-flight
QuestiePlayer.pendingCompleteQuestIds[questId] = true
if questLog[questId] and questLog[questId].timer then
-- Cancel the timer so the quest is not marked as abandoned
questLog[questId].timer:Cancel()
questLog[questId].timer = nil
end
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "was turned in and is completed")
if questLog[questId] then
-- There are quests which you just turn in so there is no preceding QUEST_ACCEPTED event and questLog[questId]
-- is empty
questLog[questId].state = QUEST_LOG_STATES.QUEST_TURNED_IN
elseif QuestieCompat.Is335 then
questLog[questId] = { state = QUEST_LOG_STATES.QUEST_TURNED_IN }
end
local parentQuest = QuestieDB.QueryQuestSingle(questId, "parentQuest")
if parentQuest and parentQuest > 0 then
-- Quests like "The Warsong Reports" have child quests which are just turned in. These child quests only
-- fire QUEST_TURNED_IN + QUEST_LOG_UPDATE
Questie:Debug(Questie.DEBUG_DEVELOP, "Quest:", questId, "Has a Parent Quest - do a full Quest Log check")
doFullQuestLogScan = true
end
local itemName, _, _, quality, _, itemID = GetQuestLogRewardInfo(GetNumQuestLogRewards(questId), questId)
if (itemID ~= nil or itemName ~= nil) and quality == 1 then
Questie:Debug(Questie.DEBUG_DEVELOP, "Quest:", questId,
"Recieved a possible Quest Item - do a full Quest Log check")
doFullQuestLogScan = true
skipNextUQLCEvent = false
else
skipNextUQLCEvent = true
end
QuestLogCache.RemoveQuest(questId)
QuestieQuest:SetObjectivesDirty(questId) -- is this necessary? should whole quest.Objectives be cleared at some point of quest removal?
-- Don't immediately mark as complete, wait for QUEST_REMOVED to confirm it was actually turned in
-- This prevents completed quests from being saved as turned in when abandoned
-- QuestieQuest:CompleteQuest(questId)
-- QuestieJourney:CompleteQuest(questId)
-- QuestieAnnounce:CompletedQuest(questId)
-- questLog[questId] = nil
QuestieCombatQueue:Queue(function()
QuestieTracker:Update()
end)
end
--- Fires when a quest is removed from the quest log. This includes turning it in and abandoning it.
---@param questId number
function _QuestEventHandler:QuestRemoved(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_REMOVED", questId)
doFullQuestLogScan = false
if (not questLog[questId]) then
questLog[questId] = {}
end
-- The party members don't care whether a quest was turned in or abandoned, so we can just broadcast here
Questie:SendMessage("QC_ID_BROADCAST_QUEST_REMOVE", questId)
-- QUEST_TURNED_IN was called before QUEST_REMOVED --> quest was turned in
if questLog[questId].state == QUEST_LOG_STATES.QUEST_TURNED_IN then
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "was turned in before. Completing quest.")
-- Ensure the guard is set in case QUEST_REMOVED fires before the combat queue drains
QuestiePlayer.pendingCompleteQuestIds[questId] = true
-- Now that we confirmed the quest was actually turned in (not abandoned), mark it as complete
QuestieQuest:CompleteQuest(questId)
QuestieJourney:CompleteQuest(questId)
QuestieAnnounce:CompletedQuest(questId)
questLog[questId] = nil
return
end
-- QUEST_REMOVED can fire before QUEST_TURNED_IN. If QUEST_TURNED_IN is not called after X seconds the quest
-- was abandoned
questLog[questId] = {
state = QUEST_LOG_STATES.QUEST_REMOVED,
timer = C_Timer.NewTicker(1, function()
_QuestEventHandler:MarkQuestAsAbandoned(questId)
end, 1)
}
skipNextUQLCEvent = true
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_REMOVED - skipNextUQLCEvent - ", skipNextUQLCEvent)
end
---@param questId number
function _QuestEventHandler:MarkQuestAsAbandoned(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "QuestEventHandler:MarkQuestAsAbandoned")
local questEntry = questLog[questId]
-- so we don't attempt to index a nil value.
if (not questEntry) then
Questie:Debug(Questie.DEBUG_DEVELOP, "QuestEventHandler:MarkQuestAsAbandoned - questLog entry missing for",
questId)
return
end
if questEntry.state == QUEST_LOG_STATES.QUEST_REMOVED then
-- Check if objectives were completed before the quest was removed.
-- This handles auto-complete quests that disappear from the log without firing QUEST_TURNED_IN properly.
-- If objectives were complete, treat it as a completion (not abandonment) so objective pins get hidden.
local objectivesWereComplete = false
local quest = QuestieDB.GetQuest(questId)
if quest then
local isComplete = QuestieDB.IsComplete(questId)
objectivesWereComplete = (isComplete == 1)
-- Check for Ebonhold auto-complete quests which might be removed before IsComplete returns 1
local desc = quest.Description
if type(desc) == "table" then desc = desc[1] end
if (not objectivesWereComplete) and desc and type(desc) == "string" then
if string.find(desc, "automatically rewarded") then
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "detected as auto-complete by description")
objectivesWereComplete = true
end
end
end
if objectivesWereComplete then
-- Objectives were complete, so this was likely an auto-complete quest.
-- Mark as complete instead of abandoned so objective pins get hidden.
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId,
"objectives were complete - treating as completed (auto-complete quest)")
questEntry.state = QUEST_LOG_STATES.QUEST_TURNED_IN
-- Clear stale objective data so re-accepting this quest later doesn't
-- inherit Cached "Completed = true" / "isUpdated = true" flags that
-- would cause PopulateObjectiveNotes to skip drawing map pins.
if quest then
quest.Objectives = {}
quest.WasComplete = nil
quest.isComplete = nil
end
QuestieQuest:SetObjectivesDirty(questId)
QuestLogCache.RemoveQuest(questId)
QuestieQuest:CompleteQuest(questId)
QuestieJourney:CompleteQuest(questId)
QuestieAnnounce:CompletedQuest(questId)
questLog[questId] = nil
else
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "was abandoned")
questEntry.state = QUEST_LOG_STATES.QUEST_ABANDONED
QuestLogCache.RemoveQuest(questId)
QuestieQuest:SetObjectivesDirty(questId) -- is this necessary? should whole quest.Objectives be cleared at some point of quest removal?
QuestieQuest:AbandonedQuest(questId)
QuestieJourney:AbandonQuest(questId)
QuestieAnnounce:AbandonedQuest(questId)
questLog[questId] = nil
end
end
end
---Fires when the quest log changed in any way. This event fires very often!
function _QuestEventHandler:QuestLogUpdate()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_LOG_UPDATE")
local continueQueuing = true
-- Some of the other quest event didn't have the required information and ordered to wait for the next QLU.
-- We are now calling the function which the event added.
while continueQueuing and next(questLogUpdateQueue) do
continueQueuing = _QuestLogUpdateQueue:GetFirst()()
end
if doFullQuestLogScan then
doFullQuestLogScan = false
-- Function call updates doFullQuestLogScan. Order matters.
_QuestEventHandler:UpdateAllQuests()
else
_QuestEventHandler:CleanupRemovedQuestsFallback()
QuestieCombatQueue:Queue(function()
QuestieTracker:Update()
end)
end
end
--- Fires whenever a quest objective progressed
---@param questId number
function _QuestEventHandler:QuestWatchUpdate(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_WATCH_UPDATE", questId)
-- We do a full scan even though we have the questId because many QUEST_WATCH_UPDATE can fire before
-- a QUEST_LOG_UPDATE. Also not every QUEST_WATCH_UPDATE gets a single QUEST_LOG_UPDATE and doing a full
-- scan is less error prone
doFullQuestLogScan = true
end
local _UnitQuestLogChangedCallback = function()
-- We also check in here because UNIT_QUEST_LOG_CHANGED is fired before the relevant events
-- (Accept, removed, ...)
if (not skipNextUQLCEvent) then
doFullQuestLogScan = true
else
doFullQuestLogScan = false
skipNextUQLCEvent = false
Questie:Debug(Questie.DEBUG_INFO, "Skipping UnitQuestLogChanged")
end
return true
end
--- Fires when an objective changed in the quest log of the unitTarget. The required data is not available yet though
---@param unitTarget string
function _QuestEventHandler:UnitQuestLogChanged(unitTarget)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] UNIT_QUEST_LOG_CHANGED", unitTarget)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] UNIT_QUEST_LOG_CHANGED - skipNextUQLCEvent - ", skipNextUQLCEvent)
-- There seem to be quests which don't trigger a QUEST_WATCH_UPDATE.
-- We don't add a full check to the queue if skipNextUQLCEvent == true (from QUEST_WATCH_UPDATE or QUEST_TURNED_IN)
if (not skipNextUQLCEvent) then
doFullQuestLogScan = true
_QuestLogUpdateQueue:Insert(_UnitQuestLogChangedCallback)
else
Questie:Debug(Questie.DEBUG_INFO, "Skipping UnitQuestLogChanged")
end
skipNextUQLCEvent = false
end
-- Fallback cleanup: some servers remove quests without firing QUEST_REMOVED reliably.
-- This compares Questie's currentQuestlog vs the game's quest log and removes stale quests.
function _QuestEventHandler:CleanupRemovedQuestsFallback()
local gameQuestIds = {}
local numEntries = select(1, GetNumQuestLogEntries()) or 0
for questLogIndex = 1, numEntries do
local title, level, questTag, isHeader, isCollapsed, isComplete, isDaily, qid = GetQuestLogTitle(questLogIndex)
if title and qid and qid > 0 and (not isHeader) then
gameQuestIds[qid] = true
end
end
if QuestiePlayer and QuestiePlayer.currentQuestlog then
local removedQuestIds = {}
for questId in pairs(QuestiePlayer.currentQuestlog) do
if questId and questId > 0 and (not gameQuestIds[questId]) then
removedQuestIds[#removedQuestIds + 1] = questId
end
end
for i = 1, #removedQuestIds do
local questId = removedQuestIds[i]
-- Quest disappeared from log (abandoned or auto-turned-in)
-- Check if this quest was confirmed as turned in (not just objectives complete)
local wasTurnedIn = questLog[questId] and questLog[questId].state == QUEST_LOG_STATES.QUEST_TURNED_IN
local wasAlreadyComplete = Questie.db.char.complete and Questie.db.char.complete[questId]
QuestLogCache.RemoveQuest(questId)
QuestieQuest:SetObjectivesDirty(questId)
-- Only mark as complete if it was actually turned in OR already marked complete from previous session
-- Don't use quest.WasComplete because that's set when objectives complete, not when quest is turned in
if wasTurnedIn or wasAlreadyComplete then
QuestieQuest:CompleteQuest(questId)
else
QuestieQuest:AbandonedQuest(questId)
QuestieJourney:AbandonQuest(questId)
QuestieAnnounce:AbandonedQuest(questId)
end
questLog[questId] = nil
end
if #removedQuestIds > 0 then
QuestieNameplate:UpdateNameplate()
QuestieCombatQueue:Queue(function()
QuestieTracker:Update()
end)
end
end
end
--- Does a full scan of the quest log and updates every quest that is in the QUEST_ACCEPTED state and which hash changed
--- since the last check
function _QuestEventHandler:UpdateAllQuests()
Questie:Debug(Questie.DEBUG_INFO, "Running full questlog check")
local questIdsToCheck = {}
-- TODO replace with a ready table so no need to generate at each call
for questId, data in pairs(questLog) do
if data.state == QUEST_LOG_STATES.QUEST_ACCEPTED then
questIdsToCheck[questId] = true
end
end
local cacheMiss, changes = QuestLogCache.CheckForChanges(questIdsToCheck)
if next(changes) then
for questId, objIds in pairs(changes) do
--Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "objectives:", table.concat(objIds, ","), "will be updated")
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "will be updated")
QuestieQuest:SetObjectivesDirty(questId)
QuestieNameplate:UpdateNameplate()
QuestieQuest:UpdateQuest(questId)
end
QuestieCombatQueue:Queue(function()
C_Timer.After(1.0, function()
QuestieTracker:Update()
end)
end)
else
Questie:Debug(Questie.DEBUG_INFO, "Nothing to update")
end
_QuestEventHandler:CleanupRemovedQuestsFallback()
-- Do UpdateAllQuests() again at next QUEST_LOG_UPDATE if there was "cacheMiss" (game's cache and addon's cache didn't have all required data yet)
doFullQuestLogScan = doFullQuestLogScan or cacheMiss
end
local lastTimeQuestRelatedFrameClosedEvent = -1
--- Blizzard does not fire any event when quest items are received or retrieved from sources other than looting.
--- So we hook events which fires once or twice after closing certain frames and do a full quest log check.
function _QuestEventHandler:QuestRelatedFrameClosed(event)
local now = math.floor(GetTime())
-- Don't do update if event fired twice
if lastTimeQuestRelatedFrameClosedEvent ~= now then
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event]", event)
lastTimeQuestRelatedFrameClosedEvent = now
_QuestEventHandler:UpdateAllQuests()
QuestieTracker:Update()
end
end
function _QuestEventHandler:ReputationChange()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] CHAT_MSG_COMBAT_FACTION_CHANGE")
-- Reputational quest progression doesn't fire UNIT_QUEST_LOG_CHANGED event, only QUEST_LOG_UPDATE event.
doFullQuestLogScan = true
end
--- Helper function to insert a callback to the questLogUpdateQueue and increase the index
function _QuestLogUpdateQueue:Insert(callback)
questLogUpdateQueue[questLogUpdateQueueSize] = callback
questLogUpdateQueueSize = questLogUpdateQueueSize + 1
end
--- Helper function to retrieve the first element of questLogUpdateQueue
---@return function @The callback that was inserted first into questLogUpdateQueue
function _QuestLogUpdateQueue:GetFirst()
questLogUpdateQueueSize = questLogUpdateQueueSize - 1
return tableRemove(questLogUpdateQueue, 1)
end
local trackerMinimizedByDungeon = false
function _QuestEventHandler:ZoneChangedNewArea()
Questie:Debug(Questie.DEBUG_DEVELOP, "[EVENT] ZONE_CHANGED_NEW_AREA")
-- By my tests it takes a full 6-7 seconds for the world to load. There are a lot of
-- backend Questie updates that occur when a player zones in/out of an instance. This
-- is necessary to get everything back into it's "normal" state after all the updates.
local isInInstance, instanceType = IsInInstance()
if isInInstance then
C_Timer.After(8, function()
Questie:Debug(Questie.DEBUG_DEVELOP, "[EVENT] ZONE_CHANGED_NEW_AREA: Entering Instance")
if Questie.db.profile.hideTrackerInDungeons then
trackerMinimizedByDungeon = true
QuestieCombatQueue:Queue(function()
QuestieTracker:Collapse()
end)
end
end)
-- We only want this to fire outside of an instance if the player isn't dead and we need to reset the Tracker
elseif (not Questie.db.char.isTrackerExpanded and not UnitIsGhost("player")) and trackerMinimizedByDungeon == true then
C_Timer.After(8, function()
Questie:Debug(Questie.DEBUG_DEVELOP, "[EVENT] ZONE_CHANGED_NEW_AREA: Exiting Instance")
if Questie.db.profile.hideTrackerInDungeons then
trackerMinimizedByDungeon = false
QuestieCombatQueue:Queue(function()
QuestieTracker:Expand()
end)
end
end)
end
end
--- Is executed whenever an event is fired and triggers relevant event handling.
---@param event string
function _QuestEventHandler:OnEvent(event, ...)
if event == "QUEST_ACCEPTED" then
_QuestEventHandler:QuestAccepted(...)
elseif event == "QUEST_TURNED_IN" then
_QuestEventHandler:QuestTurnedIn(...)
elseif event == "QUEST_REMOVED" then
_QuestEventHandler:QuestRemoved(...)
elseif event == "QUEST_LOG_UPDATE" then
_QuestEventHandler:QuestLogUpdate()
elseif event == "QUEST_WATCH_UPDATE" then
_QuestEventHandler:QuestWatchUpdate(...)
elseif event == "UNIT_QUEST_LOG_CHANGED" and select(1, ...) == "player" then
_QuestEventHandler:UnitQuestLogChanged(...)
elseif event == "ZONE_CHANGED_NEW_AREA" then
_QuestEventHandler:ZoneChangedNewArea()
elseif event == "NEW_RECIPE_LEARNED" then
Questie:Debug(Questie.DEBUG_DEVELOP, "[EVENT] NEW_RECIPE_LEARNED (QuestEventHandler)")
doFullQuestLogScan = true -- If this event is related to a spell objective, a QUEST_LOG_UPDATE will be fired afterwards
elseif event == "BAG_UPDATE_DELAYED" then
-- Autoloot bots bypass the loot frame entirely, so QUEST_WATCH_UPDATE never fires for bot-looted items.
-- Additionally, when a bot loots a stack very quickly the server batches the quest-objective counter
-- updates so C_QuestLog.GetQuestObjectives still returns the OLD count when this event fires.
-- Strategy:
-- 1) Immediate scan (catches single-item normal loots and is fast).
-- 2) Debounce rapid fires to at most one extra scan per 0.3s burst.
-- 3) Always schedule a follow-up scan 2 s later so the server counter has time to catch up.
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
-- Cancel any pending debounce timer so rapid fires collapse into one.
if _bagUpdateDebounceTimer then
_bagUpdateDebounceTimer:Cancel()
_bagUpdateDebounceTimer = nil
end
_bagUpdateDebounceTimer = C_Timer.NewTimer(0.3, function()
_bagUpdateDebounceTimer = nil
-- Second scan: picks up any items the immediate scan missed.
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
end)
-- Follow-up scan: wait for the server's quest counters to reflect the full loot batch.
if _bagUpdateFollowUpTimer then
_bagUpdateFollowUpTimer:Cancel()
end
_bagUpdateFollowUpTimer = C_Timer.NewTimer(2.0, function()
_bagUpdateFollowUpTimer = nil
Questie:Debug(Questie.DEBUG_DEVELOP, "[BAG_UPDATE_DELAYED] Follow-up scan for batch loot bot items")
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
end)
elseif event == "PLAYER_INTERACTION_MANAGER_FRAME_HIDE" then
local eventType = select(1, ...)
if eventType == 1 then
event = "TRADE_CLOSED"
elseif eventType == 5 then
event = "MERCHANT_CLOSED"
elseif eventType == 8 then
event = "BANKFRAME_CLOSED"
elseif eventType == 10 then
event = "GUILDBANKFRAME_CLOSED"
elseif eventType == 12 then
event = "VENDOR_CLOSED"
elseif eventType == 17 then
event = "MAIL_CLOSED"
elseif eventType == 21 then
event = "AUCTION_HOUSE_CLOSED"
else
-- Unknown event which we will simply ignore
return
end
_QuestEventHandler:QuestRelatedFrameClosed(event)
elseif event == "CHAT_MSG_COMBAT_FACTION_CHANGE" then
_QuestEventHandler:ReputationChange()
end
end