3222 lines
132 KiB
Lua
3222 lines
132 KiB
Lua
---@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
|
||
|
||
-- WoW API locals
|
||
local UnitExists = UnitExists
|
||
local UnitIsVisible = UnitIsVisible
|
||
local UnitIsPlayer = UnitIsPlayer
|
||
local UnitGUID = UnitGUID
|
||
local UnitName = UnitName
|
||
local UnitLevel = UnitLevel
|
||
local UnitFactionGroup = UnitFactionGroup
|
||
local UnitReaction = UnitReaction
|
||
local UnitCreatureFamily = UnitCreatureFamily
|
||
local GetRealZoneText = GetRealZoneText
|
||
local GetTitleText = GetTitleText
|
||
local GetObjectiveText = GetObjectiveText
|
||
local GetQuestDescription = GetQuestDescription
|
||
local GetRewardText = GetRewardText
|
||
local GetQuestID = GetQuestID
|
||
local GetNumQuestLogEntries = GetNumQuestLogEntries
|
||
local GetItemInfo = GetItemInfo
|
||
local CombatLogGetCurrentEventInfo = CombatLogGetCurrentEventInfo
|
||
local CreateFrame = CreateFrame
|
||
local GetTime = GetTime
|
||
|
||
-- Cache for zone lookup: zoneText -> areaId
|
||
_Learner.zoneCache = {}
|
||
|
||
-- NPC flags (WoW bitmask)
|
||
local NPC_FLAG_GOSSIP = 0x00000001
|
||
local NPC_FLAG_QUESTGIVER = 0x00000002
|
||
local NPC_FLAG_TRAINER = 0x00000010
|
||
local NPC_FLAG_VENDOR = 0x00000080
|
||
local NPC_FLAG_FLIGHTMASTER = 0x00000200
|
||
local NPC_FLAG_INNKEEPER = 0x00000800
|
||
local NPC_FLAG_BANKER = 0x00001000
|
||
local NPC_FLAG_AUCTIONEER = 0x00004000
|
||
local NPC_FLAG_STABLEMASTER = 0x00010000
|
||
|
||
-- Only cache/learn mouseover NPCs that carry one of these flags
|
||
local MOUSEOVER_LEARN_FLAGS = NPC_FLAG_QUESTGIVER
|
||
|
||
-- Coordinate grid cell size (in 0–100 map units).
|
||
-- ~2 grid units ≈ 2% of zone width — keeps clusters tight without over-splitting.
|
||
local COORD_GRID = 2.0
|
||
|
||
-- Minimum match count (Confidence) for a learned pin to appear on the map.
|
||
-- Set to 1 so that even a single kill/mouseover confirms a spawn location on
|
||
-- Ascension, where NPC databases are incomplete and every data point matters.
|
||
local MIN_CONFIDENCE_PINS = 1
|
||
|
||
_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 0–100 scale, 2-decimal precision
|
||
return floor(x * 10000) / 100, floor(y * 10000) / 100
|
||
end
|
||
return nil, nil
|
||
end
|
||
|
||
-- Returns the grid-bucket key for a coordinate so nearby points share the same slot
|
||
local function CoordBucket(x, y)
|
||
return floor(x / COORD_GRID) * COORD_GRID, floor(y / COORD_GRID) * COORD_GRID
|
||
end
|
||
|
||
-- Inserts {x, y} into coordList only when no existing point falls in the same grid bucket
|
||
local function InsertIfNewBucket(coordList, x, y, customGrid)
|
||
local grid = customGrid or COORD_GRID
|
||
local bx, by = floor(x / grid) * grid, floor(y / grid) * grid
|
||
for _, coord in ipairs(coordList) do
|
||
local cx, cy = floor(coord[1] / grid) * grid, floor(coord[2] / grid) * grid
|
||
if cx == bx and cy == by then return false end
|
||
end
|
||
table.insert(coordList, {x, y})
|
||
return true
|
||
end
|
||
|
||
-- Detects if the current map is a "Micro-Dungeon" (small interior map)
|
||
-- This is a heuristic: if we lack map data, we default to standard grid.
|
||
local function GetCustomGridPrecision()
|
||
local uiMapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
|
||
if not uiMapId then return COORD_GRID end
|
||
|
||
-- Known micro-dungeons or small interior maps where 2% precision is too coarse.
|
||
-- (e.g., Northshire Abbey, Anvilmar, Crypts, etc.)
|
||
-- For now, we use a simple list of common starting sub-zones if available.
|
||
-- Or we could check map bounds if we had that data.
|
||
local microDungeons = {
|
||
[425] = 0.5, -- Northshire Abbey
|
||
[468] = 0.5, -- Anvilmar
|
||
[469] = 0.5, -- Coldridge Valley (Interior)
|
||
-- Add more as needed
|
||
}
|
||
return microDungeons[uiMapId] or COORD_GRID
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Internal state guards
|
||
------------------------------------------------------------------------
|
||
|
||
local function EnsureLearnedData()
|
||
if not Questie.db or not Questie.dbLearner then return false end
|
||
|
||
-- Migration: If data exists in the old QuestieConfig.global.learnedData, move it to the new QuestieLearnerDB.global
|
||
if Questie.db.global.learnedData then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Migrating learnedData to separate SavedVariable...")
|
||
for k, v in pairs(Questie.db.global.learnedData) do
|
||
Questie.dbLearner.global[k] = v
|
||
end
|
||
Questie.db.global.learnedData = nil
|
||
Questie:Print("|cFF5EBAF3Questie-X:|r Learned data has been migrated to a separate SavedVariable for better performance.")
|
||
end
|
||
|
||
local ld = Questie.dbLearner.global
|
||
if (not ld.npcs) and (not ld.quests) then
|
||
ld.npcs = {}
|
||
ld.quests = {}
|
||
ld.items = {}
|
||
ld.objects = {}
|
||
ld.settings = {
|
||
enabled = true,
|
||
learnNpcs = true,
|
||
learnQuests = true,
|
||
learnItems = true,
|
||
learnObjects = true,
|
||
minConfidencePins = 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
|
||
|
||
------------------------------------------------------------------------
|
||
-- 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
|
||
local zoneId = spawnZoneId or GetZoneId()
|
||
local x, y
|
||
if spawnX and spawnY then
|
||
x, y = spawnX, spawnY
|
||
else
|
||
x, y = GetPlayerCoords()
|
||
end
|
||
|
||
local existing = Questie.dbLearner.global.npcs[npcId]
|
||
local isNew = existing == nil
|
||
if not existing then
|
||
existing = {}
|
||
Questie.dbLearner.global.npcs[npcId] = existing
|
||
end
|
||
|
||
if name and 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 x and y and zoneId and zoneId > 0 then
|
||
existing[7] = existing[7] or {}
|
||
existing[7][zoneId] = existing[7][zoneId] or {}
|
||
InsertIfNewBucket(existing[7][zoneId], x, y)
|
||
end
|
||
|
||
existing.ls = time() -- Update last seen
|
||
existing.mc = (existing.mc or 0) + 1
|
||
|
||
local threshold = (Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.minConfidencePins) or MIN_CONFIDENCE_PINS
|
||
|
||
-- Live injection: update npcDataOverrides only if confidence threshold is met
|
||
if existing.mc >= threshold and QuestieDB and QuestieDB.npcDataOverrides then
|
||
local ovr = QuestieDB.npcDataOverrides[npcId]
|
||
if not ovr then
|
||
QuestieDB.npcDataOverrides[npcId] = existing
|
||
else
|
||
-- Merge: fill missing fields; also overwrite empty-string names
|
||
for k, v in pairs(existing) do
|
||
if ovr[k] == nil or (k == 1 and ovr[k] == "") then ovr[k] = v end
|
||
end
|
||
-- Always merge spawn coords
|
||
if existing[7] then
|
||
ovr[7] = ovr[7] or {}
|
||
for zid, coords in pairs(existing[7]) do
|
||
ovr[7][zid] = ovr[7][zid] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(ovr[7][zid], coord[1], coord[2])
|
||
end
|
||
end
|
||
end
|
||
end
|
||
-- Clear the compiled DB cache so GetNPC rebuilds with the new override data.
|
||
if QuestieDB.private and QuestieDB.private.npcCache then
|
||
QuestieDB.private.npcCache[npcId] = nil
|
||
end
|
||
end
|
||
|
||
if isNew then
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New NPC learned:", npcId, name or "?")
|
||
CrossLinkAfterNPC(npcId)
|
||
end
|
||
-- Existing NPC got new spawn data: invalidate cached spawnLists
|
||
-- for any active quest objective that references this NPC so the
|
||
-- map system rebuilds them with fresh data on next update.
|
||
_InvalidateSpawnListsForNPC(npcId)
|
||
_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).
|
||
-- 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
|
||
-- 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
|
||
|
||
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: same format as recentKills so Phase 3 merge is consistent
|
||
-- Formula: floor(v * 10000) / 100 — converts normalized 0-1 to scaled 0-100
|
||
local nx = floor(x * 10000) / 100
|
||
local ny = floor(y * 10000) / 100
|
||
|
||
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
|
||
-- Round to 2 decimal places for grouping
|
||
local rx = floor(entry.x * 100 + 0.5) / 100
|
||
local ry = floor(entry.y * 100 + 0.5) / 100
|
||
local key = entry.zoneId .. "|" .. rx .. "|" .. ry
|
||
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
|
||
|
||
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
|
||
|
||
-- Only override if > 60% confidence AND spawn differs from static DB
|
||
if topPct <= 60 then
|
||
Questie:Debug(Questie.DEBUG_LEARNER,
|
||
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
|
||
"top spawn", topCount .. "/" .. totalEvidence,
|
||
"= " .. floor(topPct + 0.5) .. "% — below 60%, no override")
|
||
return false
|
||
end
|
||
|
||
-- Check against static DB entry
|
||
local staticNPC = nil
|
||
if QuestieDB and QuestieDB.QueryNPC then
|
||
staticNPC = QuestieDB.QueryNPC(npcId, 1)
|
||
end
|
||
|
||
local staticSpawnList = staticNPC and staticNPC[7]
|
||
local staticSpawnsForZone = staticSpawnList and staticSpawnList[topEvidence.zoneId]
|
||
|
||
-- Check if top spawn matches any static spawn in the same zone
|
||
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
|
||
if abs(sx - topEvidence.x) < 0.01 and abs(sy - topEvidence.y) < 0.01 then
|
||
matchesStatic = true
|
||
break
|
||
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
|
||
|
||
-- Override: inject top spawn into QuestieDB spawn overrides for this zone
|
||
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
|
||
|
||
-- Insert as new spawn (InsertIfNewBucket deduplicates)
|
||
InsertIfNewBucket(QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId],
|
||
topEvidence.x, topEvidence.y)
|
||
|
||
Questie:Debug(Questie.DEBUG_LEARNER,
|
||
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
|
||
"overrode static DB — learned spawn (" .. topEvidence.x .. "," .. topEvidence.y .. ")",
|
||
"zone " .. topEvidence.zoneId .. " at " .. floor(topPct + 0.5) .. "% confidence")
|
||
|
||
-- Clear npcCache so GetNPC returns fresh data
|
||
if QuestieDB.private and QuestieDB.private.npcCache then
|
||
QuestieDB.private.npcCache[npcId] = nil
|
||
end
|
||
|
||
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 then ovr[k] = v end
|
||
end
|
||
end
|
||
end
|
||
|
||
if isNew then
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New quest learned:", questId, existing[1] or "?")
|
||
CrossLinkAfterQuest(questId)
|
||
end
|
||
_Learner:BroadcastIfCommsAvailable("QUEST", questId, existing)
|
||
end
|
||
|
||
-- Records the NPC/object that starts or finishes a quest (array index [2] or [3])
|
||
function QuestieLearner:LearnQuestGiver(questId, entityId, entityType, isStart)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnQuests then return end
|
||
questId, entityId = tonumber(questId), tonumber(entityId)
|
||
if not questId or questId <= 0 or not entityId or entityId <= 0 then return end
|
||
|
||
local existing = Questie.dbLearner.global.quests[questId]
|
||
if not existing then
|
||
existing = {}
|
||
Questie.dbLearner.global.quests[questId] = existing
|
||
end
|
||
|
||
-- Starters/finishers: { [1]={npcIds}, [2]={objIds}, [3]={itemIds} }
|
||
local field = isStart and 2 or 3
|
||
existing[field] = existing[field] or {}
|
||
-- entityType: 1=npc, 2=obj, 3=item
|
||
local typeSlot = entityType or 1
|
||
existing[field][typeSlot] = existing[field][typeSlot] or {}
|
||
local list = existing[field][typeSlot]
|
||
for _, id in ipairs(list) do
|
||
if id == entityId then return end
|
||
end
|
||
table.insert(list, entityId)
|
||
|
||
-- Live injection into questDataOverrides so starters/finishers take effect without reload
|
||
if QuestieDB and QuestieDB.questDataOverrides then
|
||
local ovr = QuestieDB.questDataOverrides[questId] or {}
|
||
QuestieDB.questDataOverrides[questId] = ovr
|
||
ovr[field] = ovr[field] or {}
|
||
ovr[field][typeSlot] = ovr[field][typeSlot] or {}
|
||
local ovrList = ovr[field][typeSlot]
|
||
local found = false
|
||
for _, id in ipairs(ovrList) do
|
||
if id == entityId then found = true; break end
|
||
end
|
||
if not found then table.insert(ovrList, entityId) end
|
||
end
|
||
|
||
-- Cross-link both directions for all entity types
|
||
CrossLinkAfterQuestGiver(questId, entityId, typeSlot, isStart)
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Quest objective NPC learning (kill objectives)
|
||
------------------------------------------------------------------------
|
||
|
||
-- Adds npcId as a creatureObjective for questId ([10][1] in questKeys schema).
|
||
-- If the NPC already exists in the base DB the spawn data is already there;
|
||
-- we only need the quest to reference it so tooltips/map-pins get registered.
|
||
function QuestieLearner:LearnQuestObjectiveNPC(questId, npcId, objText, objectiveIndex)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnQuests then return end
|
||
questId, npcId = tonumber(questId), tonumber(npcId)
|
||
objectiveIndex = tonumber(objectiveIndex)
|
||
if not questId or questId <= 0 or not npcId or npcId <= 0 then return end
|
||
|
||
-- 1. Persist to SavedVariables
|
||
local existing = Questie.dbLearner.global.quests[questId] or {}
|
||
Questie.dbLearner.global.quests[questId] = existing
|
||
existing[10] = existing[10] or {}
|
||
existing[10][1] = existing[10][1] or {} -- creatureObjective slot
|
||
local alreadyInSV = false
|
||
for _, entry in ipairs(existing[10][1]) do
|
||
if entry[1] == npcId then alreadyInSV = true; break end
|
||
end
|
||
if not alreadyInSV then
|
||
table.insert(existing[10][1], { npcId, objText or "" })
|
||
end
|
||
|
||
-- 1.1 Persist exact objective index mapping
|
||
if objectiveIndex then
|
||
existing.objIndex = existing.objIndex or {}
|
||
local entry = existing.objIndex[objectiveIndex]
|
||
if not entry then
|
||
existing.objIndex[objectiveIndex] = { type = "monster", id = npcId, text = objText or "" }
|
||
elseif entry.id ~= npcId then
|
||
-- Handle Kill Credit (multiple NPCs for one objective)
|
||
if type(entry.id) == "number" then
|
||
entry.id = { entry.id, npcId }
|
||
entry.type = "killcredit"
|
||
else
|
||
local found = false
|
||
for _, id in ipairs(entry.id) do
|
||
if id == npcId then found = true; break end
|
||
end
|
||
if not found then table.insert(entry.id, npcId) end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 2. Apply to live questDataOverrides immediately (no reload needed)
|
||
if QuestieDB and QuestieDB.questDataOverrides then
|
||
local ovr = QuestieDB.questDataOverrides[questId] or {}
|
||
QuestieDB.questDataOverrides[questId] = ovr
|
||
ovr[10] = ovr[10] or {}
|
||
ovr[10][1] = ovr[10][1] or {}
|
||
local alreadyPresent = false
|
||
for _, entry in ipairs(ovr[10][1]) do
|
||
if entry[1] == npcId then alreadyPresent = true; break end
|
||
end
|
||
if not alreadyPresent then
|
||
table.insert(ovr[10][1], { npcId, objText or "" })
|
||
end
|
||
|
||
-- Update live objIndex override
|
||
if objectiveIndex then
|
||
ovr.objIndex = ovr.objIndex or {}
|
||
ovr.objIndex[objectiveIndex] = existing.objIndex[objectiveIndex]
|
||
end
|
||
end
|
||
|
||
-- 3. Re-process the quest so PopulateObjective registers tooltips & map pins
|
||
if QuestieQuest and QuestieQuest.UpdateQuest then
|
||
QuestieCompat.C_Timer.After(0.5, function()
|
||
QuestieQuest:UpdateQuest(questId)
|
||
end)
|
||
end
|
||
|
||
-- 3. Register with tooltip system immediately. 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 then ovr[k] = v end
|
||
end
|
||
end
|
||
end
|
||
|
||
if isNew then
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New item learned:", itemId, name or "?")
|
||
CrossLinkAfterItem(itemId)
|
||
end
|
||
_Learner:BroadcastIfCommsAvailable("ITEM", itemId, existing)
|
||
end
|
||
|
||
function QuestieLearner:LearnItemDrop(itemId, npcId)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnItems then return end
|
||
itemId, npcId = tonumber(itemId), tonumber(npcId)
|
||
if not itemId or itemId <= 0 or not npcId or npcId <= 0 then return end
|
||
|
||
local existing = Questie.dbLearner.global.items[itemId]
|
||
if not existing then
|
||
existing = {}
|
||
Questie.dbLearner.global.items[itemId] = existing
|
||
end
|
||
|
||
existing.ls = time() -- Update last seen
|
||
|
||
existing[2] = existing[2] or {}
|
||
for _, id in ipairs(existing[2]) do
|
||
if id == npcId then return end
|
||
end
|
||
table.insert(existing[2], npcId)
|
||
|
||
-- Live injection: sync drop list to itemDataOverrides
|
||
if QuestieDB and QuestieDB.itemDataOverrides then
|
||
local ovr = QuestieDB.itemDataOverrides[itemId] or {}
|
||
QuestieDB.itemDataOverrides[itemId] = ovr
|
||
ovr[2] = ovr[2] or {}
|
||
local found = false
|
||
for _, id in ipairs(ovr[2]) do
|
||
if id == npcId then found = true; break end
|
||
end
|
||
if not found then table.insert(ovr[2], npcId) end
|
||
end
|
||
|
||
-- New drop relationship: re-run item cross-link to chain drop NPC → quest objectives
|
||
CrossLinkAfterItem(itemId)
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Object learning
|
||
------------------------------------------------------------------------
|
||
|
||
function QuestieLearner:LearnObject(objectId, name)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnObjects then return end
|
||
objectId = tonumber(objectId)
|
||
if not objectId or objectId <= 0 then return end
|
||
|
||
local zoneId = GetZoneId()
|
||
local x, y = GetPlayerCoords()
|
||
|
||
local existing = Questie.dbLearner.global.objects[objectId]
|
||
local isNew = existing == nil
|
||
if not existing then
|
||
existing = {}
|
||
Questie.dbLearner.global.objects[objectId] = existing
|
||
end
|
||
|
||
if name and not existing[1] then existing[1] = name end
|
||
if zoneId and zoneId > 0 and not existing[5] then existing[5] = zoneId end
|
||
|
||
if x and y and zoneId and zoneId > 0 then
|
||
existing[4] = existing[4] or {}
|
||
existing[4][zoneId] = existing[4][zoneId] or {}
|
||
InsertIfNewBucket(existing[4][zoneId], x, y)
|
||
end
|
||
|
||
existing.ls = time() -- Update last seen
|
||
existing.mc = (existing.mc or 0) + 1
|
||
|
||
-- Live injection into objectDataOverrides so QueryObjectSingle works without reload
|
||
if QuestieDB and QuestieDB.objectDataOverrides then
|
||
local ovr = QuestieDB.objectDataOverrides[objectId]
|
||
if not ovr then
|
||
QuestieDB.objectDataOverrides[objectId] = existing
|
||
else
|
||
for k, v in pairs(existing) do
|
||
if ovr[k] == nil then ovr[k] = v end
|
||
end
|
||
if existing[4] then
|
||
ovr[4] = ovr[4] or {}
|
||
for zid, coords in pairs(existing[4]) do
|
||
ovr[4][zid] = ovr[4][zid] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(ovr[4][zid], coord[1], coord[2])
|
||
end
|
||
end
|
||
end
|
||
end
|
||
-- 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
|
||
|
||
local learned = Questie.dbLearner.global
|
||
-- 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
|
||
-- 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 ZoneDB and ZoneDB.GetAreaIdByUiMapId then
|
||
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey)
|
||
if maybeAreaId and maybeAreaId ~= zoneKey then
|
||
zonesToMigrate[zoneKey] = 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
|
||
if ZoneDB and ZoneDB.GetAreaIdByUiMapId then
|
||
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey)
|
||
if maybeAreaId and maybeAreaId ~= zoneKey then
|
||
zonesToMigrate[zoneKey] = 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 maybeAreaId = ZoneDB:GetAreaIdByUiMapId(data[9])
|
||
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 maybeAreaId = ZoneDB:GetAreaIdByUiMapId(data[5])
|
||
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
|
||
|
||
-- 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
|
||
QuestieDB.npcDataOverrides[nid or npcId] = data
|
||
npcCount = npcCount + 1
|
||
else
|
||
local existing = QuestieDB.npcDataOverrides[nid or npcId]
|
||
if data[7] then
|
||
existing[7] = existing[7] or {}
|
||
for zoneId, coords in pairs(data[7]) do
|
||
existing[7][zoneId] = existing[7][zoneId] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(existing[7][zoneId], coord[1], coord[2])
|
||
end
|
||
end
|
||
end
|
||
-- Adopt other fields if missing
|
||
for k, v in pairs(data) do
|
||
if k ~= "mc" and k ~= 7 and existing[k] == nil then
|
||
existing[k] = v
|
||
end
|
||
end
|
||
end
|
||
end
|
||
for old, new in pairs(npcIdsToFix) do
|
||
learned.npcs[new] = learned.npcs[old]
|
||
learned.npcs[old] = nil
|
||
end
|
||
|
||
-- 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]
|
||
existing[10] = existing[10] or {}
|
||
existing[10][1] = existing[10][1] or {}
|
||
if type(v[1]) == "table" then
|
||
for _, entry in ipairs(v[1]) do
|
||
local found = false
|
||
for _, ex in ipairs(existing[10][1]) do
|
||
if ex[1] == entry[1] then found = true; break end
|
||
end
|
||
if not found then
|
||
tinsert(existing[10][1], entry)
|
||
end
|
||
end
|
||
end
|
||
elseif existing[k] == nil then
|
||
existing[k] = v
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
for old, new in pairs(questIdsToFix) do
|
||
learned.quests[new] = learned.quests[old]
|
||
learned.quests[old] = nil
|
||
end
|
||
|
||
-- 3. Items
|
||
local itemIdsToFix = {}
|
||
for itemId, data in pairs(learned.items) do
|
||
local iid = tonumber(itemId)
|
||
if type(itemId) == "string" and iid then
|
||
itemIdsToFix[itemId] = iid
|
||
end
|
||
if not QuestieDB.itemDataOverrides[iid or itemId] then
|
||
QuestieDB.itemDataOverrides[iid or itemId] = data
|
||
itemCount = itemCount + 1
|
||
end
|
||
end
|
||
for old, new in pairs(itemIdsToFix) do
|
||
learned.items[new] = learned.items[old]
|
||
learned.items[old] = nil
|
||
end
|
||
|
||
-- 4. Objects
|
||
local objectIdsToFix = {}
|
||
for objectId, data in pairs(learned.objects) do
|
||
local oid = tonumber(objectId)
|
||
if type(objectId) == "string" and oid then
|
||
objectIdsToFix[objectId] = oid
|
||
end
|
||
self:Sanitize(data)
|
||
if not QuestieDB.objectDataOverrides[oid or objectId] then
|
||
QuestieDB.objectDataOverrides[oid or objectId] = data
|
||
objectCount = objectCount + 1
|
||
else
|
||
local existing = QuestieDB.objectDataOverrides[oid or objectId]
|
||
if data[4] then
|
||
existing[4] = existing[4] or {}
|
||
for zoneId, coords in pairs(data[4]) do
|
||
existing[4][zoneId] = existing[4][zoneId] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(existing[4][zoneId], coord[1], coord[2])
|
||
end
|
||
end
|
||
end
|
||
-- Adopt other fields
|
||
for k, v in pairs(data) do
|
||
if k ~= "mc" and k ~= 4 and existing[k] == nil then
|
||
existing[k] = v
|
||
end
|
||
end
|
||
end
|
||
end
|
||
for old, new in pairs(objectIdsToFix) do
|
||
learned.objects[new] = learned.objects[old]
|
||
learned.objects[old] = nil
|
||
end
|
||
|
||
if npcCount > 0 or questCount > 0 or itemCount > 0 or objectCount > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Injected learned data:",
|
||
npcCount, "NPCs,", questCount, "quests,", itemCount, "items,", objectCount, "objects")
|
||
end
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Stats / Export helpers
|
||
------------------------------------------------------------------------
|
||
|
||
function QuestieLearner:GetStats()
|
||
if not EnsureLearnedData() then return 0, 0, 0, 0 end
|
||
local learned = Questie.dbLearner.global
|
||
local n, q, i, o = 0, 0, 0, 0
|
||
for _ in pairs(learned.npcs) do n = n + 1 end
|
||
for _ in pairs(learned.quests) do q = q + 1 end
|
||
for _ in pairs(learned.items) do i = i + 1 end
|
||
for _ in pairs(learned.objects) do o = o + 1 end
|
||
return n, q, i, o
|
||
end
|
||
|
||
function QuestieLearner:ClearAllData()
|
||
if not EnsureLearnedData() then return end
|
||
Questie.dbLearner.global.npcs = {}
|
||
Questie.dbLearner.global.quests = {}
|
||
Questie.dbLearner.global.items = {}
|
||
Questie.dbLearner.global.objects = {}
|
||
Questie:Print("Cleared all learned data.")
|
||
end
|
||
|
||
function QuestieLearner:SerializeTable(t)
|
||
if type(t) ~= "table" then
|
||
if type(t) == "string" then return string.format("%q", t) end
|
||
return tostring(t)
|
||
end
|
||
local parts = {}
|
||
local isArray = #t > 0
|
||
for k, v in pairs(t) do
|
||
local key = isArray and "" or ("[" .. (type(k) == "string" and string.format("%q", k) or tostring(k)) .. "]=")
|
||
table.insert(parts, key .. self:SerializeTable(v))
|
||
end
|
||
return "{" .. table.concat(parts, ",") .. "}"
|
||
end
|
||
|
||
function QuestieLearner:ExportData()
|
||
if not EnsureLearnedData() then return "" end
|
||
local learned = Questie.dbLearner.global
|
||
local lines = {}
|
||
table.insert(lines, "-- QuestieLearner Export")
|
||
local n, q, i, o = self:GetStats()
|
||
table.insert(lines, "-- NPCs: " .. n .. " Quests: " .. q .. " Items: " .. i .. " Objects: " .. o)
|
||
table.insert(lines, "")
|
||
table.insert(lines, "QuestieLearnerExport = {")
|
||
table.insert(lines, " npcs = " .. self:SerializeTable(learned.npcs) .. ",")
|
||
table.insert(lines, " quests = " .. self:SerializeTable(learned.quests) .. ",")
|
||
table.insert(lines, " items = " .. self:SerializeTable(learned.items) .. ",")
|
||
table.insert(lines, " objects = " .. self:SerializeTable(learned.objects) .. ",")
|
||
table.insert(lines, "}")
|
||
return table.concat(lines, "\n")
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- GUID parsing
|
||
------------------------------------------------------------------------
|
||
|
||
local HEX_PREFIXES = {
|
||
["F130"] = "Creature",
|
||
["F131"] = "Vehicle",
|
||
["F140"] = "GameObject",
|
||
["F110"] = "Creature",
|
||
["F111"] = "Creature",
|
||
}
|
||
|
||
local CREATURE_HEX_PREFIXES = { ["F130"]=true, ["F131"]=true, ["F110"]=true, ["F111"]=true }
|
||
|
||
local function GetIdAndTypeFromGUID(guid)
|
||
if not guid then return nil, nil end
|
||
-- Modern dash-separated GUID (e.g. "Creature-0-3726-0-189-5638296-...")
|
||
local unitType, _, _, _, _, parsedId = strsplit("-", guid)
|
||
local id = tonumber(parsedId)
|
||
if id and id > 0 and unitType then
|
||
return id, unitType
|
||
end
|
||
-- Legacy hex GUID
|
||
if string.sub(guid, 1, 2) == "0x" and string.len(guid) >= 18 then
|
||
local prefix = string.upper(string.sub(guid, 3, 6))
|
||
local t = HEX_PREFIXES[prefix]
|
||
if t then
|
||
local low32 = tonumber(string.sub(guid, 11, 18), 16)
|
||
if low32 then
|
||
local nid = low32 % 8388608
|
||
if nid > 0 then return nid, t end
|
||
end
|
||
end
|
||
end
|
||
return nil, nil
|
||
end
|
||
|
||
-- Forward declarations for GUID parsing functions used by event handlers above.
|
||
-- The full implementations are at lines 1567 and 1604.
|
||
local GetNpcIdFromGUID = function(guid)
|
||
if not guid or type(guid) ~= "string" then return nil end
|
||
local strId = guid:match("Creature%-%d+%-%d+%-%d+%-%d+%-(%d+)")
|
||
if strId then return tonumber(strId) end
|
||
if guid:match("^0x") then
|
||
local hex = guid:sub(3)
|
||
local prefix = hex:sub(1, 4)
|
||
local isCreature = (
|
||
prefix == "F130" or prefix == "F131" or
|
||
prefix == "F110" or prefix == "F111" or
|
||
prefix == "F150" or prefix == "F151" or
|
||
(prefix:sub(1,1) == "F" and prefix ~= "F140" and prefix ~= "F141")
|
||
)
|
||
if not isCreature then return nil end
|
||
if #hex >= 10 then
|
||
local id = tonumber(hex:sub(5, 10), 16)
|
||
if id and id > 0 then return id end
|
||
end
|
||
if #hex >= 8 then
|
||
local id = tonumber(hex:sub(5, 8), 16)
|
||
if id and id > 0 then return id end
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local GetObjectIdFromGUID = function(guid)
|
||
if not guid or type(guid) ~= "string" then return nil end
|
||
local strId = guid:match("GameObject%-%d+%-%d+%-%d+%-%d+%-(%d+)")
|
||
if strId then return tonumber(strId) end
|
||
if guid:match("^0x") then
|
||
local hex = guid:sub(3)
|
||
if #hex >= 10 then
|
||
local id = tonumber(hex:sub(5, 10), 16)
|
||
if id and id > 0 then return id end
|
||
end
|
||
if #hex >= 8 then
|
||
local id = tonumber(hex:sub(5, 8), 16)
|
||
if id and id > 0 then return id end
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Event handlers
|
||
------------------------------------------------------------------------
|
||
|
||
-- Checks whether an NPC (by npcFlags bitmask) should be learned on mouseover.
|
||
-- Only quest givers and turn-in NPCs are relevant for the learner.
|
||
local function NpcFlagsHasQuestGiver(flags)
|
||
if not flags then return false end
|
||
-- bitwise AND for Lua 5.1 (no bit library guaranteed)
|
||
return math.floor(flags / NPC_FLAG_QUESTGIVER) % 2 == 1
|
||
end
|
||
|
||
function QuestieLearner:OnMouseoverUnit()
|
||
if not UnitExists("mouseover") or not UnitIsVisible("mouseover") then return end
|
||
if UnitIsPlayer("mouseover") then return end
|
||
|
||
local guid = UnitGUID("mouseover")
|
||
if not guid then return end
|
||
if guid == _Learner._lastMouseoverGuid then return end
|
||
_Learner._lastMouseoverGuid = guid
|
||
|
||
local npcId = GetNpcIdFromGUID(guid)
|
||
if not npcId or npcId <= 0 then return end
|
||
|
||
-- Only learn this NPC if it carries the questgiver flag OR if it is already
|
||
-- known in the database as a starter/finisher (so we can update its coords).
|
||
local npcFlags = UnitNPCFlags and UnitNPCFlags("mouseover") or 0
|
||
local isQuestGiver = NpcFlagsHasQuestGiver(npcFlags)
|
||
|
||
if not isQuestGiver then
|
||
-- Silently check raw table — do NOT call GetNPC which logs CRITICAL for every miss
|
||
local rawNpc = QuestieDB and QuestieDB.npcData and QuestieDB.npcData[npcId]
|
||
if rawNpc and (rawNpc[10] or rawNpc[11]) then
|
||
-- known quest starter (key 10) or quest ender (key 11)
|
||
isQuestGiver = true
|
||
end
|
||
end
|
||
|
||
if not isQuestGiver then return end
|
||
|
||
local name = UnitName("mouseover")
|
||
local level = UnitLevel("mouseover")
|
||
local zoneText = GetRealZoneText()
|
||
local areaId = _Learner.zoneCache[zoneText]
|
||
if not areaId 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)
|
||
|
||
-- Phase 3: weighted merge when evidence count >= 3
|
||
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 >= 3 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
|
||
local function _CollectOutlierKeys(spawnTable)
|
||
local keys = {}
|
||
local n = 0
|
||
local coord = next(spawnTable)
|
||
while coord do
|
||
n = n + 1
|
||
keys[n] = coord
|
||
coord = next(spawnTable, coord)
|
||
end
|
||
return keys, n
|
||
end
|
||
|
||
--- 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 coord = next(spawnTable)
|
||
while coord do
|
||
n = n + 1
|
||
learnedPoints[n] = { coord[1], coord[2] }
|
||
coord = next(spawnTable, coord)
|
||
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
|
||
coord = next(spawnTable)
|
||
while coord do
|
||
local dx = abs(coord[1] - medianX)
|
||
local dy = abs(coord[2] - medianY)
|
||
if dx > pruneX or dy > pruneY then
|
||
rmCount = rmCount + 1
|
||
toRemove[rmCount] = coord
|
||
end
|
||
coord = next(spawnTable, coord)
|
||
end
|
||
|
||
-- Pass 3: delete in second pass (no mid-iteration table mutation)
|
||
local removed = 0
|
||
for i = 1, rmCount do
|
||
spawnTable[toRemove[i]] = nil
|
||
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 staticNPC = nil
|
||
if QuestieDB and QuestieDB.QueryNPC then
|
||
staticNPC = QuestieDB.QueryNPC(npcId, 1)
|
||
end
|
||
|
||
if staticNPC and staticNPC[7] and staticNPC[7][zoneId] then
|
||
-- Static anchor path: build centroid from static spawns
|
||
local sc = staticNPC[7][zoneId]
|
||
local si = next(sc)
|
||
local sn = 0
|
||
local sumX, sumY = 0, 0
|
||
while si do
|
||
sn = sn + 1
|
||
sumX = sumX + sc[si][1]
|
||
sumY = sumY + sc[si][2]
|
||
si = 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 coord = next(zoneSpawns)
|
||
while coord do
|
||
local dx = abs(coord[1] - anchorX)
|
||
local dy = abs(coord[2] - anchorY)
|
||
if dx > threshold or dy > threshold then
|
||
rmCount = rmCount + 1
|
||
toRemove[rmCount] = coord
|
||
end
|
||
coord = next(zoneSpawns, coord)
|
||
end
|
||
for i = 1, rmCount do
|
||
zoneSpawns[toRemove[i]] = nil
|
||
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
|
||
|
||
------------------------------------------------------------------------
|
||
-- 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()
|
||
|
||
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
|
||
------------------------------------------------------------------------
|
||
|
||
function _Learner:BroadcastIfCommsAvailable(typ, id, data)
|
||
local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms")
|
||
if QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData then
|
||
local op = (data.mc and data.mc > 1) and "UPDATE" or "NEW"
|
||
QuestieLearnerComms:BroadcastLearnedData(op, typ, id, data)
|
||
end
|
||
end
|
||
|
||
-- Receives validated, decoded data from QuestieLearnerComms or QuestieLearnerExport:MergeImport
|
||
function QuestieLearner:HandleNetworkData(typ, id, d, op)
|
||
if not self:IsEnabled() then return end
|
||
if not EnsureLearnedData() then return end
|
||
if not typ or not id or not d then return end
|
||
|
||
local store
|
||
if typ == "NPC" then
|
||
if not Questie.dbLearner.global.settings.learnNpcs then return end
|
||
store = Questie.dbLearner.global.npcs
|
||
elseif typ == "QUEST" then
|
||
if not Questie.dbLearner.global.settings.learnQuests then return end
|
||
store = Questie.dbLearner.global.quests
|
||
elseif typ == "ITEM" then
|
||
if not Questie.dbLearner.global.settings.learnItems then return end
|
||
store = Questie.dbLearner.global.items
|
||
elseif typ == "OBJECT" then
|
||
if not Questie.dbLearner.global.settings.learnObjects then return end
|
||
store = Questie.dbLearner.global.objects
|
||
else
|
||
return
|
||
end
|
||
|
||
local existing = store[id]
|
||
if not existing then
|
||
store[id] = d
|
||
store[id].mc = 1
|
||
self:InjectLearnedData()
|
||
QuestieLearner.data = Questie.dbLearner.global
|
||
return
|
||
end
|
||
|
||
local changed = false
|
||
-- Merge: adopt non-nil fields we don't have locally
|
||
for k, v in pairs(d) do
|
||
if k ~= "mc" and existing[k] == nil then
|
||
existing[k] = v
|
||
changed = true
|
||
end
|
||
end
|
||
|
||
-- Merge coordinates
|
||
local coordKey = (typ == "NPC") and 7 or (typ == "OBJECT" and 4 or nil)
|
||
if coordKey and type(d[coordKey]) == "table" then
|
||
existing[coordKey] = existing[coordKey] or {}
|
||
local grid = GetCustomGridPrecision()
|
||
for zoneId, coords in pairs(d[coordKey]) do
|
||
existing[coordKey][zoneId] = existing[coordKey][zoneId] or {}
|
||
for _, coord in ipairs(coords) do
|
||
if InsertIfNewBucket(existing[coordKey][zoneId], coord[1], coord[2], grid) then
|
||
changed = true
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Merge item drop list
|
||
if typ == "ITEM" and type(d[2]) == "table" then
|
||
existing[2] = existing[2] or {}
|
||
for _, npcId in ipairs(d[2]) do
|
||
local found = false
|
||
for _, existId in ipairs(existing[2]) do
|
||
if existId == npcId then found = true; break end
|
||
end
|
||
if not found then
|
||
table.insert(existing[2], npcId)
|
||
changed = true
|
||
end
|
||
end
|
||
end
|
||
|
||
if changed or (op == "NEW" or op == "UPDATE") then
|
||
existing.ls = time() -- Refresh timestamp on network confirmation
|
||
existing.mc = (existing.mc or 0) + 1
|
||
QuestieLearner.data = Questie.dbLearner.global
|
||
self:InjectLearnedData()
|
||
end
|
||
end
|
||
|
||
return QuestieLearner
|