---@class QuestieDB local QuestieDB = QuestieLoader:CreateModule("QuestieDB") ---@type QuestieDBPrivate QuestieDB.private = QuestieDB.private or {} ---@class QuestieDBPrivate local _QuestieDB = QuestieDB.private ------------------------------------------------------------------------ -- _MergeOverride: Merge override data into a result table. -- Override tables from QuestieLearner and AscensionDB use numeric keys -- ([1]=name, [7]=spawns, ...) while the result uses string keys -- ("name", "spawns", ...). This helper checks both formats: -- 1. override[stringKey] (string-keyed, e.g. from wotlkNPCFixes) -- 2. override[intKey] (numeric-keyed, e.g. from QuestieLearner / AscensionDB) -- 3. rawdata[intKey] (fallback to compiled DB) -- -- Empty tables from QuestieLearner (e.g. spawns={}) are treated as "not present" -- so the base DB's real spawn data is preserved until actual coordinates are learned. ------------------------------------------------------------------------ local function IsEmptyTable(val) return type(val) == "table" and next(val) == nil end local function CopySpawnTable(spawns) if type(spawns) ~= "table" then return spawns end local copied = {} for zoneId, coords in pairs(spawns) do local zoneCoords = {} for _, coord in ipairs(coords or {}) do if type(coord) == "table" then zoneCoords[#zoneCoords + 1] = { coord[1], coord[2] } end end copied[zoneId] = zoneCoords end return copied end local function MergeSpawnTables(baseSpawns, overrideSpawns) if type(baseSpawns) ~= "table" then return CopySpawnTable(overrideSpawns) end if type(overrideSpawns) ~= "table" then return CopySpawnTable(baseSpawns) end local merged = CopySpawnTable(baseSpawns) for zoneId, coords in pairs(overrideSpawns) do merged[zoneId] = merged[zoneId] or {} for _, coord in ipairs(coords or {}) do local exists = false for _, existing in ipairs(merged[zoneId]) do if existing[1] == coord[1] and existing[2] == coord[2] then exists = true break end end if not exists then merged[zoneId][#merged[zoneId] + 1] = { coord[1], coord[2] } end end end return merged end local function GetSpawnTable(entry, primaryKey, legacyKey) if type(entry) ~= "table" then return nil end local spawns = entry[primaryKey] if type(spawns) == "table" then return spawns end spawns = entry[legacyKey] if type(spawns) == "table" then return spawns end return nil end local function NormalizeLearnerSpawnEntry(entry, primaryKey, legacyKey) if type(entry) ~= "table" then return false end local changed = false local currentSpawns = entry[primaryKey] local legacySpawns = entry[legacyKey] if currentSpawns ~= nil and type(currentSpawns) ~= "table" then entry[primaryKey] = nil currentSpawns = nil changed = true end if legacySpawns ~= nil and type(legacySpawns) ~= "table" then entry[legacyKey] = nil legacySpawns = nil changed = true end if type(currentSpawns) ~= "table" and type(legacySpawns) == "table" then entry[primaryKey] = CopySpawnTable(legacySpawns) entry[legacyKey] = nil return true end if type(currentSpawns) == "table" and type(legacySpawns) == "table" then entry[primaryKey] = MergeSpawnTables(currentSpawns, legacySpawns) entry[legacyKey] = nil return true end if type(currentSpawns) == "table" and legacySpawns ~= nil then entry[legacyKey] = nil changed = true end return changed end QuestieDB.private.GetLearnerSpawnTable = GetSpawnTable QuestieDB.private.NormalizeLearnerSpawnEntry = NormalizeLearnerSpawnEntry QuestieDB.private.missingQuestLog = QuestieDB.private.missingQuestLog or {} QuestieDB.private.missingNpcLog = QuestieDB.private.missingNpcLog or {} QuestieDB.private.missingObjectLog = QuestieDB.private.missingObjectLog or {} QuestieDB.private.missingItemLog = QuestieDB.private.missingItemLog or {} local function _MergeOverride(result, override, rawdata, keyMap) for stringKey, intKey in pairs(keyMap) do local overrideValue = override[stringKey] if overrideValue == nil then overrideValue = override[intKey] end if stringKey == "spawns" then if overrideValue ~= nil and not IsEmptyTable(overrideValue) then result[stringKey] = MergeSpawnTables(rawdata and rawdata[intKey], overrideValue) elseif rawdata then result[stringKey] = CopySpawnTable(rawdata[intKey]) end elseif override[stringKey] ~= nil then result[stringKey] = override[stringKey] elseif override[intKey] ~= nil and not IsEmptyTable(override[intKey]) then result[stringKey] = override[intKey] elseif rawdata then result[stringKey] = rawdata[intKey] end end end ------------------------- --Import modules. ------------------------- ---@type QuestieLib local QuestieLib = QuestieLoader:ImportModule("QuestieLib") ---@type QuestiePlayer local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer") ---@type QuestieCorrections local QuestieCorrections = QuestieLoader:ImportModule("QuestieCorrections") ---@type QuestieProfessions local QuestieProfessions = QuestieLoader:ImportModule("QuestieProfessions") ---@type DailyQuests local DailyQuests = QuestieLoader:ImportModule("DailyQuests") ---@type QuestieReputation local QuestieReputation = QuestieLoader:ImportModule("QuestieReputation") ---@type QuestieEvent local QuestieEvent = QuestieLoader:ImportModule("QuestieEvent") ---@type DBCompiler local QuestieDBCompiler = QuestieLoader:ImportModule("DBCompiler") ---@type ZoneDB local ZoneDB = QuestieLoader:ImportModule("ZoneDB") ---@type l10n local l10n = QuestieLoader:ImportModule("l10n") ---@type QuestLogCache local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache") -- Dummy handles to prevent Stage 2 crashes during deferred initialization local _dummyHandle = { QuerySingle = function() return nil end, Query = function() return nil end, pointers = {} } QuestieDB.QueryNPC = _dummyHandle.Query QuestieDB.QueryQuest = _dummyHandle.Query QuestieDB.QueryObject = _dummyHandle.Query QuestieDB.QueryItem = _dummyHandle.Query QuestieDB.QueryQuestSingle = _dummyHandle.QuerySingle QuestieDB.QueryNPCSingle = _dummyHandle.QuerySingle QuestieDB.QueryObjectSingle = _dummyHandle.QuerySingle QuestieDB.QueryItemSingle = _dummyHandle.QuerySingle QuestieDB._QueryQuestSingle = _dummyHandle.QuerySingle QuestieDB._QueryNPCSingle = _dummyHandle.QuerySingle QuestieDB._QueryObjectSingle = _dummyHandle.QuerySingle QuestieDB._QueryItemSingle = _dummyHandle.QuerySingle -- Initialize pointers to empty tables to prevent Stage 2 crashes during deferred initialization QuestieDB.QuestPointers = _dummyHandle.pointers QuestieDB.NPCPointers = _dummyHandle.pointers QuestieDB.ObjectPointers = _dummyHandle.pointers QuestieDB.ItemPointers = _dummyHandle.pointers -- Set by QuestieInit when the compiled/static base DB fails to load. -- When this is true, learner should force itself on so the addon still works -- even if the static database is unavailable or partially missing. QuestieDB.baseDatabaseMissing = false QuestieDB.baseDatabaseMissingKeys = {} -- The four core static stores. The base DB is only considered fully "missing" -- (which forces learner mode) when EVERY one of these failed to load. QuestieDB._baseDatabaseStores = { "npcData", "objectData", "questData", "itemData" } -- True only when a specific static store failed to load. Reads use this so a -- single missing/mismatched store falls back to learner data for that store -- alone, instead of locking the whole addon into learner mode. function QuestieDB:IsStoreMissing(storeKey) return QuestieDB.baseDatabaseMissingKeys and QuestieDB.baseDatabaseMissingKeys[storeKey] == true end -- Clears every per-entity cache. Used when the data source mode changes so that -- quest/npc/item/object AND per-zone results are rebuilt against the new mode. function QuestieDB:ClearModeCaches() _QuestieDB.questCache = {} _QuestieDB.itemCache = {} _QuestieDB.npcCache = {} _QuestieDB.objectCache = {} _QuestieDB.zoneCache = {} end function QuestieDB:IsBaseDatabaseMissing() if QuestieDB.baseDatabaseMissing ~= true then return false end -- Only report the base DB as missing when EVERY core store failed. A partial -- failure (e.g. itemData missing but npcData present) must not override the -- user's Data Source Mode selection — per-read fallback handles the gaps. local keys = QuestieDB.baseDatabaseMissingKeys if not keys then return false end local stores = QuestieDB._baseDatabaseStores for i = 1, 4 do if not keys[stores[i]] then return false end end return true end local function _GetLearnerSettings() local ld = Questie and Questie.dbLearner and Questie.dbLearner.global return ld and ld.settings or nil end local function _GetLearnerRecord(storeName, id) local ld = Questie and Questie.dbLearner and Questie.dbLearner.global if not ld then return nil end local store = ld[storeName] if not store then return nil end return store[id] or store[tostring(id)] end -- Map-percent radius used to collapse per-GUID kill evidence into one pin per -- physical spawn. Kill coordinates are the player's position at kill time, so -- repeated kills (and respawns, which carry fresh GUIDs) land on slightly -- different coords. Without this merge, every kill would render its own pin. -- A distance test (rather than a grid bucket) avoids the boundary artifact where -- two near-identical coords straddle a cell edge and split into separate pins. -- The radius is user-tunable via the learner "Spawn Pin Dedup Radius" knob; 0 -- disables merging (every distinct coord shown). local GUID_SPAWN_DEDUP_RADIUS = 4.0 local function _GetSpawnDedupRadius() local settings = _GetLearnerSettings() local r = settings and tonumber(settings.spawnDedupRadius) if r and r >= 0 then return r end return GUID_SPAWN_DEDUP_RADIUS end local function _BuildSpawnTableFromGuidEvidence(evidence) if type(evidence) ~= "table" then return nil end local radius = _GetSpawnDedupRadius() local radiusSq = radius * radius local spawns = {} local hasEntries = false for _, entry in pairs(evidence) do if type(entry) == "table" and entry.zoneId and entry.x and entry.y then local zoneId = tonumber(entry.zoneId) local x = tonumber(entry.x) local y = tonumber(entry.y) if zoneId and x and y then spawns[zoneId] = spawns[zoneId] or {} local zoneSpawns = spawns[zoneId] local exists = false for _, coord in ipairs(zoneSpawns) do local dx = coord[1] - x local dy = coord[2] - y if (dx * dx + dy * dy) <= radiusSq then exists = true break end end if not exists then zoneSpawns[#zoneSpawns + 1] = { x, y } hasEntries = true end end end end if not hasEntries then return nil end return spawns end ---@type QuestieQuest local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest") ---@type QuestieQuestPrivate local _QuestieQuest = QuestieQuest.private --- A list of quests that will never be available, used to quickly skip quests. ---@alias AutoBlacklistString "rep"|"skill"|"race"|"class" ---@type table QuestieDB.autoBlacklist = {} local tinsert = table.insert local bitband = bit.band -- questFlags https://github.com/cmangos/issues/wiki/Quest_template#questflags local QUEST_FLAGS_DAILY = 4096 local QUEST_FLAGS_WEEKLY = 32768 -- Pre calculated 2 * QUEST_FLAGS, for testing a bit flag local QUEST_FLAGS_DAILY_X2 = 2 * QUEST_FLAGS_DAILY local QUEST_FLAGS_WEEKLY_X2 = 2 * QUEST_FLAGS_WEEKLY --- COMPATIBILITY --- local WOW_PROJECT_ID = QuestieCompat.WOW_PROJECT_ID local WOW_PROJECT_CLASSIC = QuestieCompat.WOW_PROJECT_CLASSIC local C_QuestLog = QuestieCompat.C_QuestLog local GetQuestTagInfo = QuestieCompat.GetQuestTagInfo local IsPlayerSpell = QuestieCompat.IsPlayerSpell local IsSpellKnownOrOverridesKnown = QuestieCompat.IsSpellKnownOrOverridesKnown local IsQuestFlaggedCompleted = QuestieCompat.IsQuestFlaggedCompleted or C_QuestLog.IsQuestFlaggedCompleted --- Tag corrections for quests for which the API returns the wrong values. --- Strucute: [questId] = {tagId, "questType"} ---@type table local questTagCorrections = { [373] = {81, "Dungeon"}, [4146] = {81, "Dungeon"}, [5342] = {0, ""}, [5344] = {0, ""}, [6846] = {41, "PvP"}, [6901] = {41, "PvP"}, [7001] = {41, "PvP"}, [7027] = {41, "PvP"}, [7161] = {41, "PvP"}, [7162] = {41, "PvP"}, [7841] = {0, ""}, [7842] = {0, ""}, [7843] = {0, ""}, [8122] = {41, "PvP"}, [8386] = {41, "PvP"}, [8404] = {41, "PvP"}, [8405] = {41, "PvP"}, [8406] = {41, "PvP"}, [8407] = {41, "PvP"}, [8408] = {41, "PvP"}, [12170] = {41, "PvP"}, [12244] = {41, "PvP"}, [12268] = {41, "PvP"}, [12270] = {41, "PvP"}, [12280] = {41, "PvP"}, [12284] = {41, "PvP"}, [12288] = {41, "PvP"}, [12289] = {41, "PvP"}, [12296] = {41, "PvP"}, [12314] = {41, "PvP"}, [12315] = {41, "PvP"}, [12316] = {41, "PvP"}, [12317] = {41, "PvP"}, [12323] = {41, "PvP"}, [12324] = {41, "PvP"}, [12432] = {41, "PvP"}, [12433] = {41, "PvP"}, [12434] = {41, "PvP"}, [12437] = {41, "PvP"}, [12443] = {41, "PvP"}, [12446] = {41, "PvP"}, [13199] = {41, "PvP"}, [78680] = {1, "Elite"}, [78681] = {1, "Elite"}, [78684] = {1, "Elite"}, [80098] = {1, "Elite"}, [80147] = {1, "Elite"}, [80148] = {1, "Elite"}, [80149] = {1, "Elite"}, [80150] = {1, "Elite"}, [80151] = {1, "Elite"}, [80152] = {1, "Elite"}, [90014] = {1, "Elite"}, [90054] = {1, "Elite"}, [90070] = {1, "Elite"}, [90081] = {1, "Elite"}, [90091] = {1, "Elite"}, [90094] = {1, "Elite"}, [90113] = {1, "Elite"}, [90114] = {1, "Elite"}, [90116] = {1, "Elite"}, [90119] = {1, "Elite"}, [90122] = {1, "Elite"}, [90151] = {1, "Elite"}, [90152] = {1, "Elite"}, [90159] = {1, "Elite"}, [90163] = {1, "Elite"}, [90166] = {1, "Elite"}, [90169] = {1, "Elite"}, [90175] = {1, "Elite"}, [90190] = {1, "Elite"}, [90202] = {1, "Elite"}, [90204] = {1, "Elite"}, [90221] = {1, "Elite"}, [90225] = {1, "Elite"}, [90230] = {1, "Elite"}, [90269] = {1, "Elite"}, [90271] = {1, "Elite"}, } -- 3.3.5 compatibility: fake Item API if missing (Retail style) if not Item then Item = {} end if not Item.CreateFromItemID then local function SafeGetItemName(itemID) local numericItemID = tonumber(itemID) if not numericItemID or numericItemID == 0 then return "item:" .. tostring(itemID) end local ok, name = pcall(GetItemInfo, numericItemID) if not ok then return "item:" .. tostring(numericItemID) end return name or ("item:" .. tostring(numericItemID)) end function Item:CreateFromItemID(itemID) local obj = {} function obj:ContinueOnItemLoad(callback) -- In 3.3.5 there is no async loading, so call the callback immediately if callback then callback() end end function obj:GetItemName() return SafeGetItemName(itemID) end return obj end end -- race bitmask data, for easy access local VANILLA = WOW_PROJECT_ID == WOW_PROJECT_CLASSIC QuestieDB.raceKeys = { ALL_ALLIANCE = VANILLA and 77 or 1101, ALL_HORDE = VANILLA and 178 or 690, NONE = 0, HUMAN = 1, ORC = 2, DWARF = 4, NIGHT_ELF = 8, UNDEAD = 16, TAUREN = 32, GNOME = 64, TROLL = 128, --GOBLIN = 256, BLOOD_ELF = 512, DRAENEI = 1024 } -- Combining these with "and" makes the order matter -- 1 and 2 ~= 2 and 1 QuestieDB.classKeys = { NONE = 0, WARRIOR = 1, PALADIN = 2, HUNTER = 4, ROGUE = 8, PRIEST = 16, DEATH_KNIGHT = 32, SHAMAN = 64, MAGE = 128, WARLOCK = 256, DRUID = 1024 } QuestieDB.specialFlags = { REPEATABLE = 1, } _QuestieDB.questCache = {}; -- stores quest objects so they dont need to be regenerated _QuestieDB.itemCache = {}; _QuestieDB.npcCache = {}; _QuestieDB.objectCache = {}; _QuestieDB.zoneCache = {}; ---A Memoized table for function Quest:CheckRace --- ---Usage: checkRace[requiredRaces] ---@type table local checkRace ---A Memoized table for function Quest:CheckClass --- ---Usage: checkRace[requiredClasses] ---@type table local checkClass ---QuestieCorrections.hiddenQuests local QuestieCorrectionshiddenQuests ---Questie.db.char.hidden local Questiedbcharhidden QuestieDB.itemDataOverrides = {} QuestieDB.npcDataOverrides = {} QuestieDB.objectDataOverrides = {} QuestieDB.questDataOverrides = {} QuestieDB.activeChildQuests = {} function QuestieDB:Initialize() StaticPopupDialogs["QUESTIE_DATABASE_ERROR"] = { -- /run StaticPopup_Show ("QUESTIE_DATABASE_ERROR") text = l10n("There was a problem initializing Questie's database. This can usually be fixed by recompiling the database."), button1 = l10n("Recompile Database"), button2 = l10n("Don't show again"), OnAccept = function() if Questie.IsSoD then Questie.db.global.sod.dbIsCompiled = false else Questie.db.global.dbIsCompiled = false end ReloadUI() end, OnDecline = function() Questie.db.profile.disableDatabaseWarnings = true end, OnShow = function(self) self:SetFrameStrata("TOOLTIP") end, timeout = 0, whileDead = true, hideOnEscape = false, preferredIndex = 3 } -- For now we store both, the SoD database and the Era/HC database local npcBin, npcPtrs, questBin, questPtrs, objBin, objPtrs, itemBin, itemPtrs local binaryBucket = Questie.dbCache and Questie.dbCache.global or Questie.db.global if Questie.IsSoD then binaryBucket = binaryBucket.sod or {} npcBin = binaryBucket.npcBin npcPtrs = binaryBucket.npcPtrs questBin = binaryBucket.questBin questPtrs = binaryBucket.questPtrs objBin = binaryBucket.objBin objPtrs = binaryBucket.objPtrs itemBin = binaryBucket.itemBin itemPtrs = binaryBucket.itemPtrs else npcBin = binaryBucket.npcBin npcPtrs = binaryBucket.npcPtrs questBin = binaryBucket.questBin questPtrs = binaryBucket.questPtrs objBin = binaryBucket.objBin objPtrs = binaryBucket.objPtrs itemBin = binaryBucket.itemBin itemPtrs = binaryBucket.itemPtrs end QuestieDB.QueryNPC = QuestieDBCompiler:GetDBHandle(npcBin, npcPtrs, QuestieDBCompiler:BuildSkipMap(QuestieDB.npcCompilerTypes, QuestieDB.npcCompilerOrder), QuestieDB.npcKeys, QuestieDB.npcDataOverrides) QuestieDB.QueryQuest = QuestieDBCompiler:GetDBHandle(questBin, questPtrs, QuestieDBCompiler:BuildSkipMap(QuestieDB.questCompilerTypes, QuestieDB.questCompilerOrder), QuestieDB.questKeys, QuestieDB.questDataOverrides) QuestieDB.QueryObject = QuestieDBCompiler:GetDBHandle(objBin, objPtrs, QuestieDBCompiler:BuildSkipMap(QuestieDB.objectCompilerTypes, QuestieDB.objectCompilerOrder), QuestieDB.objectKeys, QuestieDB.objectDataOverrides) QuestieDB.QueryItem = QuestieDBCompiler:GetDBHandle(itemBin, itemPtrs, QuestieDBCompiler:BuildSkipMap(QuestieDB.itemCompilerTypes, QuestieDB.itemCompilerOrder), QuestieDB.itemKeys, QuestieDB.itemDataOverrides) QuestieDB._QueryQuestSingle = QuestieDB.QueryQuest.QuerySingle QuestieDB._QueryNPCSingle = QuestieDB.QueryNPC.QuerySingle QuestieDB._QueryObjectSingle = QuestieDB.QueryObject.QuerySingle QuestieDB._QueryItemSingle = QuestieDB.QueryItem.QuerySingle QuestieDB.NPCPointers = QuestieDB.QueryNPC.pointers QuestieDB.QuestPointers = QuestieDB.QueryQuest.pointers QuestieDB.ObjectPointers = QuestieDB.QueryObject.pointers QuestieDB.ItemPointers = QuestieDB.QueryItem.pointers QuestieDB._QueryNPC = QuestieDB.QueryNPC.Query QuestieDB._QueryQuest = QuestieDB.QueryQuest.Query QuestieDB._QueryObject = QuestieDB.QueryObject.Query QuestieDB._QueryItem = QuestieDB.QueryItem.Query QuestieDB.QueryNPC = QuestieDB._QueryNPC QuestieDB.QueryQuest = QuestieDB._QueryQuest QuestieDB.QueryObject = QuestieDB._QueryObject QuestieDB.QueryItem = QuestieDB._QueryItem QuestieDB.QueryQuestSingle = QuestieDB._QueryQuestSingle QuestieDB.QueryNPCSingle = QuestieDB._QueryNPCSingle QuestieDB.QueryObjectSingle = QuestieDB._QueryObjectSingle QuestieDB.QueryItemSingle = QuestieDB._QueryItemSingle -- data has been corrected, ensure cache is empty (something might have accessed the api before questie initialized) _QuestieDB.questCache = {}; _QuestieDB.itemCache = {}; _QuestieDB.npcCache = {}; _QuestieDB.objectCache = {}; _QuestieDB.zoneCache = {}; --? This improves performance a lot, the regular functions still work but this is much faster because i caches checkRace = QuestieLib:TableMemoizeFunction(QuestiePlayer.HasRequiredRace) checkClass = QuestieLib:TableMemoizeFunction(QuestiePlayer.HasRequiredClass) --? Set the localized versions of these. QuestieCorrectionshiddenQuests = QuestieCorrections.hiddenQuests Questiedbcharhidden = Questie.db.char.hidden end function QuestieDB:GetObject(objectId) if self ~= QuestieDB then objectId = self end objectId = tonumber(objectId) if not objectId then return nil end if _QuestieDB.objectCache[objectId] then return _QuestieDB.objectCache[objectId]; end local settings = _GetLearnerSettings() local mode = settings and settings.dataSourceMode or "auto" local learnerRecord = _GetLearnerRecord("objects", objectId) local rawdata local override local hasLearnerRecord = false override = QuestieDB.objectDataOverrides and (QuestieDB.objectDataOverrides[objectId] or QuestieDB.objectDataOverrides[tostring(objectId)]) if mode == "learner" or QuestieDB:IsStoreMissing("objectData") then rawdata = learnerRecord if rawdata then hasLearnerRecord = true else -- No learner record: fall back to the override only for metadata. Its spawns -- are stripped below so learner-only mode never draws curated/static pins. rawdata = override end -- Learner mode: discard curated coords AscensionDB wrote into objectDataOverrides -- so learner data is used exclusively (mirrors GetNPC). override = nil else rawdata = QuestieDB.QueryObject(objectId, QuestieDB._objectAdapterQueryOrder) if not rawdata and learnerRecord and mode == "auto" then -- Only "auto" overlays learner data on top of the static DB. "static" -- and "none" must never silently fall back to learner records. rawdata = learnerRecord end end if not rawdata and not override then if not QuestieDB.private.missingObjectLog[objectId] then QuestieDB.private.missingObjectLog[objectId] = true Questie:Debug(Questie.DEBUG_INFO, "[QuestieDB:GetObject] data not found for objectID:", objectId) end return nil end local obj = { id = objectId, type = "object" } if override then -- Prefer override data (corrections); _MergeOverride checks both -- string keys (override.name) and numeric keys (override[1]) so -- AscensionDB and QuestieLearner numeric-key overrides are picked up. _MergeOverride(obj, override, rawdata, QuestieDB.objectKeys) else -- Use standard DB data local stringKey, intKey = next(QuestieDB.objectKeys) while stringKey do obj[stringKey] = rawdata[intKey] stringKey, intKey = next(QuestieDB.objectKeys, stringKey) end end -- Learner-only mode draws ONLY learner-recorded spawns (see GetNPC). if mode == "learner" and not hasLearnerRecord then obj.spawns = nil end _QuestieDB.objectCache[objectId] = obj; return obj; end function QuestieDB:GetItem(itemId) if self ~= QuestieDB then itemId = self end itemId = tonumber(itemId) if (not itemId) or (itemId == 0) then return nil end if _QuestieDB.itemCache[itemId] then return _QuestieDB.itemCache[itemId]; end local settings = _GetLearnerSettings() local mode = settings and settings.dataSourceMode or "auto" local learnerRecord = _GetLearnerRecord("items", itemId) local rawdata local override override = QuestieDB.itemDataOverrides and (QuestieDB.itemDataOverrides[itemId] or QuestieDB.itemDataOverrides[tostring(itemId)]) if mode == "learner" or QuestieDB:IsStoreMissing("itemData") then rawdata = learnerRecord if not rawdata then rawdata = override end else rawdata = QuestieDB.QueryItem(itemId, QuestieDB._itemAdapterQueryOrder) if not rawdata and learnerRecord and mode == "auto" then -- Only "auto" overlays learner data on top of the static DB. "static" -- and "none" must never silently fall back to learner records. rawdata = learnerRecord end end if not rawdata and not override then if not QuestieDB.private.missingItemLog[itemId] then QuestieDB.private.missingItemLog[itemId] = true Questie:Debug(Questie.DEBUG_INFO, "[QuestieDB:GetItem] data not found for itemID:", itemId) end return nil end local item = { Id = itemId, Sources = {}, Hidden = QuestieCorrections.questItemBlacklist[itemId] } if override then -- Prefer override data (corrections); _MergeOverride handles both -- string-keyed and numeric-keyed override formats. _MergeOverride(item, override, rawdata, QuestieDB.itemKeys) else -- Use standard DB data local stringKey, intKey = next(QuestieDB.itemKeys) while stringKey do item[stringKey] = rawdata[intKey] stringKey, intKey = next(QuestieDB.itemKeys, stringKey) end end local sources = item.Sources if item.npcDrops then local _npcId, npcId = next(item.npcDrops) while _npcId do table.insert(sources, { Id = npcId, Type = "monster", }) _npcId, npcId = next(item.npcDrops, _npcId) end end if item.vendors then local _npcId, npcId = next(item.vendors) while _npcId do table.insert(sources, { Id = npcId, Type = "monster", }) _npcId, npcId = next(item.vendors, _npcId) end end if item.objectDrops then local _k, v = next(item.objectDrops) while _k do table.insert(sources, { Id = v, Type = "object", }) _k, v = next(item.objectDrops, _k) end end return item end ---@param questId number ---@return boolean function QuestieDB.IsRepeatable(questId) local flags = QuestieDB.QueryQuestSingle(questId, "specialFlags") return flags and bitband(flags, 1) ~= 0 end local _boardQuestCache = {} --- Detects Ascension "board" quests — the Call Board / Contract Board repeatable --- bounties (e.g. NPC 24 "Outlaw's Contract Board"). These are given by a starter --- NPC/object whose name contains "board" but are NOT reliably flagged repeatable in --- the DB, so QuestieDB.IsRepeatable alone misses them. Used by the "hide repeatable --- quests below level 60" option so Callboard `!` markers are actually hidden (#10). --- Result is cached per quest since starter names never change at runtime. ---@param questId number ---@return boolean function QuestieDB.IsBoardQuest(questId) local cached = _boardQuestCache[questId] if cached ~= nil then return cached end local result = false local startedBy = QuestieDB.QueryQuestSingle(questId, "startedBy") if type(startedBy) == "table" then local function _AnyNameHasBoard(ids, querySingle) if type(ids) ~= "table" then return false end for _, id in ipairs(ids) do local name = querySingle(id, "name") if type(name) == "string" and string.find(string.lower(name), "board", 1, true) then return true end end return false end result = _AnyNameHasBoard(startedBy[1], QuestieDB.QueryNPCSingle) or _AnyNameHasBoard(startedBy[2], QuestieDB.QueryObjectSingle) end _boardQuestCache[questId] = result return result end local _dungeonQuestCache = {} local function _ZoneLooksDungeon(zoneId) if type(zoneId) ~= "number" or zoneId <= 0 then return false end if ZoneDB and ZoneDB.IsDungeonZone and ZoneDB:IsDungeonZone(zoneId) then return true end if not ZoneDB then return false end local alternativeZoneId = ZoneDB.GetAlternativeZoneId and ZoneDB:GetAlternativeZoneId(zoneId) if alternativeZoneId and ZoneDB.IsDungeonZone and ZoneDB:IsDungeonZone(alternativeZoneId) then return true end local parentZoneId = ZoneDB.GetParentZoneId and ZoneDB:GetParentZoneId(zoneId) return parentZoneId and ZoneDB.IsDungeonZone and ZoneDB:IsDungeonZone(parentZoneId) or false end local function _AnySpawnZoneLooksDungeon(ids, querySingle) if type(ids) ~= "table" or type(querySingle) ~= "function" then return false end for _, id in ipairs(ids) do local spawns = querySingle(id, "spawns") if type(spawns) == "table" then for zoneId in pairs(spawns) do if _ZoneLooksDungeon(zoneId) then return true end end end end return false end ---@param questId number ---@return boolean function QuestieDB.IsDailyQuest(questId) local flags = QuestieDB.QueryQuestSingle(questId, "questFlags") -- test a bit flag: (value % (2*flag) >= flag) return flags and (flags % QUEST_FLAGS_DAILY_X2) >= QUEST_FLAGS_DAILY end ---@param questId number ---@return boolean function QuestieDB.IsWeeklyQuest(questId) local flags = QuestieDB.QueryQuestSingle(questId, "questFlags") -- test a bit flag: (value % (2*flag) >= flag) return flags and (flags % QUEST_FLAGS_WEEKLY_X2) >= QUEST_FLAGS_WEEKLY end ---@param questId number ---@return boolean function QuestieDB.IsDungeonQuest(questId) local questType, _ = QuestieDB.GetQuestTagInfo(questId) if questType == 81 then return true end if questType ~= nil and questType ~= 0 then return false end local cached = _dungeonQuestCache[questId] if cached ~= nil then return cached end local result = false local zoneOrSort = QuestieDB.QueryQuestSingle(questId, "zoneOrSort") if _ZoneLooksDungeon(zoneOrSort) then result = true else local startedBy = QuestieDB.QueryQuestSingle(questId, "startedBy") if type(startedBy) == "table" then result = _AnySpawnZoneLooksDungeon(startedBy[1], QuestieDB.QueryNPCSingle) or _AnySpawnZoneLooksDungeon(startedBy[2], QuestieDB.QueryObjectSingle) end end _dungeonQuestCache[questId] = result return result end ---@param questId number ---@return boolean function QuestieDB.IsRaidQuest(questId) local questType, _ = QuestieDB.GetQuestTagInfo(questId) return questType == 62 end ---@param questId number ---@return boolean function QuestieDB.IsPvPQuest(questId) local questType, _ = QuestieDB.GetQuestTagInfo(questId) return questType == 41 end ---@param questId number ---@return boolean function QuestieDB.IsGroupQuest(questId) local questType, _ = QuestieDB.GetQuestTagInfo(questId) return questType == 1 end --[[ Commented out because not used anywhere ---@param questId number ---@return boolean function QuestieDB:IsAQWarEffortQuest(questId) return QuestieQuestBlacklist.AQWarEffortQuests[questId] end ]]-- ---@param class string ---@return number function QuestieDB:GetZoneOrSortForClass(class) return QuestieDB.sortKeys[class] end --- Wrapper function for the GetQuestTagInfo API to correct --- quests that are falsely marked by Blizzard ---@param questId number ---@return number|nil questType, string|nil questTag function QuestieDB.GetQuestTagInfo(questId) if questTagCorrections[questId] then return questTagCorrections[questId][1], questTagCorrections[questId][2] end local questType, questTag = GetQuestTagInfo(questId) return questType, questTag end ---@param questId number ---@return boolean function QuestieDB.IsActiveEventQuest(questId) --! If you edit the logic here, also edit in QuestieDB:IsLevelRequirementsFulfilled return QuestieEvent.activeQuests[questId] == true end ---@param exclusiveTo table ---@return boolean function QuestieDB:IsExclusiveQuestInQuestLogOrComplete(exclusiveTo) if (not exclusiveTo) then return false end local _exId, exId = next(exclusiveTo) while _exId do if Questie.db.char.complete[exId] then return true end _exId, exId = next(exclusiveTo, _exId) end return false end ---@param questId QuestId ---@param minLevel Level @The level a quest must have at least to be shown ---@param maxLevel Level @The level a quest can have at most to be shown ---@param playerLevel Level? @Pass player level to avoid calling UnitLevel or to use custom level ---@return boolean function QuestieDB.IsLevelRequirementsFulfilled(questId, minLevel, maxLevel, playerLevel) playerLevel = playerLevel or QuestiePlayer.GetPlayerLevel() local level, requiredLevel, requiredMaxLevel = QuestieLib.GetTbcLevel(questId, playerLevel) requiredMaxLevel = requiredMaxLevel or 0 --* QuestiePlayer.currentQuestlog[parentQuestId] logic is from QuestieDB.IsParentQuestActive, if you edit here, also edit there local parentQuestId = QuestieDB.QueryQuestSingle(questId, "parentQuest") if parentQuestId and QuestiePlayer.currentQuestlog[parentQuestId] then -- If the quest is in the player's log already, there's no need to do any logic here, it must already be available return true end --* QuestieEvent.activeQuests[questId] logic is from QuestieDB.IsParentQuestActive, if you edit here, also edit there if (Questie.db.profile.lowLevelStyle ~= Questie.LOWLEVEL_RANGE) and minLevel > requiredLevel and QuestieEvent.activeQuests[questId] then return true end if (Questie.IsSoD == true) and (QuestieDB.IsSoDRuneQuest(questId) == true) and (requiredLevel <= playerLevel) then -- Season of Discovery Rune quests are still shown when trivial return true end if maxLevel >= level then if (Questie.db.profile.lowLevelStyle ~= Questie.LOWLEVEL_ALL) and minLevel > level then -- The quest level is too low and trivial quests are not shown return false end else if (Questie.db.profile.lowLevelStyle == Questie.LOWLEVEL_RANGE) or maxLevel < requiredLevel then -- Either an absolute level range is set and maxLevel < level OR the maxLevel is manually set to a lower value return false end end if maxLevel < requiredLevel then -- Either the players level is not high enough or the maxLevel is manually set to a lower value return false end if requiredMaxLevel ~= 0 and playerLevel > requiredMaxLevel then -- The players level exceeds the requiredMaxLevel of a quest return false end return true end ---@param parentID number ---@return boolean function QuestieDB.IsParentQuestActive(parentID) --! If you edit the logic here, also edit in QuestieDB:IsLevelRequirementsFulfilled if (not parentID) or (parentID == 0) then return false end if QuestiePlayer.currentQuestlog[parentID] then return true end return false end --- Returns a table of [npcId] = true for NPCs that have verified learned data (Confidence >= 2) --- in a specific zone. Used to hide static database spawns in favor of verified ones. ---@param zoneId number ---@return table function QuestieDB.GetSuppressedNPCs(zoneId) local suppressed = {} local ld = Questie.dbLearner.global local mode = ld and ld.settings and ld.settings.dataSourceMode or "auto" if mode == "auto" or mode == "learner" then if ld and ld.settings and ld.settings.enabled and ld.npcs then local threshold = ld.settings.minConfidencePins or 2 local npcId, entry = next(ld.npcs) while npcId do local spawns = GetSpawnTable(entry, 7, 4) if entry.mc and entry.mc >= threshold and spawns and spawns[zoneId] then suppressed[npcId] = true end npcId, entry = next(ld.npcs, npcId) end end end return suppressed end --- Returns a table of [objectId] = true for Objects that have verified learned data (Confidence >= 2) --- in a specific zone. Used to hide static database spawns in favor of verified ones. ---@param zoneId number ---@return table function QuestieDB.GetSuppressedObjects(zoneId) local suppressed = {} local ld = Questie.dbLearner.global local mode = ld and ld.settings and ld.settings.dataSourceMode or "auto" if mode == "auto" or mode == "learner" then if ld and ld.settings and ld.settings.enabled and ld.objects then local threshold = ld.settings.minConfidencePins or 2 local objId, entry = next(ld.objects) while objId do local spawns = GetSpawnTable(entry, 4, 7) if entry.mc and entry.mc >= threshold and spawns and spawns[zoneId] then suppressed[objId] = true end objId, entry = next(ld.objects, objId) end end end return suppressed end ---@param preQuestGroup table ---@return boolean function QuestieDB:IsPreQuestGroupFulfilled(preQuestGroup) if not preQuestGroup then return true end for preQuestIndex=1, table.getn(preQuestGroup) do -- If a quest is not complete and no exlusive quest is complete, the requirement is not fulfilled if not Questie.db.char.complete[preQuestGroup[preQuestIndex]] then local preQuest = QuestieDB.QueryQuestSingle(preQuestGroup[preQuestIndex], "exclusiveTo") if (not preQuest) then return false end local anyExlusiveFinished = false for i=1, table.getn(preQuest) do if Questie.db.char.complete[preQuest[i]] then anyExlusiveFinished = true end end if not anyExlusiveFinished then return false end end end -- All preQuests are complete return true end ---@param preQuestSingle number[] ---@return boolean function QuestieDB:IsPreQuestSingleFulfilled(preQuestSingle) if (not preQuestSingle) then return true end for preQuestIndex=1, table.getn(preQuestSingle) do -- If a quest is complete the requirement is fulfilled if Questie.db.char.complete[preQuestSingle[preQuestIndex]] then return true end end -- No preQuest is complete return false end ---@param questId number ---@param debugPrint boolean? -- if true, IsDoable will print conclusions to debug channel ---@return boolean function QuestieDB.IsDoable(questId, debugPrint) --! Before changing any logic in QuestieDB.IsDoable, make sure --! to mirror the same logic to QuestieDB.IsDoableVerbose! -- IsDoable determines if the player is currently eligible for -- a quest, and returns that result as true/false in order to -- programmatically show/hide quests and determine further logic. -- IsDoableVerbose does the same logic, but returns human-readable -- explanations as a string for display in the UI. -- These functions are maintained separately for performance, -- because IsDoable is often called in a loop through every -- quest in the DB in order to update icons, while -- IsDoableVerbose is only called manually by the user. -- These are localized in the init function if QuestieCorrectionshiddenQuests[questId] then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Quest " .. questId .. " is hidden automatically!") end return false end if Questiedbcharhidden[questId] then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Quest " .. questId .. " is hidden manually!") end return false end if C_QuestLog.IsOnQuest(questId) == true then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Quest " .. questId .. " is eligible because Player is on the quest!") end return true end if QuestieDB.activeChildQuests[questId] then -- The parent quest is active, so this quest is doable if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Quest " .. questId .. " is eligible because it's a child quest and the parent is active!") end return true -- this scenario actually returns true, so it skips the rest of the later checks, because -- if we're on the parent quest then we implicitly know all other requirements are met end local requiredRaces = QuestieDB.QueryQuestSingle(questId, "requiredRaces") if (requiredRaces and not checkRace[requiredRaces]) then QuestieDB.autoBlacklist[questId] = "race" if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Race requirement not fulfilled for quest " .. questId) end return false end -- Check the preQuestSingle field where just one of the required quests has to be complete for a quest to show up local preQuestSingle = QuestieDB.QueryQuestSingle(questId, "preQuestSingle") if preQuestSingle then local isPreQuestSingleFulfilled = QuestieDB:IsPreQuestSingleFulfilled(preQuestSingle) if not isPreQuestSingleFulfilled then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Pre-quest requirement not fulfilled for quest " .. questId) end return false end end local requiredClasses = QuestieDB.QueryQuestSingle(questId, "requiredClasses") if (requiredClasses and not checkClass[requiredClasses]) then QuestieDB.autoBlacklist[questId] = "class" if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Class requirement not fulfilled for quest " .. questId) end return false end local requiredMinRep = QuestieDB.QueryQuestSingle(questId, "requiredMinRep") local requiredMaxRep = QuestieDB.QueryQuestSingle(questId, "requiredMaxRep") if (requiredMinRep or requiredMaxRep) then local aboveMinRep, hasMinFaction, belowMaxRep, hasMaxFaction = QuestieReputation:HasFactionAndReputationLevel(requiredMinRep, requiredMaxRep) if (not ((aboveMinRep and hasMinFaction) and (belowMaxRep and hasMaxFaction))) then --- If we haven't got the faction for min or max we blacklist it if not hasMinFaction or not hasMaxFaction then -- or not belowMaxRep -- This is something we could have done, but would break if you rep downwards QuestieDB.autoBlacklist[questId] = "rep" end if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Player does not meet reputation requirements for quest " .. questId) end return false end end local requiredSkill = QuestieDB.QueryQuestSingle(questId, "requiredSkill") if (requiredSkill) then local hasProfession, hasSkillLevel = QuestieProfessions:HasProfessionAndSkillLevel(requiredSkill) if (not (hasProfession and hasSkillLevel)) then --? We haven't got the profession so we blacklist it. if(not hasProfession) then QuestieDB.autoBlacklist[questId] = "skill" end if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Player does not meet profession requirements for quest " .. questId) end return false end end --? PreQuestGroup and PreQuestSingle are mutualy exclusive to eachother and preQuestSingle is more prevalent --? Only try group if single does not exist. if not preQuestSingle then -- Check the preQuestGroup field where every required quest has to be complete for a quest to show up local preQuestGroup = QuestieDB.QueryQuestSingle(questId, "preQuestGroup") if preQuestGroup then local isPreQuestGroupFulfilled = QuestieDB:IsPreQuestGroupFulfilled(preQuestGroup) if not isPreQuestGroupFulfilled then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Group pre-quest requirement not fulfilled for quest " .. questId) end return false end end end local parentQuest = QuestieDB.QueryQuestSingle(questId, "parentQuest") if parentQuest and parentQuest ~= 0 then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Quest " .. questId .. " has an inactive parent quest") end return false end local nextQuestInChain = QuestieDB.QueryQuestSingle(questId, "nextQuestInChain") if nextQuestInChain and nextQuestInChain ~= 0 then if Questie.db.char.complete[nextQuestInChain] or QuestiePlayer.currentQuestlog[nextQuestInChain] then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Follow up quests already completed or in the quest log for quest " .. questId) end return false end end -- Check if a quest which is exclusive to the current has already been completed or accepted -- If yes the current quest can't be accepted local ExclusiveQuestGroup = QuestieDB.QueryQuestSingle(questId, "exclusiveTo") if ExclusiveQuestGroup then -- fix (DO NOT REVERT, tested thoroughly) local _k, v = next(ExclusiveQuestGroup) while _k do if Questie.db.char.complete[v] or QuestiePlayer.currentQuestlog[v] then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Player has completed a quest exclusive with quest " .. questId) end return false end _k, v = next(ExclusiveQuestGroup, _k) end end if (not DailyQuests:IsActiveDailyQuest(questId)) then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Quest " .. questId .. " is a daily quest which isn't active today!") end return false end local requiredSpecialization = QuestieDB.QueryQuestSingle(questId, "requiredSpecialization") if (requiredSpecialization) and (requiredSpecialization > 0) then local hasSpecialization = QuestieProfessions:HasSpecialization(requiredSpecialization) if (not hasSpecialization) then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Player does not meet profession specialization requirements for quest " .. questId) end return false end end local requiredSpell = QuestieDB.QueryQuestSingle(questId, "requiredSpell") if (requiredSpell) and (requiredSpell ~= 0) then local hasSpell = IsSpellKnownOrOverridesKnown(math.abs(requiredSpell)) local hasProfSpell = IsPlayerSpell(math.abs(requiredSpell)) if (requiredSpell > 0) and (not hasSpell) and (not hasProfSpell) then --if requiredSpell is positive, we make the quest ineligible if the player does NOT have the spell if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Player does not meet learned spell requirements for quest " .. questId) end return false elseif (requiredSpell < 0) and (hasSpell or hasProfSpell) then --if requiredSpell is negative, we make the quest ineligible if the player DOES have the spell if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Player does not meet unlearned spell requirements for quest " .. questId) end return false end end -- Check and see if the Quest requires an achievement before showing as available if _QuestieDB:CheckAchievementRequirements(questId) == false then if debugPrint then Questie:Debug(Questie.DEBUG_SPAM, "[QuestieDB.IsDoable] Player does not meet achievement requirements for quest " .. questId) end return false end return true end ---@param questId number ---@param debugPrint boolean? -- if true, IsDoable will print conclusions to debug channel ---@param returnText boolean? -- if true, IsDoable will return plaintext explanation instead of true/false ---@param returnBrief boolean? -- if true and returnText is true, IsDoable will return a very brief explanation instead of a verbose one ---@return boolean function QuestieDB.IsDoableVerbose(questId, debugPrint, returnText, returnBrief) --! Before changing any logic in QuestieDB.IsDoable, make sure --! to mirror the same logic to QuestieDB.IsDoableVerbose! -- IsDoable determines if the player is currently eligible for -- a quest, and returns that result as true/false in order to -- programmatically show/hide quests and determine further logic. -- IsDoableVerbose does the same logic, but returns human-readable -- explanations as a string for display in the UI. -- These functions are maintained separately for performance, -- because IsDoable is often called in a loop through every -- quest in the DB in order to update icons, while -- IsDoableVerbose is only called manually by the user. if C_QuestLog.IsOnQuest(questId) == true then local msg = "Quest " .. questId .. " is active!" if returnText and returnBrief then return "Eligible: Player is on quest" elseif returnText and not returnBrief then return msg end end -- These are localized in the init function if QuestieCorrectionshiddenQuests[questId] then local msg = "Quest " .. questId .. " is hidden automatically!" if returnText and returnBrief then return "Unknown: Automatically blacklisted" elseif returnText and not returnBrief then return msg end end if Questiedbcharhidden[questId] then local msg = "Quest " .. questId .. " is hidden manually!" if returnText and returnBrief then return "Unknown: Manually blacklisted" elseif returnText and not returnBrief then return msg end end if Questiedbcharhidden[questId] then local msg = "Quest " .. questId .. " is hidden manually!" if returnText and returnBrief then return "Unknown: Manually blacklisted" elseif returnText and not returnBrief then return msg end end if QuestieDB.activeChildQuests[questId] then -- The parent quest is active, so this quest is doable local msg = "Quest " .. questId .. " is eligible because it's a child quest and the parent is active!" if returnText and returnBrief then return "Eligible: Parent active" elseif returnText and not returnBrief then return msg end end local requiredRaces = QuestieDB.QueryQuestSingle(questId, "requiredRaces") if (requiredRaces and not checkRace[requiredRaces]) then local msg = "Race requirement not fulfilled for quest " .. questId if returnText and returnBrief then return "Ineligible: Race requirement" elseif returnText and not returnBrief then return msg end end -- Check the preQuestSingle field where just one of the required quests has to be complete for a quest to show up local preQuestSingle = QuestieDB.QueryQuestSingle(questId, "preQuestSingle") if preQuestSingle then local isPreQuestSingleFulfilled = QuestieDB:IsPreQuestSingleFulfilled(preQuestSingle) if not isPreQuestSingleFulfilled then local msg = "Pre-quest requirement not fulfilled for quest " .. questId if returnText and returnBrief then return "Ineligible: Incomplete pre-quest" elseif returnText and not returnBrief then return msg end end end local requiredClasses = QuestieDB.QueryQuestSingle(questId, "requiredClasses") if (requiredClasses and not checkClass[requiredClasses]) then QuestieDB.autoBlacklist[questId] = "class" local msg = "Class requirement not fulfilled for quest " .. questId if returnText and returnBrief then return "Ineligible: Class requirement" elseif returnText and not returnBrief then return msg end end local requiredMinRep = QuestieDB.QueryQuestSingle(questId, "requiredMinRep") local requiredMaxRep = QuestieDB.QueryQuestSingle(questId, "requiredMaxRep") if (requiredMinRep or requiredMaxRep) then local aboveMinRep, hasMinFaction, belowMaxRep, hasMaxFaction = QuestieReputation:HasFactionAndReputationLevel(requiredMinRep, requiredMaxRep) if (not ((aboveMinRep and hasMinFaction) and (belowMaxRep and hasMaxFaction))) then --- If we haven't got the faction for min or max we blacklist it if not hasMinFaction or not hasMaxFaction then -- or not belowMaxRep -- This is something we could have done, but would break if you rep downwards QuestieDB.autoBlacklist[questId] = "rep" end local msg = "Player does not meet reputation requirements for quest " .. questId if returnText and returnBrief then return "Ineligible: Reputation requirement" elseif returnText and not returnBrief then return msg end end end local requiredSkill = QuestieDB.QueryQuestSingle(questId, "requiredSkill") if (requiredSkill) then local hasProfession, hasSkillLevel = QuestieProfessions:HasProfessionAndSkillLevel(requiredSkill) if (not (hasProfession and hasSkillLevel)) then --? We haven't got the profession so we blacklist it. if(not hasProfession) then QuestieDB.autoBlacklist[questId] = "skill" end local msg = "Player does not meet profession requirements for quest " .. questId if returnText and returnBrief then return "Ineligible: Profession requirement" elseif returnText and not returnBrief then return msg end end end --? PreQuestGroup and PreQuestSingle are mutualy exclusive to eachother and preQuestSingle is more prevalent --? Only try group if single does not exist. if not preQuestSingle then -- Check the preQuestGroup field where every required quest has to be complete for a quest to show up local preQuestGroup = QuestieDB.QueryQuestSingle(questId, "preQuestGroup") if preQuestGroup then local isPreQuestGroupFulfilled = QuestieDB:IsPreQuestGroupFulfilled(preQuestGroup) if not isPreQuestGroupFulfilled then local msg = "Group pre-quest requirement not fulfilled for quest " .. questId if returnText and returnBrief then return "Ineligible: Incomplete pre-quest group" elseif returnText and not returnBrief then return msg end end end end local parentQuest = QuestieDB.QueryQuestSingle(questId, "parentQuest") if parentQuest and parentQuest ~= 0 then local msg = "Quest " .. questId .. " has an inactive parent quest" if returnText and returnBrief then return "Ineligible: Inactive parent" elseif returnText and not returnBrief then return msg end end local nextQuestInChain = QuestieDB.QueryQuestSingle(questId, "nextQuestInChain") if nextQuestInChain and nextQuestInChain ~= 0 then if Questie.db.char.complete[nextQuestInChain] or QuestiePlayer.currentQuestlog[nextQuestInChain] then local msg = "Follow up quests already completed or in the quest log for quest " .. questId if returnText and returnBrief then return "Ineligible: Later quest completed" elseif returnText and not returnBrief then return msg end end end -- Check if a quest which is exclusive to the current has already been completed or accepted -- If yes the current quest can't be accepted local ExclusiveQuestGroup = QuestieDB.QueryQuestSingle(questId, "exclusiveTo") if ExclusiveQuestGroup then -- fix (DO NOT REVERT, tested thoroughly) local _v, v = next(ExclusiveQuestGroup) while _v do if Questie.db.char.complete[v] or QuestiePlayer.currentQuestlog[v] then local msg = "Player has completed a quest exclusive with quest " .. questId if returnText and returnBrief then return "Ineligible: Exclusive quest completed" elseif returnText and not returnBrief then return msg end end _v, v = next(ExclusiveQuestGroup, _v) end end if (not DailyQuests:IsActiveDailyQuest(questId)) then local msg = "Quest " .. questId .. " is a daily quest which isn't active today!" if returnText and returnBrief then return "Ineligible: Inactive daily" elseif returnText and not returnBrief then return msg end end local requiredSpecialization = QuestieDB.QueryQuestSingle(questId, "requiredSpecialization") if (requiredSpecialization) and (requiredSpecialization > 0) then local hasSpecialization = QuestieProfessions:HasSpecialization(requiredSpecialization) if (not hasSpecialization) then local msg = "Player does not meet profession specialization requirements for quest " .. questId if returnText and returnBrief then return "Ineligible: Profession specialization requirement" elseif returnText and not returnBrief then return msg end end end local requiredSpell = QuestieDB.QueryQuestSingle(questId, "requiredSpell") if (requiredSpell) and (requiredSpell ~= 0) then local hasSpell = IsSpellKnownOrOverridesKnown(math.abs(requiredSpell)) local hasProfSpell = IsPlayerSpell(math.abs(requiredSpell)) if (requiredSpell > 0) and (not hasSpell) and (not hasProfSpell) then --if requiredSpell is positive, we make the quest ineligible if the player does NOT have the spell local msg = "Player does not meet learned spell requirements for quest " .. questId if returnText and returnBrief then return "Ineligible: Spell not yet learned" elseif returnText and not returnBrief then return msg end elseif (requiredSpell < 0) and (hasSpell or hasProfSpell) then --if requiredSpell is negative, we make the quest ineligible if the player DOES have the spell local msg = "Player does not meet unlearned spell requirements for quest " .. questId if returnText and returnBrief then return "Ineligible: Already learned spell" elseif returnText and not returnBrief then return msg end end end -- Check and see if the Quest requires an achievement before showing as available if _QuestieDB:CheckAchievementRequirements(questId) == false then local msg = "Player does not meet achievement requirements for quest " .. questId if returnText and returnBrief then return "Ineligible: Achievement requirement" elseif returnText and not returnBrief then return msg end end if returnText then if IsQuestFlaggedCompleted(questId) then if returnBrief then return "Already complete" else return "Player has already completed quest " .. questId .. "!" end else return "Player is eligible for quest " .. questId .. "!" end else return "" end end ---@param questId number ---@return number @Complete = 1, Failed = -1, Incomplete = 0 function QuestieDB.IsComplete(questId) local questLogEntry = QuestLogCache.questLog_DO_NOT_MODIFY[questId] -- DO NOT MODIFY THE RETURNED TABLE local noQuestItem = not QuestieQuest:CheckQuestSourceItem(questId) --[[ pseudo: if no questLogEntry then return 0 if has questLogEntry.isComplete then return questLogEntry.isComplete if no objectives and an item is needed but not obtained then return 0 if no objectives then return 1 if all objectives are finished then return 1 return 0 --]] if not questLogEntry then return 0 end if questLogEntry.isComplete == 1 or questLogEntry.isComplete == -1 then return questLogEntry.isComplete end local objectives = questLogEntry.objectives if not objectives or table.getn(objectives) == 0 then -- Before assuming an empty objective list means the quest is done, check if we *expect* objectives from the DB. -- On WotLK private servers, GetQuestObjectives sometimes transiently returns nil during cache rebuilds, -- which leads to IsComplete prematurely returning 1 and unloading map icons in the middle of a quest. local expectedQuest = QuestieDB.GetQuest(questId) local expectedObjectives = expectedQuest and expectedQuest.ObjectiveData if expectedObjectives and table.getn(expectedObjectives) > 0 then return 0 end return (noQuestItem and 0) or 1 end -- Check if every objective is fulfilled. Use numFulfilled == numRequired rather than the -- finished flag, because finished is only set by QuestLogCache when the server sends -- isComplete=1 via GetQuestLogTitle. For quests using consumable items (e.g. Cold Iron Key -- for quest 12843), the key is consumed and numFulfilled/numRequired update immediately, -- but isComplete=1 may not arrive until the player physically visits the turn-in NPC. local allDone = true for i=1, table.getn(objectives) do local obj = objectives[i] if obj.numRequired and obj.numRequired > 0 and obj.numFulfilled ~= obj.numRequired then allDone = false break end end return allDone and 1 or 0 end ---@param self Quest ---@return number @Complete = 1, Failed = -1, Incomplete = 0 local function _IsComplete(self) return QuestieDB.IsComplete(self.Id) end ---@param questLevel number the level of the quest ---@return boolean @Returns true if the quest should be grey, false otherwise function QuestieDB.IsTrivial(questLevel) if questLevel == -1 then return false -- Scaling quests are never trivial end local levelDiff = questLevel - QuestiePlayer.GetPlayerLevel(); if (levelDiff >= 5) then return false -- Red elseif (levelDiff >= 3) then return false -- Orange elseif (levelDiff >= -2) then return false -- Yellow elseif (-levelDiff <= GetQuestGreenRange("player")) then return false -- Green else return true -- Grey end end ---@return number local _GetIconScale = function() return Questie.db.profile.objectScale or 1 end ---@param questId QuestId ---@return Quest|nil @The quest object or nil if the quest is missing function QuestieDB.GetQuest(questId, ...) -- /dump QuestieDB.GetQuest(867) -- Handle colon calling QuestieDB:GetQuest(questId) if type(questId) == "table" and questId == QuestieDB then questId = select(1, ...) end -- Handle string input from console or other sources if type(questId) == "string" then questId = tonumber(questId) end if not questId then Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieDB.GetQuest] No questId.") return nil end if _QuestieDB.questCache[questId] then return _QuestieDB.questCache[questId]; end local settings = _GetLearnerSettings() local mode = settings and settings.dataSourceMode or "auto" local learnerRecord = _GetLearnerRecord("quests", questId) local rawdata local overrideData overrideData = QuestieDB.questDataOverrides and (QuestieDB.questDataOverrides[questId] or QuestieDB.questDataOverrides[tostring(questId)]) if mode == "learner" or QuestieDB:IsStoreMissing("questData") then rawdata = learnerRecord if not rawdata then rawdata = overrideData end else rawdata = QuestieDB.QueryQuest(questId, QuestieDB._questAdapterQueryOrder) if not rawdata and learnerRecord and mode == "auto" then -- Only "auto" overlays learner data on top of the static DB. "static" -- and "none" must never silently fall back to learner records. rawdata = learnerRecord end end if (not rawdata) then rawdata = overrideData if (not rawdata) then if not QuestieDB.private.missingQuestLog[questId] then QuestieDB.private.missingQuestLog[questId] = true Questie:Debug(Questie.DEBUG_INFO, "[QuestieDB.GetQuest] rawdata is nil for questID:", questId) end if questId == 0 then Questie:Error("[QuestieDB.GetQuest] rawdata is nil for questID:", questId) end return nil end -- Build a minimal live-fallback quest from the quest log so the tracker still works local logEntry = QuestLogCache.GetQuest(questId) if not logEntry then return nil end local fallback = { Id = questId, name = logEntry.title or tostring(questId), level = logEntry.level or 0, questLevel = logEntry.level or 0, requiredLevel = 0, zoneOrSort = 0, questFlags = 0, specialFlags = 0, Starts = { CreatureStarts = {}, ObjectStarts = {}, ItemStarts = {} }, Finisher = { Type = "monster", Id = 0, Name = "" }, Objectives = {}, ObjectiveData = {}, ExtraObjectives= {}, _isLogFallback = true, isComplete = (logEntry.isComplete == 1), } _QuestieDB.questCache[questId] = fallback return fallback end ---@class Quest ---@field public Id QuestId ---@field public name Name ---@field public startedBy StartedBy ---@field public finishedBy FinishedBy ---@field public requiredLevel Level ---@field public questLevel Level ---@field public requiredRaces number @bitmask ---@field public requiredClasses number @bitmask ---@field public objectivesText string[] ---@field public triggerEnd { [1]: string, [2]: table} ---@field public objectives RawObjectives ---@field public sourceItemId ItemId ---@field public preQuestGroup QuestId[] ---@field public preQuestSingle QuestId[] ---@field public childQuests QuestId[] ---@field public inGroupWith QuestId[] ---@field public exclusiveTo QuestId[] ---@field public zoneOrSort ZoneOrSort ---@field public requiredSkill SkillPair ---@field public requiredMinRep ReputationPair ---@field public requiredMaxRep ReputationPair ---@field public requiredSourceItems ItemId[] ---@field public nextQuestInChain number ---@field public questFlags number @bitmask: see https://github.com/cmangos/issues/wiki/Quest_template#questflags ---@field public specialFlags number @bitmask: 1 = Repeatable, 2 = Needs event, 4 = Monthly reset (req. 1). See https://github.com/cmangos/issues/wiki/Quest_template#specialflags ---@field public parentQuest QuestId ---@field public reputationReward ReputationPair[] ---@field public extraObjectives ExtraObjective[] ---@field public requiredMaxLevel Level local QO = { Id = questId } -- General filling of the QuestObjective with all database values local questKeys = QuestieDB.questKeys local stringKey, intKey = next(questKeys) while stringKey do QO[stringKey] = rawdata[intKey] stringKey, intKey = next(questKeys, stringKey) end -- Support for index-aware objective mapping (QuestieLearner & custom overrides) if rawdata.objIndex then QO.objIndex = rawdata.objIndex end -- Apply overrides to QO directly (merging objectives if needed) if overrideData and rawdata ~= overrideData then local _sKey, _iKey = next(questKeys) while _sKey do local overrideVal = overrideData[_iKey] or overrideData[_sKey] if overrideVal ~= nil then if _sKey == "objectives" and QO.objectives then -- Merge objectives local _objIdx, _objList = next(overrideVal) while _objIdx do if not QO.objectives[_objIdx] then QO.objectives[_objIdx] = _objList else local _id, _data = next(_objList) while _id do QO.objectives[_objIdx][_id] = _data _id, _data = next(_objList, _id) end end _objIdx, _objList = next(overrideVal, _objIdx) end else QO[_sKey] = overrideVal end end _sKey, _iKey = next(questKeys, _sKey) end if overrideData.objIndex then QO.objIndex = overrideData.objIndex end end -- In auto mode, the SavedVariables learner payload should still enrich static DB quests. -- This is required for quests like 8325 where the static DB has only a stub record, -- but QuestieLearner has the real objective payload in QuestieLearnerDB.global.quests. if mode == "auto" and learnerRecord and rawdata ~= learnerRecord then local _sKey, _iKey = next(questKeys) while _sKey do local learnerVal = learnerRecord[_iKey] or learnerRecord[_sKey] if learnerVal ~= nil then if _sKey == "objectives" and QO.objectives then local _objIdx, _objList = next(learnerVal) while _objIdx do if not QO.objectives[_objIdx] then QO.objectives[_objIdx] = _objList else local _id, _data = next(_objList) while _id do QO.objectives[_objIdx][_id] = _data _id, _data = next(_objList, _id) end end _objIdx, _objList = next(learnerVal, _objIdx) end elseif QO[_sKey] == nil then QO[_sKey] = learnerVal end end _sKey, _iKey = next(questKeys, _sKey) end if learnerRecord.objIndex and not QO.objIndex then QO.objIndex = learnerRecord.objIndex end end local questLevel, requiredLevel = QuestieLib.GetTbcLevel(questId) QO.level = questLevel QO.requiredLevel = requiredLevel ---@type StartedBy local startedBy = QO.startedBy QO.Starts = { NPC = startedBy and startedBy[1], GameObject = startedBy and startedBy[2], Item = startedBy and startedBy[3], } QO.isHidden = rawdata.hidden or QuestieCorrections.hiddenQuests[questId] QO.Description = QO.objectivesText if QO.specialFlags then QO.IsRepeatable = bitband(QO.specialFlags, 1) ~= 0 end QO.IsComplete = _IsComplete ---@type FinishedBy local finishedBy = QO.finishedBy if finishedBy and finishedBy[1] then local _id, id = next(finishedBy[1]) while _id do if id then QO.Finisher = { Type = "monster", Id = id, ---@type Name @We have to hard-type it here because of the function Name = QuestieDB.QueryNPCSingle(id, "name") } end _id, id = next(finishedBy[1], _id) end end if finishedBy and finishedBy[2] then local _id, id = next(finishedBy[2]) while _id do if id then -- Some custom servers (like Ascension) incorrectly place Item IDs in the GameObject finisher array. -- We verify the object actually exists in the ObjectDB before assigning it as a Finisher, -- to prevent "rawdata is nil for objectID" errors during Arrow/Map updates. local objectName = QuestieDB.QueryObjectSingle(id, "name") if objectName then QO.Finisher = { Type = "object", Id = id, Name = objectName } else Questie:Debug(Questie.DEBUG_INFO, "[QuestieDB:GetQuest] Ignored invalid object finisher ID:", id, "for quest:", questId) end end _id, id = next(finishedBy[2], _id) end end --- to differentiate from the current quest log info. --- Quest objectives generated from DB+Corrections. --- Data itself is for example for monster type { Type = "monster", Id = 16518, Text = "Nestlewood Owlkin inoculated" } ---@type Objective[] QO.ObjectiveData = {} -- Support for index-aware objective mapping (QuestieLearner & custom overrides) -- This handles "kill-credit" scenarios and prevents "UI swapping" by pinning -- entities to the specific quest log objective index 'j'. if QO.objIndex then local _index, _data = next(QO.objIndex) while _index do if _data and _data.id then QO.ObjectiveData[_index] = { Type = _data.type or "monster", Id = _data.id, Text = _data.text or "", -- Store original mapping for reference _isIndexMapped = true, } end _index, _data = next(QO.objIndex, _index) end end ---@type RawObjectives local objectives = QO.objectives if objectives then if objectives[1] then local _creatureObjective, creatureObjective = next(objectives[1]) while _creatureObjective do if creatureObjective then -- Only insert if not already mapped via objIndex local alreadyMapped = false if QO.objIndex then for _, existing in pairs(QO.ObjectiveData) do if existing.Id == creatureObjective[1] then alreadyMapped = true break end end end if not alreadyMapped then ---@type NpcObjective table.insert(QO.ObjectiveData, { Type = "monster", Id = creatureObjective[1], Text = creatureObjective[2], HideCondition = creatureObjective[3], }) end end _creatureObjective, creatureObjective = next(objectives[1], _creatureObjective) end end if objectives[2] then local _objectObjective, objectObjective = next(objectives[2]) while _objectObjective do if objectObjective then ---@type ObjectObjective table.insert(QO.ObjectiveData, { Type = "object", Id = objectObjective[1], Text = objectObjective[2], HideCondition = objectObjective[3], }) end _objectObjective, objectObjective = next(objectives[2], _objectObjective) end end if objectives[3] then local _itemObjective, itemObjective = next(objectives[3]) while _itemObjective do if itemObjective then ---@type ItemObjective table.insert(QO.ObjectiveData, { Type = "item", Id = itemObjective[1], Text = itemObjective[2], HideCondition = itemObjective[3], }) end _itemObjective, itemObjective = next(objectives[3], _itemObjective) end end if objectives[4] then ---@type ReputationObjective table.insert(QO.ObjectiveData, { Type = "reputation", Id = objectives[4][1], RequiredRepValue = objectives[4][2] }) end if objectives[5] and type(objectives[5]) == "table" and table.getn(objectives[5]) > 0 then local _creditObjective, creditObjective = next(objectives[5]) while _creditObjective do ---@type KillObjective local killCreditObjective = { Type = "killcredit", IdList = creditObjective[1], RootId = creditObjective[2], Id = creditObjective[2] or (creditObjective[1] and creditObjective[1][1]) or 0, Text = creditObjective[3] } --? There are quest(s) which have the killCredit at first so we need to switch them -- Place the kill credit objective first if QuestieCorrections.killCreditObjectiveFirst[questId] then tinsert(QO.ObjectiveData, 1, killCreditObjective); else tinsert(QO.ObjectiveData, killCreditObjective); end _creditObjective, creditObjective = next(objectives[5], _creditObjective) end end if objectives[6] then local index, spellObjective = next(objectives[6]) while index do if spellObjective then ---@type SpellObjective table.insert(QO.ObjectiveData, { Type = "spell", Id = spellObjective[1], Text = spellObjective[2], ItemSourceId = spellObjective[3], }) QO.SpellItemId = spellObjective[3] end index, spellObjective = next(objectives[6], index) end end end -- Events need to be added at the end of ObjectiveData local triggerEnd = QO.triggerEnd if triggerEnd then ---@type TriggerEndObjective table.insert(QO.ObjectiveData, { Type = "event", Text = triggerEnd[1], Coordinates = triggerEnd[2] }) end local preQuestGroup = QO.preQuestGroup local preQuestSingle = QO.preQuestSingle if preQuestGroup and preQuestSingle and next(preQuestGroup) and next(preQuestSingle) then Questie:Debug(Questie.DEBUG_CRITICAL, "ERRRRORRRRRRR not mutually exclusive for questID:", questId) end --- Quest objectives generated from quest log in QuestieQuest.lua -> QuestieQuest:PopulateQuestLogInfo(quest) --- Includes also icons drawn to maps, and other stuff. ---@type table QO.Objectives = {} QO.SpecialObjectives = {} ---@type ItemId[] local requiredSourceItems = QO.requiredSourceItems if requiredSourceItems then local _itemId, itemId = next(requiredSourceItems) while _itemId do if itemId then -- Make sure requiredSourceItems aren't already an objective local itemObjPresent = false if objectives and objectives[3] then local _itemObjective, itemObjective = next(objectives[3]) while _itemObjective do if itemObjective then if itemId == itemObjective[1] then itemObjPresent = true break end end _itemObjective, itemObjective = next(objectives[3], _itemObjective) end end -- Make an objective for requiredSourceItem if not itemObjPresent then QO.SpecialObjectives[itemId] = { Type = "item", Id = itemId, ---@type string @We have to hard-type it here because of the function Description = QuestieDB.QueryItemSingle(itemId, "name") } end end _itemId, itemId = next(requiredSourceItems, _itemId) end end ---@type ExtraObjective[] local extraObjectives = QO.extraObjectives if extraObjectives then local index, o = next(extraObjectives) while index do local specialObjective = { Icon = o[2], Description = o[3], RealObjectiveIndex = o[4], HideCondition = o[6], } if o[1] then -- custom spawn specialObjective.spawnList = {{ Name = o[3], Spawns = o[1], Icon = o[2], GetIconScale = _GetIconScale, IconScale = _GetIconScale(), }} end if o[5] then -- db ref specialObjective.Type = o[5][1][1] specialObjective.Id = o[5][1][2] local spawnList = {} local _ref, ref = next(o[5]) while _ref do local callFn = _QuestieQuest.objectiveSpawnListCallTable[ref[1]] local spawnResult = callFn and callFn(ref[2], specialObjective) if spawnResult then local k, v = next(spawnResult) while k do -- we want to be able to override the icon in the corrections (e.g. Questie.ICON_TYPE_OBJECT on objects instead of Questie.ICON_TYPE_LOOT) v.Icon = o[2] spawnList[k] = v k, v = next(spawnResult, k) end end _ref, ref = next(o[5], _ref) end specialObjective.spawnList = spawnList end QO.SpecialObjectives[index] = specialObjective index, o = next(extraObjectives, index) end end _QuestieDB.questCache[questId] = QO return QO end QuestieDB._CreatureLevelCache = {} ---@param quest Quest ---@return table @List of creature names with their min-max level and rank function QuestieDB:GetCreatureLevels(quest) if quest and QuestieDB._CreatureLevelCache[quest.Id] then return QuestieDB._CreatureLevelCache[quest.Id] end local creatureLevels = {} local function _CollectCreatureLevels(npcIds) local _npcId, npcId = next(npcIds) while _npcId do local npc = QuestieDB:GetNPC(npcId) if npc and not creatureLevels[npc.name] then creatureLevels[npc.name] = {npc.minLevel, npc.maxLevel, npc.rank} end _npcId, npcId = next(npcIds, _npcId) end end if quest.objectives then if quest.objectives[1] then -- Killing creatures local _creatureObjective, creatureObjective = next(quest.objectives[1]) while _creatureObjective do local npcId = creatureObjective[1] _CollectCreatureLevels({npcId}) _creatureObjective, creatureObjective = next(quest.objectives[1], _creatureObjective) end end if quest.objectives[3] then -- Looting items from creatures local _itemObjective, itemObjective = next(quest.objectives[3]) while _itemObjective do local itemId = itemObjective[1] local npcIds = QuestieDB.QueryItemSingle(itemId, "npcDrops") if npcIds then _CollectCreatureLevels(npcIds) end _itemObjective, itemObjective = next(quest.objectives[3], _itemObjective) end end if quest.objectives[5] then -- Kill credit objectives local _creditObjective, creditObjective = next(quest.objectives[5]) while _creditObjective do local npcIds = creditObjective[1] if npcIds then _CollectCreatureLevels(npcIds) end _creditObjective, creditObjective = next(quest.objectives[5], _creditObjective) end end end if quest.Id then QuestieDB._CreatureLevelCache[quest.Id] = creatureLevels end return creatureLevels end local playerFaction = UnitFactionGroup("player") local factionReactions = { A = (playerFaction == "Alliance") or nil, H = (playerFaction == "Horde") or nil, AH = true, } -- -- --function __TEST() -- local questsWithoutTriggerEnd = { -- 869, -- 944, -- 1448, -- 6185, -- 9260, -- 9261, -- 9262, -- 9263, -- 9264, -- 9265, -- 12032, -- 12036, -- 12816, -- 12817, -- 13564, -- 13639, -- 13881, -- 13961, -- 14066, -- 14165, -- 14389, -- 24452, -- 24618, -- 25081, -- 25325, -- 25621, -- 25715, -- 25930, -- 26258, -- 26512, -- 26930, -- 26975, -- 27007, -- 27044, -- 27152, -- 27341, -- 27349, -- 27610, -- 27704, -- 28228, -- 28635, -- 28732, -- 29392, -- 29415, -- 29536, -- 29539, -- } -- -- local corrections = {} -- -- -- Read QuestieDB to check if the quest has a triggerEnd -- for _, questId in pairs(questsWithoutTriggerEnd) do -- local quest = QuestieDB.GetQuest(questId) -- if (not quest) then -- print("Quest " .. questId .. " not found in QuestieDB") -- corrections[questId] = true -- else -- if quest.triggerEnd then -- print("Quest " .. questId .. " has a triggerEnd") -- else -- print("Quest " .. questId .. " does not have a triggerEnd") -- corrections[questId] = true -- end -- end -- end -- -- Questie.db.global.__TEST = corrections --end local a = { [869] = true, [13564] = true, [13639] = true, [13881] = true, [13961] = true, [14165] = true, [27610] = true, [27044] = true, [26930] = true, [14066] = true, [28635] = true, [25930] = true, [27007] = true, [14389] = true, [27704] = true, [24618] = true, [26258] = true, [26512] = true, [29539] = true, [27341] = true, [29536] = true, [29415] = true, [29392] = true, [28732] = true, [28228] = true, [27152] = true, [27349] = true, [24452] = true, [25325] = true, [26975] = true, [25621] = true, [25715] = true, [25081] = true, } ---@param npcId number ---@return table function QuestieDB:GetNPC(npcId) if self ~= QuestieDB then npcId = self end npcId = tonumber(npcId) if not npcId or npcId == 0 then return nil end if _QuestieDB.npcCache[npcId] then return _QuestieDB.npcCache[npcId] end local settings = _GetLearnerSettings() local mode = settings and settings.dataSourceMode or "auto" local learnerRecord = _GetLearnerRecord("npcs", npcId) local rawdata local override local hasLearnerRecord = false override = QuestieDB.npcDataOverrides and (QuestieDB.npcDataOverrides[npcId] or QuestieDB.npcDataOverrides[tostring(npcId)]) local ascensionOwnsSpawns = QuestieDB.ascensionOverrideKeys and QuestieDB.ascensionOverrideKeys["NPC"] and QuestieDB.ascensionOverrideKeys["NPC"][npcId] and QuestieDB.ascensionOverrideKeys["NPC"][npcId][7] local curatedSpawnOverride = override if mode == "learner" or QuestieDB:IsStoreMissing("npcData") then rawdata = learnerRecord if rawdata then hasLearnerRecord = true if override and (type(rawdata[7]) ~= "table" or next(rawdata[7]) == nil) and not ascensionOwnsSpawns then local overrideSpawns = override[7] or override.spawns if type(overrideSpawns) == "table" and next(overrideSpawns) then local mergedRawdata = {} for k, v in pairs(rawdata) do mergedRawdata[k] = v end mergedRawdata[7] = CopySpawnTable(overrideSpawns) rawdata = mergedRawdata end end else -- No learner record: fall back to the override only for metadata (name, etc.). -- Its spawns are stripped below so learner-only mode never draws curated/static pins. rawdata = override end -- Learner mode: discard any curated coords that AscensionDB's -- _Asc_MergeInto wrote into npcDataOverrides. The learner's own -- coords come from rawdata (learnerRecord), not from the override. override = nil else rawdata = QuestieDB.QueryNPC(npcId, QuestieDB._npcAdapterQueryOrder) if not rawdata and learnerRecord and mode == "auto" then -- Only "auto" overlays learner data on top of the static DB. "static" -- and "none" must never silently fall back to learner records. rawdata = learnerRecord end end if not rawdata and not override then if not QuestieDB.private.missingNpcLog[npcId] then QuestieDB.private.missingNpcLog[npcId] = true Questie:Debug(Questie.DEBUG_INFO, "[QuestieDB:GetNPC] data not found for npcID:", npcId) end return nil end local npcKeys = QuestieDB.npcKeys local npc = { id = npcId, type = "monster", } if override then -- Prefer override data (corrections); _MergeOverride handles both -- string-keyed and numeric-keyed override formats. This is essential -- for AscensionDB and QuestieLearner data which use numeric keys. _MergeOverride(npc, override, rawdata, npcKeys) else -- Use standard DB data local stringKey, intKey = next(npcKeys) while stringKey do npc[stringKey] = rawdata[intKey] stringKey, intKey = next(npcKeys, stringKey) end end -- AscensionDB hand-curates spawn coordinates for Ascension-specific zones -- (e.g. Sunstrider's Mana Wyrm 15274). When AscensionDB owns this NPC's spawns, -- use ONLY those curated coords and discard the learner-record zones that -- _MergeOverride deep-merged in: the learner's Sunstrider coords are frequently -- stored under the wrong map/zone (Sunstrider 1241 vs Eversong space), which -- otherwise leaks in as pins in the wrong corner of the map. In learner mode -- rawdata IS the learner record, so the bad zone survives the merge because -- AscensionDB has no entry to displace it — this check removes it. Direct -- ascensionOverrideKeys lookup (NOT IsAscensionProtected, which returns false -- in learner mode). if ascensionOwnsSpawns and curatedSpawnOverride then local curated = curatedSpawnOverride[7] or curatedSpawnOverride.spawns if curated and next(curated) then npc.spawns = CopySpawnTable(curated) end end local friendlyToFaction = npc.friendlyToFaction npc.friendly = (not friendlyToFaction) and true or factionReactions[friendlyToFaction] if (not npc.spawns or next(npc.spawns) == nil) and mode == "learner" then local guidSpawns = rawdata[8] local learnedSpawns = _BuildSpawnTableFromGuidEvidence(guidSpawns) if learnedSpawns then npc.spawns = learnedSpawns end end -- Learner-only mode draws ONLY learner-recorded spawns. When this NPC has no learner -- record, rawdata fell back to the curated/override entry purely for metadata; drop its -- spawns so AscensionDB/static coordinates are not rendered as pins in learner mode. if mode == "learner" and not hasLearnerRecord then npc.spawns = nil end _QuestieDB.npcCache[npcId] = npc return npc end --[[ https://github.com/cmangos/issues/wiki/AreaTable.dbc Example to differentiate between Dungeon and Zone infront of a Dungeon: 1337 Uldaman = The Dungeon (MapID ~= 0, AreaID = 0) 1517 Uldaman = Cave infront of the Dungeon (MapID = 0, AreaID = 3 (Badlands)) Check `l10n.zoneLookup` for the available IDs ]] ---@param zoneId number ---@return table function QuestieDB:GetQuestsByZoneId(zoneId) if not zoneId then return nil; end -- is in cache return that if _QuestieDB.zoneCache[zoneId] then return _QuestieDB.zoneCache[zoneId] end local zoneQuests = {}; local alternativeZoneID = ZoneDB:GetAlternativeZoneId(zoneId) local function processQuest(qid) if zoneQuests[qid] then return end local quest = QuestieDB.GetQuest(qid); if quest then if quest.zoneOrSort > 0 then if (quest.zoneOrSort == zoneId or (alternativeZoneID and quest.zoneOrSort == alternativeZoneID)) then zoneQuests[qid] = quest; end elseif quest.Starts.NPC and (not zoneQuests[qid]) then local npc = QuestieDB:GetNPC(quest.Starts.NPC[1]); if npc and npc.friendly and npc.spawns then local zone, _ = next(npc.spawns) while zone do if zone == zoneId or (alternativeZoneID and zone == alternativeZoneID) then zoneQuests[qid] = quest; break end zone, _ = next(npc.spawns, zone) end end elseif quest.Starts.GameObject and (not zoneQuests[qid]) then local obj = QuestieDB:GetObject(quest.Starts.GameObject[1]); if obj and obj.spawns then local zone, _ = next(obj.spawns) while zone do if zone == zoneId or (alternativeZoneID and zone == alternativeZoneID) then zoneQuests[qid] = quest; break end zone, _ = next(obj.spawns, zone) end end end end end -- loop over all quests to populate a zone local dataSource = QuestieDB.QuestPointers or QuestieDB.questData if dataSource then local qid, _ = next(dataSource) while qid do processQuest(qid) qid, _ = next(dataSource, qid) end end -- Loop over plugin-injected quest overrides if QuestieDB.questDataOverrides then local qid, _ = next(QuestieDB.questDataOverrides) while qid do processQuest(qid) qid, _ = next(QuestieDB.questDataOverrides, qid) end end _QuestieDB.zoneCache[zoneId] = zoneQuests; return zoneQuests; end --------------------------------------------------------------------------------------------------- -- Modifications to objectDB function _QuestieDB:DeleteGatheringNodes() local prune = { -- gathering nodes 1617,1618,1619,1620,1621,1622,1623,1624,1628, -- herbs 1731,1732,1733,1734,1735,123848,150082,175404,176643,177388,324,150079,176645,2040,123310 -- mining } local objectSpawnsKey = QuestieDB.objectKeys.spawns for i=1, table.getn(prune) do local id = prune[i] if QuestieDB.objectData[id] then QuestieDB.objectData[id][objectSpawnsKey] = nil end end end --------------------------------------------------------------------------------------------------- -- Modifications to questDB function _QuestieDB:CheckAchievementRequirements(questId) -- So far the only Quests that we know of that requires an earned Achievement are the ones offered by: -- https://www.wowhead.com/wotlk/npc=35094/crusader-silverdawn -- Get Kraken (14108) -- The Fate Of The Fallen (14107) -- This NPC requires these earned Achievements baseed on a Players home faction: -- https://www.wowhead.com/wotlk/achievement=2817/exalted-argent-champion-of-the-alliance -- https://www.wowhead.com/wotlk/achievement=2816/exalted-argent-champion-of-the-horde if questId == 14101 or questId == 14102 or questId == 14104 or questId == 14105 or questId == 14107 or questId == 14108 then local retN = QuestieCompat.Is335 and 4 or 13 if select(retN, GetAchievementInfo(2817)) or select(retN, GetAchievementInfo(2816)) then return true end return false end end function _QuestieDB:HideClassAndRaceQuests() local questKeys = QuestieDB.questKeys local _, entry = next(QuestieDB.questData) while entry do -- check requirements, set hidden flag if not met local requiredClasses = entry[questKeys.requiredClasses] if (requiredClasses) and (requiredClasses ~= 0) then if (not QuestiePlayer.HasRequiredClass(requiredClasses)) then entry.hidden = true end end local requiredRaces = entry[questKeys.requiredRaces] if (requiredRaces) and (requiredRaces ~= 0) and (requiredRaces ~= 255) then if (not QuestiePlayer.HasRequiredRace(requiredRaces)) then entry.hidden = true end end _, entry = next(QuestieDB.questData, _) end Questie:Debug(Questie.DEBUG_DEVELOP, "Other class and race quests hidden"); end -- This function is intended for usage with Gossip and Greeting frames, where there's a list of quests but no QuestIDs are -- obtainable until entering the specific quest dialog. -- This is a bruteforce method for obtaining a QuestID with no input other than a quest name, and the ID of the questgiver. -- It compares the name of the quest entry with the names of every quest that questgiver can either start or end. ---@param name string? @The name of the quest entry ---@param questgiverGUID string? @Should be UnitGUID("questnpc") ---@param questStarter boolean @Should be True if this is an available quest, False if this is an "active" quest (quest ender) ---@return number function QuestieDB.GetQuestIDFromName(name, questgiverGUID, questStarter) local questID = 0 -- worst case, we end up returning an ID of 0 if we can't find a match; any function relying on this one should handle 0 cleanly if questgiverGUID then local questgiverID = tonumber(questgiverGUID:match("-(%d+)-%x+$"), 10) local unit_type = strsplit("-", questgiverGUID) local questsStarted local questsEnded if unit_type == "Creature" then -- if questgiver is an NPC questsStarted = QuestieDB.QueryNPCSingle(questgiverID, "questStarts") questsEnded = QuestieDB.QueryNPCSingle(questgiverID, "questEnds") elseif unit_type == "GameObject" then -- if questgiver is an object (it's rare for an object to have a gossip/greeting frame, but Wanted Boards exist; see object 2713) questsStarted = QuestieDB.QueryObjectSingle(questgiverID, "questStarts") questsEnded = QuestieDB.QueryObjectSingle(questgiverID, "questEnds") else return questID; -- If the questgiver is not an NPC or object, bail! end -- iterate through every questEnds entry in our questgiver's DB, and check if each quest name matches this greeting frame entry if questStarter == true then if questsStarted then local _id, id = next(questsStarted) while _id do if (name == QuestieDB.QueryQuestSingle(id, "name")) and (QuestieDB.IsDoable(id)) then -- the QuestieDB.IsDoable check is important to filter out identically named quests questID = id end _id, id = next(questsStarted, _id) end elseif Questie.IsSoD == false then -- don't print these errors in SoD, as we expect missing data when new quests release; debug offers will handle these scenarios instead Questie:Error("Database mismatch! No entries found that match quest name. Please report this on Github or Discord!") Questie:Error("Queststarter is: " .. unit_type .. " " .. questgiverID) Questie:Error("Quest name is: " .. name) Questie:Error("Client info is: " .. GetBuildInfo() .. "; " .. QuestieLib:GetAddonVersionString()) end else if questsEnded then local _id, id = next(questsEnded) while _id do if (name == QuestieDB.QueryQuestSingle(id, "name")) and (QuestieDB.IsDoable(id)) and QuestiePlayer.currentQuestlog[id] then questID = id end _id, id = next(questsEnded, _id) end elseif Questie.IsSoD == false then -- don't print these errors in SoD, as we expect missing data when new quests release; debug offers will handle these scenarios instead Questie:Error("Database mismatch! No entries found that match quest name. Please report this on Github or Discord!") Questie:Error("Questender is: " .. unit_type .. " " .. questgiverID) Questie:Error("Quest name is: " .. name) Questie:Error("Client info is: " .. GetBuildInfo() .. "; " .. QuestieLib:GetAddonVersionString()) end end end return questID; end QuestieDB.waypointPresets = { ORGRIMS_HAMMER = {[ZoneDB.zoneIDs.ICECROWN]={{{62.37,30.60},{61.93,30.93},{61.48,31.24},{61.08,31.55},{60.74,31.92},{60.46,32.44},{60.26,33.11},{60.14,33.85},{60.11,34.63},{60.17,35.35},{60.31,36.01},{60.56,36.66},{60.84,37.33},{61.15,38.00},{61.44,38.67},{61.71,39.28},{62.00,39.92},{62.31,40.55},{62.60,41.20},{62.90,41.83},{63.05,42.20},{63.33,42.85},{63.58,43.53},{63.85,44.19},{64.08,44.86},{64.33,45.50},{64.45,45.87},{64.69,46.56},{64.94,47.21},{65.16,47.87},{65.43,48.51},{65.71,49.15},{66.03,49.77},{66.36,50.46},{66.72,51.10},{67.07,51.67},{67.41,52.08},{67.82,52.37},{68.31,52.47},{68.80,52.38},{69.23,51.98},{69.45,51.56},{69.57,51.13},{69.67,50.59},{69.73,49.96},{69.77,49.26},{69.79,48.48},{69.80,47.62},{69.79,46.68},{69.79,45.68},{69.78,44.90},{69.78,44.25},{69.76,43.55},{69.75,42.80},{69.72,42.01},{69.70,41.20},{69.67,40.38},{69.64,39.54},{69.61,38.71},{69.58,37.88},{69.55,37.07},{69.52,36.28},{69.49,35.54},{69.46,34.83},{69.45,34.18},{69.42,33.50},{69.41,32.46},{69.42,31.52},{69.45,30.67},{69.47,29.92},{69.48,29.25},{69.46,28.65},{69.42,28.12},{69.35,27.83},{69.11,27.19},{68.71,26.77},{68.23,26.54},{67.71,26.51},{67.24,26.55},{66.81,26.76},{66.35,27.09},{65.90,27.52},{65.44,27.95},{65.01,28.39},{64.58,28.73},{64.16,29.10},{63.74,29.43},{63.34,29.79},{62.94,30.11},{62.65,30.37},{62.37,30.60}}}}, THE_SKYBREAKER = {[ZoneDB.zoneIDs.ICECROWN]={{{63.59,52.34},{63.44,51.88},{63.30,51.52},{63.15,51.19},{63.01,50.85},{62.85,50.52},{62.68,50.17},{62.50,49.78},{62.31,49.36},{62.09,48.88},{61.86,48.33},{61.81,48.20},{61.67,47.88},{61.52,47.52},{61.37,47.13},{61.19,46.74},{61.02,46.32},{60.84,45.88},{60.65,45.44},{60.46,44.99},{60.28,44.53},{60.09,44.08},{59.90,43.63},{59.72,43.18},{59.54,42.75},{59.36,42.34},{59.20,41.94},{59.04,41.56},{58.89,41.22},{58.75,40.87},{58.52,40.30},{58.32,39.77},{58.14,39.27},{57.97,38.80},{57.81,38.39},{57.65,38.03},{57.49,37.72},{57.31,37.50},{57.12,37.34},{56.87,37.29},{56.57,37.39},{56.27,37.60},{55.97,37.88},{55.72,38.23},{55.53,38.61},{55.43,38.95},{55.43,39.09},{55.46,39.49},{55.52,39.92},{55.62,40.39},{55.76,40.88},{55.89,41.38},{56.02,41.85},{56.17,42.25},{56.33,42.68},{56.51,43.13},{56.70,43.56},{56.88,43.99},{57.05,44.39},{57.22,44.74},{57.42,45.15},{57.64,45.52},{57.83,45.81},{58.03,46.14},{58.23,46.56},{58.41,46.90},{58.60,47.25},{58.80,47.62},{59.00,48.03},{59.19,48.46},{59.36,48.84},{59.53,49.22},{59.69,49.63},{59.86,50.04},{60.03,50.46},{60.19,50.90},{60.36,51.32},{60.51,51.74},{60.65,52.17},{60.79,52.59},{60.94,53.02},{61.07,53.46},{61.23,53.89},{61.39,54.30},{61.55,54.72},{61.70,55.18},{61.88,55.65},{62.05,56.14},{62.23,56.58},{62.43,56.95},{62.65,57.21},{62.87,57.30},{62.95,57.27},{63.22,57.16},{63.52,56.97},{63.81,56.68},{64.07,56.32},{64.26,55.91},{64.33,55.47},{64.30,55.11},{64.25,54.72},{64.16,54.30},{64.04,53.84},{63.91,53.36},{63.76,52.88},{63.59,52.34}}}}, } -- ========================= -- Ascension custom DB merge -- ========================= local function _Asc_LoadIfString(data, label) if not data then return nil end if type(data) == "string" then local fn, err = loadstring(data) if not fn then if Questie and Questie.Debug then Questie:Debug(Questie.DEBUG_CRITICAL, "[AscensionDB] loadstring failed for " .. tostring(label) .. ": " .. tostring(err)) end return nil end local ok, tbl = pcall(fn) if not ok then if Questie and Questie.Debug then Questie:Debug(Questie.DEBUG_CRITICAL, "[AscensionDB] executing chunk failed for " .. tostring(label) .. ": " .. tostring(tbl)) end return nil end return tbl end return data end --- Returns the data-source label for a pin/spawn, or nil if it cannot be determined. --- Accuracy rule: never guess from global mode. Only returns a label backed by real --- evidence — the per-spawn `isLearned` flag, an AscensionDB curated-override entry, or a --- learner record for the entity — and otherwise reports the base "Questie DB". ---@param entityType string @"NPC" | "OBJECT" | "QUEST" | "ITEM" ---@param id number|nil ---@param spawnData table|nil @optional per-spawn entry from a spawnList (its .isLearned wins) ---@return string|nil function QuestieDB.GetPinDataSource(entityType, id, spawnData) -- Per-spawn truth is the most precise signal when available. if spawnData and spawnData.isLearned then return "Learner" end if not id then return nil end local settings = _GetLearnerSettings() local mode = settings and settings.dataSourceMode or "auto" -- Does the learner have a record for this entity? local learner = Questie.dbLearner and Questie.dbLearner.global local bucket = learner and ((entityType == "OBJECT" and learner.objects) or (entityType == "QUEST" and learner.quests) or (entityType == "ITEM" and learner.items) or learner.npcs) local hasLearner = bucket and bucket[id] ~= nil -- In learner-only mode the displayed spawns ARE the learner's (GetNPC/GetObject -- return the learner record and discard curated coords), so the learner is the -- authoritative source even for AscensionDB-curated entities. Without this, a -- freshly-learned curated NPC was mislabelled "AscensionDB". if mode == "learner" and hasLearner then return "Learner" end -- AscensionDB-curated entity (authoritative in auto/static modes). local ascKeys = QuestieDB.ascensionOverrideKeys and QuestieDB.ascensionOverrideKeys[entityType] if ascKeys and ascKeys[id] and next(ascKeys[id]) then return "AscensionDB" end if hasLearner then return "Learner" end return "Questie DB" end local function _Asc_ProtectField(dbType, id, key) QuestieDB.ascensionOverrideKeys = QuestieDB.ascensionOverrideKeys or {} QuestieDB.ascensionOverrideKeys[dbType] = QuestieDB.ascensionOverrideKeys[dbType] or {} QuestieDB.ascensionOverrideKeys[dbType][id] = QuestieDB.ascensionOverrideKeys[dbType][id] or {} QuestieDB.ascensionOverrideKeys[dbType][id][key] = true end local function _Asc_MergeInto(dst, src, dbType) if type(dst) ~= "table" or type(src) ~= "table" then return end local id, entry = next(src) while id do local existing = dst[id] if type(existing) == "table" and type(entry) == "table" then local key, value = next(entry) while key do existing[key] = value _Asc_ProtectField(dbType, id, key) key, value = next(entry, key) end else dst[id] = entry if type(entry) == "table" then local key = next(entry) while key do _Asc_ProtectField(dbType, id, key) key = next(entry, key) end end end id, entry = next(src, id) end end function QuestieDB:LoadAscensionQuestData() local A = QuestieLoader:ImportModule("AscensionDB") if not A then return end local data = _Asc_LoadIfString(A.questData, "AscensionDB.questData") QuestieDB.questDataOverrides = QuestieDB.questDataOverrides or {} _Asc_MergeInto(QuestieDB.questDataOverrides, data, "QUEST") end function QuestieDB:LoadAscensionNpcData() local A = QuestieLoader:ImportModule("AscensionDB") if not A then return end local data = _Asc_LoadIfString(A.npcData, "AscensionDB.npcData") QuestieDB.npcDataOverrides = QuestieDB.npcDataOverrides or {} _Asc_MergeInto(QuestieDB.npcDataOverrides, data, "NPC") end function QuestieDB:LoadAscensionObjectData() local A = QuestieLoader:ImportModule("AscensionDB") if not A then return end local data = _Asc_LoadIfString(A.objectData, "AscensionDB.objectData") QuestieDB.objectDataOverrides = QuestieDB.objectDataOverrides or {} _Asc_MergeInto(QuestieDB.objectDataOverrides, data, "OBJECT") end function QuestieDB:LoadAscensionItemData() local A = QuestieLoader:ImportModule("AscensionDB") if not A then return end local data = _Asc_LoadIfString(A.itemData, "AscensionDB.itemData") QuestieDB.itemDataOverrides = QuestieDB.itemDataOverrides or {} _Asc_MergeInto(QuestieDB.itemDataOverrides, data, "ITEM") end return QuestieDB