---@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") ---@type ZoneDB local ZoneDB = QuestieLoader:ImportModule("ZoneDB") local _Learner = QuestieLearner.private or {} 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 function IsAscensionProtected(dbType, id, key) local protected = QuestieDB and QuestieDB.ascensionOverrideKeys and QuestieDB.ascensionOverrideKeys[dbType] and QuestieDB.ascensionOverrideKeys[dbType][id] return protected and protected[key] == true end local function NormalizeSpawnZoneKey(zoneKey) -- Convert raw area IDs (e.g. 3431 from GetAreaID()) to the canonical map IDs -- used by AscensionDB and the rendering system (e.g. 1241 for Sunstrider Isle). -- ZoneDB.private.areaIdToUiMapId is the single source of truth for this mapping -- (zoneDB.lua: 3431→1241, 3430→1941, 668→1238, etc.). Using the same table -- ensures learner-stored zone keys are always valid for DrawWorldIcon / HBD. -- -- IMPORTANT: Never store raw area IDs (3430, 3431) in spawn data — the pin -- rendering pipeline only knows about map IDs (1241, 1941). Any future zone -- additions must be registered in ZoneDB.private.areaIdToUiMapId first. if ZoneDB and ZoneDB.private and ZoneDB.private.areaIdToUiMapId then local mapped = ZoneDB.private.areaIdToUiMapId[zoneKey] if mapped then return mapped end end return zoneKey end local function IsSunstriderNativeZone(zoneKey) return zoneKey == 1241 or zoneKey == 3431 end -- 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. -- Set to 1 so that even a single kill/mouseover confirms a spawn location on -- Ascension, where NPC databases are incomplete and every data point matters. local MIN_CONFIDENCE_PINS = 1 local function GetCoordGridForZone(zoneId) -- Sunstrider's starter mobs are packed tightly; a 2% bucket collapses -- distinct spawn points such as 58.68/43.19 and 59.11/44.00 into one pin. if IsSunstriderNativeZone(zoneId) then return 0.5 end return COORD_GRID end _Learner.pendingNpcs = {} _Learner.pendingQuests = {} _Learner.pendingItems = {} _Learner.pendingObjects = {} _Learner.pendingNetworkMerges = {} _Learner.pendingItemLinks = {} -- queue for async GetItemInfo retries _Learner.npcNameIndex = nil _Learner.npcNameIndexDirty = true -- Direct reference to learnedData, set on Initialize QuestieLearner.data = nil ------------------------------------------------------------------------ -- Coordinate helpers ------------------------------------------------------------------------ local function GetZoneId() -- Prefer the most specific zone available: GetRealZoneText() returns the -- sub-zone name when the player is on a child map (e.g. "Sunstrider Isle" -- on map 1241 → areaId 3431), and the parent zone name otherwise (e.g. -- "Eversong Woods" → areaId 3430). Using the sub-zone is correct because -- Questie resolves subzones to parents via GetParentZoneId() automatically. local zoneText = GetRealZoneText and GetRealZoneText() or "" if zoneText ~= "" then local areaId = _Learner.zoneCache[zoneText] if not areaId and l10n and l10n.GetAreaIdByLocalName then areaId = l10n:GetAreaIdByLocalName(zoneText) if areaId and areaId > 0 then _Learner.zoneCache[zoneText] = areaId end end if areaId and areaId > 0 then return areaId end end -- Fallback: uiMapId-based conversion (e.g. 1241→3431 for Sunstrider). local uiMapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") if uiMapId then if ZoneDB and ZoneDB.GetAreaIdByUiMapId then local areaId = ZoneDB:GetAreaIdByUiMapId(uiMapId) if areaId and areaId > 0 then return areaId end end return uiMapId -- fallback: no ZoneDB mapping available end -- Lua 5.0 compat: replace select(8, GetInstanceInfo()) with explicit GetInstanceInfo unpack local _, _, _, _, _, _, _, instanceMapID = GetInstanceInfo() return instanceMapID 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 local function NormalizeCoordValue(value) local coord = tonumber(value) if not coord or coord <= 0 then return nil end -- Native map APIs return 0-1, Questie stores 0-100, and a previous -- learner path accidentally persisted 0-10000 values like 5868. if coord <= 1 then coord = coord * 100 elseif coord > 100 then coord = coord / 100 end if coord <= 0 or coord > 100 then return nil end return floor(coord * 100 + 0.5) / 100 end local function NormalizeCoordPair(x, y) local nx = NormalizeCoordValue(x) local ny = NormalizeCoordValue(y) if not nx or not ny then return nil, nil end return nx, ny end local function CopyWithoutField(data, skippedKey) local copy = {} for key, value in pairs(data) do if key ~= skippedKey then copy[key] = value end end return copy end local function DeepCopy(value) if type(value) ~= "table" then return value end local copy = {} for key, child in pairs(value) do copy[key] = DeepCopy(child) end return copy 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) x, y = NormalizeCoordPair(x, y) if not x or not y then return false end local grid = customGrid or COORD_GRID local bx, by = floor(x / grid) * grid, floor(y / grid) * grid for _, coord in ipairs(coordList) do local existingX, existingY = NormalizeCoordPair(coord[1], coord[2]) if existingX and existingY then coord[1], coord[2] = existingX, existingY end local cx, cy = floor((existingX or coord[1]) / grid) * grid, floor((existingY or coord[2]) / grid) * grid if cx == bx and cy == by then return false end end table.insert(coordList, {x, y}) return true end local function CountUniqueSpawnPositions(spawns) if type(spawns) ~= "table" then return 0 end local seen = {} local count = 0 for _, coords in pairs(spawns) do if type(coords) == "table" then for _, coord in ipairs(coords) do local x, y = NormalizeCoordPair(coord[1], coord[2]) if x and y then local key = tostring(x) .. "," .. tostring(y) if not seen[key] then seen[key] = true count = count + 1 end end end end end return count 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 = 1, prioritizeMyData = true, staleThreshold = 90, -- days pruneVerified = false, -- protect verified data by default performanceMode = "balanced", pinRefreshDelay = 0.5, pinRefreshMode = "batched", liveNpcUpdateDelay = 0.5, learnerCommsIntensity = "normal", } 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 = 1 end if s.prioritizeMyData == nil then s.prioritizeMyData = true end if s.staleThreshold == nil then s.staleThreshold = 90 end if s.pruneVerified == nil then s.pruneVerified = false end if s.performanceMode == nil then s.performanceMode = "balanced" end if s.pinRefreshDelay == nil then s.pinRefreshDelay = 0.5 end if s.pinRefreshMode == nil then s.pinRefreshMode = "batched" end if s.liveNpcUpdateDelay == nil then s.liveNpcUpdateDelay = 0.5 end if s.learnerCommsIntensity == nil then s.learnerCommsIntensity = "normal" 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 local function GetLearnerSetting(key, defaultValue) if Questie and Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings then local value = Questie.dbLearner.global.settings[key] if value ~= nil then return value end end return defaultValue 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 _pendingQuestPinRefreshes = {} local _pendingQuestPinRefreshTimer = nil local _pendingQuestFrameUnloads = {} local function _FlushActiveQuestPins() local questIdSet = _pendingQuestPinRefreshes _pendingQuestPinRefreshes = {} _pendingQuestPinRefreshTimer = nil if not next(questIdSet) then return end if not QuestieQuest or not QuestieQuest.UpdateQuest then return end if not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end for questId in pairs(questIdSet) do if QuestiePlayer.currentQuestlog[questId] then if QuestieMap and QuestieMap.UnloadQuestFrames and _pendingQuestFrameUnloads[questId] then QuestieMap:UnloadQuestFrames(questId) end QuestieQuest:UpdateQuest(questId) end end _pendingQuestFrameUnloads = {} end local function _RefreshActiveQuestPins(questIdSet) -- Skip scheduling when the set is empty (avoids no-op timer callbacks) if not next(questIdSet) then return end if GetLearnerSetting("pinRefreshMode", "batched") == "manual" then for questId in pairs(questIdSet) do _pendingQuestFrameUnloads[questId] = nil end return end for questId in pairs(questIdSet) do _pendingQuestPinRefreshes[questId] = true end if _pendingQuestPinRefreshTimer then return end local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer) if timer and timer.After then _pendingQuestPinRefreshTimer = true timer.After(GetLearnerSetting("pinRefreshDelay", 0.5), _FlushActiveQuestPins) else _FlushActiveQuestPins() end end -- Invalidates cached objective.spawnList for any active quest whose objectives -- reference the given npcId. Unloads existing world/minimap icons, resets -- tooltip registration, and forces the map system to rebuild spawn lists from -- QuestieDB on the next update — picking up newly learned coordinates in real time. -- Helper: check if a single objective references the given npcId and, if so, -- unload its icons and reset its cached spawnList so the map rebuilds it. -- Returns true if the objective was invalidated. local function _TryInvalidateObjective(objective, npcId, quest) local shouldInvalidate = false -- Monster objectives reference NPCs directly in spawnList keys if objective.spawnList then if objective.spawnList[npcId] then shouldInvalidate = true end -- Also check killcredit IdList if not shouldInvalidate and objective.IdList then for _, id in ipairs(objective.IdList) do if id == npcId then shouldInvalidate = true; break end end end else -- spawnList is nil (first-ever encounter, never populated). -- Check the quest's ObjectiveData for NPC references so killcredit -- and item objectives are still invalidated on the first kill. if quest and quest.ObjectiveData and objective.Index then local objData = quest.ObjectiveData[objective.Index] if objData and objData.IdList then for _, id in ipairs(objData.IdList) do if id == npcId then shouldInvalidate = true; break end end end -- Also match the primary objective Id (e.g. single-target monster objectives) if not shouldInvalidate and objData and objData.Id == npcId then shouldInvalidate = true end end end -- Fallback: if objective Id matches the NPC (some objectives use NPC as their primary Id) if not shouldInvalidate and objective.Id == npcId then shouldInvalidate = true end if shouldInvalidate then -- Unload existing icons manually so frames are removed from map/minimap. -- We can't call QuestieQuest's local _UnloadAlreadySpawnedIcons from here, -- so we iterate the refs directly. if objective.AlreadySpawned then for _, spawn in pairs(objective.AlreadySpawned) do if spawn then if spawn.mapRefs then for _, mapIcon in ipairs(spawn.mapRefs) do if mapIcon and mapIcon.Unload then mapIcon:Unload() end end end if spawn.minimapRefs then for _, minimapIcon in ipairs(spawn.minimapRefs) do if minimapIcon and minimapIcon.Unload then minimapIcon:Unload() end end end end end end objective.spawnList = nil objective.AlreadySpawned = {} -- empty table, NOT nil (_DetermineIconsToDraw indexes this) objective.hasRegisteredTooltips = false objective.registeredItemTooltips = false end return shouldInvalidate end ------------------------------------------------------------------------ -- Debounce table: tracks pending (npcId, questId) invalidate pairs. -- Prevents scheduling multiple unload+refresh cycles for the same -- (npcId, questId) within the debounce window. local _invalidateDebounce = {} local function _InvalidateSpawnListsForNPC(npcId) if not QuestieQuest or not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer) local now = (GetTime and GetTime()) or 0 local debounceWindow = 0.5 -- seconds; coalesces rapid-fire calls local questsToRefresh = {} for questId, _ in pairs(QuestiePlayer.currentQuestlog) do local quest = QuestieDB.GetQuest and QuestieDB.GetQuest(questId) if quest then local needsUnload = false -- Scan standard Objectives if quest.Objectives then for _, objective in pairs(quest.Objectives) do if _TryInvalidateObjective(objective, npcId, quest) then needsUnload = true end end end -- Scan SpecialObjectives (demonic runestones, custom Ascension objectives, etc.) if quest.SpecialObjectives then for _, objective in pairs(quest.SpecialObjectives) do if _TryInvalidateObjective(objective, npcId, quest) then needsUnload = true end end end if needsUnload then -- Debounce frame unload only. The spawnList is always cleared by -- _TryInvalidateObjective (runs before this), so a debounced second call -- clears an already-nil spawnList safely. The refresh, however, must always -- fire so the map is eventually rebuilt with the latest npcDataOverrides -- data (already written by LearnNPC before this call). local debounceKey = npcId .. ":" .. questId local suppressUnload = _invalidateDebounce[debounceKey] and (now - _invalidateDebounce[debounceKey]) < debounceWindow if not suppressUnload then _invalidateDebounce[debounceKey] = now if GetLearnerSetting("pinRefreshMode", "batched") == "immediate" and QuestieMap and QuestieMap.UnloadQuestFrames then QuestieMap:UnloadQuestFrames(questId) else _pendingQuestFrameUnloads[questId] = true end end questsToRefresh[questId] = true -- always refresh when data changed end end end -- _RefreshActiveQuestPins guards against empty set internally _RefreshActiveQuestPins(questsToRefresh) -- Prune stale debounce keys (>2x window age) to prevent unbounded growth. -- Keys expire naturally after their window; this is just cleanup. if timer then for key, ts in pairs(_invalidateDebounce) do if (now - ts) > (debounceWindow * 2) then _invalidateDebounce[key] = nil end end end end ------------------------------------------------------------------------ -- Live learner update batching -- -- Kill/loot bursts can call LearnNPC multiple times for the same NPC: -- combat-log kill, quest-log objective progress, and loot correlation may all -- arrive within a few frames. Saved learner evidence is updated immediately, -- but live QuestieDB override/cache invalidation is batched so the large DB -- layer is not churned on every single kill. local function _ApplyNpcLiveUpdate(npcId) local existing = Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.npcs and Questie.dbLearner.global.npcs[npcId] if not existing then return false end local threshold = (Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.minConfidencePins) or MIN_CONFIDENCE_PINS if existing.mc < threshold then return false end if not (QuestieDB and QuestieDB.npcDataOverrides and existing[7] and next(existing[7])) then return false end local ovr = QuestieDB.npcDataOverrides[npcId] if not ovr then if IsAscensionProtected("NPC", npcId, 7) then QuestieDB.npcDataOverrides[npcId] = DeepCopy(CopyWithoutField(existing, 7)) else QuestieDB.npcDataOverrides[npcId] = DeepCopy(existing) end else -- Merge: fill missing fields; also overwrite empty-string names. for k, v in pairs(existing) do if k ~= 7 and not IsAscensionProtected("NPC", npcId, k) and (ovr[k] == nil or (k == 1 and ovr[k] == "")) then ovr[k] = DeepCopy(v) end end -- Always merge spawn coords. if existing[7] and not IsAscensionProtected("NPC", npcId, 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], GetCoordGridForZone(zid)) end end end end -- Clear the compiled DB cache once per flush so GetNPC rebuilds with the -- latest coalesced override data instead of once per kill. if QuestieDB.private and QuestieDB.private.npcCache then QuestieDB.private.npcCache[npcId] = nil end return true end local function _FlushNpcLiveUpdates() local pending = _Learner.pendingNpcLiveUpdates _Learner.pendingNpcLiveUpdates = {} _Learner.pendingNpcLiveUpdateTimer = nil for npcId in pairs(pending) do if _ApplyNpcLiveUpdate(npcId) then _InvalidateSpawnListsForNPC(npcId) end end end local function _QueueNpcLiveUpdate(npcId) _Learner.pendingNpcLiveUpdates = _Learner.pendingNpcLiveUpdates or {} _Learner.pendingNpcLiveUpdates[npcId] = true if _Learner.pendingNpcLiveUpdateTimer then return end local timer = QuestieCompat and QuestieCompat.C_Timer if timer and timer.After then _Learner.pendingNpcLiveUpdateTimer = true timer.After(GetLearnerSetting("liveNpcUpdateDelay", 0.5), _FlushNpcLiveUpdates) else _FlushNpcLiveUpdates() end end local function _MarkNpcNameIndexDirty() _Learner.npcNameIndexDirty = true end local function _RebuildNpcNameIndex() local overrideIndex = {} local baseIndex = {} if QuestieDB and QuestieDB.npcDataOverrides then for npcId, data in pairs(QuestieDB.npcDataOverrides) do local name = data and data[1] if type(name) == "string" and name ~= "" then overrideIndex[string.lower(name)] = npcId end end end if QuestieDB and QuestieDB.npcData then for npcId, data in pairs(QuestieDB.npcData) do local name = data and data[1] if type(name) == "string" and name ~= "" then local lowerName = string.lower(name) if overrideIndex[lowerName] == nil and baseIndex[lowerName] == nil then baseIndex[lowerName] = npcId end end end end _Learner.npcNameIndex = { override = overrideIndex, base = baseIndex, } _Learner.npcNameIndexDirty = false end local function _EnsureNpcNameIndex() if _Learner.npcNameIndex and not _Learner.npcNameIndexDirty then return _Learner.npcNameIndex end _RebuildNpcNameIndex() return _Learner.npcNameIndex 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 ------------------------------------------------------------------------ -- Player-spawned NPCs that should never be learned (totems, guardians, etc.) local PLAYER_SPAWNED_NPC_SET = { [2523] = true, -- Searing Totem [2630] = true, -- Earthbind Totem [10183] = true, -- Moonflare Totem [1103907] = true, -- Healing Stream Totem III [1107398] = true, -- Stoneclaw Totem V } 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 -- Never learn player-spawned totems if PLAYER_SPAWNED_NPC_SET[npcId] then return end -- Use provided spawn coords (e.g. from kill event) or fall back to current player position. -- Normalize area IDs → map IDs immediately so all storage uses the same key space -- as AscensionDB (e.g. 3431 → 1241, 3430 → 1941). local zoneId = NormalizeSpawnZoneKey(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 name ~= "" and (not existing[1] or existing[1] == "") then existing[1] = name _MarkNpcNameIndexDirty() 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 spawnX and spawnY and 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, GetCoordGridForZone(zoneId)) end existing.ls = time() -- Update last seen existing.mc = (existing.mc or 0) + 1 -- Live injection is intentionally batched: repeated kill/log/loot events for -- the same NPC update saved evidence immediately, then flush QuestieDB once. _QueueNpcLiveUpdate(npcId) if isNew then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New NPC learned:", npcId, name or "?") CrossLinkAfterNPC(npcId) end _Learner:BroadcastIfCommsAvailable("NPC", npcId, existing) end ------------------------------------------------------------------------ -- Phase 2: Per-GUID spawn evidence ------------------------------------------------------------------------ -- Extracts the spawn UID (low 24 bits / last 6 hex chars) from a WoW GUID. -- This is the per-spawn-instance identifier — different GUIDs for the same -- npcId indicate different spawn points (e.g. three boars at three corners -- of a field, not one boar teleporting around). -- IMPORTANT: Do not revert this to a single GUID format. Ascension clients -- may emit either dashed GUIDs or compact hex GUIDs ("0x..."), and both must -- remain supported or GUID-based learner evidence will silently stop storing. -- Format: "Creature-0-RR-RI-0-NNNNNNNN" where NNNNNNNN = spawn UID local function _ExtractSpawnUID(guid) if not guid or type(guid) ~= "string" then return nil end -- Client-arg combat log GUIDs on Ascension commonly arrive as compact hex -- strings (e.g. "0xF130003BAA009E40"). Use the low 24 bits so different -- spawn instances of the same npcId still resolve to distinct evidence keys. local hexTail = guid:match("^0x%x+(%x%x%x%x%x%x)$") if hexTail then return tonumber(hexTail, 16) end -- Dash format: Creature-0-1234-567-89-21878-0000001234 -- Last numeric segment after the 5th dash is the spawn UID local spawnUID = guid:match("^[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-(%d+)$") if spawnUID then return tonumber(spawnUID) end -- Fallback for other dash formats: take everything after the last dash spawnUID = guid:match("^.+%-(%d+)$") if spawnUID then return tonumber(spawnUID) end return nil end -- Ensures entry[8] (guidSpawns) exists on the learned NPC data table. -- entry[8] schema: { [spawnUID] = { zoneId, x, y, ts, source, confidence } } local function _GetOrCreateGuidSpawnTable(entry) if not entry then return nil end if not entry[8] then entry[8] = {} end return entry[8] end -- Stores a per-GUID kill event under the learned NPC data. -- Each unique spawn UID (per npcId per zone) gets one evidence entry. -- On repeated kills at the same spawn UID, the entry is updated (moved to -- the current position if the NPC has wandered slightly, timestamp refreshed). -- Storage is bounded: max MAX_GUID_SPAWNS_PER_NPC_PER_ZONE entries per zone; -- oldest entry evicted when at capacity. local MAX_GUID_SPAWNS_PER_NPC_PER_ZONE = 8 function QuestieLearner:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, x, y) if not self:IsEnabled() then return end if not npcId or npcId <= 0 then return end if not dstGUID or type(dstGUID) ~= "string" then return end if not zoneId or zoneId <= 0 then return end if not x or not y or x <= 0 or y <= 0 then return end -- Normalize area IDs → map IDs so evidence is keyed identically to AscensionDB. -- Without this, kills on Sunstrider store under zone 3431 (area ID) while the -- renderer expects zone 1241 (map ID), causing pins to silently not appear. zoneId = NormalizeSpawnZoneKey(zoneId) local spawnUID = _ExtractSpawnUID(dstGUID) if not spawnUID then return end local learnedNpc = Questie.dbLearner.global.npcs[npcId] if not learnedNpc then return end local guidSpawns = _GetOrCreateGuidSpawnTable(learnedNpc) if not guidSpawns then return end -- Normalize coords to Questie's 0-100 map scale. NormalizeCoordPair handles all -- three input formats: native 0-1, already-scaled 0-100, or buggy 0-10000. local nx, ny = NormalizeCoordPair(x, y) if not nx or not ny then return end -- DEBUG: log raw x/y and normalized nx/ny being stored -- Questie:Debug(Questie.DEBUG_LEARNER, -- "_StoreGuidSpawnEvidence: npcId=", npcId, -- "spawnUID=", spawnUID, -- "x=", x, "y=", y, -- "nx=", nx, "ny=", ny, -- "zoneId=", zoneId) if guidSpawns[spawnUID] then -- Existing spawn UID: update position and timestamp guidSpawns[spawnUID].x = nx guidSpawns[spawnUID].y = ny guidSpawns[spawnUID].ts = time() else -- New spawn UID: insert, bounded by npcId+zoneId. -- Evict oldest entry if at capacity. local zoneCount = 0 local oldestUID = nil local oldestTS = nil for uid in pairs(guidSpawns) do local e = guidSpawns[uid] if e and e.zoneId == zoneId then zoneCount = zoneCount + 1 if not oldestTS or e.ts < oldestTS then oldestTS = e.ts oldestUID = uid end end end if zoneCount >= MAX_GUID_SPAWNS_PER_NPC_PER_ZONE and oldestUID then guidSpawns[oldestUID] = nil end guidSpawns[spawnUID] = { zoneId = zoneId, x = nx, y = ny, ts = time(), source = "local", confidence = 1, } end end ------------------------------------------------------------------------ -- Phase 3: Weighted spawn merge ------------------------------------------------------------------------ -- Merges learned GUID-based spawn evidence into the static NPC spawn list. -- Scoring: spawn UID with most evidence across all zones wins. -- Override condition: top spawn appears in >60% of total evidence AND -- differs from static DB entry. Below 60% confidence, learned does not -- override static and both sources coexist. -- Called from the kill handler after _StoreGuidSpawnEvidence when -- evidence count >= 3 for that npcId. --@param npcId number The NPC ID to merge evidence for --@return boolean True if static DB spawn list was overridden local function _MergeSpawnEvidence(npcId) if not npcId or npcId <= 0 then return false end local learnedNpc = Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.npcs[npcId] if not learnedNpc then return false end local guidSpawns = learnedNpc[8] if not guidSpawns or not next(guidSpawns) then return false end -- Collect all evidence: group by (zoneId, x, y) rounded to 2 decimal places -- Key format: "zoneId|x|y" → count local evidence = {} -- { [key] = { zoneId, x, y, count } } local totalEvidence = 0 for spawnUID, entry in pairs(guidSpawns) do if entry and entry.zoneId and entry.x and entry.y then local evidenceX, evidenceY = NormalizeCoordPair(entry.x, entry.y) if not evidenceX or not evidenceY then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence skipping invalid coords: spawnUID=", spawnUID, "entry.x=", tostring(entry.x), "entry.y=", tostring(entry.y)) else entry.x = evidenceX entry.y = evidenceY local rx, ry = NormalizeCoordPair(evidenceX, evidenceY) local key = entry.zoneId .. "|" .. tostring(rx) .. "|" .. tostring(ry) -- DEBUG: log each entry being grouped Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence GROUPING: spawnUID=", spawnUID, "entry.x=", entry.x, "entry.y=", entry.y, "rx=", rx, "ry=", ry, "key=", key) if not evidence[key] then evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 } end evidence[key].count = evidence[key].count + 1 totalEvidence = totalEvidence + 1 end end end if totalEvidence < 3 then return false end -- Find top-scoring spawn local topKey = nil local topCount = 0 for key, e in pairs(evidence) do if e.count > topCount then topCount = e.count topKey = key end end if not topKey then return false end local topEvidence = evidence[topKey] local topPct = (topCount / totalEvidence) * 100 -- DEBUG: log topEvidence raw values to diagnose coordinate corruption Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence DEBUG: topKey=", topKey, "topEvidence.x=", topEvidence.x, "topEvidence.y=", topEvidence.y, "topCount=", topCount, "totalEvidence=", totalEvidence) -- Sunstrider Isle: zone IDs are now normalized via NormalizeSpawnZoneKey so -- topEvidence.zoneId will be 1241 (map ID), not 3431 (area ID). -- IsSunstriderNativeZone checks both to be safe against old saved data. -- Confidence threshold is bypassed for Sunstrider because spawn points are -- distributed across 5+ locations — no single point ever reaches 60% of kills. local isSunstrider = IsSunstriderNativeZone(topEvidence.zoneId) local confidenceThreshold = isSunstrider and 0 or 60 -- Only override if > confidence threshold AND spawn differs from static DB if topPct <= confidenceThreshold then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "top spawn", topCount .. "/" .. totalEvidence, "= " .. floor(topPct + 0.5) .. "%" .. (isSunstrider and " — Sunstrider, threshold bypassed" or (" — below " .. confidenceThreshold .. "%, no override"))) -- For Sunstrider, fall through and apply the learned spawn anyway if not isSunstrider then return false end end if isSunstrider then -- AscensionDB owns spawn data for known Sunstrider NPCs — never overwrite it. -- Key 7 = spawns. Without this guard the learner would pollute the curated -- AscensionDB coords with in-game kill evidence, causing wrong pin counts. -- REGRESSION NOTE: If AscensionDB protection check is removed or disabled, -- learner pins will reappear at wrong locations. Do not remove this guard. if IsAscensionProtected("NPC", npcId, 7) then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "Sunstrider zone but AscensionDB owns spawns — skipping learner injection") return false end -- topEvidence.zoneId is now a map ID (e.g. 1241) because NormalizeSpawnZoneKey -- converted the area ID at storage time. This matches AscensionDB's key space -- so DrawWorldIcon and HBD can resolve the coordinates correctly. QuestieDB.npcDataOverrides[npcId] = QuestieDB.npcDataOverrides[npcId] or {} QuestieDB.npcDataOverrides[npcId][7] = QuestieDB.npcDataOverrides[npcId][7] or {} QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] or {} local promoted = 0 local duplicates = 0 local zoneSpawns = QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] local seen = {} for _, spawnEvidence in pairs(evidence) do local sx, sy = NormalizeCoordPair(spawnEvidence.x, spawnEvidence.y) if sx and sy and not seen[sx..","..sy] then seen[sx..","..sy] = true local grid = GetCoordGridForZone(topEvidence.zoneId) if InsertIfNewBucket(zoneSpawns, sx, sy, grid) then promoted = promoted + 1 else duplicates = duplicates + 1 end else duplicates = duplicates + 1 end end Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "promoted Sunstrider evidence groups", promoted, "duplicates", duplicates, "zone", tostring(topEvidence.zoneId)) _QueueNpcLiveUpdate(npcId) return promoted > 0 or duplicates > 0 end -- Check against static DB + learner-promoted override entries. -- After a previous promotion, QueryNPCSingle returns the override data, -- so learner-promoted spawns would incorrectly "match static" and block -- re-promotion. We must exclude overrides we wrote ourselves. local staticNPC = nil if QuestieDB and QuestieDB.QueryNPCSingle then staticNPC = QuestieDB.QueryNPCSingle(npcId, "spawns") end local staticSpawnList = staticNPC local staticSpawnsForZone = staticSpawnList and staticSpawnList[topEvidence.zoneId] -- Collect spawns already promoted by the learner for this zone local learnerOverrides = QuestieDB.npcDataOverrides and QuestieDB.npcDataOverrides[npcId] and QuestieDB.npcDataOverrides[npcId][7] and QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] local learnerOverrideSet = {} if learnerOverrides then for _, entry in ipairs(learnerOverrides) do if entry and entry[1] and entry[2] then local lx = floor(entry[1] * 100 + 0.5) / 100 local ly = floor(entry[2] * 100 + 0.5) / 100 learnerOverrideSet[lx .. "|" .. ly] = true end end end -- Check if top spawn matches any static spawn in the same zone, -- excluding learner-promoted overrides (those should always be updatable) local matchesStatic = false if staticSpawnsForZone then for _, coord in ipairs(staticSpawnsForZone) do local sx = floor(coord[1] * 100 + 0.5) / 100 local sy = floor(coord[2] * 100 + 0.5) / 100 -- Skip learner-promoted entries — they are not "static" if not learnerOverrideSet[sx .. "|" .. sy] then if abs(sx - topEvidence.x) < 0.01 and abs(sy - topEvidence.y) < 0.01 then matchesStatic = true break end end end end if matchesStatic then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "top spawn matches static DB — no override needed") return false end Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence promoting npcId", npcId, "zone", tostring(topEvidence.zoneId), "x", tostring(topEvidence.x), "y", tostring(topEvidence.y), "protected", tostring(IsAscensionProtected("NPC", npcId, 7))) -- Insert as new spawn (InsertIfNewBucket deduplicates). -- Only create zone table entry if insert succeeds — an empty zone override -- {[3431] = {}} makes _MergeOverride's IsEmptyTable check fall through to -- rawdata, bypassing learner data entirely (field is non-nil but empty). local spawned = false if topEvidence.x and topEvidence.y then if not QuestieDB.npcDataOverrides[npcId] then QuestieDB.npcDataOverrides[npcId] = {} end if not QuestieDB.npcDataOverrides[npcId][7] then QuestieDB.npcDataOverrides[npcId][7] = {} end if not QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] then QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = {} end spawned = InsertIfNewBucket(QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId], topEvidence.x, topEvidence.y, GetCoordGridForZone(topEvidence.zoneId)) end Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "overrode static DB — learned spawn (" .. tostring(topEvidence.x) .. "," .. tostring(topEvidence.y) .. ")", "zone " .. topEvidence.zoneId .. " at " .. floor(topPct + 0.5) .. "% confidence", spawned and "SPAM" or "IGNORED_DUPLICATE") -- Clear/rebuild cache through the same coalesced path used by kill learning. _QueueNpcLiveUpdate(npcId) return true 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 and not IsAscensionProtected("QUEST", questId, k) 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 and not IsAscensionProtected("QUEST", questId, field) 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 and not IsAscensionProtected("QUEST", questId, 10) 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 _RefreshActiveQuestPins({ [questId] = true }) -- 3. Register with tooltip system immediately. Preserve the objective icon so -- nameplates can render the correct learned slay/loot/talk marker. local QuestieTooltips = QuestieLoader:ImportModule("QuestieTooltips") if QuestieTooltips and QuestieTooltips.RegisterObjectiveTooltip then local objectiveIcon local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache") local objectives = QuestLogCache and QuestLogCache.GetQuestObjectives and QuestLogCache.GetQuestObjectives(questId) if objectives and objText then for _, obj in next, objectives do if obj.text and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then objectiveIcon = obj.Icon break end end end QuestieTooltips:RegisterObjectiveTooltip(questId, "m_" .. npcId, { Index = 0, Description = objText or "Learned Objective", Icon = objectiveIcon, 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 and not IsAscensionProtected("ITEM", itemId, k) 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 and not IsAscensionProtected("ITEM", itemId, 2) 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 and not IsAscensionProtected("OBJECT", objectId, k) then ovr[k] = v end end if existing[4] and not IsAscensionProtected("OBJECT", objectId, 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 -- Clear compiled DB cache so GetObject rebuilds with the new override data. if QuestieDB.private and QuestieDB.private.objectCache then QuestieDB.private.objectCache[objectId] = nil 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 if not self:IsEnabled() then QuestieLearner.data = Questie.dbLearner.global Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] InjectLearnedData skipped because learner is disabled") return end local learned = Questie.dbLearner.global -- Normalize malformed saved variables before any migration or injection. -- This prevents old learner rows from carrying numeric spawn fields -- into the suppression path and crashing the quest objective filter. local sanitizedEntries = 0 if QuestieDB and QuestieDB.private and QuestieDB.private.NormalizeLearnerSpawnEntry then for _, data in pairs(learned.npcs) do if QuestieDB.private.NormalizeLearnerSpawnEntry(data, 7, 4) then sanitizedEntries = sanitizedEntries + 1 end end for _, data in pairs(learned.objects) do if QuestieDB.private.NormalizeLearnerSpawnEntry(data, 4, 7) then sanitizedEntries = sanitizedEntries + 1 end end if sanitizedEntries > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Sanitized", sanitizedEntries, "malformed learned spawn entries") end end -- Migrate old-format NPC data ([4]=spawns, [5]=zoneId) to new format ([7]=spawns, [9]=zoneId) -- Always merge [4] into [7], even when [7] already has partial data from a recent session. for npcId, data in pairs(learned.npcs) do if type(data[4]) == "table" then if data[7] == nil then -- Simple move: no [7] exists yet data[7] = data[4] else -- Merge: [7] has partial data, consolidate [4] coordinates into it for zoneId, coords in pairs(data[4]) do data[7][zoneId] = data[7][zoneId] or {} for _, coord in ipairs(coords) do InsertIfNewBucket(data[7][zoneId], coord[1], coord[2]) end end end data[4] = nil end if type(data[5]) == "number" and data[9] == nil then data[9] = data[5] data[5] = nil end end -- Merge character-specific learned NPC data into global pool if Questie.db and Questie.db.char and Questie.db.char.npcs then for npcId, data in pairs(Questie.db.char.npcs) do local globalData = learned.npcs[npcId] if not globalData then learned.npcs[npcId] = data else -- Merge spawns: char data may be old ([4]) or new ([7]) format local charSpawns = data[7] or data[4] local globalSpawns = globalData[7] or globalData[4] if charSpawns then if not globalSpawns then globalData[7] = {} globalSpawns = globalData[7] end for zoneId, coords in pairs(charSpawns) do globalSpawns[zoneId] = globalSpawns[zoneId] or {} for _, coord in ipairs(coords) do InsertIfNewBucket(globalSpawns[zoneId], coord[1], coord[2]) end end end -- Adopt zoneId if missing if not globalData[9] and data[9] then globalData[9] = data[9] end if not globalData[5] and data[5] then globalData[5] = data[5] end end end Questie.db.char.npcs = nil end local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0 -- Migration: fix spawn zone keys that were stored as uiMapId instead of areaId. -- Before the GetZoneId() fix, kills on maps like Sunstrider Isle (uiMapId 1241) -- were stored under key 1241 instead of the correct areaId 3431. -- Convert any uiMapId keys to areaId using ZoneDB. local zonesFixed = 0 for npcId, data in pairs(learned.npcs) do if data[7] then local zonesToMigrate = {} for zoneKey, coords in pairs(data[7]) do local originalZoneKey = zoneKey zoneKey = NormalizeSpawnZoneKey(zoneKey) -- Older Ascension learner data stored native Sunstrider coords -- under parent areaId 3430 while [9] still identified the row as -- Sunstrider. Move those coords to areaId 3431 so they render on -- uiMapId 1241 instead of Eversong. if originalZoneKey == 3430 and IsSunstriderNativeZone(data[9]) then zonesToMigrate[originalZoneKey] = 3431 end -- If zoneKey looks like a uiMapId (a map ID rather than an areaId), -- ZoneDB:GetAreaIdByUiMapId will return the corresponding areaId. -- If it returns nil, zoneKey is already an areaId — no migration needed. -- Skip very common areaIds that happen to look like small numbers. if not zonesToMigrate[originalZoneKey] and ZoneDB and ZoneDB.GetAreaIdByUiMapId then local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey) if maybeAreaId and maybeAreaId ~= zoneKey then zonesToMigrate[originalZoneKey] = maybeAreaId end end end for oldKey, newKey in pairs(zonesToMigrate) do local coords = data[7][oldKey] if coords then data[7][newKey] = data[7][newKey] or {} for _, coord in ipairs(coords) do InsertIfNewBucket(data[7][newKey], coord[1], coord[2]) end data[7][oldKey] = nil zonesFixed = zonesFixed + 1 end end end end -- Same migration for object spawn data (field 4) for objId, data in pairs(learned.objects) do if data[4] then local zonesToMigrate = {} for zoneKey, coords in pairs(data[4]) do local originalZoneKey = zoneKey zoneKey = NormalizeSpawnZoneKey(zoneKey) if originalZoneKey == 3430 and IsSunstriderNativeZone(data[5]) then zonesToMigrate[originalZoneKey] = 3431 end if not zonesToMigrate[originalZoneKey] and ZoneDB and ZoneDB.GetAreaIdByUiMapId then local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey) if maybeAreaId and maybeAreaId ~= zoneKey then zonesToMigrate[originalZoneKey] = maybeAreaId end end end for oldKey, newKey in pairs(zonesToMigrate) do local coords = data[4][oldKey] if coords then data[4][newKey] = data[4][newKey] or {} for _, coord in ipairs(coords) do InsertIfNewBucket(data[4][newKey], coord[1], coord[2]) end data[4][oldKey] = nil zonesFixed = zonesFixed + 1 end end end end if zonesFixed > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Migrated", zonesFixed, "spawn zone keys from uiMapId to areaId") end -- Also fix [9] zone field for NPCs and [5] zone field for Objects -- that were stored as uiMapId instead of areaId (e.g. 1241 → 3431). local fieldsFixed = 0 for npcId, data in pairs(learned.npcs) do if type(data[9]) == "number" and ZoneDB and ZoneDB.GetAreaIdByUiMapId then local normalizedZone = NormalizeSpawnZoneKey(data[9]) local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(normalizedZone) if maybeAreaId and maybeAreaId ~= data[9] then Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] NPC", npcId, "zone field [9]", data[9], "->", maybeAreaId) data[9] = maybeAreaId fieldsFixed = fieldsFixed + 1 end end end for objId, data in pairs(learned.objects) do if type(data[5]) == "number" and ZoneDB and ZoneDB.GetAreaIdByUiMapId then local normalizedZone = NormalizeSpawnZoneKey(data[5]) local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(normalizedZone) if maybeAreaId and maybeAreaId ~= data[5] then Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Object", objId, "zone field [5]", data[5], "->", maybeAreaId) data[5] = maybeAreaId fieldsFixed = fieldsFixed + 1 end end end if fieldsFixed > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Fixed", fieldsFixed, "zone fields from uiMapId to areaId") end -- Purge player-spawned totems from learned NPCs (they are not real world spawns) -- Also purge NPCs with entirely empty spawn data (stale learner artifacts) local purgedNpcs = 0 local PLAYER_SPAWNED_NPCS = { [2523] = true, -- Searing Totem [2630] = true, -- Earthbind Totem [10183] = true, -- Moonflare Totem [1103907] = true, -- Healing Stream Totem III [1107398] = true, -- Stoneclaw Totem V } for npcId, data in pairs(learned.npcs) do if PLAYER_SPAWNED_NPCS[npcId] then learned.npcs[npcId] = nil purgedNpcs = purgedNpcs + 1 Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged player-spawned NPC", npcId, data[1] or "?") elseif data[7] then -- Check for empty spawn table (no coords at all = stale learner artifact) local hasCoords = false for zoneKey, coords in pairs(data[7]) do if type(coords) == "table" and #coords > 0 then hasCoords = true break end end if not hasCoords then learned.npcs[npcId] = nil purgedNpcs = purgedNpcs + 1 Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged NPC with empty spawns", npcId, data[1] or "?") end end end if purgedNpcs > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged", purgedNpcs, "invalid NPCs from learned data") end -- Versioned cleanup: strip spawns from NPCs learned via quest giver/finisher -- fallback (player position stored as spawn by LearnNPC). These have only -- a single spawn position regardless of confidence — real kill NPCs -- accumulate spawns at multiple distinct locations. local fallbackSpawnCleanupVersion = 2 if (learned._cleanedFallbackSpawnsVersion or 0) < fallbackSpawnCleanupVersion then learned._cleanedFallbackSpawns = true learned._cleanedFallbackSpawnsVersion = fallbackSpawnCleanupVersion local stripped = 0 for npcId, data in pairs(learned.npcs) do if data[7] and CountUniqueSpawnPositions(data[7]) <= 1 then data[7] = nil stripped = stripped + 1 end end if stripped > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Stripped fallback spawns from", stripped, "NPCs") end end -- Purge Object entries that duplicate NPC entries (mobs learned as both NPC and Object). -- NPC data is richer (has names, quest IDs), so keep the NPC version and remove the Object. local dupObjectsRemoved = 0 for objId, _ in pairs(learned.objects) do if learned.npcs[objId] then learned.objects[objId] = nil dupObjectsRemoved = dupObjectsRemoved + 1 Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Removed duplicate Object", objId, "(NPC version exists)") end end if dupObjectsRemoved > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Removed", dupObjectsRemoved, "Object entries that duplicate NPC entries") end -- 1. NPCs local npcIdsToFix = {} local npcNameIndexNeedsRebuild = false 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 -- Spawn evidence is promoted through _MergeSpawnEvidence, where -- AscensionDB ownership is known. Injecting [7] here runs too early -- and can pollute curated plugin spawn tables. QuestieDB.npcDataOverrides[nid or npcId] = CopyWithoutField(data, 7) npcCount = npcCount + 1 if data[1] then npcNameIndexNeedsRebuild = true end else local existing = QuestieDB.npcDataOverrides[nid or npcId] -- Adopt other fields if missing for k, v in pairs(data) do if k ~= "mc" and k ~= 7 and existing[k] == nil and not IsAscensionProtected("NPC", nid or npcId, k) then existing[k] = v if k == 1 then npcNameIndexNeedsRebuild = true end end end end end for old, new in pairs(npcIdsToFix) do learned.npcs[new] = learned.npcs[old] learned.npcs[old] = nil end if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then -- Diagnostic: log how many NPCs were injected with spawn overrides local spawnOverrideCount = 0 for nid, ovr in pairs(QuestieDB.npcDataOverrides) do if ovr[7] and next(ovr[7]) then spawnOverrideCount = spawnOverrideCount + 1 end end Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieLearner] InjectLearnedData: injected", npcCount, "NPCs (", spawnOverrideCount, "with spawn overrides)") end if npcNameIndexNeedsRebuild then _MarkNpcNameIndexDirty() end -- Purge garbage quest entries: quests with no name [1] and only mc/ls metadata. -- These are Ascension internal tracking artifacts (hash-like IDs) with no real quest data. -- Also purge quest 788 ("Mottled Boar slain") which is an objective text, not a quest name. local purgedQuests = 0 local OBJECTIVE_TEXT_QUEST_IDS = { [788] = true, -- "Mottled Boar slain" — objective text, not a quest } for questId, data in pairs(learned.quests) do local hasRealData = false -- Check if quest has any meaningful data beyond mc/ls metadata if type(data[1]) == "string" and data[1] ~= "" then hasRealData = true end -- Also check for objective data, level, zone, etc. if not hasRealData then for k, v in pairs(data) do if k ~= "mc" and k ~= "ls" then hasRealData = true break end end end local qid = tonumber(questId) if not hasRealData or (qid and OBJECTIVE_TEXT_QUEST_IDS[qid]) then local reason = not hasRealData and "garbage" or "objective-text" Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Purged", reason, "quest", questId, data[1] or "?") learned.quests[questId] = nil purgedQuests = purgedQuests + 1 end end if purgedQuests > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged", purgedQuests, "invalid quests (garbage/objective-text)") end -- Infer sortKey [17] from zone data [3] for quests that have a name but no sortKey. -- Field [3] contains zone/area info (e.g. {{470}} for Ghostlands, {{414}} for Zul'Drak). -- The sortKey should be the areaId from [3] so the quest appears in the correct zone in the UI. local sortKeysInferred = 0 for questId, data in pairs(learned.quests) do if data[1] and not data[17] and data[3] then -- [3] can be a table of tables: {{414}} or nested {{414, ...}, ...} -- Extract the first numeric areaId from it local sortKey = nil if type(data[3]) == "table" then -- Walk into nested tables to find the first numeric value local function findFirstNumber(t) if type(t) ~= "table" then return nil end for i = 1, #t do if type(t[i]) == "number" then return t[i] elseif type(t[i]) == "table" then local result = findFirstNumber(t[i]) if result then return result end end end return nil end sortKey = findFirstNumber(data[3]) elseif type(data[3]) == "number" then sortKey = data[3] end if sortKey then data[17] = sortKey sortKeysInferred = sortKeysInferred + 1 Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Inferred sortKey", sortKey, "for quest", questId, data[1]) end end end if sortKeysInferred > 0 then Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Inferred sortKey for", sortKeysInferred, "quests from zone data") 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] if not IsAscensionProtected("QUEST", qid or questId, 10) then 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 end elseif existing[k] == nil and not IsAscensionProtected("QUEST", qid or questId, k) 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] and not IsAscensionProtected("OBJECT", oid or objectId, 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 and not IsAscensionProtected("OBJECT", oid or objectId, k) 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 and l10n and l10n.GetAreaIdByLocalName 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() } -- Pass areaId as spawnZoneId so LearnNPC stores spawn data under the -- correct areaId (3430 for Sunstrider/Eversong) rather than falling back -- to GetZoneId() which may return the same value but via a different path. -- GetPlayerCoords() fallback in LearnNPC will provide the coordinates. self:LearnNPC(npcId, name, level, subName, npcFlags, factionString, nil, nil, areaId) 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, nil, nil, zoneId) end end end end function QuestieLearner:OnQuestComplete() local questId = GetQuestID and GetQuestID() if not questId or questId <= 0 then return end -- Get current zone for quest giver spawn data local zoneId = GetZoneId() -- 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, nil, nil, zoneId) end end end end -- Helper to find an NPC ID by name (case-insensitive exact 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) local index = _EnsureNpcNameIndex() local overrideId = index.override[lowerName] if overrideId then return overrideId end return index.base[lowerName] 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 ~= "" and l10n and l10n.GetAreaIdByLocalName 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, nil, nil, GetZoneId()) 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, nil, nil, GetZoneId()) 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, nil, nil, GetZoneId()) 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(timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName) -- Guard: if combat log was unavailable or disabled, bail fast if _Learner.combatLogDisabled then return end -- arg1..arg10 are captured by the frame handler before calling this function. -- On 3.3.5a these are populated by the client engine. -- If timestamp was not passed (direct test call), try CombatLogGetCurrentEventInfo. local path if not timestamp then -- Modern path (WoW 3.3.5+): use API, skip arg globals if CombatLogGetCurrentEventInfo then local t1, t2, t3, t4, t5, t6, t7, t8, t9, t10 = CombatLogGetCurrentEventInfo() if t1 then timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName = t1, t2, t3, t4, t5, t6, t7, t8, t9, t10 path = "modern" end end -- Legacy path (3.3.5a / test): use arg1..arg10 from caller vararg -- On 3.3.5a the client delivers combat log fields via arg globals in the handler. -- When OnCombatLogEvent is called directly in test with full args, timestamp -- is non-nil so this branch never fires. if not timestamp and arg1 then timestamp = arg1 eventType = arg2 srcGUID = arg3 srcName = arg4 srcFlags = arg5 dstGUID = arg6 dstName = arg7 dstFlags = arg8 spellId = arg9 spellName = arg10 path = "legacy" elseif not timestamp then path = "none" end else -- Args passed by frame handler — 3.3.5a client path path = "client-arg" end -- Permanent minimal log: combat-log path, event, and target -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] combat-log path=", path, " event=", eventType, " dstGUID=", dstGUID, " dstName=", dstName) -- Vanilla: neither modern API nor legacy args available — throttle warning, do NOT disable permanently if not timestamp then -- Only log once per 60 seconds to avoid spam during combat log silence local now = GetTime and GetTime() or 0 if not _Learner.combatLogSilentUntil or (now - _Learner.combatLogSilentUntil) > 60 then _Learner.combatLogSilentUntil = now Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] COMBAT_LOG_EVENT_UNFILTERED fired but no args available " .. "(CombatLogGetCurrentEventInfo=" .. tostring(CombatLogGetCurrentEventInfo ~= nil) .. ", arg1=" .. tostring(arg1) .. ") " .. "— combat-log kill learning skipped this event (not disabled)") end return 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 -- Dedupe: if this GUID was processed within the last 5 seconds, skip. -- PARTY_KILL and UNIT_DIED can both fire for the same kill; we only need one. local now = time() local lastTs = _Learner.killDebounce and _Learner.killDebounce[dstGUID] if lastTs and (now - lastTs) < 5 then -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID) return end _Learner.killDebounce = _Learner.killDebounce or {} _Learner.killDebounce[dstGUID] = now -- Prune entries older than 10 seconds to keep the table bounded for g, ts in pairs(_Learner.killDebounce) do if (now - ts) > 10 then _Learner.killDebounce[g] = nil end end local npcId = GetNpcIdFromGUID(dstGUID) local name = dstName -- Fallback chain for mob name: combat-log dstName → cached target/mouseover → current target unit if not name or name == "" then if _Learner.guidNpcCache then local cached = _Learner.guidNpcCache[dstGUID] if cached and cached.name and cached.name ~= "" then name = cached.name end end end if not name or name == "" then if UnitGUID("target") == dstGUID then name = UnitName("target") end end if not npcId and _Learner.guidNpcCache then local cached = _Learner.guidNpcCache[dstGUID] if cached then npcId = cached.npcId 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 = name 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 -- UNIT_DIED fires for nearby mobs killed by other players. Keep it only as -- short-lived evidence for quest-progress correlation; otherwise bystander -- kills can churn learner spawn data and redraw active quest pins. if eventType ~= "PARTY_KILL" then return end -- Unconditionally map the spawn position for Ascension DB building self:LearnNPC(npcId, name, nil, nil, nil, nil, px, py, zoneId) -- Phase 2: store per-GUID spawn evidence for weighted merge self:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, px, py) local guidSpawnsAfterStore = Questie.dbLearner.global.npcs[npcId] and Questie.dbLearner.global.npcs[npcId][8] if guidSpawnsAfterStore then local guidCount = 0 for _ in pairs(guidSpawnsAfterStore) do guidCount = guidCount + 1 end -- Questie:Debug(Questie.DEBUG_LEARNER, -- "[QuestieLearner] GUID spawn evidence stored:", -- npcId, dstName or name or "?", -- "guidCount", guidCount, -- "zone", tostring(zoneId), -- "x", tostring(px), -- "y", tostring(py)) else -- Questie:Debug(Questie.DEBUG_LEARNER, -- "[QuestieLearner] GUID spawn evidence missing after store:", -- npcId, dstName or name or "?", -- "zone", tostring(zoneId), -- "x", tostring(px), -- "y", tostring(py)) end -- Phase 3: weighted merge when evidence count is sufficient. -- Temporarily lowered to 1 for Sunstrider/Mana Wyrm diagnostics so we can -- verify the promotion path immediately. local guidSpawns = Questie.dbLearner.global.npcs[npcId] and Questie.dbLearner.global.npcs[npcId][8] if guidSpawns then local count = 0 for _ in pairs(guidSpawns) do count = count + 1 end if count >= 1 then _MergeSpawnEvidence(npcId) end 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 --- Collect coord keys from a spawn table into a sequential array. --- Uses a two-pass pattern so deletion always happens after traversal. ---@param spawnTable table The [zoneId] sub-table containing learned coords ---@return table, number Array of keys to remove, count of keys --- Prune wildly outlying learned spawn coords for a single NPC+zone entry. --- Only operates on QuestieLearner learned data (dbLearner.global.npcs / .objects). --- Never deletes static DB or AscensionDB spawns. --- Requires at least 4 learned points before evaluating. --- Prunes in a second pass after identification to avoid mid-iteration deletions. ---@param spawnTable table The [zoneId] sub-table containing learned coords ---@param zoneId number The zone being evaluated ---@param threshold number Max percent-distance from cluster median before pruning (default 15) ---@return boolean True if any points were removed local function _PruneSpawnOutliers(spawnTable, zoneId, threshold) threshold = threshold or 15 if not spawnTable then return false end -- Pass 1: collect all points into a flat array local learnedPoints = {} local n = 0 local coordKey, coord = next(spawnTable) while coordKey do if type(coord) == "table" and coord[1] and coord[2] then n = n + 1 learnedPoints[n] = { coord[1], coord[2] } end coordKey, coord = next(spawnTable, coordKey) end if n < 4 then return false end -- Compute median x and y via insertion sort (Lua 5.0-safe) local sortedX = {} local sortedY = {} for i = 1, n do sortedX[i] = learnedPoints[i][1] sortedY[i] = learnedPoints[i][2] end for i = 2, n do local key = sortedX[i] local j = i - 1 while j >= 1 and sortedX[j] > key do sortedX[j + 1] = sortedX[j] j = j - 1 end sortedX[j + 1] = key end for i = 2, n do local key = sortedY[i] local j = i - 1 while j >= 1 and sortedY[j] > key do sortedY[j + 1] = sortedY[j] j = j - 1 end sortedY[j + 1] = key end local medianX = sortedX[floor(n / 2) + 1] local medianY = sortedY[floor(n / 2) + 1] -- Compute mean absolute deviation from median for each axis local devX, devY = 0, 0 for i = 1, n do devX = devX + abs(learnedPoints[i][1] - medianX) devY = devY + abs(learnedPoints[i][2] - medianY) end devX = devX / n devY = devY / n -- Dynamic axis threshold: 3x mean absolute deviation, floored to threshold local pruneX = devX * 3 local pruneY = devY * 3 if pruneX < threshold then pruneX = threshold end if pruneY < threshold then pruneY = threshold end -- Pass 2: identify outlier keys (collect before deleting) local toRemove = {} local rmCount = 0 coordKey, coord = next(spawnTable) while coordKey do if type(coord) == "table" and coord[1] and coord[2] then local dx = abs(coord[1] - medianX) local dy = abs(coord[2] - medianY) if dx > pruneX or dy > pruneY then rmCount = rmCount + 1 toRemove[rmCount] = coordKey end end coordKey, coord = next(spawnTable, coordKey) end table.sort(toRemove, function(a, b) if type(a) == "number" and type(b) == "number" then return a > b end return tostring(a) > tostring(b) end) -- Pass 3: delete in second pass (no mid-iteration table mutation). -- Numeric spawn arrays must be compacted so ipairs/# keep seeing later rows. local removed = 0 for i = 1, rmCount do local key = toRemove[i] if type(key) == "number" then table.remove(spawnTable, key) else spawnTable[key] = nil end removed = removed + 1 end if removed > 0 then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Pruned", removed, "outlier spawns in zone", zoneId) end return removed > 0 end --- Prune outlier learned spawn data for all NPCs and objects. --- Only touches dbLearner.global.npcs and dbLearner.global.objects (learned data). --- Guards static data: if a static DB entry exists for the same NPC+zone, use its --- centroid as the anchor and prune learned entries that deviate > threshold from it. --- If no static anchor exists, use a learned-median cluster. --- After pruning, re-injects cleaned data into live overrides so subsequent map/arrow --- consumers see the corrected pins immediately. --- Runs on learner import/save/cleanup — NOT on every map draw. ---@param threshold number Max percent-distance from anchor before pruning (default 15) ---@return boolean True if any data was changed function QuestieLearner:PruneLearnedSpawnOutliers(threshold) threshold = threshold or 15 local global = Questie.dbLearner and Questie.dbLearner.global if not global then return false end local anyChanged = false -- ── NPCs ────────────────────────────────────────────────────────────── local npcs = global.npcs if npcs then local npcId = next(npcs) while npcId do local entry = npcs[npcId] local spawns = entry and entry[7] if spawns then local zoneId = next(spawns) while zoneId do local zoneSpawns = spawns[zoneId] if zoneSpawns then local changed = false -- Check if static DB has anchors for this NPC+zone local staticSpawns = nil if QuestieDB and QuestieDB.QueryNPC then staticSpawns = QuestieDB.QueryNPCSingle and QuestieDB.QueryNPCSingle(npcId, "spawns") or nil end if staticSpawns and staticSpawns[zoneId] then -- Static anchor path: build centroid from static spawns local sc = staticSpawns[zoneId] local si, staticCoord = next(sc) local sn = 0 local sumX, sumY = 0, 0 while si do if type(staticCoord) == "table" and staticCoord[1] and staticCoord[2] then sn = sn + 1 sumX = sumX + staticCoord[1] sumY = sumY + staticCoord[2] end si, staticCoord = next(sc, si) end if sn > 0 then local anchorX = sumX / sn local anchorY = sumY / sn -- Collect outlier keys first, delete second local toRemove = {} local rmCount = 0 local coordKey, coord = next(zoneSpawns) while coordKey do if type(coord) == "table" and coord[1] and coord[2] then local dx = abs(coord[1] - anchorX) local dy = abs(coord[2] - anchorY) if dx > threshold or dy > threshold then rmCount = rmCount + 1 toRemove[rmCount] = coordKey end end coordKey, coord = next(zoneSpawns, coordKey) end table.sort(toRemove, function(a, b) if type(a) == "number" and type(b) == "number" then return a > b end return tostring(a) > tostring(b) end) for i = 1, rmCount do local key = toRemove[i] if type(key) == "number" then table.remove(zoneSpawns, key) else zoneSpawns[key] = nil end changed = true end if changed then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Pruned", rmCount, "learned NPC", npcId, "zone", zoneId, "(deviated from static anchor)") end end else -- Learned-only path: use median cluster changed = _PruneSpawnOutliers(zoneSpawns, zoneId, threshold) end if changed then anyChanged = true end end zoneId = next(spawns, zoneId) end end npcId = next(npcs, npcId) end end -- ── Objects ───────────────────────────────────────────────────────────── -- Learned objects store spawns in [4], not [7] local objects = global.objects if objects then local objectId = next(objects) while objectId do local entry = objects[objectId] local spawns = entry and entry[4] if spawns then local zoneId = next(spawns) while zoneId do local zoneSpawns = spawns[zoneId] if zoneSpawns then if _PruneSpawnOutliers(zoneSpawns, zoneId, threshold) then anyChanged = true end end zoneId = next(spawns, zoneId) end end objectId = next(objects, objectId) end end -- Re-inject cleaned data into live overrides so subsequent reads are consistent if anyChanged then self:InjectLearnedData() Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Re-injected learned data after outlier pruning") end return anyChanged 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 ------------------------------------------------------------------------ -- Real-time tooltip for learned spawns -- Hooks into GameTooltip to show "Learned spawn: (x, y) from N kills" -- when hovering over an NPC that has been learned. ------------------------------------------------------------------------ local _tooltipHookRegistered = false --- Adds a "Learned spawn: (x, y) from N kills" line to the tooltip --- for the NPC represented by the given unit token. --- Called by the GameTooltip OnTooltipSetUnit hook. ---@param unitToken string WoW unit token (e.g. "mouseover") local function _AddLearnedSpawnTooltipLine(unitToken) if not Questie or not Questie.dbLearner then return end local guid = UnitGUID(unitToken) if not guid then return end local guidType, _, _, _, _, npcIdStr, _ = strsplit("-", guid) if guidType ~= "Creature" and guidType ~= "Vehicle" then return end local npcId = tonumber(npcIdStr) if not npcId then return end local entry = Questie.dbLearner.global.npcs[npcId] if not entry or not entry[7] then return end -- Find the first zone with spawn data local spawnsByZone = entry[7] local zoneId = next(spawnsByZone) if not zoneId then return end local zoneSpawns = spawnsByZone[zoneId] if not zoneSpawns or #zoneSpawns == 0 then return end -- Use the first recorded coordinate local x = zoneSpawns[1][1] local y = zoneSpawns[1][2] local kills = entry.mc or 0 local formattedX = ("%.1f"):format(x) local formattedY = ("%.1f"):format(y) GameTooltip:AddDoubleLine("Learned spawn", ("(%s, %s) from %d kill%s"):format( formattedX, formattedY, kills, kills == 1 and "" or "s")) end --- Registers the GameTooltip OnTooltipSetUnit hook once. --- Safe to call multiple times; guard prevents double-hook. local function _RegisterLearnedSpawnTooltipHook() if _tooltipHookRegistered then return end _tooltipHookRegistered = true GameTooltip:HookScript("OnTooltipSetUnit", function() -- HookScript handlers do not reliably receive the frame as an argument on 3.3.5a. local _, unitToken = GameTooltip:GetUnit() if unitToken then _AddLearnedSpawnTooltipLine(unitToken) 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, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) 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(arg1, arg2, arg3) elseif event == "QUEST_ACCEPTED" then self:OnQuestAccepted(arg1, arg2) elseif event == "LOOT_OPENED" then self:OnLootOpened() elseif event == "GOSSIP_SHOW" then self:OnGossipShow() elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then -- arg1..arg10 must be captured HERE before any secondary call wipes them (3.3.5 behavior) -- arg1=timestamp, arg2=eventType, arg3=srcGUID, arg4=srcName, arg5=srcFlags, -- arg6=dstGUID, arg7=dstName, arg8=dstFlags, arg9=spellId, arg10=spellName self:OnCombatLogEvent(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) elseif event == "GET_ITEM_INFO_RECEIVED" then self:OnGetItemInfoReceived(arg1) elseif event == "UNIT_QUEST_LOG_CHANGED" then self:OnQuestLogUpdate() elseif event == "QUEST_REMOVED" or event == "QUEST_TURNED_IN" then self:ClearQuestObjectiveTracking(arg1) 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() _RegisterLearnedSpawnTooltipHook() 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) -- Run outlier pruning once at startup after data is loaded and DB is ready self:PruneLearnedSpawnOutliers() -- 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 ------------------------------------------------------------------------ --- Validates learned spawn data from external sources (comms, import). --- Returns true if the data is safe to merge; false to reject silently. --- Checks: data is a table, spawns[zoneId] keys are numeric, coordinates --- in each zone are numbers within 0-100 range. ---@param data table The learned entity data table (e.g. NPC entry) ---@return boolean True if valid, false if malformed local function _ValidateLearnedSpawnData(data) if type(data) ~= "table" then return false end local spawns = data[7] if not spawns then return true end -- no spawn data is OK if type(spawns) ~= "table" then return false end for zoneId, zoneSpawns in pairs(spawns) do if type(zoneId) ~= "number" then return false end if type(zoneSpawns) ~= "table" then return false end for _, coord in ipairs(zoneSpawns) do if type(coord) ~= "table" then return false end local x, y = coord[1], coord[2] if type(x) ~= "number" or type(y) ~= "number" then return false end if x < 0 or x > 100 or y < 0 or y > 100 then return false end end end return true end function _Learner:BroadcastIfCommsAvailable(typ, id, data) if Questie and Questie.db and Questie.db.profile and Questie.db.profile.learnerBroadcast == false then return end if GetLearnerSetting("learnerCommsIntensity", "normal") == "off" then return end local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms") if not (QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData) then return end _Learner.pendingBroadcasts = _Learner.pendingBroadcasts or {} local key = typ .. ":" .. tostring(id) local op = (data.mc and data.mc > 1) and "UPDATE" or "NEW" local existingPending = _Learner.pendingBroadcasts[key] _Learner.pendingBroadcasts[key] = { typ = typ, id = id, data = data, -- Preserve the first-discovery signal while still sending the latest -- coalesced payload for the entity. op = (existingPending and existingPending.op == "NEW") and "NEW" or op, } if _Learner.pendingBroadcastTimer then return end local timer = QuestieCompat and QuestieCompat.C_Timer local function FlushBroadcasts() local pending = _Learner.pendingBroadcasts _Learner.pendingBroadcasts = {} _Learner.pendingBroadcastTimer = nil local comms = QuestieLoader:ImportModule("QuestieLearnerComms") if not (comms and comms.BroadcastLearnedData) then return end for _, entry in pairs(pending) do comms:BroadcastLearnedData(entry.op, entry.typ, entry.id, entry.data) end end if timer and timer.After then _Learner.pendingBroadcastTimer = true local delay = 2 local intensity = GetLearnerSetting("learnerCommsIntensity", "normal") if intensity == "low" then delay = 4 elseif intensity == "fast" then delay = 1 end timer.After(delay, FlushBroadcasts) else FlushBroadcasts() end end local function _QueueIncomingNetworkMerge(typ, id, data, op) _Learner.pendingNetworkMerges = _Learner.pendingNetworkMerges or {} local key = typ .. ":" .. tostring(id) _Learner.pendingNetworkMerges[key] = { typ = typ, id = id, data = data, op = op, } if _Learner.pendingNetworkMergeTimer then return end local timer = QuestieCompat and QuestieCompat.C_Timer local function FlushNetworkMerges() local pending = _Learner.pendingNetworkMerges _Learner.pendingNetworkMerges = {} _Learner.pendingNetworkMergeTimer = nil local anyChanged = false for _, entry in pairs(pending) do local changed = QuestieLearner:_ApplyIncomingNetworkMerge(entry.typ, entry.id, entry.data, entry.op) anyChanged = anyChanged or changed end if anyChanged then QuestieLearner:InjectLearnedData() QuestieLearner.data = Questie.dbLearner.global end end if timer and timer.After then _Learner.pendingNetworkMergeTimer = true timer.After(GetLearnerSetting("liveNpcUpdateDelay", 0.5), FlushNetworkMerges) else FlushNetworkMerges() 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 _QueueIncomingNetworkMerge(typ, id, d, op) end function QuestieLearner:_ApplyIncomingNetworkMerge(typ, id, d, op) if not typ or not id or not d then return false end local store if typ == "NPC" then if not Questie.dbLearner.global.settings.learnNpcs then return false end store = Questie.dbLearner.global.npcs elseif typ == "QUEST" then if not Questie.dbLearner.global.settings.learnQuests then return false end store = Questie.dbLearner.global.quests elseif typ == "ITEM" then if not Questie.dbLearner.global.settings.learnItems then return false end store = Questie.dbLearner.global.items elseif typ == "OBJECT" then if not Questie.dbLearner.global.settings.learnObjects then return false end store = Questie.dbLearner.global.objects else return false end -- Validate external data before merging to prevent crash on malformed input if not _ValidateLearnedSpawnData(d) then Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Rejected malformed network data", typ, id) return false end local existing = store[id] if not existing then store[id] = d store[id].mc = 1 if typ == "NPC" and type(d[1]) == "string" and d[1] ~= "" then _MarkNpcNameIndexDirty() end return true 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 if typ == "NPC" and k == 1 and type(v) == "string" and v ~= "" then _MarkNpcNameIndexDirty() end 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 return true end return false end return QuestieLearner