Files
Questie-X/Modules/QuestieLearner.lua
T

3753 lines
155 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---@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 {}
QuestieLearner.private = _Learner
local floor = math.floor
local abs = math.abs
local time = time
local tinsert = table.insert
local ipairs = ipairs
local pairs = pairs
local next = next
local type = type
local tostring = tostring
local tonumber = tonumber
local string_trim = string.trim
local string_sub = string.sub
local string_len = string.len
local string_upper = string.upper
local 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 0100 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.pendingItemLinks = {} -- queue for async GetItemInfo retries
-- 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 0100 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
}
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
end
return true
end
------------------------------------------------------------------------
-- Public API
------------------------------------------------------------------------
function QuestieLearner:IsEnabled()
if not EnsureLearnedData() then return false end
return Questie.dbLearner.global.settings.enabled
end
function QuestieLearner:GetSettings()
if not EnsureLearnedData() then return {} end
return Questie.dbLearner.global.settings
end
------------------------------------------------------------------------
-- Cross-link engine
-- After ANY entity is learned, scan all other learned data and stitch
-- relationships automatically. Both learnedData (SavedVariables) and
-- live *DataOverrides tables are kept in sync.
--
-- Schema reference:
-- NPC [7]=spawns [10]=questStarts [11]=questEnds
-- Object [2]=questStarts [3]=questEnds [4]=spawns
-- Quest [2]=startedBy{[1]=npcIds,[2]=objIds,[3]=itemIds}
-- [3]=finishedBy{[1]=npcIds,[2]=objIds}
-- [10]=objectives{[1]={{npcId,text},...},[2]={{objId,text},...},[3]={{itemId,text},...}}
-- [11]=sourceItemId [17]=zoneOrSort
-- Item [2]=dropNpcs{npcId,...} [9]=questSource (questId that gives this item)
------------------------------------------------------------------------
-- Add value to array tbl[key] if not already present. Mirrors to live override table.
local function _AddToArray(tbl, key, value, ovrTable, ovrId)
if not tbl then return end
tbl[key] = tbl[key] or {}
for _, v in ipairs(tbl[key]) do if v == value then return end end
table.insert(tbl[key], value)
if ovrTable and ovrId then
local ovr = ovrTable[ovrId] or {}
ovrTable[ovrId] = ovr
ovr[key] = ovr[key] or {}
for _, v in ipairs(ovr[key]) do if v == value then return end end
table.insert(ovr[key], value)
end
end
-- Add value to nested array tbl[outerKey][innerKey] if not already present.
local function _AddToNestedArray(tbl, outerKey, innerKey, value, ovrTable, ovrId)
if not tbl then return end
tbl[outerKey] = tbl[outerKey] or {}
tbl[outerKey][innerKey] = tbl[outerKey][innerKey] or {}
for _, v in ipairs(tbl[outerKey][innerKey]) do if v == value then return end end
table.insert(tbl[outerKey][innerKey], value)
if ovrTable and ovrId then
local ovr = ovrTable[ovrId] or {}
ovrTable[ovrId] = ovr
ovr[outerKey] = ovr[outerKey] or {}
ovr[outerKey][innerKey] = ovr[outerKey][innerKey] or {}
for _, v in ipairs(ovr[outerKey][innerKey]) do if v == value then return end end
table.insert(ovr[outerKey][innerKey], value)
end
end
-- Add {id, text} pair to quest objectives slot (quest[10][slot]).
local function _AddToQuestObjective(qData, slot, entityId, text, ovrTable, questId)
if not qData then return end
qData[10] = qData[10] or {}
qData[10][slot] = qData[10][slot] or {}
for _, entry in ipairs(qData[10][slot]) do if entry[1] == entityId then return end end
table.insert(qData[10][slot], { entityId, text or "" })
if ovrTable and questId then
local ovr = ovrTable[questId] or {}
ovrTable[questId] = ovr
ovr[10] = ovr[10] or {}
ovr[10][slot] = ovr[10][slot] or {}
for _, entry in ipairs(ovr[10][slot]) do if entry[1] == entityId then return end end
table.insert(ovr[10][slot], { entityId, text or "" })
end
end
local function _GetDB() return Questie.dbLearner.global end
-- Triggers QuestieQuest:UpdateQuest for every active quest in the player's log
-- that is referenced in the provided set (table with questId keys).
-- Called after cross-linking so map pins refresh immediately.
local function _RefreshActiveQuestPins(questIdSet)
if not QuestieQuest or not QuestieQuest.UpdateQuest then return end
if not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end
-- Skip scheduling when the set is empty (avoids no-op timer callbacks)
if not next(questIdSet) then return end
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
for questId in pairs(questIdSet) do
if QuestiePlayer.currentQuestlog[questId] then
if timer then
timer.After(0.1, function() QuestieQuest:UpdateQuest(questId) end)
else
QuestieQuest:UpdateQuest(questId)
end
end
end
end
-- 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 QuestieMap and QuestieMap.UnloadQuestFrames then
QuestieMap:UnloadQuestFrames(questId)
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 LIVE_NPC_UPDATE_DELAY = 0.5
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(LIVE_NPC_UPDATE_DELAY, _FlushNpcLiveUpdates)
else
_FlushNpcLiveUpdates()
end
end
------------------------------------------------------------------------
-- CrossLinkAfterNPC: called when a new NPC is first learned.
-- Scans all learned quests for any reference to this npcId and stitches
-- back-links in both directions.
local function CrossLinkAfterNPC(npcId)
local learned = _GetDB()
local npcData = learned.npcs[npcId]
if not npcData then return end
local npcOvr = QuestieDB and QuestieDB.npcDataOverrides
for questId, qData in pairs(learned.quests) do
local qOvr = QuestieDB and QuestieDB.questDataOverrides
-- Quest starters: quest[2][1] lists NPCs that start this quest
if qData[2] and qData[2][1] then
for _, id in ipairs(qData[2][1]) do
if id == npcId then
_AddToArray(npcData, 10, questId, npcOvr, npcId)
break
end
end
end
-- Quest finishers: quest[3][1]
if qData[3] and qData[3][1] then
for _, id in ipairs(qData[3][1]) do
if id == npcId then
_AddToArray(npcData, 11, questId, npcOvr, npcId)
break
end
end
end
-- Creature objectives: quest[10][1] — this NPC is a kill target
-- (no back-link needed; NPC spawn data already linked via spawns[7])
-- Item objective drop chain: quest[10][3] lists items; if any item's
-- drop list (item[2]) includes this NPC, mark NPC as creature source.
if qData[10] and qData[10][3] then
for _, entry in ipairs(qData[10][3]) do
local itemId = entry[1]
local iData = learned.items[itemId]
if iData and iData[2] then
for _, dropNpc in ipairs(iData[2]) do
if dropNpc == npcId then
-- NPC drops a quest objective item → add as creature objective
_AddToQuestObjective(qData, 1, npcId, nil, qOvr, questId)
break
end
end
end
end
end
end
-- Refresh map pins for any active quests now linked to this NPC
local activeRefs = {}
if learned.quests then
for questId, qData in pairs(learned.quests) do
local refs = (qData[2] and qData[2][1]) or {}
for _, id in ipairs(refs) do if id == npcId then activeRefs[questId] = true end end
refs = (qData[3] and qData[3][1]) or {}
for _, id in ipairs(refs) do if id == npcId then activeRefs[questId] = true end end
if qData[10] and qData[10][1] then
for _, entry in ipairs(qData[10][1]) do
if entry[1] == npcId then activeRefs[questId] = true end
end
end
end
end
_RefreshActiveQuestPins(activeRefs)
end
------------------------------------------------------------------------
-- CrossLinkAfterQuest: called when a new quest is first learned.
-- Stitches NPCs, objects, and items referenced in the quest data.
local function CrossLinkAfterQuest(questId)
local learned = _GetDB()
local qData = learned.quests[questId]
if not qData then return end
local qOvr = QuestieDB and QuestieDB.questDataOverrides
local npcOvr = QuestieDB and QuestieDB.npcDataOverrides
local objOvr = QuestieDB and QuestieDB.objectDataOverrides
-- Starter NPCs: quest[2][1] → npc[10]
if qData[2] and qData[2][1] then
for _, npcId in ipairs(qData[2][1]) do
if learned.npcs[npcId] then
_AddToArray(learned.npcs[npcId], 10, questId, npcOvr, npcId)
end
end
end
-- Starter objects: quest[2][2] → obj[2]
if qData[2] and qData[2][2] then
for _, objId in ipairs(qData[2][2]) do
if learned.objects[objId] then
_AddToArray(learned.objects[objId], 2, questId, objOvr, objId)
end
end
end
-- Finisher NPCs: quest[3][1] → npc[11]
if qData[3] and qData[3][1] then
for _, npcId in ipairs(qData[3][1]) do
if learned.npcs[npcId] then
_AddToArray(learned.npcs[npcId], 11, questId, npcOvr, npcId)
end
end
end
-- Finisher objects: quest[3][2] → obj[3]
if qData[3] and qData[3][2] then
for _, objId in ipairs(qData[3][2]) do
if learned.objects[objId] then
_AddToArray(learned.objects[objId], 3, questId, objOvr, objId)
end
end
end
-- Source item: quest[11] → item[5] (item starts this quest, via startQuest key)
if qData[11] and qData[11] > 0 then
local iData = learned.items[qData[11]]
if iData then
if not iData[5] then
iData[5] = questId
if QuestieDB and QuestieDB.itemDataOverrides then
local ovr = QuestieDB.itemDataOverrides[qData[11]] or {}
QuestieDB.itemDataOverrides[qData[11]] = ovr
if not ovr[5] then ovr[5] = questId end
end
end
end
end
-- Item drop chain: quest has item objectives [10][3]; if any of those
-- items have known drop NPCs (item[2]), add those NPCs as creature objectives.
if qData[10] and qData[10][3] then
for _, entry in ipairs(qData[10][3]) do
local itemId = entry[1]
local iData = learned.items[itemId]
if iData and iData[2] then
for _, dropNpcId in ipairs(iData[2]) do
_AddToQuestObjective(qData, 1, dropNpcId, nil, qOvr, questId)
end
end
end
end
end
------------------------------------------------------------------------
-- CrossLinkAfterObject: called when a new object is first learned.
-- Scans all learned quests for references to this objectId.
local function CrossLinkAfterObject(objectId)
local learned = _GetDB()
local objData = learned.objects[objectId]
if not objData then return end
local objOvr = QuestieDB and QuestieDB.objectDataOverrides
local qOvr = QuestieDB and QuestieDB.questDataOverrides
for questId, qData in pairs(learned.quests) do
-- Object starters: quest[2][2]
if qData[2] and qData[2][2] then
for _, id in ipairs(qData[2][2]) do
if id == objectId then
_AddToArray(objData, 2, questId, objOvr, objectId)
break
end
end
end
-- Object finishers: quest[3][2]
if qData[3] and qData[3][2] then
for _, id in ipairs(qData[3][2]) do
if id == objectId then
_AddToArray(objData, 3, questId, objOvr, objectId)
break
end
end
end
-- Object objectives: quest[10][2] — this object is an interact target
-- Coords are already stored in object spawns; no extra link needed
end
-- Refresh map pins for active quests now linked to this object
local activeRefs = {}
for questId, qData in pairs(learned.quests) do
local function checkList(list)
if list then for _, id in ipairs(list) do if id == objectId then activeRefs[questId] = true end end end
end
checkList(qData[2] and qData[2][2])
checkList(qData[3] and qData[3][2])
if qData[10] and qData[10][2] then
for _, entry in ipairs(qData[10][2]) do
if entry[1] == objectId then activeRefs[questId] = true end
end
end
end
_RefreshActiveQuestPins(activeRefs)
end
------------------------------------------------------------------------
-- CrossLinkAfterItem: called when an item is first learned or when a
-- new drop-NPC relationship is added to an item.
-- Links drop NPCs → quest creature objectives for any quest needing this item.
local function CrossLinkAfterItem(itemId)
local learned = _GetDB()
local iData = learned.items[itemId]
if not iData then return end
local qOvr = QuestieDB and QuestieDB.questDataOverrides
-- If this item starts a quest (item[5]=startQuest), ensure that quest knows
-- about it via quest[2][3] (starter items slot)
for questId, qData in pairs(learned.quests) do
if qData[11] == itemId then
if not iData[5] then
iData[5] = questId
if QuestieDB and QuestieDB.itemDataOverrides then
local ovr = QuestieDB.itemDataOverrides[itemId] or {}
QuestieDB.itemDataOverrides[itemId] = ovr
if not ovr[5] then ovr[5] = questId end
end
end
end
-- If any quest has this item as an objective (quest[10][3]),
-- and we know NPCs that drop it (item[2]), add those NPCs as creature objectives.
if qData[10] and qData[10][3] then
for _, entry in ipairs(qData[10][3]) do
if entry[1] == itemId and iData[2] then
for _, dropNpcId in ipairs(iData[2]) do
_AddToQuestObjective(qData, 1, dropNpcId, nil, qOvr, questId)
end
end
end
end
end
end
------------------------------------------------------------------------
-- CrossLinkAfterQuestGiver: called when a starter/finisher relationship
-- is explicitly recorded. Stitches both the NPC→quest and quest→NPC
-- directions (and objects/items if typeSlot indicates them).
local function CrossLinkAfterQuestGiver(questId, entityId, typeSlot, isStart)
local learned = _GetDB()
local qData = learned.quests[questId]
local npcOvr = QuestieDB and QuestieDB.npcDataOverrides
local objOvr = QuestieDB and QuestieDB.objectDataOverrides
local qOvr = QuestieDB and QuestieDB.questDataOverrides
if typeSlot == 1 then
-- NPC ↔ quest
local npcData = learned.npcs[entityId]
if npcData then
_AddToArray(npcData, isStart and 10 or 11, questId, npcOvr, entityId)
end
if qData then
_AddToNestedArray(qData, isStart and 2 or 3, 1, entityId, qOvr, questId)
end
elseif typeSlot == 2 then
-- Object ↔ quest
local objData = learned.objects[entityId]
if objData then
_AddToArray(objData, isStart and 2 or 3, questId, objOvr, entityId)
end
if qData then
_AddToNestedArray(qData, isStart and 2 or 3, 2, entityId, qOvr, questId)
end
elseif typeSlot == 3 then
-- Item ↔ quest starter (item[3] = starts quest; quest[2][3])
if qData then
_AddToNestedArray(qData, 2, 3, entityId, qOvr, questId)
end
end
end
------------------------------------------------------------------------
-- NPC learning
------------------------------------------------------------------------
-- 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 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
if QuestieQuest and QuestieQuest.UpdateQuest then
QuestieCompat.C_Timer.After(0.5, function()
QuestieQuest:UpdateQuest(questId)
end)
end
-- 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 = {}
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
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
end
end
end
end
for old, new in pairs(npcIdsToFix) do
learned.npcs[new] = learned.npcs[old]
learned.npcs[old] = nil
end
-- 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)")
-- 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 substring match)
-- Used for proactive objective mapping when a quest is first accepted.
function QuestieLearner:GetNPCIdByName(npcName)
if not npcName or npcName == "" then return nil end
local lowerName = string.lower(npcName)
-- Check overrides first (most likely for custom servers)
if QuestieDB.npcDataOverrides then
for id, data in pairs(QuestieDB.npcDataOverrides) do
if data and data[1] and string.lower(data[1]) == lowerName then
return id
end
end
end
-- Check base NPC database
-- Note: QueryNPCSingle is a dummy until initialization, but we can fallback to raw access
local npcData = QuestieDB.npcData or {}
for id, data in pairs(npcData) do
if data and data[1] and string.lower(data[1]) == lowerName then
return id
end
end
return nil
end
-- Fires when a quest is accepted.
function QuestieLearner:OnQuestAccepted(firstArg, secondArg)
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestAccepted raw args: first=" .. tostring(firstArg) .. " second=" .. tostring(secondArg))
local questId
-- Try secondArg first (WotLK standard: logIndex, questID)
if secondArg and type(secondArg) == "number" and secondArg > 0 then
questId = secondArg
end
-- If secondArg was nil/0, firstArg might already be the questID (some 3.3.5 servers),
-- or it's the log index — try resolving it from the log.
if not questId or questId <= 0 then
local maxLog = GetNumQuestLogEntries and GetNumQuestLogEntries() or 25
if firstArg and type(firstArg) == "number" and firstArg > 0 then
-- If firstArg looks like a log index (small number), look it up
if firstArg <= maxLog then
local resolvedId = QuestieCompat.GetQuestIDFromLogIndex(firstArg)
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestAccepted resolved from log index", firstArg, "->", tostring(resolvedId))
if resolvedId and resolvedId > 0 then
questId = resolvedId
end
end
-- Still no questId: scan entire log for recently added quests
if not questId or questId <= 0 then
questId = firstArg -- last resort, may be wrong
end
end
end
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestAccepted id=" .. tostring(questId))
if not questId or questId <= 0 then return end
-- Build data table from quest log (scan for matching entry)
-- Only store fields that match the questKeys schema (name=1, questLevel=5).
-- Do NOT store objectives (key 10) as raw text — the DB compiler expects structured
-- {creatureId, text} tuples; plain strings crash pairs() in GetQuest.
local data = {}
for i = 1, GetNumQuestLogEntries() do
local title, level, _, isHeader, _, _, _, id = QuestieCompat.GetQuestLogTitle(i)
if not isHeader and id == questId then
data[1] = title
data[5] = level and level > 0 and level or nil
break
end
end
-- Zone: reverse-lookup from GetRealZoneText() which is always accurate on 3.3.5.
local zoneText = GetRealZoneText()
if zoneText and zoneText ~= "" 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
-- 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)
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)
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
timer.After(2, FlushBroadcasts)
else
FlushBroadcasts()
end
end
-- Receives validated, decoded data from QuestieLearnerComms or QuestieLearnerExport:MergeImport
function QuestieLearner:HandleNetworkData(typ, id, d, op)
if not self:IsEnabled() then return end
if not EnsureLearnedData() then return end
if not typ or not id or not d then return end
local store
if typ == "NPC" then
if not Questie.dbLearner.global.settings.learnNpcs then return end
store = Questie.dbLearner.global.npcs
elseif typ == "QUEST" then
if not Questie.dbLearner.global.settings.learnQuests then return end
store = Questie.dbLearner.global.quests
elseif typ == "ITEM" then
if not Questie.dbLearner.global.settings.learnItems then return end
store = Questie.dbLearner.global.items
elseif typ == "OBJECT" then
if not Questie.dbLearner.global.settings.learnObjects then return end
store = Questie.dbLearner.global.objects
else
return
end
-- 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
end
local existing = store[id]
if not existing then
store[id] = d
store[id].mc = 1
self:InjectLearnedData()
QuestieLearner.data = Questie.dbLearner.global
return
end
local changed = false
-- Merge: adopt non-nil fields we don't have locally
for k, v in pairs(d) do
if k ~= "mc" and existing[k] == nil then
existing[k] = v
changed = true
end
end
-- Merge coordinates
local coordKey = (typ == "NPC") and 7 or (typ == "OBJECT" and 4 or nil)
if coordKey and type(d[coordKey]) == "table" then
existing[coordKey] = existing[coordKey] or {}
local grid = GetCustomGridPrecision()
for zoneId, coords in pairs(d[coordKey]) do
existing[coordKey][zoneId] = existing[coordKey][zoneId] or {}
for _, coord in ipairs(coords) do
if InsertIfNewBucket(existing[coordKey][zoneId], coord[1], coord[2], grid) then
changed = true
end
end
end
end
-- Merge item drop list
if typ == "ITEM" and type(d[2]) == "table" then
existing[2] = existing[2] or {}
for _, npcId in ipairs(d[2]) do
local found = false
for _, existId in ipairs(existing[2]) do
if existId == npcId then found = true; break end
end
if not found then
table.insert(existing[2], npcId)
changed = true
end
end
end
if changed or (op == "NEW" or op == "UPDATE") then
existing.ls = time() -- Refresh timestamp on network confirmation
existing.mc = (existing.mc or 0) + 1
QuestieLearner.data = Questie.dbLearner.global
self:InjectLearnedData()
end
end
return QuestieLearner