From 8898dcf96afc188fd1a986da1b55e1b726275abf Mon Sep 17 00:00:00 2001 From: Xurkon Date: Sat, 13 Jun 2026 08:30:41 -0500 Subject: [PATCH] fix: harden quest poi filtering and objective refresh --- CHANGELOG.md | 2 + Compat/Compat.lua | 98 ++++++++++++++++++- Modules/Quest/QuestEventHandler.lua | 31 ++++++ ...QuestEventHandlerObjectiveRefresh_spec.lua | 28 ++++++ .../QuestieBlizzardObjectivePOISync_spec.lua | 8 +- 5 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 Tests/QuestEventHandlerObjectiveRefresh_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d711d..79e3215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,8 @@ ### 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. +- **[Map - Native POIs Respect Hidden Available-Quest Filters]** (#10, #11) Blizzard/server POI suppression now also hides native available-quest buttons when Questie's own available-quest filters intentionally hide that quest, such as Callboard/repeatable quests below 60 or dungeon quests while dungeon quests are disabled. This covers the case where Questie correctly hides its own `!`, leaving no visible duplicate frame for the older suppression logic to detect. Active quest-log entries are excluded so native turn-in/objective POIs are not suppressed just because Questie lacks a visible frame. +- **[Quest Events - Completed Objective Refresh Hardened]** (#21) `QUEST_WATCH_UPDATE` now dirties the updated quest and schedules debounced quest-log scans itself instead of relying on a later `QUEST_LOG_UPDATE` that Ascension may not send. A second delayed pass catches server-side objective-counter lag, so fully completed objective pins are removed and the finisher pin can replace them without waiting for the periodic refresh. - **[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/Compat/Compat.lua b/Compat/Compat.lua index 7d89417..0d26ebc 100644 --- a/Compat/Compat.lua +++ b/Compat/Compat.lua @@ -610,6 +610,15 @@ local BLIZZARD_POI_QUEST_LOG_INDEX_FIELDS = { "logIndex", } +local BLIZZARD_POI_QUEST_TAG_FIELDS = { + "questTagID", + "questTagId", + "questTag", + "questType", + "tagID", + "tagId", +} + local function _ToPositiveNumber(value) if type(value) == "number" or type(value) == "string" then local number = tonumber(value) @@ -621,6 +630,19 @@ local function _ToPositiveNumber(value) return nil end +local function _GetPlayerLevel() + if UnitLevel then + return UnitLevel("player") or 0 + end + + local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer") + if QuestiePlayer and QuestiePlayer.GetPlayerLevel then + return QuestiePlayer.GetPlayerLevel() or 0 + end + + return 0 +end + ---Enables Blizzard/server objective POIs without globally disabling them when Questie objectives are on. function QuestieCompat.EnableBlizzardObjectivePOIs() if GetCVar and GetCVar("questPOI") ~= nil and SetCVar then @@ -695,10 +717,84 @@ function QuestieCompat.GetQuestIDFromBlizzardPOIButton(poiButton) return nil end +---@param poiButton table +---@return number|nil +function QuestieCompat.GetQuestTagIDFromBlizzardPOIButton(poiButton) + if not poiButton then + return nil + end + + for _, field in ipairs(BLIZZARD_POI_QUEST_TAG_FIELDS) do + local questTagId = _ToPositiveNumber(poiButton[field]) + if questTagId then + return questTagId + end + end + + return nil +end + +---@param questId number +---@param poiButton table|nil +---@return boolean +function QuestieCompat.ShouldSuppressHiddenQuestieAvailablePOI(questId, poiButton) + if (not questId) or questId <= 0 or (not Questie) or (not Questie.db) or (not Questie.db.profile) then + return false + end + + local profile = Questie.db.profile + if not profile.enabled then + return false + end + + local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer") + if QuestiePlayer and QuestiePlayer.currentQuestlog and QuestiePlayer.currentQuestlog[questId] then + return false + end + + local QuestieDB = QuestieLoader:ImportModule("QuestieDB") + if not QuestieDB then + return false + end + + if Questie.db.char and Questie.db.char.complete and Questie.db.char.complete[questId] then + return true + end + if Questie.db.char and Questie.db.char.hidden and Questie.db.char.hidden[questId] then + return true + end + + local repeatable = QuestieDB.IsRepeatable and QuestieDB.IsRepeatable(questId) + local event = QuestieDB.IsActiveEventQuest and QuestieDB.IsActiveEventQuest(questId) + local dungeon = QuestieDB.IsDungeonQuest and QuestieDB.IsDungeonQuest(questId) + local raid = QuestieDB.IsRaidQuest and QuestieDB.IsRaidQuest(questId) + local pvp = QuestieDB.IsPvPQuest and QuestieDB.IsPvPQuest(questId) + + local questTagId = QuestieCompat.GetQuestTagIDFromBlizzardPOIButton(poiButton) + if questTagId == 81 then + dungeon = true + elseif questTagId == 62 then + raid = true + elseif questTagId == 41 then + pvp = true + elseif questTagId == 82 then + event = true + end + + local normal = not (repeatable or event or dungeon or raid or pvp) + return ((not profile.enableAvailable) and normal) + or ((not profile.showRepeatableQuests) and repeatable) + or (profile.hideRepeatableBelowMaxLevel and (repeatable or (QuestieDB.IsBoardQuest and QuestieDB.IsBoardQuest(questId))) and _GetPlayerLevel() < 60) + or ((not profile.showEventQuests) and event) + or ((not profile.showDungeonQuests) and dungeon) + or ((not profile.showRaidQuests) and raid) + or ((not profile.showPvPQuests) and pvp) +end + ---@param poiButton table function QuestieCompat.SuppressDuplicateBlizzardPOIButton(poiButton) local questId = QuestieCompat.GetQuestIDFromBlizzardPOIButton(poiButton) - if questId and QuestieCompat.HasVisibleQuestiePOIForQuest(questId) then + if questId and (QuestieCompat.HasVisibleQuestiePOIForQuest(questId) or QuestieCompat.ShouldSuppressHiddenQuestieAvailablePOI(questId, poiButton)) then poiButton.questieDuplicateSuppressed = true poiButton:Hide() end diff --git a/Modules/Quest/QuestEventHandler.lua b/Modules/Quest/QuestEventHandler.lua index 780e524..ed0839c 100644 --- a/Modules/Quest/QuestEventHandler.lua +++ b/Modules/Quest/QuestEventHandler.lua @@ -57,6 +57,11 @@ local deletedQuestItem = false -- 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 -- Periodic quest state verification timer. -- Ascension server events (QUEST_LOG_UPDATE, UNIT_QUEST_LOG_CHANGED) can be @@ -548,6 +553,32 @@ function _QuestEventHandler:QuestWatchUpdate(questId) -- 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() diff --git a/Tests/QuestEventHandlerObjectiveRefresh_spec.lua b/Tests/QuestEventHandlerObjectiveRefresh_spec.lua new file mode 100644 index 0000000..ab31c0e --- /dev/null +++ b/Tests/QuestEventHandlerObjectiveRefresh_spec.lua @@ -0,0 +1,28 @@ +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("Quest objective refresh events", function() + local handler = read("Modules/Quest/QuestEventHandler.lua") + + it("does not depend on a later QUEST_LOG_UPDATE after QUEST_WATCH_UPDATE", function() + local start = assert(handler:find("function _QuestEventHandler:QuestWatchUpdate", 1, true)) + local finish = assert(handler:find("local _UnitQuestLogChangedCallback", start, true)) + local body = handler:sub(start, finish) + + assert.is_true(has(handler, "_questWatchUpdateDebounceTimer")) + assert.is_true(has(handler, "_questWatchUpdateFollowUpTimer")) + assert.is_true(has(body, "QuestieQuest:SetObjectivesDirty(questId)")) + assert.is_true(has(body, "state = QUEST_LOG_STATES.QUEST_ACCEPTED")) + assert.is_true(has(body, "C_Timer.NewTimer(0.2")) + assert.is_true(has(body, "C_Timer.NewTimer(1.0")) + assert.is_true(has(body, "_QuestEventHandler:QuestLogUpdate()")) + end) +end) diff --git a/Tests/QuestieBlizzardObjectivePOISync_spec.lua b/Tests/QuestieBlizzardObjectivePOISync_spec.lua index 95ca8d4..aa72020 100644 --- a/Tests/QuestieBlizzardObjectivePOISync_spec.lua +++ b/Tests/QuestieBlizzardObjectivePOISync_spec.lua @@ -15,7 +15,7 @@ describe("Blizzard objective POI suppression", function() local iconOptions = read("Modules/Options/IconsTab/QuestieOptionsIcons.lua") local questieMenu = read("Modules/QuestieMenu/QuestieMenu.lua") - it("suppresses only native POI buttons that duplicate visible Questie quest icons", function() + it("suppresses native POI buttons that duplicate visible or intentionally hidden Questie quest icons", function() local helperStart = assert(compat:find("function QuestieCompat.HasVisibleQuestiePOIForQuest", 1, true)) local helperEnd = assert(compat:find("-- https://wowpedia.fandom.com/wiki/API_GetQuestLink", helperStart, true)) local helper = compat:sub(helperStart, helperEnd) @@ -28,6 +28,12 @@ describe("Blizzard objective POI suppression", function() assert.is_true(has(compat, "frame:ShouldBeHidden()")) assert.is_true(has(helper, "QuestieMap.questIdFrames and QuestieMap.questIdFrames[questId]")) assert.is_true(has(helper, "poiButton:Hide()")) + assert.is_true(has(helper, "function QuestieCompat.ShouldSuppressHiddenQuestieAvailablePOI")) + assert.is_true(has(helper, "profile.hideRepeatableBelowMaxLevel")) + assert.is_true(has(helper, "QuestieDB.IsBoardQuest")) + assert.is_true(has(helper, "profile.showDungeonQuests")) + assert.is_true(has(helper, "questTagId == 81")) + assert.is_true(has(helper, "QuestieCompat.HasVisibleQuestiePOIForQuest(questId) or QuestieCompat.ShouldSuppressHiddenQuestieAvailablePOI(questId, poiButton)")) end) it("hooks Blizzard POI display without globally disabling native POIs", function()