4536 lines
186 KiB
Lua
4536 lines
186 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 {}
|
||
|
||
local GetDataSourceMode
|
||
local DeepCopy
|
||
|
||
local floor = math.floor
|
||
local abs = math.abs
|
||
local time = time
|
||
local tinsert = table.insert
|
||
local ipairs = ipairs
|
||
local pairs = pairs
|
||
local next = next
|
||
local type = type
|
||
local tostring = tostring
|
||
local tonumber = tonumber
|
||
local string_trim = string.trim
|
||
local string_sub = string.sub
|
||
local string_len = string.len
|
||
local string_upper = string.upper
|
||
local AceConfigRegistry = LibStub and LibStub("AceConfigRegistry-3.0", true)
|
||
|
||
local function IsAscensionProtected(dbType, id, key)
|
||
local mode = GetDataSourceMode and GetDataSourceMode() or "auto"
|
||
if mode == "learner" or mode == "none" then
|
||
return false
|
||
end
|
||
local protected = QuestieDB
|
||
and QuestieDB.ascensionOverrideKeys
|
||
and QuestieDB.ascensionOverrideKeys[dbType]
|
||
and QuestieDB.ascensionOverrideKeys[dbType][id]
|
||
|
||
return protected and protected[key] == true
|
||
end
|
||
|
||
local function HasAscensionQuestObjectiveData(questId)
|
||
return IsAscensionProtected("QUEST", questId, 10)
|
||
end
|
||
|
||
local function IsBaseDatabaseMissing()
|
||
return QuestieDB
|
||
and QuestieDB.IsBaseDatabaseMissing
|
||
and QuestieDB:IsBaseDatabaseMissing()
|
||
end
|
||
|
||
local function GetDataSourceMode()
|
||
if IsBaseDatabaseMissing() then
|
||
return "learner"
|
||
end
|
||
|
||
local settings = Questie
|
||
and Questie.dbLearner
|
||
and Questie.dbLearner.global
|
||
and Questie.dbLearner.global.settings
|
||
if not settings then
|
||
return "auto"
|
||
end
|
||
|
||
local mode = settings.dataSourceMode
|
||
if mode == "auto" or mode == "learner" or mode == "static" or mode == "none" then
|
||
return mode
|
||
end
|
||
|
||
if settings.prioritizeMyData == false then
|
||
return "static"
|
||
end
|
||
|
||
return "auto"
|
||
end
|
||
|
||
local function WipeTable(tbl)
|
||
if type(tbl) ~= "table" then return end
|
||
for key in pairs(tbl) do
|
||
tbl[key] = nil
|
||
end
|
||
end
|
||
|
||
local function CopyTable(dst, src)
|
||
WipeTable(dst)
|
||
if type(src) ~= "table" then return end
|
||
for key, value in pairs(src) do
|
||
dst[key] = DeepCopy(value)
|
||
end
|
||
end
|
||
|
||
local function NormalizeSpawnZoneKey(zoneKey)
|
||
-- Convert raw area IDs (e.g. 3431 from GetAreaID()) to the canonical map IDs
|
||
-- used by AscensionDB and the rendering system (e.g. 1241 for Sunstrider Isle).
|
||
-- ZoneDB.private.areaIdToUiMapId is the single source of truth for this mapping
|
||
-- (zoneDB.lua: 3431→1241, 3430→1941, 668→1238, etc.). Using the same table
|
||
-- ensures learner-stored zone keys are always valid for DrawWorldIcon / HBD.
|
||
--
|
||
-- IMPORTANT: Never store raw area IDs (3430, 3431) in spawn data — the pin
|
||
-- rendering pipeline only knows about map IDs (1241, 1941). Any future zone
|
||
-- additions must be registered in ZoneDB.private.areaIdToUiMapId first.
|
||
if ZoneDB and ZoneDB.private and ZoneDB.private.areaIdToUiMapId then
|
||
local mapped = ZoneDB.private.areaIdToUiMapId[zoneKey]
|
||
if mapped then return mapped end
|
||
end
|
||
return zoneKey
|
||
end
|
||
|
||
local function IsSunstriderNativeZone(zoneKey)
|
||
return zoneKey == 1241 or zoneKey == 3431
|
||
end
|
||
|
||
-- WoW API locals
|
||
local UnitExists = UnitExists
|
||
local UnitIsVisible = UnitIsVisible
|
||
local UnitIsPlayer = UnitIsPlayer
|
||
local UnitGUID = QuestieCompat and QuestieCompat.UnitGUID or UnitGUID or function() return nil end
|
||
local UnitName = UnitName
|
||
local UnitLevel = UnitLevel
|
||
local UnitFactionGroup = UnitFactionGroup
|
||
local UnitReaction = UnitReaction
|
||
local UnitCreatureFamily = UnitCreatureFamily
|
||
local GetRealZoneText = GetRealZoneText
|
||
local GetTitleText = GetTitleText
|
||
local GetObjectiveText = GetObjectiveText
|
||
local GetQuestDescription = GetQuestDescription
|
||
local GetRewardText = GetRewardText
|
||
local GetQuestID = GetQuestID
|
||
local GetNumQuestLogEntries = GetNumQuestLogEntries
|
||
local GetItemInfo = GetItemInfo
|
||
local CombatLogGetCurrentEventInfo = CombatLogGetCurrentEventInfo
|
||
local CreateFrame = CreateFrame
|
||
local GetTime = GetTime
|
||
|
||
-- Cache for zone lookup: zoneText -> areaId
|
||
_Learner.zoneCache = {}
|
||
|
||
-- NPC flags (WoW bitmask)
|
||
local NPC_FLAG_GOSSIP = 0x00000001
|
||
local NPC_FLAG_QUESTGIVER = 0x00000002
|
||
local NPC_FLAG_TRAINER = 0x00000010
|
||
local NPC_FLAG_VENDOR = 0x00000080
|
||
local NPC_FLAG_FLIGHTMASTER = 0x00000200
|
||
local NPC_FLAG_INNKEEPER = 0x00000800
|
||
local NPC_FLAG_BANKER = 0x00001000
|
||
local NPC_FLAG_AUCTIONEER = 0x00004000
|
||
local NPC_FLAG_STABLEMASTER = 0x00010000
|
||
|
||
-- Only cache/learn mouseover NPCs that carry one of these flags
|
||
local MOUSEOVER_LEARN_FLAGS = NPC_FLAG_QUESTGIVER
|
||
|
||
-- Coordinate grid cell size (in 0–100 map units).
|
||
-- ~2 grid units ≈ 2% of zone width — keeps clusters tight without over-splitting.
|
||
local COORD_GRID = 2.0
|
||
|
||
-- Minimum match count (Confidence) for a learned pin to appear on the map.
|
||
-- Set to 1 so that even a single kill/mouseover confirms a spawn location on
|
||
-- Ascension, where NPC databases are incomplete and every data point matters.
|
||
local MIN_CONFIDENCE_PINS = 1
|
||
|
||
local function GetCoordGridForZone(zoneId)
|
||
-- Sunstrider's starter mobs are packed tightly; a 2% bucket collapses
|
||
-- distinct spawn points such as 58.68/43.19 and 59.11/44.00 into one pin.
|
||
if IsSunstriderNativeZone(zoneId) then
|
||
return 0.5
|
||
end
|
||
return COORD_GRID
|
||
end
|
||
|
||
_Learner.pendingNpcs = {}
|
||
_Learner.pendingQuests = {}
|
||
_Learner.pendingItems = {}
|
||
_Learner.pendingObjects = {}
|
||
_Learner.pendingNetworkMerges = {}
|
||
_Learner.pendingItemLinks = {} -- queue for async GetItemInfo retries
|
||
_Learner.npcNameIndex = nil
|
||
_Learner.npcNameIndexDirty = true
|
||
|
||
-- Direct reference to learnedData, set on Initialize
|
||
QuestieLearner.data = nil
|
||
|
||
------------------------------------------------------------------------
|
||
-- Coordinate helpers
|
||
------------------------------------------------------------------------
|
||
|
||
local function GetZoneId()
|
||
-- Prefer the most specific zone available: GetRealZoneText() returns the
|
||
-- sub-zone name when the player is on a child map (e.g. "Sunstrider Isle"
|
||
-- on map 1241 → areaId 3431), and the parent zone name otherwise (e.g.
|
||
-- "Eversong Woods" → areaId 3430). Using the sub-zone is correct because
|
||
-- Questie resolves subzones to parents via GetParentZoneId() automatically.
|
||
local zoneText = GetRealZoneText and GetRealZoneText() or ""
|
||
if zoneText ~= "" then
|
||
local areaId = _Learner.zoneCache[zoneText]
|
||
if not areaId and l10n and l10n.GetAreaIdByLocalName then
|
||
areaId = l10n:GetAreaIdByLocalName(zoneText)
|
||
if areaId and areaId > 0 then
|
||
_Learner.zoneCache[zoneText] = areaId
|
||
end
|
||
end
|
||
if areaId and areaId > 0 then
|
||
return areaId
|
||
end
|
||
end
|
||
|
||
-- Fallback: uiMapId-based conversion (e.g. 1241→3431 for Sunstrider).
|
||
local uiMapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
|
||
if uiMapId then
|
||
if ZoneDB and ZoneDB.GetAreaIdByUiMapId then
|
||
local areaId = ZoneDB:GetAreaIdByUiMapId(uiMapId)
|
||
if areaId and areaId > 0 then
|
||
return areaId
|
||
end
|
||
end
|
||
return uiMapId -- fallback: no ZoneDB mapping available
|
||
end
|
||
-- Lua 5.0 compat: replace select(8, GetInstanceInfo()) with explicit GetInstanceInfo unpack
|
||
local _, _, _, _, _, _, _, instanceMapID = GetInstanceInfo()
|
||
return instanceMapID or 0
|
||
end
|
||
|
||
local function GetPlayerCoords()
|
||
local x, y = GetPlayerMapPosition("player")
|
||
if x and y and x > 0 and y > 0 then
|
||
-- Store in 0–100 scale, 2-decimal precision
|
||
return floor(x * 10000) / 100, floor(y * 10000) / 100
|
||
end
|
||
return nil, nil
|
||
end
|
||
|
||
local function NormalizeCoordValue(value)
|
||
local coord = tonumber(value)
|
||
if not coord or coord <= 0 then return nil end
|
||
|
||
-- Native map APIs return 0-1, Questie stores 0-100, and a previous
|
||
-- learner path accidentally persisted 0-10000 values like 5868.
|
||
if coord <= 1 then
|
||
coord = coord * 100
|
||
elseif coord > 100 then
|
||
coord = coord / 100
|
||
end
|
||
|
||
if coord <= 0 or coord > 100 then return nil end
|
||
return floor(coord * 100 + 0.5) / 100
|
||
end
|
||
|
||
local function NormalizeCoordPair(x, y)
|
||
local nx = NormalizeCoordValue(x)
|
||
local ny = NormalizeCoordValue(y)
|
||
if not nx or not ny then return nil, nil end
|
||
return nx, ny
|
||
end
|
||
|
||
local function CopyWithoutField(data, skippedKey)
|
||
local copy = {}
|
||
for key, value in pairs(data) do
|
||
if key ~= skippedKey then
|
||
copy[key] = value
|
||
end
|
||
end
|
||
return copy
|
||
end
|
||
|
||
local function DeepCopy(value)
|
||
if type(value) ~= "table" then return value end
|
||
|
||
local copy = {}
|
||
for key, child in pairs(value) do
|
||
copy[key] = DeepCopy(child)
|
||
end
|
||
return copy
|
||
end
|
||
|
||
local function CaptureStaticOverrideSnapshot()
|
||
if _Learner.staticOverrideSnapshot then
|
||
return
|
||
end
|
||
|
||
_Learner.staticOverrideSnapshot = {
|
||
npcs = DeepCopy(QuestieDB.npcDataOverrides or {}),
|
||
quests = DeepCopy(QuestieDB.questDataOverrides or {}),
|
||
items = DeepCopy(QuestieDB.itemDataOverrides or {}),
|
||
objects = DeepCopy(QuestieDB.objectDataOverrides or {}),
|
||
}
|
||
end
|
||
|
||
local function RestoreStaticOverridesForMode()
|
||
local snapshot = _Learner.staticOverrideSnapshot
|
||
local mode = GetDataSourceMode()
|
||
|
||
if mode == "learner" or mode == "none" then
|
||
WipeTable(QuestieDB.npcDataOverrides)
|
||
WipeTable(QuestieDB.questDataOverrides)
|
||
WipeTable(QuestieDB.itemDataOverrides)
|
||
WipeTable(QuestieDB.objectDataOverrides)
|
||
return
|
||
end
|
||
|
||
if not snapshot then return end
|
||
|
||
CopyTable(QuestieDB.npcDataOverrides, snapshot.npcs)
|
||
CopyTable(QuestieDB.questDataOverrides, snapshot.quests)
|
||
CopyTable(QuestieDB.itemDataOverrides, snapshot.items)
|
||
CopyTable(QuestieDB.objectDataOverrides, snapshot.objects)
|
||
end
|
||
|
||
-- Returns the grid-bucket key for a coordinate so nearby points share the same slot
|
||
local function CoordBucket(x, y)
|
||
return floor(x / COORD_GRID) * COORD_GRID, floor(y / COORD_GRID) * COORD_GRID
|
||
end
|
||
|
||
-- Inserts {x, y} into coordList only when no existing point falls in the same grid bucket
|
||
local function InsertIfNewBucket(coordList, x, y, customGrid)
|
||
x, y = NormalizeCoordPair(x, y)
|
||
if not x or not y then return false end
|
||
|
||
local grid = customGrid or COORD_GRID
|
||
local bx, by = floor(x / grid) * grid, floor(y / grid) * grid
|
||
for _, coord in ipairs(coordList) do
|
||
local existingX, existingY = NormalizeCoordPair(coord[1], coord[2])
|
||
if existingX and existingY then
|
||
coord[1], coord[2] = existingX, existingY
|
||
end
|
||
local cx, cy = floor((existingX or coord[1]) / grid) * grid, floor((existingY or coord[2]) / grid) * grid
|
||
if cx == bx and cy == by then return false end
|
||
end
|
||
table.insert(coordList, {x, y})
|
||
return true
|
||
end
|
||
|
||
local function CountUniqueSpawnPositions(spawns)
|
||
if type(spawns) ~= "table" then return 0 end
|
||
|
||
local seen = {}
|
||
local count = 0
|
||
for _, coords in pairs(spawns) do
|
||
if type(coords) == "table" then
|
||
for _, coord in ipairs(coords) do
|
||
local x, y = NormalizeCoordPair(coord[1], coord[2])
|
||
if x and y then
|
||
local key = tostring(x) .. "," .. tostring(y)
|
||
if not seen[key] then
|
||
seen[key] = true
|
||
count = count + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
return count
|
||
end
|
||
|
||
-- Detects if the current map is a "Micro-Dungeon" (small interior map)
|
||
-- This is a heuristic: if we lack map data, we default to standard grid.
|
||
local function GetCustomGridPrecision()
|
||
local uiMapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
|
||
if not uiMapId then return COORD_GRID end
|
||
|
||
-- Known micro-dungeons or small interior maps where 2% precision is too coarse.
|
||
-- (e.g., Northshire Abbey, Anvilmar, Crypts, etc.)
|
||
-- For now, we use a simple list of common starting sub-zones if available.
|
||
-- Or we could check map bounds if we had that data.
|
||
local microDungeons = {
|
||
[425] = 0.5, -- Northshire Abbey
|
||
[468] = 0.5, -- Anvilmar
|
||
[469] = 0.5, -- Coldridge Valley (Interior)
|
||
-- Add more as needed
|
||
}
|
||
return microDungeons[uiMapId] or COORD_GRID
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Internal state guards
|
||
------------------------------------------------------------------------
|
||
|
||
local function EnsureLearnedData()
|
||
if not Questie.db or not Questie.dbLearner then return false end
|
||
|
||
-- Migration: If data exists in the old QuestieConfig.global.learnedData, move it to the new QuestieLearnerDB.global
|
||
if Questie.db.global.learnedData then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Migrating learnedData to separate SavedVariable...")
|
||
for k, v in pairs(Questie.db.global.learnedData) do
|
||
Questie.dbLearner.global[k] = v
|
||
end
|
||
Questie.db.global.learnedData = nil
|
||
Questie:Print("|cFF5EBAF3Questie-X:|r Learned data has been migrated to a separate SavedVariable for better performance.")
|
||
end
|
||
|
||
local ld = Questie.dbLearner.global
|
||
if (not ld.npcs) and (not ld.quests) then
|
||
ld.npcs = {}
|
||
ld.quests = {}
|
||
ld.items = {}
|
||
ld.objects = {}
|
||
ld.settings = {
|
||
enabled = true,
|
||
learnNpcs = true,
|
||
learnQuests = true,
|
||
learnItems = true,
|
||
learnObjects = true,
|
||
minConfidencePins = 1,
|
||
spawnDedupRadius = 4.0,
|
||
prioritizeMyData = true,
|
||
dataSourceMode = "auto",
|
||
staleThreshold = 90, -- days
|
||
pruneVerified = false, -- protect verified data by default
|
||
performanceMode = "balanced",
|
||
pinRefreshDelay = 0.75,
|
||
pinRefreshMode = "batched",
|
||
pinRefreshMaxWait = 5.0,
|
||
liveNpcUpdateDelay = 0.75,
|
||
learnerCommsIntensity = "normal",
|
||
}
|
||
else
|
||
-- Backfill sub-tables that may be missing from older SavedVariables
|
||
ld.npcs = ld.npcs or {}
|
||
ld.quests = ld.quests or {}
|
||
ld.items = ld.items or {}
|
||
ld.objects = ld.objects or {}
|
||
ld.settings = ld.settings or {}
|
||
local s = ld.settings
|
||
if s.enabled == nil then s.enabled = true end
|
||
if s.learnNpcs == nil then s.learnNpcs = true end
|
||
if s.learnQuests == nil then s.learnQuests = true end
|
||
if s.learnItems == nil then s.learnItems = true end
|
||
if s.learnObjects == nil then s.learnObjects = true end
|
||
if s.minConfidencePins == nil then s.minConfidencePins = 1 end
|
||
if s.spawnDedupRadius == nil then s.spawnDedupRadius = 4.0 end
|
||
if s.prioritizeMyData == nil then s.prioritizeMyData = true end
|
||
if s.dataSourceMode == nil then
|
||
if s.prioritizeMyData == false then
|
||
s.dataSourceMode = "static"
|
||
else
|
||
s.dataSourceMode = "auto"
|
||
end
|
||
end
|
||
if s.dataSourceMode == "static" or s.dataSourceMode == "none" then
|
||
s.prioritizeMyData = false
|
||
else
|
||
s.prioritizeMyData = true
|
||
end
|
||
if s.staleThreshold == nil then
|
||
s.staleThreshold = 90
|
||
end
|
||
if s.pruneVerified == nil then
|
||
s.pruneVerified = false
|
||
end
|
||
if s.performanceMode == nil then
|
||
s.performanceMode = "balanced"
|
||
end
|
||
if s.pinRefreshDelay == nil then
|
||
s.pinRefreshDelay = 0.75
|
||
end
|
||
if s.pinRefreshMode == nil then
|
||
s.pinRefreshMode = "batched"
|
||
end
|
||
if s.pinRefreshMaxWait == nil then
|
||
s.pinRefreshMaxWait = 5.0
|
||
end
|
||
if s.liveNpcUpdateDelay == nil then
|
||
s.liveNpcUpdateDelay = 0.75
|
||
end
|
||
if s.learnerCommsIntensity == nil then
|
||
s.learnerCommsIntensity = "normal"
|
||
end
|
||
end
|
||
return true
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Public API
|
||
------------------------------------------------------------------------
|
||
|
||
function QuestieLearner:IsEnabled()
|
||
if not EnsureLearnedData() then return false end
|
||
if IsBaseDatabaseMissing() then
|
||
return true
|
||
end
|
||
return Questie.dbLearner.global.settings.enabled
|
||
end
|
||
|
||
local function NotifyLearnerOptionsChanged()
|
||
if AceConfigRegistry and AceConfigRegistry.NotifyChange then
|
||
AceConfigRegistry:NotifyChange("Questie")
|
||
end
|
||
end
|
||
|
||
function QuestieLearner:GetSettings()
|
||
if not EnsureLearnedData() then return {} end
|
||
return Questie.dbLearner.global.settings
|
||
end
|
||
|
||
function QuestieLearner:GetDataSourceMode()
|
||
if not EnsureLearnedData() then return "auto" end
|
||
return GetDataSourceMode()
|
||
end
|
||
|
||
function QuestieLearner:IsLearnerLiveEnabled()
|
||
local mode = self:GetDataSourceMode()
|
||
return mode == "auto" or mode == "learner"
|
||
end
|
||
|
||
function QuestieLearner:ApplyDataSourceMode()
|
||
if not EnsureLearnedData() then return end
|
||
local settings = Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings
|
||
if settings and GetDataSourceMode() == "learner" and settings.enabled == false then
|
||
settings.enabled = true
|
||
end
|
||
CaptureStaticOverrideSnapshot()
|
||
RestoreStaticOverridesForMode()
|
||
self:InjectLearnedData()
|
||
-- Rebuild every per-entity cache (including the per-zone quest cache) so the
|
||
-- mode switch takes effect immediately for reads, pins, and zone lookups.
|
||
if QuestieDB and QuestieDB.ClearModeCaches then
|
||
QuestieDB:ClearModeCaches()
|
||
elseif QuestieDB and QuestieDB.private then
|
||
QuestieDB.private.questCache = {}
|
||
QuestieDB.private.itemCache = {}
|
||
QuestieDB.private.npcCache = {}
|
||
QuestieDB.private.objectCache = {}
|
||
QuestieDB.private.zoneCache = {}
|
||
end
|
||
QuestieLearner.data = Questie.dbLearner.global
|
||
end
|
||
|
||
local function GetLearnerSetting(key, defaultValue)
|
||
if Questie and Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings then
|
||
local value = Questie.dbLearner.global.settings[key]
|
||
if value ~= nil then
|
||
return value
|
||
end
|
||
end
|
||
return defaultValue
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Cross-link engine
|
||
-- After ANY entity is learned, scan all other learned data and stitch
|
||
-- relationships automatically. Both learnedData (SavedVariables) and
|
||
-- live *DataOverrides tables are kept in sync.
|
||
--
|
||
-- Schema reference:
|
||
-- NPC [7]=spawns [10]=questStarts [11]=questEnds
|
||
-- Object [2]=questStarts [3]=questEnds [4]=spawns
|
||
-- Quest [2]=startedBy{[1]=npcIds,[2]=objIds,[3]=itemIds}
|
||
-- [3]=finishedBy{[1]=npcIds,[2]=objIds}
|
||
-- [10]=objectives{[1]={{npcId,text},...},[2]={{objId,text},...},[3]={{itemId,text},...}}
|
||
-- [11]=sourceItemId [17]=zoneOrSort
|
||
-- Item [2]=dropNpcs{npcId,...} [9]=questSource (questId that gives this item)
|
||
------------------------------------------------------------------------
|
||
|
||
-- Add value to array tbl[key] if not already present. Mirrors to live override table.
|
||
local function _AddToArray(tbl, key, value, ovrTable, ovrId)
|
||
if not tbl then return end
|
||
tbl[key] = tbl[key] or {}
|
||
for _, v in ipairs(tbl[key]) do if v == value then return end end
|
||
table.insert(tbl[key], value)
|
||
if ovrTable and ovrId then
|
||
local ovr = ovrTable[ovrId] or {}
|
||
ovrTable[ovrId] = ovr
|
||
ovr[key] = ovr[key] or {}
|
||
for _, v in ipairs(ovr[key]) do if v == value then return end end
|
||
table.insert(ovr[key], value)
|
||
end
|
||
end
|
||
|
||
-- Add value to nested array tbl[outerKey][innerKey] if not already present.
|
||
local function _AddToNestedArray(tbl, outerKey, innerKey, value, ovrTable, ovrId)
|
||
if not tbl then return end
|
||
tbl[outerKey] = tbl[outerKey] or {}
|
||
tbl[outerKey][innerKey] = tbl[outerKey][innerKey] or {}
|
||
for _, v in ipairs(tbl[outerKey][innerKey]) do if v == value then return end end
|
||
table.insert(tbl[outerKey][innerKey], value)
|
||
if ovrTable and ovrId then
|
||
local ovr = ovrTable[ovrId] or {}
|
||
ovrTable[ovrId] = ovr
|
||
ovr[outerKey] = ovr[outerKey] or {}
|
||
ovr[outerKey][innerKey] = ovr[outerKey][innerKey] or {}
|
||
for _, v in ipairs(ovr[outerKey][innerKey]) do if v == value then return end end
|
||
table.insert(ovr[outerKey][innerKey], value)
|
||
end
|
||
end
|
||
|
||
-- Add {id, text} pair to quest objectives slot (quest[10][slot]).
|
||
local function _AddToQuestObjective(qData, slot, entityId, text, ovrTable, questId)
|
||
if not qData then return end
|
||
qData[10] = qData[10] or {}
|
||
qData[10][slot] = qData[10][slot] or {}
|
||
for _, entry in ipairs(qData[10][slot]) do if entry[1] == entityId then return end end
|
||
table.insert(qData[10][slot], { entityId, text or "" })
|
||
if ovrTable and questId then
|
||
local ovr = ovrTable[questId] or {}
|
||
ovrTable[questId] = ovr
|
||
ovr[10] = ovr[10] or {}
|
||
ovr[10][slot] = ovr[10][slot] or {}
|
||
for _, entry in ipairs(ovr[10][slot]) do if entry[1] == entityId then return end end
|
||
table.insert(ovr[10][slot], { entityId, text or "" })
|
||
end
|
||
end
|
||
|
||
local function _GetDB() return Questie.dbLearner.global end
|
||
|
||
-- Triggers QuestieQuest:UpdateQuest for every active quest in the player's log
|
||
-- that is referenced in the provided set (table with questId keys).
|
||
-- Called after cross-linking so map pins refresh immediately.
|
||
local _pendingQuestPinRefreshes = {}
|
||
local _pendingQuestPinRefreshTimer = nil
|
||
local _pendingQuestPinFirstDirty = nil -- GetTime() of first pending change since last flush
|
||
local _pendingQuestPinLastActivity = nil -- GetTime() of most recent queued change
|
||
local _pendingQuestFrameUnloads = {}
|
||
|
||
-- Trailing-debounce gate. When new learner activity keeps arriving, the flush is
|
||
-- pushed out by pinRefreshDelay (the "quiet window") so a fast kill streak — or a
|
||
-- crowd of nearby players — does not redraw pins every window. pinRefreshMaxWait
|
||
-- caps the worst-case latency: once that many seconds have elapsed since the first
|
||
-- pending change, the flush fires even if kills are still coming (0 = pure debounce,
|
||
-- never force). Only "batched" mode debounces; "immediate" flushes on first fire.
|
||
-- Performs the actual pin rebuild for every pending quest. No debounce gate — the
|
||
-- caller is responsible for deciding when to fire (either the trailing-debounce
|
||
-- wrapper below, or an immediate flush from the already-debounced NPC live-update
|
||
-- flush, which makes the redundant second debounce stage unnecessary).
|
||
local function _DoFlushActiveQuestPins()
|
||
local questIdSet = _pendingQuestPinRefreshes
|
||
_pendingQuestPinRefreshes = {}
|
||
_pendingQuestPinRefreshTimer = nil
|
||
_pendingQuestPinFirstDirty = nil
|
||
_pendingQuestPinLastActivity = nil
|
||
|
||
if not next(questIdSet) then return end
|
||
if GetLearnerSetting("pinRefreshMode", "batched") == "manual" then
|
||
_pendingQuestFrameUnloads = {}
|
||
return
|
||
end
|
||
if not QuestieQuest or not QuestieQuest.UpdateQuest then return end
|
||
if not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end
|
||
|
||
for questId in pairs(questIdSet) do
|
||
if QuestiePlayer.currentQuestlog[questId] then
|
||
if QuestieMap and QuestieMap.UnloadQuestFrames and _pendingQuestFrameUnloads[questId] then
|
||
QuestieMap:UnloadQuestFrames(questId)
|
||
end
|
||
QuestieQuest:UpdateQuest(questId)
|
||
end
|
||
end
|
||
_pendingQuestFrameUnloads = {}
|
||
end
|
||
|
||
local function _FlushActiveQuestPins()
|
||
-- Only defer while there is genuine pending activity. If the timestamps were
|
||
-- already cleared (e.g. the NPC live-update flush force-flushed the pins via
|
||
-- _DoFlushActiveQuestPins), a leftover timer must NOT treat the nil timestamp
|
||
-- as "now" and re-arm forever — it should fall through and flush (a no-op when
|
||
-- the pending set is empty).
|
||
if GetTime and GetLearnerSetting("pinRefreshMode", "batched") == "batched"
|
||
and _pendingQuestPinLastActivity then
|
||
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
|
||
local now = GetTime()
|
||
local delay = GetLearnerSetting("pinRefreshDelay", 0.75)
|
||
local maxWait = GetLearnerSetting("pinRefreshMaxWait", 5.0)
|
||
local quiet = now - _pendingQuestPinLastActivity
|
||
local waited = now - (_pendingQuestPinFirstDirty or _pendingQuestPinLastActivity)
|
||
if timer and timer.After and quiet < delay and (maxWait <= 0 or waited < maxWait) then
|
||
local remaining = delay - quiet
|
||
if maxWait > 0 then
|
||
local capRemaining = maxWait - waited
|
||
if capRemaining < remaining then remaining = capRemaining end
|
||
end
|
||
if remaining < 0 then remaining = 0 end
|
||
-- _pendingQuestPinRefreshTimer stays true so concurrent queues don't double-arm.
|
||
timer.After(remaining, _FlushActiveQuestPins)
|
||
return
|
||
end
|
||
end
|
||
|
||
_DoFlushActiveQuestPins()
|
||
end
|
||
|
||
-- When true, _RefreshActiveQuestPins only accumulates pending quests and does NOT
|
||
-- arm its own trailing-debounce timer. Set by _FlushNpcLiveUpdates, which already
|
||
-- debounced via liveNpcUpdateDelay and force-flushes the pins itself afterwards —
|
||
-- so the second debounce stage would only add redundant latency.
|
||
local _deferPinRefreshScheduling = false
|
||
|
||
local function _RefreshActiveQuestPins(questIdSet)
|
||
-- Skip scheduling when the set is empty (avoids no-op timer callbacks)
|
||
if not next(questIdSet) then return end
|
||
if GetLearnerSetting("pinRefreshMode", "batched") == "manual" then
|
||
for questId in pairs(questIdSet) do
|
||
_pendingQuestFrameUnloads[questId] = nil
|
||
end
|
||
return
|
||
end
|
||
for questId in pairs(questIdSet) do
|
||
_pendingQuestPinRefreshes[questId] = true
|
||
end
|
||
|
||
-- Record activity so the debounce gate in _FlushActiveQuestPins can re-arm.
|
||
local now = (GetTime and GetTime()) or 0
|
||
_pendingQuestPinLastActivity = now
|
||
if not _pendingQuestPinFirstDirty then
|
||
_pendingQuestPinFirstDirty = now
|
||
end
|
||
|
||
-- Caller (NPC live-update flush) will force-flush; don't arm a redundant timer.
|
||
if _deferPinRefreshScheduling then
|
||
return
|
||
end
|
||
|
||
if _pendingQuestPinRefreshTimer then
|
||
return
|
||
end
|
||
|
||
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
|
||
if timer and timer.After then
|
||
_pendingQuestPinRefreshTimer = true
|
||
timer.After(GetLearnerSetting("pinRefreshDelay", 0.75), _FlushActiveQuestPins)
|
||
else
|
||
_FlushActiveQuestPins()
|
||
end
|
||
end
|
||
|
||
-- Invalidates cached objective.spawnList for any active quest whose objectives
|
||
-- reference the given npcId. Unloads existing world/minimap icons, resets
|
||
-- tooltip registration, and forces the map system to rebuild spawn lists from
|
||
-- QuestieDB on the next update — picking up newly learned coordinates in real time.
|
||
-- Helper: check if a single objective references the given npcId and, if so,
|
||
-- unload its icons and reset its cached spawnList so the map rebuilds it.
|
||
-- Returns true if the objective was invalidated.
|
||
local function _TryInvalidateObjective(objective, npcId, quest)
|
||
local shouldInvalidate = false
|
||
-- Monster objectives reference NPCs directly in spawnList keys
|
||
if objective.spawnList then
|
||
if objective.spawnList[npcId] then
|
||
shouldInvalidate = true
|
||
end
|
||
-- Also check killcredit IdList
|
||
if not shouldInvalidate and objective.IdList then
|
||
for _, id in ipairs(objective.IdList) do
|
||
if id == npcId then shouldInvalidate = true; break end
|
||
end
|
||
end
|
||
else
|
||
-- spawnList is nil (first-ever encounter, never populated).
|
||
-- Check the quest's ObjectiveData for NPC references so killcredit
|
||
-- and item objectives are still invalidated on the first kill.
|
||
if quest and quest.ObjectiveData and objective.Index then
|
||
local objData = quest.ObjectiveData[objective.Index]
|
||
if objData and objData.IdList then
|
||
for _, id in ipairs(objData.IdList) do
|
||
if id == npcId then shouldInvalidate = true; break end
|
||
end
|
||
end
|
||
-- Also match the primary objective Id (e.g. single-target monster objectives)
|
||
if not shouldInvalidate and objData and objData.Id == npcId then
|
||
shouldInvalidate = true
|
||
end
|
||
end
|
||
end
|
||
-- Fallback: if objective Id matches the NPC (some objectives use NPC as their primary Id)
|
||
if not shouldInvalidate and objective.Id == npcId then
|
||
shouldInvalidate = true
|
||
end
|
||
if shouldInvalidate then
|
||
-- Unload existing icons manually so frames are removed from map/minimap.
|
||
-- We can't call QuestieQuest's local _UnloadAlreadySpawnedIcons from here,
|
||
-- so we iterate the refs directly.
|
||
if objective.AlreadySpawned then
|
||
for _, spawn in pairs(objective.AlreadySpawned) do
|
||
if spawn then
|
||
if spawn.mapRefs then
|
||
for _, mapIcon in ipairs(spawn.mapRefs) do
|
||
if mapIcon and mapIcon.Unload then mapIcon:Unload() end
|
||
end
|
||
end
|
||
if spawn.minimapRefs then
|
||
for _, minimapIcon in ipairs(spawn.minimapRefs) do
|
||
if minimapIcon and minimapIcon.Unload then minimapIcon:Unload() end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
objective.spawnList = nil
|
||
objective.AlreadySpawned = {} -- empty table, NOT nil (_DetermineIconsToDraw indexes this)
|
||
objective.hasRegisteredTooltips = false
|
||
objective.registeredItemTooltips = false
|
||
end
|
||
return shouldInvalidate
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Debounce table: tracks pending (npcId, questId) invalidate pairs.
|
||
-- Prevents scheduling multiple unload+refresh cycles for the same
|
||
-- (npcId, questId) within the debounce window.
|
||
local _invalidateDebounce = {}
|
||
|
||
local function _InvalidateSpawnListsForNPC(npcId)
|
||
if not QuestieQuest or not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end
|
||
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
|
||
local now = (GetTime and GetTime()) or 0
|
||
local debounceWindow = 0.5 -- seconds; coalesces rapid-fire calls
|
||
|
||
local questsToRefresh = {}
|
||
for questId, _ in pairs(QuestiePlayer.currentQuestlog) do
|
||
local quest = QuestieDB.GetQuest and QuestieDB.GetQuest(questId)
|
||
if quest then
|
||
local needsUnload = false
|
||
-- Scan standard Objectives
|
||
if quest.Objectives then
|
||
for _, objective in pairs(quest.Objectives) do
|
||
if _TryInvalidateObjective(objective, npcId, quest) then
|
||
needsUnload = true
|
||
end
|
||
end
|
||
end
|
||
-- Scan SpecialObjectives (demonic runestones, custom Ascension objectives, etc.)
|
||
if quest.SpecialObjectives then
|
||
for _, objective in pairs(quest.SpecialObjectives) do
|
||
if _TryInvalidateObjective(objective, npcId, quest) then
|
||
needsUnload = true
|
||
end
|
||
end
|
||
end
|
||
if needsUnload then
|
||
-- Debounce frame unload only. The spawnList is always cleared by
|
||
-- _TryInvalidateObjective (runs before this), so a debounced second call
|
||
-- clears an already-nil spawnList safely. The refresh, however, must always
|
||
-- fire so the map is eventually rebuilt with the latest npcDataOverrides
|
||
-- data (already written by LearnNPC before this call).
|
||
local debounceKey = npcId .. ":" .. questId
|
||
local suppressUnload = _invalidateDebounce[debounceKey]
|
||
and (now - _invalidateDebounce[debounceKey]) < debounceWindow
|
||
if not suppressUnload then
|
||
_invalidateDebounce[debounceKey] = now
|
||
if GetLearnerSetting("pinRefreshMode", "batched") == "immediate" and QuestieMap and QuestieMap.UnloadQuestFrames then
|
||
QuestieMap:UnloadQuestFrames(questId)
|
||
else
|
||
_pendingQuestFrameUnloads[questId] = true
|
||
end
|
||
end
|
||
questsToRefresh[questId] = true -- always refresh when data changed
|
||
end
|
||
end
|
||
end
|
||
-- _RefreshActiveQuestPins guards against empty set internally
|
||
_RefreshActiveQuestPins(questsToRefresh)
|
||
|
||
-- Prune stale debounce keys (>2x window age) to prevent unbounded growth.
|
||
-- Keys expire naturally after their window; this is just cleanup.
|
||
if timer then
|
||
for key, ts in pairs(_invalidateDebounce) do
|
||
if (now - ts) > (debounceWindow * 2) then
|
||
_invalidateDebounce[key] = nil
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Live learner update batching
|
||
--
|
||
-- Kill/loot bursts can call LearnNPC multiple times for the same NPC:
|
||
-- combat-log kill, quest-log objective progress, and loot correlation may all
|
||
-- arrive within a few frames. Saved learner evidence is updated immediately,
|
||
-- but live QuestieDB override/cache invalidation is batched so the large DB
|
||
-- layer is not churned on every single kill.
|
||
|
||
local function _ApplyNpcLiveUpdate(npcId)
|
||
local existing = Questie.dbLearner
|
||
and Questie.dbLearner.global
|
||
and Questie.dbLearner.global.npcs
|
||
and Questie.dbLearner.global.npcs[npcId]
|
||
if not existing then return false end
|
||
|
||
local threshold = (Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.minConfidencePins) or MIN_CONFIDENCE_PINS
|
||
if existing.mc < threshold then return false end
|
||
if not (QuestieDB and QuestieDB.npcDataOverrides and existing[7] and next(existing[7])) then return false end
|
||
|
||
local ovr = QuestieDB.npcDataOverrides[npcId]
|
||
if not ovr then
|
||
if IsAscensionProtected("NPC", npcId, 7) then
|
||
QuestieDB.npcDataOverrides[npcId] = DeepCopy(CopyWithoutField(existing, 7))
|
||
else
|
||
QuestieDB.npcDataOverrides[npcId] = DeepCopy(existing)
|
||
end
|
||
else
|
||
-- Merge: fill missing fields; also overwrite empty-string names.
|
||
for k, v in pairs(existing) do
|
||
if k ~= 7 and not IsAscensionProtected("NPC", npcId, k) and (ovr[k] == nil or (k == 1 and ovr[k] == "")) then
|
||
ovr[k] = DeepCopy(v)
|
||
end
|
||
end
|
||
-- Always merge spawn coords.
|
||
if existing[7] and not IsAscensionProtected("NPC", npcId, 7) then
|
||
ovr[7] = ovr[7] or {}
|
||
for zid, coords in pairs(existing[7]) do
|
||
ovr[7][zid] = ovr[7][zid] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(ovr[7][zid], coord[1], coord[2], GetCoordGridForZone(zid))
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Clear the compiled DB cache once per flush so GetNPC rebuilds with the
|
||
-- latest coalesced override data instead of once per kill.
|
||
if QuestieDB.private and QuestieDB.private.npcCache then
|
||
QuestieDB.private.npcCache[npcId] = nil
|
||
end
|
||
|
||
return true
|
||
end
|
||
|
||
-- Trailing-debounce gate, mirroring _FlushActiveQuestPins. liveNpcUpdateDelay is
|
||
-- the quiet window; pinRefreshMaxWait is the shared worst-case cap (0 = never force).
|
||
local function _FlushNpcLiveUpdates()
|
||
local timer = QuestieCompat and QuestieCompat.C_Timer
|
||
local now = (GetTime and GetTime()) or 0
|
||
local delay = GetLearnerSetting("liveNpcUpdateDelay", 0.75)
|
||
local maxWait = GetLearnerSetting("pinRefreshMaxWait", 5.0)
|
||
local quiet = now - (_Learner.pendingNpcLiveUpdateLastActivity or now)
|
||
local waited = now - (_Learner.pendingNpcLiveUpdateFirstDirty or now)
|
||
if GetTime and timer and timer.After and quiet < delay and (maxWait <= 0 or waited < maxWait) then
|
||
local remaining = delay - quiet
|
||
if maxWait > 0 then
|
||
local capRemaining = maxWait - waited
|
||
if capRemaining < remaining then remaining = capRemaining end
|
||
end
|
||
if remaining < 0 then remaining = 0 end
|
||
-- pendingNpcLiveUpdateTimer stays true so concurrent queues don't double-arm.
|
||
timer.After(remaining, _FlushNpcLiveUpdates)
|
||
return
|
||
end
|
||
|
||
local pending = _Learner.pendingNpcLiveUpdates
|
||
_Learner.pendingNpcLiveUpdates = {}
|
||
_Learner.pendingNpcLiveUpdateTimer = nil
|
||
_Learner.pendingNpcLiveUpdateFirstDirty = nil
|
||
_Learner.pendingNpcLiveUpdateLastActivity = nil
|
||
|
||
-- This flush already coalesced kills over liveNpcUpdateDelay. Suppress the
|
||
-- per-NPC pin-refresh debounce while invalidating, then flush all affected
|
||
-- quests once, immediately — instead of waiting out a second pinRefreshDelay.
|
||
_deferPinRefreshScheduling = true
|
||
for npcId in pairs(pending) do
|
||
if _ApplyNpcLiveUpdate(npcId) then
|
||
_InvalidateSpawnListsForNPC(npcId)
|
||
end
|
||
end
|
||
_deferPinRefreshScheduling = false
|
||
_DoFlushActiveQuestPins()
|
||
end
|
||
|
||
local function _QueueNpcLiveUpdate(npcId)
|
||
if not (QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled()) then
|
||
return
|
||
end
|
||
_Learner.pendingNpcLiveUpdates = _Learner.pendingNpcLiveUpdates or {}
|
||
_Learner.pendingNpcLiveUpdates[npcId] = true
|
||
|
||
-- Record activity so the debounce gate in _FlushNpcLiveUpdates can re-arm.
|
||
local now = (GetTime and GetTime()) or 0
|
||
_Learner.pendingNpcLiveUpdateLastActivity = now
|
||
if not _Learner.pendingNpcLiveUpdateFirstDirty then
|
||
_Learner.pendingNpcLiveUpdateFirstDirty = now
|
||
end
|
||
|
||
if _Learner.pendingNpcLiveUpdateTimer then return end
|
||
|
||
local timer = QuestieCompat and QuestieCompat.C_Timer
|
||
if timer and timer.After then
|
||
_Learner.pendingNpcLiveUpdateTimer = true
|
||
timer.After(GetLearnerSetting("liveNpcUpdateDelay", 0.75), _FlushNpcLiveUpdates)
|
||
else
|
||
_FlushNpcLiveUpdates()
|
||
end
|
||
end
|
||
|
||
local function _MarkNpcNameIndexDirty()
|
||
_Learner.npcNameIndexDirty = true
|
||
end
|
||
|
||
local function _RebuildNpcNameIndex()
|
||
local overrideIndex = {}
|
||
local baseIndex = {}
|
||
|
||
if QuestieDB and QuestieDB.npcDataOverrides then
|
||
for npcId, data in pairs(QuestieDB.npcDataOverrides) do
|
||
local name = data and data[1]
|
||
if type(name) == "string" and name ~= "" then
|
||
overrideIndex[string.lower(name)] = npcId
|
||
end
|
||
end
|
||
end
|
||
|
||
if QuestieDB and QuestieDB.npcData then
|
||
for npcId, data in pairs(QuestieDB.npcData) do
|
||
local name = data and data[1]
|
||
if type(name) == "string" and name ~= "" then
|
||
local lowerName = string.lower(name)
|
||
if overrideIndex[lowerName] == nil and baseIndex[lowerName] == nil then
|
||
baseIndex[lowerName] = npcId
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
_Learner.npcNameIndex = {
|
||
override = overrideIndex,
|
||
base = baseIndex,
|
||
}
|
||
_Learner.npcNameIndexDirty = false
|
||
end
|
||
|
||
local function _EnsureNpcNameIndex()
|
||
if _Learner.npcNameIndex and not _Learner.npcNameIndexDirty then
|
||
return _Learner.npcNameIndex
|
||
end
|
||
|
||
_RebuildNpcNameIndex()
|
||
return _Learner.npcNameIndex
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- CrossLinkAfterNPC: called when a new NPC is first learned.
|
||
-- Scans all learned quests for any reference to this npcId and stitches
|
||
-- back-links in both directions.
|
||
local function CrossLinkAfterNPC(npcId)
|
||
local learned = _GetDB()
|
||
local npcData = learned.npcs[npcId]
|
||
if not npcData then return end
|
||
local liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled()
|
||
local npcOvr = liveEnabled and QuestieDB and QuestieDB.npcDataOverrides or nil
|
||
|
||
for questId, qData in pairs(learned.quests) do
|
||
local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil
|
||
|
||
-- 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 liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled()
|
||
local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil
|
||
local npcOvr = liveEnabled and QuestieDB and QuestieDB.npcDataOverrides or nil
|
||
local objOvr = liveEnabled and QuestieDB and QuestieDB.objectDataOverrides or nil
|
||
|
||
-- 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 liveEnabled and 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 liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled()
|
||
local objOvr = liveEnabled and QuestieDB and QuestieDB.objectDataOverrides or nil
|
||
local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil
|
||
|
||
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 liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled()
|
||
local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil
|
||
|
||
-- 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 liveEnabled and 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 liveEnabled = QuestieLearner and QuestieLearner.IsLearnerLiveEnabled and QuestieLearner:IsLearnerLiveEnabled()
|
||
local npcOvr = liveEnabled and QuestieDB and QuestieDB.npcDataOverrides or nil
|
||
local objOvr = liveEnabled and QuestieDB and QuestieDB.objectDataOverrides or nil
|
||
local qOvr = liveEnabled and QuestieDB and QuestieDB.questDataOverrides or nil
|
||
|
||
if typeSlot == 1 then
|
||
-- NPC ↔ quest
|
||
local npcData = learned.npcs[entityId]
|
||
if npcData then
|
||
_AddToArray(npcData, isStart and 10 or 11, questId, npcOvr, entityId)
|
||
end
|
||
if qData then
|
||
_AddToNestedArray(qData, isStart and 2 or 3, 1, entityId, qOvr, questId)
|
||
end
|
||
elseif typeSlot == 2 then
|
||
-- Object ↔ quest
|
||
local objData = learned.objects[entityId]
|
||
if objData then
|
||
_AddToArray(objData, isStart and 2 or 3, questId, objOvr, entityId)
|
||
end
|
||
if qData then
|
||
_AddToNestedArray(qData, isStart and 2 or 3, 2, entityId, qOvr, questId)
|
||
end
|
||
elseif typeSlot == 3 then
|
||
-- Item ↔ quest starter (item[3] = starts quest; quest[2][3])
|
||
if qData then
|
||
_AddToNestedArray(qData, 2, 3, entityId, qOvr, questId)
|
||
end
|
||
end
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- NPC learning
|
||
------------------------------------------------------------------------
|
||
|
||
-- Player-spawned NPCs that should never be learned (totems, guardians, etc.)
|
||
local PLAYER_SPAWNED_NPC_SET = {
|
||
[2523] = true, -- Searing Totem
|
||
[2630] = true, -- Earthbind Totem
|
||
[10183] = true, -- Moonflare Totem
|
||
[1103907] = true, -- Healing Stream Totem III
|
||
[1107398] = true, -- Stoneclaw Totem V
|
||
}
|
||
|
||
function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString, spawnX, spawnY, spawnZoneId)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnNpcs then return end
|
||
if not npcId or npcId <= 0 then return end
|
||
-- Never learn player-spawned totems
|
||
if PLAYER_SPAWNED_NPC_SET[npcId] then return end
|
||
|
||
-- Use provided spawn coords (e.g. from kill event) or fall back to current player position.
|
||
-- Normalize area IDs → map IDs immediately so all storage uses the same key space
|
||
-- as AscensionDB (e.g. 3431 → 1241, 3430 → 1941).
|
||
local zoneId = NormalizeSpawnZoneKey(spawnZoneId or GetZoneId())
|
||
local x, y
|
||
if spawnX and spawnY then
|
||
x, y = spawnX, spawnY
|
||
else
|
||
x, y = GetPlayerCoords()
|
||
end
|
||
|
||
local existing = Questie.dbLearner.global.npcs[npcId]
|
||
local isNew = existing == nil
|
||
if not existing then
|
||
existing = {}
|
||
Questie.dbLearner.global.npcs[npcId] = existing
|
||
end
|
||
|
||
if name and name ~= "" and (not existing[1] or existing[1] == "") then
|
||
existing[1] = name
|
||
_MarkNpcNameIndexDirty()
|
||
end
|
||
if level then
|
||
if not existing[4] or level < existing[4] then existing[4] = level end
|
||
if not existing[5] or level > existing[5] then existing[5] = level end
|
||
end
|
||
if zoneId and zoneId > 0 and not existing[9] then existing[9] = zoneId end
|
||
if factionString and not existing[13] then existing[13] = factionString end
|
||
if subName and not existing[14] then existing[14] = subName end
|
||
if spawnX and spawnY and x and y and zoneId and zoneId > 0 then
|
||
existing[7] = existing[7] or {}
|
||
existing[7][zoneId] = existing[7][zoneId] or {}
|
||
InsertIfNewBucket(existing[7][zoneId], x, y, GetCoordGridForZone(zoneId))
|
||
end
|
||
|
||
existing.ls = time() -- Update last seen
|
||
existing.mc = (existing.mc or 0) + 1
|
||
|
||
-- Live injection is intentionally batched: repeated kill/log/loot events for
|
||
-- the same NPC update saved evidence immediately, then flush QuestieDB once.
|
||
if self:IsLearnerLiveEnabled() then
|
||
_QueueNpcLiveUpdate(npcId)
|
||
end
|
||
|
||
if isNew then
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New NPC learned:", npcId, name or "?")
|
||
CrossLinkAfterNPC(npcId)
|
||
end
|
||
_Learner:BroadcastIfCommsAvailable("NPC", npcId, existing)
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Phase 2: Per-GUID spawn evidence
|
||
------------------------------------------------------------------------
|
||
|
||
-- Extracts the spawn UID (low 24 bits / last 6 hex chars) from a WoW GUID.
|
||
-- This is the per-spawn-instance identifier — different GUIDs for the same
|
||
-- npcId indicate different spawn points (e.g. three boars at three corners
|
||
-- of a field, not one boar teleporting around).
|
||
-- IMPORTANT: Do not revert this to a single GUID format. Ascension clients
|
||
-- may emit either dashed GUIDs or compact hex GUIDs ("0x..."), and both must
|
||
-- remain supported or GUID-based learner evidence will silently stop storing.
|
||
-- Format: "Creature-0-RR-RI-0-NNNNNNNN" where NNNNNNNN = spawn UID
|
||
local function _ExtractSpawnUID(guid)
|
||
if not guid or type(guid) ~= "string" then return nil end
|
||
-- Client-arg combat log GUIDs on Ascension commonly arrive as compact hex
|
||
-- strings (e.g. "0xF130003BAA009E40"). Use the low 24 bits so different
|
||
-- spawn instances of the same npcId still resolve to distinct evidence keys.
|
||
local hexTail = guid:match("^0x%x+(%x%x%x%x%x%x)$")
|
||
if hexTail then
|
||
return tonumber(hexTail, 16)
|
||
end
|
||
-- Dash format: Creature-0-1234-567-89-21878-0000001234
|
||
-- Last numeric segment after the 5th dash is the spawn UID
|
||
local spawnUID = guid:match("^[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-(%d+)$")
|
||
if spawnUID then
|
||
return tonumber(spawnUID)
|
||
end
|
||
-- Fallback for other dash formats: take everything after the last dash
|
||
spawnUID = guid:match("^.+%-(%d+)$")
|
||
if spawnUID then
|
||
return tonumber(spawnUID)
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- Ensures entry[8] (guidSpawns) exists on the learned NPC data table.
|
||
-- entry[8] schema: { [spawnUID] = { zoneId, x, y, ts, source, confidence } }
|
||
local function _GetOrCreateGuidSpawnTable(entry)
|
||
if not entry then return nil end
|
||
if not entry[8] then entry[8] = {} end
|
||
return entry[8]
|
||
end
|
||
|
||
-- Stores a per-GUID kill event under the learned NPC data.
|
||
-- Each unique spawn UID (per npcId per zone) gets one evidence entry.
|
||
-- On repeated kills at the same spawn UID, the entry is updated (moved to
|
||
-- the current position if the NPC has wandered slightly, timestamp refreshed).
|
||
-- Storage is bounded: max MAX_GUID_SPAWNS_PER_NPC_PER_ZONE entries per zone;
|
||
-- oldest entry evicted when at capacity.
|
||
local MAX_GUID_SPAWNS_PER_NPC_PER_ZONE = 8
|
||
|
||
function QuestieLearner:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, x, y)
|
||
if not self:IsEnabled() then return end
|
||
if not npcId or npcId <= 0 then return end
|
||
if not dstGUID or type(dstGUID) ~= "string" then return end
|
||
if not zoneId or zoneId <= 0 then return end
|
||
if not x or not y or x <= 0 or y <= 0 then return end
|
||
|
||
-- Normalize area IDs → map IDs so evidence is keyed identically to AscensionDB.
|
||
-- Without this, kills on Sunstrider store under zone 3431 (area ID) while the
|
||
-- renderer expects zone 1241 (map ID), causing pins to silently not appear.
|
||
zoneId = NormalizeSpawnZoneKey(zoneId)
|
||
|
||
local spawnUID = _ExtractSpawnUID(dstGUID)
|
||
if not spawnUID then return end
|
||
|
||
local learnedNpc = Questie.dbLearner.global.npcs[npcId]
|
||
if not learnedNpc then return end
|
||
|
||
local guidSpawns = _GetOrCreateGuidSpawnTable(learnedNpc)
|
||
if not guidSpawns then return end
|
||
|
||
-- Normalize coords to Questie's 0-100 map scale. NormalizeCoordPair handles all
|
||
-- three input formats: native 0-1, already-scaled 0-100, or buggy 0-10000.
|
||
local nx, ny = NormalizeCoordPair(x, y)
|
||
if not nx or not ny then return end
|
||
|
||
-- DEBUG: log raw x/y and normalized nx/ny being stored
|
||
-- Questie:Debug(Questie.DEBUG_LEARNER,
|
||
-- "_StoreGuidSpawnEvidence: npcId=", npcId,
|
||
-- "spawnUID=", spawnUID,
|
||
-- "x=", x, "y=", y,
|
||
-- "nx=", nx, "ny=", ny,
|
||
-- "zoneId=", zoneId)
|
||
|
||
if guidSpawns[spawnUID] then
|
||
-- Existing spawn UID: update position and timestamp
|
||
guidSpawns[spawnUID].x = nx
|
||
guidSpawns[spawnUID].y = ny
|
||
guidSpawns[spawnUID].ts = time()
|
||
else
|
||
-- New spawn UID: insert, bounded by npcId+zoneId.
|
||
-- Evict oldest entry if at capacity.
|
||
local zoneCount = 0
|
||
local oldestUID = nil
|
||
local oldestTS = nil
|
||
for uid in pairs(guidSpawns) do
|
||
local e = guidSpawns[uid]
|
||
if e and e.zoneId == zoneId then
|
||
zoneCount = zoneCount + 1
|
||
if not oldestTS or e.ts < oldestTS then
|
||
oldestTS = e.ts
|
||
oldestUID = uid
|
||
end
|
||
end
|
||
end
|
||
if zoneCount >= MAX_GUID_SPAWNS_PER_NPC_PER_ZONE and oldestUID then
|
||
guidSpawns[oldestUID] = nil
|
||
end
|
||
guidSpawns[spawnUID] = {
|
||
zoneId = zoneId,
|
||
x = nx,
|
||
y = ny,
|
||
ts = time(),
|
||
source = "local",
|
||
confidence = 1,
|
||
}
|
||
end
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Phase 3: Weighted spawn merge
|
||
------------------------------------------------------------------------
|
||
|
||
-- Merges learned GUID-based spawn evidence into the static NPC spawn list.
|
||
-- Scoring: spawn UID with most evidence across all zones wins.
|
||
-- Override condition: top spawn appears in >60% of total evidence AND
|
||
-- differs from static DB entry. Below 60% confidence, learned does not
|
||
-- override static and both sources coexist.
|
||
-- Called from the kill handler after _StoreGuidSpawnEvidence when
|
||
-- evidence count >= 3 for that npcId.
|
||
--@param npcId number The NPC ID to merge evidence for
|
||
--@return boolean True if static DB spawn list was overridden
|
||
local function _MergeSpawnEvidence(npcId)
|
||
if not npcId or npcId <= 0 then return false end
|
||
|
||
local learnedNpc = Questie.dbLearner and Questie.dbLearner.global
|
||
and Questie.dbLearner.global.npcs[npcId]
|
||
if not learnedNpc then return false end
|
||
|
||
local guidSpawns = learnedNpc[8]
|
||
if not guidSpawns or not next(guidSpawns) then return false end
|
||
|
||
-- Collect all evidence: group by (zoneId, x, y) rounded to 2 decimal places
|
||
-- Key format: "zoneId|x|y" → count
|
||
local evidence = {} -- { [key] = { zoneId, x, y, count } }
|
||
local totalEvidence = 0
|
||
|
||
for spawnUID, entry in pairs(guidSpawns) do
|
||
if entry and entry.zoneId and entry.x and entry.y then
|
||
local evidenceX, evidenceY = NormalizeCoordPair(entry.x, entry.y)
|
||
if not evidenceX or not evidenceY then
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[QuestieLearner] _MergeSpawnEvidence skipping invalid coords: spawnUID=", spawnUID,
|
||
"entry.x=", tostring(entry.x), "entry.y=", tostring(entry.y))
|
||
else
|
||
entry.x = evidenceX
|
||
entry.y = evidenceY
|
||
|
||
local rx, ry = NormalizeCoordPair(evidenceX, evidenceY)
|
||
-- Group kills by a coordinate bucket, not by exact coords. Kill
|
||
-- evidence is the player's position at kill time, which drifts a
|
||
-- little every kill, so exact keys would treat each kill as its own
|
||
-- "location" — producing one pin per kill and never letting any
|
||
-- single spot accumulate enough evidence to clear the confidence
|
||
-- threshold. Bucketing collapses repeated kills at the same spawn
|
||
-- into one location (matching the [7] InsertIfNewBucket behavior).
|
||
local grid = GetCoordGridForZone(entry.zoneId)
|
||
local bx = floor(rx / grid)
|
||
local by = floor(ry / grid)
|
||
local key = entry.zoneId .. "|" .. bx .. "|" .. by
|
||
if not evidence[key] then
|
||
evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 }
|
||
end
|
||
evidence[key].count = evidence[key].count + 1
|
||
totalEvidence = totalEvidence + 1
|
||
end
|
||
end
|
||
end
|
||
|
||
if totalEvidence < 3 then return false end
|
||
|
||
-- Find top-scoring spawn
|
||
local topKey = nil
|
||
local topCount = 0
|
||
for key, e in pairs(evidence) do
|
||
if e.count > topCount then
|
||
topCount = e.count
|
||
topKey = key
|
||
end
|
||
end
|
||
|
||
if not topKey then return false end
|
||
|
||
local topEvidence = evidence[topKey]
|
||
local topPct = (topCount / totalEvidence) * 100
|
||
|
||
-- Info-only trace: raw top evidence is useful for diagnosing coordinate corruption
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[QuestieLearner] _MergeSpawnEvidence: topKey=", topKey,
|
||
"topEvidence.x=", topEvidence.x, "topEvidence.y=", topEvidence.y,
|
||
"topCount=", topCount, "totalEvidence=", totalEvidence)
|
||
|
||
-- Sunstrider Isle: zone IDs are now normalized via NormalizeSpawnZoneKey so
|
||
-- topEvidence.zoneId will be 1241 (map ID), not 3431 (area ID).
|
||
-- IsSunstriderNativeZone checks both to be safe against old saved data.
|
||
-- Confidence threshold is bypassed for Sunstrider because spawn points are
|
||
-- distributed across 5+ locations — no single point ever reaches 60% of kills.
|
||
local isSunstrider = IsSunstriderNativeZone(topEvidence.zoneId)
|
||
local learnerLiveMode = QuestieLearner
|
||
and QuestieLearner.IsLearnerLiveEnabled
|
||
and QuestieLearner:IsLearnerLiveEnabled()
|
||
local confidenceThreshold = isSunstrider and 0 or 60
|
||
|
||
-- Only override if > confidence threshold AND spawn differs from static DB
|
||
if topPct <= confidenceThreshold then
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
|
||
"top spawn", topCount .. "/" .. totalEvidence,
|
||
"= " .. floor(topPct + 0.5) .. "%" ..
|
||
(isSunstrider and " — Sunstrider, threshold bypassed" or (" — below " .. confidenceThreshold .. "%, no override")))
|
||
-- For Sunstrider, fall through and apply the learned spawn anyway
|
||
if not isSunstrider then
|
||
return false
|
||
end
|
||
end
|
||
|
||
if isSunstrider then
|
||
-- AscensionDB owns spawn data for known Sunstrider NPCs — never overwrite it.
|
||
-- Key 7 = spawns. Without this guard the learner would pollute the curated
|
||
-- AscensionDB coords with in-game kill evidence, causing wrong pin counts.
|
||
-- REGRESSION NOTE: If AscensionDB protection check is removed or disabled,
|
||
-- learner pins will reappear at wrong locations. Do not remove this guard.
|
||
if (not learnerLiveMode) and IsAscensionProtected("NPC", npcId, 7) then
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
|
||
"Sunstrider zone but AscensionDB owns spawns — skipping learner injection")
|
||
return false
|
||
end
|
||
|
||
-- topEvidence.zoneId is now a map ID (e.g. 1241) because NormalizeSpawnZoneKey
|
||
-- converted the area ID at storage time. This matches AscensionDB's key space
|
||
-- so DrawWorldIcon and HBD can resolve the coordinates correctly.
|
||
QuestieDB.npcDataOverrides[npcId] = QuestieDB.npcDataOverrides[npcId] or {}
|
||
QuestieDB.npcDataOverrides[npcId][7] = QuestieDB.npcDataOverrides[npcId][7] or {}
|
||
QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] or {}
|
||
|
||
local promoted = 0
|
||
local duplicates = 0
|
||
local zoneSpawns = QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId]
|
||
local seen = {}
|
||
for _, spawnEvidence in pairs(evidence) do
|
||
local sx, sy = NormalizeCoordPair(spawnEvidence.x, spawnEvidence.y)
|
||
if sx and sy and not seen[sx..","..sy] then
|
||
seen[sx..","..sy] = true
|
||
local grid = GetCoordGridForZone(topEvidence.zoneId)
|
||
if InsertIfNewBucket(zoneSpawns, sx, sy, grid) then
|
||
promoted = promoted + 1
|
||
else
|
||
duplicates = duplicates + 1
|
||
end
|
||
else
|
||
duplicates = duplicates + 1
|
||
end
|
||
end
|
||
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
|
||
"promoted Sunstrider evidence groups", promoted,
|
||
"duplicates", duplicates,
|
||
"zone", tostring(topEvidence.zoneId))
|
||
|
||
_QueueNpcLiveUpdate(npcId)
|
||
|
||
return promoted > 0 or duplicates > 0
|
||
end
|
||
|
||
-- Check against static DB + learner-promoted override entries.
|
||
-- After a previous promotion, QueryNPCSingle returns the override data,
|
||
-- so learner-promoted spawns would incorrectly "match static" and block
|
||
-- re-promotion. We must exclude overrides we wrote ourselves.
|
||
local staticNPC = nil
|
||
if QuestieDB and QuestieDB.QueryNPCSingle then
|
||
staticNPC = QuestieDB.QueryNPCSingle(npcId, "spawns")
|
||
end
|
||
|
||
local staticSpawnList = staticNPC
|
||
local staticSpawnsForZone = staticSpawnList and staticSpawnList[topEvidence.zoneId]
|
||
|
||
-- Collect spawns already promoted by the learner for this zone
|
||
local learnerOverrides = QuestieDB.npcDataOverrides
|
||
and QuestieDB.npcDataOverrides[npcId]
|
||
and QuestieDB.npcDataOverrides[npcId][7]
|
||
and QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId]
|
||
local learnerOverrideSet = {}
|
||
if learnerOverrides then
|
||
for _, entry in ipairs(learnerOverrides) do
|
||
if entry and entry[1] and entry[2] then
|
||
local lx = floor(entry[1] * 100 + 0.5) / 100
|
||
local ly = floor(entry[2] * 100 + 0.5) / 100
|
||
learnerOverrideSet[lx .. "|" .. ly] = true
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Check if top spawn matches any static spawn in the same zone,
|
||
-- excluding learner-promoted overrides (those should always be updatable)
|
||
local matchesStatic = false
|
||
if staticSpawnsForZone then
|
||
for _, coord in ipairs(staticSpawnsForZone) do
|
||
local sx = floor(coord[1] * 100 + 0.5) / 100
|
||
local sy = floor(coord[2] * 100 + 0.5) / 100
|
||
-- Skip learner-promoted entries — they are not "static"
|
||
if not learnerOverrideSet[sx .. "|" .. sy] then
|
||
if abs(sx - topEvidence.x) < 0.01 and abs(sy - topEvidence.y) < 0.01 then
|
||
matchesStatic = true
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
if matchesStatic then
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
|
||
"top spawn matches static DB — no override needed")
|
||
return false
|
||
end
|
||
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[QuestieLearner] _MergeSpawnEvidence promoting npcId", npcId,
|
||
"zone", tostring(topEvidence.zoneId),
|
||
"x", tostring(topEvidence.x),
|
||
"y", tostring(topEvidence.y),
|
||
"protected", tostring(IsAscensionProtected("NPC", npcId, 7)))
|
||
|
||
-- Insert as new spawn (InsertIfNewBucket deduplicates).
|
||
-- Only create zone table entry if insert succeeds — an empty zone override
|
||
-- {[3431] = {}} makes _MergeOverride's IsEmptyTable check fall through to
|
||
-- rawdata, bypassing learner data entirely (field is non-nil but empty).
|
||
local spawned = false
|
||
if topEvidence.x and topEvidence.y then
|
||
if not QuestieDB.npcDataOverrides[npcId] then
|
||
QuestieDB.npcDataOverrides[npcId] = {}
|
||
end
|
||
if not QuestieDB.npcDataOverrides[npcId][7] then
|
||
QuestieDB.npcDataOverrides[npcId][7] = {}
|
||
end
|
||
if not QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] then
|
||
QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = {}
|
||
end
|
||
spawned = InsertIfNewBucket(QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId],
|
||
topEvidence.x, topEvidence.y, GetCoordGridForZone(topEvidence.zoneId))
|
||
end
|
||
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
|
||
"overrode static DB — learned spawn (" .. tostring(topEvidence.x) .. "," .. tostring(topEvidence.y) .. ")",
|
||
"zone " .. topEvidence.zoneId .. " at " .. floor(topPct + 0.5) .. "% confidence",
|
||
spawned and "SPAM" or "IGNORED_DUPLICATE")
|
||
|
||
-- Clear/rebuild cache through the same coalesced path used by kill learning.
|
||
_QueueNpcLiveUpdate(npcId)
|
||
|
||
return true
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Quest learning
|
||
------------------------------------------------------------------------
|
||
|
||
-- Captures all fields accessible from the WoW API.
|
||
-- Quest data array indices follow the Questie wiki spec exactly:
|
||
-- [1] name [2] starters (npc/obj/item arrays) [3] finishers
|
||
-- [4] requiredLevel [5] questLevel [6] infoText (objectives text block)
|
||
-- [7] requiredMoney [8] zoneOrSort [12] requiredRaces [13] requiredClasses
|
||
-- [17] details text [18] finishText [19] completedText
|
||
function QuestieLearner:LearnQuest(questId, data)
|
||
if not self:IsEnabled() then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[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_INFO, "[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 self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.questDataOverrides then
|
||
local ovr = QuestieDB.questDataOverrides[questId]
|
||
if not ovr then
|
||
QuestieDB.questDataOverrides[questId] = existing
|
||
else
|
||
for k, v in pairs(existing) do
|
||
if ovr[k] == nil and not IsAscensionProtected("QUEST", questId, k) then ovr[k] = v end
|
||
end
|
||
end
|
||
end
|
||
|
||
if isNew then
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New quest learned:", questId, existing[1] or "?")
|
||
CrossLinkAfterQuest(questId)
|
||
end
|
||
_Learner:BroadcastIfCommsAvailable("QUEST", questId, existing)
|
||
end
|
||
|
||
-- Records the NPC/object that starts or finishes a quest (array index [2] or [3])
|
||
function QuestieLearner:LearnQuestGiver(questId, entityId, entityType, isStart)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnQuests then return end
|
||
questId, entityId = tonumber(questId), tonumber(entityId)
|
||
if not questId or questId <= 0 or not entityId or entityId <= 0 then return end
|
||
|
||
local existing = Questie.dbLearner.global.quests[questId]
|
||
if not existing then
|
||
existing = {}
|
||
Questie.dbLearner.global.quests[questId] = existing
|
||
end
|
||
|
||
-- Starters/finishers: { [1]={npcIds}, [2]={objIds}, [3]={itemIds} }
|
||
local field = isStart and 2 or 3
|
||
existing[field] = existing[field] or {}
|
||
-- entityType: 1=npc, 2=obj, 3=item
|
||
local typeSlot = entityType or 1
|
||
existing[field][typeSlot] = existing[field][typeSlot] or {}
|
||
local list = existing[field][typeSlot]
|
||
for _, id in ipairs(list) do
|
||
if id == entityId then return end
|
||
end
|
||
table.insert(list, entityId)
|
||
|
||
-- Live injection into questDataOverrides so starters/finishers take effect without reload
|
||
if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, field) then
|
||
local ovr = QuestieDB.questDataOverrides[questId] or {}
|
||
QuestieDB.questDataOverrides[questId] = ovr
|
||
ovr[field] = ovr[field] or {}
|
||
ovr[field][typeSlot] = ovr[field][typeSlot] or {}
|
||
local ovrList = ovr[field][typeSlot]
|
||
local found = false
|
||
for _, id in ipairs(ovrList) do
|
||
if id == entityId then found = true; break end
|
||
end
|
||
if not found then table.insert(ovrList, entityId) end
|
||
end
|
||
|
||
-- Cross-link both directions for all entity types
|
||
CrossLinkAfterQuestGiver(questId, entityId, typeSlot, isStart)
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Quest objective NPC learning (kill objectives)
|
||
------------------------------------------------------------------------
|
||
|
||
-- Adds npcId as a creatureObjective for questId ([10][1] in questKeys schema).
|
||
-- If the NPC already exists in the base DB the spawn data is already there;
|
||
-- we only need the quest to reference it so tooltips/map-pins get registered.
|
||
function QuestieLearner:LearnQuestObjectiveNPC(questId, npcId, objText, objectiveIndex)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnQuests then return end
|
||
questId, npcId = tonumber(questId), tonumber(npcId)
|
||
objectiveIndex = tonumber(objectiveIndex)
|
||
if not questId or questId <= 0 or not npcId or npcId <= 0 then return end
|
||
|
||
-- 1. Persist to SavedVariables
|
||
local existing = Questie.dbLearner.global.quests[questId] or {}
|
||
Questie.dbLearner.global.quests[questId] = existing
|
||
existing[10] = existing[10] or {}
|
||
existing[10][1] = existing[10][1] or {} -- creatureObjective slot
|
||
local alreadyInSV = false
|
||
for _, entry in ipairs(existing[10][1]) do
|
||
if entry[1] == npcId then alreadyInSV = true; break end
|
||
end
|
||
if not alreadyInSV then
|
||
table.insert(existing[10][1], { npcId, objText or "" })
|
||
end
|
||
|
||
-- 1.1 Persist exact objective index mapping
|
||
if objectiveIndex then
|
||
existing.objIndex = existing.objIndex or {}
|
||
local entry = existing.objIndex[objectiveIndex]
|
||
if not entry then
|
||
existing.objIndex[objectiveIndex] = { type = "monster", id = npcId, text = objText or "" }
|
||
elseif entry.id ~= npcId then
|
||
-- Handle Kill Credit (multiple NPCs for one objective)
|
||
if type(entry.id) == "number" then
|
||
entry.id = { entry.id, npcId }
|
||
entry.type = "killcredit"
|
||
else
|
||
local found = false
|
||
for _, id in ipairs(entry.id) do
|
||
if id == npcId then found = true; break end
|
||
end
|
||
if not found then table.insert(entry.id, npcId) end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 2. Apply to live questDataOverrides immediately (no reload needed)
|
||
if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, 10) then
|
||
local ovr = QuestieDB.questDataOverrides[questId] or {}
|
||
QuestieDB.questDataOverrides[questId] = ovr
|
||
ovr[10] = ovr[10] or {}
|
||
ovr[10][1] = ovr[10][1] or {}
|
||
local alreadyPresent = false
|
||
for _, entry in ipairs(ovr[10][1]) do
|
||
if entry[1] == npcId then alreadyPresent = true; break end
|
||
end
|
||
if not alreadyPresent then
|
||
table.insert(ovr[10][1], { npcId, objText or "" })
|
||
end
|
||
|
||
-- Update live objIndex override
|
||
if objectiveIndex then
|
||
ovr.objIndex = ovr.objIndex or {}
|
||
ovr.objIndex[objectiveIndex] = existing.objIndex[objectiveIndex]
|
||
end
|
||
end
|
||
|
||
-- 3. Re-process the quest so PopulateObjective registers tooltips & map pins
|
||
if self:IsLearnerLiveEnabled() then
|
||
_RefreshActiveQuestPins({ [questId] = true })
|
||
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 self:IsLearnerLiveEnabled() and QuestieTooltips and QuestieTooltips.RegisterObjectiveTooltip and not HasAscensionQuestObjectiveData(questId) 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
|
||
|
||
-- Adds objectId as an objectObjective for questId ([10][2] in questKeys schema).
|
||
function QuestieLearner:LearnQuestObjectiveObject(questId, objectId, objText, objectiveIndex)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnQuests then return end
|
||
questId, objectId = tonumber(questId), tonumber(objectId)
|
||
objectiveIndex = tonumber(objectiveIndex)
|
||
if not questId or questId <= 0 or not objectId or objectId <= 0 then return end
|
||
|
||
local existing = Questie.dbLearner.global.quests[questId] or {}
|
||
Questie.dbLearner.global.quests[questId] = existing
|
||
existing[10] = existing[10] or {}
|
||
existing[10][2] = existing[10][2] or {}
|
||
local alreadyInSV = false
|
||
for _, entry in ipairs(existing[10][2]) do
|
||
if entry[1] == objectId then alreadyInSV = true; break end
|
||
end
|
||
if not alreadyInSV then
|
||
table.insert(existing[10][2], { objectId, objText or "" })
|
||
end
|
||
|
||
if objectiveIndex then
|
||
existing.objIndex = existing.objIndex or {}
|
||
local entry = existing.objIndex[objectiveIndex]
|
||
if not entry then
|
||
existing.objIndex[objectiveIndex] = { type = "object", id = objectId, text = objText or "" }
|
||
end
|
||
end
|
||
|
||
if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, 10) then
|
||
local ovr = QuestieDB.questDataOverrides[questId] or {}
|
||
QuestieDB.questDataOverrides[questId] = ovr
|
||
ovr[10] = ovr[10] or {}
|
||
ovr[10][2] = ovr[10][2] or {}
|
||
local alreadyPresent = false
|
||
for _, entry in ipairs(ovr[10][2]) do
|
||
if entry[1] == objectId then alreadyPresent = true; break end
|
||
end
|
||
if not alreadyPresent then
|
||
table.insert(ovr[10][2], { objectId, objText or "" })
|
||
end
|
||
if objectiveIndex then
|
||
ovr.objIndex = ovr.objIndex or {}
|
||
ovr.objIndex[objectiveIndex] = existing.objIndex[objectiveIndex]
|
||
end
|
||
end
|
||
|
||
if self:IsLearnerLiveEnabled() then
|
||
_RefreshActiveQuestPins({ [questId] = true })
|
||
end
|
||
|
||
local QuestieTooltips = QuestieLoader:ImportModule("QuestieTooltips")
|
||
if self:IsLearnerLiveEnabled() and QuestieTooltips and QuestieTooltips.RegisterObjectiveTooltip and not HasAscensionQuestObjectiveData(questId) then
|
||
QuestieTooltips:RegisterObjectiveTooltip(questId, "o_" .. objectId, {
|
||
Index = 0,
|
||
Description = objText or "Learned Objective",
|
||
Update = function() end
|
||
})
|
||
end
|
||
|
||
Questie:Debug(Questie.DEBUG_LEARNER,
|
||
"[QuestieLearner] Quest", questId, "objective OBJECT learned:", objectId, 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 self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.itemDataOverrides then
|
||
local ovr = QuestieDB.itemDataOverrides[itemId]
|
||
if not ovr then
|
||
QuestieDB.itemDataOverrides[itemId] = existing
|
||
else
|
||
for k, v in pairs(existing) do
|
||
if ovr[k] == nil and not IsAscensionProtected("ITEM", itemId, k) then ovr[k] = v end
|
||
end
|
||
end
|
||
end
|
||
|
||
if isNew then
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New item learned:", itemId, name or "?")
|
||
CrossLinkAfterItem(itemId)
|
||
end
|
||
_Learner:BroadcastIfCommsAvailable("ITEM", itemId, existing)
|
||
end
|
||
|
||
function QuestieLearner:LearnItemDrop(itemId, npcId)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnItems then return end
|
||
itemId, npcId = tonumber(itemId), tonumber(npcId)
|
||
if not itemId or itemId <= 0 or not npcId or npcId <= 0 then return end
|
||
|
||
local existing = Questie.dbLearner.global.items[itemId]
|
||
if not existing then
|
||
existing = {}
|
||
Questie.dbLearner.global.items[itemId] = existing
|
||
end
|
||
|
||
existing.ls = time() -- Update last seen
|
||
|
||
existing[2] = existing[2] or {}
|
||
for _, id in ipairs(existing[2]) do
|
||
if id == npcId then return end
|
||
end
|
||
table.insert(existing[2], npcId)
|
||
|
||
-- Live injection: sync drop list to itemDataOverrides
|
||
if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.itemDataOverrides and not IsAscensionProtected("ITEM", itemId, 2) then
|
||
local ovr = QuestieDB.itemDataOverrides[itemId] or {}
|
||
QuestieDB.itemDataOverrides[itemId] = ovr
|
||
ovr[2] = ovr[2] or {}
|
||
local found = false
|
||
for _, id in ipairs(ovr[2]) do
|
||
if id == npcId then found = true; break end
|
||
end
|
||
if not found then table.insert(ovr[2], npcId) end
|
||
end
|
||
|
||
-- New drop relationship: re-run item cross-link to chain drop NPC → quest objectives
|
||
CrossLinkAfterItem(itemId)
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Object learning
|
||
------------------------------------------------------------------------
|
||
|
||
function QuestieLearner:LearnObject(objectId, name)
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnObjects then return end
|
||
objectId = tonumber(objectId)
|
||
if not objectId or objectId <= 0 then return end
|
||
|
||
local zoneId = GetZoneId()
|
||
local x, y = GetPlayerCoords()
|
||
|
||
local existing = Questie.dbLearner.global.objects[objectId]
|
||
local isNew = existing == nil
|
||
if not existing then
|
||
existing = {}
|
||
Questie.dbLearner.global.objects[objectId] = existing
|
||
end
|
||
|
||
if name and not existing[1] then existing[1] = name end
|
||
if zoneId and zoneId > 0 and not existing[5] then existing[5] = zoneId end
|
||
|
||
if x and y and zoneId and zoneId > 0 then
|
||
existing[4] = existing[4] or {}
|
||
existing[4][zoneId] = existing[4][zoneId] or {}
|
||
InsertIfNewBucket(existing[4][zoneId], x, y)
|
||
end
|
||
|
||
existing.ls = time() -- Update last seen
|
||
existing.mc = (existing.mc or 0) + 1
|
||
|
||
-- Live injection into objectDataOverrides so QueryObjectSingle works without reload
|
||
if self:IsLearnerLiveEnabled() and QuestieDB and QuestieDB.objectDataOverrides then
|
||
local ovr = QuestieDB.objectDataOverrides[objectId]
|
||
if not ovr then
|
||
QuestieDB.objectDataOverrides[objectId] = existing
|
||
else
|
||
for k, v in pairs(existing) do
|
||
if ovr[k] == nil and not IsAscensionProtected("OBJECT", objectId, k) then ovr[k] = v end
|
||
end
|
||
if existing[4] and not IsAscensionProtected("OBJECT", objectId, 4) then
|
||
ovr[4] = ovr[4] or {}
|
||
for zid, coords in pairs(existing[4]) do
|
||
ovr[4][zid] = ovr[4][zid] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(ovr[4][zid], coord[1], coord[2])
|
||
end
|
||
end
|
||
end
|
||
end
|
||
-- Clear compiled DB cache so GetObject rebuilds with the new override data.
|
||
if QuestieDB.private and QuestieDB.private.objectCache then
|
||
QuestieDB.private.objectCache[objectId] = nil
|
||
end
|
||
end
|
||
|
||
if isNew then
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New object learned:", objectId, name or "?")
|
||
CrossLinkAfterObject(objectId)
|
||
end
|
||
_Learner:BroadcastIfCommsAvailable("OBJECT", objectId, existing)
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- InjectLearnedData — pushes learnedData into QuestieDB overrides
|
||
------------------------------------------------------------------------
|
||
|
||
function QuestieLearner:Sanitize(data)
|
||
if not data or type(data) ~= "table" then return end
|
||
|
||
-- De-duplicate coordinates if any
|
||
-- NPCs: key 7, Objects: key 4
|
||
for _, coordKey in ipairs({7, 4}) do
|
||
if data[coordKey] and type(data[coordKey]) == "table" then
|
||
for zoneId, coords in pairs(data[coordKey]) do
|
||
local unique = {}
|
||
local grid = COORD_GRID -- use standard for static sanitization
|
||
for _, c in ipairs(coords) do
|
||
local bx, by = floor(c[1] / grid) * grid, floor(c[2] / grid) * grid
|
||
local key = bx .. "," .. by
|
||
if not unique[key] then
|
||
unique[key] = c
|
||
end
|
||
end
|
||
local newList = {}
|
||
for _, c in pairs(unique) do table.insert(newList, c) end
|
||
data[coordKey][zoneId] = newList
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Trim name/text strings
|
||
if data[1] and type(data[1]) == "string" then
|
||
data[1] = string.trim(data[1])
|
||
end
|
||
|
||
return data
|
||
end
|
||
|
||
function QuestieLearner:InjectLearnedData()
|
||
if not EnsureLearnedData() then return end
|
||
if not self:IsEnabled() then
|
||
QuestieLearner.data = Questie.dbLearner.global
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] InjectLearnedData skipped because learner is disabled")
|
||
return
|
||
end
|
||
|
||
local mode = self:GetDataSourceMode()
|
||
if mode == "static" or mode == "none" then
|
||
QuestieLearner.data = Questie.dbLearner.global
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] InjectLearnedData skipped because data source mode is", mode)
|
||
return
|
||
end
|
||
|
||
local learned = Questie.dbLearner.global
|
||
-- Normalize malformed saved variables before any migration or injection.
|
||
-- This prevents old learner rows from carrying numeric spawn fields
|
||
-- into the suppression path and crashing the quest objective filter.
|
||
local sanitizedEntries = 0
|
||
if QuestieDB and QuestieDB.private and QuestieDB.private.NormalizeLearnerSpawnEntry then
|
||
for _, data in pairs(learned.npcs) do
|
||
if QuestieDB.private.NormalizeLearnerSpawnEntry(data, 7, 4) then
|
||
sanitizedEntries = sanitizedEntries + 1
|
||
end
|
||
end
|
||
for _, data in pairs(learned.objects) do
|
||
if QuestieDB.private.NormalizeLearnerSpawnEntry(data, 4, 7) then
|
||
sanitizedEntries = sanitizedEntries + 1
|
||
end
|
||
end
|
||
if sanitizedEntries > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Sanitized", sanitizedEntries, "malformed learned spawn entries")
|
||
end
|
||
end
|
||
|
||
-- Migrate old-format NPC data ([4]=spawns, [5]=zoneId) to new format ([7]=spawns, [9]=zoneId)
|
||
-- Always merge [4] into [7], even when [7] already has partial data from a recent session.
|
||
for npcId, data in pairs(learned.npcs) do
|
||
if type(data[4]) == "table" then
|
||
if data[7] == nil then
|
||
-- Simple move: no [7] exists yet
|
||
data[7] = data[4]
|
||
else
|
||
-- Merge: [7] has partial data, consolidate [4] coordinates into it
|
||
for zoneId, coords in pairs(data[4]) do
|
||
data[7][zoneId] = data[7][zoneId] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(data[7][zoneId], coord[1], coord[2])
|
||
end
|
||
end
|
||
end
|
||
data[4] = nil
|
||
end
|
||
if type(data[5]) == "number" and data[9] == nil then
|
||
data[9] = data[5]
|
||
data[5] = nil
|
||
end
|
||
end
|
||
|
||
-- Merge character-specific learned NPC data into global pool
|
||
if Questie.db and Questie.db.char and Questie.db.char.npcs then
|
||
for npcId, data in pairs(Questie.db.char.npcs) do
|
||
local globalData = learned.npcs[npcId]
|
||
if not globalData then
|
||
learned.npcs[npcId] = data
|
||
else
|
||
-- Merge spawns: char data may be old ([4]) or new ([7]) format
|
||
local charSpawns = data[7] or data[4]
|
||
local globalSpawns = globalData[7] or globalData[4]
|
||
if charSpawns then
|
||
if not globalSpawns then
|
||
globalData[7] = {}
|
||
globalSpawns = globalData[7]
|
||
end
|
||
for zoneId, coords in pairs(charSpawns) do
|
||
globalSpawns[zoneId] = globalSpawns[zoneId] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(globalSpawns[zoneId], coord[1], coord[2])
|
||
end
|
||
end
|
||
end
|
||
-- Adopt zoneId if missing
|
||
if not globalData[9] and data[9] then globalData[9] = data[9] end
|
||
if not globalData[5] and data[5] then globalData[5] = data[5] end
|
||
end
|
||
end
|
||
Questie.db.char.npcs = nil
|
||
end
|
||
|
||
local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0
|
||
|
||
-- Migration: fix spawn zone keys that were stored as uiMapId instead of areaId.
|
||
-- Before the GetZoneId() fix, kills on maps like Sunstrider Isle (uiMapId 1241)
|
||
-- were stored under key 1241 instead of the correct areaId 3431.
|
||
-- Convert any uiMapId keys to areaId using ZoneDB.
|
||
local zonesFixed = 0
|
||
for npcId, data in pairs(learned.npcs) do
|
||
if data[7] then
|
||
local zonesToMigrate = {}
|
||
for zoneKey, coords in pairs(data[7]) do
|
||
local originalZoneKey = zoneKey
|
||
zoneKey = NormalizeSpawnZoneKey(zoneKey)
|
||
-- Older Ascension learner data stored native Sunstrider coords
|
||
-- under parent areaId 3430 while [9] still identified the row as
|
||
-- Sunstrider. Move those coords to areaId 3431 so they render on
|
||
-- uiMapId 1241 instead of Eversong.
|
||
if originalZoneKey == 3430 and IsSunstriderNativeZone(data[9]) then
|
||
zonesToMigrate[originalZoneKey] = 3431
|
||
end
|
||
-- If zoneKey looks like a uiMapId (a map ID rather than an areaId),
|
||
-- ZoneDB:GetAreaIdByUiMapId will return the corresponding areaId.
|
||
-- If it returns nil, zoneKey is already an areaId — no migration needed.
|
||
-- Skip very common areaIds that happen to look like small numbers.
|
||
if not zonesToMigrate[originalZoneKey] and ZoneDB and ZoneDB.GetAreaIdByUiMapId then
|
||
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey)
|
||
if maybeAreaId and maybeAreaId ~= zoneKey then
|
||
zonesToMigrate[originalZoneKey] = maybeAreaId
|
||
end
|
||
end
|
||
end
|
||
for oldKey, newKey in pairs(zonesToMigrate) do
|
||
local coords = data[7][oldKey]
|
||
if coords then
|
||
data[7][newKey] = data[7][newKey] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(data[7][newKey], coord[1], coord[2])
|
||
end
|
||
data[7][oldKey] = nil
|
||
zonesFixed = zonesFixed + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
-- Same migration for object spawn data (field 4)
|
||
for objId, data in pairs(learned.objects) do
|
||
if data[4] then
|
||
local zonesToMigrate = {}
|
||
for zoneKey, coords in pairs(data[4]) do
|
||
local originalZoneKey = zoneKey
|
||
zoneKey = NormalizeSpawnZoneKey(zoneKey)
|
||
if originalZoneKey == 3430 and IsSunstriderNativeZone(data[5]) then
|
||
zonesToMigrate[originalZoneKey] = 3431
|
||
end
|
||
if not zonesToMigrate[originalZoneKey] and ZoneDB and ZoneDB.GetAreaIdByUiMapId then
|
||
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey)
|
||
if maybeAreaId and maybeAreaId ~= zoneKey then
|
||
zonesToMigrate[originalZoneKey] = maybeAreaId
|
||
end
|
||
end
|
||
end
|
||
for oldKey, newKey in pairs(zonesToMigrate) do
|
||
local coords = data[4][oldKey]
|
||
if coords then
|
||
data[4][newKey] = data[4][newKey] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(data[4][newKey], coord[1], coord[2])
|
||
end
|
||
data[4][oldKey] = nil
|
||
zonesFixed = zonesFixed + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if zonesFixed > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Migrated", zonesFixed, "spawn zone keys from uiMapId to areaId")
|
||
end
|
||
|
||
-- Also fix [9] zone field for NPCs and [5] zone field for Objects
|
||
-- that were stored as uiMapId instead of areaId (e.g. 1241 → 3431).
|
||
local fieldsFixed = 0
|
||
for npcId, data in pairs(learned.npcs) do
|
||
if type(data[9]) == "number" and ZoneDB and ZoneDB.GetAreaIdByUiMapId then
|
||
local normalizedZone = NormalizeSpawnZoneKey(data[9])
|
||
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(normalizedZone)
|
||
if maybeAreaId and maybeAreaId ~= data[9] then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] NPC", npcId, "zone field [9]", data[9], "->", maybeAreaId)
|
||
data[9] = maybeAreaId
|
||
fieldsFixed = fieldsFixed + 1
|
||
end
|
||
end
|
||
end
|
||
for objId, data in pairs(learned.objects) do
|
||
if type(data[5]) == "number" and ZoneDB and ZoneDB.GetAreaIdByUiMapId then
|
||
local normalizedZone = NormalizeSpawnZoneKey(data[5])
|
||
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(normalizedZone)
|
||
if maybeAreaId and maybeAreaId ~= data[5] then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Object", objId, "zone field [5]", data[5], "->", maybeAreaId)
|
||
data[5] = maybeAreaId
|
||
fieldsFixed = fieldsFixed + 1
|
||
end
|
||
end
|
||
end
|
||
if fieldsFixed > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Fixed", fieldsFixed, "zone fields from uiMapId to areaId")
|
||
end
|
||
|
||
-- Purge player-spawned totems from learned NPCs (they are not real world spawns)
|
||
-- Also purge NPCs with entirely empty spawn data (stale learner artifacts)
|
||
local purgedNpcs = 0
|
||
local PLAYER_SPAWNED_NPCS = {
|
||
[2523] = true, -- Searing Totem
|
||
[2630] = true, -- Earthbind Totem
|
||
[10183] = true, -- Moonflare Totem
|
||
[1103907] = true, -- Healing Stream Totem III
|
||
[1107398] = true, -- Stoneclaw Totem V
|
||
}
|
||
for npcId, data in pairs(learned.npcs) do
|
||
if PLAYER_SPAWNED_NPCS[npcId] then
|
||
learned.npcs[npcId] = nil
|
||
purgedNpcs = purgedNpcs + 1
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged player-spawned NPC", npcId, data[1] or "?")
|
||
elseif data[7] then
|
||
-- Check for empty spawn table (no coords at all = stale learner artifact)
|
||
local hasCoords = false
|
||
for zoneKey, coords in pairs(data[7]) do
|
||
if type(coords) == "table" and #coords > 0 then
|
||
hasCoords = true
|
||
break
|
||
end
|
||
end
|
||
if not hasCoords then
|
||
learned.npcs[npcId] = nil
|
||
purgedNpcs = purgedNpcs + 1
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged NPC with empty spawns", npcId, data[1] or "?")
|
||
end
|
||
end
|
||
end
|
||
if purgedNpcs > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged", purgedNpcs, "invalid NPCs from learned data")
|
||
end
|
||
|
||
-- Versioned cleanup: strip spawns from NPCs learned via quest giver/finisher
|
||
-- fallback (player position stored as spawn by LearnNPC). These have only
|
||
-- a single spawn position regardless of confidence — real kill NPCs
|
||
-- accumulate spawns at multiple distinct locations.
|
||
local fallbackSpawnCleanupVersion = 2
|
||
if (learned._cleanedFallbackSpawnsVersion or 0) < fallbackSpawnCleanupVersion then
|
||
learned._cleanedFallbackSpawns = true
|
||
learned._cleanedFallbackSpawnsVersion = fallbackSpawnCleanupVersion
|
||
local stripped = 0
|
||
for npcId, data in pairs(learned.npcs) do
|
||
if data[7] and CountUniqueSpawnPositions(data[7]) <= 1 then
|
||
data[7] = nil
|
||
stripped = stripped + 1
|
||
end
|
||
end
|
||
if stripped > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Stripped fallback spawns from", stripped, "NPCs")
|
||
end
|
||
end
|
||
|
||
-- Purge Object entries that duplicate NPC entries (mobs learned as both NPC and Object).
|
||
-- NPC data is richer (has names, quest IDs), so keep the NPC version and remove the Object.
|
||
local dupObjectsRemoved = 0
|
||
for objId, _ in pairs(learned.objects) do
|
||
if learned.npcs[objId] then
|
||
learned.objects[objId] = nil
|
||
dupObjectsRemoved = dupObjectsRemoved + 1
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Removed duplicate Object", objId, "(NPC version exists)")
|
||
end
|
||
end
|
||
if dupObjectsRemoved > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Removed", dupObjectsRemoved, "Object entries that duplicate NPC entries")
|
||
end
|
||
|
||
-- 1. NPCs
|
||
local npcIdsToFix = {}
|
||
local npcNameIndexNeedsRebuild = false
|
||
for npcId, data in pairs(learned.npcs) do
|
||
local nid = tonumber(npcId)
|
||
if type(npcId) == "string" and nid then
|
||
npcIdsToFix[npcId] = nid
|
||
end
|
||
self:Sanitize(data)
|
||
if not QuestieDB.npcDataOverrides[nid or npcId] then
|
||
-- Spawn evidence is promoted through _MergeSpawnEvidence, where
|
||
-- AscensionDB ownership is known. Injecting [7] here runs too early
|
||
-- and can pollute curated plugin spawn tables.
|
||
QuestieDB.npcDataOverrides[nid or npcId] = CopyWithoutField(data, 7)
|
||
npcCount = npcCount + 1
|
||
if data[1] then npcNameIndexNeedsRebuild = true end
|
||
else
|
||
local existing = QuestieDB.npcDataOverrides[nid or npcId]
|
||
-- Adopt other fields if missing
|
||
for k, v in pairs(data) do
|
||
if k ~= "mc" and k ~= 7 and existing[k] == nil and not IsAscensionProtected("NPC", nid or npcId, k) then
|
||
existing[k] = v
|
||
if k == 1 then npcNameIndexNeedsRebuild = true end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
for old, new in pairs(npcIdsToFix) do
|
||
learned.npcs[new] = learned.npcs[old]
|
||
learned.npcs[old] = nil
|
||
end
|
||
|
||
if Questie.db and Questie.db.profile and Questie.db.profile.debugEnabled then
|
||
-- Diagnostic: log how many NPCs were injected with spawn overrides
|
||
local spawnOverrideCount = 0
|
||
for nid, ovr in pairs(QuestieDB.npcDataOverrides) do
|
||
if ovr[7] and next(ovr[7]) then
|
||
spawnOverrideCount = spawnOverrideCount + 1
|
||
end
|
||
end
|
||
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieLearner] InjectLearnedData: injected", npcCount, "NPCs (", spawnOverrideCount, "with spawn overrides)")
|
||
end
|
||
if npcNameIndexNeedsRebuild then
|
||
_MarkNpcNameIndexDirty()
|
||
end
|
||
|
||
-- Purge garbage quest entries: quests with no name [1] and only mc/ls metadata.
|
||
-- These are Ascension internal tracking artifacts (hash-like IDs) with no real quest data.
|
||
-- Also purge quest 788 ("Mottled Boar slain") which is an objective text, not a quest name.
|
||
local purgedQuests = 0
|
||
local OBJECTIVE_TEXT_QUEST_IDS = {
|
||
[788] = true, -- "Mottled Boar slain" — objective text, not a quest
|
||
}
|
||
for questId, data in pairs(learned.quests) do
|
||
local hasRealData = false
|
||
-- Check if quest has any meaningful data beyond mc/ls metadata
|
||
if type(data[1]) == "string" and data[1] ~= "" then
|
||
hasRealData = true
|
||
end
|
||
-- Also check for objective data, level, zone, etc.
|
||
if not hasRealData then
|
||
for k, v in pairs(data) do
|
||
if k ~= "mc" and k ~= "ls" then
|
||
hasRealData = true
|
||
break
|
||
end
|
||
end
|
||
end
|
||
local qid = tonumber(questId)
|
||
if not hasRealData or (qid and OBJECTIVE_TEXT_QUEST_IDS[qid]) then
|
||
local reason = not hasRealData and "garbage" or "objective-text"
|
||
Questie:Debug(Questie.DEBUG_INFO, "[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_INFO, "[QuestieLearner] Inferred sortKey", sortKey, "for quest", questId, data[1])
|
||
end
|
||
end
|
||
end
|
||
if sortKeysInferred > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Inferred sortKey for", sortKeysInferred, "quests from zone data")
|
||
end
|
||
|
||
-- 2. Quests
|
||
local questIdsToFix = {}
|
||
for questId, data in pairs(learned.quests) do
|
||
local qid = tonumber(questId)
|
||
if type(questId) == "string" and qid then
|
||
questIdsToFix[questId] = qid
|
||
end
|
||
self:Sanitize(data)
|
||
-- Legacy cleanup for malformed objective data
|
||
if data[10] ~= nil then
|
||
local ok = type(data[10]) == "table"
|
||
if ok then
|
||
for _, v in pairs(data[10]) do
|
||
if type(v) ~= "table" then ok = false; break end
|
||
end
|
||
end
|
||
if not ok then data[10] = nil end
|
||
end
|
||
if data[8] ~= nil and type(data[8]) ~= "table" then
|
||
data[8] = nil
|
||
end
|
||
|
||
if not QuestieDB.questDataOverrides[qid or questId] then
|
||
QuestieDB.questDataOverrides[qid or questId] = data
|
||
questCount = questCount + 1
|
||
else
|
||
local existing = QuestieDB.questDataOverrides[qid or questId]
|
||
for k, v in pairs(data) do
|
||
if k ~= "mc" then
|
||
if k == 10 then
|
||
-- Special merge: add learned creatureObjective entries to [10][1]
|
||
if not IsAscensionProtected("QUEST", qid or questId, 10) then
|
||
existing[10] = existing[10] or {}
|
||
existing[10][1] = existing[10][1] or {}
|
||
if type(v[1]) == "table" then
|
||
for _, entry in ipairs(v[1]) do
|
||
local found = false
|
||
for _, ex in ipairs(existing[10][1]) do
|
||
if ex[1] == entry[1] then found = true; break end
|
||
end
|
||
if not found then
|
||
tinsert(existing[10][1], entry)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
elseif existing[k] == nil and not IsAscensionProtected("QUEST", qid or questId, k) then
|
||
existing[k] = v
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
for old, new in pairs(questIdsToFix) do
|
||
learned.quests[new] = learned.quests[old]
|
||
learned.quests[old] = nil
|
||
end
|
||
|
||
-- 3. Items
|
||
local itemIdsToFix = {}
|
||
for itemId, data in pairs(learned.items) do
|
||
local iid = tonumber(itemId)
|
||
if type(itemId) == "string" and iid then
|
||
itemIdsToFix[itemId] = iid
|
||
end
|
||
if not QuestieDB.itemDataOverrides[iid or itemId] then
|
||
QuestieDB.itemDataOverrides[iid or itemId] = data
|
||
itemCount = itemCount + 1
|
||
end
|
||
end
|
||
for old, new in pairs(itemIdsToFix) do
|
||
learned.items[new] = learned.items[old]
|
||
learned.items[old] = nil
|
||
end
|
||
|
||
-- 4. Objects
|
||
local objectIdsToFix = {}
|
||
for objectId, data in pairs(learned.objects) do
|
||
local oid = tonumber(objectId)
|
||
if type(objectId) == "string" and oid then
|
||
objectIdsToFix[objectId] = oid
|
||
end
|
||
self:Sanitize(data)
|
||
if not QuestieDB.objectDataOverrides[oid or objectId] then
|
||
QuestieDB.objectDataOverrides[oid or objectId] = data
|
||
objectCount = objectCount + 1
|
||
else
|
||
local existing = QuestieDB.objectDataOverrides[oid or objectId]
|
||
if data[4] and not IsAscensionProtected("OBJECT", oid or objectId, 4) then
|
||
existing[4] = existing[4] or {}
|
||
for zoneId, coords in pairs(data[4]) do
|
||
existing[4][zoneId] = existing[4][zoneId] or {}
|
||
for _, coord in ipairs(coords) do
|
||
InsertIfNewBucket(existing[4][zoneId], coord[1], coord[2])
|
||
end
|
||
end
|
||
end
|
||
-- Adopt other fields
|
||
for k, v in pairs(data) do
|
||
if k ~= "mc" and k ~= 4 and existing[k] == nil and not IsAscensionProtected("OBJECT", oid or objectId, k) then
|
||
existing[k] = v
|
||
end
|
||
end
|
||
end
|
||
end
|
||
for old, new in pairs(objectIdsToFix) do
|
||
learned.objects[new] = learned.objects[old]
|
||
learned.objects[old] = nil
|
||
end
|
||
|
||
if npcCount > 0 or questCount > 0 or itemCount > 0 or objectCount > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Injected learned data:",
|
||
npcCount, "NPCs,", questCount, "quests,", itemCount, "items,", objectCount, "objects")
|
||
end
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Stats / Export helpers
|
||
------------------------------------------------------------------------
|
||
|
||
function QuestieLearner:GetStats()
|
||
if not EnsureLearnedData() then return 0, 0, 0, 0 end
|
||
local learned = Questie.dbLearner.global
|
||
local n, q, i, o = 0, 0, 0, 0
|
||
for _ in pairs(learned.npcs) do n = n + 1 end
|
||
for _ in pairs(learned.quests) do q = q + 1 end
|
||
for _ in pairs(learned.items) do i = i + 1 end
|
||
for _ in pairs(learned.objects) do o = o + 1 end
|
||
return n, q, i, o
|
||
end
|
||
|
||
function QuestieLearner:ClearAllData()
|
||
if not EnsureLearnedData() then return end
|
||
local learned = Questie.dbLearner.global
|
||
local settings = learned.settings or {}
|
||
|
||
for key in pairs(learned) do
|
||
if key ~= "settings" then
|
||
learned[key] = nil
|
||
end
|
||
end
|
||
|
||
learned.settings = settings
|
||
learned.npcs = {}
|
||
learned.quests = {}
|
||
learned.items = {}
|
||
learned.objects = {}
|
||
|
||
self:ApplyDataSourceMode()
|
||
NotifyLearnerOptionsChanged()
|
||
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 _GetDashGuidField(guid, index)
|
||
if not guid or type(guid) ~= "string" or index <= 0 then return nil end
|
||
local field = 1
|
||
local startPos = 1
|
||
while true do
|
||
local sepStart, sepEnd = string.find(guid, "-", startPos, true)
|
||
if not sepStart then
|
||
if field == index then
|
||
return string.sub(guid, startPos)
|
||
end
|
||
return nil
|
||
end
|
||
if field == index then
|
||
return string.sub(guid, startPos, sepStart - 1)
|
||
end
|
||
field = field + 1
|
||
startPos = sepEnd + 1
|
||
end
|
||
end
|
||
|
||
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 = _GetDashGuidField(guid, 1)
|
||
local parsedId = _GetDashGuidField(guid, 6)
|
||
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
|
||
|
||
local function TraceLearnerEntity(source, guid, unitType, entityId, name)
|
||
if not Questie or not Questie.Debug then return end
|
||
local prefix = "n/a"
|
||
if type(guid) == "string" and guid:sub(1, 2) == "0x" then
|
||
prefix = string.upper(guid:sub(3, 6))
|
||
end
|
||
Questie:Debug(
|
||
Questie.DEBUG_DEVELOP,
|
||
"[QuestieLearner:ObjectTrace]",
|
||
source,
|
||
"guid=",
|
||
tostring(guid),
|
||
"prefix=",
|
||
tostring(prefix),
|
||
"unitType=",
|
||
tostring(unitType),
|
||
"id=",
|
||
tostring(entityId),
|
||
"name=",
|
||
tostring(name)
|
||
)
|
||
end
|
||
|
||
local function ResolveObjectName(objectId)
|
||
if not objectId or objectId <= 0 then return nil end
|
||
local name = QuestieDB and QuestieDB.QueryObjectSingle and QuestieDB.QueryObjectSingle(objectId, "name")
|
||
if name and name ~= "" then return name end
|
||
if l10n and l10n.objectNameLookup then
|
||
for localizedName, ids in pairs(l10n.objectNameLookup) do
|
||
if ids then
|
||
for _, id in ipairs(ids) do
|
||
if id == objectId then
|
||
return localizedName
|
||
end
|
||
end
|
||
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 name = UnitName("mouseover")
|
||
local entityId, unitType = self:ResolveNpcIdFromGuidAndName(guid, name)
|
||
TraceLearnerEntity("mouseover", guid, unitType, entityId, name)
|
||
|
||
if not entityId or entityId <= 0 then return end
|
||
if unitType == "GameObject" then
|
||
self:LearnObject(entityId, name)
|
||
return
|
||
end
|
||
if unitType ~= "Creature" and unitType ~= "Vehicle" 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[entityId]
|
||
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 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(entityId, 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 name = UnitName("target")
|
||
local entityId, unitType = self:ResolveNpcIdFromGuidAndName(guid, name)
|
||
TraceLearnerEntity("target", guid, unitType, entityId, name)
|
||
|
||
if not entityId or entityId <= 0 then return end
|
||
if unitType == "GameObject" then
|
||
self:LearnObject(entityId, name)
|
||
return
|
||
end
|
||
if unitType ~= "Creature" and unitType ~= "Vehicle" then return end
|
||
|
||
local level = UnitLevel("target")
|
||
|
||
_Learner.guidNpcCache = _Learner.guidNpcCache or {}
|
||
_Learner.guidNpcCache[guid] = { npcId = entityId, 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 = self:ResolveNpcIdFromGuidAndName(npcGuid, UnitName("npc"))
|
||
TraceLearnerEntity("quest_detail", npcGuid, unitType, entityId, UnitName("npc"))
|
||
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 = self:ResolveNpcIdFromGuidAndName(npcGuid, UnitName("npc"))
|
||
TraceLearnerEntity("quest_complete", npcGuid, unitType, entityId, UnitName("npc"))
|
||
if entityId and entityId > 0 then
|
||
local entityName = UnitName("npc")
|
||
if unitType == "GameObject" then
|
||
self:LearnQuestGiver(questId, entityId, 2, false)
|
||
self:LearnObject(entityId, entityName)
|
||
elseif unitType == "Creature" or unitType == "Vehicle" then
|
||
self:LearnQuestGiver(questId, entityId, 1, false)
|
||
local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 2
|
||
self:LearnNPC(entityId, entityName, nil, nil, npcFlags, nil, nil, nil, zoneId)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Helper to find an NPC ID by name (case-insensitive exact match)
|
||
-- Used for proactive objective mapping when a quest is first accepted.
|
||
function QuestieLearner:GetNPCIdByName(npcName)
|
||
if not npcName or npcName == "" then return nil end
|
||
local lowerName = string.lower(npcName)
|
||
|
||
local index = _EnsureNpcNameIndex()
|
||
local overrideId = index.override[lowerName]
|
||
if overrideId then return overrideId end
|
||
return index.base[lowerName]
|
||
end
|
||
|
||
function QuestieLearner:ResolveNpcIdFromGuidAndName(guid, npcName)
|
||
local entityId, unitType = GetIdAndTypeFromGUID(guid)
|
||
if not npcName or npcName == "" then
|
||
return entityId, unitType
|
||
end
|
||
|
||
local namedId = self:GetNPCIdByName(npcName)
|
||
if not namedId or namedId <= 0 then
|
||
return entityId, unitType
|
||
end
|
||
|
||
if entityId and entityId > 0 and entityId ~= namedId then
|
||
local parsedNpc = QuestieDB and QuestieDB.GetNPC and QuestieDB:GetNPC(entityId)
|
||
local parsedName = parsedNpc and parsedNpc.name
|
||
if not parsedName or string.lower(parsedName) ~= string.lower(npcName) then
|
||
return namedId, unitType
|
||
end
|
||
elseif not entityId or entityId <= 0 then
|
||
return namedId, unitType
|
||
end
|
||
|
||
return entityId, unitType
|
||
end
|
||
|
||
local function ResolveAcceptedQuestId(firstArg, secondArg)
|
||
local maxLog = GetNumQuestLogEntries and GetNumQuestLogEntries() or 25
|
||
|
||
local function resolveFromLogIndex(logIndex)
|
||
if not logIndex or type(logIndex) ~= "number" or logIndex <= 0 or logIndex > maxLog then
|
||
return nil
|
||
end
|
||
|
||
local resolvedId = QuestieCompat.GetQuestIDFromLogIndex and QuestieCompat.GetQuestIDFromLogIndex(logIndex)
|
||
if resolvedId and resolvedId > 0 then
|
||
return resolvedId
|
||
end
|
||
end
|
||
|
||
local function resolveFromQuestId(questId)
|
||
if not questId or type(questId) ~= "number" or questId <= 0 then
|
||
return nil
|
||
end
|
||
|
||
if QuestieCompat.GetQuestLogIndexByID and QuestieCompat.GetQuestLogIndexByID(questId) then
|
||
return questId
|
||
end
|
||
end
|
||
|
||
if secondArg and type(secondArg) == "number" and secondArg > 0 then
|
||
local resolvedId = resolveFromLogIndex(secondArg) or resolveFromQuestId(secondArg)
|
||
if resolvedId then
|
||
return resolvedId
|
||
end
|
||
end
|
||
|
||
if firstArg and type(firstArg) == "number" and firstArg > 0 then
|
||
local resolvedId = resolveFromLogIndex(firstArg) or resolveFromQuestId(firstArg)
|
||
if resolvedId then
|
||
return resolvedId
|
||
end
|
||
end
|
||
|
||
local selectedIndex = QuestieCompat.GetQuestLogSelection and QuestieCompat.GetQuestLogSelection()
|
||
if selectedIndex and selectedIndex > 0 then
|
||
return resolveFromLogIndex(selectedIndex)
|
||
end
|
||
|
||
return nil
|
||
end
|
||
|
||
-- Fires when a quest is accepted.
|
||
function QuestieLearner:OnQuestAccepted(firstArg, secondArg)
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] OnQuestAccepted raw args: first=" .. tostring(firstArg) .. " second=" .. tostring(secondArg))
|
||
local questId = ResolveAcceptedQuestId(firstArg, secondArg)
|
||
|
||
Questie:Debug(Questie.DEBUG_INFO, "[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" or objType == "object") 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
|
||
local objectId = 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
|
||
elseif objType == "object" then
|
||
local now = time()
|
||
local bestObject = nil
|
||
for _, obj in pairs(_Learner.recentObjects or {}) do
|
||
if obj and obj.name and obj.name ~= "" and (now - (obj.ts or 0)) <= 10 then
|
||
local objName = string.lower(obj.name)
|
||
local text = string.lower(objText)
|
||
local target = string.lower(targetName)
|
||
local match = objName == target
|
||
or string.find(text, objName, 1, true)
|
||
or string.find(objName, text, 1, true)
|
||
if match and (not bestObject or (obj.ts or 0) > (bestObject.ts or 0)) then
|
||
bestObject = obj
|
||
end
|
||
end
|
||
end
|
||
if bestObject and bestObject.objectId then
|
||
objectId = bestObject.objectId
|
||
end
|
||
end
|
||
-- Fallback to name-based lookup
|
||
if not npcId and not objectId then
|
||
npcId = self:GetNPCIdByName(targetName)
|
||
end
|
||
if npcId then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Proactively mapped objective", j, "to NPC", npcId, "(" .. targetName .. ")")
|
||
self:LearnQuestObjectiveNPC(questId, npcId, objText, j)
|
||
elseif objectId then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Proactively mapped objective", j, "to OBJECT", objectId, "(" .. targetName .. ")")
|
||
self:LearnQuestObjectiveObject(questId, objectId, 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 = self:ResolveNpcIdFromGuidAndName(npcGuid, UnitName("npc"))
|
||
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 = self:ResolveNpcIdFromGuidAndName(npcGuid, UnitName("npc"))
|
||
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 targetId, targetType = nil, nil
|
||
local npcId = nil
|
||
if targetGuid then
|
||
targetId, targetType = self:ResolveNpcIdFromGuidAndName(targetGuid, UnitName("target"))
|
||
TraceLearnerEntity("loot_target", targetGuid, targetType, targetId, UnitName("target"))
|
||
if targetType == "GameObject" and targetId and targetId > 0 then
|
||
self:LearnObject(targetId, UnitName("target"))
|
||
end
|
||
if targetType == "Creature" or targetType == "Vehicle" then
|
||
npcId = targetId
|
||
else
|
||
npcId = GetNpcIdFromGUID(targetGuid)
|
||
end
|
||
end
|
||
|
||
local numItems = GetNumLootItems()
|
||
for i = 1, numItems do
|
||
local _, lootName, _, _, lootQuality = GetLootSlotInfo(i)
|
||
if lootName then
|
||
if GetLootSourceInfo then
|
||
local sources = { GetLootSourceInfo(i) }
|
||
local sourceCount = table.getn(sources)
|
||
for j = 1, sourceCount, 2 do
|
||
local sourceGuid = sources[j]
|
||
local sourceQty = sources[j + 1]
|
||
Questie:Debug(
|
||
Questie.DEBUG_DEVELOP,
|
||
"[QuestieLearner:LootSourceTrace]",
|
||
"slot=",
|
||
i,
|
||
"sourceIndex=",
|
||
j,
|
||
"guid=",
|
||
tostring(sourceGuid),
|
||
"qty=",
|
||
tostring(sourceQty),
|
||
"lootName=",
|
||
tostring(lootName)
|
||
)
|
||
TraceLearnerEntity("loot_source", sourceGuid, nil, sourceQty, lootName)
|
||
if type(sourceGuid) == "string" then
|
||
local sourceId, sourceType = GetIdAndTypeFromGUID(sourceGuid)
|
||
if sourceType == "GameObject" and sourceId and sourceId > 0 then
|
||
self:LearnObject(sourceId, nil)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
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
|
||
self:LearnItem(itemId, itemName, itemLevel, requiredLevel, itemClassId, itemSubClassId)
|
||
if itemClassId == 12 and npcId then self:LearnItemDrop(itemId, npcId) 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:OnGameObjectUsed(objectId)
|
||
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 objectName = ResolveObjectName(objectId)
|
||
Questie:Debug(
|
||
Questie.DEBUG_DEVELOP,
|
||
"[QuestieLearner:GameObjectUsedTrace]",
|
||
"id=",
|
||
tostring(objectId),
|
||
"name=",
|
||
tostring(objectName)
|
||
)
|
||
TraceLearnerEntity("gameobject_used", nil, "GameObject", objectId, objectName)
|
||
self:LearnObject(objectId, objectName)
|
||
_Learner.recentObjects[objectId] = {
|
||
objectId = objectId,
|
||
name = objectName,
|
||
ts = time(),
|
||
zoneId = GetZoneId(),
|
||
}
|
||
end
|
||
|
||
function QuestieLearner:OnGossipShow()
|
||
local npcGuid = UnitGUID("npc")
|
||
if not npcGuid then return end
|
||
|
||
local id, unitType = self:ResolveNpcIdFromGuidAndName(npcGuid, UnitName("npc"))
|
||
if not id or id <= 0 then return end
|
||
|
||
local name = UnitName("npc")
|
||
TraceLearnerEntity("gossip", npcGuid, unitType, id, name)
|
||
-- 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 self:ResolveNpcIdFromGuidAndName(dstGUID, dstName)
|
||
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
|
||
self:LearnItem(itemId, itemName, itemLevel, requiredLevel, itemClassId, itemSubClassId)
|
||
if itemClassId == 12 and entry.npcId then self:LearnItemDrop(itemId, entry.npcId) 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 {}
|
||
_Learner.recentObjects = _Learner.recentObjects 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_INFO, "[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
|
||
|
||
-- Player/group engagement tracking. Quest-progress correlation (OnQuestLogUpdate)
|
||
-- must never attribute a *nearby* player's kill to our own objectives. We record
|
||
-- which mobs we — or our pet/party/raid — actually damaged, so a kill is only
|
||
-- "credited" to us if it was a PARTY_KILL or we recently engaged that GUID.
|
||
-- This runs before the kill-event filter so damage events are captured too.
|
||
if dstGUID and srcGUID then
|
||
local mine = false
|
||
local playerGUID = UnitGUID and UnitGUID("player")
|
||
if playerGUID and srcGUID == playerGUID then
|
||
mine = true
|
||
elseif UnitGUID and srcGUID == UnitGUID("pet") then
|
||
mine = true
|
||
elseif srcFlags and bit and bit.band then
|
||
-- Affiliation bits (mine/party/raid) flag damage from our group.
|
||
local ours = 0
|
||
if COMBATLOG_OBJECT_AFFILIATION_MINE then ours = ours + COMBATLOG_OBJECT_AFFILIATION_MINE end
|
||
if COMBATLOG_OBJECT_AFFILIATION_PARTY then ours = ours + COMBATLOG_OBJECT_AFFILIATION_PARTY end
|
||
if COMBATLOG_OBJECT_AFFILIATION_RAID then ours = ours + COMBATLOG_OBJECT_AFFILIATION_RAID end
|
||
if ours ~= 0 and bit.band(srcFlags, ours) ~= 0 then
|
||
mine = true
|
||
end
|
||
end
|
||
if mine then
|
||
_Learner.playerEngaged = _Learner.playerEngaged or {}
|
||
_Learner.playerEngaged[dstGUID] = time()
|
||
end
|
||
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 last = _Learner.killDebounce and _Learner.killDebounce[dstGUID]
|
||
local lastTs = type(last) == "table" and last.ts or last
|
||
local lastEventType = type(last) == "table" and last.eventType or nil
|
||
if lastTs and (now - lastTs) < 5 then
|
||
-- UNIT_DIED can arrive before PARTY_KILL for our own kill. Never let the
|
||
-- bystander-safe cache path suppress the authoritative local kill event.
|
||
if eventType ~= "PARTY_KILL" or lastEventType == "PARTY_KILL" then
|
||
-- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID)
|
||
return
|
||
end
|
||
end
|
||
_Learner.killDebounce = _Learner.killDebounce or {}
|
||
_Learner.killDebounce[dstGUID] = { ts = now, eventType = eventType }
|
||
-- Prune entries older than 10 seconds to keep the table bounded
|
||
for g, entry in pairs(_Learner.killDebounce) do
|
||
local ts = type(entry) == "table" and entry.ts or entry
|
||
if (now - ts) > 10 then
|
||
_Learner.killDebounce[g] = nil
|
||
end
|
||
end
|
||
-- Prune stale engagement entries (mobs we damaged but never finished).
|
||
if _Learner.playerEngaged then
|
||
for g, ts in pairs(_Learner.playerEngaged) do
|
||
if (now - ts) > 60 then
|
||
_Learner.playerEngaged[g] = nil
|
||
end
|
||
end
|
||
end
|
||
|
||
local npcId = self:ResolveNpcIdFromGuidAndName(dstGUID, dstName)
|
||
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 ""
|
||
-- Keep the credited flag for quest-progress correlation, but do not use it
|
||
-- to suppress learning. The learner should still harvest kill data from
|
||
-- nearby players so static import coverage stays as complete as possible.
|
||
local engagedTs = _Learner.playerEngaged and _Learner.playerEngaged[dstGUID]
|
||
local credited = (eventType == "PARTY_KILL")
|
||
or (engagedTs ~= nil and (now - engagedTs) <= 60)
|
||
_Learner.recentKills[dstGUID] = {
|
||
npcId = npcId,
|
||
name = name or "",
|
||
x = px,
|
||
y = py,
|
||
zoneId = zoneId,
|
||
zone = zoneText,
|
||
ts = time(),
|
||
credited = credited,
|
||
}
|
||
|
||
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)
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Combat-log learned NPC:", eventType, npcId, name or "?")
|
||
|
||
-- Phase 2: store per-GUID spawn evidence for weighted merge
|
||
self:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, px, py)
|
||
local guidSpawnsAfterStore = Questie.dbLearner.global.npcs[npcId]
|
||
and Questie.dbLearner.global.npcs[npcId][8]
|
||
if guidSpawnsAfterStore then
|
||
local guidCount = 0
|
||
for _ in pairs(guidSpawnsAfterStore) do guidCount = guidCount + 1 end
|
||
-- Questie:Debug(Questie.DEBUG_LEARNER,
|
||
-- "[QuestieLearner] GUID spawn evidence stored:",
|
||
-- npcId, dstName or name or "?",
|
||
-- "guidCount", guidCount,
|
||
-- "zone", tostring(zoneId),
|
||
-- "x", tostring(px),
|
||
-- "y", tostring(py))
|
||
else
|
||
-- Questie:Debug(Questie.DEBUG_LEARNER,
|
||
-- "[QuestieLearner] GUID spawn evidence missing after store:",
|
||
-- npcId, dstName or name or "?",
|
||
-- "zone", tostring(zoneId),
|
||
-- "x", tostring(px),
|
||
-- "y", tostring(py))
|
||
end
|
||
|
||
-- Phase 3: weighted merge when evidence count is sufficient.
|
||
-- Temporarily lowered to 1 for Sunstrider/Mana Wyrm diagnostics so we can
|
||
-- verify the promotion path immediately.
|
||
local guidSpawns = Questie.dbLearner.global.npcs[npcId]
|
||
and Questie.dbLearner.global.npcs[npcId][8]
|
||
if guidSpawns then
|
||
local count = 0
|
||
for _ in pairs(guidSpawns) do count = count + 1 end
|
||
if self:IsLearnerLiveEnabled() and count >= 1 then
|
||
_MergeSpawnEvidence(npcId)
|
||
end
|
||
end
|
||
|
||
-- TTL cleanup: drop entries older than 10 minutes
|
||
local now = time()
|
||
for g, entry in pairs(_Learner.recentKills) do
|
||
if (now - (entry.ts or 0)) > 600 then
|
||
_Learner.recentKills[g] = nil
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Periodic cleanup for guidNpcCache to prevent unbounded growth
|
||
function QuestieLearner:PruneGuidNpcCache()
|
||
if not _Learner.guidNpcCache then return end
|
||
local now = time()
|
||
local count = 0
|
||
-- Prune entries older than 2 hours. This is used for combat log correlation
|
||
-- and doesn't need to persist indefinitely.
|
||
for guid, entry in pairs(_Learner.guidNpcCache) do
|
||
if entry.ts and (now - entry.ts) > 7200 then
|
||
_Learner.guidNpcCache[guid] = nil
|
||
count = count + 1
|
||
end
|
||
end
|
||
if count > 0 then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Pruned", count, "entries from guidNpcCache")
|
||
end
|
||
end
|
||
|
||
--- Collect coord keys from a spawn table into a sequential array.
|
||
--- Uses a two-pass pattern so deletion always happens after traversal.
|
||
---@param spawnTable table The [zoneId] sub-table containing learned coords
|
||
---@return table, number Array of keys to remove, count of keys
|
||
--- Prune wildly outlying learned spawn coords for a single NPC+zone entry.
|
||
--- Only operates on QuestieLearner learned data (dbLearner.global.npcs / .objects).
|
||
--- Never deletes static DB or AscensionDB spawns.
|
||
--- Requires at least 4 learned points before evaluating.
|
||
--- Prunes in a second pass after identification to avoid mid-iteration deletions.
|
||
---@param spawnTable table The [zoneId] sub-table containing learned coords
|
||
---@param zoneId number The zone being evaluated
|
||
---@param threshold number Max percent-distance from cluster median before pruning (default 15)
|
||
---@return boolean True if any points were removed
|
||
local function _PruneSpawnOutliers(spawnTable, zoneId, threshold)
|
||
threshold = threshold or 15
|
||
if not spawnTable then return false end
|
||
|
||
-- Pass 1: collect all points into a flat array
|
||
local learnedPoints = {}
|
||
local n = 0
|
||
local coordKey, coord = next(spawnTable)
|
||
while coordKey do
|
||
if type(coord) == "table" and coord[1] and coord[2] then
|
||
n = n + 1
|
||
learnedPoints[n] = { coord[1], coord[2] }
|
||
end
|
||
coordKey, coord = next(spawnTable, coordKey)
|
||
end
|
||
|
||
if n < 4 then return false end
|
||
|
||
-- Compute median x and y via insertion sort (Lua 5.0-safe)
|
||
local sortedX = {}
|
||
local sortedY = {}
|
||
for i = 1, n do
|
||
sortedX[i] = learnedPoints[i][1]
|
||
sortedY[i] = learnedPoints[i][2]
|
||
end
|
||
for i = 2, n do
|
||
local key = sortedX[i]
|
||
local j = i - 1
|
||
while j >= 1 and sortedX[j] > key do
|
||
sortedX[j + 1] = sortedX[j]
|
||
j = j - 1
|
||
end
|
||
sortedX[j + 1] = key
|
||
end
|
||
for i = 2, n do
|
||
local key = sortedY[i]
|
||
local j = i - 1
|
||
while j >= 1 and sortedY[j] > key do
|
||
sortedY[j + 1] = sortedY[j]
|
||
j = j - 1
|
||
end
|
||
sortedY[j + 1] = key
|
||
end
|
||
local medianX = sortedX[floor(n / 2) + 1]
|
||
local medianY = sortedY[floor(n / 2) + 1]
|
||
|
||
-- Compute mean absolute deviation from median for each axis
|
||
local devX, devY = 0, 0
|
||
for i = 1, n do
|
||
devX = devX + abs(learnedPoints[i][1] - medianX)
|
||
devY = devY + abs(learnedPoints[i][2] - medianY)
|
||
end
|
||
devX = devX / n
|
||
devY = devY / n
|
||
|
||
-- Dynamic axis threshold: 3x mean absolute deviation, floored to threshold
|
||
local pruneX = devX * 3
|
||
local pruneY = devY * 3
|
||
if pruneX < threshold then pruneX = threshold end
|
||
if pruneY < threshold then pruneY = threshold end
|
||
|
||
-- Pass 2: identify outlier keys (collect before deleting)
|
||
local toRemove = {}
|
||
local rmCount = 0
|
||
coordKey, coord = next(spawnTable)
|
||
while coordKey do
|
||
if type(coord) == "table" and coord[1] and coord[2] then
|
||
local dx = abs(coord[1] - medianX)
|
||
local dy = abs(coord[2] - medianY)
|
||
if dx > pruneX or dy > pruneY then
|
||
rmCount = rmCount + 1
|
||
toRemove[rmCount] = coordKey
|
||
end
|
||
end
|
||
coordKey, coord = next(spawnTable, coordKey)
|
||
end
|
||
|
||
table.sort(toRemove, function(a, b)
|
||
if type(a) == "number" and type(b) == "number" then
|
||
return a > b
|
||
end
|
||
return tostring(a) > tostring(b)
|
||
end)
|
||
|
||
-- Pass 3: delete in second pass (no mid-iteration table mutation).
|
||
-- Numeric spawn arrays must be compacted so ipairs/# keep seeing later rows.
|
||
local removed = 0
|
||
for i = 1, rmCount do
|
||
local key = toRemove[i]
|
||
if type(key) == "number" then
|
||
table.remove(spawnTable, key)
|
||
else
|
||
spawnTable[key] = nil
|
||
end
|
||
removed = removed + 1
|
||
end
|
||
|
||
if removed > 0 then
|
||
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Pruned", removed, "outlier spawns in zone", zoneId)
|
||
end
|
||
return removed > 0
|
||
end
|
||
|
||
--- Prune outlier learned spawn data for all NPCs and objects.
|
||
--- Only touches dbLearner.global.npcs and dbLearner.global.objects (learned data).
|
||
--- Guards static data: if a static DB entry exists for the same NPC+zone, use its
|
||
--- centroid as the anchor and prune learned entries that deviate > threshold from it.
|
||
--- If no static anchor exists, use a learned-median cluster.
|
||
--- After pruning, re-injects cleaned data into live overrides so subsequent map/arrow
|
||
--- consumers see the corrected pins immediately.
|
||
--- Runs on learner import/save/cleanup — NOT on every map draw.
|
||
---@param threshold number Max percent-distance from anchor before pruning (default 15)
|
||
---@return boolean True if any data was changed
|
||
function QuestieLearner:PruneLearnedSpawnOutliers(threshold)
|
||
threshold = threshold or 15
|
||
|
||
local global = Questie.dbLearner and Questie.dbLearner.global
|
||
if not global then return false end
|
||
|
||
local anyChanged = false
|
||
|
||
-- ── NPCs ──────────────────────────────────────────────────────────────
|
||
local npcs = global.npcs
|
||
if npcs then
|
||
local npcId = next(npcs)
|
||
while npcId do
|
||
local entry = npcs[npcId]
|
||
local spawns = entry and entry[7]
|
||
if spawns then
|
||
local zoneId = next(spawns)
|
||
while zoneId do
|
||
local zoneSpawns = spawns[zoneId]
|
||
if zoneSpawns then
|
||
local changed = false
|
||
|
||
-- Check if static DB has anchors for this NPC+zone
|
||
local staticSpawns = nil
|
||
if QuestieDB and QuestieDB.QueryNPC then
|
||
staticSpawns = QuestieDB.QueryNPCSingle and QuestieDB.QueryNPCSingle(npcId, "spawns") or nil
|
||
end
|
||
|
||
if staticSpawns and staticSpawns[zoneId] then
|
||
-- Static anchor path: build centroid from static spawns
|
||
local sc = staticSpawns[zoneId]
|
||
local si, staticCoord = next(sc)
|
||
local sn = 0
|
||
local sumX, sumY = 0, 0
|
||
while si do
|
||
if type(staticCoord) == "table" and staticCoord[1] and staticCoord[2] then
|
||
sn = sn + 1
|
||
sumX = sumX + staticCoord[1]
|
||
sumY = sumY + staticCoord[2]
|
||
end
|
||
si, staticCoord = next(sc, si)
|
||
end
|
||
|
||
if sn > 0 then
|
||
local anchorX = sumX / sn
|
||
local anchorY = sumY / sn
|
||
|
||
-- Collect outlier keys first, delete second
|
||
local toRemove = {}
|
||
local rmCount = 0
|
||
local coordKey, coord = next(zoneSpawns)
|
||
while coordKey do
|
||
if type(coord) == "table" and coord[1] and coord[2] then
|
||
local dx = abs(coord[1] - anchorX)
|
||
local dy = abs(coord[2] - anchorY)
|
||
if dx > threshold or dy > threshold then
|
||
rmCount = rmCount + 1
|
||
toRemove[rmCount] = coordKey
|
||
end
|
||
end
|
||
coordKey, coord = next(zoneSpawns, coordKey)
|
||
end
|
||
table.sort(toRemove, function(a, b)
|
||
if type(a) == "number" and type(b) == "number" then
|
||
return a > b
|
||
end
|
||
return tostring(a) > tostring(b)
|
||
end)
|
||
for i = 1, rmCount do
|
||
local key = toRemove[i]
|
||
if type(key) == "number" then
|
||
table.remove(zoneSpawns, key)
|
||
else
|
||
zoneSpawns[key] = nil
|
||
end
|
||
changed = true
|
||
end
|
||
if changed then
|
||
Questie:Debug(Questie.DEBUG_INFO,
|
||
"[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_INFO, "[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_INFO, "[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
|
||
-- Only correlate kills credited to us; bystander kills
|
||
-- (nearby players) must never be learned as our objective.
|
||
if kill.credited and (now - kill.ts) <= 10 then
|
||
if not bestKill or kill.ts > bestKill.ts then
|
||
bestGuid, bestKill = guid, kill
|
||
end
|
||
end
|
||
end
|
||
if bestKill and bestKill.npcId then
|
||
local cleanText = objText:match("^(.-)%s*:") or (bestKill.name or "")
|
||
Questie:Debug(Questie.DEBUG_LEARNER,
|
||
"[QuestieLearner] Quest", questId, "obj", j,
|
||
"progressed — learning kill NPC:", bestKill.npcId, bestKill.name)
|
||
-- Pass exact kill coordinates so spawn list reflects NPC location, not player location
|
||
self:LearnNPC(bestKill.npcId, bestKill.name, nil, nil, nil, nil, bestKill.x, bestKill.y, bestKill.zoneId)
|
||
self:LearnQuestObjectiveNPC(questId, bestKill.npcId, cleanText, j)
|
||
_Learner.recentKills[bestGuid] = nil
|
||
end
|
||
_Learner.prevObjCounts[questId][j] = count
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Real-time tooltip for learned spawns
|
||
-- Hooks into GameTooltip to show "Learned spawn: (x, y) from N kills"
|
||
-- when hovering over an NPC that has been learned.
|
||
------------------------------------------------------------------------
|
||
|
||
local _tooltipHookRegistered = false
|
||
|
||
--- Adds a "Learned spawn: (x, y) from N kills" line to the tooltip
|
||
--- for the NPC represented by the given unit token.
|
||
--- Called by the GameTooltip OnTooltipSetUnit hook.
|
||
---@param unitToken string WoW unit token (e.g. "mouseover")
|
||
local function _AddLearnedSpawnTooltipLine(unitToken)
|
||
if not Questie or not Questie.dbLearner then return end
|
||
local guid = UnitGUID(unitToken)
|
||
if not guid then return end
|
||
|
||
local npcId, guidType = GetIdAndTypeFromGUID(guid)
|
||
if guidType ~= "Creature" and guidType ~= "Vehicle" then return end
|
||
if not npcId then return end
|
||
|
||
local entry = Questie.dbLearner.global.npcs[npcId]
|
||
if not entry or not entry[7] then return end
|
||
|
||
-- Find the first zone with spawn data
|
||
local spawnsByZone = entry[7]
|
||
local zoneId = next(spawnsByZone)
|
||
if not zoneId then return end
|
||
|
||
local zoneSpawns = spawnsByZone[zoneId]
|
||
if not zoneSpawns or #zoneSpawns == 0 then return end
|
||
|
||
-- Use the first recorded coordinate
|
||
local x = zoneSpawns[1][1]
|
||
local y = zoneSpawns[1][2]
|
||
local kills = entry.mc or 0
|
||
|
||
local formattedX = ("%.1f"):format(x)
|
||
local formattedY = ("%.1f"):format(y)
|
||
GameTooltip:AddDoubleLine("Learned spawn", ("(%s, %s) from %d kill%s"):format(
|
||
formattedX, formattedY, kills, kills == 1 and "" or "s"))
|
||
end
|
||
|
||
--- Registers the GameTooltip OnTooltipSetUnit hook once.
|
||
--- Safe to call multiple times; guard prevents double-hook.
|
||
local function _RegisterLearnedSpawnTooltipHook()
|
||
if _tooltipHookRegistered then return end
|
||
_tooltipHookRegistered = true
|
||
GameTooltip:HookScript("OnTooltipSetUnit", function()
|
||
-- HookScript handlers do not reliably receive the frame as an argument on 3.3.5a.
|
||
local _, unitToken = GameTooltip:GetUnit()
|
||
if unitToken then
|
||
_AddLearnedSpawnTooltipLine(unitToken)
|
||
end
|
||
end)
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Event registration
|
||
------------------------------------------------------------------------
|
||
|
||
function QuestieLearner:RegisterEvents()
|
||
local frame = CreateFrame("Frame", "QuestieLearnerFrame")
|
||
|
||
frame:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
|
||
frame:RegisterEvent("PLAYER_TARGET_CHANGED")
|
||
frame:RegisterEvent("QUEST_DETAIL")
|
||
frame:RegisterEvent("QUEST_COMPLETE")
|
||
frame:RegisterEvent("QUEST_TURNED_IN")
|
||
frame:RegisterEvent("QUEST_ACCEPTED")
|
||
frame:RegisterEvent("LOOT_OPENED")
|
||
frame:RegisterEvent("GOSSIP_SHOW")
|
||
frame:RegisterEvent("GAMEOBJECT_USED")
|
||
frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
|
||
frame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
|
||
frame:RegisterEvent("UNIT_QUEST_LOG_CHANGED")
|
||
frame:RegisterEvent("QUEST_REMOVED")
|
||
|
||
frame:SetScript("OnEvent", function(_, event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
|
||
if event == "UPDATE_MOUSEOVER_UNIT" then
|
||
self:OnMouseoverUnit()
|
||
elseif event == "PLAYER_TARGET_CHANGED" then
|
||
self:OnTargetChanged()
|
||
elseif event == "QUEST_DETAIL" then
|
||
self:OnQuestDetail()
|
||
elseif event == "QUEST_COMPLETE" then
|
||
self:OnQuestComplete()
|
||
elseif event == "QUEST_TURNED_IN" then
|
||
self:OnQuestTurnedIn(arg1, arg2, arg3)
|
||
elseif event == "QUEST_ACCEPTED" then
|
||
self:OnQuestAccepted(arg1, arg2)
|
||
elseif event == "LOOT_OPENED" then
|
||
self:OnLootOpened()
|
||
elseif event == "GOSSIP_SHOW" then
|
||
self:OnGossipShow()
|
||
elseif event == "GAMEOBJECT_USED" then
|
||
self:OnGameObjectUsed(arg1)
|
||
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:ApplyDataSourceMode()
|
||
_RegisterLearnedSpawnTooltipHook()
|
||
|
||
local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms")
|
||
if QuestieLearnerComms and QuestieLearnerComms.Initialize then
|
||
QuestieLearnerComms:Initialize()
|
||
end
|
||
|
||
-- Start periodic cleanup ticker (every 30 mins)
|
||
QuestieCompat.C_Timer.NewTicker(1800, function()
|
||
self:PruneGuidNpcCache()
|
||
end)
|
||
|
||
-- Run outlier pruning once at startup after data is loaded and DB is ready
|
||
self:PruneLearnedSpawnOutliers()
|
||
|
||
-- Scan existing quests in log after initialization (deferred to ensure DB is ready)
|
||
QuestieCompat.C_Timer.After(1, function()
|
||
self:ScanExistingQuestLog()
|
||
end)
|
||
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Initialized")
|
||
end
|
||
|
||
function QuestieLearner:ScanExistingQuestLog()
|
||
if not self:IsEnabled() then return end
|
||
if not Questie.dbLearner.global.settings.learnQuests then return end
|
||
if not GetNumQuestLogEntries then return end
|
||
|
||
Questie:Debug(Questie.DEBUG_INFO, "[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_INFO, "[QuestieLearner] Scanned existing quest", questId, "objective", j, "to NPC", npcId, "(" .. targetName .. ")")
|
||
self:LearnQuestObjectiveNPC(questId, npcId, objText, j)
|
||
count = count + 1
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
if count > 0 then
|
||
self:InjectLearnedData()
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Scanned existing quest log, mapped", count, "objectives")
|
||
end
|
||
end
|
||
|
||
------------------------------------------------------------------------
|
||
-- Network bridge
|
||
------------------------------------------------------------------------
|
||
|
||
--- Validates learned spawn data from external sources (comms, import).
|
||
--- Returns true if the data is safe to merge; false to reject silently.
|
||
--- Checks: data is a table, spawns[zoneId] keys are numeric, coordinates
|
||
--- in each zone are numbers within 0-100 range.
|
||
---@param data table The learned entity data table (e.g. NPC entry)
|
||
---@return boolean True if valid, false if malformed
|
||
local function _ValidateLearnedSpawnData(data)
|
||
if type(data) ~= "table" then return false end
|
||
|
||
local spawns = data[7]
|
||
if not spawns then return true end -- no spawn data is OK
|
||
if type(spawns) ~= "table" then return false end
|
||
|
||
for zoneId, zoneSpawns in pairs(spawns) do
|
||
if type(zoneId) ~= "number" then return false end
|
||
if type(zoneSpawns) ~= "table" then return false end
|
||
for _, coord in ipairs(zoneSpawns) do
|
||
if type(coord) ~= "table" then return false end
|
||
local x, y = coord[1], coord[2]
|
||
if type(x) ~= "number" or type(y) ~= "number" then return false end
|
||
if x < 0 or x > 100 or y < 0 or y > 100 then return false end
|
||
end
|
||
end
|
||
return true
|
||
end
|
||
|
||
function _Learner:BroadcastIfCommsAvailable(typ, id, data)
|
||
if Questie and Questie.db and Questie.db.profile and Questie.db.profile.learnerBroadcast == false then
|
||
return
|
||
end
|
||
if GetLearnerSetting("learnerCommsIntensity", "normal") == "off" then
|
||
return
|
||
end
|
||
|
||
local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms")
|
||
if not (QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData) then
|
||
return
|
||
end
|
||
|
||
_Learner.pendingBroadcasts = _Learner.pendingBroadcasts or {}
|
||
local key = typ .. ":" .. tostring(id)
|
||
local op = (data.mc and data.mc > 1) and "UPDATE" or "NEW"
|
||
local existingPending = _Learner.pendingBroadcasts[key]
|
||
_Learner.pendingBroadcasts[key] = {
|
||
typ = typ,
|
||
id = id,
|
||
data = data,
|
||
-- Preserve the first-discovery signal while still sending the latest
|
||
-- coalesced payload for the entity.
|
||
op = (existingPending and existingPending.op == "NEW") and "NEW" or op,
|
||
}
|
||
|
||
if _Learner.pendingBroadcastTimer then return end
|
||
|
||
local timer = QuestieCompat and QuestieCompat.C_Timer
|
||
local function FlushBroadcasts()
|
||
local pending = _Learner.pendingBroadcasts
|
||
_Learner.pendingBroadcasts = {}
|
||
_Learner.pendingBroadcastTimer = nil
|
||
|
||
local comms = QuestieLoader:ImportModule("QuestieLearnerComms")
|
||
if not (comms and comms.BroadcastLearnedData) then return end
|
||
|
||
for _, entry in pairs(pending) do
|
||
comms:BroadcastLearnedData(entry.op, entry.typ, entry.id, entry.data)
|
||
end
|
||
end
|
||
|
||
if timer and timer.After then
|
||
_Learner.pendingBroadcastTimer = true
|
||
local delay = 2
|
||
local intensity = GetLearnerSetting("learnerCommsIntensity", "normal")
|
||
if intensity == "low" then
|
||
delay = 4
|
||
elseif intensity == "fast" then
|
||
delay = 1
|
||
end
|
||
timer.After(delay, FlushBroadcasts)
|
||
else
|
||
FlushBroadcasts()
|
||
end
|
||
end
|
||
|
||
local function _QueueIncomingNetworkMerge(typ, id, data, op)
|
||
_Learner.pendingNetworkMerges = _Learner.pendingNetworkMerges or {}
|
||
local key = typ .. ":" .. tostring(id)
|
||
_Learner.pendingNetworkMerges[key] = {
|
||
typ = typ,
|
||
id = id,
|
||
data = data,
|
||
op = op,
|
||
}
|
||
|
||
if _Learner.pendingNetworkMergeTimer then return end
|
||
|
||
local timer = QuestieCompat and QuestieCompat.C_Timer
|
||
local function FlushNetworkMerges()
|
||
local pending = _Learner.pendingNetworkMerges
|
||
_Learner.pendingNetworkMerges = {}
|
||
_Learner.pendingNetworkMergeTimer = nil
|
||
|
||
local anyChanged = false
|
||
for _, entry in pairs(pending) do
|
||
local changed = QuestieLearner:_ApplyIncomingNetworkMerge(entry.typ, entry.id, entry.data, entry.op)
|
||
anyChanged = anyChanged or changed
|
||
end
|
||
|
||
if anyChanged then
|
||
QuestieLearner:InjectLearnedData()
|
||
QuestieLearner.data = Questie.dbLearner.global
|
||
end
|
||
end
|
||
|
||
if timer and timer.After then
|
||
_Learner.pendingNetworkMergeTimer = true
|
||
timer.After(GetLearnerSetting("liveNpcUpdateDelay", 0.75), FlushNetworkMerges)
|
||
else
|
||
FlushNetworkMerges()
|
||
end
|
||
end
|
||
|
||
-- Receives validated, decoded data from QuestieLearnerComms or QuestieLearnerExport:MergeImport
|
||
function QuestieLearner:HandleNetworkData(typ, id, d, op)
|
||
if not self:IsEnabled() then return end
|
||
if not EnsureLearnedData() then return end
|
||
if not typ or not id or not d then return end
|
||
|
||
_QueueIncomingNetworkMerge(typ, id, d, op)
|
||
end
|
||
|
||
function QuestieLearner:_ApplyIncomingNetworkMerge(typ, id, d, op)
|
||
if not typ or not id or not d then return false end
|
||
|
||
local store
|
||
if typ == "NPC" then
|
||
if not Questie.dbLearner.global.settings.learnNpcs then return false end
|
||
store = Questie.dbLearner.global.npcs
|
||
elseif typ == "QUEST" then
|
||
if not Questie.dbLearner.global.settings.learnQuests then return false end
|
||
store = Questie.dbLearner.global.quests
|
||
elseif typ == "ITEM" then
|
||
if not Questie.dbLearner.global.settings.learnItems then return false end
|
||
store = Questie.dbLearner.global.items
|
||
elseif typ == "OBJECT" then
|
||
if not Questie.dbLearner.global.settings.learnObjects then return false end
|
||
store = Questie.dbLearner.global.objects
|
||
else
|
||
return false
|
||
end
|
||
|
||
-- Validate external data before merging to prevent crash on malformed input
|
||
if not _ValidateLearnedSpawnData(d) then
|
||
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Rejected malformed network data", typ, id)
|
||
return false
|
||
end
|
||
|
||
local existing = store[id]
|
||
if not existing then
|
||
store[id] = d
|
||
store[id].mc = 1
|
||
if typ == "NPC" and type(d[1]) == "string" and d[1] ~= "" then
|
||
_MarkNpcNameIndexDirty()
|
||
end
|
||
return true
|
||
end
|
||
|
||
local changed = false
|
||
-- Merge: adopt non-nil fields we don't have locally
|
||
for k, v in pairs(d) do
|
||
if k ~= "mc" and existing[k] == nil then
|
||
existing[k] = v
|
||
changed = true
|
||
if typ == "NPC" and k == 1 and type(v) == "string" and v ~= "" then
|
||
_MarkNpcNameIndexDirty()
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Merge coordinates
|
||
local coordKey = (typ == "NPC") and 7 or (typ == "OBJECT" and 4 or nil)
|
||
if coordKey and type(d[coordKey]) == "table" then
|
||
existing[coordKey] = existing[coordKey] or {}
|
||
local grid = GetCustomGridPrecision()
|
||
for zoneId, coords in pairs(d[coordKey]) do
|
||
existing[coordKey][zoneId] = existing[coordKey][zoneId] or {}
|
||
for _, coord in ipairs(coords) do
|
||
if InsertIfNewBucket(existing[coordKey][zoneId], coord[1], coord[2], grid) then
|
||
changed = true
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Merge item drop list
|
||
if typ == "ITEM" and type(d[2]) == "table" then
|
||
existing[2] = existing[2] or {}
|
||
for _, npcId in ipairs(d[2]) do
|
||
local found = false
|
||
for _, existId in ipairs(existing[2]) do
|
||
if existId == npcId then found = true; break end
|
||
end
|
||
if not found then
|
||
table.insert(existing[2], npcId)
|
||
changed = true
|
||
end
|
||
end
|
||
end
|
||
|
||
if changed or (op == "NEW" or op == "UPDATE") then
|
||
existing.ls = time() -- Refresh timestamp on network confirmation
|
||
existing.mc = (existing.mc or 0) + 1
|
||
return true
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
return QuestieLearner
|