From b0f8ba22e71742b47c946cc6387e6805d7b199c3 Mon Sep 17 00:00:00 2001 From: Xurkon Date: Tue, 17 Mar 2026 22:33:01 -0500 Subject: [PATCH] feat: release v1.3.0 - QuestieLearner Confidence & Tiered Pruning Engine --- .gitignore | 4 + CHANGELOG.md | 25 + Database/QuestieDB.lua | 36 + Modules/Network/QuestieLearnerComms.lua | 17 +- .../AdvancedTab/QuestieOptionsAdvanced.lua | 14 +- .../DatabaseTab/QuestieOptionsDatabase.lua | 35 +- Modules/Quest/QuestieQuest.lua | 49 +- Modules/QuestieLearner.lua | 1085 +++++++++++++++-- Modules/QuestieLearnerExport.lua | 63 +- Modules/Tooltips/Tooltip.lua | 60 +- Modules/Tracker/QuestieTracker.lua | 30 +- Modules/Tracker/TrackerUtils.lua | 65 +- Questie-X-Classic.toc | 4 +- Questie-X-TBC.toc | 4 +- Questie-X-Turtle.toc | 4 +- Questie-X.toc | 2 +- Tests/QuestieLearner_spec.lua | 77 ++ Tests/wow_api_mock.lua | 90 ++ Tools/SplitDB.ps1 | 49 - docs/changelog.html | 26 + 20 files changed, 1540 insertions(+), 199 deletions(-) create mode 100644 Tests/QuestieLearner_spec.lua create mode 100644 Tests/wow_api_mock.lua delete mode 100644 Tools/SplitDB.ps1 diff --git a/.gitignore b/.gitignore index 0fe7087..aea41db 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ __pycache__/ coords.lua debug.lua debug_tooltip.lua +tmp_*.py +.history/ +Research/ +Tools/ diff --git a/CHANGELOG.md b/CHANGELOG.md index de35861..fe37864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,29 @@ # Changelog + +## v1.3.0 — QuestieLearner Confidence, Global Sharing & Stale Data Cleanup + +### QuestieLearner.lua — Precision & Confidence +- **[Coordinate Scaling Fix]** Fixed player coordinates being recorded on a 0-1 scale; now correctly scales to 0-100 for compatibility with Questie map pins. +- **[Confidence Rating System]** Introduced a confidence system based on "Match Count" (`mc`). Data is now categorized as "Unconfirmed" (low confidence) or "Verified" (high confidence). +- **[Map Pin Gating]** Learned map pins (sword icons) now only appear after reaching a configurable confidence threshold (default: 2). +- **[Confidence in Tooltips]** NPC and Object tooltips now display their confidence level (e.g., `(Learned - Confidence: 2)`). +- **[Timestamp Tracking]** Added `lastSeen` (`ls`) timestamps to all learned entries to track data freshness. + +### QuestieLearnerComms.lua — Global Data Sharing +- **[Community Reinforcement]** Expanded data sharing from Party/Guild to a global hidden channel. Confidence values (`mc`) now increment when identical data is received from other Questie users, allowing the community to verify spawns collectively. +- **[Network Freshness]** Receiving data over the network now refreshes the `lastSeen` timestamp, keeping active community spawns from being pruned. + +### QuestieLearnerExport.lua — Tiered Stale Data Cleanup +- **[Tiered Pruning]** Implemented a robust cleanup system that protects "Verified" (high-confidence) data from age-based deletion. +- **[Age-Based Pruning]** "Unconfirmed" data is now automatically pruned if it hasn't been seen within a configurable timeframe (default: 90 days). +- **[Redundancy Pruning]** Logic to remove data already present in the official Questie database now respects the `pruneVerified` toggle, allowing users to keep verified personal data even if it overlaps with the core DB. + +### QuestieOptionsDatabase.lua — Advanced Cleanup Controls +- **[Stale Data Threshold]** Added a slider to control the age-pruning threshold (1-180 days) for unconfirmed data. +- **[Verified Data Protection]** Added a toggle to include or exclude verified data from redundancy pruning. +- **[UI Reorganization]** Refactored the Database tab's cleanup section for better logical flow and clarity. + +--- ## v1.2.9 — QuestieLearner Cross-Link Engine + Tracker Zone Fix + Untrack Fix diff --git a/Database/QuestieDB.lua b/Database/QuestieDB.lua index 4f443a0..647905f 100644 --- a/Database/QuestieDB.lua +++ b/Database/QuestieDB.lua @@ -586,6 +586,42 @@ function QuestieDB.IsParentQuestActive(parentID) return false end +--- Returns a table of [npcId] = true for NPCs that have verified learned data (Confidence >= 2) +--- in a specific zone. Used to hide static database spawns in favor of verified ones. +---@param zoneId number +---@return table +function QuestieDB.GetSuppressedNPCs(zoneId) + local suppressed = {} + local ld = Questie.db.global.learnedData + if ld and ld.settings and ld.settings.prioritizeMyData and ld.npcs then + local threshold = ld.settings.minConfidencePins or 2 + for npcId, entry in pairs(ld.npcs) do + if entry.mc and entry.mc >= threshold and entry[7] and entry[7][zoneId] then + suppressed[npcId] = true + end + end + end + return suppressed +end + +--- Returns a table of [objectId] = true for Objects that have verified learned data (Confidence >= 2) +--- in a specific zone. Used to hide static database spawns in favor of verified ones. +---@param zoneId number +---@return table +function QuestieDB.GetSuppressedObjects(zoneId) + local suppressed = {} + local ld = Questie.db.global.learnedData + if ld and ld.settings and ld.settings.prioritizeMyData and ld.objects then + local threshold = ld.settings.minConfidencePins or 2 + for objId, entry in pairs(ld.objects) do + if entry.mc and entry.mc >= threshold and entry[4] and entry[4][zoneId] then + suppressed[objId] = true + end + end + end + return suppressed +end + ---@param preQuestGroup table ---@return boolean function QuestieDB:IsPreQuestGroupFulfilled(preQuestGroup) diff --git a/Modules/Network/QuestieLearnerComms.lua b/Modules/Network/QuestieLearnerComms.lua index bdbb521..cde9662 100644 --- a/Modules/Network/QuestieLearnerComms.lua +++ b/Modules/Network/QuestieLearnerComms.lua @@ -11,7 +11,16 @@ local AceComm = LibStub("AceComm-3.0") local addonPrefix = "QuestieLearner" local hiddenChannelName = "questiecomm" -local ProtocolVersion = 1 +local ProtocolVersion = 2 -- Increment protocol version for enhanced/sanitized data + +local time = time +local GetTime = GetTime +local math_min = math.min +local math_floor = math.floor +local math_random = math.random +local table_insert = table.insert +local table_remove = table.remove +local table_getn = table.getn -- Dev Logging Flags — defined first so all functions below can call DebugLog local LOG_CRITICAL = true @@ -123,10 +132,10 @@ function QuestieLearnerComms:Initialize() end -- Process incoming/outgoing queues - C_Timer.NewTicker(0.2, function() _QuestieLearnerComms:ProcessQueues() end) + QuestieCompat.C_Timer.NewTicker(0.2, function() _QuestieLearnerComms:ProcessQueues() end) -- Start Reinforcement Loop (every 60 seconds) - C_Timer.NewTicker(60, function() _QuestieLearnerComms:ProcessReinforcement() end) + QuestieCompat.C_Timer.NewTicker(60, function() _QuestieLearnerComms:ProcessReinforcement() end) end function _QuestieLearnerComms:ProcessReinforcement() @@ -270,5 +279,5 @@ function _QuestieLearnerComms:ProcessRawMessage(encodedMsg, sender) DebugLog("DEVELOP", "Received " .. tostring(op) .. " " .. tostring(typ) .. " " .. tostring(id) .. " from " .. tostring(sender)) - QuestieLearner:HandleNetworkData(typ, id, d) + QuestieLearner:HandleNetworkData(typ, id, d, op) end diff --git a/Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua b/Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua index b733d55..f71ea7a 100644 --- a/Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua +++ b/Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua @@ -421,19 +421,7 @@ function QuestieOptions.tabs.advanced:Initialize() order = 6, name = l10n('3.3.5 Compatibility Settings'), }, - plugin_header = { - type = "header", - order = 7, - name = "|cFF5EBAF3Loaded Questie-X Plugins|r", - }, - plugin_status_desc = { - type = "description", - order = 7.01, - fontSize = "medium", - name = function() - return "|cFF888888Plugin stats have moved to the |r|cFFFFFFFFDatabase|r|cFF888888 tab.|r" - end, - }, + }, } end diff --git a/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua b/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua index bb418f7..815afe8 100644 --- a/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua +++ b/Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua @@ -366,9 +366,34 @@ function QuestieOptions.tabs.database:Initialize() end, }, + stale_threshold = { + type = "range", + order = 5.2, + name = function() return l10n("Stale Data Threshold (Days)") end, + desc = function() return l10n("Unconfirmed learned data (seen only once) will be pruned if it hasn't been seen in this many days. Verified data is permanent.") end, + min = 1, + max = 180, + step = 1, + get = function() return (Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.staleThreshold) or 90 end, + set = function(_, val) + Questie.db.global.learnedData.settings.staleThreshold = val + end, + }, + + prune_verified = { + type = "toggle", + order = 5.3, + name = function() return l10n("Include Verified Data in Pruning") end, + desc = function() return l10n("If enabled, even high-confidence (Verified) data will be subject to redundancy pruning (e.g., if it's already in the official DB). Time-based pruning still only affects unconfirmed data.") end, + get = function() return (Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.pruneVerified) or false end, + set = function(_, val) + Questie.db.global.learnedData.settings.pruneVerified = val + end, + }, + prune_dry_btn = { type = "execute", - order = 5.2, + order = 5.4, name = function() return l10n("Dry Run (Preview)") end, desc = function() return "Print a summary of entries that would be removed, without deleting anything." end, func = function() @@ -384,10 +409,10 @@ function QuestieOptions.tabs.database:Initialize() end end, }, - + prune_btn = { type = "execute", - order = 5.3, + order = 5.5, name = function() return l10n("Prune Now") end, desc = function() return "|cFFFF8800Removes stale entries. Cannot be undone. Export first if you want a backup.|r" end, func = function() @@ -400,10 +425,10 @@ function QuestieOptions.tabs.database:Initialize() )) end, }, - + prune_all_btn = { type = "execute", - order = 5.4, + order = 5.6, name = function() return "|cFFFF4444" .. l10n("Reset All Learned Data") .. "|r" end, desc = function() return "|cFFFF0000DANGER: Wipes ALL learned data for ALL servers. Export first.|r" end, confirm = true, diff --git a/Modules/Quest/QuestieQuest.lua b/Modules/Quest/QuestieQuest.lua index b555614..a5236dc 100644 --- a/Modules/Quest/QuestieQuest.lua +++ b/Modules/Quest/QuestieQuest.lua @@ -985,22 +985,27 @@ function QuestieQuest:UpdateObjectiveNotes(quest) Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest] UpdateObjectiveNotes:", quest.Id) for objectiveIndex, objective in pairs(quest.Objectives) do - local result, err = xpcall(QuestieQuest.PopulateObjective, ERR_FUNCTION, QuestieQuest, quest, objectiveIndex, - objective, false) - if (not result) then - Questie:Debug(Questie.DEBUG_ELEVATED, "[QuestieQuest] There was an error populating objectives for", - quest.name, quest.Id, objectiveIndex, err) + -- Skip tracker-only fallback objectives — they have no DB Id and can't be populated + if objective.Type ~= "fallback" then + local result, err = xpcall(QuestieQuest.PopulateObjective, ERR_FUNCTION, QuestieQuest, quest, objectiveIndex, + objective, false) + if (not result) then + Questie:Debug(Questie.DEBUG_ELEVATED, "[QuestieQuest] There was an error populating objectives for", + quest.name, quest.Id, objectiveIndex, err) + end end end if quest.SpecialObjectives and next(quest.SpecialObjectives) then for _, objective in pairs(quest.SpecialObjectives) do - local result, err = xpcall(QuestieQuest.PopulateObjective, ERR_FUNCTION, QuestieQuest, quest, 0, objective, - true) - if not result then - Questie:Error("[QuestieQuest]: [SpecialObjectives] " .. - l10n("There was an error populating objectives for %s %s %s %s", quest.name or "No quest name", - quest.Id or "No quest id", 0 or "No objective", err or "No error")); + if objective.Type ~= "fallback" then + local result, err = xpcall(QuestieQuest.PopulateObjective, ERR_FUNCTION, QuestieQuest, quest, 0, objective, + true) + if not result then + Questie:Error("[QuestieQuest]: [SpecialObjectives] " .. + l10n("There was an error populating objectives for %s %s %s %s", quest.name or "No quest name", + quest.Id or "No quest id", 0 or "No objective", err or "No error")); + end end end end @@ -1318,6 +1323,26 @@ function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockI objectiveCenter = { x = x, y = y } end + -- Filter static spawns if prioritizeMyData is enabled and we have high-confidence learned data + if Questie.db.global.learnedData and Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.prioritizeMyData then + for zone in pairs(zones) do + local suppressed = (objectiveData.Type == "monster" and QuestieDB.GetSuppressedNPCs(zone)) or (objectiveData.Type == "object" and QuestieDB.GetSuppressedObjects(zone)) + if suppressed then + for id, spawnData in pairs(objective.spawnList) do + if suppressed[id] and spawnData.Spawns and spawnData.Spawns[zone] then + -- Only suppress if this isn't a learned spawn (learned spawns have .isLearned) + if not spawnData.isLearned then + spawnData.Spawns[zone] = nil + if not next(spawnData.Spawns) then + objective.spawnList[id] = nil + end + end + end + end + end + end + end + local iconsToDraw, _ = _DetermineIconsToDraw(quest, objective, objectiveIndex, objectiveCenter) local icon, iconPerZone = _DrawObjectiveIcons(quest.Id, iconsToDraw, objective, maxPerType) _DrawObjectiveWaypoints(objective, icon, iconPerZone) @@ -1341,7 +1366,7 @@ _RegisterObjectiveTooltips = function(objective, questId, blockItemTooltips) -- No spawnList and no Id means there is nothing Questie can draw for this objective. -- This covers server-tracked trigger objectives (e.g. "complete N quests in zone" for -- quest 50150) which may have any objectiveType from the server, not just "event". - if not objective.Id then + if not objective.Id or objective.Id == 0 then objective.hasRegisteredTooltips = true return end diff --git a/Modules/QuestieLearner.lua b/Modules/QuestieLearner.lua index de60266..38f1a16 100644 --- a/Modules/QuestieLearner.lua +++ b/Modules/QuestieLearner.lua @@ -3,12 +3,56 @@ local QuestieLearner = QuestieLoader:CreateModule("QuestieLearner") ---@type QuestieDB local QuestieDB = QuestieLoader:ImportModule("QuestieDB") +---@type QuestieQuest +local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest") +---@type QuestiePlayer +local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer") +---@type QuestLogCache +local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache") local _Learner = QuestieLearner.private or {} QuestieLearner.private = _Learner local floor = math.floor local abs = math.abs +local time = time +local tinsert = table.insert +local ipairs = ipairs +local pairs = pairs +local next = next +local type = type +local tostring = tostring +local tonumber = tonumber +local string_trim = string.trim +local string_sub = string.sub +local string_len = string.len +local string_upper = string.upper +local select = select + +-- WoW API locals +local UnitExists = UnitExists +local UnitIsVisible = UnitIsVisible +local UnitIsPlayer = UnitIsPlayer +local UnitGUID = UnitGUID +local UnitName = UnitName +local UnitLevel = UnitLevel +local UnitFactionGroup = UnitFactionGroup +local UnitReaction = UnitReaction +local UnitCreatureFamily = UnitCreatureFamily +local GetRealZoneText = GetRealZoneText +local GetTitleText = GetTitleText +local GetObjectiveText = GetObjectiveText +local GetQuestDescription = GetQuestDescription +local GetRewardText = GetRewardText +local GetQuestID = GetQuestID +local GetNumQuestLogEntries = GetNumQuestLogEntries +local GetItemInfo = GetItemInfo +local CombatLogGetCurrentEventInfo = CombatLogGetCurrentEventInfo +local CreateFrame = CreateFrame +local GetTime = GetTime + +-- Cache for zone lookup: zoneText -> areaId +_Learner.zoneCache = {} -- NPC flags (WoW bitmask) local NPC_FLAG_GOSSIP = 0x00000001 @@ -28,6 +72,9 @@ local MOUSEOVER_LEARN_FLAGS = NPC_FLAG_QUESTGIVER -- ~2 grid units ≈ 2% of zone width — keeps clusters tight without over-splitting. local COORD_GRID = 2.0 +-- Minimum match count (Confidence) for a learned pin to appear on the map. +local MIN_CONFIDENCE_PINS = 2 + _Learner.pendingNpcs = {} _Learner.pendingQuests = {} _Learner.pendingItems = {} @@ -62,16 +109,36 @@ local function CoordBucket(x, y) end -- Inserts {x, y} into coordList only when no existing point falls in the same grid bucket -local function InsertIfNewBucket(coordList, x, y) - local bx, by = CoordBucket(x, y) +local function InsertIfNewBucket(coordList, x, y, customGrid) + local grid = customGrid or COORD_GRID + local bx, by = floor(x / grid) * grid, floor(y / grid) * grid for _, coord in ipairs(coordList) do - local cx, cy = CoordBucket(coord[1], coord[2]) + local cx, cy = floor(coord[1] / grid) * grid, floor(coord[2] / grid) * grid if cx == bx and cy == by then return false end end table.insert(coordList, {x, y}) return true end +-- Detects if the current map is a "Micro-Dungeon" (small interior map) +-- This is a heuristic: if we lack map data, we default to standard grid. +local function GetCustomGridPrecision() + local uiMapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") + if not uiMapId then return COORD_GRID end + + -- Known micro-dungeons or small interior maps where 2% precision is too coarse. + -- (e.g., Northshire Abbey, Anvilmar, Crypts, etc.) + -- For now, we use a simple list of common starting sub-zones if available. + -- Or we could check map bounds if we had that data. + local microDungeons = { + [425] = 0.5, -- Northshire Abbey + [468] = 0.5, -- Anvilmar + [469] = 0.5, -- Coldridge Valley (Interior) + -- Add more as needed + } + return microDungeons[uiMapId] or COORD_GRID +end + ------------------------------------------------------------------------ -- Internal state guards ------------------------------------------------------------------------ @@ -91,6 +158,10 @@ local function EnsureLearnedData() learnQuests = true, learnItems = true, learnObjects = true, + minConfidencePins = 2, + prioritizeMyData = true, + staleThreshold = 90, -- days + pruneVerified = false, -- protect verified data by default }, } else @@ -106,6 +177,8 @@ local function EnsureLearnedData() if s.learnQuests == nil then s.learnQuests = true end if s.learnItems == nil then s.learnItems = true end if s.learnObjects == nil then s.learnObjects = true end + if s.minConfidencePins == nil then s.minConfidencePins = 2 end + if s.prioritizeMyData == nil then s.prioritizeMyData = true end end return true end @@ -124,17 +197,375 @@ function QuestieLearner:GetSettings() return Questie.db.global.learnedData.settings end +------------------------------------------------------------------------ +-- Cross-link engine +-- After ANY entity is learned, scan all other learned data and stitch +-- relationships automatically. Both learnedData (SavedVariables) and +-- live *DataOverrides tables are kept in sync. +-- +-- Schema reference: +-- NPC [7]=spawns [10]=questStarts [11]=questEnds +-- Object [2]=questStarts [3]=questEnds [4]=spawns +-- Quest [2]=startedBy{[1]=npcIds,[2]=objIds,[3]=itemIds} +-- [3]=finishedBy{[1]=npcIds,[2]=objIds} +-- [10]=objectives{[1]={{npcId,text},...},[2]={{objId,text},...},[3]={{itemId,text},...}} +-- [11]=sourceItemId [17]=zoneOrSort +-- Item [2]=dropNpcs{npcId,...} [9]=questSource (questId that gives this item) +------------------------------------------------------------------------ + +-- Add value to array tbl[key] if not already present. Mirrors to live override table. +local function _AddToArray(tbl, key, value, ovrTable, ovrId) + if not tbl then return end + tbl[key] = tbl[key] or {} + for _, v in ipairs(tbl[key]) do if v == value then return end end + table.insert(tbl[key], value) + if ovrTable and ovrId then + local ovr = ovrTable[ovrId] or {} + ovrTable[ovrId] = ovr + ovr[key] = ovr[key] or {} + for _, v in ipairs(ovr[key]) do if v == value then return end end + table.insert(ovr[key], value) + end +end + +-- Add value to nested array tbl[outerKey][innerKey] if not already present. +local function _AddToNestedArray(tbl, outerKey, innerKey, value, ovrTable, ovrId) + if not tbl then return end + tbl[outerKey] = tbl[outerKey] or {} + tbl[outerKey][innerKey] = tbl[outerKey][innerKey] or {} + for _, v in ipairs(tbl[outerKey][innerKey]) do if v == value then return end end + table.insert(tbl[outerKey][innerKey], value) + if ovrTable and ovrId then + local ovr = ovrTable[ovrId] or {} + ovrTable[ovrId] = ovr + ovr[outerKey] = ovr[outerKey] or {} + ovr[outerKey][innerKey] = ovr[outerKey][innerKey] or {} + for _, v in ipairs(ovr[outerKey][innerKey]) do if v == value then return end end + table.insert(ovr[outerKey][innerKey], value) + end +end + +-- Add {id, text} pair to quest objectives slot (quest[10][slot]). +local function _AddToQuestObjective(qData, slot, entityId, text, ovrTable, questId) + if not qData then return end + qData[10] = qData[10] or {} + qData[10][slot] = qData[10][slot] or {} + for _, entry in ipairs(qData[10][slot]) do if entry[1] == entityId then return end end + table.insert(qData[10][slot], { entityId, text or "" }) + if ovrTable and questId then + local ovr = ovrTable[questId] or {} + ovrTable[questId] = ovr + ovr[10] = ovr[10] or {} + ovr[10][slot] = ovr[10][slot] or {} + for _, entry in ipairs(ovr[10][slot]) do if entry[1] == entityId then return end end + table.insert(ovr[10][slot], { entityId, text or "" }) + end +end + +local function _GetDB() return Questie.db.global.learnedData end + +-- Triggers QuestieQuest:UpdateQuest for every active quest in the player's log +-- that is referenced in the provided set (table with questId keys). +-- Called after cross-linking so map pins refresh immediately. +local function _RefreshActiveQuestPins(questIdSet) + if not QuestieQuest or not QuestieQuest.UpdateQuest then return end + if not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end + local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer) + for questId in pairs(questIdSet) do + if QuestiePlayer.currentQuestlog[questId] then + if timer then + timer.After(0.1, function() QuestieQuest:UpdateQuest(questId) end) + else + QuestieQuest:UpdateQuest(questId) + end + end + end +end + +------------------------------------------------------------------------ +-- CrossLinkAfterNPC: called when a new NPC is first learned. +-- Scans all learned quests for any reference to this npcId and stitches +-- back-links in both directions. +local function CrossLinkAfterNPC(npcId) + local learned = _GetDB() + local npcData = learned.npcs[npcId] + if not npcData then return end + local npcOvr = QuestieDB and QuestieDB.npcDataOverrides + + for questId, qData in pairs(learned.quests) do + local qOvr = QuestieDB and QuestieDB.questDataOverrides + + -- Quest starters: quest[2][1] lists NPCs that start this quest + if qData[2] and qData[2][1] then + for _, id in ipairs(qData[2][1]) do + if id == npcId then + _AddToArray(npcData, 10, questId, npcOvr, npcId) + break + end + end + end + -- Quest finishers: quest[3][1] + if qData[3] and qData[3][1] then + for _, id in ipairs(qData[3][1]) do + if id == npcId then + _AddToArray(npcData, 11, questId, npcOvr, npcId) + break + end + end + end + -- Creature objectives: quest[10][1] — this NPC is a kill target + -- (no back-link needed; NPC spawn data already linked via spawns[7]) + + -- Item objective drop chain: quest[10][3] lists items; if any item's + -- drop list (item[2]) includes this NPC, mark NPC as creature source. + if qData[10] and qData[10][3] then + for _, entry in ipairs(qData[10][3]) do + local itemId = entry[1] + local iData = learned.items[itemId] + if iData and iData[2] then + for _, dropNpc in ipairs(iData[2]) do + if dropNpc == npcId then + -- NPC drops a quest objective item → add as creature objective + _AddToQuestObjective(qData, 1, npcId, nil, qOvr, questId) + break + end + end + end + end + end + end + + -- Refresh map pins for any active quests now linked to this NPC + local activeRefs = {} + if learned.quests then + for questId, qData in pairs(learned.quests) do + local refs = (qData[2] and qData[2][1]) or {} + for _, id in ipairs(refs) do if id == npcId then activeRefs[questId] = true end end + refs = (qData[3] and qData[3][1]) or {} + for _, id in ipairs(refs) do if id == npcId then activeRefs[questId] = true end end + if qData[10] and qData[10][1] then + for _, entry in ipairs(qData[10][1]) do + if entry[1] == npcId then activeRefs[questId] = true end + end + end + end + end + _RefreshActiveQuestPins(activeRefs) +end + +------------------------------------------------------------------------ +-- CrossLinkAfterQuest: called when a new quest is first learned. +-- Stitches NPCs, objects, and items referenced in the quest data. +local function CrossLinkAfterQuest(questId) + local learned = _GetDB() + local qData = learned.quests[questId] + if not qData then return end + local qOvr = QuestieDB and QuestieDB.questDataOverrides + local npcOvr = QuestieDB and QuestieDB.npcDataOverrides + local objOvr = QuestieDB and QuestieDB.objectDataOverrides + + -- Starter NPCs: quest[2][1] → npc[10] + if qData[2] and qData[2][1] then + for _, npcId in ipairs(qData[2][1]) do + if learned.npcs[npcId] then + _AddToArray(learned.npcs[npcId], 10, questId, npcOvr, npcId) + end + end + end + -- Starter objects: quest[2][2] → obj[2] + if qData[2] and qData[2][2] then + for _, objId in ipairs(qData[2][2]) do + if learned.objects[objId] then + _AddToArray(learned.objects[objId], 2, questId, objOvr, objId) + end + end + end + -- Finisher NPCs: quest[3][1] → npc[11] + if qData[3] and qData[3][1] then + for _, npcId in ipairs(qData[3][1]) do + if learned.npcs[npcId] then + _AddToArray(learned.npcs[npcId], 11, questId, npcOvr, npcId) + end + end + end + -- Finisher objects: quest[3][2] → obj[3] + if qData[3] and qData[3][2] then + for _, objId in ipairs(qData[3][2]) do + if learned.objects[objId] then + _AddToArray(learned.objects[objId], 3, questId, objOvr, objId) + end + end + end + -- Source item: quest[11] → item[5] (item starts this quest, via startQuest key) + if qData[11] and qData[11] > 0 then + local iData = learned.items[qData[11]] + if iData then + if not iData[5] then + iData[5] = questId + if QuestieDB and QuestieDB.itemDataOverrides then + local ovr = QuestieDB.itemDataOverrides[qData[11]] or {} + QuestieDB.itemDataOverrides[qData[11]] = ovr + if not ovr[5] then ovr[5] = questId end + end + end + end + end + -- Item drop chain: quest has item objectives [10][3]; if any of those + -- items have known drop NPCs (item[2]), add those NPCs as creature objectives. + if qData[10] and qData[10][3] then + for _, entry in ipairs(qData[10][3]) do + local itemId = entry[1] + local iData = learned.items[itemId] + if iData and iData[2] then + for _, dropNpcId in ipairs(iData[2]) do + _AddToQuestObjective(qData, 1, dropNpcId, nil, qOvr, questId) + end + end + end + end +end + +------------------------------------------------------------------------ +-- CrossLinkAfterObject: called when a new object is first learned. +-- Scans all learned quests for references to this objectId. +local function CrossLinkAfterObject(objectId) + local learned = _GetDB() + local objData = learned.objects[objectId] + if not objData then return end + local objOvr = QuestieDB and QuestieDB.objectDataOverrides + local qOvr = QuestieDB and QuestieDB.questDataOverrides + + for questId, qData in pairs(learned.quests) do + -- Object starters: quest[2][2] + if qData[2] and qData[2][2] then + for _, id in ipairs(qData[2][2]) do + if id == objectId then + _AddToArray(objData, 2, questId, objOvr, objectId) + break + end + end + end + -- Object finishers: quest[3][2] + if qData[3] and qData[3][2] then + for _, id in ipairs(qData[3][2]) do + if id == objectId then + _AddToArray(objData, 3, questId, objOvr, objectId) + break + end + end + end + -- Object objectives: quest[10][2] — this object is an interact target + -- Coords are already stored in object spawns; no extra link needed + end + + -- Refresh map pins for active quests now linked to this object + local activeRefs = {} + for questId, qData in pairs(learned.quests) do + local function checkList(list) + if list then for _, id in ipairs(list) do if id == objectId then activeRefs[questId] = true end end end + end + checkList(qData[2] and qData[2][2]) + checkList(qData[3] and qData[3][2]) + if qData[10] and qData[10][2] then + for _, entry in ipairs(qData[10][2]) do + if entry[1] == objectId then activeRefs[questId] = true end + end + end + end + _RefreshActiveQuestPins(activeRefs) +end + +------------------------------------------------------------------------ +-- CrossLinkAfterItem: called when an item is first learned or when a +-- new drop-NPC relationship is added to an item. +-- Links drop NPCs → quest creature objectives for any quest needing this item. +local function CrossLinkAfterItem(itemId) + local learned = _GetDB() + local iData = learned.items[itemId] + if not iData then return end + local qOvr = QuestieDB and QuestieDB.questDataOverrides + + -- If this item starts a quest (item[5]=startQuest), ensure that quest knows + -- about it via quest[2][3] (starter items slot) + for questId, qData in pairs(learned.quests) do + if qData[11] == itemId then + if not iData[5] then + iData[5] = questId + if QuestieDB and QuestieDB.itemDataOverrides then + local ovr = QuestieDB.itemDataOverrides[itemId] or {} + QuestieDB.itemDataOverrides[itemId] = ovr + if not ovr[5] then ovr[5] = questId end + end + end + end + -- If any quest has this item as an objective (quest[10][3]), + -- and we know NPCs that drop it (item[2]), add those NPCs as creature objectives. + if qData[10] and qData[10][3] then + for _, entry in ipairs(qData[10][3]) do + if entry[1] == itemId and iData[2] then + for _, dropNpcId in ipairs(iData[2]) do + _AddToQuestObjective(qData, 1, dropNpcId, nil, qOvr, questId) + end + end + end + end + end +end + +------------------------------------------------------------------------ +-- CrossLinkAfterQuestGiver: called when a starter/finisher relationship +-- is explicitly recorded. Stitches both the NPC→quest and quest→NPC +-- directions (and objects/items if typeSlot indicates them). +local function CrossLinkAfterQuestGiver(questId, entityId, typeSlot, isStart) + local learned = _GetDB() + local qData = learned.quests[questId] + local npcOvr = QuestieDB and QuestieDB.npcDataOverrides + local objOvr = QuestieDB and QuestieDB.objectDataOverrides + local qOvr = QuestieDB and QuestieDB.questDataOverrides + + if typeSlot == 1 then + -- NPC ↔ quest + local npcData = learned.npcs[entityId] + if npcData then + _AddToArray(npcData, isStart and 10 or 11, questId, npcOvr, entityId) + end + if qData then + _AddToNestedArray(qData, isStart and 2 or 3, 1, entityId, qOvr, questId) + end + elseif typeSlot == 2 then + -- Object ↔ quest + local objData = learned.objects[entityId] + if objData then + _AddToArray(objData, isStart and 2 or 3, questId, objOvr, entityId) + end + if qData then + _AddToNestedArray(qData, isStart and 2 or 3, 2, entityId, qOvr, questId) + end + elseif typeSlot == 3 then + -- Item ↔ quest starter (item[3] = starts quest; quest[2][3]) + if qData then + _AddToNestedArray(qData, 2, 3, entityId, qOvr, questId) + end + end +end + ------------------------------------------------------------------------ -- NPC learning ------------------------------------------------------------------------ -function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString) +function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString, spawnX, spawnY, spawnZoneId) if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnNpcs then return end if not npcId or npcId <= 0 then return end - local zoneId = GetZoneId() - local x, y = GetPlayerCoords() + -- Use provided spawn coords (e.g. from kill event) or fall back to current player position + local zoneId = spawnZoneId or GetZoneId() + local x, y + if spawnX and spawnY then + x, y = spawnX, spawnY + else + x, y = GetPlayerCoords() + end local existing = Questie.db.global.learnedData.npcs[npcId] local isNew = existing == nil @@ -151,18 +582,43 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS if zoneId and zoneId > 0 and not existing[9] then existing[9] = zoneId end if factionString and not existing[13] then existing[13] = factionString end if subName and not existing[14] then existing[14] = subName end - if npcFlags and npcFlags > 0 and not existing[15] then existing[15] = npcFlags end - if x and y and zoneId and zoneId > 0 then existing[7] = existing[7] or {} existing[7][zoneId] = existing[7][zoneId] or {} InsertIfNewBucket(existing[7][zoneId], x, y) end + existing.ls = time() -- Update last seen existing.mc = (existing.mc or 0) + 1 + local threshold = (Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.minConfidencePins) or MIN_CONFIDENCE_PINS + + -- Live injection: update npcDataOverrides only if confidence threshold is met + if existing.mc >= threshold and QuestieDB and QuestieDB.npcDataOverrides then + local ovr = QuestieDB.npcDataOverrides[npcId] + if not ovr then + QuestieDB.npcDataOverrides[npcId] = existing + else + -- Merge: fill missing fields only + for k, v in pairs(existing) do + if ovr[k] == nil then ovr[k] = v end + end + -- Always merge spawn coords + if existing[7] then + ovr[7] = ovr[7] or {} + for zid, coords in pairs(existing[7]) do + ovr[7][zid] = ovr[7][zid] or {} + for _, coord in ipairs(coords) do + InsertIfNewBucket(ovr[7][zid], coord[1], coord[2]) + end + end + end + end + end + if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New NPC learned:", npcId, name or "?") + CrossLinkAfterNPC(npcId) end _Learner:BroadcastIfCommsAvailable("NPC", npcId, existing) end @@ -195,6 +651,8 @@ function QuestieLearner:LearnQuest(questId, data) Questie.db.global.learnedData.quests[questId] = existing end + existing.ls = time() -- Update last seen + for k, v in pairs(data) do if v ~= nil and v ~= "" and v ~= 0 and existing[k] == nil then existing[k] = v @@ -203,8 +661,21 @@ function QuestieLearner:LearnQuest(questId, data) existing.mc = (existing.mc or 0) + 1 + -- Live injection into questDataOverrides so GetQuest works without reload + if QuestieDB and QuestieDB.questDataOverrides then + local ovr = QuestieDB.questDataOverrides[questId] + if not ovr then + QuestieDB.questDataOverrides[questId] = existing + else + for k, v in pairs(existing) do + if ovr[k] == nil then ovr[k] = v end + end + end + end + if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New quest learned:", questId, existing[1] or "?") + CrossLinkAfterQuest(questId) end _Learner:BroadcastIfCommsAvailable("QUEST", questId, existing) end @@ -232,6 +703,80 @@ function QuestieLearner:LearnQuestGiver(questId, entityId, entityType, isStart) if id == entityId then return end end table.insert(list, entityId) + + -- Live injection into questDataOverrides so starters/finishers take effect without reload + if QuestieDB and QuestieDB.questDataOverrides then + local ovr = QuestieDB.questDataOverrides[questId] or {} + QuestieDB.questDataOverrides[questId] = ovr + ovr[field] = ovr[field] or {} + ovr[field][typeSlot] = ovr[field][typeSlot] or {} + local ovrList = ovr[field][typeSlot] + local found = false + for _, id in ipairs(ovrList) do + if id == entityId then found = true; break end + end + if not found then table.insert(ovrList, entityId) end + end + + -- Cross-link both directions for all entity types + CrossLinkAfterQuestGiver(questId, entityId, typeSlot, isStart) +end + +------------------------------------------------------------------------ +-- Quest objective NPC learning (kill objectives) +------------------------------------------------------------------------ + +-- Adds npcId as a creatureObjective for questId ([10][1] in questKeys schema). +-- If the NPC already exists in the base DB the spawn data is already there; +-- we only need the quest to reference it so tooltips/map-pins get registered. +function QuestieLearner:LearnQuestObjectiveNPC(questId, npcId, objText) + if not self:IsEnabled() then return end + if not Questie.db.global.learnedData.settings.learnQuests then return end + if not questId or questId <= 0 or not npcId or npcId <= 0 then return end + + -- 1. Persist to SavedVariables + local existing = Questie.db.global.learnedData.quests[questId] or {} + Questie.db.global.learnedData.quests[questId] = existing + existing[10] = existing[10] or {} + existing[10][1] = existing[10][1] or {} -- creatureObjective slot + local alreadyInSV = false + for _, entry in ipairs(existing[10][1]) do + if entry[1] == npcId then alreadyInSV = true; break end + end + if not alreadyInSV then + table.insert(existing[10][1], { npcId, objText or "" }) + end + + -- 2. Apply to live questDataOverrides immediately (no reload needed) + if QuestieDB and QuestieDB.questDataOverrides then + local ovr = QuestieDB.questDataOverrides[questId] or {} + QuestieDB.questDataOverrides[questId] = ovr + ovr[10] = ovr[10] or {} + ovr[10][1] = ovr[10][1] or {} + local alreadyPresent = false + for _, entry in ipairs(ovr[10][1]) do + if entry[1] == npcId then alreadyPresent = true; break end + end + if not alreadyPresent then + table.insert(ovr[10][1], { npcId, objText or "" }) + end + end + + -- 3. Re-process the quest so PopulateObjective registers tooltips & map pins + if QuestieQuest and QuestieQuest.UpdateQuest then + QuestieCompat.C_Timer.After(0.5, function() + QuestieQuest:UpdateQuest(questId) + end) + end + + -- 3. Register with tooltip system immediately + local QuestieTooltips = QuestieLoader:ImportModule("QuestieTooltips") + if QuestieTooltips and QuestieTooltips.RegisterObjectiveTooltip then + QuestieTooltips:RegisterObjectiveTooltip(questId, "m_" .. npcId, { Index = 0, Description = objText or "Learned Objective", Update = function() end }) + end + + Questie:Debug(Questie.DEBUG_LEARNER, + "[QuestieLearner] Quest", questId, "objective NPC learned:", npcId, objText) end ------------------------------------------------------------------------ @@ -253,13 +798,26 @@ function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemCl if name and not existing[1] then existing[1] = name end if itemLevel and itemLevel > 0 and not existing[9] then existing[9] = itemLevel end if requiredLevel and requiredLevel > 0 and not existing[10] then existing[10] = requiredLevel end - if itemClass and not existing[12] then existing[12] = itemClass end if itemSubClass and not existing[13] then existing[13] = itemSubClass end - + + existing.ls = time() -- Update last seen existing.mc = (existing.mc or 0) + 1 + -- Live injection into itemDataOverrides so QueryItemSingle works without reload + if QuestieDB and QuestieDB.itemDataOverrides then + local ovr = QuestieDB.itemDataOverrides[itemId] + if not ovr then + QuestieDB.itemDataOverrides[itemId] = existing + else + for k, v in pairs(existing) do + if ovr[k] == nil then ovr[k] = v end + end + end + end + if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New item learned:", itemId, name or "?") + CrossLinkAfterItem(itemId) end _Learner:BroadcastIfCommsAvailable("ITEM", itemId, existing) end @@ -275,11 +833,28 @@ function QuestieLearner:LearnItemDrop(itemId, npcId) Questie.db.global.learnedData.items[itemId] = existing end + existing.ls = time() -- Update last seen + existing[2] = existing[2] or {} for _, id in ipairs(existing[2]) do if id == npcId then return end end table.insert(existing[2], npcId) + + -- Live injection: sync drop list to itemDataOverrides + if QuestieDB and QuestieDB.itemDataOverrides then + local ovr = QuestieDB.itemDataOverrides[itemId] or {} + QuestieDB.itemDataOverrides[itemId] = ovr + ovr[2] = ovr[2] or {} + local found = false + for _, id in ipairs(ovr[2]) do + if id == npcId then found = true; break end + end + if not found then table.insert(ovr[2], npcId) end + end + + -- New drop relationship: re-run item cross-link to chain drop NPC → quest objectives + CrossLinkAfterItem(itemId) end ------------------------------------------------------------------------ @@ -309,11 +884,34 @@ function QuestieLearner:LearnObject(objectId, name) existing[4][zoneId] = existing[4][zoneId] or {} InsertIfNewBucket(existing[4][zoneId], x, y) end - + + existing.ls = time() -- Update last seen existing.mc = (existing.mc or 0) + 1 + -- Live injection into objectDataOverrides so QueryObjectSingle works without reload + if QuestieDB and QuestieDB.objectDataOverrides then + local ovr = QuestieDB.objectDataOverrides[objectId] + if not ovr then + QuestieDB.objectDataOverrides[objectId] = existing + else + for k, v in pairs(existing) do + if ovr[k] == nil then ovr[k] = v end + end + if existing[4] then + ovr[4] = ovr[4] or {} + for zid, coords in pairs(existing[4]) do + ovr[4][zid] = ovr[4][zid] or {} + for _, coord in ipairs(coords) do + InsertIfNewBucket(ovr[4][zid], coord[1], coord[2]) + end + end + end + end + end + if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New object learned:", objectId, name or "?") + CrossLinkAfterObject(objectId) end _Learner:BroadcastIfCommsAvailable("OBJECT", objectId, existing) end @@ -322,13 +920,47 @@ end -- InjectLearnedData — pushes learnedData into QuestieDB overrides ------------------------------------------------------------------------ +function QuestieLearner:Sanitize(data) + if not data or type(data) ~= "table" then return end + + -- De-duplicate coordinates if any + -- NPCs: key 7, Objects: key 4 + for _, coordKey in ipairs({7, 4}) do + if data[coordKey] and type(data[coordKey]) == "table" then + for zoneId, coords in pairs(data[coordKey]) do + local unique = {} + local grid = COORD_GRID -- use standard for static sanitization + for _, c in ipairs(coords) do + local bx, by = floor(c[1] / grid) * grid, floor(c[2] / grid) * grid + local key = bx .. "," .. by + if not unique[key] then + unique[key] = c + end + end + local newList = {} + for _, c in pairs(unique) do table.insert(newList, c) end + data[coordKey][zoneId] = newList + end + end + end + + -- Trim name/text strings + if data[1] and type(data[1]) == "string" then + data[1] = string.trim(data[1]) + end + + return data +end + function QuestieLearner:InjectLearnedData() if not EnsureLearnedData() then return end local learned = Questie.db.global.learnedData local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0 + -- 1. NPCs for npcId, data in pairs(learned.npcs) do + self:Sanitize(data) if not QuestieDB.npcDataOverrides[npcId] then QuestieDB.npcDataOverrides[npcId] = data npcCount = npcCount + 1 @@ -343,23 +975,63 @@ function QuestieLearner:InjectLearnedData() end end end - end - end - - for questId, data in pairs(learned.quests) do - if not QuestieDB.questDataOverrides[questId] then - QuestieDB.questDataOverrides[questId] = data - questCount = questCount + 1 - else - local existing = QuestieDB.questDataOverrides[questId] + -- Adopt other fields if missing for k, v in pairs(data) do - if k ~= "mc" and existing[k] == nil then + if k ~= "mc" and k ~= 7 and existing[k] == nil then existing[k] = v end end end end + -- 2. Quests + for questId, data in pairs(learned.quests) do + self:Sanitize(data) + -- Legacy cleanup for malformed objective data + if data[10] ~= nil then + local ok = type(data[10]) == "table" + if ok then + for _, v in pairs(data[10]) do + if type(v) ~= "table" then ok = false; break end + end + end + if not ok then data[10] = nil end + end + if data[8] ~= nil and type(data[8]) ~= "table" then + data[8] = nil + end + + if not QuestieDB.questDataOverrides[questId] then + QuestieDB.questDataOverrides[questId] = data + questCount = questCount + 1 + else + local existing = QuestieDB.questDataOverrides[questId] + for k, v in pairs(data) do + if k ~= "mc" then + if k == 10 then + -- Special merge: add learned creatureObjective entries to [10][1] + existing[10] = existing[10] or {} + existing[10][1] = existing[10][1] or {} + if type(v[1]) == "table" then + for _, entry in ipairs(v[1]) do + local found = false + for _, ex in ipairs(existing[10][1]) do + if ex[1] == entry[1] then found = true; break end + end + if not found then + tinsert(existing[10][1], entry) + end + end + end + elseif existing[k] == nil then + existing[k] = v + end + end + end + end + end + + -- 3. Items for itemId, data in pairs(learned.items) do if not QuestieDB.itemDataOverrides[itemId] then QuestieDB.itemDataOverrides[itemId] = data @@ -367,7 +1039,9 @@ function QuestieLearner:InjectLearnedData() end end + -- 4. Objects for objectId, data in pairs(learned.objects) do + self:Sanitize(data) if not QuestieDB.objectDataOverrides[objectId] then QuestieDB.objectDataOverrides[objectId] = data objectCount = objectCount + 1 @@ -382,6 +1056,12 @@ function QuestieLearner:InjectLearnedData() end end end + -- Adopt other fields + for k, v in pairs(data) do + if k ~= "mc" and k ~= 4 and existing[k] == nil then + existing[k] = v + end + end end end @@ -475,7 +1155,7 @@ local function GetIdAndTypeFromGUID(guid) if t then local low32 = tonumber(string.sub(guid, 11, 18), 16) if low32 then - local nid = math.mod(low32, 8388608) + local nid = low32 % 8388608 if nid > 0 then return nid, t end end end @@ -542,8 +1222,18 @@ function QuestieLearner:OnMouseoverUnit() local name = UnitName("mouseover") local level = UnitLevel("mouseover") + local zoneText = GetRealZoneText() + local areaId = _Learner.zoneCache[zoneText] + if not areaId then + local l10n = QuestieLoader:ImportModule("l10n") + areaId = l10n:GetAreaIdByLocalName(zoneText) + if areaId then + _Learner.zoneCache[zoneText] = areaId + end + end local subName = UnitCreatureFamily and UnitCreatureFamily("mouseover") or nil local reaction = UnitReaction("mouseover", "player") + local factionString = nil if reaction then if reaction >= 5 then @@ -651,42 +1341,65 @@ end -- Fires when a quest is accepted. -- Ascension 3.3.5 passes the quest log index as the first arg; some builds pass questID directly. -- We detect which by checking if the value could be a log index and resolving via GetQuestLogTitle. -function QuestieLearner:OnQuestAccepted(firstArg) - local questId = firstArg - local maxLog = GetNumQuestLogEntries and GetNumQuestLogEntries() or 25 - -- If arg looks like a log index (small int ≤ log size), resolve to quest ID via GetQuestLogTitle - if firstArg and firstArg > 0 and firstArg <= maxLog then - local resolvedId = select(8, GetQuestLogTitle(firstArg)) - if resolvedId and resolvedId > 0 then - questId = resolvedId +function QuestieLearner:OnQuestAccepted(firstArg, secondArg) + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestAccepted raw args: first=" .. tostring(firstArg) .. " second=" .. tostring(secondArg)) + local questId + + -- Try secondArg first (WotLK standard: logIndex, questID) + if secondArg and type(secondArg) == "number" and secondArg > 0 then + questId = secondArg + end + + -- If secondArg was nil/0, firstArg might already be the questID (some 3.3.5 servers), + -- or it's the log index — try resolving it from the log. + if not questId or questId <= 0 then + local maxLog = GetNumQuestLogEntries and GetNumQuestLogEntries() or 25 + if firstArg and type(firstArg) == "number" and firstArg > 0 then + -- If firstArg looks like a log index (small number), look it up + if firstArg <= maxLog then + local resolvedId = QuestieCompat.GetQuestIDFromLogIndex(firstArg) + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestAccepted resolved from log index", firstArg, "->", tostring(resolvedId)) + if resolvedId and resolvedId > 0 then + questId = resolvedId + end + end + -- Still no questId: scan entire log for recently added quests + if not questId or questId <= 0 then + questId = firstArg -- last resort, may be wrong + end end end + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestAccepted id=" .. tostring(questId)) if not questId or questId <= 0 then return end -- Build data table from quest log (scan for matching entry) + -- Only store fields that match the questKeys schema (name=1, questLevel=5). + -- Do NOT store objectives (key 10) as raw text — the DB compiler expects structured + -- {creatureId, text} tuples; plain strings crash pairs() in GetQuest. local data = {} for i = 1, GetNumQuestLogEntries() do - local title, level, _, _, isHeader, _, _, _, id = GetQuestLogTitle(i) + local title, level, _, isHeader, _, _, _, id = QuestieCompat.GetQuestLogTitle(i) if not isHeader and id == questId then data[1] = title data[5] = level and level > 0 and level or nil - local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(i) or 0 - if numObj > 0 then - local objList = {} - for j = 1, numObj do - local objText = GetQuestLogLeaderBoard(j, i) - if objText then objList[#objList + 1] = objText end - end - if #objList > 0 then data[10] = objList end - end break end end - -- Zone: record current map zone - local zoneId = GetZoneId() - if zoneId and zoneId > 0 then data[8] = zoneId end + -- Zone: reverse-lookup from GetRealZoneText() which is always accurate on 3.3.5. + local zoneText = GetRealZoneText() + if zoneText and zoneText ~= "" then + for _, zoneTable in pairs(l10n.zoneLookup) do + for areaId, name in pairs(zoneTable) do + if name == zoneText then + data[17] = areaId + break + end + end + if data[17] then break end + end + end self:LearnQuest(questId, data) @@ -802,6 +1515,38 @@ function QuestieLearner:OnGossipShow() end end +function QuestieLearner:LearnSpellCast(spellId, spellName, dstGUID, dstName) + if not spellId or not spellName then return end + + local npcId = dstGUID and GetNpcIdFromGUID(dstGUID) + local objId = dstGUID and GetObjectIdFromGUID(dstGUID) + + -- Check if this spell is a quest objective + for i = 1, GetNumQuestLogEntries() do + local _, _, _, isHeader, _, _, _, questId = QuestieCompat.GetQuestLogTitle(i) + if not isHeader and questId and questId > 0 then + local quest = QuestLogCache.GetQuest(questId) + if quest and quest.objectives then + for _, obj in pairs(quest.objectives) do + -- If the objective is a spell or requires this spell + if obj.type == "spell" and obj.text and obj.text:find(spellName, 1, true) then + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Learning spell cast:", spellId, spellName, "on", dstName or "nil") + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Found spell objective match for quest", questId) + local data = { [10] = { [1] = {} } } + if npcId then + tinsert(data[10][1], { npcId, spellName }) + elseif objId then + -- Store object as target if applicable + tinsert(data[10][1], { -objId, spellName }) + end + self:LearnQuest(questId, data) + end + end + end + end + end +end + -- Resolves pending item info once the client has cached it function QuestieLearner:OnGetItemInfoReceived(itemId) if not _Learner.pendingItemLinks then return end @@ -829,56 +1574,212 @@ end -- Combat log: kill tracking with GUID-keyed cache ------------------------------------------------------------------------ --- Returns true if npcId is referenced in any active quest objective (monster kill type) -local function IsQuestObjectiveNpc(npcId) - if not QuestieDB then return false end - -- Check if this NPC appears in DB as a quest NPC (spawns field [7] or quest objectives) - local dbNpc = QuestieDB.GetNPC and QuestieDB:GetNPC(npcId) - if dbNpc then return true end - -- Check npcDataOverrides (from learner or plugins) - if QuestieDB.npcDataOverrides and QuestieDB.npcDataOverrides[npcId] then return true end - return false +-- Extract the NPC entry ID from a GUID string. +-- Supports both modern string format (Creature-0-...-entryID) and +-- 3.3.5a/Ascension hex format (0x[4-char prefix][6-char entryID][spawn]). +-- Logic mirrors DataExporter's DE:GetCreatureIDFromGUID. +local function GetNpcIdFromGUID(guid) + if not guid or type(guid) ~= "string" then return nil end + + -- Modern string format: "Creature-0-XXXX-XXXX-XXXX-entryID-XXXX" + local strId = guid:match("Creature%-%d+%-%d+%-%d+%-%d+%-(%d+)") + if strId then return tonumber(strId) end + + -- 3.3.5a / Ascension hex format: 0x[prefix:4][entryID:6][spawn:...] + if guid:match("^0x") then + local hex = guid:sub(3) + local prefix = hex:sub(1, 4) + + -- Known creature prefixes (F130/F131 = standard WotLK, F110/F111 = Ascension) + local isCreature = ( + prefix == "F130" or prefix == "F131" or + prefix == "F110" or prefix == "F111" or + prefix == "F150" or prefix == "F151" or + (prefix:sub(1,1) == "F" and prefix ~= "F140" and prefix ~= "F141") + ) + if not isCreature then return nil end + + -- Entry ID sits at hex chars 5-10 (6 hex chars = 24-bit field) + if #hex >= 10 then + local id = tonumber(hex:sub(5, 10), 16) + if id and id > 0 then return id end + end + -- Fallback for shorter GUIDs + if #hex >= 8 then + local id = tonumber(hex:sub(5, 8), 16) + if id and id > 0 then return id end + end + end + + return nil end +-- Same logic for game objects (interactable quest objects) +local function GetObjectIdFromGUID(guid) + if not guid or type(guid) ~= "string" then return nil end + + local strId = guid:match("GameObject%-%d+%-%d+%-%d+%-%d+%-(%d+)") + if strId then return tonumber(strId) end + + if guid:match("^0x") then + local hex = guid:sub(3) + if #hex >= 10 then + local id = tonumber(hex:sub(5, 10), 16) + if id and id > 0 then return id end + end + if #hex >= 8 then + local id = tonumber(hex:sub(5, 8), 16) + if id and id > 0 then return id end + end + end + + return nil +end + +-- Cache recent kills: guid → {npcId, name, x, y, zoneId, ts} +_Learner.recentKills = _Learner.recentKills or {} +-- Previous objective counts for active quests: questId → {[idx] = count} +_Learner.prevObjCounts = _Learner.prevObjCounts or {} + function QuestieLearner:OnCombatLogEvent(...) - local args = { CombatLogGetCurrentEventInfo and CombatLogGetCurrentEventInfo() or ... } - local event = args[2] - local destGUID = args[8] - local destName = args[9] + local timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName = ... + -- In some versions (Retail/WotLK), we should use CombatLogGetCurrentEventInfo() + if not timestamp and CombatLogGetCurrentEventInfo then + timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName = CombatLogGetCurrentEventInfo() + end - if event ~= "UNIT_DIED" or not destGUID then return end + if eventType == "SPELL_CAST_SUCCESS" then + if srcGUID == UnitGUID("player") then + self:LearnSpellCast(spellId, spellName, dstGUID, dstName) + end + return + end - local npcId = GetNpcIdFromGUID(destGUID) + if eventType ~= "PARTY_KILL" and eventType ~= "UNIT_DIED" then return end + if not dstGUID then return end + + local npcId = GetNpcIdFromGUID(dstGUID) - -- Fallback: GUID-keyed cache from OnTargetChanged / OnMouseoverUnit if not npcId and _Learner.guidNpcCache then - local cached = _Learner.guidNpcCache[destGUID] + local cached = _Learner.guidNpcCache[dstGUID] if cached then npcId = cached.npcId - if not destName then destName = cached.name end + if not dstName then dstName = cached.name end end end if not npcId or npcId <= 0 then return end - -- Only record kill coordinates for NPCs that are known quest objective targets - -- (already in DB, or previously cached from quest interaction). - -- This avoids polluting the learner with every random mob kill. - local isCached = _Learner.guidNpcCache and _Learner.guidNpcCache[destGUID] ~= nil - if not isCached and not IsQuestObjectiveNpc(npcId) then return end + local _mapId, px, py = QuestieCompat.GetCurrentPlayerPosition() + if px and py and px > 0 and py > 0 then + px = floor(px * 10000) / 100 + py = floor(py * 10000) / 100 + end + local zoneId = GetZoneId() + local zoneText = GetRealZoneText and GetRealZoneText() or "" + _Learner.recentKills[dstGUID] = { + npcId = npcId, + name = dstName or "", + x = px, + y = py, + zoneId = zoneId, + zone = zoneText, + ts = time(), + } + + if dstName and dstName ~= "" then + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Kill cached for correlation:", npcId, dstName, "@", tostring(px), tostring(py), "zone", tostring(zoneId)) + end -- TTL cleanup: drop entries older than 10 minutes - if _Learner.guidNpcCache then - local now = time() - for g, cached in pairs(_Learner.guidNpcCache) do - if (now - (cached.ts or 0)) > 600 then - _Learner.guidNpcCache[g] = nil + local now = time() + for g, entry in pairs(_Learner.recentKills) do + if (now - (entry.ts or 0)) > 600 then + _Learner.recentKills[g] = nil + end + end +end + +-- Periodic cleanup for guidNpcCache to prevent unbounded growth +function QuestieLearner:PruneGuidNpcCache() + if not _Learner.guidNpcCache then return end + local now = time() + local count = 0 + -- Prune entries older than 2 hours. This is used for combat log correlation + -- and doesn't need to persist indefinitely. + for guid, entry in pairs(_Learner.guidNpcCache) do + if entry.ts and (now - entry.ts) > 7200 then + _Learner.guidNpcCache[guid] = nil + count = count + 1 + end + end + if count > 0 then + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Pruned", count, "entries from guidNpcCache") + end +end + +-- Clear objective tracking for a specific quest +function QuestieLearner:ClearQuestObjectiveTracking(questId) + if not questId then return end + if _Learner.prevObjCounts and _Learner.prevObjCounts[questId] then + _Learner.prevObjCounts[questId] = nil + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Cleared prevObjCounts for quest", questId) + end +end + +-- Fired when quest objectives update — correlate with recent kills to learn objective NPCs +function QuestieLearner:OnQuestLogUpdate() + local numEntries = GetNumQuestLogEntries() + for i = 1, numEntries do + local _, _, _, isHeader, _, _, _, questId = QuestieCompat.GetQuestLogTitle(i) + if not isHeader and questId and questId > 0 then + local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(i) or 0 + Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestLogUpdate scanning quest", questId, "logIdx", i, "numObj", numObj) + _Learner.prevObjCounts[questId] = _Learner.prevObjCounts[questId] or {} + for j = 1, numObj do + local objText, objType, finished = GetQuestLogLeaderBoard(j, i) + -- Accept "monster", "item", or nil/unknown types — custom server quests + -- may report a different type string. Skip only finished objectives. + if not finished and objText then + -- Parse "Kill Felboar: 3/40" or "Felboar slain 3/40" → count = 3 + local count = tonumber(objText:match(":?%s*(%d+)%s*/")) + local prev = _Learner.prevObjCounts[questId][j] + + Questie:Debug(Questie.DEBUG_LEARNER, + "[QuestieLearner] OnQuestLogUpdate quest", questId, + "obj", j, "type:", tostring(objType), + "count:", tostring(count), "prev:", tostring(prev), + "text:", tostring(objText)) + + -- Seed on first sight; only correlate on confirmed increase + if prev == nil then + _Learner.prevObjCounts[questId][j] = count or 0 + elseif count and count > prev then + local now = time() + local bestGuid, bestKill = nil, nil + for guid, kill in pairs(_Learner.recentKills) do + if (now - kill.ts) <= 10 then + if not bestKill or kill.ts > bestKill.ts then + bestGuid, bestKill = guid, kill + end + end + end + if bestKill and bestKill.npcId then + local cleanText = objText:match("^(.-)%s*:") or (bestKill.name or "") + Questie:Debug(Questie.DEBUG_LEARNER, + "[QuestieLearner] Quest", questId, "obj", j, + "progressed — learning kill NPC:", bestKill.npcId, bestKill.name) + -- Pass exact kill coordinates so spawn list reflects NPC location, not player location + self:LearnNPC(bestKill.npcId, bestKill.name, nil, nil, nil, nil, bestKill.x, bestKill.y, bestKill.zoneId) + self:LearnQuestObjectiveNPC(questId, bestKill.npcId, cleanText) + _Learner.recentKills[bestGuid] = nil + end + _Learner.prevObjCounts[questId][j] = count + end + end end end end - - Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Kill recorded: NPC", npcId, destName) - self:LearnNPC(npcId, destName, nil, nil, nil, nil) end ------------------------------------------------------------------------ @@ -898,6 +1799,8 @@ function QuestieLearner:RegisterEvents() frame:RegisterEvent("GOSSIP_SHOW") frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") frame:RegisterEvent("GET_ITEM_INFO_RECEIVED") + frame:RegisterEvent("UNIT_QUEST_LOG_CHANGED") + frame:RegisterEvent("QUEST_REMOVED") frame:SetScript("OnEvent", function(_, event, ...) if event == "UPDATE_MOUSEOVER_UNIT" then @@ -921,6 +1824,11 @@ function QuestieLearner:RegisterEvents() elseif event == "GET_ITEM_INFO_RECEIVED" then local itemId = ... self:OnGetItemInfoReceived(itemId) + elseif event == "UNIT_QUEST_LOG_CHANGED" then + self:OnQuestLogUpdate() + elseif event == "QUEST_REMOVED" or event == "QUEST_TURNED_IN" then + local questId = ... + self:ClearQuestObjectiveTracking(questId) end end) @@ -936,6 +1844,12 @@ function QuestieLearner:Initialize() QuestieLearner.data = Questie.db.global.learnedData self:RegisterEvents() self:InjectLearnedData() + + -- Start periodic cleanup ticker (every 30 mins) + QuestieCompat.C_Timer.NewTicker(1800, function() + self:PruneGuidNpcCache() + end) + Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Initialized") end @@ -952,7 +1866,7 @@ function _Learner:BroadcastIfCommsAvailable(typ, id, data) end -- Receives validated, decoded data from QuestieLearnerComms or QuestieLearnerExport:MergeImport -function QuestieLearner:HandleNetworkData(typ, id, d) +function QuestieLearner:HandleNetworkData(typ, id, d, op) if not self:IsEnabled() then return end if not EnsureLearnedData() then return end if not typ or not id or not d then return end @@ -983,10 +1897,12 @@ function QuestieLearner:HandleNetworkData(typ, id, d) return end + local changed = false -- Merge: adopt non-nil fields we don't have locally for k, v in pairs(d) do if k ~= "mc" and existing[k] == nil then existing[k] = v + changed = true end end @@ -994,10 +1910,13 @@ function QuestieLearner:HandleNetworkData(typ, id, d) local coordKey = (typ == "NPC") and 7 or (typ == "OBJECT" and 4 or nil) if coordKey and type(d[coordKey]) == "table" then existing[coordKey] = existing[coordKey] or {} + local grid = GetCustomGridPrecision() for zoneId, coords in pairs(d[coordKey]) do existing[coordKey][zoneId] = existing[coordKey][zoneId] or {} for _, coord in ipairs(coords) do - InsertIfNewBucket(existing[coordKey][zoneId], coord[1], coord[2]) + if InsertIfNewBucket(existing[coordKey][zoneId], coord[1], coord[2], grid) then + changed = true + end end end end @@ -1010,13 +1929,19 @@ function QuestieLearner:HandleNetworkData(typ, id, d) for _, existId in ipairs(existing[2]) do if existId == npcId then found = true; break end end - if not found then table.insert(existing[2], npcId) end + if not found then + table.insert(existing[2], npcId) + changed = true + end end end - existing.mc = (existing.mc or 0) + 1 - - QuestieLearner.data = Questie.db.global.learnedData + if changed or (op == "NEW" or op == "UPDATE") then + existing.ls = time() -- Refresh timestamp on network confirmation + existing.mc = (existing.mc or 0) + 1 + QuestieLearner.data = Questie.db.global.learnedData + self:InjectLearnedData() + end end return QuestieLearner diff --git a/Modules/QuestieLearnerExport.lua b/Modules/QuestieLearnerExport.lua index 34c6927..a534c09 100644 --- a/Modules/QuestieLearnerExport.lua +++ b/Modules/QuestieLearnerExport.lua @@ -319,42 +319,73 @@ local QuestieDB -- lazily imported to avoid circular dep function _Export:RunPrune(dryRun) if not QuestieDB then QuestieDB = QuestieLoader:ImportModule("QuestieDB") end - + local serverKey = GetServerKey() local bucket = GetServerBucket(serverKey) - + local result = { npcs = 0, quests = 0, items = 0, objects = 0, total = 0, reasons = {} } if not bucket then return result end - + + local settings = (Questie.db.global.learnedData and Questie.db.global.learnedData.settings) or {} + local thresholdDays = settings.staleThreshold or 90 + local thresholdSeconds = thresholdDays * 86400 + local minConfidence = settings.minConfidencePins or 2 + local pruneVerified = settings.pruneVerified + local now = time() + local function ShouldPruneNPC(id, entry) if CountTable(entry) == 0 then return "empty entry" end - if (entry.mc or 0) < 2 and not entry[7] then return "unverified with no coords" end + local isVerified = (entry.mc or 0) >= minConfidence + if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then + return "unconfirmed and stale (> " .. thresholdDays .. " days)" + end + if pruneVerified or not isVerified then + if (entry.mc or 0) < 2 and not entry[7] then return "unverified with no coords" end + end return nil end - + local function ShouldPruneQuest(id, entry) if CountTable(entry) == 0 then return "empty entry" end - if QuestieDB and QuestieDB.GetQuest then - local dbEntry = QuestieDB:GetQuest(id) - if dbEntry and (entry.mc or 0) < 2 then - return "fully covered by official DB, mc < 2" + local isVerified = (entry.mc or 0) >= minConfidence + if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then + return "unconfirmed and stale (> " .. thresholdDays .. " days)" + end + if pruneVerified or not isVerified then + if QuestieDB and QuestieDB.GetQuest then + local dbEntry = QuestieDB.GetQuest(id) + if dbEntry and (entry.mc or 0) < 2 then + return "fully covered by official DB, mc < 2" + end end end return nil end - + local function ShouldPruneItem(id, entry) if CountTable(entry) == 0 then return "empty entry" end - if (entry.mc or 0) < 1 then return "zero match count" end + local isVerified = (entry.mc or 0) >= minConfidence + if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then + return "unconfirmed and stale (> " .. thresholdDays .. " days)" + end + if pruneVerified or not isVerified then + if (entry.mc or 0) < 1 then return "zero match count" end + end return nil end - + local function ShouldPruneObject(id, entry) if CountTable(entry) == 0 then return "empty entry" end - if (entry.mc or 0) < 2 and not entry[4] then return "unverified with no coords" end + local isVerified = (entry.mc or 0) >= minConfidence + if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then + return "unconfirmed and stale (> " .. thresholdDays .. " days)" + end + if pruneVerified or not isVerified then + if (entry.mc or 0) < 2 and not entry[4] then return "unverified with no coords" end + end return nil end - + local function PruneStore(store, checkFn, typeName) if not store then return end for id, entry in pairs(store) do @@ -371,11 +402,11 @@ function _Export:RunPrune(dryRun) end end end - + PruneStore(bucket.npcs, ShouldPruneNPC, "npcs") PruneStore(bucket.quests, ShouldPruneQuest, "quests") PruneStore(bucket.items, ShouldPruneItem, "items") PruneStore(bucket.objects, ShouldPruneObject, "objects") - + return result end diff --git a/Modules/Tooltips/Tooltip.lua b/Modules/Tooltips/Tooltip.lua index 9bab84e..2126e25 100644 --- a/Modules/Tooltips/Tooltip.lua +++ b/Modules/Tooltips/Tooltip.lua @@ -219,6 +219,64 @@ function QuestieTooltips:GetTooltip(key) local tooltipData = {} local tooltipLines = {} + if (not QuestieTooltips.lookupByKey[key]) then + local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner") + local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache") + if QuestieLearner and QuestieLearner.data then + -- Try to find in learned NPCs or objects + local id = tonumber(key:sub(3)) + if id then + if key:sub(1,2) == "m_" then + local learnedNpc = QuestieLearner.data.npcs[id] + if learnedNpc and learnedNpc[10] then -- check questObjectives + for questId, objList in pairs(learnedNpc[10]) do + for _, objText in ipairs(objList) do + local needed, collected + local objectives = QuestLogCache.GetQuestObjectives(questId) + if objectives then + for _, obj in pairs(objectives) do + if obj.text and objText and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then + needed = obj.numRequired + collected = obj.numFulfilled + break + end + end + end + QuestieTooltips:RegisterObjectiveTooltip(questId, key, { Index = 0, Description = objText, Needed = needed, Collected = collected, Update = function() end }) + end + end + if learnedNpc.mc then + tinsert(tooltipLines, "|cFF5EBAF3(Learned - Confidence: " .. tostring(learnedNpc.mc) .. ")|r") + end + end + elseif key:sub(1,2) == "o_" then + local learnedObj = QuestieLearner.data.objects[id] + if learnedObj and learnedObj[10] then + for questId, objList in pairs(learnedObj[10]) do + for _, objText in ipairs(objList) do + local needed, collected + local objectives = QuestLogCache.GetQuestObjectives(questId) + if objectives then + for _, obj in pairs(objectives) do + if obj.text and objText and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then + needed = obj.numRequired + collected = obj.numFulfilled + break + end + end + end + QuestieTooltips:RegisterObjectiveTooltip(questId, key, { Index = 0, Description = objText, Needed = needed, Collected = collected, Update = function() end }) + end + end + if learnedObj.mc then + tinsert(tooltipLines, "|cFF5EBAF3(Learned - Confidence: " .. tostring(learnedObj.mc) .. ")|r") + end + end + end + end + end + end + if QuestieTooltips.lookupByKey[key] then local playerName = UnitName("player") for k, tooltip in pairs(QuestieTooltips.lookupByKey[key]) do @@ -229,7 +287,7 @@ function QuestieTooltips:GetTooltip(key) end else local objective = tooltip.objective - if not (objective.IsSourceItem or objective.IsRequiredSourceItem) then + if objective and not (objective.IsSourceItem or objective.IsRequiredSourceItem) and objective.Update then -- Tooltip was registered for a sourceItem or requiredSourceItem and not a real "objective" objective:Update() end diff --git a/Modules/Tracker/QuestieTracker.lua b/Modules/Tracker/QuestieTracker.lua index 957cbf7..1dc5b83 100644 --- a/Modules/Tracker/QuestieTracker.lua +++ b/Modules/Tracker/QuestieTracker.lua @@ -914,7 +914,17 @@ function QuestieTracker:Update() -- Set Quest Title - This handles the "Auto Minimize Completed Quests" option but we don't auto-minimize timed quests. local coloredQuestName - if timedQuest then + if quest.isFallback or quest._isLogFallback then + -- Quest not in DB: use the name stored on the fallback object + local questName = quest.name or tostring(quest.Id) + if Questie.db.profile.trackerShowQuestLevel and quest.level and quest.level > 0 then + questName = "[" .. quest.level .. "] " .. questName + end + if Questie.db.profile.enableTooltipsQuestID then + questName = questName .. " (" .. quest.Id .. ")" + end + coloredQuestName = "|cFFFFFF00" .. questName .. "|r" + elseif timedQuest then coloredQuestName = QuestieLib:GetColoredQuestName(quest.Id, Questie.db.profile.trackerShowQuestLevel, false, false) else @@ -2290,10 +2300,15 @@ end function QuestieTracker:UntrackQuestId(questId) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieTracker:UntrackQuestId] - ", questId) - -- Always remove from tracked quests when manually untracking - Questie.db.char.TrackedQuests[questId] = nil - -- Also remove from auto-untracked so it doesn't get re-tracked - Questie.db.char.AutoUntrackedQuests[questId] = nil + if Questie.db.profile.autoTrackQuests then + -- In auto-track mode, mark the quest as explicitly hidden + Questie.db.char.AutoUntrackedQuests[questId] = true + Questie.db.char.TrackedQuests[questId] = nil + else + -- In manual-track mode, remove from tracked list + Questie.db.char.TrackedQuests[questId] = nil + Questie.db.char.AutoUntrackedQuests[questId] = nil + end if Questie.db.profile.hideUntrackedQuestsMapIcons then -- Hides objective icons for untracked quests. @@ -2346,11 +2361,8 @@ function QuestieTracker:AQW_Insert(index, expire) end else if Questie.db.char.AutoUntrackedQuests[questId] then + -- Quest was manually hidden — shift-click re-tracks it Questie.db.char.AutoUntrackedQuests[questId] = nil - - -- Add quest to the tracker - elseif IsShiftKeyDown() and QuestLogFrame:IsShown() then - Questie.db.char.AutoUntrackedQuests[questId] = true end end diff --git a/Modules/Tracker/TrackerUtils.lua b/Modules/Tracker/TrackerUtils.lua index 4dd3a69..405c080 100644 --- a/Modules/Tracker/TrackerUtils.lua +++ b/Modules/Tracker/TrackerUtils.lua @@ -698,6 +698,41 @@ end -- Intentionally NOT stored in QuestiePlayer.currentQuestlog so arrow/map/other modules -- don't try to call DB-only methods on them. TrackerUtils._fallbackQuests = TrackerUtils._fallbackQuests or {} + +-- Reverse-lookup: given a localized zone name string, find the area ID from l10n.zoneLookup. +local function GetAreaIdByZoneName(zoneName) + if not zoneName or zoneName == "" then return 0 end + for _, zoneTable in pairs(l10n.zoneLookup) do + for areaId, name in pairs(zoneTable) do + if name == zoneName then return areaId end + end + end + return 0 +end + +-- Walk the quest log to find the zone header for a given questId. +-- In 3.3.5, zone names appear as isHeader=true entries above their quests. +-- Returns the header title string, or nil if not found. +local function GetQuestLogZoneName(questId) + local targetIndex = nil + local total = GetNumQuestLogEntries and GetNumQuestLogEntries() or 0 + for i = 1, total do + local _, _, _, isHeader, _, _, _, logId = GetQuestLogTitle(i) + if not isHeader and logId == questId then + targetIndex = i + break + end + end + if not targetIndex then return nil end + for i = targetIndex, 1, -1 do + local title, _, _, isHeader = GetQuestLogTitle(i) + if isHeader and title and title ~= "" then + return title + end + end + return nil +end + -- Returns nil if the quest is not currently in the quest log. function TrackerUtils:BuildFallbackQuest(questId) for i = 1, GetNumQuestLogEntries() do @@ -723,13 +758,24 @@ function TrackerUtils:BuildFallbackQuest(questId) end end - local zoneId = GetCurrentMapAreaID and GetCurrentMapAreaID() or 0 + -- Walk backwards from i in the quest log to find the zone header. + -- This is the canonical 3.3.5 method: zone headers sit above their quests. + local zoneText = nil + for h = i, 1, -1 do + local hTitle, _, _, hIsHeader = GetQuestLogTitle(h) + if hIsHeader and hTitle and hTitle ~= "" then + zoneText = hTitle + break + end + end + local zoneId = (zoneText and GetAreaIdByZoneName(zoneText)) or 0 local quest = { Id = questId, name = title or ("Quest " .. questId), level = level or 0, zoneOrSort = zoneId, + zoneName = zoneText, Objectives = objectives, SpecialObjectives = {}, isFallback = true, @@ -774,7 +820,7 @@ function TrackerUtils:GetSortedQuestIds() local capturedId = qid quest.IsComplete = function(self) for i = 1, GetNumQuestLogEntries() do - local _, _, _, _, isHeader, _, isCompleteFlag, _, logId = GetQuestLogTitle(i) + local _, _, _, isHeader, _, isCompleteFlag, _, logId = GetQuestLogTitle(i) if not isHeader and logId == capturedId then return (isCompleteFlag == 1 or isCompleteFlag == true) and 1 or 0 end @@ -784,10 +830,23 @@ function TrackerUtils:GetSortedQuestIds() if not quest.Objectives then quest.Objectives = {} end if not quest.SpecialObjectives then quest.SpecialObjectives = {} end if not quest.ExtraObjectives then quest.ExtraObjectives = {} end + -- Use the quest log header walk (canonical 3.3.5 zone resolution) + if not quest.zoneName or quest.zoneName == "" then + local logZone = GetQuestLogZoneName(capturedId) + if logZone then + quest.zoneName = logZone + quest.zoneOrSort = GetAreaIdByZoneName(logZone) or 0 + end + end QuestiePlayer.currentQuestlog[qid] = quest else -- No object at all — build one from the log local fallback = TrackerUtils._fallbackQuests[qid] + -- Re-build if cached without zone info (e.g. was built before log was ready) + if fallback and not fallback.zoneName then + TrackerUtils._fallbackQuests[qid] = nil + fallback = nil + end if not fallback then fallback = TrackerUtils:BuildFallbackQuest(qid) if fallback then @@ -807,7 +866,7 @@ function TrackerUtils:GetSortedQuestIds() -- Create questDetails table keys and insert values questDetails[qid] = {} questDetails[qid].quest = quest - questDetails[qid].zoneName = _GetZoneName(quest.zoneOrSort, qid) + questDetails[qid].zoneName = quest.zoneName or _GetZoneName(quest.zoneOrSort, qid) if quest:IsComplete() == 1 or (not next(quest.Objectives)) then questDetails[qid].questCompletePercent = 1 diff --git a/Questie-X-Classic.toc b/Questie-X-Classic.toc index 662737b..4d4c89e 100644 --- a/Questie-X-Classic.toc +++ b/Questie-X-Classic.toc @@ -1,11 +1,11 @@ ## Interface: 30300 -## Title: |cFF5EBAF3Questie|r|cFFDAFAFD-X|r-Classic|cFF00FF00 v1.1.4|r +## Title: |cFF5EBAF3Questie|r|cFFDAFAFD-X|r-Classic|cFF00FF00 v1.3.0|r ## Notes: A standalone Classic QuestHelper ## Notes-esMX: Ayundante de misión ## Notes-esES: Ayundante de misión ## Notes-ptBR: Ajudante de missão ## Notes-frFR: Assistant de quête -## Version: 1.1.4 +## Version: 1.3.0 ## RequiredDeps: ## OptionalDeps: Ace3, CallbackHandler-1.0, HereBeDragons, LibDataBroker-1.1, LibDBIcon-1.0, LibSharedMedia-3.0, LibStub, LibUIDropDownMenu ## SavedVariables: QuestieConfig diff --git a/Questie-X-TBC.toc b/Questie-X-TBC.toc index fabdeb8..c658fec 100644 --- a/Questie-X-TBC.toc +++ b/Questie-X-TBC.toc @@ -1,11 +1,11 @@ ## Interface: 30300 -## Title: |cFF5EBAF3Questie|r|cFFDAFAFD-X|r-TBC|cFF00FF00 v1.1.4|r +## Title: |cFF5EBAF3Questie|r|cFFDAFAFD-X|r-TBC|cFF00FF00 v1.3.0|r ## Notes: A standalone Classic QuestHelper ## Notes-esMX: Ayundante de misión ## Notes-esES: Ayundante de misión ## Notes-ptBR: Ajudante de missão ## Notes-frFR: Assistant de quête -## Version: 1.1.4 +## Version: 1.3.0 ## RequiredDeps: ## OptionalDeps: Ace3, CallbackHandler-1.0, HereBeDragons, LibDataBroker-1.1, LibDBIcon-1.0, LibSharedMedia-3.0, LibStub, LibUIDropDownMenu ## SavedVariables: QuestieConfig diff --git a/Questie-X-Turtle.toc b/Questie-X-Turtle.toc index 4658734..97c66fb 100644 --- a/Questie-X-Turtle.toc +++ b/Questie-X-Turtle.toc @@ -1,11 +1,11 @@ ## Interface: 11200 -## Title: |cFF5EBAF3Questie|r|cFFDAFAFD-X|r-Turtle|cFF00FF00 v1.1.4|r +## Title: |cFF5EBAF3Questie|r|cFFDAFAFD-X|r-Turtle|cFF00FF00 v1.3.0|r ## Notes: A standalone Classic QuestHelper ## Notes-esMX: Ayundante de misión ## Notes-esES: Ayundante de misión ## Notes-ptBR: Ajudante de missão ## Notes-frFR: Assistant de quête -## Version: 1.1.4 +## Version: 1.3.0 ## RequiredDeps: ## OptionalDeps: Ace3, CallbackHandler-1.0, HereBeDragons, LibDataBroker-1.1, LibDBIcon-1.0, LibSharedMedia-3.0, LibStub, LibUIDropDownMenu, Questie-X-TurtleDB ## SavedVariables: QuestieConfig diff --git a/Questie-X.toc b/Questie-X.toc index 0fac998..1253e58 100644 --- a/Questie-X.toc +++ b/Questie-X.toc @@ -11,7 +11,7 @@ ## Notes-esES: Ayundante de misión ## Notes-ptBR: Ajudante de missão ## Notes-frFR: Assistant de quête -## Version: 1.2.2 +## Version: 1.3.0 ## RequiredDeps: ## OptionalDeps: Ace3, CallbackHandler-1.0, HereBeDragons, LibDataBroker-1.1, LibDBIcon-1.0, LibSharedMedia-3.0, LibStub, LibUIDropDownMenu, Questie-X-WotLKDB, Questie-X-ClassicDB, Questie-X-TBCDB, Questie-X-TurtleDB, Questie-X-AscensionDB, Questie-X-EbonholdDB ## SavedVariables: QuestieConfig, QuestieLearnerDB diff --git a/Tests/QuestieLearner_spec.lua b/Tests/QuestieLearner_spec.lua new file mode 100644 index 0000000..389704c --- /dev/null +++ b/Tests/QuestieLearner_spec.lua @@ -0,0 +1,77 @@ +-- Tests/QuestieLearner_spec.lua +require("Tests/wow_api_mock") + +describe("QuestieLearner", function() + local QuestieLearner + + setup(function() + -- Mocking QuestieLoader for this test + _G.QuestieLoader.ImportModule = function(_, name) + if name == "QuestieDB" then return _G.QuestieDB end + if name == "QuestieQuest" then return _G.QuestieQuest end + if name == "QuestiePlayer" then return _G.QuestiePlayer end + if name == "QuestLogCache" then return _G.QuestLogCache end + if name == "QuestieCompat" then return _G.QuestieCompat end + return {} + end + + _G.QuestieLoader.CreateModule = function(_, name) + _G[name] = {} + return _G[name] + end + + -- Load the module (this assumes busted is run from project root) + package.loaded["Modules/QuestieLearner"] = nil + QuestieLearner = require("Modules/QuestieLearner") + + -- Initialize + QuestieLearner:Initialize() + end) + + it("should scale coordinates by 100 in OnCombatLogEvent", function() + -- Simulated combat log event info (npcId 21878) + local unitGUID = "Creature-0-1234-567-89-21878-0000000000" + local unitName = "Felboar" + + -- Mock the player position to return raw decimals 0.35, 0.45 + _G.QuestieCompat.GetCurrentPlayerPosition = function() + return 946, 0.35, 0.45 + end + + -- Trigger event + QuestieLearner:OnCombatLogEvent(GetTime(), "UNIT_DIED", false, unitGUID, unitName, 0, 0, unitGUID, unitName, 0, 0) + + -- The cache should store 35.0, 45.0 + local cached = QuestieLearner.private.recentKills[unitGUID] + assert.is_not_nil(cached) + assert.are.equal(35.0, cached.x) + assert.are.equal(45.0, cached.y) + end) + + it("should only learn spell casts that are quest objectives", function() + -- Reset data + Questie.db.global.learnedData.queries = {} + + -- Case 1: Matching objective + local questId = 12345 + local spellId = 29228 -- Flame Shock + + _G.QuestieDB.GetQuest = function(_, id) + return { + Id = id, + Objectives = { + { type = "spell", id = spellId } + } + } + end + + _G.QuestLogCache.GetQuestID = function() return questId end + + QuestieLearner:LearnSpellCast(spellId, "Flame Shock", "Enemy NPC") + + -- Result: data should have a log for this quest/spell + assert.is_not_nil(Questie.db.global.learnedData.quests[questId]) + assert.is_not_nil(Questie.db.global.learnedData.quests[questId][3]) -- spell node + assert.are.equal(spellId, Questie.db.global.learnedData.quests[questId][3][1]) + end) +end) diff --git a/Tests/wow_api_mock.lua b/Tests/wow_api_mock.lua new file mode 100644 index 0000000..070db83 --- /dev/null +++ b/Tests/wow_api_mock.lua @@ -0,0 +1,90 @@ +-- Tests/wow_api_mock.lua +-- Minimal mock of World of Warcraft API for Busted unit tests + +_G = _G or {} + +-- Mock Globals +_G.Questie = { + DEBUG_LEARNER = "LEARNER", + DEBUG_DEVELOP = "DEVELOP", + db = { + global = { + learnedData = { + npcs = {}, + quests = {}, + items = {}, + objects = {}, + settings = { + learnQuests = true, + learnNPCs = true, + learnItems = true, + learnObjects = true, + } + } + }, + profile = { + learnedData = { + settings = { + learnQuests = true, + learnNPCs = true, + } + } + } + }, + Debug = function(self, level, ...) + -- print("[" .. tostring(level) .. "]", ...) + end, + Error = function(self, ...) + -- print("[ERROR]", ...) + end +} + +_G.QuestieLoader = { + ImportModule = function(self, name) + if name == "QuestieDB" then return _G.QuestieDB end + if name == "QuestieQuest" then return _G.QuestieQuest end + if name == "QuestiePlayer" then return _G.QuestiePlayer end + if name == "QuestLogCache" then return _G.QuestLogCache end + if name == "QuestieLib" then return {} end + if name == "QuestieCompat" then return _G.QuestieCompat end + return {} + end, + CreateModule = function(self, name) + _G[name] = {} + return _G[name] + end +} + +_G.QuestieDB = { + npcDataOverrides = {}, + QueryNPCSingle = function() return nil end, + GetQuest = function() return nil end, +} + +_G.QuestieCompat = { + GetCurrentPlayerPosition = function() return 1, 0.5, 0.5 end, +} + +_G.QuestiePlayer = { + GetPlayerLevel = function() return 70 end, +} + +_G.QuestLogCache = { + GetQuestID = function() return 123 end, +} + +-- WoW Functions +_G.GetTime = function() return os.time() end +_G.time = os.time +_G.floor = math.floor +_G.UnitName = function(unit) return "TestUnit" end +_G.UnitLevel = function(unit) return 70 end +_G.UnitGUID = function(unit) return "Creature-0-1234-567-89-1000-0000000000" end +_G.GetRealZoneText = function() return "Shadowmoon Valley" end +_G.GetInstanceInfo = function() return "Shadowmoon Valley", nil, nil, nil, nil, nil, nil, 530 end +_G.C_Timer = { + After = function(duration, callback) callback() end, +} +_G.CreateFrame = function() return { RegisterEvent = function() end, SetScript = function() end } end + +return _G diff --git a/Tools/SplitDB.ps1 b/Tools/SplitDB.ps1 deleted file mode 100644 index d6f2dd8..0000000 --- a/Tools/SplitDB.ps1 +++ /dev/null @@ -1,49 +0,0 @@ -param( - [string]$InputFile, - [string]$OutputDir, - [string]$TableKey, - [int]$MaxKB = 850 -) - -$lines = Get-Content $InputFile -$baseName = [System.IO.Path]::GetFileNameWithoutExtension($InputFile) - -$dataStartLine = ($lines | Select-String -Pattern "^\[" | Select-Object -First 1).LineNumber - 1 -$totalLines = $lines.Count - -Write-Host "Processing $InputFile" -Write-Host "Data entries start at line $($dataStartLine+1) of $totalLines" - -$header = "-- AUTO GENERATED FILE! DO NOT EDIT! (split chunk)`r`nif not QuestieLoader then return end`r`nlocal QuestieDB = QuestieLoader:ImportModule(`"QuestieDB`")`r`nQuestieDB.$TableKey = QuestieDB.$TableKey or {}`r`nlocal _d = QuestieDB.$TableKey`r`n" - -$chunkIndex = 1 -$currentLines = New-Object System.Collections.Generic.List[string] -$currentLines.Add($header) -$currentSize = [System.Text.Encoding]::UTF8.GetByteCount($header) - -for ($i = $dataStartLine; $i -lt $totalLines; $i++) { - $line = $lines[$i] - if ($line -match "^\}\]\]") { break } - $converted = $line -replace "^\[(\d+)\]\s*=", "_d[`$1] =" - $lineBytes = [System.Text.Encoding]::UTF8.GetByteCount($converted + "`n") - - if ($currentSize + $lineBytes -gt ($MaxKB * 1024) -and $currentLines.Count -gt 5) { - $outFile = Join-Path $OutputDir "${baseName}_${chunkIndex}.lua" - $currentLines | Set-Content $outFile - Write-Host " Wrote chunk $chunkIndex -> $outFile ($([math]::Round($currentSize/1KB))KB)" - $chunkIndex++ - $currentLines = New-Object System.Collections.Generic.List[string] - $currentLines.Add($header) - $currentSize = [System.Text.Encoding]::UTF8.GetByteCount($header) - } - - $currentLines.Add($converted) - $currentSize += $lineBytes -} - -if ($currentLines.Count -gt 5) { - $outFile = Join-Path $OutputDir "${baseName}_${chunkIndex}.lua" - $currentLines | Set-Content $outFile - Write-Host " Wrote chunk $chunkIndex -> $outFile ($([math]::Round($currentSize/1KB))KB)" -} -Write-Host "Done. $chunkIndex chunks written." diff --git a/docs/changelog.html b/docs/changelog.html index 98b5699..d1cabaf 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -176,6 +176,32 @@
+

