---@class QuestieLearner 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") ---@type l10n local l10n = QuestieLoader:ImportModule("l10n") 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 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 -- Minimum match count (Confidence) for a learned pin to appear on the map. local MIN_CONFIDENCE_PINS = 2 _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, 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 = 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 ------------------------------------------------------------------------ local function EnsureLearnedData() if not Questie.db or not Questie.dbLearner then return false end -- Migration: If data exists in the old QuestieConfig.global.learnedData, move it to the new QuestieLearnerDB.global if Questie.db.global.learnedData then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Migrating learnedData to separate SavedVariable...") for k, v in pairs(Questie.db.global.learnedData) do Questie.dbLearner.global[k] = v end Questie.db.global.learnedData = nil Questie:Print("|cFF5EBAF3Questie-X:|r Learned data has been migrated to a separate SavedVariable for better performance.") end local ld = Questie.dbLearner.global if (not ld.npcs) and (not ld.quests) then ld.npcs = {} ld.quests = {} ld.items = {} ld.objects = {} ld.settings = { enabled = true, learnNpcs = true, learnQuests = true, learnItems = true, learnObjects = true, minConfidencePins = 2, prioritizeMyData = true, staleThreshold = 90, -- days pruneVerified = false, -- protect verified data by default } 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 if s.minConfidencePins == nil then s.minConfidencePins = 2 end if s.prioritizeMyData == nil then s.prioritizeMyData = true end end return true end ------------------------------------------------------------------------ -- Public API ------------------------------------------------------------------------ function QuestieLearner:IsEnabled() if not EnsureLearnedData() then return false end return Questie.dbLearner.global.settings.enabled end function QuestieLearner:GetSettings() if not EnsureLearnedData() then return {} end return Questie.dbLearner.global.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.dbLearner.global 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, spawnX, spawnY, spawnZoneId) if not self:IsEnabled() then return end if not Questie.dbLearner.global.settings.learnNpcs then return end if not npcId or npcId <= 0 then return end -- 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.dbLearner.global.npcs[npcId] local isNew = existing == nil if not existing then existing = {} Questie.dbLearner.global.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 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.dbLearner.global.settings and Questie.dbLearner.global.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 ------------------------------------------------------------------------ -- 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 questId = tonumber(questId) if not questId or questId <= 0 then return end if not Questie.dbLearner.global.settings.learnQuests then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] LearnQuest blocked: learnQuests=", tostring(Questie.dbLearner.global.settings.learnQuests)) return end if not questId or questId <= 0 then return end local existing = Questie.dbLearner.global.quests[questId] local isNew = existing == nil if not existing then existing = {} Questie.dbLearner.global.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 end end 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 -- 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.dbLearner.global.settings.learnQuests then return end questId, entityId = tonumber(questId), tonumber(entityId) if not questId or questId <= 0 or not entityId or entityId <= 0 then return end local existing = Questie.dbLearner.global.quests[questId] if not existing then existing = {} Questie.dbLearner.global.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) -- 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, objectiveIndex) if not self:IsEnabled() then return end if not Questie.dbLearner.global.settings.learnQuests then return end questId, npcId = tonumber(questId), tonumber(npcId) objectiveIndex = tonumber(objectiveIndex) if not questId or questId <= 0 or not npcId or npcId <= 0 then return end -- 1. Persist to SavedVariables local existing = Questie.dbLearner.global.quests[questId] or {} Questie.dbLearner.global.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 -- 1.1 Persist exact objective index mapping if objectiveIndex then existing.objIndex = existing.objIndex or {} local entry = existing.objIndex[objectiveIndex] if not entry then existing.objIndex[objectiveIndex] = { type = "monster", id = npcId, text = objText or "" } elseif entry.id ~= npcId then -- Handle Kill Credit (multiple NPCs for one objective) if type(entry.id) == "number" then entry.id = { entry.id, npcId } entry.type = "killcredit" else local found = false for _, id in ipairs(entry.id) do if id == npcId then found = true; break end end if not found then table.insert(entry.id, npcId) end end end 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 -- Update live objIndex override if objectiveIndex then ovr.objIndex = ovr.objIndex or {} ovr.objIndex[objectiveIndex] = existing.objIndex[objectiveIndex] 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 ------------------------------------------------------------------------ -- Item learning ------------------------------------------------------------------------ function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemClass, itemSubClass) if not self:IsEnabled() then return end if not Questie.dbLearner.global.settings.learnItems then return end itemId = tonumber(itemId) if not itemId or itemId <= 0 then return end local existing = Questie.dbLearner.global.items[itemId] local isNew = existing == nil if not existing then existing = {} Questie.dbLearner.global.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 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 function QuestieLearner:LearnItemDrop(itemId, npcId) if not self:IsEnabled() then return end if not Questie.dbLearner.global.settings.learnItems then return end itemId, npcId = tonumber(itemId), tonumber(npcId) if not itemId or itemId <= 0 or not npcId or npcId <= 0 then return end local existing = Questie.dbLearner.global.items[itemId] if not existing then existing = {} Questie.dbLearner.global.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 ------------------------------------------------------------------------ -- Object learning ------------------------------------------------------------------------ function QuestieLearner:LearnObject(objectId, name) if not self:IsEnabled() then return end if not Questie.dbLearner.global.settings.learnObjects then return end objectId = tonumber(objectId) if not objectId or objectId <= 0 then return end local zoneId = GetZoneId() local x, y = GetPlayerCoords() local existing = Questie.dbLearner.global.objects[objectId] local isNew = existing == nil if not existing then existing = {} Questie.dbLearner.global.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.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 ------------------------------------------------------------------------ -- 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.dbLearner.global local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0 -- 1. NPCs local npcIdsToFix = {} for npcId, data in pairs(learned.npcs) do local nid = tonumber(npcId) if type(npcId) == "string" and nid then npcIdsToFix[npcId] = nid end self:Sanitize(data) if not QuestieDB.npcDataOverrides[nid or npcId] then QuestieDB.npcDataOverrides[nid or npcId] = data npcCount = npcCount + 1 else local existing = QuestieDB.npcDataOverrides[nid or 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 -- Adopt other fields if missing for k, v in pairs(data) do if k ~= "mc" and k ~= 7 and existing[k] == nil then existing[k] = v end end end end for old, new in pairs(npcIdsToFix) do learned.npcs[new] = learned.npcs[old] learned.npcs[old] = nil end -- 2. Quests local questIdsToFix = {} for questId, data in pairs(learned.quests) do local qid = tonumber(questId) if type(questId) == "string" and qid then questIdsToFix[questId] = qid end 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[qid or questId] then QuestieDB.questDataOverrides[qid or questId] = data questCount = questCount + 1 else local existing = QuestieDB.questDataOverrides[qid or 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 for old, new in pairs(questIdsToFix) do learned.quests[new] = learned.quests[old] learned.quests[old] = nil end -- 3. Items local itemIdsToFix = {} for itemId, data in pairs(learned.items) do local iid = tonumber(itemId) if type(itemId) == "string" and iid then itemIdsToFix[itemId] = iid end if not QuestieDB.itemDataOverrides[iid or itemId] then QuestieDB.itemDataOverrides[iid or itemId] = data itemCount = itemCount + 1 end end for old, new in pairs(itemIdsToFix) do learned.items[new] = learned.items[old] learned.items[old] = nil end -- 4. Objects local objectIdsToFix = {} for objectId, data in pairs(learned.objects) do local oid = tonumber(objectId) if type(objectId) == "string" and oid then objectIdsToFix[objectId] = oid end self:Sanitize(data) if not QuestieDB.objectDataOverrides[oid or objectId] then QuestieDB.objectDataOverrides[oid or objectId] = data objectCount = objectCount + 1 else local existing = QuestieDB.objectDataOverrides[oid or 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 -- 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 for old, new in pairs(objectIdsToFix) do learned.objects[new] = learned.objects[old] learned.objects[old] = nil 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.dbLearner.global 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.dbLearner.global.npcs = {} Questie.dbLearner.global.quests = {} Questie.dbLearner.global.items = {} Questie.dbLearner.global.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.dbLearner.global 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 = low32 % 8388608 if nid > 0 then return nid, t end end end end return nil, nil end -- Forward declarations for GUID parsing functions used by event handlers above. -- The full implementations are at lines 1567 and 1604. local GetNpcIdFromGUID = function(guid) if not guid or type(guid) ~= "string" then return nil end local strId = guid:match("Creature%-%d+%-%d+%-%d+%-%d+%-(%d+)") if strId then return tonumber(strId) end if guid:match("^0x") then local hex = guid:sub(3) local prefix = hex:sub(1, 4) 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 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 local GetObjectIdFromGUID = function(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 ------------------------------------------------------------------------ -- 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 zoneText = GetRealZoneText() local areaId = _Learner.zoneCache[zoneText] if not areaId then 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 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 -- Helper to find an NPC ID by name (case-insensitive substring match) -- Used for proactive objective mapping when a quest is first accepted. function QuestieLearner:GetNPCIdByName(npcName) if not npcName or npcName == "" then return nil end local lowerName = string.lower(npcName) -- Check overrides first (most likely for custom servers) if QuestieDB.npcDataOverrides then for id, data in pairs(QuestieDB.npcDataOverrides) do if data and data[1] and string.lower(data[1]) == lowerName then return id end end end -- Check base NPC database -- Note: QueryNPCSingle is a dummy until initialization, but we can fallback to raw access local npcData = QuestieDB.npcData or {} for id, data in pairs(npcData) do if data and data[1] and string.lower(data[1]) == lowerName then return id end end return nil end -- Fires when a quest is accepted. 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 = QuestieCompat.GetQuestLogTitle(i) if not isHeader and id == questId then data[1] = title data[5] = level and level > 0 and level or nil break end end -- Zone: reverse-lookup from GetRealZoneText() which is always accurate on 3.3.5. local zoneText = GetRealZoneText() if zoneText and zoneText ~= "" then local areaId = l10n:GetAreaIdByLocalName(zoneText) if areaId and areaId > 0 then data[17] = areaId end end self:LearnQuest(questId, data) -- Proactively map objectives based on quest log text local logIdx = 0 for i = 1, GetNumQuestLogEntries() do local _, _, _, isHeader, _, _, _, id = QuestieCompat.GetQuestLogTitle(i) if not isHeader and id == questId then logIdx = i break end end if logIdx > 0 then local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(logIdx) or 0 for j = 1, numObj do local objText, objType, finished = GetQuestLogLeaderBoard(j, logIdx) if objText and not finished and (objType == "monster" or objType == "killcredit") then local targetName = objText:match("^%d+/%d+%s+(.+)%s*") or objText:match("^(.+):%s*%d+/%d+") if not targetName then targetName = objText:gsub("%d+/%d+", ""):gsub("%d+", ""):gsub("[:!?,.%(%)]", ""):gsub("^%s+", ""):gsub("%s+$", "") end if targetName and targetName ~= "" then local npcId = nil -- For killcredit, try ID-based lookup first using quest objectives data if objType == "killcredit" then local quest = QuestieDB and QuestieDB.GetQuest and QuestieDB.GetQuest(questId) if quest and quest.ObjectiveData and quest.ObjectiveData[j] then local objData = quest.ObjectiveData[j] if objData.IdList then for _, possibleId in ipairs(objData.IdList) do if possibleId and possibleId > 0 then local npc = QuestieDB:GetNPC(possibleId) if npc and npc.name and string.lower(npc.name) == string.lower(targetName) then npcId = possibleId break end end end -- Fallback: try first valid ID in the list even if name doesn't match if not npcId then for _, possibleId in ipairs(objData.IdList) do if possibleId and possibleId > 0 then local npc = QuestieDB:GetNPC(possibleId) if npc then npcId = possibleId break end end end end end end end -- Fallback to name-based lookup if not npcId then npcId = self:GetNPCIdByName(targetName) end if npcId then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Proactively mapped objective", j, "to NPC", npcId, "(" .. targetName .. ")") self:LearnQuestObjectiveNPC(questId, npcId, objText, j) end end end end end -- 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.dbLearner.global.settings.learnQuests then return end questId = tonumber(questId) 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.dbLearner.global.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 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 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 ------------------------------------------------------------------------ -- 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 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 eventType == "SPELL_CAST_SUCCESS" then if srcGUID == UnitGUID("player") then self:LearnSpellCast(spellId, spellName, dstGUID, dstName) end return end if eventType ~= "PARTY_KILL" and eventType ~= "UNIT_DIED" then return end if not dstGUID then return end local npcId = GetNpcIdFromGUID(dstGUID) if not npcId and _Learner.guidNpcCache then local cached = _Learner.guidNpcCache[dstGUID] if cached then npcId = cached.npcId if not dstName then dstName = cached.name end end end if not npcId or npcId <= 0 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 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, j) _Learner.recentKills[bestGuid] = nil end _Learner.prevObjCounts[questId][j] = count end end end end end 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:RegisterEvent("UNIT_QUEST_LOG_CHANGED") frame:RegisterEvent("QUEST_REMOVED") 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) 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) Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Events registered") end ------------------------------------------------------------------------ -- Initialize ------------------------------------------------------------------------ function QuestieLearner:Initialize() EnsureLearnedData() QuestieLearner.data = Questie.dbLearner.global self:RegisterEvents() self:InjectLearnedData() local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms") if QuestieLearnerComms and QuestieLearnerComms.Initialize then QuestieLearnerComms:Initialize() end -- Start periodic cleanup ticker (every 30 mins) QuestieCompat.C_Timer.NewTicker(1800, function() self:PruneGuidNpcCache() end) -- Scan existing quests in log after initialization (deferred to ensure DB is ready) QuestieCompat.C_Timer.After(1, function() self:ScanExistingQuestLog() end) Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Initialized") end function QuestieLearner:ScanExistingQuestLog() if not self:IsEnabled() then return end if not Questie.dbLearner.global.settings.learnQuests then return end if not GetNumQuestLogEntries then return end Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Scanning existing quest log...") local count = 0 for i = 1, GetNumQuestLogEntries() do local title, level, _, isHeader, _, _, _, questId = QuestieCompat.GetQuestLogTitle(i) if not isHeader and questId and questId > 0 then -- Check if this quest needs objective mapping local existingData = Questie.dbLearner.global.quests[questId] local needsMapping = not existingData or not existingData.objIndex or not next(existingData.objIndex) if needsMapping then -- Use the existing OnQuestAccepted logic by manually triggering objective mapping local logIdx = i local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(logIdx) or 0 for j = 1, numObj do local objText, objType, finished = GetQuestLogLeaderBoard(j, logIdx) if objText and not finished and (objType == "monster" or objType == "killcredit") then local targetName = objText:match("^%d+/%d+%s+(.+)%s*") or objText:match("^(.+):%s*%d+/%d+") if not targetName then targetName = objText:gsub("%d+/%d+", ""):gsub("%d+", ""):gsub("[:!?,.%(%)]", ""):gsub("^%s+", ""):gsub("%s+$", "") end if targetName and targetName ~= "" then local npcId = nil -- For killcredit, try ID-based lookup first if objType == "killcredit" then local quest = QuestieDB and QuestieDB.GetQuest and QuestieDB.GetQuest(questId) if quest and quest.ObjectiveData and quest.ObjectiveData[j] then local objData = quest.ObjectiveData[j] if objData.IdList then for _, possibleId in ipairs(objData.IdList) do if possibleId and possibleId > 0 then local npc = QuestieDB:GetNPC(possibleId) if npc and npc.name and string.lower(npc.name) == string.lower(targetName) then npcId = possibleId break end end end -- Fallback: try first valid ID in the list if not npcId then for _, possibleId in ipairs(objData.IdList) do if possibleId and possibleId > 0 then local npc = QuestieDB:GetNPC(possibleId) if npc then npcId = possibleId break end end end end end end end -- Fallback to name-based lookup if not npcId then npcId = self:GetNPCIdByName(targetName) end if npcId then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Scanned existing quest", questId, "objective", j, "to NPC", npcId, "(" .. targetName .. ")") self:LearnQuestObjectiveNPC(questId, npcId, objText, j) count = count + 1 end end end end end end end if count > 0 then self:InjectLearnedData() Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Scanned existing quest log, mapped", count, "objectives") end 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, 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 local store if typ == "NPC" then if not Questie.dbLearner.global.settings.learnNpcs then return end store = Questie.dbLearner.global.npcs elseif typ == "QUEST" then if not Questie.dbLearner.global.settings.learnQuests then return end store = Questie.dbLearner.global.quests elseif typ == "ITEM" then if not Questie.dbLearner.global.settings.learnItems then return end store = Questie.dbLearner.global.items elseif typ == "OBJECT" then if not Questie.dbLearner.global.settings.learnObjects then return end store = Questie.dbLearner.global.objects else return end local existing = store[id] if not existing then store[id] = d store[id].mc = 1 self:InjectLearnedData() QuestieLearner.data = Questie.dbLearner.global 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 -- 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 {} local grid = GetCustomGridPrecision() for zoneId, coords in pairs(d[coordKey]) do existing[coordKey][zoneId] = existing[coordKey][zoneId] or {} for _, coord in ipairs(coords) do if InsertIfNewBucket(existing[coordKey][zoneId], coord[1], coord[2], grid) then changed = true end 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) changed = true end end end 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.dbLearner.global self:InjectLearnedData() end end return QuestieLearner