Files
Questie-X/Database/QuestieDB.lua
T
Xurkon fa37ab8eb5 feat(tooltip): ElvUI style without ElvUI; source in secondary only; fix learner source label
- ElvUI tooltip style: new 'ElvUI tooltip style' option (General tab, on by
  default) skins GameTooltip/WorldMapTooltip/ItemRefTooltip/shopping tooltips
  and the secondary learner frame with ElvUI's transparent flat look (dark bg
  + thin 1px border) when ElvUI is not installed. Corrected the secondary
  frame fallback that used the chunky WoW border. No-op when ElvUI is loaded.

- Source attribution now shows ONLY inside the secondary learner tooltip when
  'Use secondary learner tooltip' is enabled; removed from the main NPC/object/
  item tooltip and gated off entirely when the secondary tooltip is disabled
  (map-pin source gated the same way).

- Fixed learner-learned pins mislabelled 'AscensionDB': GetPinDataSource is now
  mode-aware and returns 'Learner' in learner mode when the entity has a learner
  record, even if AscensionDB also curates it (curated coords are discarded by
  GetNPC/GetObject in learner mode anyway).

Test-neutral (145 successes / same 7 pre-existing failures + 1 error).
2026-06-09 21:52:55 -05:00

2728 lines
107 KiB
Lua

---@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<number, AutoBlacklistString>
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<number, {[1]: number, [2]: string}>
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<number, boolean>
local checkRace
---A Memoized table for function Quest:CheckClass
---
---Usage: checkRace[requiredClasses]
---@type table<number, boolean>
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
---@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)
return questType == 81
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<number, number>
---@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<number, boolean>
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<number, boolean>
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<number, number>
---@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 expectedObjectives = QuestieDB.GetQuest(questId) and QuestieDB.GetQuest(questId).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<AreaId, CoordPair[]>}
---@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<ObjectiveIndex, QuestObjective>
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<string, 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)])
if mode == "learner" or QuestieDB:IsStoreMissing("npcData") then
rawdata = learnerRecord
if rawdata then
hasLearnerRecord = true
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 override and QuestieDB.ascensionOverrideKeys and QuestieDB.ascensionOverrideKeys["NPC"]
and QuestieDB.ascensionOverrideKeys["NPC"][npcId]
and QuestieDB.ascensionOverrideKeys["NPC"][npcId][7] then
local curated = override[7] or override.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