diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a8c85..0e85707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,7 @@ - **[Tooltip - Data Source In Secondary Tooltip Only]** The `Source:` attribution line now appears only inside the secondary learner tooltip (when "Use secondary learner tooltip" is enabled) and is never added to the main NPC/object/item tooltip; with the secondary tooltip disabled it does not appear at all. The map-pin source line is gated the same way. The "Show data source" option now depends on the secondary-tooltip option being enabled. - **[Tooltip - Learner Pin Mislabelled As AscensionDB]** A spawn freshly learned via the learner was shown as `Source: AscensionDB` instead of `Learner`. `QuestieDB.GetPinDataSource` resolved AscensionDB-curated ownership before checking the learner, but in learner-only mode the displayed spawns are the learner's (curated coords are discarded by `GetNPC`/`GetObject`). It is now mode-aware: in learner mode, an entity with a learner record reports `Learner` even when AscensionDB also curates it. - **[Learner - Learner-Only Mode No Longer Draws AscensionDB/Static Pins]** In learner-only mode the map/minimap drew curated AscensionDB (and static) spawns for every quest NPC/object the player had not personally recorded, instead of only the learner's own spawns. `QuestieDB:GetNPC`/`GetObject` fall back to the `npcDataOverrides`/`objectDataOverrides` entry for metadata when there is no learner record, and that entry's spawns (AscensionDB-curated for un-recorded entities) were being used as pins. Both functions now track whether a real learner record exists and, in learner mode, strip the spawns when it does not — so learner-only mode shows exclusively what the learner has recorded (the metadata fallback for names/tooltips is kept). Added a regression test. Recorded entities still show their learner spawns; auto/static/none modes are unchanged. -- **[Map - Quest Type Filters Now Apply To Minimap]** (#11) Quest-type filters (Available Dungeon/Raid/PvP/Repeatable/Event Quests, plus other `ShouldBeHidden` rules like untracked/unexplored and `enableMiniMapIcons`) were honored on the world map but not the minimap. The world-map icon stayed hidden once `ShouldBeHidden` filtered it, but the minimap icon's per-frame `FadeLogic` re-showed any hidden icon as soon as the player came within range, so e.g. a filtered dungeon quest still appeared on the minimap. `FadeLogic` now re-checks `ShouldBeHidden` before re-showing, so the same filters apply to both maps (fixed in both the world-icon and manual-icon minimap fade paths). +- **[Map - Quest Type Filters Now Apply To Minimap]** (#11) Quest-type filters (Available Dungeon/Raid/PvP/Repeatable/Event Quests, plus other `ShouldBeHidden` rules like untracked/unexplored and `enableMiniMapIcons`) were honored on the world map but not the minimap. The world-map icon stayed hidden once `ShouldBeHidden` filtered it, but the minimap icon's per-frame `FadeLogic` re-showed any hidden icon as soon as the player came within range, so e.g. a filtered dungeon quest still appeared on the minimap. `FadeLogic` now re-checks `ShouldBeHidden` before re-showing, so the same filters apply to both maps (fixed in both the world-icon and manual-icon minimap fade paths). Dungeon detection is also hardened now: when the quest tag is missing, Questie falls back to quest zone/sort data and dungeon-only starter spawns so missing quest-key flags do not leak dungeon quests back onto the minimap. - **[Libs/AceComm - Long Comm Prefix Login Error]** (#12) Fixed a recurring login Lua error `AceComm:RegisterComm(...): prefix length is limited to 16 characters` on servers like ChromieCraft. The cause is a third-party addon (e.g. AtlasLoot) registering an AceComm prefix longer than the client's 16-character limit; because Questie's bundled AceComm is the LibStub winner and its `xpcall` polyfill wraps AceAddon's `OnEnable`, the upstream hard `error()` surfaced through Questie's frames every login. Our bundled `AceComm:RegisterComm` now degrades gracefully for over-long prefixes (which can never work on the client anyway): it warns once and skips the registration instead of throwing, so the offending addon's `OnEnable` is no longer aborted and the error popup is gone. - **[Tracker - VoiceOver questPlayButtons Nil Crash]** (#15) Fixed `attempt to index field 'questPlayButtons' (a nil value)` in `TrackerUtils:UpdateVoiceOverPlayButtons` and `TrackerLinePool.SetAllPlayButtonAlpha`. `TrackerUtils:IsVoiceOverLoaded` only checked that the VoiceOver addons were loaded, but some VoiceOver builds (seen on Elune) expose a `QuestOverlayUI` without a `questPlayButtons` table. It now also verifies `VoiceOver.QuestOverlayUI.questPlayButtons` exists; since every play-button call site gates on this function, the integration is now safely skipped instead of crashing on those builds. - **[Libs/AceGUI - Tree Tooltip Nil Crash]** (#15) Hardened `AceGUIContainer-TreeGroup` against `attempt to index ... 'tooltip' (a nil value)` when a conflicting addon registers a broken `AceGUI-3.0` core (observed in the wild as version `1.#INF`) that wins LibStub but never creates the shared `AceGUI.tooltip` frame. The tree button enter/leave handlers now lazily (re)create the tooltip frame; the widget version was bumped 47→48 so the fixed widget wins registration over an unpatched same-version copy. diff --git a/Database/QuestieDB.lua b/Database/QuestieDB.lua index 89a27d1..613d535 100644 --- a/Database/QuestieDB.lua +++ b/Database/QuestieDB.lua @@ -867,6 +867,49 @@ function QuestieDB.IsBoardQuest(questId) return result end +local _dungeonQuestCache = {} + +local function _ZoneLooksDungeon(zoneId) + if type(zoneId) ~= "number" or zoneId <= 0 then + return false + end + + if ZoneDB and ZoneDB.IsDungeonZone and ZoneDB:IsDungeonZone(zoneId) then + return true + end + + if not ZoneDB then + return false + end + + local alternativeZoneId = ZoneDB.GetAlternativeZoneId and ZoneDB:GetAlternativeZoneId(zoneId) + if alternativeZoneId and ZoneDB.IsDungeonZone and ZoneDB:IsDungeonZone(alternativeZoneId) then + return true + end + + local parentZoneId = ZoneDB.GetParentZoneId and ZoneDB:GetParentZoneId(zoneId) + return parentZoneId and ZoneDB.IsDungeonZone and ZoneDB:IsDungeonZone(parentZoneId) or false +end + +local function _AnySpawnZoneLooksDungeon(ids, querySingle) + if type(ids) ~= "table" or type(querySingle) ~= "function" then + return false + end + + for _, id in ipairs(ids) do + local spawns = querySingle(id, "spawns") + if type(spawns) == "table" then + for zoneId in pairs(spawns) do + if _ZoneLooksDungeon(zoneId) then + return true + end + end + end + end + + return false +end + ---@param questId number ---@return boolean function QuestieDB.IsDailyQuest(questId) @@ -887,7 +930,33 @@ end ---@return boolean function QuestieDB.IsDungeonQuest(questId) local questType, _ = QuestieDB.GetQuestTagInfo(questId) - return questType == 81 + if questType == 81 then + return true + end + + if questType ~= nil and questType ~= 0 then + return false + end + + local cached = _dungeonQuestCache[questId] + if cached ~= nil then + return cached + end + + local result = false + local zoneOrSort = QuestieDB.QueryQuestSingle(questId, "zoneOrSort") + if _ZoneLooksDungeon(zoneOrSort) then + result = true + else + local startedBy = QuestieDB.QueryQuestSingle(questId, "startedBy") + if type(startedBy) == "table" then + result = _AnySpawnZoneLooksDungeon(startedBy[1], QuestieDB.QueryNPCSingle) + or _AnySpawnZoneLooksDungeon(startedBy[2], QuestieDB.QueryObjectSingle) + end + end + + _dungeonQuestCache[questId] = result + return result end ---@param questId number diff --git a/Tests/QuestieDB_suppression_spec.lua b/Tests/QuestieDB_suppression_spec.lua new file mode 100644 index 0000000..67821c4 --- /dev/null +++ b/Tests/QuestieDB_suppression_spec.lua @@ -0,0 +1,201 @@ +describe("QuestieDB learner spawn suppression hardening", function() + before_each(function() + dofile("Tests/wow_api_mock.lua") + dofile("Database/QuestieDB.lua") + end) + + it("ignores malformed numeric NPC spawns while still suppressing valid learned rows", function() + Questie.dbLearner.global.settings.enabled = true + Questie.dbLearner.global.settings.prioritizeMyData = true + + Questie.dbLearner.global.npcs = { + [1001] = { + mc = 2, + [7] = 123, + }, + [1002] = { + mc = 2, + [7] = { + [3431] = { { 12.5, 34.5 } }, + }, + }, + } + + local suppressed = QuestieDB.GetSuppressedNPCs(3431) + + assert.is_nil(suppressed[1001]) + assert.is_true(suppressed[1002]) + end) + + it("falls back to legacy object spawn tables when the current field is malformed", function() + Questie.dbLearner.global.settings.enabled = true + Questie.dbLearner.global.settings.prioritizeMyData = true + + Questie.dbLearner.global.objects = { + [2001] = { + mc = 2, + [4] = 456, + [7] = { + [3431] = { { 55.5, 66.6 } }, + }, + }, + [2002] = { + mc = 2, + [4] = 789, + }, + } + + local suppressed = QuestieDB.GetSuppressedObjects(3431) + + assert.is_true(suppressed[2001]) + assert.is_nil(suppressed[2002]) + end) + + it("normalizes malformed NPC spawn entries during cleanup", function() + local entry = { + [1] = "Test NPC", + [4] = { + [3431] = { { 10.1, 20.2 } }, + }, + [7] = 999, + } + + local changed = QuestieDB.private.NormalizeLearnerSpawnEntry(entry, 7, 4) + + assert.is_true(changed) + assert.is_table(entry[7]) + assert.is_nil(entry[4]) + assert.are.same({ [3431] = { { 10.1, 20.2 } } }, entry[7]) + end) + + it("normalizes malformed object spawn entries during cleanup", function() + local entry = { + [1] = "Test Object", + [4] = 999, + [7] = { + [3431] = { { 77.7, 88.8 } }, + }, + } + + local changed = QuestieDB.private.NormalizeLearnerSpawnEntry(entry, 4, 7) + + assert.is_true(changed) + assert.is_table(entry[4]) + assert.is_nil(entry[7]) + assert.are.same({ [3431] = { { 77.7, 88.8 } } }, entry[4]) + end) + + it("treats dungeon quests with missing quest tags as dungeon quests when zone data proves it", function() + local oldGetQuestTagInfo = _G.GetQuestTagInfo + local oldQueryQuestSingle = QuestieDB.QueryQuestSingle + local oldIsDungeonZone = ZoneDB.IsDungeonZone + local oldGetAlternativeZoneId = ZoneDB.GetAlternativeZoneId + local oldGetParentZoneId = ZoneDB.GetParentZoneId + + _G.GetQuestTagInfo = function() return nil end + QuestieDB.QueryQuestSingle = function(_, key) + if key == "zoneOrSort" then + return 4810 + end + return nil + end + ZoneDB.IsDungeonZone = function(_, areaId) + return areaId == 4810 + end + ZoneDB.GetAlternativeZoneId = function() return nil end + ZoneDB.GetParentZoneId = function() return nil end + + local isDungeon = QuestieDB.IsDungeonQuest(12345) + + _G.GetQuestTagInfo = oldGetQuestTagInfo + QuestieDB.QueryQuestSingle = oldQueryQuestSingle + ZoneDB.IsDungeonZone = oldIsDungeonZone + ZoneDB.GetAlternativeZoneId = oldGetAlternativeZoneId + ZoneDB.GetParentZoneId = oldGetParentZoneId + + assert.is_true(isDungeon) + end) + + it("treats dungeon quests with missing quest tags as dungeon quests when starter spawns are dungeon-only", function() + local oldGetQuestTagInfo = _G.GetQuestTagInfo + local oldQueryQuestSingle = QuestieDB.QueryQuestSingle + local oldQueryNPCSingle = QuestieDB.QueryNPCSingle + local oldQueryObjectSingle = QuestieDB.QueryObjectSingle + local oldIsDungeonZone = ZoneDB.IsDungeonZone + local oldGetAlternativeZoneId = ZoneDB.GetAlternativeZoneId + local oldGetParentZoneId = ZoneDB.GetParentZoneId + + _G.GetQuestTagInfo = function() return nil end + QuestieDB.QueryQuestSingle = function(_, key) + if key == "startedBy" then + return { + { 101 }, + { 202 }, + } + end + return nil + end + QuestieDB.QueryNPCSingle = function(id, key) + if id == 101 and key == "spawns" then + return { + [4810] = { { 12.5, 34.5 } }, + } + end + return nil + end + QuestieDB.QueryObjectSingle = function() return nil end + ZoneDB.IsDungeonZone = function(_, areaId) + return areaId == 4810 + end + ZoneDB.GetAlternativeZoneId = function() return nil end + ZoneDB.GetParentZoneId = function() return nil end + + local isDungeon = QuestieDB.IsDungeonQuest(23456) + + _G.GetQuestTagInfo = oldGetQuestTagInfo + QuestieDB.QueryQuestSingle = oldQueryQuestSingle + QuestieDB.QueryNPCSingle = oldQueryNPCSingle + QuestieDB.QueryObjectSingle = oldQueryObjectSingle + ZoneDB.IsDungeonZone = oldIsDungeonZone + ZoneDB.GetAlternativeZoneId = oldGetAlternativeZoneId + ZoneDB.GetParentZoneId = oldGetParentZoneId + + assert.is_true(isDungeon) + end) + + it("does not treat ordinary quests as dungeon quests when the tag is missing", function() + local oldGetQuestTagInfo = _G.GetQuestTagInfo + local oldQueryQuestSingle = QuestieDB.QueryQuestSingle + local oldIsDungeonZone = ZoneDB.IsDungeonZone + local oldGetAlternativeZoneId = ZoneDB.GetAlternativeZoneId + local oldGetParentZoneId = ZoneDB.GetParentZoneId + + _G.GetQuestTagInfo = function() return nil end + QuestieDB.QueryQuestSingle = function(_, key) + if key == "zoneOrSort" then + return 12 + elseif key == "startedBy" then + return { + { 301 }, + { 401 }, + } + end + return nil + end + ZoneDB.IsDungeonZone = function(_, areaId) + return false + end + ZoneDB.GetAlternativeZoneId = function() return nil end + ZoneDB.GetParentZoneId = function() return nil end + + local isDungeon = QuestieDB.IsDungeonQuest(34567) + + _G.GetQuestTagInfo = oldGetQuestTagInfo + QuestieDB.QueryQuestSingle = oldQueryQuestSingle + ZoneDB.IsDungeonZone = oldIsDungeonZone + ZoneDB.GetAlternativeZoneId = oldGetAlternativeZoneId + ZoneDB.GetParentZoneId = oldGetParentZoneId + + assert.is_false(isDungeon) + end) +end)