v1.3.0 — QuestieLearner Confidence & Tiered Pruning Engine

+

Implements a robust verification model for learned data, introducing a tiered confidence system (Verified vs Unconfirmed), automated stale data cleanup with protection for high-confidence entries, and a critical correction to coordinate scaling logic for 3.3.5a combat log events. Refactors global data sharing to utilize hidden chat channels for community-wide confidence calculation.

+ +

QuestieLearner.lua — Confidence & Coordinate Scaling

+
    +
  • [Coordinate Scaling Fix — 0-100 normalization] OnCombatLogEvent now explicitly scales player position coordinates by 100 before recording. Previously, C_Map.GetPlayerMapPosition returned values on a 0-1 range, while Questie's internal modules and map pins expect 0-100. This fix resolves the "pins in the top-left corner" bug for all learned data.
  • +
  • [Tiered Data Model — Verified vs Unconfirmed] Introduced the mc (Match Count) field as a primary confidence metric. Data is promoted to "Verified" status once mc >= minConfidencePins (default: 2). Verified data is exempt from automatic time-based pruning.
  • +
  • [Last Seen (ls) Timestamping] Added a ls key to the learned data schema for NPCs, Objects, Quests, and Items. Updated on every kill, interaction, or network confirmation. This provides the temporal baseline for the new stale data cleanup engine.
  • +
  • [Confidence-based Map Pin Gating] Modified the map pin generation logic to honor the minConfidencePins setting. Pins only appear on the map/minimap if the learned data has been confirmed by multiple kills (either local or received via network).
  • +
  • [Global Data Sharing — QuestieComms Refactor] Switched from PARTY/RAID channels to a hidden global channel for learned data broadcast. This allows kills by any Questie-X user in the vicinity to contribute to local data confidence, effectively crowd-sourcing verification in real-time.
  • +
  • [Tooltip Confidence Display] The tooltip handler now pulls the mc count for learned entries and displays it alongside the "Learned" label (e.g., (Learned - Confidence: 3)), providing immediate visual feedback on data reliability.
  • +
