From 12b30cd566c71f54f9f1ec6ebaca04962adf6ca5 Mon Sep 17 00:00:00 2001 From: Xurkon Date: Sat, 13 Jun 2026 07:49:04 -0500 Subject: [PATCH] fix: purge lingering completed quest icons --- CHANGELOG.md | 1 + Modules/Map/QuestieMap.lua | 82 +++++++++++++++++--- Modules/Quest/QuestEventHandler.lua | 4 +- Modules/Quest/QuestieQuest.lua | 2 +- Tests/QuestieQuestCompletionCleanup_spec.lua | 57 ++++++++++++++ 5 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 Tests/QuestieQuestCompletionCleanup_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index bd3929c..c4d711d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ ### Bug Fixes +- **[Map - Completed Quest Icon Cleanup Hardened]** (#9) Completed/removed quest cleanup now purges quest-owned frames from Questie's registry, pending map/minimap draw queues, and HBD's active map/minimap pin tables. The removed-quest fallback also snapshots the last known completion state before clearing the quest cache, so Ascension quest-log removals without clean turn-in events are completed instead of misclassified as abandoned. - **[Options - Instant Quest Text Toggle]** Fixed the General tab "Enable Instant Quest Text" checkbox so it can be toggled even when the client reports the backing `instantQuestText` CVar as unset before the first write. - **[Learner - Secondary Tooltip Without Spawn Coordinates]** Unit-hover learner tooltips now still open the secondary learner tooltip when the learner has confidence data but no recorded spawn coordinates yet. The normal unit tooltip suppression path also suppresses the old inline learner confidence line, so secondary mode no longer leaks learner-only lines back into the main tooltip. - **[Map - Suppress Duplicate Native Quest POIs]** Rather than globally disabling the server/Blizzard objective POIs, Questie now keeps them enabled and hides only the individual Blizzard POI buttons for quests that already have a visible Questie POI (per-quest duplicate-POI suppression in `QuestieCompat`, hooked at init). Blizzard POIs still appear for quests Questie does not cover, but no longer stack on top of Questie's own objective icons. diff --git a/Modules/Map/QuestieMap.lua b/Modules/Map/QuestieMap.lua index 5d94419..a6d9951 100644 --- a/Modules/Map/QuestieMap.lua +++ b/Modules/Map/QuestieMap.lua @@ -127,19 +127,18 @@ function QuestieMap:UnloadQuestFramesForObjective(questId, objectiveIndex) end function QuestieMap:UnloadQuestFrames(questId, iconType) + if not iconType then + QuestieMap:PurgeQuestFrames(questId) + Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap] Unloading quest frames for questid:", questId) + return + end + if QuestieMap.questIdFrames[questId] then - if not iconType then - for _, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do + for name, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do + if frame and frame.data and frame.data.Icon == iconType then frame:Unload(); - end - QuestieMap.questIdFrames[questId] = nil; - else - for name, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do - if frame and frame.data and frame.data.Icon == iconType then - frame:Unload(); - QuestieMap.questIdFrames[questId][name] = nil - _G[name] = nil - end + QuestieMap.questIdFrames[questId][name] = nil + _G[name] = nil end end Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap] Unloading quest frames for questid:", questId) @@ -257,6 +256,67 @@ function QuestieMap:DequeueFrameDrawCalls(frame) end end +function QuestieMap:PurgeQuestFrames(questId) + if not questId then return end + + local frames = {} + for _, frame in pairs(QuestieMap:GetFramesForQuest(questId)) do + if frame then + frames[frame] = true + end + end + + local function collectQueuedFrames(queue) + if type(queue) ~= "table" then return end + for i = #queue, 1, -1 do + local frame = queue[i] and queue[i][2] + if frame and frame.data and frame.data.Id == questId then + frames[frame] = true + tremove(queue, i) + end + end + end + + collectQueuedFrames(QuestieMap._mapDrawQueue) + collectQueuedFrames(QuestieMap._minimapDrawQueue) + + local function collectHbdFrames(pinTable) + if type(pinTable) ~= "table" then return end + for frame in pairs(pinTable) do + if frame and frame.data and frame.data.Id == questId then + frames[frame] = true + end + end + end + + if HBDPins then + collectHbdFrames(HBDPins.activeMinimapPins) + collectHbdFrames(HBDPins.worldmapPins) + end + + for frame in pairs(frames) do + if frame.Unload then + frame:Unload() + else + if HBDPins then + HBDPins:RemoveMinimapIcon(Questie, frame) + HBDPins:RemoveWorldMapIcon(Questie, frame) + end + if frame.Hide then frame:Hide() end + if frame.ClearAllPoints then frame:ClearAllPoints() end + end + + if frame.GetName then + local frameName = frame:GetName() + if frameName then + _G[frameName] = nil + end + end + end + + QuestieMap.questIdFrames[questId] = nil +end + --- Called at startup (Stage 3) and on PLAYER_ENTERING_WORLD to reset the draw queue. function QuestieMap:InitializeQueue() Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieMap] Starting draw queue timer!") diff --git a/Modules/Quest/QuestEventHandler.lua b/Modules/Quest/QuestEventHandler.lua index 68f6159..780e524 100644 --- a/Modules/Quest/QuestEventHandler.lua +++ b/Modules/Quest/QuestEventHandler.lua @@ -607,13 +607,15 @@ function _QuestEventHandler:CleanupRemovedQuestsFallback() -- 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) + local shouldComplete = wasTurnedIn or wasAlreadyComplete or completeAtRemoval == 1 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 + if shouldComplete then QuestieQuest:CompleteQuest(questId) else QuestieQuest:AbandonedQuest(questId) diff --git a/Modules/Quest/QuestieQuest.lua b/Modules/Quest/QuestieQuest.lua index bf1b63a..9a6c4c5 100644 --- a/Modules/Quest/QuestieQuest.lua +++ b/Modules/Quest/QuestieQuest.lua @@ -652,7 +652,7 @@ local function _CleanupCompletedQuestObjectivePins(quest) end end - QuestieMap:UnloadQuestFrames(quest.Id) + QuestieMap:PurgeQuestFrames(quest.Id) end ---@param questId number diff --git a/Tests/QuestieQuestCompletionCleanup_spec.lua b/Tests/QuestieQuestCompletionCleanup_spec.lua new file mode 100644 index 0000000..ec6b21a --- /dev/null +++ b/Tests/QuestieQuestCompletionCleanup_spec.lua @@ -0,0 +1,57 @@ +local function read(path) + local f = assert(io.open(path, "r"), "cannot open " .. path) + local content = f:read("*a") + f:close() + return content +end + +local function has(content, needle) + return content:find(needle, 1, true) ~= nil +end + +describe("QuestieQuest completion objective pin cleanup", function() + local questieQuest = read("Modules/Quest/QuestieQuest.lua") + local questieMap = read("Modules/Map/QuestieMap.lua") + local questEventHandler = read("Modules/Quest/QuestEventHandler.lua") + + it("unloads objective-owned spawned pins before clearing cached objectives", function() + local completeStart = assert(questieQuest:find("function QuestieQuest:CompleteQuest(questId)", 1, true)) + local completeEnd = assert(questieQuest:find("---@param questId number\nfunction QuestieQuest:AbandonedQuest", completeStart, true)) + local completeQuest = questieQuest:sub(completeStart, completeEnd) + local cleanupCall = assert(completeQuest:find("_CleanupCompletedQuestObjectivePins(quest)", 1, true)) + local clearObjectives = assert(completeQuest:find("quest.Objectives = {}", 1, true)) + + assert.is_true(cleanupCall < clearObjectives) + end) + + it("cleans standard objectives, special objectives, and registry frames", function() + local helperStart = assert(questieQuest:find("local function _CleanupCompletedQuestObjectivePins(quest)", 1, true)) + local helperEnd = assert(questieQuest:find("---@param questId number\nfunction QuestieQuest:CompleteQuest", helperStart, true)) + local helper = questieQuest:sub(helperStart, helperEnd) + + assert.is_true(has(helper, "for objectiveIndex, objective in pairs(quest.Objectives) do")) + assert.is_true(has(helper, "_UnloadAlreadySpawnedIcons(objective)")) + assert.is_true(has(helper, "QuestieMap:UnloadQuestFramesForObjective(quest.Id, objectiveIndex)")) + assert.is_true(has(helper, "for _, objective in pairs(quest.SpecialObjectives) do")) + assert.is_true(has(helper, "QuestieMap:PurgeQuestFrames(quest.Id)")) + end) + + it("purges quest-owned frames from HBD registries and pending draw queues", function() + assert.is_true(has(questieMap, "function QuestieMap:PurgeQuestFrames(questId)")) + assert.is_true(has(questieMap, "if not iconType then\n QuestieMap:PurgeQuestFrames(questId)")) + assert.is_true(has(questieMap, "QuestieMap._mapDrawQueue")) + assert.is_true(has(questieMap, "QuestieMap._minimapDrawQueue")) + assert.is_true(has(questieMap, "HBDPins.activeMinimapPins")) + assert.is_true(has(questieMap, "HBDPins.worldmapPins")) + assert.is_true(has(questieMap, "frame.data.Id == questId")) + end) + + it("uses the last known complete state when fallback detects a removed quest", function() + local fallbackStart = assert(questEventHandler:find("function _QuestEventHandler:CleanupRemovedQuestsFallback()", 1, true)) + local fallback = questEventHandler:sub(fallbackStart) + + assert.is_true(has(fallback, "local completeAtRemoval = QuestieDB.IsComplete(questId)")) + assert.is_true(has(fallback, "local shouldComplete = wasTurnedIn or wasAlreadyComplete or completeAtRemoval == 1")) + assert.is_true(has(fallback, "if shouldComplete then")) + end) +end)