feat: QuestieLearner comprehensive overhaul v1.2.6

- Full quest field capture (title, level, objectives, description, money, zone)
- Mouseover filter: only record NPCs with QUESTGIVER npcFlag (0x02) or known DB entries
- OnTargetChanged: cache GUID for kill tracking only, no more spurious LearnNPC calls
- Grid-bucket coordinate clustering (COORD_GRID=2.0) replaces naive radius dedup
- Object recording: detect GameObject loot, gossip, and quest giver/finisher
- Item loot: async GetItemInfo retry via GET_ITEM_INFO_RECEIVED event
- LearnQuestGiver: entityType parameter (1=NPC,2=obj,3=item) for correct wiki layout
- InjectLearnedData: uses grid clustering in all merge paths
- MergeImport: calls InjectLearnedData immediately after merge (live override injection)
- README: corrected import reload requirement description
- CHANGELOG + docs/changelog.html updated with developer detail
This commit is contained in:
Xurkon
2026-03-16 20:49:02 -05:00
parent 46a3476884
commit 8bc74a488b
5 changed files with 502 additions and 278 deletions
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## v1.2.6 — QuestieLearner Comprehensive Overhaul
> Rewrote QuestieLearner from scratch to fix all known data-capture deficiencies. Adds full quest field coverage, grid-based coordinate clustering, quest-giver-only mouseover filtering, async item-info retry, object loot detection, and live inject-on-import so imported data takes effect without a reload.
### QuestieLearner.lua — Full Rewrite
- **[Quest capture completeness]** `LearnQuest` now accepts a generic `data` table keyed by Questie wiki array indices rather than individual positional arguments. `OnQuestDetail` captures title, objectives text block, quest description body, and current zone as `zoneOrSort[8]`. `OnQuestAccepted` fills quest level from `GetQuestLogTitle`, per-objective text from `GetQuestLogLeaderBoard`, and required money from `GetQuestLogRequiredMoney`. `OnQuestComplete` captures finish/reward text via `GetRewardText`.
- **[Mouseover filter]** `OnMouseoverUnit` now only learns an NPC if its `UnitNPCFlags` bitmask includes the `QUESTGIVER` bit (`0x02`), OR if the NPC already exists in `QuestieDB` as a known quest starter/finisher. All other NPCs are silently ignored — this eliminates the flood of irrelevant NPC entries the learner previously accumulated.
- **[Target changed]** `OnTargetChanged` no longer calls `LearnNPC` on every target switch. It now only populates the `guidNpcCache` for kill-tracking purposes, avoiding recording non-quest NPCs.
- **[Coordinate clustering — grid bucketing]** Replaced the naive "within 1 unit radius" deduplication with a 2×2 grid bucket scheme (`COORD_GRID = 2.0`). A new point is inserted only when no existing point shares the same grid cell. This prevents coordinate scatter across a kill area while still preserving distinct spawn clusters. The `InsertIfNewBucket` helper is shared across NPC, Object, and all merge paths (including `InjectLearnedData` and `HandleNetworkData`).
- **[Object recording]** `OnLootOpened` now checks whether the loot source GUID is a `GameObject` (not just a creature). If so, `LearnObject` is called with the object's ID and name. `OnGossipShow` records both objects and NPCs via `GetIdAndTypeFromGUID`. `OnQuestDetail` and `OnQuestComplete` also record the interacting object when the quest giver/finisher is a `GameObject`.
- **[Item loot — async GetItemInfo retry]** `OnLootOpened` queues unresolved item links (where `GetItemInfo` returns nil because the item is not yet in the client cache) into `_Learner.pendingItemLinks`. A new `OnGetItemInfoReceived(itemId)` handler fires on the `GET_ITEM_INFO_RECEIVED` event, resolves queued links for that item ID, and calls `LearnItem`/`LearnItemDrop` once the data is available. This fixes silent data loss on first-encounter loots.
- **[Quest giver entity type]** `LearnQuestGiver` now accepts an `entityType` argument (1=NPC, 2=GameObject, 3=item) and stores starters/finishers in the correct sub-array slot matching the Questie wiki spec `{ [1]={npcIds}, [2]={objIds}, [3]={itemIds} }`.
- **[Kill tracking fallback]** `OnCombatLogEvent` retains all three GUID-resolution paths (dash-split, GUID cache, hex-prefix) with a 10-minute TTL cache cleanup. Uses `CombatLogGetCurrentEventInfo()` with fallback to varargs for cross-client compatibility.
- **[GET_ITEM_INFO_RECEIVED event]** Registered in `RegisterEvents` so the async item retry path fires correctly.
- **[InjectLearnedData — grid clustering]** All coordinate merge loops in `InjectLearnedData` now use `InsertIfNewBucket` instead of the old radius check.
### QuestieLearnerExport.lua — Import Live-Inject Fix
- **[MergeImport → InjectLearnedData]** After `MergeType` completes for all four categories, `MergeImport` now immediately calls `QuestieLearner:InjectLearnedData()`. Imported data is pushed into `QuestieDB.*DataOverrides` in the same frame — override-driven map pins update without a `/reload`. A full reload is still needed to pick up newly imported quest starters/finishers for quests already tracked in the player's quest log.
### README — Import Clarification
- Corrected the "no reload required" claim. The import flow now accurately describes when an immediate effect is visible versus when a `/reload` is beneficial.
---
## v1.2.5 — Ebonhold DB Plugin Load Fix ## v1.2.5 — Ebonhold DB Plugin Load Fix
> Fixed a fatal load-time crash in all four Ebonhold DB files caused by calling `GetRealmName()` and `QuestieLoader:CreateModule()` at file scope (before WoW's API is fully available). Switched to plain global table population; realm-gating and injection remain safely deferred to `EbonholdLoader.lua`'s `PLAYER_LOGIN` handler. > Fixed a fatal load-time crash in all four Ebonhold DB files caused by calling `GetRealmName()` and `QuestieLoader:CreateModule()` at file scope (before WoW's API is fully available). Switched to plain global table population; realm-gating and injection remain safely deferred to `EbonholdLoader.lua`'s `PLAYER_LOGIN` handler.
+414 -251
View File
@@ -8,29 +8,74 @@ local _Learner = QuestieLearner.private or {}
QuestieLearner.private = _Learner QuestieLearner.private = _Learner
local floor = math.floor local floor = math.floor
local abs = math.abs
-- NPC flags (WoW bitmask)
local NPC_FLAG_GOSSIP = 0x00000001
local NPC_FLAG_QUESTGIVER = 0x00000002
local NPC_FLAG_TRAINER = 0x00000010
local NPC_FLAG_VENDOR = 0x00000080
local NPC_FLAG_FLIGHTMASTER = 0x00000200
local NPC_FLAG_INNKEEPER = 0x00000800
local NPC_FLAG_BANKER = 0x00001000
local NPC_FLAG_AUCTIONEER = 0x00004000
local NPC_FLAG_STABLEMASTER = 0x00010000
-- Only cache/learn mouseover NPCs that carry one of these flags
local MOUSEOVER_LEARN_FLAGS = NPC_FLAG_QUESTGIVER
-- Coordinate grid cell size (in 0100 map units).
-- ~2 grid units ≈ 2% of zone width — keeps clusters tight without over-splitting.
local COORD_GRID = 2.0
_Learner.pendingNpcs = {} _Learner.pendingNpcs = {}
_Learner.pendingQuests = {} _Learner.pendingQuests = {}
_Learner.pendingItems = {} _Learner.pendingItems = {}
_Learner.pendingObjects = {} _Learner.pendingObjects = {}
_Learner.pendingItemLinks = {} -- queue for async GetItemInfo retries
-- Direct reference to learnedData, set on Initialize -- Direct reference to learnedData, set on Initialize
QuestieLearner.data = nil QuestieLearner.data = nil
------------------------------------------------------------------------
-- Coordinate helpers
------------------------------------------------------------------------
local function GetZoneId() local function GetZoneId()
local mapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") local mapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
if mapId then return mapId end if mapId then return mapId end
return GetRealZoneText() and select(8, GetInstanceInfo()) or 0 return select(8, GetInstanceInfo()) or 0
end end
local function GetPlayerCoords() local function GetPlayerCoords()
local x, y = GetPlayerMapPosition("player") local x, y = GetPlayerMapPosition("player")
if x and y and x > 0 and y > 0 then if x and y and x > 0 and y > 0 then
return floor(x * 100 * 100) / 100, floor(y * 100 * 100) / 100 -- Store in 0100 scale, 2-decimal precision
return floor(x * 10000) / 100, floor(y * 10000) / 100
end end
return nil, nil return nil, nil
end 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)
local bx, by = CoordBucket(x, y)
for _, coord in ipairs(coordList) do
local cx, cy = CoordBucket(coord[1], coord[2])
if cx == bx and cy == by then return false end
end
table.insert(coordList, {x, y})
return true
end
------------------------------------------------------------------------
-- Internal state guards
------------------------------------------------------------------------
local function EnsureLearnedData() local function EnsureLearnedData()
if not Questie.db then return false end if not Questie.db then return false end
Questie.db.global.learnedData = Questie.db.global.learnedData or { Questie.db.global.learnedData = Questie.db.global.learnedData or {
@@ -49,6 +94,10 @@ local function EnsureLearnedData()
return true return true
end end
------------------------------------------------------------------------
-- Public API
------------------------------------------------------------------------
function QuestieLearner:IsEnabled() function QuestieLearner:IsEnabled()
if not EnsureLearnedData() then return false end if not EnsureLearnedData() then return false end
return Questie.db.global.learnedData.settings.enabled return Questie.db.global.learnedData.settings.enabled
@@ -59,6 +108,10 @@ function QuestieLearner:GetSettings()
return Questie.db.global.learnedData.settings return Questie.db.global.learnedData.settings
end end
------------------------------------------------------------------------
-- NPC learning
------------------------------------------------------------------------
function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString) function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString)
if not self:IsEnabled() then return end if not self:IsEnabled() then return end
if not Questie.db.global.learnedData.settings.learnNpcs then return end if not Questie.db.global.learnedData.settings.learnNpcs then return end
@@ -83,19 +136,10 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
if subName and not existing[14] then existing[14] = subName end if subName and not existing[14] then existing[14] = subName end
if npcFlags and npcFlags > 0 and not existing[15] then existing[15] = npcFlags end if npcFlags and npcFlags > 0 and not existing[15] then existing[15] = npcFlags end
if x and y and zoneId then if x and y and zoneId and zoneId > 0 then
existing[7] = existing[7] or {} existing[7] = existing[7] or {}
existing[7][zoneId] = existing[7][zoneId] or {} existing[7][zoneId] = existing[7][zoneId] or {}
local found = false InsertIfNewBucket(existing[7][zoneId], x, y)
for _, coord in ipairs(existing[7][zoneId]) do
if math.abs(coord[1] - x) < 1 and math.abs(coord[2] - y) < 1 then
found = true
break
end
end
if not found then
table.insert(existing[7][zoneId], { x, y })
end
end end
existing.mc = (existing.mc or 0) + 1 existing.mc = (existing.mc or 0) + 1
@@ -104,7 +148,17 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
_Learner:BroadcastIfCommsAvailable("NPC", npcId, existing) _Learner:BroadcastIfCommsAvailable("NPC", npcId, existing)
end end
function QuestieLearner:LearnQuest(questId, name, questLevel, requiredLevel, zoneOrSort, objectives) ------------------------------------------------------------------------
-- 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 return end if not self:IsEnabled() then return end
if not Questie.db.global.learnedData.settings.learnQuests then return end if not Questie.db.global.learnedData.settings.learnQuests then return end
if not questId or questId <= 0 then return end if not questId or questId <= 0 then return end
@@ -115,28 +169,23 @@ function QuestieLearner:LearnQuest(questId, name, questLevel, requiredLevel, zon
Questie.db.global.learnedData.quests[questId] = existing Questie.db.global.learnedData.quests[questId] = existing
end end
if name and not existing[1] then existing[1] = name end for k, v in pairs(data) do
if requiredLevel and requiredLevel > 0 and not existing[4] then existing[4] = requiredLevel end if v ~= nil and v ~= "" and v ~= 0 and existing[k] == nil then
if questLevel and questLevel > 0 and not existing[5] then existing[5] = questLevel end existing[k] = v
if zoneOrSort and zoneOrSort ~= 0 and not existing[17] then existing[17] = zoneOrSort end
if objectives and not existing[8] then
if type(objectives) == "table" then
existing[8] = objectives
elseif type(objectives) == "string" then
existing[8] = { objectives }
end end
end end
existing.mc = (existing.mc or 0) + 1 existing.mc = (existing.mc or 0) + 1
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Learned Quest:", questId, name or "?") Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Learned Quest:", questId, existing[1] or "?")
_Learner:BroadcastIfCommsAvailable("QUEST", questId, existing) _Learner:BroadcastIfCommsAvailable("QUEST", questId, existing)
end end
function QuestieLearner:LearnQuestGiver(questId, npcId, isStart) -- 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 self:IsEnabled() then return end
if not Questie.db.global.learnedData.settings.learnQuests then return end if not Questie.db.global.learnedData.settings.learnQuests then return end
if not questId or questId <= 0 or not npcId or npcId <= 0 then return end if not questId or questId <= 0 or not entityId or entityId <= 0 then return end
local existing = Questie.db.global.learnedData.quests[questId] local existing = Questie.db.global.learnedData.quests[questId]
if not existing then if not existing then
@@ -144,29 +193,23 @@ function QuestieLearner:LearnQuestGiver(questId, npcId, isStart)
Questie.db.global.learnedData.quests[questId] = existing Questie.db.global.learnedData.quests[questId] = existing
end end
if isStart then -- Starters/finishers: { [1]={npcIds}, [2]={objIds}, [3]={itemIds} }
existing[2] = existing[2] or {} local field = isStart and 2 or 3
existing[2][1] = existing[2][1] or {} existing[field] = existing[field] or {}
local found = false -- entityType: 1=npc, 2=obj, 3=item
for _, id in ipairs(existing[2][1]) do local typeSlot = entityType or 1
if id == npcId then existing[field][typeSlot] = existing[field][typeSlot] or {}
found = true; break local list = existing[field][typeSlot]
end for _, id in ipairs(list) do
end if id == entityId then return end
if not found then table.insert(existing[2][1], npcId) end
else
existing[3] = existing[3] or {}
existing[3][1] = existing[3][1] or {}
local found = false
for _, id in ipairs(existing[3][1]) do
if id == npcId then
found = true; break
end
end
if not found then table.insert(existing[3][1], npcId) end
end end
table.insert(list, entityId)
end end
------------------------------------------------------------------------
-- Item learning
------------------------------------------------------------------------
function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemClass, itemSubClass) function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemClass, itemSubClass)
if not self:IsEnabled() then return end if not self:IsEnabled() then return end
if not Questie.db.global.learnedData.settings.learnItems then return end if not Questie.db.global.learnedData.settings.learnItems then return end
@@ -202,15 +245,16 @@ function QuestieLearner:LearnItemDrop(itemId, npcId)
end end
existing[2] = existing[2] or {} existing[2] = existing[2] or {}
local found = false
for _, id in ipairs(existing[2]) do for _, id in ipairs(existing[2]) do
if id == npcId then if id == npcId then return end
found = true; break
end end
end table.insert(existing[2], npcId)
if not found then table.insert(existing[2], npcId) end
end end
------------------------------------------------------------------------
-- Object learning
------------------------------------------------------------------------
function QuestieLearner:LearnObject(objectId, name) function QuestieLearner:LearnObject(objectId, name)
if not self:IsEnabled() then return end if not self:IsEnabled() then return end
if not Questie.db.global.learnedData.settings.learnObjects then return end if not Questie.db.global.learnedData.settings.learnObjects then return end
@@ -228,19 +272,10 @@ function QuestieLearner:LearnObject(objectId, name)
if name and not existing[1] then existing[1] = name 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 zoneId and zoneId > 0 and not existing[5] then existing[5] = zoneId end
if x and y and zoneId then if x and y and zoneId and zoneId > 0 then
existing[4] = existing[4] or {} existing[4] = existing[4] or {}
existing[4][zoneId] = existing[4][zoneId] or {} existing[4][zoneId] = existing[4][zoneId] or {}
local found = false InsertIfNewBucket(existing[4][zoneId], x, y)
for _, coord in ipairs(existing[4][zoneId]) do
if math.abs(coord[1] - x) < 1 and math.abs(coord[2] - y) < 1 then
found = true
break
end
end
if not found then
table.insert(existing[4][zoneId], { x, y })
end
end end
existing.mc = (existing.mc or 0) + 1 existing.mc = (existing.mc or 0) + 1
@@ -249,6 +284,10 @@ function QuestieLearner:LearnObject(objectId, name)
_Learner:BroadcastIfCommsAvailable("OBJECT", objectId, existing) _Learner:BroadcastIfCommsAvailable("OBJECT", objectId, existing)
end end
------------------------------------------------------------------------
-- InjectLearnedData — pushes learnedData into QuestieDB overrides
------------------------------------------------------------------------
function QuestieLearner:InjectLearnedData() function QuestieLearner:InjectLearnedData()
if not EnsureLearnedData() then return end if not EnsureLearnedData() then return end
@@ -266,16 +305,7 @@ function QuestieLearner:InjectLearnedData()
for zoneId, coords in pairs(data[7]) do for zoneId, coords in pairs(data[7]) do
existing[7][zoneId] = existing[7][zoneId] or {} existing[7][zoneId] = existing[7][zoneId] or {}
for _, coord in ipairs(coords) do for _, coord in ipairs(coords) do
local found = false InsertIfNewBucket(existing[7][zoneId], coord[1], coord[2])
for _, existCoord in ipairs(existing[7][zoneId]) do
if math.abs(existCoord[1] - coord[1]) < 1 and math.abs(existCoord[2] - coord[2]) < 1 then
found = true
break
end
end
if not found then
table.insert(existing[7][zoneId], coord)
end
end end
end end
end end
@@ -286,6 +316,13 @@ function QuestieLearner:InjectLearnedData()
if not QuestieDB.questDataOverrides[questId] then if not QuestieDB.questDataOverrides[questId] then
QuestieDB.questDataOverrides[questId] = data QuestieDB.questDataOverrides[questId] = data
questCount = questCount + 1 questCount = questCount + 1
else
local existing = QuestieDB.questDataOverrides[questId]
for k, v in pairs(data) do
if k ~= "mc" and existing[k] == nil then
existing[k] = v
end
end
end end
end end
@@ -307,16 +344,7 @@ function QuestieLearner:InjectLearnedData()
for zoneId, coords in pairs(data[4]) do for zoneId, coords in pairs(data[4]) do
existing[4][zoneId] = existing[4][zoneId] or {} existing[4][zoneId] = existing[4][zoneId] or {}
for _, coord in ipairs(coords) do for _, coord in ipairs(coords) do
local found = false InsertIfNewBucket(existing[4][zoneId], coord[1], coord[2])
for _, existCoord in ipairs(existing[4][zoneId]) do
if math.abs(existCoord[1] - coord[1]) < 1 and math.abs(existCoord[2] - coord[2]) < 1 then
found = true
break
end
end
if not found then
table.insert(existing[4][zoneId], coord)
end
end end
end end
end end
@@ -329,15 +357,19 @@ function QuestieLearner:InjectLearnedData()
end end
end end
------------------------------------------------------------------------
-- Stats / Export helpers
------------------------------------------------------------------------
function QuestieLearner:GetStats() function QuestieLearner:GetStats()
if not EnsureLearnedData() then return 0, 0, 0, 0 end if not EnsureLearnedData() then return 0, 0, 0, 0 end
local learned = Questie.db.global.learnedData local learned = Questie.db.global.learnedData
local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0 local n, q, i, o = 0, 0, 0, 0
for _ in pairs(learned.npcs) do npcCount = npcCount + 1 end for _ in pairs(learned.npcs) do n = n + 1 end
for _ in pairs(learned.quests) do questCount = questCount + 1 end for _ in pairs(learned.quests) do q = q + 1 end
for _ in pairs(learned.items) do itemCount = itemCount + 1 end for _ in pairs(learned.items) do i = i + 1 end
for _ in pairs(learned.objects) do objectCount = objectCount + 1 end for _ in pairs(learned.objects) do o = o + 1 end
return npcCount, questCount, itemCount, objectCount return n, q, i, o
end end
function QuestieLearner:ClearAllData() function QuestieLearner:ClearAllData()
@@ -349,16 +381,27 @@ function QuestieLearner:ClearAllData()
Questie:Print("Cleared all learned data.") Questie:Print("Cleared all learned data.")
end 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() function QuestieLearner:ExportData()
if not EnsureLearnedData() then return "" end if not EnsureLearnedData() then return "" end
local learned = Questie.db.global.learnedData local learned = Questie.db.global.learnedData
local lines = {} local lines = {}
table.insert(lines, "-- QuestieLearner Export") table.insert(lines, "-- QuestieLearner Export")
table.insert(lines, "-- NPCs: " .. select(1, self:GetStats())) local n, q, i, o = self:GetStats()
table.insert(lines, "-- Quests: " .. select(2, self:GetStats())) table.insert(lines, "-- NPCs: " .. n .. " Quests: " .. q .. " Items: " .. i .. " Objects: " .. o)
table.insert(lines, "-- Items: " .. select(3, self:GetStats()))
table.insert(lines, "-- Objects: " .. select(4, self:GetStats()))
table.insert(lines, "") table.insert(lines, "")
table.insert(lines, "QuestieLearnerExport = {") table.insert(lines, "QuestieLearnerExport = {")
table.insert(lines, " npcs = " .. self:SerializeTable(learned.npcs) .. ",") table.insert(lines, " npcs = " .. self:SerializeTable(learned.npcs) .. ",")
@@ -366,34 +409,12 @@ function QuestieLearner:ExportData()
table.insert(lines, " items = " .. self:SerializeTable(learned.items) .. ",") table.insert(lines, " items = " .. self:SerializeTable(learned.items) .. ",")
table.insert(lines, " objects = " .. self:SerializeTable(learned.objects) .. ",") table.insert(lines, " objects = " .. self:SerializeTable(learned.objects) .. ",")
table.insert(lines, "}") table.insert(lines, "}")
return table.concat(lines, "\n") return table.concat(lines, "\n")
end end
function QuestieLearner:SerializeTable(t, indent) ------------------------------------------------------------------------
indent = indent or "" -- GUID parsing
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, indent .. " "))
end
return "{" .. table.concat(parts, ",") .. "}"
end
local CREATURE_HEX_PREFIXES = {
["F130"] = true, -- Creature
["F131"] = true, -- Vehicle
["F110"] = true, -- Pet
["F111"] = true, -- Pet
}
local HEX_PREFIXES = { local HEX_PREFIXES = {
["F130"] = "Creature", ["F130"] = "Creature",
@@ -403,21 +424,25 @@ local HEX_PREFIXES = {
["F111"] = "Creature", ["F111"] = "Creature",
} }
local CREATURE_HEX_PREFIXES = { ["F130"]=true, ["F131"]=true, ["F110"]=true, ["F111"]=true }
local function GetIdAndTypeFromGUID(guid) local function GetIdAndTypeFromGUID(guid)
if not guid then return nil, nil end if not guid then return nil, nil end
-- Modern dash-separated GUID (e.g. "Creature-0-3726-0-189-5638296-...")
local unitType, _, _, _, _, parsedId = strsplit("-", guid) local unitType, _, _, _, _, parsedId = strsplit("-", guid)
local id = tonumber(parsedId) local id = tonumber(parsedId)
if id and id > 0 and unitType then if id and id > 0 and unitType then
return id, unitType return id, unitType
end end
-- Legacy hex GUID
if string.sub(guid, 1, 2) == "0x" and string.len(guid) >= 18 then if string.sub(guid, 1, 2) == "0x" and string.len(guid) >= 18 then
local prefix = string.upper(string.sub(guid, 3, 6)) local prefix = string.upper(string.sub(guid, 3, 6))
local unitType = HEX_PREFIXES[prefix] local t = HEX_PREFIXES[prefix]
if unitType then if t then
local low32 = tonumber(string.sub(guid, 11, 18), 16) local low32 = tonumber(string.sub(guid, 11, 18), 16)
if low32 then if low32 then
local id = math.mod(low32, 8388608) local nid = math.mod(low32, 8388608)
if id > 0 then return id, unitType end if nid > 0 then return nid, t end
end end
end end
end end
@@ -436,84 +461,21 @@ local function GetObjectIdFromGUID(guid)
return nil return nil
end end
function QuestieLearner:RegisterEvents() -- Expose for use in event handlers below
local frame = CreateFrame("Frame", "QuestieLearnerFrame") _Learner.GetNpcIdFromGUID = GetNpcIdFromGUID
_Learner.GetObjectIdFromGUID = GetObjectIdFromGUID
_Learner.GetIdAndTypeFromGUID = GetIdAndTypeFromGUID
frame:RegisterEvent("UPDATE_MOUSEOVER_UNIT") ------------------------------------------------------------------------
frame:RegisterEvent("PLAYER_TARGET_CHANGED") -- Event handlers
frame:RegisterEvent("QUEST_DETAIL") ------------------------------------------------------------------------
frame:RegisterEvent("QUEST_COMPLETE")
frame:RegisterEvent("QUEST_ACCEPTED")
frame:RegisterEvent("LOOT_OPENED")
frame:RegisterEvent("GOSSIP_SHOW")
frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
frame:SetScript("OnEvent", function(_, event, ...) -- Checks whether an NPC (by npcFlags bitmask) should be learned on mouseover.
if event == "UPDATE_MOUSEOVER_UNIT" then -- Only quest givers and turn-in NPCs are relevant for the learner.
self:OnMouseoverUnit() local function NpcFlagsHasQuestGiver(flags)
elseif event == "PLAYER_TARGET_CHANGED" then if not flags then return false end
self:OnTargetChanged() -- bitwise AND for Lua 5.1 (no bit library guaranteed)
elseif event == "QUEST_DETAIL" then return math.floor(flags / NPC_FLAG_QUESTGIVER) % 2 == 1
self:OnQuestDetail()
elseif event == "QUEST_COMPLETE" then
self:OnQuestComplete()
elseif event == "QUEST_ACCEPTED" then
self:OnQuestAccepted(...)
elseif event == "LOOT_OPENED" then
self:OnLootOpened()
elseif event == "GOSSIP_SHOW" then
self:OnGossipShow()
elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
self:OnCombatLogEvent(...)
end
end)
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Events registered")
end
function QuestieLearner:OnCombatLogEvent(timestamp, event, sourceGUID, sourceName, sourceFlags, destGUID, destName,
destFlags, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24, a25)
if event == "UNIT_DIED" and destGUID then
local npcId = nil
-- Path 1: extract from GUID directly (handles both dash and hex formats)
npcId = GetNpcIdFromGUID(destGUID)
-- Path 2: GUID-keyed cache populated by OnTargetChanged / OnMouseoverUnit.
if not npcId and _Learner.guidNpcCache then
local cached = _Learner.guidNpcCache[destGUID]
if cached then
npcId = cached.npcId
end
end
-- Path 3: hex prefix + name scan for untargeted creatures not in cache.
if not npcId and destGUID and string.match(destGUID, "^0x") then
local prefix = string.upper(string.sub(destGUID, 3, 6))
if CREATURE_HEX_PREFIXES[prefix] and _Learner.guidNpcCache then
for _, cached in pairs(_Learner.guidNpcCache) do
if cached.name == destName then
npcId = cached.npcId
break
end
end
end
end
if npcId and npcId > 0 then
-- TTL cleanup: drop entries older than 10 minutes
if _Learner.guidNpcCache then
local now = time()
for g, cached in pairs(_Learner.guidNpcCache) do
if (now - (cached.ts or 0)) > 600 then
_Learner.guidNpcCache[g] = nil
end
end
end
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] UNIT_DIED: recording NPC", npcId, destName)
self:LearnNPC(npcId, destName, nil, nil, nil, nil)
end
end
end end
function QuestieLearner:OnMouseoverUnit() function QuestieLearner:OnMouseoverUnit()
@@ -528,8 +490,25 @@ function QuestieLearner:OnMouseoverUnit()
local npcId = GetNpcIdFromGUID(guid) local npcId = GetNpcIdFromGUID(guid)
if not npcId or npcId <= 0 then return end if not npcId or npcId <= 0 then return end
-- Only learn this NPC if it carries the questgiver flag OR if it is already
-- known in the database as a starter/finisher (so we can update its coords).
local npcFlags = UnitNPCFlags and UnitNPCFlags("mouseover") or 0
local isQuestGiver = NpcFlagsHasQuestGiver(npcFlags)
if not isQuestGiver then
-- Check if the DB already knows this NPC as a quest starter or finisher
local dbNpc = QuestieDB and QuestieDB.GetNPC and QuestieDB:GetNPC(npcId)
if dbNpc and (dbNpc[7] or dbNpc[8]) then
-- known quest-related NPC: update coordinates only
isQuestGiver = true
end
end
if not isQuestGiver then return end
local name = UnitName("mouseover") local name = UnitName("mouseover")
local level = UnitLevel("mouseover") local level = UnitLevel("mouseover")
local subName = UnitCreatureFamily and UnitCreatureFamily("mouseover") or nil
local reaction = UnitReaction("mouseover", "player") local reaction = UnitReaction("mouseover", "player")
local factionString = nil local factionString = nil
if reaction then if reaction then
@@ -544,7 +523,7 @@ function QuestieLearner:OnMouseoverUnit()
_Learner.guidNpcCache[guid] = { npcId = npcId, name = name, ts = time() } _Learner.guidNpcCache[guid] = { npcId = npcId, name = name, ts = time() }
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Cached mouseover NPC:", npcId, name, "guid:", guid) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Cached mouseover NPC:", npcId, name, "guid:", guid)
self:LearnNPC(npcId, name, level, nil, nil, factionString) self:LearnNPC(npcId, name, level, subName, npcFlags, factionString)
end end
function QuestieLearner:OnTargetChanged() function QuestieLearner:OnTargetChanged()
@@ -565,32 +544,46 @@ function QuestieLearner:OnTargetChanged()
_Learner.guidNpcCache = _Learner.guidNpcCache or {} _Learner.guidNpcCache = _Learner.guidNpcCache or {}
_Learner.guidNpcCache[guid] = { npcId = npcId, name = name, ts = time() } _Learner.guidNpcCache[guid] = { npcId = npcId, name = name, ts = time() }
-- Target changes don't guarantee a quest giver, but we still cache GUID for kill tracking
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Cached target NPC:", npcId, name, "guid:", guid) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Cached target NPC:", npcId, name, "guid:", guid)
self:LearnNPC(npcId, name, level, nil, nil, nil)
end end
-- Collects all available quest data from the quest detail/offer screen (before accepting)
function QuestieLearner:OnQuestDetail() function QuestieLearner:OnQuestDetail()
local questId = GetQuestID and GetQuestID() local questId = GetQuestID and GetQuestID()
if not questId or questId <= 0 then return end if not questId or questId <= 0 then return end
local title = GetTitleText() local data = {}
local questLevel = 0 data[1] = GetTitleText and GetTitleText() or nil
local objectives = GetObjectiveText and GetObjectiveText() -- 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
self:LearnQuest(questId, title, questLevel, 0, nil, objectives) -- 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") local npcGuid = UnitGUID("npc")
if npcGuid then if npcGuid then
local npcId, unitType = GetIdAndTypeFromGUID(npcGuid) local entityId, unitType = GetIdAndTypeFromGUID(npcGuid)
if npcId and npcId > 0 and unitType then if entityId and entityId > 0 then
local entityName = UnitName("npc")
if unitType == "GameObject" then if unitType == "GameObject" then
self:LearnQuestGiver(questId, npcId, true) self:LearnQuestGiver(questId, entityId, 2, true)
local npcName = UnitName("npc") self:LearnObject(entityId, entityName)
self:LearnObject(npcId, npcName)
elseif unitType == "Creature" or unitType == "Vehicle" then elseif unitType == "Creature" or unitType == "Vehicle" then
self:LearnQuestGiver(questId, npcId, true) self:LearnQuestGiver(questId, entityId, 1, true)
local npcName = UnitName("npc") local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 2
self:LearnNPC(npcId, npcName, nil, nil, 2, nil) self:LearnNPC(entityId, entityName, nil, nil, npcFlags, nil)
end end
end end
end end
@@ -600,54 +593,109 @@ function QuestieLearner:OnQuestComplete()
local questId = GetQuestID and GetQuestID() local questId = GetQuestID and GetQuestID()
if not questId or questId <= 0 then return end if not questId or questId <= 0 then return end
-- Capture completion/finish text
local data = {}
if GetRewardText then
data[18] = GetRewardText()
end
self:LearnQuest(questId, data)
-- Identify the quest turn-in NPC or object
local npcGuid = UnitGUID("npc") local npcGuid = UnitGUID("npc")
if npcGuid then if npcGuid then
local npcId, unitType = GetIdAndTypeFromGUID(npcGuid) local entityId, unitType = GetIdAndTypeFromGUID(npcGuid)
if npcId and npcId > 0 and unitType then if entityId and entityId > 0 then
local entityName = UnitName("npc")
if unitType == "GameObject" then if unitType == "GameObject" then
self:LearnQuestGiver(questId, npcId, false) self:LearnQuestGiver(questId, entityId, 2, false)
local npcName = UnitName("npc") self:LearnObject(entityId, entityName)
self:LearnObject(npcId, npcName)
elseif unitType == "Creature" or unitType == "Vehicle" then elseif unitType == "Creature" or unitType == "Vehicle" then
self:LearnQuestGiver(questId, npcId, false) self:LearnQuestGiver(questId, entityId, 1, false)
local npcName = UnitName("npc") local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 2
self:LearnNPC(npcId, npcName, nil, nil, 2, nil) self:LearnNPC(entityId, entityName, nil, nil, npcFlags, nil)
end end
end end
end end
end end
-- Fires after the player clicks Accept; questLogIndex and questId are available here
function QuestieLearner:OnQuestAccepted(questLogIndex, questId) function QuestieLearner:OnQuestAccepted(questLogIndex, questId)
-- Resolve questId from log index if not provided
if not questId or questId <= 0 then if not questId or questId <= 0 then
if questLogIndex then if questLogIndex then
questId = select(8, GetQuestLogTitle(questLogIndex)) local _, _, _, _, _, _, _, id = GetQuestLogTitle(questLogIndex)
questId = id
end end
end end
if not questId or questId <= 0 then return end if not questId or questId <= 0 then return end
local title = GetQuestLogTitle(questLogIndex) or GetTitleText() -- Build data table from quest log entry (richest source)
self:LearnQuest(questId, title, nil, nil, nil, nil) local data = {}
if questLogIndex then
local title, level, _, isHeader, _, isComplete, frequency, id = GetQuestLogTitle(questLogIndex)
if not isHeader then
data[1] = title
data[5] = level and level > 0 and level or nil -- questLevel
end
-- Objectives from leaderboard
local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(questLogIndex) or 0
if numObj > 0 then
local objList = {}
for i = 1, numObj do
local text = GetQuestLogLeaderBoard and GetQuestLogLeaderBoard(i, questLogIndex)
if text then table.insert(objList, text) end
end
if #objList > 0 then data[6] = objList end
end
-- Required money
local reqMoney = GetQuestLogRequiredMoney and GetQuestLogRequiredMoney(questLogIndex) or 0
if reqMoney and reqMoney > 0 then data[7] = reqMoney end
else
-- Fallback: use GetTitleText from the still-open quest frame
data[1] = GetTitleText and GetTitleText() or nil
end
-- Zone sort: record current map zone
local zoneId = GetZoneId()
if zoneId and zoneId > 0 then data[8] = zoneId end
self:LearnQuest(questId, data)
end end
-- Loot handler with async GetItemInfo retry
function QuestieLearner:OnLootOpened() function QuestieLearner:OnLootOpened()
if not self:IsEnabled() then return end
if not Questie.db.global.learnedData.settings.learnItems then return end
local targetGuid = UnitGUID("target") local targetGuid = UnitGUID("target")
local npcId = targetGuid and GetNpcIdFromGUID(targetGuid) or nil local npcId = targetGuid and GetNpcIdFromGUID(targetGuid) or nil
-- Also try to record the object if target is a game object
if targetGuid then
local objId = GetObjectIdFromGUID(targetGuid)
if objId and objId > 0 then
local objName = UnitName("target")
self:LearnObject(objId, objName)
end
end
local numItems = GetNumLootItems() local numItems = GetNumLootItems()
for i = 1, numItems do for i = 1, numItems do
local lootIcon, lootName, lootQuantity, currencyID, lootQuality, locked, isQuestItem, questId, isActive = local _, lootName, _, _, lootQuality = GetLootSlotInfo(i)
GetLootSlotInfo(i)
if lootName then if lootName then
local link = GetLootSlotLink(i) local link = GetLootSlotLink(i)
if link then if link then
local itemId = tonumber(string.match(link, "item:(%d+)")) local itemId = tonumber(string.match(link, "item:(%d+)"))
if itemId then if itemId and itemId > 0 then
local _, _, _, itemLevel, requiredLevel, itemType, itemSubType, _, _, _, _, itemClassId, itemSubClassId = local itemName, _, _, itemLevel, requiredLevel, _, _, _, _, _, _, itemClassId, itemSubClassId = GetItemInfo(link)
GetItemInfo(link) if itemName then
self:LearnItem(itemId, lootName, itemLevel, requiredLevel, itemClassId, itemSubClassId) self:LearnItem(itemId, itemName, itemLevel, requiredLevel, itemClassId, itemSubClassId)
if npcId then self:LearnItemDrop(itemId, npcId) end
if npcId then else
self:LearnItemDrop(itemId, npcId) -- GetItemInfo returned nil (item not in cache); queue for retry
table.insert(_Learner.pendingItemLinks, { link = link, itemId = itemId, npcId = npcId })
end end
end end
end end
@@ -659,13 +707,132 @@ function QuestieLearner:OnGossipShow()
local npcGuid = UnitGUID("npc") local npcGuid = UnitGUID("npc")
if not npcGuid then return end if not npcGuid then return end
local npcId = GetNpcIdFromGUID(npcGuid) local id, unitType = GetIdAndTypeFromGUID(npcGuid)
if not id or id <= 0 then return end
local name = UnitName("npc")
if unitType == "GameObject" then
self:LearnObject(id, name)
elseif unitType == "Creature" or unitType == "Vehicle" then
local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 1
self:LearnNPC(id, name, nil, nil, npcFlags, nil)
end
end
-- 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 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
------------------------------------------------------------------------
-- Combat log: kill tracking with GUID-keyed cache
------------------------------------------------------------------------
function QuestieLearner:OnCombatLogEvent(...)
local args = { CombatLogGetCurrentEventInfo and CombatLogGetCurrentEventInfo() or ... }
local event = args[2]
local destGUID = args[8]
local destName = args[9]
if event ~= "UNIT_DIED" or not destGUID then return end
local npcId = GetNpcIdFromGUID(destGUID)
-- Fallback: GUID-keyed cache from OnTargetChanged / OnMouseoverUnit
if not npcId and _Learner.guidNpcCache then
local cached = _Learner.guidNpcCache[destGUID]
if cached then
npcId = cached.npcId
if not destName then destName = cached.name end
end
end
-- Fallback: hex-prefix scan for creatures not in cache
if not npcId and destGUID and string.sub(destGUID, 1, 2) == "0x" then
local prefix = string.upper(string.sub(destGUID, 3, 6))
if CREATURE_HEX_PREFIXES[prefix] then
-- We don't have the ID but we have a name; nothing useful to record
end
end
if not npcId or npcId <= 0 then return end if not npcId or npcId <= 0 then return end
local npcName = UnitName("npc") -- TTL cleanup: drop entries older than 10 minutes
self:LearnNPC(npcId, npcName, nil, nil, 1, nil) if _Learner.guidNpcCache then
local now = time()
for g, cached in pairs(_Learner.guidNpcCache) do
if (now - (cached.ts or 0)) > 600 then
_Learner.guidNpcCache[g] = nil
end
end
end
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] UNIT_DIED: recording kill NPC", npcId, destName)
self:LearnNPC(npcId, destName, nil, nil, nil, nil)
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_ACCEPTED")
frame:RegisterEvent("LOOT_OPENED")
frame:RegisterEvent("GOSSIP_SHOW")
frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
frame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
frame:SetScript("OnEvent", function(_, event, ...)
if event == "UPDATE_MOUSEOVER_UNIT" then
self:OnMouseoverUnit()
elseif event == "PLAYER_TARGET_CHANGED" then
self:OnTargetChanged()
elseif event == "QUEST_DETAIL" then
self:OnQuestDetail()
elseif event == "QUEST_COMPLETE" then
self:OnQuestComplete()
elseif event == "QUEST_ACCEPTED" then
self:OnQuestAccepted(...)
elseif event == "LOOT_OPENED" then
self:OnLootOpened()
elseif event == "GOSSIP_SHOW" then
self:OnGossipShow()
elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
self:OnCombatLogEvent(...)
elseif event == "GET_ITEM_INFO_RECEIVED" then
local itemId = ...
self:OnGetItemInfoReceived(itemId)
end
end)
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Events registered")
end
------------------------------------------------------------------------
-- Initialize
------------------------------------------------------------------------
function QuestieLearner:Initialize() function QuestieLearner:Initialize()
EnsureLearnedData() EnsureLearnedData()
QuestieLearner.data = Questie.db.global.learnedData QuestieLearner.data = Questie.db.global.learnedData
@@ -674,7 +841,10 @@ function QuestieLearner:Initialize()
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Initialized") Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Initialized")
end end
-- Called by LearnNPC/Quest/Item/Object after every write. Broadcasts only if comms module is loaded. ------------------------------------------------------------------------
-- Network bridge
------------------------------------------------------------------------
function _Learner:BroadcastIfCommsAvailable(typ, id, data) function _Learner:BroadcastIfCommsAvailable(typ, id, data)
local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms") local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms")
if QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData then if QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData then
@@ -683,7 +853,7 @@ function _Learner:BroadcastIfCommsAvailable(typ, id, data)
end end
end end
-- Receives validated, decoded data from QuestieLearnerComms and merges it into local learnedData. -- Receives validated, decoded data from QuestieLearnerComms or QuestieLearnerExport:MergeImport
function QuestieLearner:HandleNetworkData(typ, id, d) function QuestieLearner:HandleNetworkData(typ, id, d)
if not self:IsEnabled() then return end if not self:IsEnabled() then return end
if not EnsureLearnedData() then return end if not EnsureLearnedData() then return end
@@ -711,38 +881,32 @@ function QuestieLearner:HandleNetworkData(typ, id, d)
store[id] = d store[id] = d
store[id].mc = 1 store[id].mc = 1
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Network NEW", typ, id) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Network NEW", typ, id)
-- Immediately inject into QuestieDB overrides
self:InjectLearnedData()
QuestieLearner.data = Questie.db.global.learnedData
return return
end end
-- Merge: adopt non-nil fields from remote that we don't have locally -- Merge: adopt non-nil fields we don't have locally
for k, v in pairs(d) do for k, v in pairs(d) do
if k ~= "mc" and existing[k] == nil then if k ~= "mc" and existing[k] == nil then
existing[k] = v existing[k] = v
end end
end end
-- Merge coordinate tables (index [7] for NPCs, [4] for Objects) -- Merge coordinates
local coordKey = (typ == "NPC") and 7 or (typ == "OBJECT" and 4 or nil) local coordKey = (typ == "NPC") and 7 or (typ == "OBJECT" and 4 or nil)
if coordKey and type(d[coordKey]) == "table" then if coordKey and type(d[coordKey]) == "table" then
existing[coordKey] = existing[coordKey] or {} existing[coordKey] = existing[coordKey] or {}
for zoneId, coords in pairs(d[coordKey]) do for zoneId, coords in pairs(d[coordKey]) do
existing[coordKey][zoneId] = existing[coordKey][zoneId] or {} existing[coordKey][zoneId] = existing[coordKey][zoneId] or {}
for _, coord in ipairs(coords) do for _, coord in ipairs(coords) do
local found = false InsertIfNewBucket(existing[coordKey][zoneId], coord[1], coord[2])
for _, existCoord in ipairs(existing[coordKey][zoneId]) do
if math.abs(existCoord[1] - coord[1]) < 1 and math.abs(existCoord[2] - coord[2]) < 1 then
found = true
break
end
end
if not found then
table.insert(existing[coordKey][zoneId], coord)
end
end end
end end
end end
-- Merge item drop list (index [2] for Items) -- Merge item drop list
if typ == "ITEM" and type(d[2]) == "table" then if typ == "ITEM" and type(d[2]) == "table" then
existing[2] = existing[2] or {} existing[2] = existing[2] or {}
for _, npcId in ipairs(d[2]) do for _, npcId in ipairs(d[2]) do
@@ -757,7 +921,6 @@ function QuestieLearner:HandleNetworkData(typ, id, d)
existing.mc = (existing.mc or 0) + 1 existing.mc = (existing.mc or 0) + 1
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Network MERGE", typ, id, "mc:", existing.mc) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Network MERGE", typ, id, "mc:", existing.mc)
-- Keep .data reference in sync
QuestieLearner.data = Questie.db.global.learnedData QuestieLearner.data = Questie.db.global.learnedData
end end
+6
View File
@@ -288,6 +288,12 @@ function QuestieLearnerExport:MergeImport()
self.lastImportData = nil self.lastImportData = nil
self.lastImportStats = nil self.lastImportStats = nil
-- Push merged data into QuestieDB overrides immediately (no reload required for override data)
local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner")
if QuestieLearner and QuestieLearner.InjectLearnedData then
QuestieLearner:InjectLearnedData()
end
local msg = "Import complete: merged " .. merged .. " entries, skipped " .. skipped .. " (already known)." local msg = "Import complete: merged " .. merged .. " entries, skipped " .. skipped .. " (already known)."
Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport]", msg) Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport]", msg)
return true, msg return true, msg
+1 -1
View File
@@ -245,7 +245,7 @@ To load data exported by another player or provided by the community:
1. Open Questie-X options → **Database** tab. 1. Open Questie-X options → **Database** tab.
2. Paste the export string into the import text box. 2. Paste the export string into the import text box.
3. Click **Import**. Questie-X decodes and merges the data into your local database immediately — no reload required. 3. Click **Import**. Questie-X decodes and merges the data into your local database and immediately injects it into the active override tables — map pins and tracker entries update without a full reload. A `/reload` is only required if you want newly imported quest starters/finishers to appear on the world map for quests already in your log.
Imported entries follow the same validation rules as received broadcast entries. Conflicts (same NPC ID with different coordinates) are resolved by keeping the entry with the most data fields populated. Imported entries follow the same validation rules as received broadcast entries. Conflicts (same NPC ID with different coordinates) are resolved by keeping the entry with the most data fields populated.
+28
View File
@@ -175,6 +175,34 @@
</div> </div>
</div> </div>
<div class="container">
<h2 id="v126">v1.2.6 — QuestieLearner Comprehensive Overhaul</h2>
<p><em>Rewrote QuestieLearner from scratch to fix all known data-capture deficiencies. Adds full quest field coverage, grid-based coordinate clustering, quest-giver-only mouseover filtering, async item-info retry, object loot detection, and live inject-on-import so imported data takes effect without a reload.</em></p>
<h3>QuestieLearner.lua — Full Rewrite</h3>
<ul>
<li><strong>[Quest capture completeness]</strong> <code>LearnQuest</code> now accepts a generic <code>data</code> table keyed by Questie wiki array indices rather than individual positional arguments. <code>OnQuestDetail</code> captures title, objectives text block, quest description body, and current zone as <code>zoneOrSort[8]</code>. <code>OnQuestAccepted</code> fills quest level from <code>GetQuestLogTitle</code>, per-objective text from <code>GetQuestLogLeaderBoard</code>, and required money from <code>GetQuestLogRequiredMoney</code>. <code>OnQuestComplete</code> captures finish/reward text via <code>GetRewardText</code>.</li>
<li><strong>[Mouseover filter]</strong> <code>OnMouseoverUnit</code> now only learns an NPC if its <code>UnitNPCFlags</code> bitmask includes the <code>QUESTGIVER</code> bit (<code>0x02</code>), OR if the NPC already exists in <code>QuestieDB</code> as a known quest starter/finisher. All other NPCs are silently ignored — this eliminates the flood of irrelevant NPC entries the learner previously accumulated.</li>
<li><strong>[Target changed]</strong> <code>OnTargetChanged</code> no longer calls <code>LearnNPC</code> on every target switch. It now only populates the <code>guidNpcCache</code> for kill-tracking purposes, avoiding recording non-quest NPCs.</li>
<li><strong>[Coordinate clustering — grid bucketing]</strong> Replaced the naive "within 1 unit radius" deduplication with a 2×2 grid bucket scheme (<code>COORD_GRID = 2.0</code>). A new point is inserted only when no existing point shares the same grid cell. This prevents coordinate scatter across a kill area while still preserving distinct spawn clusters. The <code>InsertIfNewBucket</code> helper is shared across NPC, Object, and all merge paths (including <code>InjectLearnedData</code> and <code>HandleNetworkData</code>).</li>
<li><strong>[Object recording]</strong> <code>OnLootOpened</code> now checks whether the loot source GUID is a <code>GameObject</code>. If so, <code>LearnObject</code> is called with the object's ID and name. <code>OnGossipShow</code> records both objects and NPCs via <code>GetIdAndTypeFromGUID</code>. <code>OnQuestDetail</code> and <code>OnQuestComplete</code> also record the interacting object when the quest giver/finisher is a <code>GameObject</code>.</li>
<li><strong>[Item loot — async GetItemInfo retry]</strong> <code>OnLootOpened</code> queues unresolved item links (where <code>GetItemInfo</code> returns nil because the item is not yet in the client cache) into <code>_Learner.pendingItemLinks</code>. A new <code>OnGetItemInfoReceived(itemId)</code> handler fires on the <code>GET_ITEM_INFO_RECEIVED</code> event, resolves queued links for that item ID, and calls <code>LearnItem</code>/<code>LearnItemDrop</code> once the data is available. This fixes silent data loss on first-encounter loots.</li>
<li><strong>[Quest giver entity type]</strong> <code>LearnQuestGiver</code> now accepts an <code>entityType</code> argument (1=NPC, 2=GameObject, 3=item) and stores starters/finishers in the correct sub-array slot matching the Questie wiki spec <code>{ [1]={npcIds}, [2]={objIds}, [3]={itemIds} }</code>.</li>
<li><strong>[Kill tracking]</strong> <code>OnCombatLogEvent</code> retains all three GUID-resolution paths (dash-split, GUID cache, hex-prefix) with a 10-minute TTL cache cleanup. Uses <code>CombatLogGetCurrentEventInfo()</code> with fallback to varargs for cross-client compatibility.</li>
<li><strong>[InjectLearnedData — grid clustering]</strong> All coordinate merge loops now use <code>InsertIfNewBucket</code> instead of the old radius check.</li>
</ul>
<h3>QuestieLearnerExport.lua — Import Live-Inject Fix</h3>
<ul>
<li><strong>[MergeImport → InjectLearnedData]</strong> After merging all four data categories, <code>MergeImport</code> immediately calls <code>QuestieLearner:InjectLearnedData()</code>. Imported data is pushed into <code>QuestieDB.*DataOverrides</code> in the same frame — override-driven map pins update without a <code>/reload</code>. A full reload is still needed to pick up newly imported quest starters/finishers for quests already in the player's quest log.</li>
</ul>
<h3>README — Import Clarification</h3>
<ul>
<li>Corrected the "no reload required" claim. The import flow now accurately describes when an immediate effect is visible versus when a <code>/reload</code> is beneficial.</li>
</ul>
</div>
<div class="container"> <div class="container">
<h2 id="v125">v1.2.5 — Ebonhold &amp; Ascension DB Plugin Load Fix</h2> <h2 id="v125">v1.2.5 — Ebonhold &amp; Ascension DB Plugin Load Fix</h2>
<p><em>Fixed a fatal load-time crash in all four Ebonhold DB files (and identically in the Ascension DB files) caused by calling <code>GetRealmName()</code> and <code>QuestieLoader:CreateModule()</code> at file scope — before WoW's API is fully available. Switched to plain global table population; realm-gating and injection remain safely deferred to the loader's <code>PLAYER_LOGIN</code> handler.</em></p> <p><em>Fixed a fatal load-time crash in all four Ebonhold DB files (and identically in the Ascension DB files) caused by calling <code>GetRealmName()</code> and <code>QuestieLoader:CreateModule()</code> at file scope — before WoW's API is fully available. Switched to plain global table population; realm-gating and injection remain safely deferred to the loader's <code>PLAYER_LOGIN</code> handler.</em></p>