---@class QuestieLearner local QuestieLearner = QuestieLoader:CreateModule("QuestieLearner") ---@type QuestieDB local QuestieDB = QuestieLoader:ImportModule("QuestieDB") local _Learner = QuestieLearner.private or {} QuestieLearner.private = _Learner local floor = math.floor local abs = math.abs -- NPC flags (WoW bitmask) local NPC_FLAG_GOSSIP = 0x00000001 local NPC_FLAG_QUESTGIVER = 0x00000002 local NPC_FLAG_TRAINER = 0x00000010 local NPC_FLAG_VENDOR = 0x00000080 local NPC_FLAG_FLIGHTMASTER = 0x00000200 local NPC_FLAG_INNKEEPER = 0x00000800 local NPC_FLAG_BANKER = 0x00001000 local NPC_FLAG_AUCTIONEER = 0x00004000 local NPC_FLAG_STABLEMASTER = 0x00010000 -- Only cache/learn mouseover NPCs that carry one of these flags local MOUSEOVER_LEARN_FLAGS = NPC_FLAG_QUESTGIVER -- Coordinate grid cell size (in 0–100 map units). -- ~2 grid units ≈ 2% of zone width — keeps clusters tight without over-splitting. local COORD_GRID = 2.0 _Learner.pendingNpcs = {} _Learner.pendingQuests = {} _Learner.pendingItems = {} _Learner.pendingObjects = {} _Learner.pendingItemLinks = {} -- queue for async GetItemInfo retries -- Direct reference to learnedData, set on Initialize QuestieLearner.data = nil ------------------------------------------------------------------------ -- Coordinate helpers ------------------------------------------------------------------------ local function GetZoneId() local mapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") if mapId then return mapId end return select(8, GetInstanceInfo()) or 0 end local function GetPlayerCoords() local x, y = GetPlayerMapPosition("player") if x and y and x > 0 and y > 0 then -- Store in 0–100 scale, 2-decimal precision return floor(x * 10000) / 100, floor(y * 10000) / 100 end return nil, nil end -- Returns the grid-bucket key for a coordinate so nearby points share the same slot local function CoordBucket(x, y) return floor(x / COORD_GRID) * COORD_GRID, floor(y / COORD_GRID) * COORD_GRID 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) for _, coord in ipairs(coordList) do local cx, cy = CoordBucket(coord[1], coord[2]) if cx == bx and cy == by then return false end end table.insert(coordList, {x, y}) return true end ------------------------------------------------------------------------ -- Internal state guards ------------------------------------------------------------------------ local function EnsureLearnedData() if not Questie.db then return false end local ld = Questie.db.global.learnedData if not ld then Questie.db.global.learnedData = { npcs = {}, quests = {}, items = {}, objects = {}, settings = { enabled = true, learnNpcs = true, learnQuests = true, learnItems = true, learnObjects = true, }, } else -- Backfill sub-tables that may be missing from older SavedVariables ld.npcs = ld.npcs or {} ld.quests = ld.quests or {} ld.items = ld.items or {} ld.objects = ld.objects or {} ld.settings = ld.settings or {} local s = ld.settings if s.enabled == nil then s.enabled = true end if s.learnNpcs == nil then s.learnNpcs = true end 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 end return true end ------------------------------------------------------------------------ -- Public API ------------------------------------------------------------------------ function QuestieLearner:IsEnabled() if not EnsureLearnedData() then return false end return Questie.db.global.learnedData.settings.enabled end function QuestieLearner:GetSettings() if not EnsureLearnedData() then return {} end return Questie.db.global.learnedData.settings end ------------------------------------------------------------------------ -- NPC learning ------------------------------------------------------------------------ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString) 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() local existing = Questie.db.global.learnedData.npcs[npcId] local isNew = existing == nil if not existing then existing = {} Questie.db.global.learnedData.npcs[npcId] = existing end if name and not existing[1] then existing[1] = name end if level then if not existing[4] or level < existing[4] then existing[4] = level end if not existing[5] or level > existing[5] then existing[5] = level end end 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.mc = (existing.mc or 0) + 1 if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New NPC learned:", npcId, name or "?") end _Learner:BroadcastIfCommsAvailable("NPC", npcId, existing) end ------------------------------------------------------------------------ -- Quest learning ------------------------------------------------------------------------ -- Captures all fields accessible from the WoW API. -- Quest data array indices follow the Questie wiki spec exactly: -- [1] name [2] starters (npc/obj/item arrays) [3] finishers -- [4] requiredLevel [5] questLevel [6] infoText (objectives text block) -- [7] requiredMoney [8] zoneOrSort [12] requiredRaces [13] requiredClasses -- [17] details text [18] finishText [19] completedText function QuestieLearner:LearnQuest(questId, data) if not self:IsEnabled() then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] LearnQuest blocked: learner not enabled") return end if not Questie.db.global.learnedData.settings.learnQuests then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] LearnQuest blocked: learnQuests=", tostring(Questie.db.global.learnedData.settings.learnQuests)) return end if not questId or questId <= 0 then return end local existing = Questie.db.global.learnedData.quests[questId] local isNew = existing == nil if not existing then existing = {} Questie.db.global.learnedData.quests[questId] = existing end for k, v in pairs(data) do if v ~= nil and v ~= "" and v ~= 0 and existing[k] == nil then existing[k] = v end end existing.mc = (existing.mc or 0) + 1 if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New quest learned:", questId, existing[1] or "?") end _Learner:BroadcastIfCommsAvailable("QUEST", questId, existing) end -- Records the NPC/object that starts or finishes a quest (array index [2] or [3]) function QuestieLearner:LearnQuestGiver(questId, entityId, entityType, isStart) 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 entityId or entityId <= 0 then return end local existing = Questie.db.global.learnedData.quests[questId] if not existing then existing = {} Questie.db.global.learnedData.quests[questId] = existing end -- Starters/finishers: { [1]={npcIds}, [2]={objIds}, [3]={itemIds} } local field = isStart and 2 or 3 existing[field] = existing[field] or {} -- entityType: 1=npc, 2=obj, 3=item local typeSlot = entityType or 1 existing[field][typeSlot] = existing[field][typeSlot] or {} local list = existing[field][typeSlot] for _, id in ipairs(list) do if id == entityId then return end end table.insert(list, entityId) end ------------------------------------------------------------------------ -- Item learning ------------------------------------------------------------------------ function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemClass, itemSubClass) if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnItems then return end if not itemId or itemId <= 0 then return end local existing = Questie.db.global.learnedData.items[itemId] local isNew = existing == nil if not existing then existing = {} Questie.db.global.learnedData.items[itemId] = existing end 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.mc = (existing.mc or 0) + 1 if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New item learned:", itemId, name or "?") end _Learner:BroadcastIfCommsAvailable("ITEM", itemId, existing) end function QuestieLearner:LearnItemDrop(itemId, npcId) if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnItems then return end if not itemId or itemId <= 0 or not npcId or npcId <= 0 then return end local existing = Questie.db.global.learnedData.items[itemId] if not existing then existing = {} Questie.db.global.learnedData.items[itemId] = existing end existing[2] = existing[2] or {} for _, id in ipairs(existing[2]) do if id == npcId then return end end table.insert(existing[2], npcId) end ------------------------------------------------------------------------ -- Object learning ------------------------------------------------------------------------ function QuestieLearner:LearnObject(objectId, name) if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnObjects then return end if not objectId or objectId <= 0 then return end local zoneId = GetZoneId() local x, y = GetPlayerCoords() local existing = Questie.db.global.learnedData.objects[objectId] local isNew = existing == nil if not existing then existing = {} Questie.db.global.learnedData.objects[objectId] = existing end if name and not existing[1] then existing[1] = name end if zoneId and zoneId > 0 and not existing[5] then existing[5] = zoneId end if x and y and zoneId and zoneId > 0 then existing[4] = existing[4] or {} existing[4][zoneId] = existing[4][zoneId] or {} InsertIfNewBucket(existing[4][zoneId], x, y) end existing.mc = (existing.mc or 0) + 1 if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New object learned:", objectId, name or "?") end _Learner:BroadcastIfCommsAvailable("OBJECT", objectId, existing) end ------------------------------------------------------------------------ -- InjectLearnedData — pushes learnedData into QuestieDB overrides ------------------------------------------------------------------------ function QuestieLearner:InjectLearnedData() if not EnsureLearnedData() then return end local learned = Questie.db.global.learnedData local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0 for npcId, data in pairs(learned.npcs) do if not QuestieDB.npcDataOverrides[npcId] then QuestieDB.npcDataOverrides[npcId] = data npcCount = npcCount + 1 else local existing = QuestieDB.npcDataOverrides[npcId] if data[7] then existing[7] = existing[7] or {} for zoneId, coords in pairs(data[7]) do existing[7][zoneId] = existing[7][zoneId] or {} for _, coord in ipairs(coords) do InsertIfNewBucket(existing[7][zoneId], coord[1], coord[2]) 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] for k, v in pairs(data) do if k ~= "mc" and existing[k] == nil then existing[k] = v end end end end for itemId, data in pairs(learned.items) do if not QuestieDB.itemDataOverrides[itemId] then QuestieDB.itemDataOverrides[itemId] = data itemCount = itemCount + 1 end end for objectId, data in pairs(learned.objects) do if not QuestieDB.objectDataOverrides[objectId] then QuestieDB.objectDataOverrides[objectId] = data objectCount = objectCount + 1 else local existing = QuestieDB.objectDataOverrides[objectId] if data[4] then existing[4] = existing[4] or {} for zoneId, coords in pairs(data[4]) do existing[4][zoneId] = existing[4][zoneId] or {} for _, coord in ipairs(coords) do InsertIfNewBucket(existing[4][zoneId], coord[1], coord[2]) end end end end end if npcCount > 0 or questCount > 0 or itemCount > 0 or objectCount > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Injected learned data:", npcCount, "NPCs,", questCount, "quests,", itemCount, "items,", objectCount, "objects") end end ------------------------------------------------------------------------ -- Stats / Export helpers ------------------------------------------------------------------------ function QuestieLearner:GetStats() if not EnsureLearnedData() then return 0, 0, 0, 0 end local learned = Questie.db.global.learnedData local n, q, i, o = 0, 0, 0, 0 for _ in pairs(learned.npcs) do n = n + 1 end for _ in pairs(learned.quests) do q = q + 1 end for _ in pairs(learned.items) do i = i + 1 end for _ in pairs(learned.objects) do o = o + 1 end return n, q, i, o end function QuestieLearner:ClearAllData() if not EnsureLearnedData() then return end Questie.db.global.learnedData.npcs = {} Questie.db.global.learnedData.quests = {} Questie.db.global.learnedData.items = {} Questie.db.global.learnedData.objects = {} Questie:Print("Cleared all learned data.") end function QuestieLearner:SerializeTable(t) if type(t) ~= "table" then if type(t) == "string" then return string.format("%q", t) end return tostring(t) end local parts = {} local isArray = #t > 0 for k, v in pairs(t) do local key = isArray and "" or ("[" .. (type(k) == "string" and string.format("%q", k) or tostring(k)) .. "]=") table.insert(parts, key .. self:SerializeTable(v)) end return "{" .. table.concat(parts, ",") .. "}" end function QuestieLearner:ExportData() if not EnsureLearnedData() then return "" end local learned = Questie.db.global.learnedData local lines = {} table.insert(lines, "-- QuestieLearner Export") local n, q, i, o = self:GetStats() table.insert(lines, "-- NPCs: " .. n .. " Quests: " .. q .. " Items: " .. i .. " Objects: " .. o) table.insert(lines, "") table.insert(lines, "QuestieLearnerExport = {") table.insert(lines, " npcs = " .. self:SerializeTable(learned.npcs) .. ",") table.insert(lines, " quests = " .. self:SerializeTable(learned.quests) .. ",") table.insert(lines, " items = " .. self:SerializeTable(learned.items) .. ",") table.insert(lines, " objects = " .. self:SerializeTable(learned.objects) .. ",") table.insert(lines, "}") return table.concat(lines, "\n") end ------------------------------------------------------------------------ -- GUID parsing ------------------------------------------------------------------------ local HEX_PREFIXES = { ["F130"] = "Creature", ["F131"] = "Vehicle", ["F140"] = "GameObject", ["F110"] = "Creature", ["F111"] = "Creature", } local CREATURE_HEX_PREFIXES = { ["F130"]=true, ["F131"]=true, ["F110"]=true, ["F111"]=true } local function GetIdAndTypeFromGUID(guid) if not guid then return nil, nil end -- Modern dash-separated GUID (e.g. "Creature-0-3726-0-189-5638296-...") local unitType, _, _, _, _, parsedId = strsplit("-", guid) local id = tonumber(parsedId) if id and id > 0 and unitType then return id, unitType end -- Legacy hex GUID if string.sub(guid, 1, 2) == "0x" and string.len(guid) >= 18 then local prefix = string.upper(string.sub(guid, 3, 6)) local t = HEX_PREFIXES[prefix] if t then local low32 = tonumber(string.sub(guid, 11, 18), 16) if low32 then local nid = math.mod(low32, 8388608) if nid > 0 then return nid, t end end end end return nil, nil end local function GetNpcIdFromGUID(guid) local id, unitType = GetIdAndTypeFromGUID(guid) if unitType == "Creature" or unitType == "Vehicle" then return id end return nil end local function GetObjectIdFromGUID(guid) local id, unitType = GetIdAndTypeFromGUID(guid) if unitType == "GameObject" then return id end return nil end -- Expose for use in event handlers below _Learner.GetNpcIdFromGUID = GetNpcIdFromGUID _Learner.GetObjectIdFromGUID = GetObjectIdFromGUID _Learner.GetIdAndTypeFromGUID = GetIdAndTypeFromGUID ------------------------------------------------------------------------ -- Event handlers ------------------------------------------------------------------------ -- Checks whether an NPC (by npcFlags bitmask) should be learned on mouseover. -- Only quest givers and turn-in NPCs are relevant for the learner. local function NpcFlagsHasQuestGiver(flags) if not flags then return false end -- bitwise AND for Lua 5.1 (no bit library guaranteed) return math.floor(flags / NPC_FLAG_QUESTGIVER) % 2 == 1 end function QuestieLearner:OnMouseoverUnit() if not UnitExists("mouseover") or not UnitIsVisible("mouseover") then return end if UnitIsPlayer("mouseover") then return end local guid = UnitGUID("mouseover") if not guid then return end if guid == _Learner._lastMouseoverGuid then return end _Learner._lastMouseoverGuid = guid local npcId = GetNpcIdFromGUID(guid) if not npcId or npcId <= 0 then return end -- Only learn this NPC if it carries the questgiver flag OR if it is already -- known in the database as a starter/finisher (so we can update its coords). local npcFlags = UnitNPCFlags and UnitNPCFlags("mouseover") or 0 local isQuestGiver = NpcFlagsHasQuestGiver(npcFlags) if not isQuestGiver then -- Silently check raw table — do NOT call GetNPC which logs CRITICAL for every miss local rawNpc = QuestieDB and QuestieDB.npcData and QuestieDB.npcData[npcId] if rawNpc and (rawNpc[10] or rawNpc[11]) then -- known quest starter (key 10) or quest ender (key 11) isQuestGiver = true end end if not isQuestGiver then return end local name = UnitName("mouseover") local level = UnitLevel("mouseover") local subName = UnitCreatureFamily and UnitCreatureFamily("mouseover") or nil local reaction = UnitReaction("mouseover", "player") local factionString = nil if reaction then if reaction >= 5 then factionString = UnitFactionGroup("player") == "Alliance" and "A" or "H" elseif reaction >= 4 then factionString = "AH" end end _Learner.guidNpcCache = _Learner.guidNpcCache or {} _Learner.guidNpcCache[guid] = { npcId = npcId, name = name, ts = time() } self:LearnNPC(npcId, name, level, subName, npcFlags, factionString) end function QuestieLearner:OnTargetChanged() if not UnitExists("target") or not UnitIsVisible("target") then return end if UnitIsPlayer("target") then return end local guid = UnitGUID("target") if not guid then return end if guid == _Learner._lastTargetGuid then return end _Learner._lastTargetGuid = guid local npcId = GetNpcIdFromGUID(guid) if not npcId or npcId <= 0 then return end local name = UnitName("target") local level = UnitLevel("target") _Learner.guidNpcCache = _Learner.guidNpcCache or {} _Learner.guidNpcCache[guid] = { npcId = npcId, name = name, ts = time() } end -- Collects all available quest data from the quest detail/offer screen (before accepting) function QuestieLearner:OnQuestDetail() local questId = GetQuestID and GetQuestID() if not questId or questId <= 0 then return end local data = {} data[1] = GetTitleText and GetTitleText() or nil -- requiredLevel and questLevel are not always available on the detail screen; -- they will be filled in by OnQuestAccepted from the quest log. data[6] = GetObjectiveText and GetObjectiveText() or nil -- objectives text -- Details/description text (body) if GetQuestDescription then data[17] = GetQuestDescription() end -- Record current zone as zoneOrSort if not already set local zoneId = GetZoneId() if zoneId and zoneId > 0 then data[8] = zoneId end self:LearnQuest(questId, data) -- Identify the quest giver NPC or object local npcGuid = UnitGUID("npc") if npcGuid then local entityId, unitType = GetIdAndTypeFromGUID(npcGuid) if entityId and entityId > 0 then local entityName = UnitName("npc") if unitType == "GameObject" then self:LearnQuestGiver(questId, entityId, 2, true) self:LearnObject(entityId, entityName) elseif unitType == "Creature" or unitType == "Vehicle" then self:LearnQuestGiver(questId, entityId, 1, true) local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 2 self:LearnNPC(entityId, entityName, nil, nil, npcFlags, nil) end end end end function QuestieLearner:OnQuestComplete() local questId = GetQuestID and GetQuestID() if not questId or questId <= 0 then return end -- Capture completion/finish text local data = {} if GetRewardText then data[18] = GetRewardText() end self:LearnQuest(questId, data) -- Identify the quest turn-in NPC or object local npcGuid = UnitGUID("npc") if npcGuid then local entityId, unitType = GetIdAndTypeFromGUID(npcGuid) if entityId and entityId > 0 then local entityName = UnitName("npc") if unitType == "GameObject" then self:LearnQuestGiver(questId, entityId, 2, false) self:LearnObject(entityId, entityName) elseif unitType == "Creature" or unitType == "Vehicle" then self:LearnQuestGiver(questId, entityId, 1, false) local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 2 self:LearnNPC(entityId, entityName, nil, nil, npcFlags, nil) end end end end -- Fires when a quest is accepted; in 3.3.5 the first (and only) arg IS the questID directly function QuestieLearner:OnQuestAccepted(questId) -- Fallback: scan log for an unknown quest in case the server sends log index instead if not questId or questId <= 0 then for i = 1, GetNumQuestLogEntries() do local _, _, _, _, isHeader, _, _, _, id = GetQuestLogTitle(i) if not isHeader and id and id > 0 and not Questie.db.global.learnedData.quests[id] then if not (QuestieDB and QuestieDB.questData and QuestieDB.questData[id]) then questId = id break end 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) local data = {} for i = 1, GetNumQuestLogEntries() do local title, level, _, _, isHeader, _, _, _, id = 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 self:LearnQuest(questId, data) -- Associate the quest giver: prefer live UnitGUID("npc"), fall back to last gossip entity -- (for Objectives Board quests, GOSSIP_CLOSED fires before QUEST_ACCEPTED so "npc" is nil) local npcGuid = UnitGUID("npc") local giverEntity = nil if npcGuid then local entityId, unitType = GetIdAndTypeFromGUID(npcGuid) if entityId and entityId > 0 then giverEntity = { id = entityId, name = UnitName("npc"), unitType = unitType } end end if not giverEntity and _Learner._lastGossipEntity then giverEntity = _Learner._lastGossipEntity end if giverEntity then if giverEntity.unitType == "GameObject" then self:LearnQuestGiver(questId, giverEntity.id, 2, true) self:LearnObject(giverEntity.id, giverEntity.name) elseif giverEntity.unitType == "Creature" or giverEntity.unitType == "Vehicle" then self:LearnQuestGiver(questId, giverEntity.id, 1, true) local npcFlags = (npcGuid and UnitNPCFlags and UnitNPCFlags("npc")) or 1 self:LearnNPC(giverEntity.id, giverEntity.name, nil, nil, npcFlags, nil) end end end -- Fires when any quest is turned in (covers auto-complete quests that skip the QUEST_COMPLETE dialog) function QuestieLearner:OnQuestTurnedIn(questId, xpReward, moneyReward) if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnQuests then return end if not questId or questId <= 0 then return end local data = {} -- Capture turn-in NPC/object while the gossip unit is still set local npcGuid = UnitGUID("npc") if npcGuid then local entityId, unitType = GetIdAndTypeFromGUID(npcGuid) if entityId and entityId > 0 then local entityName = UnitName("npc") if unitType == "GameObject" then self:LearnQuestGiver(questId, entityId, 2, false) self:LearnObject(entityId, entityName) elseif unitType == "Creature" or unitType == "Vehicle" then self:LearnQuestGiver(questId, entityId, 1, false) local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 2 self:LearnNPC(entityId, entityName, nil, nil, npcFlags, nil) end end end self:LearnQuest(questId, data) end -- Loot handler with async GetItemInfo retry function QuestieLearner:OnLootOpened() if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnItems then return end local targetGuid = UnitGUID("target") local npcId = targetGuid and GetNpcIdFromGUID(targetGuid) or nil -- Also try to record the object if target is a game object if targetGuid then local objId = GetObjectIdFromGUID(targetGuid) if objId and objId > 0 then local objName = UnitName("target") self:LearnObject(objId, objName) end end local numItems = GetNumLootItems() for i = 1, numItems do local _, lootName, _, _, lootQuality = GetLootSlotInfo(i) if lootName then local link = GetLootSlotLink(i) if link then local itemId = tonumber(string.match(link, "item:(%d+)")) if itemId and itemId > 0 then local itemName, _, _, itemLevel, requiredLevel, _, _, _, _, _, _, itemClassId, itemSubClassId = GetItemInfo(link) if itemName then -- Only record quest items (class 12) if itemClassId == 12 then self:LearnItem(itemId, itemName, itemLevel, requiredLevel, itemClassId, itemSubClassId) if npcId then self:LearnItemDrop(itemId, npcId) end end else -- GetItemInfo returned nil; queue for retry (class check happens on retry) table.insert(_Learner.pendingItemLinks, { link = link, itemId = itemId, npcId = npcId }) end end end end end end function QuestieLearner:OnGossipShow() local npcGuid = UnitGUID("npc") if not npcGuid then return end local id, unitType = GetIdAndTypeFromGUID(npcGuid) if not id or id <= 0 then return end local name = UnitName("npc") -- Cache the last gossip entity so OnQuestAccepted can associate it after GOSSIP_CLOSED _Learner._lastGossipEntity = { id = id, name = name, unitType = unitType, guid = npcGuid } if unitType == "GameObject" then self:LearnObject(id, name) elseif unitType == "Creature" or unitType == "Vehicle" then local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 1 self:LearnNPC(id, name, nil, nil, npcFlags, nil) end end -- Resolves pending item info once the client has cached it function QuestieLearner:OnGetItemInfoReceived(itemId) if not _Learner.pendingItemLinks then return end local remaining = {} for _, entry in ipairs(_Learner.pendingItemLinks) do if entry.itemId == itemId then local itemName, _, _, itemLevel, requiredLevel, _, _, _, _, _, _, itemClassId, itemSubClassId = GetItemInfo(entry.link) if itemName then -- Only record quest items (class 12) if itemClassId == 12 then self:LearnItem(itemId, itemName, itemLevel, requiredLevel, itemClassId, itemSubClassId) if entry.npcId then self:LearnItemDrop(itemId, entry.npcId) end end else table.insert(remaining, entry) -- still not cached, keep end else table.insert(remaining, entry) end end _Learner.pendingItemLinks = remaining 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 end function QuestieLearner:OnCombatLogEvent(...) local args = { CombatLogGetCurrentEventInfo and CombatLogGetCurrentEventInfo() or ... } local event = args[2] local destGUID = args[8] local destName = args[9] if event ~= "UNIT_DIED" or not destGUID then return end local npcId = GetNpcIdFromGUID(destGUID) -- Fallback: GUID-keyed cache from OnTargetChanged / OnMouseoverUnit if not npcId and _Learner.guidNpcCache then local cached = _Learner.guidNpcCache[destGUID] if cached then npcId = cached.npcId if not destName then destName = 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 -- 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 end end end Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Kill recorded: NPC", npcId, destName) self:LearnNPC(npcId, destName, nil, nil, nil, nil) end ------------------------------------------------------------------------ -- Event registration ------------------------------------------------------------------------ function QuestieLearner:RegisterEvents() local frame = CreateFrame("Frame", "QuestieLearnerFrame") frame:RegisterEvent("UPDATE_MOUSEOVER_UNIT") frame:RegisterEvent("PLAYER_TARGET_CHANGED") frame:RegisterEvent("QUEST_DETAIL") frame:RegisterEvent("QUEST_COMPLETE") frame:RegisterEvent("QUEST_TURNED_IN") frame:RegisterEvent("QUEST_ACCEPTED") frame:RegisterEvent("LOOT_OPENED") frame:RegisterEvent("GOSSIP_SHOW") frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") frame:RegisterEvent("GET_ITEM_INFO_RECEIVED") frame:SetScript("OnEvent", function(_, event, ...) if event == "UPDATE_MOUSEOVER_UNIT" then self:OnMouseoverUnit() elseif event == "PLAYER_TARGET_CHANGED" then self:OnTargetChanged() elseif event == "QUEST_DETAIL" then self:OnQuestDetail() elseif event == "QUEST_COMPLETE" then self:OnQuestComplete() elseif event == "QUEST_TURNED_IN" then self:OnQuestTurnedIn(...) elseif event == "QUEST_ACCEPTED" then self:OnQuestAccepted(...) elseif event == "LOOT_OPENED" then self:OnLootOpened() elseif event == "GOSSIP_SHOW" then self:OnGossipShow() elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then self:OnCombatLogEvent(...) elseif event == "GET_ITEM_INFO_RECEIVED" then local itemId = ... self:OnGetItemInfoReceived(itemId) end end) Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Events registered") end ------------------------------------------------------------------------ -- Initialize ------------------------------------------------------------------------ function QuestieLearner:Initialize() EnsureLearnedData() QuestieLearner.data = Questie.db.global.learnedData self:RegisterEvents() self:InjectLearnedData() Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Initialized") end ------------------------------------------------------------------------ -- Network bridge ------------------------------------------------------------------------ function _Learner:BroadcastIfCommsAvailable(typ, id, data) local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms") if QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData then local op = (data.mc and data.mc > 1) and "UPDATE" or "NEW" QuestieLearnerComms:BroadcastLearnedData(op, typ, id, data) end end -- Receives validated, decoded data from QuestieLearnerComms or QuestieLearnerExport:MergeImport function QuestieLearner:HandleNetworkData(typ, id, d) 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 local store if typ == "NPC" then if not Questie.db.global.learnedData.settings.learnNpcs then return end store = Questie.db.global.learnedData.npcs elseif typ == "QUEST" then if not Questie.db.global.learnedData.settings.learnQuests then return end store = Questie.db.global.learnedData.quests elseif typ == "ITEM" then if not Questie.db.global.learnedData.settings.learnItems then return end store = Questie.db.global.learnedData.items elseif typ == "OBJECT" then if not Questie.db.global.learnedData.settings.learnObjects then return end store = Questie.db.global.learnedData.objects else return end local existing = store[id] if not existing then store[id] = d store[id].mc = 1 self:InjectLearnedData() QuestieLearner.data = Questie.db.global.learnedData return end -- 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 end end -- Merge coordinates 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 {} 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]) end end end -- Merge item drop list if typ == "ITEM" and type(d[2]) == "table" then existing[2] = existing[2] or {} for _, npcId in ipairs(d[2]) do local found = false 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 end end existing.mc = (existing.mc or 0) + 1 QuestieLearner.data = Questie.db.global.learnedData end return QuestieLearner