Files
pfQuest/database.lua

2688 lines
85 KiB
Lua

-- multi api compat
local compat = pfQuestCompat
-- Performance: cache frequently-used globals
local pairs, ipairs, next = pairs, ipairs, next
local strfind, strlower, strlen = strfind, strlower, strlen
local format = string.format
local min, max, abs = math.min, math.max, math.abs
local floor, ceil = floor or math.floor, ceil or math.ceil
local band = bit.band
local getn, insert, concat = table.getn, table.insert, table.concat
local tostring, tonumber, type = tostring, tonumber, type
local unpack = unpack
local GetTime = GetTime
local UnitLevel, UnitRace, UnitClass = UnitLevel, UnitRace, UnitClass
local UnitFactionGroup, UnitName, UnitSex = UnitFactionGroup, UnitName, UnitSex
-- Ensure pfQuestConfig.path exists (fallback if config.lua failed to set it)
if not pfQuestConfig then
pfQuestConfig = CreateFrame("Frame", "pfQuestConfig", UIParent)
pfQuestConfig:Hide()
end
if not pfQuestConfig.path then
pfQuestConfig.path = "Interface\\AddOns\\pfQuest"
end
pfDatabase = { icons = {} }
local loc = GetLocale()
local dbs =
{ "items", "quests", "quests-itemreq", "objects", "units", "zones", "professions", "areatrigger", "refloot" }
local noloc = { items = true, quests = true, objects = true, units = true }
pfDB.locales = {
["enUS"] = "English",
["koKR"] = "Korean",
["frFR"] = "French",
["deDE"] = "German",
["zhCN"] = "Chinese (Simplified)",
["zhTW"] = "Chinese (Traditional)",
["esES"] = "Spanish",
["ruRU"] = "Russian",
["ptBR"] = "Portuguese",
}
-- Patch databases to further expansions
local function patchtable(base, diff)
for k, v in pairs(diff) do
if type(v) == "string" and v == "_" then
base[k] = nil
else
base[k] = v
end
end
end
-- Return the best cluster point for a coordiante table
local best, neighbors = { index = 1, neighbors = 0 }, 0
local cache, cacheindex = {}, nil
local cache_count = 0
local CLUSTER_CACHE_MAX = 200
local ymin, ymax, xmin, xmax
local function getcluster(tbl, name)
local count = 0
best.index, best.neighbors = 1, 0
cacheindex = format("%s:%s", name, getn(tbl))
-- calculate new cluster if nothing is cached
if not cache[cacheindex] then
-- evict cache if too large
if cache_count >= CLUSTER_CACHE_MAX then
cache = {}
cache_count = 0
end
for index, data in pairs(tbl) do
-- precalculate the limits, and compare directly.
-- This way is much faster than the math.abs function.
xmin, xmax = data[1] - 5, data[1] + 5
ymin, ymax = data[2] - 5, data[2] + 5
neighbors = 0
count = count + 1
for _, compare in pairs(tbl) do
if compare[1] > xmin and compare[1] < xmax and compare[2] > ymin and compare[2] < ymax then
neighbors = neighbors + 1
end
end
if neighbors > best.neighbors then
best.neighbors = neighbors
best.index = index
end
end
cache[cacheindex] = { tbl[best.index][1] + 0.001, tbl[best.index][2] + 0.001, count }
cache_count = cache_count + 1
end
return cache[cacheindex][1], cache[cacheindex][2], cache[cacheindex][3]
end
-- Detects if a non indexed table is empty
local function isempty(tbl)
return next(tbl) == nil
end
-- Returns the levenshtein distance between two strings
-- based on: https://gist.github.com/Badgerati/3261142
local len1, len2, cost, lev_best
local levcache = {}
local levcache_count = 0
local LEVCACHE_MAX = 500
-- Pre-allocate matrix for strings up to 100 chars (reused across calls)
local lev_matrix = {}
for i = 0, 100 do
lev_matrix[i] = {}
end
local function lev(str1, str2, limit)
local key = str1 .. ":" .. str2
if levcache[key] then
return levcache[key]
end
len1, len2, cost = strlen(str1), strlen(str2), 0
-- abort early on empty strings
if len1 == 0 then
return len2
elseif len2 == 0 then
return len1
elseif str1 == str2 then
return 0
end
-- initialise the base matrix (reuse pre-allocated matrix)
local matrix = lev_matrix
for i = 0, len1, 1 do
if not matrix[i] then
matrix[i] = {}
end
matrix[i][0] = i
for j = 1, len2 do
matrix[i][j] = nil
end
end
for j = 0, len2, 1 do
matrix[0][j] = j
end
-- levenshtein algorithm
for i = 1, len1, 1 do
lev_best = limit
for j = 1, len2, 1 do
cost = string.byte(str1, i) == string.byte(str2, j) and 0 or 1
matrix[i][j] = min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
if limit and matrix[i][j] < limit then
lev_best = matrix[i][j]
end
end
if limit and lev_best >= limit then
-- evict cache if too large before adding
if levcache_count >= LEVCACHE_MAX then
levcache = {}
levcache_count = 0
end
levcache[key] = limit
levcache_count = levcache_count + 1
return limit
end
end
-- evict cache if too large before adding
if levcache_count >= LEVCACHE_MAX then
levcache = {}
levcache_count = 0
end
-- return the levenshtein distance
levcache[key] = matrix[len1][len2]
levcache_count = levcache_count + 1
return matrix[len1][len2]
end
local loc_core, loc_update
for _, exp in pairs({ "-tbc", "-wotlk" }) do
for _, db in pairs(dbs) do
if pfDB[db]["data" .. exp] then
patchtable(pfDB[db]["data"], pfDB[db]["data" .. exp])
end
for loc, _ in pairs(pfDB.locales) do
if pfDB[db][loc] and pfDB[db][loc .. exp] then
loc_update = pfDB[db][loc .. exp] or pfDB[db]["enUS" .. exp]
patchtable(pfDB[db][loc], loc_update)
end
end
end
loc_core = pfDB["professions"][loc] or pfDB["professions"]["enUS"]
loc_update = pfDB["professions"][loc .. exp] or pfDB["professions"]["enUS" .. exp]
if loc_update then
patchtable(loc_core, loc_update)
end
if pfDB["minimap" .. exp] then
patchtable(pfDB["minimap"], pfDB["minimap" .. exp])
end
if pfDB["meta" .. exp] then
patchtable(pfDB["meta"], pfDB["meta" .. exp])
end
end
-- detect installed locales
for key, name in pairs(pfDB.locales) do
if not pfDB["quests"][key] then
pfDB.locales[key] = nil
end
end
-- detect localized databases
pfDatabase.dbstring = ""
for id, db in pairs(dbs) do
-- assign existing locale
pfDB[db]["loc"] = pfDB[db][loc] or pfDB[db]["enUS"] or {}
pfDatabase.dbstring = pfDatabase.dbstring
.. " |cffcccccc[|cffffffff"
.. db
.. "|cffcccccc:|cff33ffcc"
.. (pfDB[db][loc] and loc or "enUS")
.. "|cffcccccc]"
end
-- Free unused locale data to reduce memory (~65MB savings)
-- The "loc" reference already points to the correct table, so we can safely
-- nil out all other locale tables and let them be garbage collected
for id, db in pairs(dbs) do
for locale in pairs(pfDB.locales) do
-- Keep Russian quest text available as an optional translation on
-- English-only Turtle clients. Other non-active locale tables are still
-- discarded, so this does not retain full foreign item/NPC databases.
local keepTranslation = db == "quests" and locale == "ruRU"
if pfDB[db][locale] and pfDB[db][locale] ~= pfDB[db]["loc"] and not keepTranslation then
pfDB[db][locale] = nil
end
end
-- Also free enUS if it's not the active locale (enUS may not be in pfDB.locales)
if pfDB[db]["enUS"] and pfDB[db]["enUS"] ~= pfDB[db]["loc"] then
pfDB[db]["enUS"] = nil
end
-- Free expansion patch data (already merged into base tables)
pfDB[db]["data-tbc"] = nil
pfDB[db]["data-wotlk"] = nil
for locale in pairs(pfDB.locales) do
pfDB[db][locale .. "-tbc"] = nil
pfDB[db][locale .. "-wotlk"] = nil
end
pfDB[db]["enUS-tbc"] = nil
pfDB[db]["enUS-wotlk"] = nil
end
-- Free expansion meta/minimap tables (already merged)
pfDB["minimap-tbc"] = nil
pfDB["minimap-wotlk"] = nil
pfDB["meta-tbc"] = nil
pfDB["meta-wotlk"] = nil
-- track all previous meta selections on login
pfDatabase.tracking = CreateFrame("Frame", "pfDatabaseMetaTracking", UIParent)
pfDatabase.tracking:RegisterEvent("PLAYER_ENTERING_WORLD")
pfDatabase.tracking:SetScript("OnEvent", function()
-- break on empty config
if not pfQuest_track then
return
end
-- build static reject set now that all addon Lua is loaded and
-- UnitRace/UnitClass/GetBitByRace are all available
pfDatabase:BuildStaticRejectSet()
-- enable all tracked
for name, data in pairs(pfQuest_track) do
pfDatabase:SearchMetaRelation(data[1], data[2])
end
-- remove events
this:UnregisterAllEvents()
end)
-- track questitems to maintain object requirements
pfDatabase.itemlist = CreateFrame("Frame", "pfDatabaseQuestItemTracker", UIParent)
pfDatabase.itemlist.update = 0
pfDatabase.itemlist.db = {}
pfDatabase.itemlist.db_tmp = {}
pfDatabase.itemlist.registry = {}
pfDatabase.TrackQuestItemDependency = function(self, item, qid)
self.itemlist.registry[item] = qid
-- only set the deadline if a scan isn't already pending
if not self.itemlist.pending then
self.itemlist.update = GetTime() + 0.5
self.itemlist.pending = true
self.itemlist:Show()
end
end
pfDatabase.itemlist:RegisterEvent("BAG_UPDATE")
pfDatabase.itemlist:SetScript("OnEvent", function()
-- only set the deadline on the first event in a burst
if not this.pending then
this.update = GetTime() + 0.5
this.pending = true
this:Show()
end
end)
pfDatabase.itemlist:SetScript("OnUpdate", function()
if GetTime() < this.update then
return
end
-- clear pending flag so the next BAG_UPDATE burst can schedule a new scan
this.pending = false
-- remove obsolete registry entries
for item, qid in pairs(this.registry) do
if not pfQuest.questlog[qid] then
this.registry[item] = nil
end
end
-- swap db and db_tmp: db_tmp becomes the new db, old db becomes previous
local previous = this.db
this.db = this.db_tmp
this.db_tmp = previous
-- clear the new db in-place (avoids table allocation)
for k in pairs(this.db) do
this.db[k] = nil
end
-- fill new item db with bag items
for bag = 4, 0, -1 do
for slot = 1, GetContainerNumSlots(bag) do
local link = GetContainerItemLink(bag, slot)
local _, _, parse = strfind((link or ""), "(%d+):")
if parse then
local item = GetItemInfo(parse)
if item then
this.db[item] = true
end
end
end
end
-- fill new item db with equipped items
for i = 1, 19 do
if GetInventoryItemLink("player", i) then
local _, _, link = string.find(GetInventoryItemLink("player", i), "(item:%d+:%d+:%d+:%d+)")
local item = GetItemInfo(link)
if item then
this.db[item] = true
end
end
end
-- find new items
for item in pairs(this.db) do
if not previous[item] and this.registry[item] then
pfQuest.questlog[this.registry[item]] = nil
pfQuest:UpdateQuestlog()
end
end
-- find removed items
for item in pairs(previous) do
if not this.db[item] and this.registry[item] then
pfQuest.questlog[this.registry[item]] = nil
pfQuest:UpdateQuestlog()
end
end
this:Hide()
end)
-- check for unlocalized servers and fallback to enUS databases when the server
-- returns item names that are different to the database ones. (check via. Hearthstone)
CreateFrame("Frame", "pfQuestLocaleCheck", UIParent):SetScript("OnUpdate", function()
-- throttle to to one item per second
if (this.tick or 0) > GetTime() then
return
else
this.tick = GetTime() + 0.1
end
if not this.dryrun then
-- give the server one iteration to return the itemname.
-- this is required for clients that use a clean wdb folder.
ItemRefTooltip:SetOwner(UIParent, "ANCHOR_PRESERVE")
ItemRefTooltip:SetHyperlink("item:6948:0:0:0")
ItemRefTooltip:Hide()
this.dryrun = true
return
end
-- try to load hearthstone into tooltip
ItemRefTooltip:SetOwner(UIParent, "ANCHOR_PRESERVE")
ItemRefTooltip:SetHyperlink("item:6948:0:0:0")
-- check tooltip for results
if ItemRefTooltip:IsShown() and ItemRefTooltipTextLeft1 and ItemRefTooltipTextLeft1:IsVisible() then
-- once the tooltip shows up, read the name and hide it
local name = ItemRefTooltipTextLeft1:GetText()
ItemRefTooltip:Hide()
-- check for noloc
if name and name ~= "" and pfDB["items"][loc] and pfDB["items"][loc][6948] then
if not strfind(name, pfDB["items"][loc][6948], 1) then
pfDatabase.dbstring = ""
for id, db in pairs(dbs) do
-- assign existing locale and update dbstring
pfDB[db]["loc"] = noloc[db] and pfDB[db]["enUS"] or pfDB[db][loc] or {}
pfDatabase.dbstring = pfDatabase.dbstring
.. " |cffcccccc[|cffffffff"
.. db
.. "|cffcccccc:|cff33ffcc"
.. (noloc[db] and "enUS" or loc)
.. "|cffcccccc]"
end
end
pfDatabase.localized = true
pfDatabase:BuildNameIndex()
pfDatabase:BuildStaticRejectSet()
this:Hide()
end
end
-- set a detection timeout to 15 seconds
if GetTime() > 15 then
pfDatabase.localized = true
pfDatabase:BuildNameIndex()
pfDatabase:BuildStaticRejectSet()
this:Hide()
end
end)
-- sanity check the databases
if isempty(pfDB["quests"]["loc"]) then
CreateFrame("Frame"):SetScript("OnUpdate", function()
if GetTime() < 3 then
return
end
DEFAULT_CHAT_FRAME:AddMessage(
"|cffff5555 !! |cffffaaaaWrong version of |cff33ffccpf|cffffffffQuest|cffffaaaa detected.|cffff5555 !!"
)
DEFAULT_CHAT_FRAME:AddMessage("|cffffccccThe language pack does not match the gameclient's language.")
DEFAULT_CHAT_FRAME:AddMessage(
"|cffffccccYou'd either need to pick the complete or the " .. GetLocale() .. "-version."
)
DEFAULT_CHAT_FRAME:AddMessage("|cffffccccFor more details, see: https://shagu.org/pfQuest")
this:Hide()
end)
end
-- add database shortcuts
local items, units, objects, quests, zones, refloot, itemreq, areatrigger, professions
pfDatabase.Reload = function()
items = pfDB["items"]["data"]
units = pfDB["units"]["data"]
objects = pfDB["objects"]["data"]
quests = pfDB["quests"]["data"]
zones = pfDB["zones"]["data"]
refloot = pfDB["refloot"]["data"]
itemreq = pfDB["quests-itemreq"]["data"]
areatrigger = pfDB["areatrigger"]["data"]
professions = pfDB["professions"]["loc"]
end
pfDatabase.Reload()
-- Inverted name index: maps name → {id, id, ...} for O(1) exact-match lookups.
-- Built once after locale is known. Used by GetIDByName to skip full-table scans
-- for exact matches (the hot path in SearchQuestID). Partial-match calls (browser,
-- slash commands) still use the full scan since they can't use this index.
pfDatabase.nameIndex = {}
pfDatabase.lastQuestGiversSet = {}
-- Pre-computed set of quest IDs that will never pass QuestFilter for this
-- character, regardless of level, questlog, or config changes.
-- Populated by BuildStaticRejectSet, called from PLAYER_ENTERING_WORLD
-- and from the locale-detection OnUpdate (covers locale swaps).
-- Covers: wrong race, wrong class, missing loc name.
pfDatabase.staticRejectSet = {}
function pfDatabase:BuildNameIndex()
local idx = self.nameIndex
-- clear existing index in-place
for db in pairs(idx) do
for name in pairs(idx[db]) do
idx[db][name] = nil
end
idx[db] = nil
end
for _, db in pairs({ "units", "objects", "items" }) do
idx[db] = {}
for id, loc in pairs(pfDB[db]["loc"]) do
if loc then
if not idx[db][loc] then
idx[db][loc] = {}
end
insert(idx[db][loc], id)
end
end
end
-- Quest localizations contain title/objective/description tables rather
-- than plain strings. Index their titles separately so same-name chain
-- resolution does not scan the whole quest database on every turn-in.
idx.quests = {}
for id, loc in pairs(pfDB["quests"]["loc"]) do
if quests[id] and loc and loc.T then
if not idx.quests[loc.T] then
idx.quests[loc.T] = {}
end
insert(idx.quests[loc.T], id)
end
end
-- locale tables may have changed; force SearchQuests to re-add all nodes
for id in pairs(self.lastQuestGiversSet) do
self.lastQuestGiversSet[id] = nil
end
end
-- BuildStaticRejectSet
-- Pre-computes the set of quest IDs that can never pass QuestFilter for this
-- character, independent of level, questlog, history, config, or skills.
-- Called from PLAYER_ENTERING_WORLD (guaranteed after all Lua is loaded)
-- and from the locale-detection OnUpdate (covers locale swaps).
-- Checks: wrong race bitmask, wrong class bitmask, missing loc name.
function pfDatabase:BuildStaticRejectSet()
local reject = self.staticRejectSet
for id in pairs(reject) do
reject[id] = nil
end
-- UnitRace/UnitClass may return nil before PLAYER_ENTERING_WORLD.
-- In that case skip race/class checks; BuildNameIndex is called again
-- from the locale-detection OnUpdate after login, which will populate them.
local _, race = UnitRace("player")
local _, class = UnitClass("player")
local prace = race and pfDatabase:GetBitByRace(race) or nil
local pclass = class and pfDatabase:GetBitByClass(class) or nil
for id in pairs(quests) do
-- missing loc name
if not pfDB.quests.loc[id] or not pfDB.quests.loc[id].T then
reject[id] = true
-- wrong race (only when prace is known)
elseif prace and quests[id]["race"] and not (bit.band(quests[id]["race"], prace) == prace) then
reject[id] = true
-- wrong class (only when pclass is known)
elseif pclass and quests[id]["class"] and not (bit.band(quests[id]["class"], pclass) == pclass) then
reject[id] = true
end
end
end
pfDatabase:BuildNameIndex()
-- Reusable parse_obj table (cleared and reused each SearchQuestID call)
local parse_obj = { ["U"] = {}, ["O"] = {}, ["I"] = {} }
local function clear_parse_obj()
for k in pairs(parse_obj["U"]) do
parse_obj["U"][k] = nil
end
for k in pairs(parse_obj["O"]) do
parse_obj["O"][k] = nil
end
for k in pairs(parse_obj["I"]) do
parse_obj["I"][k] = nil
end
end
-- Pre-defined vertex color tables (avoid creating new tables each quest)
local VERTEX_BLACK = { 0, 0, 0 }
local VERTEX_RED = { 1, 0.6, 0.6 }
local VERTEX_WHITE = { 1, 1, 1 }
local VERTEX_BLUE = { 0.2, 0.8, 1 }
-- factionMap for GetRaceMaskByID (avoid recreation per call)
local factionMap = { ["A"] = 77, ["H"] = 178, ["AH"] = 255, ["HA"] = 255 }
local bitraces = {
[1] = "Human",
[2] = "Orc",
[4] = "Dwarf",
[8] = "NightElf",
[16] = "Scourge",
[32] = "Tauren",
[64] = "Gnome",
[128] = "Troll",
}
-- append with playable races by expansion
if pfQuestCompat.client > 11200 then
bitraces[512] = "BloodElf"
bitraces[1024] = "Draenei"
end
-- make it public for extensions
pfDB.bitraces = bitraces
local bitclasses = {
[1] = "WARRIOR",
[2] = "PALADIN",
[4] = "HUNTER",
[8] = "ROGUE",
[16] = "PRIEST",
[32] = "DEATHKNIGHT",
[64] = "SHAMAN",
[128] = "MAGE",
[256] = "WARLOCK",
[1024] = "DRUID",
}
-- make it public for extensions
pfDB.bitclasses = bitclasses
function pfDatabase:IsFriendly(id)
if id and units[id] and units[id].fac then
local faction = string.lower(UnitFactionGroup("player") or "")
faction = faction == "horde" and "H" or faction == "alliance" and "A" or "UNKNOWN"
if string.find(units[id].fac, faction) then
return true
end
end
return false
end
function pfDatabase:BuildQuestDescription(meta)
if not meta.title or not meta.quest or not meta.QTYPE then
return meta.description
end
if meta.QTYPE == "NPC_START" then
return string.format(
pfQuest_Loc["Speak with |cff33ffcc%s|r to obtain |cffffcc00[!]|cff33ffcc %s|r"],
(meta.spawn or UNKNOWN),
(meta.quest or UNKNOWN)
)
elseif meta.QTYPE == "OBJECT_START" then
return string.format(
pfQuest_Loc["Interact with |cff33ffcc%s|r to obtain |cffffcc00[!]|cff33ffcc %s|r"],
(meta.spawn or UNKNOWN),
(meta.quest or UNKNOWN)
)
elseif meta.QTYPE == "NPC_END" then
return string.format(
pfQuest_Loc["Speak with |cff33ffcc%s|r to complete |cffffcc00[?]|cff33ffcc %s|r"],
(meta.spawn or UNKNOWN),
(meta.quest or UNKNOWN)
)
elseif meta.QTYPE == "OBJECT_END" then
return string.format(
pfQuest_Loc["Interact with |cff33ffcc%s|r to complete |cffffcc00[?]|cff33ffcc %s|r"],
(meta.spawn or UNKNOWN),
(meta.quest or UNKNOWN)
)
elseif meta.QTYPE == "UNIT_OBJECTIVE" then
local talk = pfDB.quests.talkObjectives and pfDB.quests.talkObjectives[meta.questid]
if talk and talk[meta.spawnid] then
return string.format(pfQuest_Loc["Talk to |cff33ffcc%s|r"], (meta.spawn or UNKNOWN))
end
if pfDatabase:IsFriendly(meta.spawnid) then
return string.format(pfQuest_Loc["Talk to |cff33ffcc%s|r"], (meta.spawn or UNKNOWN))
else
return string.format(pfQuest_Loc["Kill |cff33ffcc%s|r"], (meta.spawn or UNKNOWN))
end
elseif meta.QTYPE == "UNIT_OBJECTIVE_ITEMREQ" then
return string.format(
pfQuest_Loc["Use |cff33ffcc%s|r on |cff33ffcc%s|r"],
(meta.itemreq or UNKNOWN),
(meta.spawn or UNKNOWN)
)
elseif meta.QTYPE == "OBJECT_OBJECTIVE" then
return string.format(pfQuest_Loc["Interact with |cff33ffcc%s|r"], (meta.spawn or UNKNOWN))
elseif meta.QTYPE == "OBJECT_OBJECTIVE_ITEMREQ" then
return string.format(
pfQuest_Loc["Use |cff33ffcc%s|r at |cff33ffcc%s|r"],
(meta.itemreq or UNKNOWN),
(meta.spawn or UNKNOWN)
)
elseif meta.QTYPE == "ITEM_OBJECTIVE_LOOT" then
return string.format(
pfQuest_Loc["Loot |cff33ffcc[%s]|r from |cff33ffcc%s|r"],
(meta.item or UNKNOWN),
(meta.spawn or UNKNOWN)
)
elseif meta.QTYPE == "ITEM_OBJECTIVE_USE" then
return string.format(
pfQuest_Loc["Loot and/or Use |cff33ffcc[%s]|r from |cff33ffcc%s|r"],
(meta.item or UNKNOWN),
(meta.spawn or UNKNOWN)
)
elseif meta.QTYPE == "AREATRIGGER_OBJECTIVE" then
return string.format(pfQuest_Loc["Explore |cff33ffcc%s|r"], (meta.spawn or UNKNOWN))
elseif meta.QTYPE == "ZONE_OBJECTIVE" then
return string.format(pfQuest_Loc["Use Quest Item at |cff33ffcc%s|r"], (meta.spawn or UNKNOWN))
end
end
-- ShowExtendedTooltip
-- Draws quest informations into a tooltip
local MAX_TOOLTIP_DESCRIPTION = 500
local function GetTooltipDescription(text)
local formatted = pfDatabase:FormatQuestText(text)
if strlen(formatted) <= MAX_TOOLTIP_DESCRIPTION then
return formatted
end
-- Extended descriptions are useful context, but a few custom quests contain
-- several full paragraphs. Keep tooltips readable without hiding objectives.
return strsub(formatted, 1, MAX_TOOLTIP_DESCRIPTION) .. "..."
end
function pfDatabase:ShowExtendedTooltip(id, tooltip, parent, anchor, offx, offy)
local tooltip = tooltip or GameTooltip
local parent = parent or this
local anchor = anchor or "ANCHOR_LEFT"
tooltip:SetOwner(parent, anchor, offx, offy)
local locales = pfDB["quests"]["loc"][id]
local data = pfDB["quests"]["data"][id]
if locales then
tooltip:SetText((locales["T"] or UNKNOWN), 0.3, 1, 0.8)
tooltip:AddLine(" ")
else
tooltip:SetText(UNKNOWN, 0.3, 1, 0.8)
end
if data then
-- scan for active quests
local queststate = pfQuest_history[id] and 2 or 0
queststate = pfQuest.questlog[id] and 1 or queststate
if queststate == 0 then
tooltip:AddLine(pfQuest_Loc["You don't have this quest."] .. "\n\n", 1, 0.5, 0.5)
elseif queststate == 1 then
tooltip:AddLine(pfQuest_Loc["You are on this quest."] .. "\n\n", 1, 1, 0.5)
elseif queststate == 2 then
tooltip:AddLine(pfQuest_Loc["You already did this quest."] .. "\n\n", 0.5, 1, 0.5)
end
-- quest start
if data["start"] then
for key, db in pairs({ ["U"] = "units", ["O"] = "objects", ["I"] = "items" }) do
if data["start"][key] then
local entries = ""
for _, id in pairs(data["start"][key]) do
entries = entries .. (entries == "" and "" or ", ") .. (pfDB[db]["loc"][id] or UNKNOWN)
end
tooltip:AddDoubleLine(pfQuest_Loc["Quest Start"] .. ":", entries, 1, 1, 1, 1, 1, 0.8)
end
end
end
-- quest end
if data["end"] then
for key, db in pairs({ ["U"] = "units", ["O"] = "objects" }) do
if data["end"][key] then
local entries = ""
for _, id in ipairs(data["end"][key]) do
entries = entries .. (entries == "" and "" or ", ") .. (pfDB[db]["loc"][id] or UNKNOWN)
end
tooltip:AddDoubleLine(pfQuest_Loc["Quest End"] .. ":", entries, 1, 1, 1, 1, 1, 0.8)
end
end
end
end
if locales then
-- obectives
if locales["O"] and locales["O"] ~= "" then
tooltip:AddLine(" ")
tooltip:AddLine(pfDatabase:FormatQuestText(locales["O"]), 1, 1, 1, true)
end
-- details
if locales["D"] and locales["D"] ~= "" then
tooltip:AddLine(" ")
tooltip:AddLine(GetTooltipDescription(locales["D"]), 0.6, 0.6, 0.6, true)
end
end
-- add levels
if data then
if data["lvl"] or data["min"] then
tooltip:AddLine(" ")
end
if data["lvl"] then
local questlevel = tonumber(data["lvl"])
local color = pfQuestCompat.GetDifficultyColor(questlevel)
tooltip:AddLine("|cffffffff" .. pfQuest_Loc["Quest Level"] .. ": |r" .. questlevel, color.r, color.g, color.b)
end
if data["min"] then
local questlevel = tonumber(data["min"])
local color = pfQuestCompat.GetDifficultyColor(questlevel)
tooltip:AddLine("|cffffffff" .. pfQuest_Loc["Required Level"] .. ": |r" .. questlevel, color.r, color.g, color.b)
end
end
tooltip:Show()
end
-- GetPlayerSkill
-- Returns false if the player doesn't have the required skill, or their rank if they do
function pfDatabase:GetPlayerSkill(skill)
if not professions[skill] then
return false
end
for i = 0, GetNumSkillLines() do
local skillName, _, _, skillRank = GetSkillLineInfo(i)
if skillName == professions[skill] then
return skillRank
end
end
return false
end
-- GetBitByRace
-- Returns bit of the current race
function pfDatabase:GetBitByRace(model)
-- Turtle WoW's High Elf client token is not part of the original 1.12
-- race list. It uses the same bit as the Turtle database's High Elf
-- quest entries (512); without this, High-Elf-only starter quests are
-- treated as unavailable and their map pins are filtered out.
if model == "HighElf" or model == "High Elf" then
return 512
end
if model == "Goblin" then
return 256
end
-- scan for regular bitmasks
for bit, v in pairs(bitraces) do
if model == v then
return bit
end
end
-- return alliance/horde racemask as fallback for unknown races
return UnitFactionGroup("player") == "Alliance" and 77 or 178
end
-- GetBitByClass
-- Returns bit of the current class
function pfDatabase:GetBitByClass(class)
for bit, v in pairs(bitclasses) do
if class == v then
return bit
end
end
end
-- GetHexDifficultyColor
-- Returns a string with the difficulty color of the given level
function pfDatabase:GetHexDifficultyColor(level, force)
if force and UnitLevel("player") < level then
return "|cffff5555"
else
local c = pfQuestCompat.GetDifficultyColor(level)
return string.format("|cff%02x%02x%02x", c.r * 255, c.g * 255, c.b * 255)
end
end
-- GetRaceMaskByID
function pfDatabase:GetRaceMaskByID(id, db)
-- Uses module-level factionMap: A=77, H=178, AH/HA=255
local raceMask = 0
if db == "quests" then
raceMask = quests[id]["race"] or raceMask
if quests[id]["start"] then
local questStartRaceMask = 0
-- get quest starter faction
if quests[id]["start"]["U"] then
for _, startUnitId in ipairs(quests[id]["start"]["U"]) do
if units[startUnitId] and units[startUnitId]["fac"] and factionMap[units[startUnitId]["fac"]] then
questStartRaceMask = bit.bor(factionMap[units[startUnitId]["fac"]])
end
end
end
-- get quest object starter faction
if quests[id]["start"]["O"] then
for _, startObjectId in ipairs(quests[id]["start"]["O"]) do
if objects[startObjectId] and objects[startObjectId]["fac"] and factionMap[objects[startObjectId]["fac"]] then
questStartRaceMask = bit.bor(factionMap[objects[startObjectId]["fac"]])
end
end
end
-- apply starter faction as racemask
if raceMask == 0 and questStartRaceMask > 0 and questStartRaceMask ~= raceMask then
raceMask = questStartRaceMask
end
end
elseif pfDB[db] and pfDB[db]["data"] and pfDB[db]["data"]["fac"] then
raceMask = factionMap[pfDB[db]["data"]["fac"]]
end
return raceMask
end
-- GetIDByName
-- Scans localization tables for matching IDs
-- Returns table with all IDs
function pfDatabase:GetIDByName(name, db, partial, server)
if not pfDB[db] then
return nil
end
local ret = {}
-- Fast path: O(1) index lookup for exact case-sensitive matches with no
-- server filter. This is the hot path called from SearchQuestID for monster
-- and item objective lookups. Quests are excluded because their loc is a
-- table ({T=, O=, D=}) and are not indexed.
if not partial and not server and db ~= "quests" then
local ids = pfDatabase.nameIndex[db] and pfDatabase.nameIndex[db][name]
if ids then
for _, id in pairs(ids) do
ret[id] = pfDB[db]["loc"][id]
end
end
return ret
end
-- Slow path: full scan for partial matches (browser/slash commands) and quests
for id, loc in pairs(pfDB[db]["loc"]) do
if db == "quests" then
loc = loc["T"]
end
local custom = server and pfQuest_server[db] and pfQuest_server[db][id] or not server
if loc and name then
if partial == true and strfind(strlower(loc), strlower(name), 1, true) and custom then
ret[id] = loc
elseif partial == "LOWER" and strlower(loc) == strlower(name) and custom then
ret[id] = loc
elseif loc == name and custom then
ret[id] = loc
end
end
end
return ret
end
-- GetIDByIDPart
-- Scans localization tables for matching IDs
-- Returns table with all IDs
function pfDatabase:GetIDByIDPart(idPart, db)
if not pfDB[db] then
return nil
end
local ret = {}
for id, loc in pairs(pfDB[db]["loc"]) do
if db == "quests" then
loc = loc["T"]
end
if idPart and loc and strfind(tostring(id), idPart) then
ret[id] = loc
end
end
return ret
end
-- GetBestMap
-- Scans a map table for all spawns
-- Returns the map with most spawns
function pfDatabase:GetBestMap(maps)
local bestmap, bestscore = nil, 0
-- calculate best map results
for map, count in pairs(maps or {}) do
local preferOutdoor = count == bestscore and pfMap and pfMap.IsZoneMapID
and pfMap:IsZoneMapID(map) and (not bestmap or not pfMap:IsZoneMapID(bestmap))
if count > bestscore or (count == 0 and bestscore == 0) or preferOutdoor then
bestscore = count
bestmap = map
end
end
return bestmap or nil, bestscore or nil
end
-- SearchAreaTriggerID
-- Scans for all mobs with a specified ID
-- Adds map nodes for each and returns its map table
function pfDatabase:SearchAreaTriggerID(id, meta, maps, prio)
if not areatrigger[id] or not areatrigger[id]["coords"] then
return maps
end
local maps = maps or {}
local prio = prio or 1
for _, data in pairs(areatrigger[id]["coords"]) do
local x, y, zone = unpack(data)
if zone and zone > 0 then
-- add all gathered data
meta = meta or {}
meta["spawn"] = pfQuest_Loc["Exploration Mark"]
meta["spawnid"] = id
meta["item"] = nil
meta["title"] = meta["quest"] or meta["item"] or meta["spawn"]
meta["zone"] = zone
meta["x"] = x
meta["y"] = y
meta["level"] = pfQuest_Loc["N/A"]
meta["spawntype"] = pfQuest_Loc["Trigger"]
meta["respawn"] = pfQuest_Loc["N/A"]
maps[zone] = maps[zone] and maps[zone] + prio or prio
pfMap:AddNode(meta)
end
end
return maps
end
-- SearchMobID
-- Scans for all mobs with a specified ID
-- Adds map nodes for each and returns its map table
function pfDatabase:SearchMobID(id, meta, maps, prio)
if not units[id] or not units[id]["coords"] then
return maps
end
local maps = maps or {}
local prio = prio or 1
meta = meta or {}
-- hoist invariant fields outside the coord loop; these are the same for
-- every spawn point of this mob so there is no need to set them per-coord
meta["spawn"] = pfDB.units.loc[id]
meta["spawnid"] = id
meta["title"] = meta["quest"] or meta["item"] or meta["spawn"]
meta["level"] = units[id]["lvl"] or UNKNOWN
meta["spawntype"] = pfQuest_Loc["Unit"]
-- description only depends on the above invariant fields + QTYPE/quest/item
-- compute once here; AddNode will skip its own BuildQuestDescription call
meta["description"] = pfDatabase:BuildQuestDescription(meta)
for _, data in pairs(units[id]["coords"]) do
local x, y, zone, respawn = unpack(data)
if zone > 0 then
meta["zone"] = zone
meta["x"] = x
meta["y"] = y
-- Summoned creatures can have a location without a respawn timer.
meta["respawn"] = respawn and respawn > 0 and SecondsToTime(respawn) or pfQuest_Loc["N/A"]
maps[zone] = maps[zone] and maps[zone] + prio or prio
pfMap:AddNode(meta)
end
end
return maps
end
-- Search MetaRelation
-- Scans for all entries within the specified meta name
-- Adds map nodes for each and returns its map table
-- query = { relation-name, relation-min, relation-max }
local alias = {
["flightmaster"] = "flight",
["taxi"] = "flight",
["flights"] = "flight",
["raremobs"] = "rares",
}
local skill = {
["herbs"] = true,
["mines"] = true,
["rares"] = true,
["chests"] = true,
}
pfDatabase.metaSkillRelations = skill
pfDatabase.metaAutoSkills = pfDatabase.metaAutoSkills or { mines = 186, herbs = 182 }
function pfDatabase:SearchMetaRelation(query, meta, show)
local maps = {}
-- abort on invalid queries
if not query.name then
return
end
-- convert track name aliases
local track = alias[query.name] or query.name
if pfDB["meta"] and pfDB["meta"][track] then
-- check which faction should be searched
local faction = query.faction and string.lower(query.faction)
or (UnitFactionGroup("player") and string.lower(UnitFactionGroup("player")))
faction = faction == "horde" and "H" or faction == "alliance" and "A" or ""
-- iterate over all tracking entries
for entry, value in pairs(pfDB["meta"][track]) do
if skill[track] and tonumber(query.min) and tonumber(value) < tonumber(query.min) then
-- required skill is lower than the queried one
elseif skill[track] and tonumber(query.max) and tonumber(value) > tonumber(query.max) then
-- required skill is lower than the queried one
elseif not skill[track] and not string.find(value, faction) then
-- faction is different from the queried one
else
local prev_icon = meta.icon
local prev_level = meta.level
local object = pfDB["objects"]["loc"][math.abs(entry)]
local unit = pfDB["units"]["loc"][entry]
-- set node as tracking result
meta.tracking = true
local skillCaption = pfDatabase.metaSkillCaptions and pfDatabase.metaSkillCaptions[track]
if skillCaption then
meta.level = string.format("%s [%s]", value, skillCaption)
end
-- handle custom tracking icons
if pfQuest_config.trackingicons == "0" then
meta.icon = nil
elseif entry < 0 and object and pfDatabase.icons[object] then
meta.icon = pfDatabase.icons[object]
elseif entry > 0 and unit and pfDatabase.icons[unit] then
meta.icon = pfDatabase.icons[unit]
end
-- set custom fade range for skill-trackables
if meta.icon and skill[track] then
meta.fade_range = 85
elseif meta.icon then
meta.fade_range = 10
else
meta.fade_range = nil
end
if entry < 0 then
pfDatabase:SearchObjectID(math.abs(entry), meta, maps)
else
pfDatabase:SearchMobID(entry, meta, maps)
end
-- reset meta table
meta.icon = prev_icon
meta.level = prev_level
meta.tracking = false
end
end
end
return maps
end
-- Search TrackMeta
-- Scans for all entries within the specified list
-- Adds map nodes for each, saves it to the persistent
-- tracking variable per character and returns a map table
function pfDatabase:TrackMeta(list, state)
local list = alias[list] and alias[list] or list
local identifier = "TRACK_" .. string.upper(list)
local meta = {
["addon"] = identifier,
["icon"] = (pfDatabase.metaTrackingIcons and pfDatabase.metaTrackingIcons[list])
or (pfQuestConfig.path .. "\\img\\tracking\\" .. list),
}
local query = {
name = list,
}
local maps = nil
-- hide previous tracks
pfQuest_track[list] = nil
pfMap:DeleteNode(identifier)
pfMap:UpdateNodes()
-- break here if nothing should be tracked
if not state then
return
end
-- add extended state values to query
-- this is used for min/max values
if type(state) == "table" then
for k, v in pairs(state) do
query[k] = v
end
end
-- save and perform the actual meta tracking
pfQuest_track[list] = { query, meta }
local maps = pfDatabase:SearchMetaRelation(query, meta)
-- remove invalid results
if not maps then
pfQuest_track[list] = nil
end
-- return map results
return maps
end
-- SearchMob
-- Scans for all mobs with a specified name
-- Adds map nodes for each and returns its map table
function pfDatabase:SearchMob(mob, meta, partial)
local maps = {}
for id in pairs(pfDatabase:GetIDByName(mob, "units", partial)) do
if units[id] and units[id]["coords"] then
maps = pfDatabase:SearchMobID(id, meta, maps)
end
end
return maps
end
-- SearchZoneID
-- Scans for all zones with a specific ID
-- Add nodes to the center of that location
function pfDatabase:SearchZoneID(id, meta, maps, prio)
if not zones[id] then
return maps
end
local maps = maps or {}
local prio = prio or 1
local zone, width, height, x, y, ex, ey = unpack(zones[id])
if zone > 0 then
maps[zone] = maps[zone] and maps[zone] + prio or prio
meta = meta or {}
meta["spawn"] = pfDB.zones.loc[id] or UNKNOWN
meta["spawnid"] = id
meta["title"] = meta["quest"] or meta["item"] or meta["spawn"]
meta["zone"] = zone
meta["level"] = "N/A"
meta["spawntype"] = pfQuest_Loc["Area/Zone"]
meta["respawn"] = "N/A"
meta["x"] = x
meta["y"] = y
pfMap:AddNode(meta)
return maps
end
return maps
end
-- SearchZone
-- Scans for all zones with a specified name
-- Adds map nodes for each and returns its map table
function pfDatabase:SearchZone(obj, meta, partial)
local maps = {}
for id in pairs(pfDatabase:GetIDByName(obj, "zones", partial)) do
if zones[id] then
maps = pfDatabase:SearchZoneID(id, meta, maps)
end
end
return maps
end
function pfDatabase:SearchObjectSkill(id)
if not id or not tonumber(id) then
return
end
local skill, caption = nil, nil
if pfDB["meta"]["herbs"][-id] then
skill = pfDB["meta"]["herbs"][-id]
caption = pfQuest_Loc["Herbalism"]
elseif pfDB["meta"]["mines"][-id] then
skill = pfDB["meta"]["mines"][-id]
caption = pfQuest_Loc["Mining"]
end
return skill, caption
end
-- Scans for all objects with a specified ID
-- Adds map nodes for each and returns its map table
function pfDatabase:SearchObjectID(id, meta, maps, prio)
if not objects[id] or not objects[id]["coords"] then
return maps
end
local skill, caption = pfDatabase:SearchObjectSkill(id)
local maps = maps or {}
local prio = prio or 1
meta = meta or {}
-- hoist invariant fields outside the coord loop
meta["spawn"] = pfDB.objects.loc[id]
meta["spawnid"] = id
meta["title"] = meta["quest"] or meta["item"] or meta["spawn"]
meta["level"] = skill and string.format("%s [%s]", skill, caption) or meta["level"]
meta["spawntype"] = pfQuest_Loc["Object"]
-- description only depends on invariant fields; compute once
meta["description"] = pfDatabase:BuildQuestDescription(meta)
for _, data in pairs(objects[id]["coords"]) do
local x, y, zone, respawn = unpack(data)
if zone > 0 then
meta["zone"] = zone
meta["x"] = x
meta["y"] = y
meta["respawn"] = respawn and respawn > 0 and SecondsToTime(respawn) or pfQuest_Loc["N/A"]
maps[zone] = maps[zone] and maps[zone] + prio or prio
pfMap:AddNode(meta)
end
end
return maps
end
-- SearchObject
-- Scans for all objects with a specified name
-- Adds map nodes for each and returns its map table
function pfDatabase:SearchObject(obj, meta, partial)
local maps = {}
for id in pairs(pfDatabase:GetIDByName(obj, "objects", partial)) do
if objects[id] and objects[id]["coords"] then
maps = pfDatabase:SearchObjectID(id, meta, maps)
end
end
return maps
end
-- SearchItemID
-- Scans for all items with a specified ID
-- Adds map nodes for each drop and vendor
-- Returns its map table
function pfDatabase:SearchItemID(id, meta, maps, allowedTypes)
if not items[id] then
return maps
end
local maps = maps or {}
local meta = meta or {}
meta["itemid"] = id
meta["item"] = pfDB.items.loc[id]
local minChance = tonumber(pfQuest_config.mindropchance)
if not minChance then
minChance = 0
end
-- search unit drops
if items[id]["U"] and ((not allowedTypes) or allowedTypes["U"]) then
for unit, chance in pairs(items[id]["U"]) do
if chance >= minChance then
meta["texture"] = nil
meta["droprate"] = chance
meta["sellcount"] = nil
maps = pfDatabase:SearchMobID(unit, meta, maps)
end
end
end
-- search object loot (veins, chests, ..)
if items[id]["O"] and ((not allowedTypes) or allowedTypes["O"]) then
for object, chance in pairs(items[id]["O"]) do
if chance >= minChance and chance > 0 then
meta["texture"] = nil
meta["droprate"] = chance
meta["sellcount"] = nil
maps = pfDatabase:SearchObjectID(object, meta, maps)
end
end
end
-- search reference loot (objects, creatures)
if items[id]["R"] then
for ref, chance in pairs(items[id]["R"]) do
if chance >= minChance and refloot[ref] then
-- ref creatures
if refloot[ref]["U"] and ((not allowedTypes) or allowedTypes["U"]) then
for unit in pairs(refloot[ref]["U"]) do
meta["texture"] = nil
meta["droprate"] = chance
meta["sellcount"] = nil
maps = pfDatabase:SearchMobID(unit, meta, maps)
end
end
-- ref objects
if refloot[ref]["O"] and ((not allowedTypes) or allowedTypes["O"]) then
for object in pairs(refloot[ref]["O"]) do
meta["texture"] = nil
meta["droprate"] = chance
meta["sellcount"] = nil
maps = pfDatabase:SearchObjectID(object, meta, maps)
end
end
end
end
end
-- search vendor goods
if items[id]["V"] and ((not allowedTypes) or allowedTypes["V"]) then
for unit, chance in pairs(items[id]["V"]) do
meta["texture"] = pfQuestConfig.path .. "\\img\\icon_vendor"
meta["droprate"] = nil
meta["sellcount"] = chance
maps = pfDatabase:SearchMobID(unit, meta, maps)
end
end
return maps
end
-- SearchItem
-- Scans for all items with a specified name
-- Adds map nodes for each drop and vendor
-- Returns its map table
function pfDatabase:SearchItem(item, meta, partial)
local maps = {}
local bestmap, bestscore = nil, 0
for id in pairs(pfDatabase:GetIDByName(item, "items", partial)) do
maps = pfDatabase:SearchItemID(id, meta, maps)
end
return maps
end
-- SearchVendor
-- Scans for all items with a specified name
-- Adds map nodes for each vendor
-- Returns its map table
function pfDatabase:SearchVendor(item, meta, partial)
local maps = {}
local meta = meta or {}
local bestmap, bestscore = nil, 0
for id in pairs(pfDatabase:GetIDByName(item, "items", partial)) do
meta["itemid"] = id
meta["item"] = pfDB.items.loc[id]
-- search vendor goods
if items[id] and items[id]["V"] then
for unit, chance in pairs(items[id]["V"]) do
meta["texture"] = pfQuestConfig.path .. "\\img\\icon_vendor"
meta["droprate"] = nil
meta["sellcount"] = chance
maps = pfDatabase:SearchMobID(unit, meta, maps)
end
end
end
return maps
end
-- SearchQuestID
-- Scans for all quests with a specified ID
-- Adds map nodes for each objective and involved units
-- Returns its map table
function pfDatabase:SearchQuestID(id, meta, maps)
if not quests[id] then
return
end
local maps = maps or {}
local meta = meta or {}
meta["questid"] = id
meta["quest"] = pfDB.quests.loc[id] and pfDB.quests.loc[id].T
meta["qlvl"] = quests[id]["lvl"]
meta["qmin"] = quests[id]["min"]
-- clear previous unified quest nodes
if meta.quest then
pfMap.unifiedcache[meta.quest] = {}
end
if pfQuest_config["currentquestgivers"] == "1" then
-- search quest-starter
if quests[id]["start"] and not meta["qlogid"] then
-- units
if quests[id]["start"]["U"] then
for _, unit in pairs(quests[id]["start"]["U"]) do
meta = meta or {}
meta["QTYPE"] = "NPC_START"
meta["layer"] = meta["layer"] or 4
meta["texture"] = pfQuestConfig.path .. "\\img\\available_c"
maps = pfDatabase:SearchMobID(unit, meta, maps, 0)
end
end
-- objects
if quests[id]["start"]["O"] then
for _, object in pairs(quests[id]["start"]["O"]) do
meta = meta or {}
meta["QTYPE"] = "OBJECT_START"
meta["texture"] = pfQuestConfig.path .. "\\img\\available_c"
maps = pfDatabase:SearchObjectID(object, meta, maps, 0)
end
end
end
-- search quest-ender
if quests[id]["end"] then
-- compute complete state once, outside both ender loops
local ender_texture
if meta["qlogid"] then
-- A quest-log slot can briefly have no objective rows while the game
-- reindexes it after a turn-in, so do not infer completion from the
-- live objective count alone. Static quests with no objective table
-- are genuine talk/report quests and are ready as soon as accepted.
local _, _, _, _, _, complete = compat.GetQuestLogTitle(meta["qlogid"])
local ready = complete == true or complete == 1 or (not quests[id]["obj"]
and not (pfDB.quests.requireClientCompletion and pfDB.quests.requireClientCompletion[id]))
ender_texture = ready and pfQuestConfig.path .. "\\img\\complete_c"
or pfQuestConfig.path .. "\\img\\complete"
else
ender_texture = pfQuestConfig.path .. "\\img\\complete_c"
end
-- units
if quests[id]["end"]["U"] then
for _, unit in pairs(quests[id]["end"]["U"]) do
meta["texture"] = ender_texture
meta["QTYPE"] = "NPC_END"
maps = pfDatabase:SearchMobID(unit, meta, maps, 0)
end
end
-- objects
if quests[id]["end"]["O"] then
for _, object in pairs(quests[id]["end"]["O"]) do
meta["texture"] = ender_texture
meta["QTYPE"] = "OBJECT_END"
maps = pfDatabase:SearchObjectID(object, meta, maps, 0)
end
end
end
end
-- Clear and reuse the module-level parse_obj table
clear_parse_obj()
-- If QuestLogID is given, scan and add all finished objectives to blacklist
if meta["qlogid"] then
local objectives = GetNumQuestLeaderBoards(meta["qlogid"])
local _, _, _, _, _, complete = compat.GetQuestLogTitle(meta["qlogid"])
if complete then
return maps
end
if objectives then
for i = 1, objectives, 1 do
local text, type, done = GetQuestLogLeaderBoard(i, meta["qlogid"])
local talk = pfDB.quests.talkObjectives and pfDB.quests.talkObjectives[id]
for unitID, row in pairs(talk or {}) do
if row == i then parse_obj.U[unitID] = done and "DONE" or "PROG" end
end
-- spawn data
if type == "monster" then
local i, j, monsterName, objNum, objNeeded = strfind(text, pfUI.api.SanitizePattern(QUEST_MONSTERS_KILLED))
for id in pairs(pfDatabase:GetIDByName(monsterName, "units")) do
parse_obj["U"][id] = (objNum + 0 >= objNeeded + 0 or done) and "DONE" or "PROG"
end
for id in pairs(pfDatabase:GetIDByName(monsterName, "objects")) do
parse_obj["O"][id] = (objNum + 0 >= objNeeded + 0 or done) and "DONE" or "PROG"
end
end
-- item data
if type == "item" then
local i, j, itemName, objNum, objNeeded = strfind(text, pfUI.api.SanitizePattern(QUEST_OBJECTS_FOUND))
for id in pairs(pfDatabase:GetIDByName(itemName, "items")) do
parse_obj["I"][id] = (objNum + 0 >= objNeeded + 0 or done) and "DONE" or "PROG"
end
end
end
end
end
-- search quest-objectives
if quests[id]["obj"] then
local skip_objects
local skip_creatures
-- item requirements
if quests[id]["obj"]["IR"] then
local requirement
for _, item in pairs(quests[id]["obj"]["IR"]) do
if itemreq[item] then
requirement = pfDB["items"]["loc"][item] or UNKNOWN
for object, spell in pairs(itemreq[item]) do
if object < 0 then
-- gameobject
meta["texture"] = nil
meta["layer"] = 2
meta["QTYPE"] = "OBJECT_OBJECTIVE_ITEMREQ"
meta.itemreq = requirement
skip_objects = skip_objects or {}
skip_objects[math.abs(object)] = true
pfDatabase:TrackQuestItemDependency(requirement, id)
if pfDatabase.itemlist.db[requirement] then
maps = pfDatabase:SearchObjectID(math.abs(object), meta, maps)
end
elseif object > 0 then
-- creature
meta["texture"] = nil
meta["layer"] = 2
meta["QTYPE"] = "UNIT_OBJECTIVE_ITEMREQ"
meta.itemreq = requirement
skip_creatures = skip_creatures or {}
skip_creatures[math.abs(object)] = true
pfDatabase:TrackQuestItemDependency(requirement, id)
if pfDatabase.itemlist.db[requirement] then
maps = pfDatabase:SearchMobID(math.abs(object), meta, maps)
end
end
end
end
end
end
-- units
if quests[id]["obj"]["U"] then
for _, unit in pairs(quests[id]["obj"]["U"]) do
if not parse_obj["U"][unit] or parse_obj["U"][unit] ~= "DONE" then
if not skip_creatures or not skip_creatures[unit] then
meta = meta or {}
meta["texture"] = nil
meta["QTYPE"] = "UNIT_OBJECTIVE"
maps = pfDatabase:SearchMobID(unit, meta, maps)
end
end
end
end
-- objects
if quests[id]["obj"]["O"] then
for _, object in pairs(quests[id]["obj"]["O"]) do
if not parse_obj["O"][object] or parse_obj["O"][object] ~= "DONE" then
if not skip_objects or not skip_objects[object] then
meta = meta or {}
meta["texture"] = nil
meta["layer"] = 2
meta["QTYPE"] = "OBJECT_OBJECTIVE"
maps = pfDatabase:SearchObjectID(object, meta, maps)
end
end
end
end
-- items
if quests[id]["obj"]["I"] then
for _, item in pairs(quests[id]["obj"]["I"]) do
if not parse_obj["I"][item] or parse_obj["I"][item] ~= "DONE" then
meta = meta or {}
meta["texture"] = nil
meta["layer"] = 2
if parse_obj["I"][item] then
meta["QTYPE"] = "ITEM_OBJECTIVE_LOOT"
else
meta["QTYPE"] = "ITEM_OBJECTIVE_USE"
end
maps = pfDatabase:SearchItemID(item, meta, maps)
end
end
end
-- areatrigger
if quests[id]["obj"]["A"] then
for _, areatrigger in pairs(quests[id]["obj"]["A"]) do
meta = meta or {}
meta["texture"] = nil
meta["layer"] = 2
meta["QTYPE"] = "AREATRIGGER_OBJECTIVE"
maps = pfDatabase:SearchAreaTriggerID(areatrigger, meta, maps)
end
end
-- zones
if quests[id]["obj"]["Z"] then
for _, zone in pairs(quests[id]["obj"]["Z"]) do
meta = meta or {}
meta["texture"] = nil
meta["layer"] = 2
meta["QTYPE"] = "ZONE_OBJECTIVE"
maps = pfDatabase:SearchZoneID(zone, meta, maps)
end
end
end
-- prepare unified quest location markers
local addon = meta["addon"] or "PFDB"
if pfMap.nodes[addon] then
for map in pairs(pfMap.nodes[addon]) do
if meta.quest and pfMap.unifiedcache[meta.quest] and pfMap.unifiedcache[meta.quest][map] then
for hash, data in pairs(pfMap.unifiedcache[meta.quest][map]) do
meta = data.meta
meta["title"] = meta["quest"]
meta["cluster"] = true
meta["zone"] = map
local icon = pfQuest_config["clustermono"] == "1" and "_mono" or ""
if meta.item then
meta["x"], meta["y"], meta["priority"] = getcluster(data.coords, meta["quest"] .. hash .. map)
meta["texture"] = pfQuestConfig.path .. "\\img\\cluster_item" .. icon
pfMap:AddNode(meta, true)
elseif meta.spawntype and meta.spawntype == pfQuest_Loc["Unit"] and meta.spawn and not meta.itemreq then
meta["x"], meta["y"], meta["priority"] = getcluster(data.coords, meta["quest"] .. hash .. map)
meta["texture"] = pfQuestConfig.path .. "\\img\\cluster_mob" .. icon
pfMap:AddNode(meta, true)
else
meta["x"], meta["y"], meta["priority"] = getcluster(data.coords, meta["quest"] .. hash .. map)
meta["texture"] = pfQuestConfig.path .. "\\img\\cluster_misc" .. icon
pfMap:AddNode(meta, true)
end
end
end
end
end
return maps
end
-- SearchQuest
-- Scans for all quests with a specified name
-- Adds map nodes for each objective and involved unit
-- Returns its map table
function pfDatabase:SearchQuest(quest, meta, partial)
local maps = {}
for id in pairs(pfDatabase:GetIDByName(quest, "quests", partial)) do
maps = pfDatabase:SearchQuestID(id, meta, maps)
end
return maps
end
-- Optional introductions stop being offered while their destination is
-- active or completed. Abandonment restores them when no completion is saved.
local breadcrumbDestinations = { [2981] = 2975 }
function pfDatabase:IsBreadcrumbBlocked(id)
local destination = breadcrumbDestinations[tonumber(id)]
return destination and ((pfQuest.questlog and pfQuest.questlog[destination])
or pfQuest_history[destination]) and true or false
end
function pfDatabase:QuestChangesBreadcrumbAvailability(id)
id = tonumber(id)
for _, destination in pairs(breadcrumbDestinations) do
if destination == id then return true end
end
return false
end
-- Retain old quest records for lookup/history, but do not advertise offers
-- confirmed absent from the current server.
local unavailableQuestOffers = { [6221] = true }
function pfDatabase:IsQuestOfferExcluded(id)
return unavailableQuestOffers[tonumber(id)] and true or false
end
-- Alternate offers in a mutually exclusive group disappear when another
-- member is active or completed. Ignore the row's self-reference.
function pfDatabase:IsQuestExclusiveBlocked(id)
id = tonumber(id)
local quest = quests[id]
local group = pfDB.quests.exclusive and pfDB.quests.exclusive[id]
or (type(quest) == "table" and quest.close)
for _, other in pairs(group or {}) do
other = tonumber(other)
if other and other ~= id and (pfQuest_history[other]
or (pfQuest.questlog and pfQuest.questlog[other])) then return true end
end
return false
end
function pfDatabase:QuestFilter(id, plevel, pclass, prace)
if pfDatabase:IsQuestOfferExcluded(id) then return end
if self:IsBreadcrumbBlocked(id) then return end
if self:IsQuestExclusiveBlocked(id) then return end
-- fast reject: race, class, and missing loc name are session-constants,
-- pre-computed in BuildStaticRejectSet to avoid repeating bit ops per call
if pfDatabase.staticRejectSet[id] then
return
end
-- hide active quest
if pfQuest.questlog[id] then
return
end
local repeatable = pfDB.quests.repeatable and pfDB.quests.repeatable[id]
if repeatable and pfQuest_config["showrepeatable"] ~= "1" then return end
-- Repeatable quests remain available after a prior turn-in.
if pfQuest_history[id] and not repeatable then
return
end
-- hide missing pre-quests
local requiredPre = quests[id]["preall"]
or (pfDB["quests"]["preall"] and pfDB["quests"]["preall"][id])
if requiredPre then
-- Convergence quests require every listed branch to be complete.
for _, prequest in pairs(requiredPre) do
if not pfQuest_history[prequest] then return end
end
elseif quests[id]["pre"] then
-- check all pre-quests for one to be completed
local one_complete = nil
for _, prequest in pairs(quests[id]["pre"]) do
if pfQuest_history[prequest] then
one_complete = true
end
end
-- hide if none of the pre-quests has been completed
if not one_complete then
return
end
end
-- hide non-available quests for your race (redundant when staticRejectSet is warm,
-- retained as fallback for the brief window before locale detection completes)
if quests[id]["race"] and not (bit.band(quests[id]["race"], prace) == prace) then
return
end
-- hide non-available quests for your class
if quests[id]["class"] and not (bit.band(quests[id]["class"], pclass) == pclass) then
return
end
-- hide non-available quests for your profession (uses cache when inside SearchQuests)
if not pfDatabase:MeetsQuestProfession(id, quests[id]["skill"]) then
return
end
local levelRange = pfQuest_config["questpinlevelrange"] or "off"
-- "all" was the original default while this filter was being introduced.
-- Treat it as disabled so existing characters return to normal pfQuest
-- high/low-level behavior too.
if levelRange == "all" then levelRange = "off" end
if levelRange ~= "off" and quests[id]["lvl"] then
-- Match the client's own quest-color rules rather than hard-coding level
-- offsets. A selected color includes that color and every easier color.
local color = pfQuestCompat.GetDifficultyColor(tonumber(quests[id]["lvl"]))
local rank
if color.r > .9 and color.g < .15 then
rank = 5 -- red
elseif color.r > .9 and color.g < .9 then
rank = 4 -- orange
elseif color.r > .9 then
rank = 3 -- yellow
elseif color.g > color.r then
rank = 2 -- green
else
rank = 1 -- grey
end
local threshold = ({ red = 5, orange = 4, yellow = 3, green = 2, gray = 1 })[levelRange]
local direction = pfQuest_config["questpinleveldirection"] == "higher" and "higher" or "lower"
if threshold and direction == "lower" and rank > threshold then return end
if threshold and direction == "higher" and rank < threshold then return end
elseif levelRange == "off" then
-- With no Level Range selected, preserve normal pfQuest high-level
-- behavior. The low-level preference is applied independently below.
if quests[id]["min"] and quests[id]["min"] > plevel + (pfQuest_config["showhighlevel"] == "1" and 3 or 0) then
return
end
end
-- This remains authoritative even while an optional Level Range is active.
-- Otherwise selecting a range makes the existing checkbox appear broken on
-- both the World Map and minimap.
if quests[id]["lvl"] and quests[id]["lvl"] < plevel - 4 and pfQuest_config["showlowlevel"] == "0" then
return
end
-- hide event quests
if quests[id]["event"] and pfQuest_config["showfestival"] == "0" then
return
end
return true
end
-- SearchQuests skill cache: built once per SearchQuests call, used by QuestFilter
-- via pfDatabase:GetPlayerSkillCached(). Avoids scanning all skill lines per quest.
pfDatabase.skillcache = {}
function pfDatabase:BuildSkillCache()
for k in pairs(self.skillcache) do
self.skillcache[k] = nil
end
for i = 0, GetNumSkillLines() do
local skillName, _, _, skillRank = GetSkillLineInfo(i)
if skillName then
self.skillcache[skillName] = skillRank
end
end
end
function pfDatabase:GetPlayerSkillCached(skill)
if not professions[skill] then
return false
end
local rank = self.skillcache[professions[skill]]
return rank or false
end
-- Profession requirements shared by Lua and HearthDB quest availability.
function pfDatabase:MeetsQuestProfession(id, skill)
local requirement = pfDB.questProfessionRequirements and pfDB.questProfessionRequirements[tonumber(id)]
local requiredSkill = requirement and requirement[1] or tonumber(skill)
if not requiredSkill then return true end
local rank = self:GetPlayerSkillCached(requiredSkill)
return rank and rank >= (requirement and requirement[2] or 1) or false
end
-- SearchQuests incremental node cache.
-- Tracks which quest IDs passed QuestFilter on the last run. On subsequent
-- calls only the delta (quests entering or leaving the passing set) is
-- processed, so SearchMobID/SearchObjectID are skipped for quests whose
-- questgiver nodes already exist. Must be cleared whenever pfMap.nodes
-- ["PFQUEST"] is wiped (e.g. ResetAll).
-- (Initialised at line ~435 alongside nameIndex so BuildNameIndex can clear it.)
-- SearchQuests
-- Scans for available quests and adds/removes questgiver map nodes.
-- On the first call processes all quests; on subsequent calls only the
-- delta between the previous passing set and the current one is touched.
function pfDatabase:SearchQuests(meta, maps)
local maps = maps or {}
local meta = meta or {}
local plevel = UnitLevel("player")
local refreshAppearance = self.lastQuestGiverLevel ~= plevel
local pfaction = UnitFactionGroup("player")
if pfaction == "Horde" then
pfaction = "H"
elseif pfaction == "Alliance" then
pfaction = "A"
else
pfaction = "GM"
end
local _, race = UnitRace("player")
local prace = pfDatabase:GetBitByRace(race)
local _, class = UnitClass("player")
local pclass = pfDatabase:GetBitByClass(class)
-- build skill cache once for this scan (used by QuestFilter / GetPlayerSkillCached)
pfDatabase:BuildSkillCache()
local t_filter, t_nodes, t_start = 0, 0, GetTime()
-- Phase 1: build the full set of quests that currently pass the filter.
-- This loop is unavoidable but is the only O(all_quests) work we do.
-- Static rejects (wrong race/class, missing name) are skipped before
-- calling QuestFilter to avoid the function call overhead entirely.
local currentSet = {}
local staticReject = self.staticRejectSet
for id in pairs(quests) do
if not staticReject[id] then
local tf0 = GetTime()
local pass = pfDatabase:QuestFilter(id, plevel, pclass, prace)
t_filter = t_filter + (GetTime() - tf0)
if pass then
currentSet[id] = true
end
end
end
-- Phase 2: remove nodes for quests that left the passing set.
local t_rm0 = GetTime()
local removed = 0
local activeRebuild = {}
for id in pairs(self.lastQuestGiversSet) do
if not currentSet[id] then
local title = (pfDB.quests.loc[id] and pfDB.quests.loc[id].T) or UNKNOWN
pfMap:DeleteNode("PFQUEST", title)
-- Available quest-giver markers and active quest objectives currently
-- share their title/node namespace. Accepting a quest therefore makes
-- it leave this available set, and the title removal would otherwise
-- also erase its just-added objective pins. Rebuild only that confirmed
-- active quest after the availability delta is complete.
local active = pfQuest.questlog and pfQuest.questlog[id]
if active and active.qlogid then
activeRebuild[id] = active.qlogid
end
removed = removed + 1
end
end
local t_rm = GetTime() - t_rm0
-- Phase 3: add nodes only for quests newly entering the passing set.
-- Quests already in lastQuestGiversSet are skipped — their nodes exist.
for id in pairs(currentSet) do
if refreshAppearance or not self.lastQuestGiversSet[id] then
-- set metadata
meta["quest"] = (pfDB.quests.loc[id] and pfDB.quests.loc[id].T) or UNKNOWN
meta["questid"] = id
meta["texture"] = pfQuestConfig.path .. "\\img\\available_c"
meta["qlvl"] = quests[id]["lvl"]
meta["qmin"] = quests[id]["min"]
meta["vertex"] = VERTEX_BLACK
meta["layer"] = 3
-- tint high level quests red
if quests[id]["min"] and quests[id]["min"] > plevel then
meta["texture"] = pfQuestConfig.path .. "\\img\\available"
meta["vertex"] = VERTEX_RED
meta["layer"] = 2
end
-- tint low level quests grey
if quests[id]["lvl"] and quests[id]["lvl"] + 10 < plevel then
meta["texture"] = pfQuestConfig.path .. "\\img\\available"
meta["vertex"] = VERTEX_WHITE
meta["layer"] = 2
end
-- tint event quests as blue
if quests[id]["event"] or (pfDB.quests.repeatable and pfDB.quests.repeatable[id]) then
meta["texture"] = pfQuestConfig.path .. "\\img\\available"
meta["vertex"] = VERTEX_BLUE
meta["layer"] = 2
end
-- add questgiver nodes
if quests[id]["start"] then
-- units
if quests[id]["start"]["U"] then
meta["QTYPE"] = "NPC_START"
for _, unit in pairs(quests[id]["start"]["U"]) do
if units[unit] and strfind(units[unit]["fac"] or pfaction, pfaction) then
local tn0 = GetTime()
maps = pfDatabase:SearchMobID(unit, meta, maps)
t_nodes = t_nodes + (GetTime() - tn0)
end
end
end
-- objects
if quests[id]["start"]["O"] then
meta["QTYPE"] = "OBJECT_START"
for _, object in pairs(quests[id]["start"]["O"]) do
if objects[object] and strfind(objects[object]["fac"] or pfaction, pfaction) then
local tn0 = GetTime()
maps = pfDatabase:SearchObjectID(object, meta, maps)
t_nodes = t_nodes + (GetTime() - tn0)
end
end
end
end
end
end
-- Restore active-objective nodes that were removed alongside their former
-- quest-giver marker above. SearchQuestID deduplicates coordinates, so this
-- only repopulates the affected active quests.
for id, qlogid in pairs(activeRebuild) do
pfDatabase:SearchQuestID(id, { ["addon"] = "PFQUEST", ["qlogid"] = qlogid })
end
self.lastQuestGiverLevel = plevel
-- Update lastQuestGiversSet to reflect the current passing set.
-- Reuse the table in-place to avoid allocation.
for id in pairs(self.lastQuestGiversSet) do
self.lastQuestGiversSet[id] = nil
end
for id in pairs(currentSet) do
self.lastQuestGiversSet[id] = true
end
pfQuest:Debug(
format(
"|cffff3333TIMER SearchQuests total=%.4fs filter=%.4fs remove=%d(%.4fs) nodes=%.4fs",
GetTime() - t_start,
t_filter,
removed,
t_rm,
t_nodes
)
)
end
-- AddCustomIcon
-- Helper function to add custom tracking node icons
-- id: negative for objects, positive for units
-- img: path to the image that is appended to root
-- root: optional, default: "Interface\\AddOns\\pfQuest"
function pfDatabase:AddCustomIcon(id, img, root)
if not id or not img then
return
end
root = root and root .. "\\" or pfQuestConfig.path .. "\\"
local object = pfDB["objects"]["loc"][math.abs(id)]
local unit = pfDB["units"]["loc"][math.abs(id)]
if id < 0 and object then
pfDatabase.icons[object] = root .. img
elseif id > 0 and unit then
pfDatabase.icons[unit] = root .. img
end
end
function pfDatabase:FormatQuestText(questText)
questText = string.gsub(questText, "$[Nn]", UnitName("player"))
questText = string.gsub(questText, "$[Cc]", strlower(UnitClass("player")))
questText = string.gsub(questText, "$[Rr]", strlower(UnitRace("player")))
questText = string.gsub(questText, "$[Bb]", "\n")
-- UnitSex("player") returns 2 for male and 3 for female
-- that's why there is an unused capture group around the $[Gg]
return string.gsub(questText, "($[Gg])([^:]+):([^;]+);", "%" .. UnitSex("player"))
end
-- Remember collapsed quest-log headers before a legacy selection fallback.
-- Selecting a hidden row expands its category on 1.12 clients, and the native
-- UI leaves that category open after the selection is restored.
local function CaptureCollapsedQuestHeaders()
if not CollapseQuestHeader then return nil end
local collapsed = {}
local total = GetNumQuestLogEntries()
for index = 1, total do
local title, _, _, isHeader, isCollapsed = compat.GetQuestLogTitle(index)
if title and isHeader and (isCollapsed == true or isCollapsed == 1) then
collapsed[title] = true
end
end
return collapsed
end
local function RestoreCollapsedQuestHeaders(collapsed)
if not collapsed or not CollapseQuestHeader then return end
-- Work upward: collapsing a header changes the rows after it, but not the
-- indices of headers above it.
for index = GetNumQuestLogEntries(), 1, -1 do
local title, _, _, isHeader, isCollapsed = compat.GetQuestLogTitle(index)
if title and isHeader and collapsed[title] and not (isCollapsed == true or isCollapsed == 1) then
CollapseQuestHeader(index)
end
end
end
-- GetQuestIDs
-- Try to guess the quest ID based on the questlog ID
-- Returns possible quest IDs
function pfDatabase:GetQuestIDs(qid, preserveQuestLogSelection)
local title, level, _, header = compat.GetQuestLogTitle(qid)
if header or not title then
return
end
-- Some enhanced 1.12 clients expose GetQuestLinkForLogIndex instead of
-- GetQuestLink. Both provide the exact ID without selecting the quest-log
-- row, which avoids a visible hitch on same-name quest chains. Custom
-- servers can expose an internal ID that differs from the canonical ID in
-- the database, so only trust it when its title also matches.
local getQuestLink = GetQuestLink or GetQuestLinkForLogIndex
if getQuestLink then
local ok, questLink = pcall(getQuestLink, qid)
if ok and questLink then
local _, _, id = strfind(questLink, "|c.*|Hquest:([%d]+):([-]?[%d]+)|h%[(.*)%]|h|r")
if id then
id = tonumber(id)
local linkedQuest = quests[id]
local linkedTitleMatches
for locale in pairs(pfDB.locales or {}) do
local localeData = pfDB.quests[locale] or pfDB.quests[locale .. "-turtle"]
if localeData and localeData[id] and localeData[id].T == title then
linkedTitleMatches = true
break
end
end
if linkedQuest and linkedTitleMatches then
return { [1] = id }
end
end
end
end
-- Most quests have a unique localized title. Resolve those directly before
-- touching the selected quest-log entry. On some heavily hooked 1.12
-- clients, SelectQuestLogEntry during login/quest acceptance can crash the
-- native client instead of returning a Lua error. The selection fallback is
-- only needed when multiple database quests share the same title.
pfQuest_questcache = pfQuest_questcache or {}
local titleKey = "title-v2:" .. title .. ":" .. (level or "")
if pfQuest_questcache[titleKey] and pfQuest_questcache[titleKey][1] then
return pfQuest_questcache[titleKey]
end
local titleCandidates = pfDatabase.nameIndex.quests and pfDatabase.nameIndex.quests[title]
if not titleCandidates then
-- A server may return English quest text while the client and pfQuest use
-- another locale. Search every loaded locale before treating it as an
-- unknown quest. This also maps alternate server IDs back to the canonical
-- database ID used by objectives, starters and enders.
local candidates = {}
local seen = {}
for locale in pairs(pfDB.locales or {}) do
-- Unused base locales are released to save memory. Database extensions
-- load afterward and may still provide their locale-specific delta.
local localeData = pfDB.quests[locale] or pfDB.quests[locale .. "-turtle"]
if localeData then
for id, data in pairs(localeData) do
if quests[id] and data.T == title and not seen[id] then
seen[id] = true
table.insert(candidates, id)
end
end
end
end
if candidates[1] then
titleCandidates = candidates
end
end
local exactID = titleCandidates and titleCandidates[1]
local exactCount = titleCandidates and table.getn(titleCandidates) or 0
if exactCount == 1 then
pfQuest_questcache[titleKey] = { exactID }
return pfQuest_questcache[titleKey]
end
-- Background quest-log scans must not select a hidden quest row while the
-- Quest Log is visible. On legacy 1.12 clients that selection expands the
-- row's collapsed category. Reuse the active quest mapping when possible;
-- otherwise let the caller keep its title fallback until a non-UI scan can
-- resolve the ambiguous title safely.
if preserveQuestLogSelection then
local existingID
for id, data in pairs(pfQuest.questlog or {}) do
if type(id) == "number" and data and data.title == title then
if data.qlogid == qid then
return { [1] = id }
elseif existingID and existingID ~= id then
existingID = nil
break
else
existingID = id
end
end
end
if existingID then return { [1] = existingID } end
return
end
local oldID = GetQuestLogSelection()
local collapsedHeaders = CaptureCollapsedQuestHeaders()
SelectQuestLogEntry(qid)
local text, objective = GetQuestLogQuestText()
SelectQuestLogEntry(oldID)
RestoreCollapsedQuestHeaders(collapsedHeaders)
-- Version this key when resolver rules change so stale same-title matches
-- do not keep bypassing the improved live-objective disambiguation.
local identifier = "objective-v4:" .. title .. ":" .. (level or "") .. ":" .. (objective or "") .. ":" .. (text or "")
-- always make sure the quest-cache exists
pfQuest_questcache = pfQuest_questcache or {}
if pfQuest_questcache[identifier] and pfQuest_questcache[identifier][1] then
return pfQuest_questcache[identifier]
end
local _, race = UnitRace("player")
local prace = pfDatabase:GetBitByRace(race)
local _, class = UnitClass("player")
local pclass = pfDatabase:GetBitByClass(class)
local best = 0
local results = {}
-- Several quest chains reuse both title and level (The Defias Brotherhood
-- is a prominent example). The description fallback can tie on Turtle's
-- altered text, so collect actual item objectives from the quest log and
-- use them as a definitive stage discriminator below.
local objectiveItems = {}
local objectiveUnits = {}
local boardCount = GetNumQuestLeaderBoards(qid) or 0
for board = 1, boardCount do
local boardText, boardType = GetQuestLogLeaderBoard(board, qid)
if boardType == "item" and boardText then
local _, _, itemName = strfind(boardText, "^(.-):")
if itemName then
for itemId in pairs(pfDatabase:GetIDByName(itemName, "items")) do
objectiveItems[itemId] = true
end
end
elseif boardType == "monster" and boardText then
local _, _, unitName = strfind(boardText, "^(.-):")
if unitName then
for unitId in pairs(pfDatabase:GetIDByName(unitName, "units")) do
objectiveUnits[unitId] = true
end
end
end
end
local tcount = titleCandidates and table.getn(titleCandidates) or 0
-- no title was found, run levenshtein on titles
if tcount == 0 and title then
local tlen = string.len(title)
local tscore, tbest, ttitle = nil, math.min(tlen / 2, 5), nil
for id, data in pairs(pfDB["quests"]["loc"]) do
if quests[id] and data.T then
tscore = lev(data.T, title, tbest)
if tscore < tbest then
tbest = tscore
ttitle = data.T
end
end
end
if not ttitle then
-- return early on unknown quests.
if not pfDatabase.localized then
-- skip cache if locale-checks are still running
return { title }
else
-- flag quest as unknown and return
pfQuest_questcache[identifier] = { title }
return pfQuest_questcache[identifier]
end
else
-- set title to best result
title = ttitle
titleCandidates = pfDatabase.nameIndex.quests and pfDatabase.nameIndex.quests[title]
tcount = titleCandidates and table.getn(titleCandidates) or 0
end
end
for _, id in pairs(titleCandidates or {}) do
local data = pfDB["quests"]["loc"][id]
local score = 0
if quests[id] and data and data.T and data.T == title then
-- low score for same name
score = 1
-- check level and set score
if quests[id]["lvl"] == level then
score = score + 8
end
-- check race and set score
if quests[id]["race"] and (bit.band(quests[id]["race"], prace) == prace) then
score = score + 8
end
-- check class and set score
if quests[id]["class"] and (bit.band(quests[id]["class"], pclass) == pclass) then
score = score + 8
end
-- if multiple quests share the same name, use levenshtein algorithm,
-- to compare quest text distances in order to estimate the best quest id
if tcount > 1 then
-- check objective and calculate score
score = score + max(24 - lev(pfDatabase:FormatQuestText(pfDB.quests.loc[id]["O"]), objective, 24), 0)
-- check description and calculate score
score = score + max(24 - lev(pfDatabase:FormatQuestText(pfDB.quests.loc[id]["D"]), text, 24), 0)
-- A matching live item objective distinguishes same-name stages even
-- when their title, level, and Turtle quest text are otherwise equal.
if quests[id]["obj"] and quests[id]["obj"]["I"] then
for _, itemId in pairs(quests[id]["obj"]["I"]) do
if objectiveItems[itemId] then
score = score + 64
break
end
end
end
-- Same-name kill stages (such as The People's Militia) need the
-- live target names as well; descriptions alone can be too similar.
if quests[id]["obj"] and quests[id]["obj"]["U"] then
for _, unitId in pairs(quests[id]["obj"]["U"]) do
if objectiveUnits[unitId] then
score = score + 64
break
end
end
end
end
if score > best then
best = score
end
results[score] = results[score] or {}
if score > 0 then
table.insert(results[score], id)
end
end
end
-- cache for next time
pfQuest_questcache[identifier] = results[best]
return results[best]
end
-- browser search related defaults and values
pfDatabase.lastSearchQuery = ""
pfDatabase.lastSearchResults = { ["items"] = {}, ["quests"] = {}, ["objects"] = {}, ["units"] = {} }
-- BrowserSearch
-- Search for a list of IDs of the specified `searchType` based on if `query` is
-- part of the name or ID of the database entry it is compared against.
--
-- `query` must be a string. If the string represents a number, the search is
-- based on IDs, otherwise it compares names.
--
-- `searchType` must be one of these strings: "items", "quests", "objects" or
-- "units"
--
-- Returns a table and an integer, the latter being the element count of the
-- former. The table contains the ID as keys for the name of the search result.
-- E.g.: {{[5] = "Some Name", [231] = "Another Name"}, 2}
-- If the query doesn't satisfy the minimum search length requiered for its
-- type (number/string), the favourites for the `searchType` are returned.
function pfDatabase:BrowserSearch(query, searchType)
local queryLength = strlen(query) -- needed for some checks
local queryNumber = tonumber(query) -- if nil, the query is NOT a number
local results = {} -- save results
local resultCount = 0 -- count results
-- Set the DB to be searched
local minChars = 3
local minInts = 1
if (queryLength >= minChars) or (queryNumber and (queryLength >= minInts)) then -- make sure this is no fav display
if
((queryLength > minChars) or (queryNumber and (queryLength > minInts)))
and (pfDatabase.lastSearchQuery ~= "" and queryLength > strlen(pfDatabase.lastSearchQuery))
then
-- there are previous search results to use
local searchDatabase = pfDatabase.lastSearchResults[searchType]
-- iterate the last search
for id, _ in pairs(searchDatabase) do
local dbLocale = pfDB[searchType]["loc"][id]
if dbLocale then
local compare
local search = query
if queryNumber then
-- do number search
compare = tostring(id)
else
-- do name search
search = strlower(query)
if searchType == "quests" then
compare = strlower(dbLocale["T"])
else
compare = strlower(dbLocale)
end
end
-- search and save on match
if strfind(compare, search) then
results[id] = dbLocale
resultCount = resultCount + 1
end
end
end
return results, resultCount
else
-- no previous results, search whole DB
if queryNumber then
results = pfDatabase:GetIDByIDPart(query, searchType)
else
results = pfDatabase:GetIDByName(query, searchType, true)
end
local resultCount = 0
for _, _ in pairs(results) do
resultCount = resultCount + 1
end
return results, resultCount
end
else
-- minimal search length not satisfied, reset search results and return favourites
return pfBrowser_fav[searchType], -1
end
end
local function LoadCustomData(always)
-- table.getn doesn't work here :/
local icount = 0
for _, _ in pairs(pfQuest_server["items"]) do
icount = icount + 1
end
if icount > 0 or always then
for id, name in pairs(pfQuest_server["items"]) do
pfDB["items"]["loc"][id] = name
end
DEFAULT_CHAT_FRAME:AddMessage(
"|cff33ffccpf|cffffffffQuest: |cff33ffcc" .. icount .. "|cffffffff " .. pfQuest_Loc["custom items loaded."]
)
end
end
local pfServerScan = CreateFrame("Frame", "pfServerItemScan", UIParent)
pfServerScan:SetWidth(200)
pfServerScan:SetHeight(100)
pfServerScan:SetPoint("TOP", 0, 0)
pfServerScan:Hide()
pfServerScan.scanID = 1
pfServerScan.max = 100000
pfServerScan.perloop = 100
pfServerScan.header = pfServerScan:CreateFontString("Caption", "LOW", "GameFontWhite")
pfServerScan.header:SetFont(STANDARD_TEXT_FONT, 13, "OUTLINE")
pfServerScan.header:SetJustifyH("CENTER")
pfServerScan.header:SetPoint("CENTER", 0, 0)
pfServerScan:RegisterEvent("VARIABLES_LOADED")
pfServerScan:SetScript("OnEvent", function()
pfQuest_server = pfQuest_server or {}
pfQuest_server["items"] = pfQuest_server["items"] or {}
LoadCustomData()
end)
pfServerScan:SetScript("OnHide", function()
ItemRefTooltip:Show()
LoadCustomData(true)
end)
pfServerScan:SetScript("OnShow", function()
this.scanID = 1
pfQuest_server["items"] = {}
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccpf|cffffffffQuest: " .. pfQuest_Loc["Server scan started..."])
end)
local ignore, custom_id, custom_skip = {}, nil, nil
pfServerScan:SetScript("OnUpdate", function()
if this.scanID >= this.max then
this:Hide()
return
end
-- scan X items per update
for i = this.scanID, this.scanID + this.perloop do
pfServerScan.header:SetText(
pfQuest_Loc["Scanning server for items..."] .. " " .. string.format("%.1f", 100 * i / this.max) .. "%"
)
local link = "item:" .. i .. ":0:0:0"
ItemRefTooltip:SetOwner(UIParent, "ANCHOR_PRESERVE")
ItemRefTooltip:SetHyperlink(link)
if ItemRefTooltipTextLeft1 and ItemRefTooltipTextLeft1:IsVisible() then
local name = ItemRefTooltipTextLeft1:GetText()
ItemRefTooltip:Hide()
-- skip-wait for item retrieval
if name == (RETRIEVING_ITEM_INFO or "") then
if not ignore[i] then
if custom_id == i and custom_skip >= 3 then
-- ignore item and proceed
ignore[i] = true
elseif custom_id == i then
-- try again up to 3 times
custom_skip = custom_skip + 1
return
elseif custom_id ~= i then
-- give it another try
custom_id = i
custom_skip = 0
return
end
end
end
-- assign item to custom server table
if not pfDB["items"]["loc"][i] and not ignore[i] then
pfQuest_server["items"][i] = name
end
end
end
this.scanID = this.scanID + this.perloop
end)
function pfDatabase:ScanServer()
pfServerScan:Show()
end
-- Pre-create frame and handler for QueryServer (avoid creating per call)
local queryFrame
local function OnQuestQueryComplete(self)
self:UnregisterEvent("QUEST_QUERY_COMPLETE")
-- Retrieve completed quests after the QUEST_QUERY_COMPLETE event
local completedQuests = GetQuestsCompleted()
if type(completedQuests) == "table" then
for questID, _ in pairs(completedQuests) do
pfQuest_history[questID] = { time(), UnitLevel("player") }
end
-- Reset all quest markers after processing completed quests
pfQuest:ResetAll()
elseif completedQuests == nil then
print("Error: GetQuestsCompleted() returned nil.")
else
print("Error: GetQuestsCompleted() did not return a valid table. Value: ", completedQuests)
end
end
function pfDatabase:QueryServer()
-- break here on incompatible versions
if not QueryQuestsCompleted then
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccpf|cffffffffQuest: Option is not available on your server.")
return
end
-- Reuse frame instead of creating new one each call
if not queryFrame then
queryFrame = CreateFrame("Frame")
queryFrame:SetScript("OnEvent", OnQuestQueryComplete)
end
queryFrame:RegisterEvent("QUEST_QUERY_COMPLETE")
QueryQuestsCompleted()
end