From 8bc74a488b7abab915290eb8d2be08da2cbb20e7 Mon Sep 17 00:00:00 2001 From: Xurkon Date: Mon, 16 Mar 2026 20:49:02 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 27 ++ Modules/QuestieLearner.lua | 717 +++++++++++++++++++------------ Modules/QuestieLearnerExport.lua | 6 + README.md | 2 +- docs/changelog.html | 28 ++ 5 files changed, 502 insertions(+), 278 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7cc89b..bc2c53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # 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 > 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. diff --git a/Modules/QuestieLearner.lua b/Modules/QuestieLearner.lua index 82b6c55..faa748d 100644 --- a/Modules/QuestieLearner.lua +++ b/Modules/QuestieLearner.lua @@ -8,47 +8,96 @@ local _Learner = QuestieLearner.private or {} QuestieLearner.private = _Learner local floor = math.floor +local abs = math.abs -_Learner.pendingNpcs = {} -_Learner.pendingQuests = {} -_Learner.pendingItems = {} +-- 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 + +_Learner.pendingNpcs = {} +_Learner.pendingQuests = {} +_Learner.pendingItems = {} _Learner.pendingObjects = {} +_Learner.pendingItemLinks = {} -- queue for async GetItemInfo retries -- Direct reference to learnedData, set on Initialize QuestieLearner.data = nil +------------------------------------------------------------------------ +-- Coordinate helpers +------------------------------------------------------------------------ + local function GetZoneId() local mapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") if mapId then return mapId end - return GetRealZoneText() and select(8, GetInstanceInfo()) or 0 + return select(8, GetInstanceInfo()) or 0 end local function GetPlayerCoords() local x, y = GetPlayerMapPosition("player") if x and y and x > 0 and y > 0 then - return floor(x * 100 * 100) / 100, floor(y * 100 * 100) / 100 + -- Store in 0–100 scale, 2-decimal precision + return floor(x * 10000) / 100, floor(y * 10000) / 100 end return nil, nil end +-- Returns the grid-bucket key for a coordinate so nearby points share the same slot +local function CoordBucket(x, y) + return floor(x / COORD_GRID) * COORD_GRID, floor(y / COORD_GRID) * COORD_GRID +end + +-- Inserts {x, y} into coordList only when no existing point falls in the same grid bucket +local function InsertIfNewBucket(coordList, x, y) + 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() if not Questie.db then return false end Questie.db.global.learnedData = Questie.db.global.learnedData or { - npcs = {}, - quests = {}, - items = {}, + npcs = {}, + quests = {}, + items = {}, objects = {}, settings = { - enabled = true, - learnNpcs = true, - learnQuests = true, - learnItems = true, + enabled = true, + learnNpcs = true, + learnQuests = true, + learnItems = true, learnObjects = true, }, } return true end +------------------------------------------------------------------------ +-- Public API +------------------------------------------------------------------------ + function QuestieLearner:IsEnabled() if not EnsureLearnedData() then return false end return Questie.db.global.learnedData.settings.enabled @@ -59,13 +108,17 @@ function QuestieLearner:GetSettings() return Questie.db.global.learnedData.settings end +------------------------------------------------------------------------ +-- NPC learning +------------------------------------------------------------------------ + function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString) if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnNpcs then return end if not npcId or npcId <= 0 then return end local zoneId = GetZoneId() - local x, y = GetPlayerCoords() + local x, y = GetPlayerCoords() local existing = Questie.db.global.learnedData.npcs[npcId] if not existing then @@ -73,29 +126,20 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS Questie.db.global.learnedData.npcs[npcId] = existing end - if name and not existing[1] then existing[1] = name end + if name and not existing[1] then existing[1] = name end if level then if not existing[4] or level < existing[4] then existing[4] = level end if not existing[5] or level > existing[5] then existing[5] = level end end - if zoneId and zoneId > 0 and not existing[9] then existing[9] = zoneId end + if 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 npcFlags and npcFlags > 0 and not existing[15] then existing[15] = npcFlags 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 x and y and zoneId then + if x and y and zoneId and zoneId > 0 then existing[7] = existing[7] or {} existing[7][zoneId] = existing[7][zoneId] or {} - local found = false - 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 + InsertIfNewBucket(existing[7][zoneId], x, y) end 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) 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 Questie.db.global.learnedData.settings.learnQuests 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 end - if name and not existing[1] then existing[1] = name end - if requiredLevel and requiredLevel > 0 and not existing[4] then existing[4] = requiredLevel end - if questLevel and questLevel > 0 and not existing[5] then existing[5] = questLevel end - 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 } + 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 - 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) 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 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] if not existing then @@ -144,29 +193,23 @@ function QuestieLearner:LearnQuestGiver(questId, npcId, isStart) Questie.db.global.learnedData.quests[questId] = existing end - if isStart then - existing[2] = existing[2] or {} - existing[2][1] = existing[2][1] or {} - local found = false - for _, id in ipairs(existing[2][1]) do - if id == npcId then - found = true; break - end - 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 + -- 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) end +------------------------------------------------------------------------ +-- Item learning +------------------------------------------------------------------------ + function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemClass, itemSubClass) if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnItems then return end @@ -178,10 +221,10 @@ function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemCl Questie.db.global.learnedData.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 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 itemClass and not existing[12] then existing[12] = itemClass end + if itemClass and not existing[12] then existing[12] = itemClass end if itemSubClass and not existing[13] then existing[13] = itemSubClass end existing.mc = (existing.mc or 0) + 1 @@ -202,22 +245,23 @@ function QuestieLearner:LearnItemDrop(itemId, npcId) end existing[2] = existing[2] or {} - local found = false for _, id in ipairs(existing[2]) do - if id == npcId then - found = true; break - end + if id == npcId then return end end - if not found then table.insert(existing[2], npcId) end + table.insert(existing[2], npcId) end +------------------------------------------------------------------------ +-- Object learning +------------------------------------------------------------------------ + function QuestieLearner:LearnObject(objectId, name) if not self:IsEnabled() then return end if not Questie.db.global.learnedData.settings.learnObjects then return end if not objectId or objectId <= 0 then return end local zoneId = GetZoneId() - local x, y = GetPlayerCoords() + local x, y = GetPlayerCoords() local existing = Questie.db.global.learnedData.objects[objectId] if not existing then @@ -225,22 +269,13 @@ function QuestieLearner:LearnObject(objectId, name) Questie.db.global.learnedData.objects[objectId] = existing end - 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 x and y and zoneId then + if x and y and zoneId and zoneId > 0 then existing[4] = existing[4] or {} existing[4][zoneId] = existing[4][zoneId] or {} - local found = false - 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 + InsertIfNewBucket(existing[4][zoneId], x, y) end existing.mc = (existing.mc or 0) + 1 @@ -249,6 +284,10 @@ function QuestieLearner:LearnObject(objectId, name) _Learner:BroadcastIfCommsAvailable("OBJECT", objectId, existing) end +------------------------------------------------------------------------ +-- InjectLearnedData — pushes learnedData into QuestieDB overrides +------------------------------------------------------------------------ + function QuestieLearner:InjectLearnedData() if not EnsureLearnedData() then return end @@ -266,16 +305,7 @@ function QuestieLearner:InjectLearnedData() for zoneId, coords in pairs(data[7]) do existing[7][zoneId] = existing[7][zoneId] or {} for _, coord in ipairs(coords) do - local found = false - 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 + InsertIfNewBucket(existing[7][zoneId], coord[1], coord[2]) end end end @@ -286,6 +316,13 @@ function QuestieLearner:InjectLearnedData() if not QuestieDB.questDataOverrides[questId] then QuestieDB.questDataOverrides[questId] = data 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 @@ -307,16 +344,7 @@ function QuestieLearner:InjectLearnedData() for zoneId, coords in pairs(data[4]) do existing[4][zoneId] = existing[4][zoneId] or {} for _, coord in ipairs(coords) do - local found = false - 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 + InsertIfNewBucket(existing[4][zoneId], coord[1], coord[2]) end end end @@ -329,71 +357,64 @@ function QuestieLearner:InjectLearnedData() end end +------------------------------------------------------------------------ +-- Stats / Export helpers +------------------------------------------------------------------------ + function QuestieLearner:GetStats() if not EnsureLearnedData() then return 0, 0, 0, 0 end local learned = Questie.db.global.learnedData - local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0 - for _ in pairs(learned.npcs) do npcCount = npcCount + 1 end - for _ in pairs(learned.quests) do questCount = questCount + 1 end - for _ in pairs(learned.items) do itemCount = itemCount + 1 end - for _ in pairs(learned.objects) do objectCount = objectCount + 1 end - return npcCount, questCount, itemCount, objectCount + local n, q, i, o = 0, 0, 0, 0 + for _ in pairs(learned.npcs) do n = n + 1 end + for _ in pairs(learned.quests) do q = q + 1 end + for _ in pairs(learned.items) do i = i + 1 end + for _ in pairs(learned.objects) do o = o + 1 end + return n, q, i, o end function QuestieLearner:ClearAllData() if not EnsureLearnedData() then return end - Questie.db.global.learnedData.npcs = {} - Questie.db.global.learnedData.quests = {} - Questie.db.global.learnedData.items = {} + Questie.db.global.learnedData.npcs = {} + Questie.db.global.learnedData.quests = {} + Questie.db.global.learnedData.items = {} Questie.db.global.learnedData.objects = {} Questie:Print("Cleared all learned data.") end +function QuestieLearner:SerializeTable(t) + if type(t) ~= "table" then + if type(t) == "string" then return string.format("%q", t) end + return tostring(t) + end + local parts = {} + local isArray = #t > 0 + for k, v in pairs(t) do + local key = isArray and "" or ("[" .. (type(k) == "string" and string.format("%q", k) or tostring(k)) .. "]=") + table.insert(parts, key .. self:SerializeTable(v)) + end + return "{" .. table.concat(parts, ",") .. "}" +end + function QuestieLearner:ExportData() if not EnsureLearnedData() then return "" end local learned = Questie.db.global.learnedData local lines = {} - table.insert(lines, "-- QuestieLearner Export") - table.insert(lines, "-- NPCs: " .. select(1, self:GetStats())) - table.insert(lines, "-- Quests: " .. select(2, self:GetStats())) - table.insert(lines, "-- Items: " .. select(3, self:GetStats())) - table.insert(lines, "-- Objects: " .. select(4, self:GetStats())) + 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, " 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 -function QuestieLearner:SerializeTable(t, indent) - indent = indent or "" - 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 -} +------------------------------------------------------------------------ +-- GUID parsing +------------------------------------------------------------------------ local HEX_PREFIXES = { ["F130"] = "Creature", @@ -403,21 +424,25 @@ local HEX_PREFIXES = { ["F111"] = "Creature", } +local CREATURE_HEX_PREFIXES = { ["F130"]=true, ["F131"]=true, ["F110"]=true, ["F111"]=true } + local function GetIdAndTypeFromGUID(guid) if not guid then return nil, nil end + -- Modern dash-separated GUID (e.g. "Creature-0-3726-0-189-5638296-...") local unitType, _, _, _, _, parsedId = strsplit("-", guid) local id = tonumber(parsedId) if id and id > 0 and unitType then return id, unitType end + -- Legacy hex GUID if string.sub(guid, 1, 2) == "0x" and string.len(guid) >= 18 then local prefix = string.upper(string.sub(guid, 3, 6)) - local unitType = HEX_PREFIXES[prefix] - if unitType then + local t = HEX_PREFIXES[prefix] + if t then local low32 = tonumber(string.sub(guid, 11, 18), 16) if low32 then - local id = math.mod(low32, 8388608) - if id > 0 then return id, unitType end + local nid = math.mod(low32, 8388608) + if nid > 0 then return nid, t end end end end @@ -436,84 +461,21 @@ local function GetObjectIdFromGUID(guid) return nil end -function QuestieLearner:RegisterEvents() - local frame = CreateFrame("Frame", "QuestieLearnerFrame") +-- Expose for use in event handlers below +_Learner.GetNpcIdFromGUID = GetNpcIdFromGUID +_Learner.GetObjectIdFromGUID = GetObjectIdFromGUID +_Learner.GetIdAndTypeFromGUID = GetIdAndTypeFromGUID - 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") +------------------------------------------------------------------------ +-- Event handlers +------------------------------------------------------------------------ - 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(...) - 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 +-- 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() @@ -528,8 +490,25 @@ function QuestieLearner:OnMouseoverUnit() local npcId = GetNpcIdFromGUID(guid) if not npcId or npcId <= 0 then return end + -- Only learn this NPC if it carries the questgiver flag OR if it is already + -- known in the database as a starter/finisher (so we can update its coords). + local npcFlags = UnitNPCFlags and UnitNPCFlags("mouseover") or 0 + local isQuestGiver = NpcFlagsHasQuestGiver(npcFlags) + + if not isQuestGiver then + -- 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 level = UnitLevel("mouseover") + local subName = UnitCreatureFamily and UnitCreatureFamily("mouseover") or nil local reaction = UnitReaction("mouseover", "player") local factionString = nil if reaction then @@ -544,7 +523,7 @@ function QuestieLearner:OnMouseoverUnit() _Learner.guidNpcCache[guid] = { npcId = npcId, name = name, ts = time() } 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 function QuestieLearner:OnTargetChanged() @@ -565,32 +544,46 @@ function QuestieLearner:OnTargetChanged() _Learner.guidNpcCache = _Learner.guidNpcCache or {} _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) - self:LearnNPC(npcId, name, level, nil, nil, nil) 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 title = GetTitleText() - local questLevel = 0 - local objectives = GetObjectiveText and GetObjectiveText() + 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 - 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") if npcGuid then - local npcId, unitType = GetIdAndTypeFromGUID(npcGuid) - if npcId and npcId > 0 and unitType then + local entityId, unitType = GetIdAndTypeFromGUID(npcGuid) + if entityId and entityId > 0 then + local entityName = UnitName("npc") if unitType == "GameObject" then - self:LearnQuestGiver(questId, npcId, true) - local npcName = UnitName("npc") - self:LearnObject(npcId, npcName) + self:LearnQuestGiver(questId, entityId, 2, true) + self:LearnObject(entityId, entityName) elseif unitType == "Creature" or unitType == "Vehicle" then - self:LearnQuestGiver(questId, npcId, true) - local npcName = UnitName("npc") - self:LearnNPC(npcId, npcName, nil, nil, 2, nil) + self:LearnQuestGiver(questId, entityId, 1, true) + local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 2 + self:LearnNPC(entityId, entityName, nil, nil, npcFlags, nil) end end end @@ -600,54 +593,109 @@ function QuestieLearner:OnQuestComplete() local questId = GetQuestID and GetQuestID() 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") if npcGuid then - local npcId, unitType = GetIdAndTypeFromGUID(npcGuid) - if npcId and npcId > 0 and unitType then + local entityId, unitType = GetIdAndTypeFromGUID(npcGuid) + if entityId and entityId > 0 then + local entityName = UnitName("npc") if unitType == "GameObject" then - self:LearnQuestGiver(questId, npcId, false) - local npcName = UnitName("npc") - self:LearnObject(npcId, npcName) + self:LearnQuestGiver(questId, entityId, 2, false) + self:LearnObject(entityId, entityName) elseif unitType == "Creature" or unitType == "Vehicle" then - self:LearnQuestGiver(questId, npcId, false) - local npcName = UnitName("npc") - self:LearnNPC(npcId, npcName, nil, nil, 2, nil) + self:LearnQuestGiver(questId, entityId, 1, false) + local npcFlags = UnitNPCFlags and UnitNPCFlags("npc") or 2 + self:LearnNPC(entityId, entityName, nil, nil, npcFlags, nil) end end end end +-- Fires after the player clicks Accept; questLogIndex and questId are available here function QuestieLearner:OnQuestAccepted(questLogIndex, questId) + -- Resolve questId from log index if not provided if not questId or questId <= 0 then if questLogIndex then - questId = select(8, GetQuestLogTitle(questLogIndex)) + local _, _, _, _, _, _, _, id = GetQuestLogTitle(questLogIndex) + questId = id end end if not questId or questId <= 0 then return end - local title = GetQuestLogTitle(questLogIndex) or GetTitleText() - self:LearnQuest(questId, title, nil, nil, nil, nil) + -- Build data table from quest log entry (richest source) + 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 +-- Loot handler with async GetItemInfo retry 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 npcId = targetGuid and GetNpcIdFromGUID(targetGuid) or nil + -- Also try to record the object if target is a game object + if targetGuid then + local objId = GetObjectIdFromGUID(targetGuid) + if objId and objId > 0 then + local objName = UnitName("target") + self:LearnObject(objId, objName) + end + end + local numItems = GetNumLootItems() for i = 1, numItems do - local lootIcon, lootName, lootQuantity, currencyID, lootQuality, locked, isQuestItem, questId, isActive = - GetLootSlotInfo(i) + local _, lootName, _, _, lootQuality = GetLootSlotInfo(i) if lootName then local link = GetLootSlotLink(i) if link then local itemId = tonumber(string.match(link, "item:(%d+)")) - if itemId then - local _, _, _, itemLevel, requiredLevel, itemType, itemSubType, _, _, _, _, itemClassId, itemSubClassId = - GetItemInfo(link) - self:LearnItem(itemId, lootName, itemLevel, requiredLevel, itemClassId, itemSubClassId) - - if npcId then - self:LearnItemDrop(itemId, npcId) + 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 npcId then self:LearnItemDrop(itemId, npcId) end + else + -- GetItemInfo returned nil (item not in cache); queue for retry + table.insert(_Learner.pendingItemLinks, { link = link, itemId = itemId, npcId = npcId }) end end end @@ -659,13 +707,132 @@ function QuestieLearner:OnGossipShow() local npcGuid = UnitGUID("npc") 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 - local npcName = UnitName("npc") - self:LearnNPC(npcId, npcName, nil, nil, 1, nil) + -- 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 kill NPC", npcId, destName) + self:LearnNPC(npcId, destName, nil, nil, nil, nil) 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() EnsureLearnedData() QuestieLearner.data = Questie.db.global.learnedData @@ -674,7 +841,10 @@ function QuestieLearner:Initialize() Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Initialized") 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) local QuestieLearnerComms = QuestieLoader:ImportModule("QuestieLearnerComms") if QuestieLearnerComms and QuestieLearnerComms.BroadcastLearnedData then @@ -683,7 +853,7 @@ function _Learner:BroadcastIfCommsAvailable(typ, id, data) 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) if not self:IsEnabled() then return end if not EnsureLearnedData() then return end @@ -711,38 +881,32 @@ function QuestieLearner:HandleNetworkData(typ, id, d) store[id] = d store[id].mc = 1 Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Network NEW", typ, id) + -- Immediately inject into QuestieDB overrides + self:InjectLearnedData() + QuestieLearner.data = Questie.db.global.learnedData return 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 if k ~= "mc" and existing[k] == nil then existing[k] = v 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) if coordKey and type(d[coordKey]) == "table" then existing[coordKey] = existing[coordKey] or {} for zoneId, coords in pairs(d[coordKey]) do existing[coordKey][zoneId] = existing[coordKey][zoneId] or {} for _, coord in ipairs(coords) do - local found = false - 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 + InsertIfNewBucket(existing[coordKey][zoneId], coord[1], coord[2]) end end end - -- Merge item drop list (index [2] for Items) + -- 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 @@ -757,7 +921,6 @@ function QuestieLearner:HandleNetworkData(typ, id, d) existing.mc = (existing.mc or 0) + 1 Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Network MERGE", typ, id, "mc:", existing.mc) - -- Keep .data reference in sync QuestieLearner.data = Questie.db.global.learnedData end diff --git a/Modules/QuestieLearnerExport.lua b/Modules/QuestieLearnerExport.lua index ce8d23e..34c6927 100644 --- a/Modules/QuestieLearnerExport.lua +++ b/Modules/QuestieLearnerExport.lua @@ -288,6 +288,12 @@ function QuestieLearnerExport:MergeImport() self.lastImportData = 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)." Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport]", msg) return true, msg diff --git a/README.md b/README.md index ec857dc..6fae6ce 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ To load data exported by another player or provided by the community: 1. Open Questie-X options → **Database** tab. 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. diff --git a/docs/changelog.html b/docs/changelog.html index 718adba..65aec7b 100644 --- a/docs/changelog.html +++ b/docs/changelog.html @@ -175,6 +175,34 @@ +
+

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

+ + +

QuestieLearnerExport.lua — Import Live-Inject Fix

+ + +

README — Import Clarification

+ +
+

v1.2.5 — Ebonhold & Ascension DB Plugin Load Fix

Fixed a fatal load-time crash in all four Ebonhold DB files (and identically in the Ascension 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 the loader's PLAYER_LOGIN handler.