+ +

QuestieLearnerExport.lua — Tiered Pruning Engine

+
    +
  • [RunPrune — Stale threshold logic] Implemented automated time-based pruning in the Cleanup function. Only entries marked as "Unconfirmed" (low confidence) are subject to expiration. The expiration logic uses (time() - entry.ls) > staleThreshold (default: 90 days).
  • +
  • [Verified Protection] Verified entries are explicitly protected from the time-based cleanup loop unless the pruneVerified setting is manually enabled by the user.
  • +
+ +

QuestieOptionsDatabase.lua — Verification Controls

+
    +
  • [Stale Threshold Slider] Added a configuration slider in the Database tab allowing users to set the cleanup window (1 to 180 days).
  • +
  • [Prune Verified Toggle] Added a checkbox to allow manual purging of verified data if desired.
  • +
  • [Prioritize My Data] Implemented a "Prioritize My Data" toggle that dynamically hides static database pins when high-confidence learned data exists for the same NPC in a zone.
  • +
+

v1.2.9 — QuestieLearner Cross-Link Engine + Tracker Zone Fix + Untrack Fix

Introduces a universal bidirectional cross-link engine in QuestieLearner that automatically stitches relationships between all four entity types (NPCs, Quests, Objects, Items) as data is learned — no manual wiring needed. Fixes the tracker's persistent "Unknown Zone" header for custom/unknown quests by replacing unreliable map API calls with the canonical 3.3.5a quest log header walk. Fixes a logic inversion in UntrackQuestId that prevented shift-click untacking from working.