Files
2026-09-30 22:21:38 +02:00

680 lines
21 KiB
Lua

BNP = BNP or {}
-- Quest nameplate indicators integrated from QuestPlatesOcto.
-- QuestPlatesOcto is MIT licensed, Copyright (c) 2026 Pap Gergő Rajmund.
-- Original project: https://github.com/papgergo/QuestPlatesOcto
-- The dependency/data lookup approach also credits GabHST/QuestPlates as in
-- the upstream project. See LICENSE-QuestPlatesOcto.txt shipped with BNP.
local KILL_TEXTURE = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\quest_kill.tga"
local ITEM_TEXTURE = "Interface\\AddOns\\BlizzNameplatesPlus\\media\\quest_item.tga"
local ICON_SPACING = 5
local MAX_ICONS_PER_PLATE = 8
local questObjectives = {}
local questObjectiveKeys = {}
local questIdCache = {}
local questMonsterSourceCache = {}
local questItemSourceCache = {}
local cachedDependencyName = nil
local questRevision = 0
local updatePending = false
local updateDelay = 0.50
local updateElapsed = 0
local questRuntimeEnabled = false
local questCallbacksRegistered = false
local eventFrame
local QuestRuntimeOnUpdate
local QUEST_EVENTS = {
"QUEST_LOG_UPDATE",
"QUEST_WATCH_UPDATE",
"UNIT_QUEST_LOG_CHANGED",
}
local function ClearTable(tbl)
local key
for key in pairs(tbl) do tbl[key] = nil end
end
local function Lower(value)
if not value then return nil end
return string.lower(tostring(value))
end
local function QuestieReady()
return QuestieOcto and QuestieOcto.DatabaseAPI and
QuestieOcto.DatabaseAPI.IsReady and
QuestieOcto.DatabaseAPI:IsReady()
end
local function PfQuestReady()
return pfDB and pfDB.quests and pfDB.quests.data and pfDB.quests.loc
end
function BNP:GetQuestPlateDependencyName()
if QuestieReady() then return "Questie-Octo" end
if PfQuestReady() then return "pfQuest" end
return nil
end
local function ResetQuestDataCaches()
ClearTable(questIdCache)
ClearTable(questMonsterSourceCache)
ClearTable(questItemSourceCache)
end
local function SyncQuestDataDependency()
local dependencyName = BNP:GetQuestPlateDependencyName()
if dependencyName ~= cachedDependencyName then
cachedDependencyName = dependencyName
ResetQuestDataCaches()
end
return dependencyName
end
local function GetQuestIdByTitle(title, level)
if not title then return nil end
local wantedTitle = Lower(title)
local wantedLevel = tonumber(level)
local cacheKey = wantedTitle .. "|" .. tostring(wantedLevel or "")
if questIdCache[cacheKey] ~= nil then
return questIdCache[cacheKey] or nil
end
if QuestieReady() then
local DB = QuestieOcto.DatabaseAPI
local ids = DB:GetQuestIDs()
if ids then
local i
for i = 1, table.getn(ids) do
local id = ids[i]
local q = QuestieOcto.QuestModel:Get(id)
if q and tonumber(q.level) == wantedLevel and Lower(q.title) == wantedTitle then
questIdCache[cacheKey] = id
return id
end
end
end
end
if PfQuestReady() then
local id
for id in pairs(pfDB.quests.data) do
local loc = pfDB.quests.loc[id]
local questTitle = type(loc) == "table" and (loc.T or loc[1]) or loc
local questData = pfDB.quests.data[id]
local questLevel = questData and (questData.lvl or questData.level)
if questTitle and tonumber(questLevel) == wantedLevel and Lower(questTitle) == wantedTitle then
questIdCache[cacheKey] = id
return id
end
end
end
-- false is used as a negative cache sentinel.
questIdCache[cacheKey] = false
return nil
end
local function GetDataFromQuestie(questId, itemName)
if not QuestieReady() then return nil end
local DB = QuestieOcto.DatabaseAPI
local q = QuestieOcto.QuestModel:Get(questId)
if not q or not q.objectives or not q.objectives.item then return nil end
local mobs = {}
local seen = {}
local wanted = Lower(itemName) or ""
local i
for i = 1, table.getn(q.objectives.item) do
local itemId = q.objectives.item[i]
if Lower(DB:GetItemName(itemId) or "") == wanted then
local sources = DB:GetItemSources(itemId)
if sources and sources.Creature then
local mobId
for mobId in pairs(sources.Creature) do
local mobName = DB:GetCreatureName(mobId)
if mobName and not seen[mobName] then
table.insert(mobs, mobName)
seen[mobName] = true
end
end
end
if sources and sources.Reference then
local refId
for refId in pairs(sources.Reference) do
local refLoot = DB:GetReferenceLootRaw(refId)
if refLoot and refLoot.U then
local mobId
for mobId in pairs(refLoot.U) do
local mobName = DB:GetCreatureName(mobId)
if mobName and not seen[mobName] then
table.insert(mobs, mobName)
seen[mobName] = true
end
end
end
end
end
end
end
if table.getn(mobs) > 0 then return mobs end
return nil
end
local function PfLocalizedName(loc, id)
if not loc then return nil end
local value = loc[id]
if type(value) == "table" then return value.T or value[1] end
return value
end
local function GetDataFromPfDb(questId, itemName)
if not pfDB or not pfDB.quests or not pfDB.items or not pfDB.units then return nil end
local questData = pfDB.quests.data and pfDB.quests.data[questId]
if not questData or not questData.obj or not questData.obj.I then return nil end
local qdata = pfDB.quests.data
local itemData = pfDB.items.data
local itemLoc = pfDB.items.loc
local unitLoc = pfDB.units.loc
local refloot = pfDB.refloot and pfDB.refloot.data
if not qdata or not itemData or not itemLoc or not unitLoc then return nil end
local wanted = Lower(itemName) or ""
local mobs = {}
local seen = {}
local obj = qdata[questId] and qdata[questId].obj
if not obj or not obj.I then return nil end
local _, itemId
for _, itemId in pairs(obj.I) do
local sourceName = PfLocalizedName(itemLoc, itemId)
if sourceName and Lower(sourceName) == wanted and itemData[itemId] then
if itemData[itemId].U then
local unitId
for unitId in pairs(itemData[itemId].U) do
local name = PfLocalizedName(unitLoc, unitId)
if name and not seen[name] then
table.insert(mobs, name)
seen[name] = true
end
end
end
if itemData[itemId].R and refloot then
local ref
for ref in pairs(itemData[itemId].R) do
if refloot[ref] and refloot[ref].U then
local unitId
for unitId in pairs(refloot[ref].U) do
local name = PfLocalizedName(unitLoc, unitId)
if name and not seen[name] then
table.insert(mobs, name)
seen[name] = true
end
end
end
end
end
end
end
if table.getn(mobs) > 0 then return mobs end
return nil
end
local function GetItemDropSource(questId, itemName)
if not questId or not itemName then return nil end
local wanted = Lower(itemName) or ""
local cacheKey = tostring(questId) .. "|" .. wanted
if questItemSourceCache[cacheKey] ~= nil then
return questItemSourceCache[cacheKey] or nil
end
local mobs
if QuestieReady() then mobs = GetDataFromQuestie(questId, itemName) end
if (not mobs or table.getn(mobs) == 0) and PfQuestReady() then
mobs = GetDataFromPfDb(questId, itemName)
end
questItemSourceCache[cacheKey] = (mobs and table.getn(mobs) > 0) and mobs or false
return questItemSourceCache[cacheKey] or nil
end
local function GetMonsterDataFromQuestie(questId)
if not QuestieReady() then return nil end
local DB = QuestieOcto.DatabaseAPI
local q = QuestieOcto.QuestModel:Get(questId)
if not q or not q.objectives or not q.objectives.monster then return nil end
local mobs = {}
local seen = {}
local i
for i = 1, table.getn(q.objectives.monster) do
local mobId = q.objectives.monster[i]
local mobName = DB:GetCreatureName(mobId)
if mobName and not seen[mobName] then
table.insert(mobs, mobName)
seen[mobName] = true
end
end
if table.getn(mobs) > 0 then return mobs end
return nil
end
local function GetMonsterDataFromPfDb(questId)
if not pfDB or not pfDB.quests or not pfDB.quests.data or
not pfDB.units or not pfDB.units.loc then return nil end
local questData = pfDB.quests.data[questId]
if not questData or not questData.obj or not questData.obj.U then return nil end
local mobs = {}
local seen = {}
local _, unitId
for _, unitId in pairs(questData.obj.U) do
local name = PfLocalizedName(pfDB.units.loc, unitId)
if name and not seen[name] then
table.insert(mobs, name)
seen[name] = true
end
end
if table.getn(mobs) > 0 then return mobs end
return nil
end
local function GetMonsterSource(questId)
if not questId then return nil end
if questMonsterSourceCache[questId] ~= nil then
return questMonsterSourceCache[questId] or nil
end
local mobs
if QuestieReady() then mobs = GetMonsterDataFromQuestie(questId) end
if (not mobs or table.getn(mobs) == 0) and PfQuestReady() then
mobs = GetMonsterDataFromPfDb(questId)
end
questMonsterSourceCache[questId] = (mobs and table.getn(mobs) > 0) and mobs or false
return questMonsterSourceCache[questId] or nil
end
local function IsMobMatchObjective(mobName, objectiveName)
local lm = Lower(mobName) or ""
local lo = Lower(objectiveName) or ""
lo = string.gsub(lo, "%s*slain.*$", "")
if lm == lo then return true end
local singular = string.gsub(lo, "s$", "")
if lm == singular then return true end
if singular ~= "" and string.find(lm, singular, 1, true) then return true end
if lm ~= "" and string.find(lo, lm, 1, true) then return true end
return false
end
local function AddObjective(target, mobName, objectiveName, texture, remaining)
local key = Lower(mobName)
if not key or key == "" then return end
if not target[key] then target[key] = {} end
target[key][objectiveName] = {
icon = texture,
text = tostring(remaining),
}
end
local function BuildObjectiveKey(objectives)
if not objectives then return "" end
local parts = {}
local objectiveName, data
for objectiveName, data in pairs(objectives) do
table.insert(parts, tostring(objectiveName) .. "=" .. tostring(data.icon or "") .. ":" .. tostring(data.text or ""))
end
table.sort(parts)
return table.concat(parts, "|")
end
local function BuildObjectiveKeys(objectives)
local keys = {}
local mobKey, mobObjectives
for mobKey, mobObjectives in pairs(objectives) do
keys[mobKey] = BuildObjectiveKey(mobObjectives)
end
return keys
end
local function FindChangedObjectiveKeys(oldKeys, newKeys)
local changed = {}
local key, value
for key, value in pairs(oldKeys) do
if newKeys[key] ~= value then changed[key] = true end
end
for key, value in pairs(newKeys) do
if oldKeys[key] ~= value then changed[key] = true end
end
return changed
end
function BNP:UpdateQuestPlateObjectives()
if not questRuntimeEnabled or not self:AreQuestPlateIndicatorsEnabled() then return end
-- Quest IDs and source mappings are static. Keep them cached between
-- QUEST_LOG_UPDATE events; only invalidate if the backing dependency changes.
SyncQuestDataDependency()
local nextObjectives = {}
local oldSelection
if GetQuestLogSelection then oldSelection = GetQuestLogSelection() end
local numEntries = GetNumQuestLogEntries and GetNumQuestLogEntries() or 0
local i
for i = 1, numEntries do
SelectQuestLogEntry(i)
local questTitle, questLevel, _, isHeader, _, isComplete = GetQuestLogTitle(i)
if not isHeader and not isComplete and questTitle then
local questId = GetQuestIdByTitle(questTitle, questLevel)
local numObjectives = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards() or 0
local monsterCount = 0
local j
for j = 1, numObjectives do
local _, objectiveType = GetQuestLogLeaderBoard(j)
if objectiveType == "monster" then monsterCount = monsterCount + 1 end
end
for j = 1, numObjectives do
local description, objectiveType = GetQuestLogLeaderBoard(j)
if description then
-- WoW 1.12 uses Lua 5.0: string.find captures instead of string.match.
local _, _, objectiveName, currentText, totalText =
string.find(description, "^(.+):%s*(%d+)%s*/%s*(%d+)")
local current = tonumber(currentText)
local total = tonumber(totalText)
if objectiveName and current and total and current < total then
local mobs
local texture
if objectiveType == "monster" then
if questId then mobs = GetMonsterSource(questId) end
if mobs and monsterCount > 1 then
local filtered = {}
local _, mobName
for _, mobName in pairs(mobs) do
if IsMobMatchObjective(mobName, objectiveName) then
table.insert(filtered, mobName)
end
end
if table.getn(filtered) > 0 then mobs = filtered end
end
if not mobs or table.getn(mobs) == 0 then
local fallback = string.gsub(objectiveName, "%s+slain$", "")
if fallback and fallback ~= "" then mobs = { fallback } end
end
texture = KILL_TEXTURE
elseif objectiveType == "item" and questId then
mobs = GetItemDropSource(questId, objectiveName)
texture = ITEM_TEXTURE
end
if mobs and texture then
local _, mobName
for _, mobName in pairs(mobs) do
AddObjective(nextObjectives, mobName, objectiveName, texture, total - current)
end
end
end
end
end
end
end
if oldSelection and oldSelection > 0 and oldSelection <= numEntries then
SelectQuestLogEntry(oldSelection)
end
local nextObjectiveKeys = BuildObjectiveKeys(nextObjectives)
local changedKeys = FindChangedObjectiveKeys(questObjectiveKeys, nextObjectiveKeys)
questObjectives = nextObjectives
questObjectiveKeys = nextObjectiveKeys
questRevision = questRevision + 1
-- Only touch nameplates whose objective display actually changed. A kill or
-- loot event normally affects one mob type, not every visible nameplate.
if self.RefreshQuestPlateIndicators then self:RefreshQuestPlateIndicators(changedKeys) end
end
local function HideQuestIcons(plate)
if not plate or not plate.BNPQuestIcons then return end
local i
for i = 1, table.getn(plate.BNPQuestIcons) do
plate.BNPQuestIcons[i]:Hide()
end
plate.BNPQuestRenderKey = nil
end
local function GetQuestIcon(plate, index)
plate.BNPQuestIcons = plate.BNPQuestIcons or {}
local icon = plate.BNPQuestIcons[index]
if icon then return icon end
icon = CreateFrame("Frame", nil, plate)
icon.texture = icon:CreateTexture(nil, "ARTWORK")
icon.texture:SetAllPoints(icon)
icon.text = icon:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
icon.text:SetPoint("LEFT", icon, "RIGHT", 1, 0)
if icon.text.SetShadowOffset then icon.text:SetShadowOffset(1, -1) end
if icon.text.SetTextColor then icon.text:SetTextColor(1, 0.82, 0) end
plate.BNPQuestIcons[index] = icon
return icon
end
function BNP:UpdateQuestPlateIndicator(plate, force)
if not plate then return end
if not self:AreQuestPlateIndicatorsEnabled() then
HideQuestIcons(plate)
return
end
local healthbar = plate.healthbar
local nameRegion = plate.name
if not healthbar or not nameRegion or not nameRegion.GetText then
HideQuestIcons(plate)
return
end
local unitName = nameRegion:GetText()
local objectives = unitName and questObjectives[Lower(unitName)] or nil
if not objectives then
HideQuestIcons(plate)
return
end
local size = self:GetQuestPlateIconSize()
local x = self:GetQuestPlateXOffset()
local y = self:GetQuestPlateYOffset()
local objectiveKey = questObjectiveKeys[Lower(unitName)] or ""
local renderKey = tostring(objectiveKey) .. "|" .. tostring(unitName) .. "|" ..
tostring(size) .. "|" .. tostring(x) .. "|" .. tostring(y)
if not force and plate.BNPQuestRenderKey == renderKey then return end
plate.BNPQuestRenderKey = renderKey
local entries = {}
local objectiveName, data
for objectiveName, data in pairs(objectives) do
table.insert(entries, { name = objectiveName, data = data })
end
table.sort(entries, function(a, b) return tostring(a.name) < tostring(b.name) end)
local previous
local shown = 0
local i
for i = 1, table.getn(entries) do
if shown >= MAX_ICONS_PER_PLATE then break end
shown = shown + 1
local icon = GetQuestIcon(plate, shown)
icon:SetParent(healthbar)
if icon.SetFrameLevel and healthbar.GetFrameLevel then
icon:SetFrameLevel((healthbar:GetFrameLevel() or 1) + 8)
end
icon:SetWidth(size)
icon:SetHeight(size)
icon:ClearAllPoints()
if previous then
icon:SetPoint("LEFT", previous.text, "RIGHT", ICON_SPACING, 0)
else
icon:SetPoint("LEFT", healthbar, "RIGHT", x, y)
end
icon.texture:SetTexture(entries[i].data.icon)
icon.text:SetText(entries[i].data.text or "")
icon:Show()
previous = icon
end
if plate.BNPQuestIcons then
for i = shown + 1, table.getn(plate.BNPQuestIcons) do
plate.BNPQuestIcons[i]:Hide()
end
end
end
function BNP:RefreshQuestPlateIndicators(changedKeys)
local plate
for plate in pairs(self.plates or {}) do
local shouldUpdate = true
if changedKeys then
local nameRegion = plate and plate.name
local unitName = nameRegion and nameRegion.GetText and nameRegion:GetText()
local mobKey = unitName and Lower(unitName) or nil
shouldUpdate = mobKey and changedKeys[mobKey]
end
if shouldUpdate then
if plate and plate.IsShown and plate:IsShown() then
self:UpdateQuestPlateIndicator(plate, true)
else
HideQuestIcons(plate)
end
end
end
end
function BNP:RequestQuestPlateObjectiveUpdate()
if not questRuntimeEnabled or not self:AreQuestPlateIndicatorsEnabled() then return end
updatePending = true
updateElapsed = 0
end
local function SetQuestEventsRegistered(enabled)
if not eventFrame then return end
local i
for i = 1, table.getn(QUEST_EVENTS) do
if enabled then
eventFrame:RegisterEvent(QUEST_EVENTS[i])
else
eventFrame:UnregisterEvent(QUEST_EVENTS[i])
end
end
end
local function RemoveCallback(list, callback)
local i
for i = table.getn(list), 1, -1 do
if list[i] == callback then
table.remove(list, i)
end
end
end
local QuestPlateOnShow = function(plate)
plate.BNPQuestRenderKey = nil
BNP:UpdateQuestPlateIndicator(plate, true)
end
local QuestPlateOnUpdate = function(plate)
BNP:UpdateQuestPlateIndicator(plate, false)
end
local function SetQuestNameplateCallbacksRegistered(enabled)
if enabled and not questCallbacksRegistered then
table.insert(BNP.libnameplate.OnShow, QuestPlateOnShow)
table.insert(BNP.libnameplate.OnUpdate, QuestPlateOnUpdate)
questCallbacksRegistered = true
elseif not enabled and questCallbacksRegistered then
RemoveCallback(BNP.libnameplate.OnShow, QuestPlateOnShow)
RemoveCallback(BNP.libnameplate.OnUpdate, QuestPlateOnUpdate)
questCallbacksRegistered = false
end
end
function BNP:SetQuestPlateRuntimeEnabled(enabled, rebuild)
enabled = enabled and true or false
if questRuntimeEnabled ~= enabled then
questRuntimeEnabled = enabled
SetQuestEventsRegistered(enabled)
SetQuestNameplateCallbacksRegistered(enabled)
if eventFrame then
eventFrame:SetScript("OnUpdate", enabled and QuestRuntimeOnUpdate or nil)
end
end
if enabled then
if rebuild then
ClearTable(questObjectives)
ClearTable(questObjectiveKeys)
cachedDependencyName = nil
ResetQuestDataCaches()
questRevision = questRevision + 1
end
self:RequestQuestPlateObjectiveUpdate()
if self.RefreshQuestPlateIndicators then self:RefreshQuestPlateIndicators() end
else
updatePending = false
updateElapsed = 0
ClearTable(questObjectives)
ClearTable(questObjectiveKeys)
cachedDependencyName = nil
ResetQuestDataCaches()
questRevision = questRevision + 1
if self.RefreshQuestPlateIndicators then self:RefreshQuestPlateIndicators() end
end
end
function BNP:SyncQuestPlateRuntime(rebuild)
self:SetQuestPlateRuntimeEnabled(self:AreQuestPlateIndicatorsEnabled(), rebuild)
end
QuestRuntimeOnUpdate = function()
if not updatePending then return end
updateElapsed = updateElapsed + arg1
if updateElapsed < updateDelay then return end
updatePending = false
updateElapsed = 0
BNP:UpdateQuestPlateObjectives()
end
eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("VARIABLES_LOADED")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:SetScript("OnEvent", function()
if event == "VARIABLES_LOADED" or event == "PLAYER_LOGIN" or event == "PLAYER_ENTERING_WORLD" then
if BNP:AreQuestPlateIndicatorsEnabled() then
BNP:SetQuestPlateRuntimeEnabled(true, true)
elseif questRuntimeEnabled then
BNP:SetQuestPlateRuntimeEnabled(false, false)
end
return
end
if questRuntimeEnabled then
BNP:RequestQuestPlateObjectiveUpdate()
end
end)