Files
Questie-X/Modules/Quest/QuestEventHandler.lua
T
Narcasung de270a0e8e fix(tracker): drop quests the player no longer has
Ascension's special quests are finished by the server the moment their
objectives are met, and one of them stayed in the tracker for the rest of
the session: currentQuestlog is only as good as the removal events that
maintain it, and nothing landed for that quest.

The tracker now checks the quest log itself, which it already walks once
per draw for the zone headers, and skips anything that is no longer in
it, dropping the fallback object built for it as well. Skipped rather
than pruned, so a redraw that catches the log mid-refresh does not throw
away state Questie is about to want back.

Three things on the event side that let it get that far:

- CleanupRemovedQuestsFallback now also runs after a full quest log scan.
  UpdateAllQuests only inspects quests still in the log, so for as long
  as something kept asking for full scans, nothing looked for removals.
- A removed quest counts as completed when the server has it flagged
  complete. QuestieDB.IsComplete cannot answer for a quest the database
  has never heard of, so these were filed as abandoned instead.
- The removal scan type-checks the quest id before comparing it, so a
  stray string key cannot error out the pass for every other quest.
2026-07-30 18:26:42 +02:00

909 lines
40 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
-- Debounce QUEST_WATCH_UPDATE scans too. Ascension can send objective progress
-- without a follow-up QUEST_LOG_UPDATE, so waiting for the next QLU can leave
-- completed objective pins visible until the periodic refresh catches them.
local _questWatchUpdateDebounceTimer = nil
local _questWatchUpdateFollowUpTimer = nil
local _unitQuestLogChangedDebounceTimer = nil
local _unitQuestLogChangedFollowUpTimer = 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
-- Force a full reconciliation whenever the player opens the native quest log.
-- QuestLogCache only updates its stored objective counts when it detects a change, so if a
-- scan ever ran while the game's own quest log text was still lagging behind the server, the
-- Tracker can get stuck showing stale progress with nothing left to trigger a retry. Looking
-- at the quest log is the moment the player is most likely to notice the mismatch, so treat it
-- as a cue to force a fresh comparison against the live quest log state.
local questLogFrame = QuestLogExFrame or ClassicQuestLog or QuestLogFrame
if questLogFrame then
questLogFrame:HookScript("OnShow", function()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] Quest log opened: 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)
local _, _, _, _, _, _, _, questLogQuestId = GetQuestLogTitle(questLogIndex)
questId = questId or questLogQuestId
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.
--
-- Capture the completion state NOW, while the quest is still in QuestLogCache. On Ascension some
-- turn-ins (notably crafting/auto-complete quests) never fire QUEST_TURNED_IN, only QUEST_REMOVED,
-- so MarkQuestAsAbandoned runs a second later — by which point the cache has been cleared and
-- IsComplete can no longer tell the quest was finished, causing a turned-in quest to be misclassified
-- as abandoned (its objective pins / turn-in "?" then linger). Snapshot it here so the timer can
-- treat a quest that was complete at removal as a completion.
local completeAtRemoval = QuestieDB.IsComplete(questId)
questLog[questId] = {
state = QUEST_LOG_STATES.QUEST_REMOVED,
completeAtRemoval = completeAtRemoval,
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)
-- Prefer the completion state snapshotted at QUEST_REMOVED time (before the cache was
-- cleared); fall back to a live IsComplete if no snapshot was captured. This reliably
-- catches turn-ins that skipped QUEST_TURNED_IN so they are completed, not abandoned.
if questEntry.completeAtRemoval == 1 then
objectivesWereComplete = true
end
if quest then
local isComplete = QuestieDB.IsComplete(questId)
objectivesWereComplete = objectivesWereComplete or (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()
-- Also on this path: UpdateAllQuests only looks at quests still in the log, so a removal
-- that fired no event of its own would sit there unnoticed for as long as full scans keep
-- being asked for.
_QuestEventHandler:CleanupRemovedQuestsFallback()
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
if questId and questId > 0 then
questLog[questId] = questLog[questId] or {
state = QUEST_LOG_STATES.QUEST_ACCEPTED
}
QuestieQuest:SetObjectivesDirty(questId)
end
if _questWatchUpdateDebounceTimer then
_questWatchUpdateDebounceTimer:Cancel()
_questWatchUpdateDebounceTimer = nil
end
_questWatchUpdateDebounceTimer = C_Timer.NewTimer(0.2, function()
_questWatchUpdateDebounceTimer = nil
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
end)
if _questWatchUpdateFollowUpTimer then
_questWatchUpdateFollowUpTimer:Cancel()
_questWatchUpdateFollowUpTimer = nil
end
_questWatchUpdateFollowUpTimer = C_Timer.NewTimer(1.0, function()
_questWatchUpdateFollowUpTimer = nil
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
end)
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)
-- Ascension can fire UNIT_QUEST_LOG_CHANGED without a follow-up QUEST_LOG_UPDATE.
-- The event arrives before objective data is ready, so run a short delayed scan
-- and one follow-up scan instead of waiting for an event that may never come.
if _unitQuestLogChangedDebounceTimer then
_unitQuestLogChangedDebounceTimer:Cancel()
_unitQuestLogChangedDebounceTimer = nil
end
_unitQuestLogChangedDebounceTimer = C_Timer.NewTimer(0.2, function()
_unitQuestLogChangedDebounceTimer = nil
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
end)
if _unitQuestLogChangedFollowUpTimer then
_unitQuestLogChangedFollowUpTimer:Cancel()
_unitQuestLogChangedFollowUpTimer = nil
end
_unitQuestLogChangedFollowUpTimer = C_Timer.NewTimer(1.0, function()
_unitQuestLogChangedFollowUpTimer = nil
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
end)
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
-- Typed check: a stray string key (saved variables have produced them) would other-
-- wise error on the comparison and take the whole pass down with it.
if type(questId) == "number" 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]
local completeAtRemoval = QuestieDB.IsComplete(questId)
-- The server's own record, and the only one that knows anything about a quest the
-- database has never heard of: QuestieDB.IsComplete cannot answer for those, so an
-- Ascension quest the server finished by itself would otherwise be filed as abandoned.
local serverFlaggedComplete = IsQuestFlaggedCompleted and IsQuestFlaggedCompleted(questId)
local shouldComplete = wasTurnedIn or wasAlreadyComplete or completeAtRemoval == 1 or
serverFlaggedComplete
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 shouldComplete 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()
QuestieTracker:Update()
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