v9.7.2: Ebonhold Database integration and core logic refinements

This commit is contained in:
Xurkon
2026-02-14 21:13:44 -06:00
parent 0a2cc15641
commit 6ef85d4e2d
573 changed files with 1751730 additions and 2 deletions
+426
View File
@@ -0,0 +1,426 @@
---@class AvailableQuests
local AvailableQuests = QuestieLoader:CreateModule("AvailableQuests")
---@type ThreadLib
local ThreadLib = QuestieLoader:ImportModule("ThreadLib")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
---@type ZoneDB
local ZoneDB = QuestieLoader:ImportModule("ZoneDB")
---@type QuestiePlayer
local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer")
---@type QuestieMap
local QuestieMap = QuestieLoader:ImportModule("QuestieMap")
---@type QuestieTooltips
local QuestieTooltips = QuestieLoader:ImportModule("QuestieTooltips")
---@type QuestieCorrections
local QuestieCorrections = QuestieLoader:ImportModule("QuestieCorrections")
---@type QuestieQuestBlacklist
local QuestieQuestBlacklist = QuestieLoader:ImportModule("QuestieQuestBlacklist")
---@type IsleOfQuelDanas
local IsleOfQuelDanas = QuestieLoader:ImportModule("IsleOfQuelDanas")
---@type QuestieLib
local QuestieLib = QuestieLoader:ImportModule("QuestieLib")
local GetQuestGreenRange = GetQuestGreenRange
local yield = coroutine.yield
local tinsert = table.insert
local NewThread = ThreadLib.ThreadSimple
local QUESTS_PER_YIELD = 24
--- Used to keep track of the active timer for CalculateAndDrawAll
---@type Ticker|nil
local timer
-- Keep track of all available quests to unload undoable when abandoning a quest
local availableQuests = {}
local dungeons = ZoneDB:GetDungeons()
local _CalculateAvailableQuests, _DrawChildQuests, _AddStarter, _DrawAvailableQuest, _GetQuestIcon, _GetIconScaleForAvailable, _HasProperDistanceToAlreadyAddedSpawns
---@param callback function | nil
function AvailableQuests.CalculateAndDrawAll(callback)
Questie:Debug(Questie.DEBUG_INFO, "[AvailableQuests.CalculateAndDrawAll]")
--? Cancel the previously running timer to not have multiple running at the same time
if timer then
timer:Cancel()
end
timer = ThreadLib.Thread(_CalculateAvailableQuests, 0, "Error in AvailableQuests.CalculateAndDrawAll", callback)
end
--Draw a single available quest, it is used by the CalculateAndDrawAll function.
---@param quest Quest
function AvailableQuests.DrawAvailableQuest(quest) -- prevent recursion
--? Some quests can be started by both an NPC and a GameObject
if not quest or not quest.Starts then
return
end
if quest.Starts["GameObject"] then
local gameObjects = quest.Starts["GameObject"]
for i = 1, #gameObjects do
local objId = gameObjects[i]
local obj = QuestieDB:GetObject(objId)
if obj and obj.id then
_AddStarter(obj, quest, "o_" .. obj.id)
end
end
end
if quest.Starts["NPC"] then
local npcs = quest.Starts["NPC"]
for i = 1, #npcs do
local starterId = npcs[i]
local npc = QuestieDB:GetNPC(starterId)
if npc and npc.id then
_AddStarter(npc, quest, "m_" .. npc.id)
else
-- Ascension: sometimes quest starters are GameObjects but end up in the NPC starts list
local obj = QuestieDB:GetObject(starterId)
if obj and obj.id then
_AddStarter(obj, quest, "o_" .. obj.id)
end
end
end
end
end
function AvailableQuests.UnloadUndoable()
for questId, _ in pairs(availableQuests) do
if (not QuestieDB.IsDoable(questId)) then
QuestieMap:UnloadQuestFrames(questId)
end
end
end
_CalculateAvailableQuests = function()
-- Localize the variables for speeeeed
local debugEnabled = Questie.db.profile.debugEnabled
local questData = QuestieDB.QuestPointers or QuestieDB.questData
local playerLevel = QuestiePlayer.GetPlayerLevel()
local minLevel = playerLevel - GetQuestGreenRange("player")
local maxLevel = playerLevel
if Questie.db.profile.lowLevelStyle == Questie.LOWLEVEL_RANGE then
minLevel = Questie.db.profile.minLevelFilter
maxLevel = Questie.db.profile.maxLevelFilter
elseif Questie.db.profile.lowLevelStyle == Questie.LOWLEVEL_OFFSET then
minLevel = playerLevel - Questie.db.profile.manualLevelOffset
end
local completedQuests = Questie.db.char.complete
local showRepeatableQuests = Questie.db.profile.showRepeatableQuests
local showDungeonQuests = Questie.db.profile.showDungeonQuests
local showRaidQuests = Questie.db.profile.showRaidQuests
local showPvPQuests = Questie.db.profile.showPvPQuests
local showAQWarEffortQuests = Questie.db.profile.showAQWarEffortQuests
local autoBlacklist = QuestieDB.autoBlacklist
local hiddenQuests = QuestieCorrections.hiddenQuests
local hidden = Questie.db.char.hidden
local currentQuestlog = QuestiePlayer.currentQuestlog
local currentIsleOfQuelDanasQuests = IsleOfQuelDanas.quests[Questie.db.profile.isleOfQuelDanasPhase] or {}
local aqWarEffortQuests = QuestieQuestBlacklist.AQWarEffortQuests
QuestieDB.activeChildQuests = {} -- Reset here so we don't need to keep track in the quest event system
local activeChildQuests = QuestieDB.activeChildQuests
-- We create a local function here to improve readability but use the localized variables above.
-- The order of checks is important here to bring the speed to a max
local function _DrawQuestIfAvailable(questId)
if (autoBlacklist[questId] or -- Don't show autoBlacklist quests marked as such by IsDoable
completedQuests[questId] or -- Don't show completed quests
hiddenQuests[questId] or -- Don't show blacklisted quests
hidden[questId] or -- Don't show quests hidden by the player
activeChildQuests[questId] -- We already drew this quest in a previous loop iteration
) then
return
end
if currentQuestlog[questId] then
_DrawChildQuests(questId, currentQuestlog, completedQuests)
if QuestieDB.IsComplete(questId) ~= -1 then -- The quest in the quest log is not failed, so we don't show it as available
return
end
end
if (
((not showRepeatableQuests) and QuestieDB.IsRepeatable(questId)) or -- Don't show repeatable quests if option is disabled
((not showPvPQuests) and QuestieDB.IsPvPQuest(questId)) or -- Don't show PvP quests if option is disabled
((not showDungeonQuests) and QuestieDB.IsDungeonQuest(questId)) or -- Don't show dungeon quests if option is disabled
((not showRaidQuests) and QuestieDB.IsRaidQuest(questId)) or -- Don't show raid quests if option is disabled
((not showAQWarEffortQuests) and aqWarEffortQuests[questId]) or -- Don't show AQ War Effort quests if the option disabled
(Questie.IsClassic and currentIsleOfQuelDanasQuests[questId]) or -- Don't show Isle of Quel'Danas quests for Era/HC/SoX
(Questie.IsSoD and QuestieDB.IsRuneAndShouldBeHidden(questId)) -- Don't show SoD Rune quests with the option disabled
) then
return
end
if (
(not QuestieDB.IsLevelRequirementsFulfilled(questId, minLevel, maxLevel, playerLevel)) or
(not QuestieDB.IsDoable(questId, debugEnabled))
) then
--If the quests are not within level range we want to unload them
--(This is for when people level up or change settings etc)
if availableQuests[questId] then
QuestieMap:UnloadQuestFrames(questId)
QuestieTooltips:RemoveQuest(questId)
end
return
end
availableQuests[questId] = true
if QuestieMap.questIdFrames[questId] then
-- We already drew this quest so we might need to update the icon (config changed/level up)
for _, frame in ipairs(QuestieMap:GetFramesForQuest(questId)) do
if frame and frame.data and frame.data.QuestData then
local newIcon = _GetQuestIcon(frame.data.QuestData)
if newIcon ~= frame.data.Icon then
frame:UpdateTexture(Questie.usedIcons[newIcon])
end
end
end
return
end
_DrawAvailableQuest(questId)
end
local questCount = 0
-- 1) Base Questie DB (compiled pointers)
for questId in pairs(questData) do
_DrawQuestIfAvailable(questId)
questCount = questCount + 1
if questCount > QUESTS_PER_YIELD then
questCount = 0
yield()
end
end
-- 2) Ascension override quests (not present in QuestPointers)
-- These are injected into QuestieDB.questDataOverrides by AscensionLoader.
local ascensionQuestIds = QuestieDB.ascensionQuestIds or QuestieDB.questDataOverrides
if type(ascensionQuestIds) == "table" then
for questId in pairs(ascensionQuestIds) do
if type(questId) == "number" then
_DrawQuestIfAvailable(questId)
questCount = questCount + 1
if questCount > QUESTS_PER_YIELD then
questCount = 0
yield()
end
end
end
end
end
--- Mark all child quests as active when the parent quest is in the quest log
---@param questId number
---@param currentQuestlog table<number, boolean>
---@param completedQuests table<number, boolean>
_DrawChildQuests = function(questId, currentQuestlog, completedQuests)
local childQuests = QuestieDB.QueryQuestSingle(questId, "childQuests")
if (not childQuests) then
return
end
for _, childQuestId in pairs(childQuests) do
if (not completedQuests[childQuestId]) and (not currentQuestlog[childQuestId]) then
local childQuestExclusiveTo = QuestieDB.QueryQuestSingle(childQuestId, "exclusiveTo")
local blockedByExclusiveTo = false
for _, exclusiveToQuestId in pairs(childQuestExclusiveTo or {}) do
if QuestiePlayer.currentQuestlog[exclusiveToQuestId] or completedQuests[exclusiveToQuestId] then
blockedByExclusiveTo = true
break
end
end
if (not blockedByExclusiveTo) then
QuestieDB.activeChildQuests[childQuestId] = true
availableQuests[childQuestId] = true
-- Draw them right away and skip all other irrelevant checks
_DrawAvailableQuest(childQuestId)
end
end
end
end
---@param questId number
_DrawAvailableQuest = function(questId)
NewThread(function()
local quest = QuestieDB.GetQuest(questId)
if (not quest.tagInfoWasCached) then
QuestieDB.GetQuestTagInfo(questId) -- cache to load in the tooltip
quest.tagInfoWasCached = true
end
AvailableQuests.DrawAvailableQuest(quest)
end, 0)
end
---@param quest Quest
_GetQuestIcon = function(quest)
if Questie.IsSoD == true and QuestieDB.IsSoDRuneQuest(quest.Id) then
return Questie.ICON_TYPE_SODRUNE
elseif QuestieDB.IsActiveEventQuest(quest.Id) then
return Questie.ICON_TYPE_EVENTQUEST
end
if QuestieDB.IsPvPQuest(quest.Id) then
return Questie.ICON_TYPE_PVPQUEST
end
-- Ascension level scaling: treat the quest level used for trivial\/difficulty logic as the effective scaled level
local playerLevel = QuestiePlayer.GetPlayerLevel()
local effectiveQuestLevel, effectiveRequiredLevel = QuestieLib.GetTbcLevel(quest.Id, playerLevel)
if effectiveRequiredLevel and effectiveRequiredLevel > playerLevel then
return Questie.ICON_TYPE_AVAILABLE_GRAY
end
if quest.IsRepeatable then
return Questie.ICON_TYPE_REPEATABLE
end
if QuestieLib:IsQuestTrivialScaled(quest.Id, effectiveQuestLevel) then
return Questie.ICON_TYPE_AVAILABLE_GRAY
end
return Questie.ICON_TYPE_AVAILABLE
end
---@param starter table Either an object or an NPC
---@param quest Quest
---@param tooltipKey string the tooltip key. For objects it's "o_<ID>", for NPCs it's "m_<ID>"
_AddStarter = function(starter, quest, tooltipKey)
if (not starter) then
return
end
QuestieTooltips:RegisterQuestStartTooltip(quest.Id, starter.name, starter.id, tooltipKey)
local starterIcons = {}
local starterLocs = {}
for zone, spawns in pairs(starter.spawns or {}) do
local alreadyAddedSpawns = {}
if (zone and spawns) then
local coords
for spawnIndex = 1, #spawns do
coords = spawns[spawnIndex]
if #spawns == 1 or _HasProperDistanceToAlreadyAddedSpawns(coords, alreadyAddedSpawns) then
local data = {
Id = quest.Id,
Icon = _GetQuestIcon(quest),
GetIconScale = _GetIconScaleForAvailable,
IconScale = _GetIconScaleForAvailable(),
Type = "available",
QuestData = quest,
Name = starter.name,
IsObjectiveNote = false,
}
if (coords[1] == -1 or coords[2] == -1) then
local dungeonLocation = ZoneDB:GetDungeonLocation(zone)
if dungeonLocation then
for _, value in ipairs(dungeonLocation) do
QuestieMap:DrawWorldIcon(data, value[1], value[2], value[3])
end
end
else
local icon = QuestieMap:DrawWorldIcon(data, zone, coords[1], coords[2])
if starter.waypoints then
-- This is only relevant for waypoint drawing
starterIcons[zone] = icon
if not starterLocs[zone] then
starterLocs[zone] = { coords[1], coords[2] }
end
end
tinsert(alreadyAddedSpawns, coords)
end
end
end
end
end
-- Only for NPCs since objects do not move
if starter.waypoints then
for zone, waypoints in pairs(starter.waypoints or {}) do
if not dungeons[zone] and waypoints[1] and waypoints[1][1] and waypoints[1][1][1] then
if not starterIcons[zone] then
local data = {
Id = quest.Id,
Icon = _GetQuestIcon(quest),
GetIconScale = _GetIconScaleForAvailable,
IconScale = _GetIconScaleForAvailable(),
Type = "available",
QuestData = quest,
Name = starter.name,
IsObjectiveNote = false,
}
starterIcons[zone] = QuestieMap:DrawWorldIcon(data, zone, waypoints[1][1][1], waypoints[1][1][2])
starterLocs[zone] = { waypoints[1][1][1], waypoints[1][1][2] }
end
QuestieMap:DrawWaypoints(starterIcons[zone], waypoints, zone)
end
end
end
end
_HasProperDistanceToAlreadyAddedSpawns = function(coords, alreadyAddedSpawns)
for _, alreadyAdded in pairs(alreadyAddedSpawns) do
local distance = QuestieLib.GetSpawnDistance(alreadyAdded, coords)
-- 29 seems like a good distance. The "Undying Laborer" in Westfall shows both spawns for the "Horn of Lordaeron" rune
if distance < 29 then
return false
end
end
return true
end
_GetIconScaleForAvailable = function()
return Questie.db.profile.availableScale or 1.3
end
-- Periodic cleanup to ensure completed quest icons are removed
-- This is needed because sometimes QuestieMap:UnloadQuestFrames doesn't fully clean up
-- or icons are redrawn by race conditions
local cleanupTimer
local function StartPeriodicCleanup()
if cleanupTimer then
cleanupTimer:Cancel()
end
-- Check every 5 seconds
cleanupTimer = C_Timer.NewTicker(5, function()
-- Only run if Questie isn't busy
if QuestieMap._mapDrawQueue and #QuestieMap._mapDrawQueue == 0 and
QuestieMap._minimapDrawQueue and #QuestieMap._minimapDrawQueue == 0 then
local completedQuests = Questie.db.char.complete
if not completedQuests then return end
for questId, frameList in pairs(QuestieMap.questIdFrames) do
if completedQuests[questId] then
-- This quest is complete but still has frames on the map
Questie:Debug(Questie.DEBUG_INFO, "[AvailableQuests] Cleanup: Removing lingering frames for completed quest:", questId)
QuestieMap:UnloadQuestFrames(questId)
QuestieTooltips:RemoveQuest(questId)
end
end
end
end)
end
-- Start the cleanup timer
StartPeriodicCleanup()
+213
View File
@@ -0,0 +1,213 @@
---@class DailyQuests
local DailyQuests = QuestieLoader:CreateModule("DailyQuests");
local _DailyQuests = {}
--- COMPATIBILITY ---
local IsQuestFlaggedCompleted = QuestieCompat.IsQuestFlaggedCompleted or C_QuestLog.IsQuestFlaggedCompleted
local GetQuestLogIndexByID = QuestieCompat.GetQuestLogIndexByID
---@type QuestieMap
local QuestieMap = QuestieLoader:ImportModule("QuestieMap");
---@type QuestieQuest
local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest");
---@type QuestieTooltips
local QuestieTooltips = QuestieLoader:ImportModule("QuestieTooltips");
---@type QuestiePlayer
local QuestiePlayer = QuestieLoader:ImportModule("QuestiePlayer");
local nhcDailyIds, hcDailyIds, cookingDailyIds, fishingDailyIds, pvpDailyIds
local lastCheck
---@param message string
---@return nil
function DailyQuests:FilterDailies(message, _, _)
if message and Questie.db.profile.showRepeatableQuests and QuestiePlayer.GetPlayerLevel() == 70 then
-- If the REPUTABLE message is empty, i.e contains "::::::::::" we don't count it as a check.
if (not lastCheck) and not string.find(message, "::::::::::") then
lastCheck = GetTime();
elseif lastCheck and GetTime() - lastCheck < 10 and not string.find(message, "::::::::::") then
lastCheck = GetTime();
return;
end
local nhcQuestId, hcQuestId, cookingQuestId, fishingQuestId, pvpQuestId = _DailyQuests:GetDailyIds(message);
local somethingChanged = _DailyQuests:ResetIfRequired(nhcQuestId, hcQuestId, cookingQuestId, fishingQuestId, pvpQuestId);
if (not somethingChanged) then
-- We are already showing the correct quests
return;
end
_DailyQuests:HandleDailyQuests(nhcDailyIds, nhcQuestId, "nhc");
_DailyQuests:HandleDailyQuests(hcDailyIds, hcQuestId, "hc");
_DailyQuests:HandleDailyQuests(cookingDailyIds, cookingQuestId, "cooking");
_DailyQuests:HandleDailyQuests(fishingDailyIds, fishingQuestId, "fishing");
_DailyQuests:HandleDailyQuests(pvpDailyIds, pvpQuestId, "pvp");
end
end
-- /run DailyQuests:FilterDailies("0:0:11364:0:11354:0:11377:0:11667:0:11340:0")
-- /run Questie.db.char.hiddenDailies = {nhc={},hc={},cooking={},fishing={},pvp={}}
---@param message string
---@return number, number, number, number, number
function _DailyQuests:GetDailyIds(message)
-- Each questId is followed by the timestamp from GetQuestResetTime(). We don't use that timestamp (yet)
local _, _, nhcQuestId, _, hcQuestId, _, cookingQuestId, _, fishingQuestId, _, pvpQuestId, _ = strsplit(":", message);
return tonumber(nhcQuestId) or 0,
tonumber(hcQuestId) or 0,
tonumber(cookingQuestId) or 0,
tonumber(fishingQuestId) or 0,
tonumber(pvpQuestId) or 0;
end
---@param nhcQuestId number
---@param hcQuestId number
---@param cookingQuestId number
---@param fishingQuestId number
---@param pvpQuestId number
---@return boolean
function _DailyQuests:ResetIfRequired(nhcQuestId, hcQuestId, cookingQuestId, fishingQuestId, pvpQuestId)
local somethingChanged = false
if nhcQuestId > 0 and (Questie.db.char.hiddenDailies.nhc[nhcQuestId] or (not next(Questie.db.char.hiddenDailies.nhc))) and (not IsQuestFlaggedCompleted(nhcQuestId)) then
Questie.db.char.hiddenDailies.nhc = {};
somethingChanged = true;
end
if hcQuestId > 0 and (Questie.db.char.hiddenDailies.hc[hcQuestId] or (not next(Questie.db.char.hiddenDailies.hc))) and (not IsQuestFlaggedCompleted(hcQuestId)) then
Questie.db.char.hiddenDailies.hc = {};
somethingChanged = true;
end
if cookingQuestId > 0 and (Questie.db.char.hiddenDailies.cooking[cookingQuestId] or (not next(Questie.db.char.hiddenDailies.cooking))) and (not IsQuestFlaggedCompleted(cookingQuestId)) then
Questie.db.char.hiddenDailies.cooking = {};
somethingChanged = true;
end
if fishingQuestId > 0 and (Questie.db.char.hiddenDailies.fishing[fishingQuestId] or (not next(Questie.db.char.hiddenDailies.fishing))) and (not IsQuestFlaggedCompleted(fishingQuestId)) then
Questie.db.char.hiddenDailies.fishing = {};
somethingChanged = true;
end
if pvpQuestId > 0 and (Questie.db.char.hiddenDailies.pvp[pvpQuestId] or (not next(Questie.db.char.hiddenDailies.pvp))) and (not IsQuestFlaggedCompleted(pvpQuestId)) then
Questie.db.char.hiddenDailies.pvp = {};
somethingChanged = true;
end
return somethingChanged;
end
---@param possibleQuestIds table<number, number>
---@param currentQuestId number
---@param type string
---@return nil
function _DailyQuests:HandleDailyQuests(possibleQuestIds, currentQuestId, type)
if currentQuestId == 0 then
return;
end
for questId, _ in pairs(possibleQuestIds) do
if questId == currentQuestId then
_DailyQuests.ShowDailyQuest(questId);
Questie.db.char.hiddenDailies[type][questId] = nil;
else
-- If the quest is not in the questlog remove all frames
if (GetQuestLogIndexByID(questId) == 0) then
_DailyQuests:HideDailyQuest(questId);
end
Questie.db.char.hiddenDailies[type][questId] = true;
end
end
end
---@param questId number
---@return nil
function _DailyQuests:HideDailyQuest(questId)
QuestieMap:UnloadQuestFrames(questId);
QuestieTooltips:RemoveQuest(questId);
end
---@param questId number
---@return nil
function _DailyQuests.ShowDailyQuest(questId)
if (not QuestieMap.questIdFrames[questId]) then
QuestieQuest.DrawDailyQuest(questId);
end
end
---@param questId number
---@return boolean
function DailyQuests:IsActiveDailyQuest(questId)
return true
-- TODO: This might be reusable when reworking this module
--local hiddenQuests = Questie.db.char.hiddenDailies
--return not (hiddenQuests.nhc[questId] or
-- hiddenQuests.hc[questId] or
-- hiddenQuests.cooking[questId] or
-- hiddenQuests.fishing[questId] or
-- hiddenQuests.pvp[questId]);
end
---@param questId number
---@return boolean
function DailyQuests:IsDailyQuest(questId)
return nhcDailyIds[questId] ~= nil or
hcDailyIds[questId] ~= nil or
cookingDailyIds[questId] ~= nil or
fishingDailyIds[questId] ~= nil or
pvpDailyIds[questId] ~= nil;
end
nhcDailyIds = {
[11364] = true,
[11371] = true,
[11376] = true,
[11383] = true,
[11385] = true,
[11387] = true,
[11389] = true,
[11500] = true,
};
hcDailyIds = {
[11354] = true,
[11362] = true,
[11363] = true,
[11368] = true,
[11369] = true,
[11370] = true,
[11372] = true,
[11373] = true,
[11374] = true,
[11375] = true,
[11378] = true,
[11382] = true,
[11384] = true,
[11386] = true,
[11388] = true,
[11499] = true,
};
cookingDailyIds = {
[11377] = true,
[11379] = true,
[11380] = true,
[11381] = true,
};
fishingDailyIds = {
[11667] = true,
[11665] = true,
[11666] = true,
[11668] = true,
[11669] = true,
};
pvpDailyIds = {
[11335] = true,
[11336] = true,
[11337] = true,
[11338] = true,
[11339] = true,
[11340] = true,
[11341] = true,
[11342] = true,
}
+227
View File
@@ -0,0 +1,227 @@
---@class IsleOfQuelDanas
local IsleOfQuelDanas = QuestieLoader:CreateModule("IsleOfQuelDanas")
---@type l10n
local l10n = QuestieLoader:ImportModule("l10n")
IsleOfQuelDanas.MAX_ISLE_OF_QUEL_DANAS_PHASES = 9
IsleOfQuelDanas.localizedPhaseNames = {}
function IsleOfQuelDanas.Initialize()
IsleOfQuelDanas.localizedPhaseNames = {
l10n("Phase 1 - Sun's Reach Sanctum"),
l10n("Phase 2 - Activating the Sunwell Portal"),
l10n("Phase 2.1 - Sun's Reach Armory"),
l10n("Phase 3 - Rebuilding the Anvil and Forge"),
l10n("Phase 3.1 - Sun's Reach Harbor"),
l10n("Phase 4 - Creating the Alchemy Lab"),
l10n("Phase 4.1 - Building the Monument to the Fallen"),
l10n("Phase 4.2 - Sun's Reach"),
l10n("Phase 5"),
}
end
---@param questId number
---@return boolean
function IsleOfQuelDanas.CheckForActivePhase(questId)
local isleQuests = IsleOfQuelDanas.quests
if isleQuests[1][questId] and isleQuests[Questie.db.global.isleOfQuelDanasPhase][questId] then
-- The accepted quest is one from the Isle Of Quel'Danas
local phaseToSwitchTo = 2
for i = 2, IsleOfQuelDanas.MAX_ISLE_OF_QUEL_DANAS_PHASES do
if (not isleQuests[i][questId]) then
-- This is the phase that unlocked this quest
phaseToSwitchTo = i
break
end
end
Questie:Print(l10n("You picked up a quest from '%s'. Automatically switching to this phase...", IsleOfQuelDanas.localizedPhaseNames[phaseToSwitchTo]))
Questie.db.global.isleOfQuelDanasPhase = phaseToSwitchTo
return true
end
return false
end
-- These quests are the blacklisted ones for each phase
IsleOfQuelDanas.quests = {
{ -- Phase 1
[11513] = true,
[11514] = true,
[11520] = true,
[11521] = true,
[11523] = true,
[11525] = true,
[11526] = true,
[11532] = true,
[11533] = true,
[11534] = true,
[11535] = true,
[11536] = true,
[11537] = true,
[11538] = true,
[11539] = true,
[11540] = true,
[11541] = true,
[11542] = true,
[11543] = true,
[11544] = true,
[11545] = true,
[11546] = true,
[11547] = true,
[11548] = true,
[11549] = true,
},
{ -- Phase 2
-- temp quests from previous phase
[11496] = true,
[11514] = true,
[11524] = true,
[11534] = true,
--
[11520] = true,
[11521] = true,
[11533] = true,
[11535] = true,
[11536] = true,
[11537] = true,
[11539] = true,
[11540] = true,
[11541] = true,
[11542] = true,
[11543] = true,
[11544] = true,
[11545] = true,
[11546] = true,
[11547] = true,
[11548] = true,
[11549] = true,
},
{ -- Phase 2.1 Shatt Portal done
-- temp quests from previous phases
[11496] = true,
[11513] = true,
[11517] = true,
[11524] = true,
--
[11520] = true,
[11521] = true,
[11533] = true,
[11535] = true,
[11536] = true,
[11537] = true,
[11539] = true,
[11540] = true,
[11541] = true,
[11542] = true,
[11543] = true,
[11544] = true,
[11545] = true,
[11546] = true,
[11548] = true,
[11549] = true,
},
{ -- Phase 3
-- temp quests from previous phases
[11496] = true,
[11513] = true,
[11517] = true,
[11524] = true,
[11532] = true,
[11538] = true,
--
[11520] = true,
[11521] = true,
[11536] = true,
[11540] = true,
[11541] = true,
[11543] = true,
[11544] = true,
[11545] = true,
[11546] = true,
[11548] = true,
[11549] = true,
},
{ -- Phase 3.1 Anvil and Forge done
-- temp quests from previous phases
[11496] = true,
[11513] = true,
[11517] = true,
[11524] = true,
[11532] = true,
[11535] = true,
[11538] = true,
--
[11520] = true,
[11521] = true,
[11540] = true,
[11541] = true,
[11543] = true,
[11545] = true,
[11546] = true,
[11548] = true,
[11549] = true,
},
{ -- Phase 4
-- temp quests from previous phases
[11496] = true,
[11513] = true,
[11517] = true,
[11524] = true,
[11532] = true,
[11535] = true,
[11538] = true,
[11539] = true,
[11542] = true,
--
[11521] = true,
[11546] = true,
[11548] = true,
},
{ -- Phase 4.1 - Alchemy Lab done
-- temp quests from previous phases
[11496] = true,
[11513] = true,
[11517] = true,
[11520] = true,
[11524] = true,
[11532] = true,
[11535] = true,
[11538] = true,
[11539] = true,
[11542] = true,
--
[11548] = true,
},
{ -- Phase 4.2 - Monument of the Fallen done
-- temp quests from previous phases
[11496] = true,
[11513] = true,
[11517] = true,
[11524] = true,
[11532] = true,
[11535] = true,
[11538] = true,
[11539] = true,
[11542] = true,
[11545] = true,
--
[11521] = true,
[11546] = true,
},
{ -- Phase 5 - Both buildings done
-- temp quests from previous phases
[11496] = true,
[11513] = true,
[11517] = true,
[11520] = true,
[11524] = true,
[11532] = true,
[11535] = true,
[11538] = true,
[11539] = true,
[11542] = true,
[11545] = true,
--
}
}
+670
View File
@@ -0,0 +1,670 @@
---@class QuestEventHandler
local QuestEventHandler = QuestieLoader:CreateModule("QuestEventHandler")
---@class QuestEventHandlerPrivate
local _QuestEventHandler = QuestEventHandler.private
local _QuestLogUpdateQueue = {} -- Helper module
local questLogUpdateQueue = {} -- The actual queue
---@type QuestEventHandlerPrivate
QuestEventHandler.private = QuestEventHandler.private or {}
---@type QuestLogCache
local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache")
---@type QuestieQuest
local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest")
---@type QuestieJourney
local QuestieJourney = QuestieLoader:ImportModule("QuestieJourney")
---@type QuestieNameplate
local QuestieNameplate = QuestieLoader:ImportModule("QuestieNameplate")
---@type QuestieLib
local QuestieLib = QuestieLoader:ImportModule("QuestieLib")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
---@type QuestieAnnounce
local QuestieAnnounce = QuestieLoader:ImportModule("QuestieAnnounce")
---@type IsleOfQuelDanas
local IsleOfQuelDanas = QuestieLoader:ImportModule("IsleOfQuelDanas")
---@type QuestieCombatQueue
local QuestieCombatQueue = QuestieLoader:ImportModule("QuestieCombatQueue")
---@type QuestieTracker
local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker")
---@type l10n
local l10n = QuestieLoader:ImportModule("l10n")
--- COMPATIBILITY ---
local C_Timer = QuestieCompat.C_Timer
local GetQuestLogTitle = QuestieCompat.GetQuestLogTitle
local GetItemInfo = QuestieCompat.GetItemInfo
local tableRemove = table.remove
local QUEST_LOG_STATES = {
QUEST_ACCEPTED = "QUEST_ACCEPTED",
QUEST_TURNED_IN = "QUEST_TURNED_IN",
QUEST_REMOVED = "QUEST_REMOVED",
QUEST_ABANDONED = "QUEST_ABANDONED"
}
local eventFrame = CreateFrame("Frame", "QuestieQuestEventFrame")
local questLog = {}
local questLogUpdateQueueSize = 1
local skipNextUQLCEvent = false
local doFullQuestLogScan = false
local deletedQuestItem = false
--- Registers all events that are required for questing (accepting, removing, objective updates, ...)
function QuestEventHandler:RegisterEvents()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] RegisterEvents")
eventFrame:RegisterEvent("QUEST_ACCEPTED")
eventFrame:RegisterEvent("QUEST_TURNED_IN")
eventFrame:RegisterEvent("QUEST_REMOVED")
eventFrame:RegisterEvent("QUEST_LOG_UPDATE")
eventFrame:RegisterEvent("QUEST_WATCH_UPDATE")
eventFrame:RegisterEvent("UNIT_QUEST_LOG_CHANGED")
eventFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
eventFrame:RegisterEvent("NEW_RECIPE_LEARNED") -- Spell objectives; Runes in SoD count as recipes because "Engraving" is a profession?
--eventFrame:RegisterEvent("SPELLS_CHANGED") -- Spell objectives
eventFrame:RegisterEvent("PLAYER_INTERACTION_MANAGER_FRAME_HIDE")
eventFrame:RegisterEvent("CHAT_MSG_COMBAT_FACTION_CHANGE")
eventFrame:SetScript("OnEvent", _QuestEventHandler.OnEvent)
-- StaticPopup dialog hooks. Deleteing Quest items do not always trigger a Quest Log Update.
hooksecurefunc("StaticPopup_Show", function(...)
-- Hook StaticPopup_Show. If we find the "DELETE_ITEM" dialog, check for Quest Items and notify the player.
local which, text_arg1 = ...
if which == "DELETE_ITEM" then
local quest
local questName
local foundQuestItem = false
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] StaticPopup_Show: Item Name: ", text_arg1)
if deletedQuestItem == true then
deletedQuestItem = false
end
for questLogIndex = 1, 75 do
local title, _, _, isHeader, _, _, _, questId = GetQuestLogTitle(questLogIndex)
if (not title) then
break
end
if (not isHeader) then
quest = QuestieDB.GetQuest(questId)
if quest then
local info = StaticPopupDialogs[which]
local sourceItemId, soureItemName, sourceItemType, soureClassID
local reqSourceItemId, reqSoureItemName, reqSourceItemType, reqSoureClassID
if quest.sourceItemId then
sourceItemId = quest.sourceItemId
if sourceItemId then
soureItemName, _, _, _, _, sourceItemType, _, _, _, _, _, soureClassID = GetItemInfo(sourceItemId)
end
end
if quest.requiredSourceItems then
reqSourceItemId = quest.requiredSourceItems[1]
if reqSourceItemId then
reqSoureItemName, _, _, _, _, reqSourceItemType, _, _, _, _, _, reqSoureClassID = GetItemInfo(reqSourceItemId)
end
end
if sourceItemId and soureItemName and sourceItemType and soureClassID and (sourceItemType == "Quest" or soureClassID == 12) and QuestieDB.QueryItemSingle(sourceItemId, "class") == 12 and text_arg1 == soureItemName then
questName = quest.name
foundQuestItem = true
break
elseif reqSourceItemId and reqSoureItemName and reqSourceItemType and reqSoureClassID and (reqSourceItemType == "Quest" or reqSoureClassID == 12) and QuestieDB.QueryItemSingle(reqSourceItemId, "class") == 12 and text_arg1 == reqSoureItemName then
questName = quest.name
foundQuestItem = true
break
else
if quest.Objectives and #quest.Objectives > 0 then
for _, objective in pairs(quest.Objectives) do
if text_arg1 == objective.Description then
questName = quest.name
foundQuestItem = true
break
end
end
end
end
end
end
end
if foundQuestItem and quest and questName then
local frame, text
for i = 1, STATICPOPUP_NUMDIALOGS do
frame = _G["StaticPopup" .. i]
if (frame:IsShown()) and ((frame.text.text_arg1 == text_arg1) or (string.find(frame.text:GetText(), text_arg1))) then
text = _G[frame:GetName() .. "Text"]
break
end
end
if frame ~= nil and text ~= nil then
local updateText = l10n("Quest Item %%s might be needed for the quest %%s. \n\nAre you sure you want to delete this?")
text:SetFormattedText(updateText, text_arg1, questName)
text.text_arg1 = updateText
StaticPopup_Resize(frame, which)
deletedQuestItem = true
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] StaticPopup_Show: Quest Item Detected. Updating Static Popup.")
end
end
end
end)
hooksecurefunc("DeleteCursorItem", function()
-- Hook DeleteCursorItem so we know when the player clicks the Accept button
if deletedQuestItem then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] DeleteCursorItem: Quest Item deleted. Update all quests.")
C_Timer.After(0.25, function()
_QuestEventHandler:UpdateAllQuests()
deletedQuestItem = false
end)
end
end)
_QuestEventHandler:InitQuestLog()
end
--- On Login mark all quests in the quest log with QUEST_ACCEPTED state
function _QuestEventHandler:InitQuestLog()
-- Fill the QuestLogCache for first time
local cacheMiss, changes = QuestLogCache.CheckForChanges(nil)
-- if cacheMiss then
-- TODO actually can happen in rare edge case if player accepts new quest during questie init. *cough*
-- or if someone managed to overflow game cache already at this point.
--Questie:Error("Did you accept a quest during InitQuestLog? Please report on Github or Discord. Game's quest log cache is not ok. This shouldn't happen. Questie may malfunction.")
-- end
for questId, _ in pairs(changes) do
questLog[questId] = {
state = QUEST_LOG_STATES.QUEST_ACCEPTED
}
QuestieLib:CacheItemNames(questId)
end
end
--- Fires when a quest is accepted in anyway.
---@param questLogIndex number
---@param questId number
function _QuestEventHandler:QuestAccepted(questLogIndex, questId)
questId = questId or select(8, GetQuestLogTitle(questLogIndex))
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_ACCEPTED", questLogIndex, questId)
if questLog[questId] and questLog[questId].timer then
-- We had a QUEST_REMOVED event which started this timer and now it was accepted again.
-- So the quest was abandoned before, because QUEST_TURNED_IN would have run before QUEST_ACCEPTED.
questLog[questId].timer:Cancel()
questLog[questId].timer = nil
QuestieCombatQueue:Queue(function()
_QuestEventHandler:MarkQuestAsAbandoned(questId)
end)
end
questLog[questId] = {}
-- Timed quests do not need a full Quest Log Update.
-- TODO: Add achievement timers later.
local questTimers = GetQuestTimers(questId)
if type(questTimers) == "number" then
skipNextUQLCEvent = false
else
skipNextUQLCEvent = true
end
QuestieCombatQueue:Queue(function()
QuestieLib:CacheItemNames(questId)
_QuestEventHandler:HandleQuestAccepted(questId)
QuestieTracker:Update()
end)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_ACCEPTED - skipNextUQLCEvent - ", skipNextUQLCEvent)
end
---@param questId number
---@return boolean true @if the function was successful, false otherwise
function _QuestEventHandler:HandleQuestAccepted(questId)
local idx = QuestieCompat.GetQuestLogIndexByID(questId)
if not idx then
_QuestLogUpdateQueue:Insert(function()
return _QuestEventHandler:HandleQuestAccepted(questId)
end)
return false
end
-- We first check the quest objectives and retry in the next QLU event if they are not correct yet
local cacheMiss, changes = QuestLogCache.CheckForChanges({ [questId] = true })
if cacheMiss then
-- if cacheMiss, no need to check changes as only 1 questId
Questie:Debug(Questie.DEBUG_INFO, "Objectives are not cached yet")
_QuestLogUpdateQueue:Insert(function()
return _QuestEventHandler:HandleQuestAccepted(questId)
end)
return false
end
Questie:Debug(Questie.DEBUG_INFO, "Objectives are correct. Calling accept logic. quest:", questId)
questLog[questId].state = QUEST_LOG_STATES.QUEST_ACCEPTED
QuestieQuest:SetObjectivesDirty(questId)
QuestieJourney:AcceptQuest(questId)
QuestieAnnounce:AcceptedQuest(questId)
local isLastIslePhase = Questie.db.global.isleOfQuelDanasPhase == IsleOfQuelDanas.MAX_ISLE_OF_QUEL_DANAS_PHASES
if Questie.IsWotlk and (not isLastIslePhase) and IsleOfQuelDanas.CheckForActivePhase(questId) then
QuestieQuest:SmoothReset()
else
QuestieQuest:AcceptQuest(questId)
end
QuestieCompat.C_Timer.After(0.2, function()
QuestieTracker:Update()
end)
return true
end
--- Fires when a quest is turned in
---@param questId number
---@param xpReward number
---@param moneyReward number
function _QuestEventHandler:QuestTurnedIn(questId, xpReward, moneyReward)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_TURNED_IN", xpReward, moneyReward, questId)
if questLog[questId] and questLog[questId].timer then
-- Cancel the timer so the quest is not marked as abandoned
questLog[questId].timer:Cancel()
questLog[questId].timer = nil
end
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "was turned in and is completed")
if questLog[questId] then
-- There are quests which you just turn in so there is no preceding QUEST_ACCEPTED event and questLog[questId]
-- is empty
questLog[questId].state = QUEST_LOG_STATES.QUEST_TURNED_IN
elseif QuestieCompat.Is335 then
questLog[questId] = {state = QUEST_LOG_STATES.QUEST_TURNED_IN}
end
local parentQuest = QuestieDB.QueryQuestSingle(questId, "parentQuest")
if parentQuest and parentQuest > 0 then
-- Quests like "The Warsong Reports" have child quests which are just turned in. These child quests only
-- fire QUEST_TURNED_IN + QUEST_LOG_UPDATE
Questie:Debug(Questie.DEBUG_DEVELOP, "Quest:", questId, "Has a Parent Quest - do a full Quest Log check")
doFullQuestLogScan = true
end
local itemName, _, _, quality, _, itemID = GetQuestLogRewardInfo(GetNumQuestLogRewards(questId), questId)
if (itemID ~= nil or itemName ~= nil) and quality == 1 then
Questie:Debug(Questie.DEBUG_DEVELOP, "Quest:", questId, "Recieved a possible Quest Item - do a full Quest Log check")
doFullQuestLogScan = true
skipNextUQLCEvent = false
else
skipNextUQLCEvent = true
end
QuestLogCache.RemoveQuest(questId)
QuestieQuest:SetObjectivesDirty(questId) -- is this necessary? should whole quest.Objectives be cleared at some point of quest removal?
-- Don't immediately mark as complete, wait for QUEST_REMOVED to confirm it was actually turned in
-- This prevents completed quests from being saved as turned in when abandoned
-- QuestieQuest:CompleteQuest(questId)
-- QuestieJourney:CompleteQuest(questId)
-- QuestieAnnounce:CompletedQuest(questId)
-- questLog[questId] = nil
QuestieCombatQueue:Queue(function()
QuestieTracker:Update()
end)
end
--- Fires when a quest is removed from the quest log. This includes turning it in and abandoning it.
---@param questId number
function _QuestEventHandler:QuestRemoved(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_REMOVED", questId)
doFullQuestLogScan = false
if (not questLog[questId]) then
questLog[questId] = {}
end
-- The party members don't care whether a quest was turned in or abandoned, so we can just broadcast here
Questie:SendMessage("QC_ID_BROADCAST_QUEST_REMOVE", questId)
-- QUEST_TURNED_IN was called before QUEST_REMOVED --> quest was turned in
if questLog[questId].state == QUEST_LOG_STATES.QUEST_TURNED_IN then
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "was turned in before. Completing quest.")
-- Now that we confirmed the quest was actually turned in (not abandoned), mark it as complete
QuestieQuest:CompleteQuest(questId)
QuestieJourney:CompleteQuest(questId)
QuestieAnnounce:CompletedQuest(questId)
questLog[questId] = nil
return
end
-- QUEST_REMOVED can fire before QUEST_TURNED_IN. If QUEST_TURNED_IN is not called after X seconds the quest
-- was abandoned
questLog[questId] = {
state = QUEST_LOG_STATES.QUEST_REMOVED,
timer = C_Timer.NewTicker(1, function()
_QuestEventHandler:MarkQuestAsAbandoned(questId)
end, 1)
}
skipNextUQLCEvent = true
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_REMOVED - skipNextUQLCEvent - ", skipNextUQLCEvent)
end
---@param questId number
function _QuestEventHandler:MarkQuestAsAbandoned(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "QuestEventHandler:MarkQuestAsAbandoned")
local questEntry = questLog[questId]
-- so we don't attempt to index a nil value.
if (not questEntry) then
Questie:Debug(Questie.DEBUG_DEVELOP, "QuestEventHandler:MarkQuestAsAbandoned - questLog entry missing for", questId)
return
end
if questEntry.state == QUEST_LOG_STATES.QUEST_REMOVED then
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "was abandoned")
questEntry.state = QUEST_LOG_STATES.QUEST_ABANDONED
QuestLogCache.RemoveQuest(questId)
QuestieQuest:SetObjectivesDirty(questId) -- is this necessary? should whole quest.Objectives be cleared at some point of quest removal?
QuestieQuest:AbandonedQuest(questId)
QuestieJourney:AbandonQuest(questId)
QuestieAnnounce:AbandonedQuest(questId)
questLog[questId] = nil
end
end
---Fires when the quest log changed in any way. This event fires very often!
function _QuestEventHandler:QuestLogUpdate()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_LOG_UPDATE")
local continueQueuing = true
-- Some of the other quest event didn't have the required information and ordered to wait for the next QLU.
-- We are now calling the function which the event added.
while continueQueuing and next(questLogUpdateQueue) do
continueQueuing = _QuestLogUpdateQueue:GetFirst()()
end
if doFullQuestLogScan then
doFullQuestLogScan = false
-- Function call updates doFullQuestLogScan. Order matters.
_QuestEventHandler:UpdateAllQuests()
else
_QuestEventHandler:CleanupRemovedQuestsFallback()
QuestieCombatQueue:Queue(function()
QuestieTracker:Update()
end)
end
end
--- Fires whenever a quest objective progressed
---@param questId number
function _QuestEventHandler:QuestWatchUpdate(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] QUEST_WATCH_UPDATE", questId)
-- We do a full scan even though we have the questId because many QUEST_WATCH_UPDATE can fire before
-- a QUEST_LOG_UPDATE. Also not every QUEST_WATCH_UPDATE gets a single QUEST_LOG_UPDATE and doing a full
-- scan is less error prone
doFullQuestLogScan = true
end
local _UnitQuestLogChangedCallback = function()
-- We also check in here because UNIT_QUEST_LOG_CHANGED is fired before the relevant events
-- (Accept, removed, ...)
if (not skipNextUQLCEvent) then
doFullQuestLogScan = true
else
doFullQuestLogScan = false
skipNextUQLCEvent = false
Questie:Debug(Questie.DEBUG_INFO, "Skipping UnitQuestLogChanged")
end
return true
end
--- Fires when an objective changed in the quest log of the unitTarget. The required data is not available yet though
---@param unitTarget string
function _QuestEventHandler:UnitQuestLogChanged(unitTarget)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] UNIT_QUEST_LOG_CHANGED", unitTarget)
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] UNIT_QUEST_LOG_CHANGED - skipNextUQLCEvent - ", skipNextUQLCEvent)
-- There seem to be quests which don't trigger a QUEST_WATCH_UPDATE.
-- We don't add a full check to the queue if skipNextUQLCEvent == true (from QUEST_WATCH_UPDATE or QUEST_TURNED_IN)
if (not skipNextUQLCEvent) then
doFullQuestLogScan = true
_QuestLogUpdateQueue:Insert(_UnitQuestLogChangedCallback)
else
Questie:Debug(Questie.DEBUG_INFO, "Skipping UnitQuestLogChanged")
end
skipNextUQLCEvent = false
end
-- Fallback cleanup: some servers remove quests without firing QUEST_REMOVED reliably.
-- This compares Questie's currentQuestlog vs the game's quest log and removes stale quests.
function _QuestEventHandler:CleanupRemovedQuestsFallback()
local gameQuestIds = {}
local numEntries = select(1, GetNumQuestLogEntries()) or 0
for questLogIndex = 1, numEntries do
local title, _, _, isHeader, _, _, _, qid = GetQuestLogTitle(questLogIndex)
if title and qid and qid > 0 and (not isHeader) then
gameQuestIds[qid] = true
end
end
if QuestiePlayer and QuestiePlayer.currentQuestlog then
local removedQuestIds = {}
for questId in pairs(QuestiePlayer.currentQuestlog) do
if questId and questId > 0 and (not gameQuestIds[questId]) then
removedQuestIds[#removedQuestIds + 1] = questId
end
end
for i = 1, #removedQuestIds do
local questId = removedQuestIds[i]
-- Quest disappeared from log (abandoned or auto-turned-in)
-- Check if this quest was confirmed as turned in (not just objectives complete)
local wasTurnedIn = questLog[questId] and questLog[questId].state == QUEST_LOG_STATES.QUEST_TURNED_IN
local wasAlreadyComplete = Questie.db.char.complete and Questie.db.char.complete[questId]
QuestLogCache.RemoveQuest(questId)
QuestieQuest:SetObjectivesDirty(questId)
-- Only mark as complete if it was actually turned in OR already marked complete from previous session
-- Don't use quest.WasComplete because that's set when objectives complete, not when quest is turned in
if wasTurnedIn or wasAlreadyComplete then
QuestieQuest:CompleteQuest(questId)
else
QuestieQuest:AbandonedQuest(questId)
QuestieJourney:AbandonQuest(questId)
QuestieAnnounce:AbandonedQuest(questId)
end
questLog[questId] = nil
end
if #removedQuestIds > 0 then
QuestieNameplate:UpdateNameplate()
QuestieCombatQueue:Queue(function()
QuestieTracker:Update()
end)
end
end
end
--- Does a full scan of the quest log and updates every quest that is in the QUEST_ACCEPTED state and which hash changed
--- since the last check
function _QuestEventHandler:UpdateAllQuests()
Questie:Debug(Questie.DEBUG_INFO, "Running full questlog check")
local questIdsToCheck = {}
-- TODO replace with a ready table so no need to generate at each call
for questId, data in pairs(questLog) do
if data.state == QUEST_LOG_STATES.QUEST_ACCEPTED then
questIdsToCheck[questId] = true
end
end
local cacheMiss, changes = QuestLogCache.CheckForChanges(questIdsToCheck)
if next(changes) then
for questId, objIds in pairs(changes) do
--Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "objectives:", table.concat(objIds, ","), "will be updated")
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "will be updated")
QuestieQuest:SetObjectivesDirty(questId)
QuestieNameplate:UpdateNameplate()
QuestieQuest:UpdateQuest(questId)
end
QuestieCombatQueue:Queue(function()
C_Timer.After(1.0, function()
QuestieTracker:Update()
end)
end)
else
Questie:Debug(Questie.DEBUG_INFO, "Nothing to update")
end
_QuestEventHandler:CleanupRemovedQuestsFallback()
-- Do UpdateAllQuests() again at next QUEST_LOG_UPDATE if there was "cacheMiss" (game's cache and addon's cache didn't have all required data yet)
doFullQuestLogScan = doFullQuestLogScan or cacheMiss
end
local lastTimeQuestRelatedFrameClosedEvent = -1
--- Blizzard does not fire any event when quest items are received or retrieved from sources other than looting.
--- So we hook events which fires once or twice after closing certain frames and do a full quest log check.
function _QuestEventHandler:QuestRelatedFrameClosed(event)
local now = math.floor(GetTime())
-- Don't do update if event fired twice
if lastTimeQuestRelatedFrameClosedEvent ~= now then
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event]", event)
lastTimeQuestRelatedFrameClosedEvent = now
_QuestEventHandler:UpdateAllQuests()
QuestieTracker:Update()
end
end
function _QuestEventHandler:ReputationChange()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] CHAT_MSG_COMBAT_FACTION_CHANGE")
-- Reputational quest progression doesn't fire UNIT_QUEST_LOG_CHANGED event, only QUEST_LOG_UPDATE event.
doFullQuestLogScan = true
end
--- Helper function to insert a callback to the questLogUpdateQueue and increase the index
function _QuestLogUpdateQueue:Insert(callback)
questLogUpdateQueue[questLogUpdateQueueSize] = callback
questLogUpdateQueueSize = questLogUpdateQueueSize + 1
end
--- Helper function to retrieve the first element of questLogUpdateQueue
---@return function @The callback that was inserted first into questLogUpdateQueue
function _QuestLogUpdateQueue:GetFirst()
questLogUpdateQueueSize = questLogUpdateQueueSize - 1
return tableRemove(questLogUpdateQueue, 1)
end
local trackerMinimizedByDungeon = false
function _QuestEventHandler:ZoneChangedNewArea()
Questie:Debug(Questie.DEBUG_DEVELOP, "[EVENT] ZONE_CHANGED_NEW_AREA")
-- By my tests it takes a full 6-7 seconds for the world to load. There are a lot of
-- backend Questie updates that occur when a player zones in/out of an instance. This
-- is necessary to get everything back into it's "normal" state after all the updates.
local isInInstance, instanceType = IsInInstance()
if isInInstance then
C_Timer.After(8, function()
Questie:Debug(Questie.DEBUG_DEVELOP, "[EVENT] ZONE_CHANGED_NEW_AREA: Entering Instance")
if Questie.db.profile.hideTrackerInDungeons then
trackerMinimizedByDungeon = true
QuestieCombatQueue:Queue(function()
QuestieTracker:Collapse()
end)
end
end)
-- We only want this to fire outside of an instance if the player isn't dead and we need to reset the Tracker
elseif (not Questie.db.char.isTrackerExpanded and not UnitIsGhost("player")) and trackerMinimizedByDungeon == true then
C_Timer.After(8, function()
Questie:Debug(Questie.DEBUG_DEVELOP, "[EVENT] ZONE_CHANGED_NEW_AREA: Exiting Instance")
if Questie.db.profile.hideTrackerInDungeons then
trackerMinimizedByDungeon = false
QuestieCombatQueue:Queue(function()
QuestieTracker:Expand()
end)
end
end)
end
end
--- Is executed whenever an event is fired and triggers relevant event handling.
---@param event string
function _QuestEventHandler:OnEvent(event, ...)
if event == "QUEST_ACCEPTED" then
_QuestEventHandler:QuestAccepted(...)
elseif event == "QUEST_TURNED_IN" then
_QuestEventHandler:QuestTurnedIn(...)
elseif event == "QUEST_REMOVED" then
_QuestEventHandler:QuestRemoved(...)
elseif event == "QUEST_LOG_UPDATE" then
_QuestEventHandler:QuestLogUpdate()
elseif event == "QUEST_WATCH_UPDATE" then
_QuestEventHandler:QuestWatchUpdate(...)
elseif event == "UNIT_QUEST_LOG_CHANGED" and select(1, ...) == "player" then
_QuestEventHandler:UnitQuestLogChanged(...)
elseif event == "ZONE_CHANGED_NEW_AREA" then
_QuestEventHandler:ZoneChangedNewArea()
elseif event == "NEW_RECIPE_LEARNED" then
Questie:Debug(Questie.DEBUG_DEVELOP, "[EVENT] NEW_RECIPE_LEARNED (QuestEventHandler)")
doFullQuestLogScan = true -- If this event is related to a spell objective, a QUEST_LOG_UPDATE will be fired afterwards
elseif event == "PLAYER_INTERACTION_MANAGER_FRAME_HIDE" then
local eventType = select(1, ...)
if eventType == 1 then
event = "TRADE_CLOSED"
elseif eventType == 5 then
event = "MERCHANT_CLOSED"
elseif eventType == 8 then
event = "BANKFRAME_CLOSED"
elseif eventType == 10 then
event = "GUILDBANKFRAME_CLOSED"
elseif eventType == 12 then
event = "VENDOR_CLOSED"
elseif eventType == 17 then
event = "MAIL_CLOSED"
elseif eventType == 21 then
event = "AUCTION_HOUSE_CLOSED"
else
-- Unknown event which we will simply ignore
return
end
_QuestEventHandler:QuestRelatedFrameClosed(event)
elseif event == "CHAT_MSG_COMBAT_FACTION_CHANGE" then
_QuestEventHandler:ReputationChange()
end
end
+349
View File
@@ -0,0 +1,349 @@
--- Contains last known valid state of each quest in game's quest log, per quest.
--- I.E. All data related to a quest is valid.
--- Includes a "hack" to have correct objectives' progress while quest isComplete = 1. Otherwise it would need to be done everywhere else in code
---@class QuestLogCache
local QuestLogCache = QuestieLoader:CreateModule("QuestLogCache")
---@type QuestieLib
local QuestieLib = QuestieLoader:ImportModule("QuestieLib")
---@type Sounds
local Sounds = QuestieLoader:ImportModule("Sounds")
--- COMPATIBILITY ---
local GetQuestLogTitle = QuestieCompat.GetQuestLogTitle
local C_QuestLog_GetQuestObjectives = QuestieCompat.C_QuestLog.GetQuestObjectives
local HaveQuestData = QuestieCompat.HaveQuestData
local stringByte = string.byte
-- 3 * (Max possible number of quests in game quest log)
-- This is a safe value, even smaller would be enough. Too large won't effect performance
local MAX_QUEST_LOG_INDEX = 75
--[[
Example of data in cache table.
raw_* are as in game's quest log. Their non-raw versions are corrected/modified for addon's easy use.
local cache = {
[questId] = {
title = "Quest name",
questTag = "Dungeon", -- nil, "Dungeon", "Raid", etc.
isComplete = nil,
objectives = {
{
text = "Objective Text"
type = "monster",
finished = false,
numFulfilled = 2,
numRequired = 3,
raw_Text = "Objective Text slain: 2/3",
raw_finished = false
raw_numFulfilled = 2,
},
{
text = "Objective2"
type = "item",
finished = false,
numFulfilled = 0,
numRequired = 5,
raw_text = "Objective2 : 0/5",
raw_finished = false,
raw_numFulfilled = 0,
},
....
},
[questId2] = ....,
}
]]--
---@class QuestLogCacheObjectiveData
---@field text string "Objective Text"
---@field type "monster"|"object"|"item"|"reputation"|"killcredit"|"event"|"spell"
---@field finished boolean
---@field numFulfilled number
---@field numRequired number
---@field raw_Text string E.g "Objective Text slain: 2/3",
---@field raw_finished boolean
---@field raw_numFulfilled number
---@class QuestLogCacheData
---@field title string
---@field questTag QuestTag
---@field isComplete -1|0|1 @ -1 = failed, 0 = not complete, 1 = complete
---@field objectives QuestLogCacheObjectiveData[]
---@type table<QuestId, QuestLogCacheData>
local cache = {}
--- NEVER EVER EDIT this table outside of the QuestLogCache module! !!!
---@type table<QuestId, QuestLogCacheData>
QuestLogCache.questLog_DO_NOT_MODIFY = cache
---@return table? newObjectives, ObjectiveIndex[] changedObjIds @nil == cache miss in both addon and game caches. table {} == no objectives.
local function GetNewObjectives(questId, oldObjectives, questLogIndex)
local newObjectives = {} -- creating a fresh one to be able revert to old easily in case of missing data
local changedObjIds -- not assigning {} for easier nil when nothing changed
local objectives = C_QuestLog_GetQuestObjectives(questId, questLogIndex)
for objIndex=1, #objectives do -- iterate manually to be sure getting those in order
local oldObj = oldObjectives[objIndex]
local newObj = objectives[objIndex]
-- Check if objective.text is in game's cache
if (newObj.text) and (stringByte(newObj.text, 1) ~= 32) then
-- Check if objective has changed
if oldObj and oldObj.raw_numFulfilled == newObj.numFulfilled and oldObj.raw_text == newObj.text and oldObj.raw_finished == newObj.finished and oldObj.numRequired == newObj.numRequired and oldObj.type == newObj.type then
-- Not changed
newObjectives[objIndex] = oldObj
else
-- objective has changed, add it to list of change ones
if (not changedObjIds) then
changedObjIds = { objIndex }
else
changedObjIds[#changedObjIds+1] = objIndex
end
if oldObj and newObj and oldObj.numRequired ~= oldObj.numFulfilled and newObj.numRequired == newObj.numFulfilled then
Sounds.PlayObjectiveComplete()
end
if oldObj and newObj and oldObj.numRequired ~= oldObj.numFulfilled and newObj.numRequired ~= newObj.numFulfilled then
Sounds.PlayObjectiveProgress()
end
newObjectives[objIndex] = {
raw_text = newObj.text,
raw_finished = newObj.finished,
raw_numFulfilled = newObj.numFulfilled,
type = newObj.type,
numRequired = newObj.numRequired,
text = QuestieLib.TrimObjectiveText(newObj.text, newObj.type),
finished = newObj.finished, -- gets overwritten with correct value later if quest isComplete
numFulfilled = newObj.numFulfilled, -- gets overwritten with correct value later if quest isComplete
}
end
else -- objective text not in game's cache
if oldObj then
Questie:Debug(Questie.DEBUG_INFO, "[GetNewObjectives] objective not in game's cache. Using addon's cache. questID, objIndex:", questId, objIndex)
-- Extremely unlikely that the objective has changed from cached version as a change SHOULD trigger fetching data into game cache.
-- Possible bug point if there comes desync issues.
newObjectives[objIndex] = oldObj
else
Questie:Debug(Questie.DEBUG_INFO, "[GetNewObjectives] \"WARNING\" objective not in game's cache nor addon's cache. questID, objIndex:", questId, objIndex)
-- Objective has been never cached
-- Tell to function caller that we couldn't get all required data from game's cache
-- Don't loop rest of objectives as we won't anyway save those into cache[] and C_QuestLog.GetQuestObjectives() call already triggered game to initiate caching those into game's cache.
return nil
end
end
end
return newObjectives, changedObjIds
end
-- For profiling
QuestLogCache._GetNewObjectives = GetNewObjectives
--- Updates questlogcache.
--- Remember to handle returned changes table even when cacheMiss == true. Returned changes are still valid. There may just be more changes that we couldn't get yet.
--- Called only from QuestEventHandler.
---@param questIdsToCheck table? @keys are the questIds
---@return boolean cacheMiss, table changes @cacheMiss = couldn't get all required data ; changes[questId] = list of changed objectiveIndexes (may be an empty list if quest has no objectives)
function QuestLogCache.CheckForChanges(questIdsToCheck)
local cacheMiss = false
local changes = {}
local questIdsChecked = {}
local numEntries = select(1, GetNumQuestLogEntries()) or 0
for questLogIndex = 1, numEntries do
local title, _, questTag, isHeader, _, isComplete, _, questId = GetQuestLogTitle(questLogIndex)
-- Skip weird/header entries / questId=0 (these happen a lot on your server)
if title and questId and questId > 0 and (not isHeader) then
if (not questIdsToCheck) or questIdsToCheck[questId] then
questIdsChecked[questId] = true
if HaveQuestData(questId) then
local cachedQuest = cache[questId]
local cachedObjectives = cachedQuest and cachedQuest.objectives or {}
local newObjectives, changedObjIds = GetNewObjectives(questId, cachedObjectives, questLogIndex)
if newObjectives then
if (not cachedQuest) or (#cachedObjectives == #newObjectives and #cachedObjectives > 0 and
(cachedQuest.title ~= title or cachedQuest.questTag ~= questTag or cachedQuest.isComplete ~= isComplete)) then
changedObjIds = {}
for i = 1, #newObjectives do
changedObjIds[i] = i
end
if isComplete == 1 then
for i = 1, #newObjectives do
local o = newObjectives[i]
o.finished = true
o.numFulfilled = o.numRequired
end
end
end
if cachedQuest and (not cachedQuest.isComplete) and isComplete == 1 then
Sounds.PlayQuestComplete()
end
if changedObjIds then
cache[questId] = {
title = title,
questTag = questTag,
isComplete = isComplete,
objectives = newObjectives,
}
changes[questId] = changedObjIds
end
else
cacheMiss = true
end
else
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestLogCache.CheckForChanges] HaveQuestData() == false. questId, index:", questId, questLogIndex)
C_QuestLog_GetQuestObjectives(questId, questLogIndex)
cacheMiss = true
end
end
end
end
-- Debug / warning: ignore questId=0 and don't treat it as "missing" when the log has weird entries
if questIdsToCheck then
for questId in pairs(questIdsToCheck) do
if questId and questId > 0 and (not questIdsChecked[questId]) then
Questie:Warning("Please report on Github or Discord. QuestId doesn't exist in Game's quest log:", questId)
end
end
end
return cacheMiss, changes
end
function QuestLogCache.RemoveQuest(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestLogCache.RemoveQuest] remove questId:", questId)
cache[questId] = nil
end
--- Tests if game client's cache has all quest log quests and objectives cached.
--- Avoid using this function if possible.
---@return boolean gameCacheOK
function QuestLogCache.TestGameCache()
local gameCacheOK = true
for questLogIndex = 1, MAX_QUEST_LOG_INDEX do
local title, _, _, isHeader, _, _, _, questId = GetQuestLogTitle(questLogIndex)
if (not title) then
break -- We exceeded the valid quest log entries
end
if (not isHeader) then
if HaveQuestData(questId) then
local objectives = C_QuestLog_GetQuestObjectives(questId, questLogIndex)
for objIndex=1, #objectives do
local text = objectives[objIndex].text
-- Check if objective.text is not in game's cache
if (not text) or (stringByte(text, 1) == 32) then
gameCacheOK = false
end
end
else
gameCacheOK = false
end
end
end
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestLogCache.TestGameCache]", (gameCacheOK and "Cache ok." or "Cache missing data."))
return gameCacheOK
end
--- A wrapper function to add error check instead using exposed table directly.
---@param questId QuestId
---@return QuestLogCacheData? @NEVER EVER MODIFY THE RETURNED TABLE
function QuestLogCache.GetQuest(questId)
-- Fix the issue at function caller side if this error pops up.
if (not cache[questId]) then
Questie:Print(debugstack(1, 20, 4))
Questie:Error("Please report this error. GetQuest: The quest doesn't exist in QuestLogCache.", questId)
return
end
return cache[questId]
end
--- A wrapper function to add error check instead using exposed table directly.
---@param questId QuestId
---@return table<ObjectiveIndex, QuestLogCacheObjectiveData>? @NEVER EVER MODIFY THE RETURNED TABLE
function QuestLogCache.GetQuestObjectives(questId)
-- Fix the issue at function caller side if this error pops up.
if (not cache[questId]) then
Questie:Print(debugstack(1, 20, 4))
Questie:Error("Please report this error. GetQuestObjectives: The quest doesn't exist in QuestLogCache.", questId)
return
end
return cache[questId].objectives
end
---@param q table @quest
---@param i number @index of the objective
---@param o table @objective
local function DebugPrintObjective(q, i, o)
if (o.raw_numFulfilled == o.numFulfilled) and (o.raw_finished == o.finished) then
print(" ", i.."/"..#q.objectives..":",
o.numFulfilled.."/"..o.numRequired.."="..tostring(o.finished),
o.type,
"\""..o.raw_text.."\" \""..o.text.."\"")
else
print(" ", i.."/"..#q.objectives..":",
o.raw_numFulfilled.."/"..o.numRequired.."="..tostring(o.raw_finished),
"FIX:", o.numFulfilled.."/"..o.numRequired.."="..tostring(o.finished),
o.type,
"\""..o.raw_text.."\" \""..o.text.."\"")
end
end
--- Debug function, prints whole cache
function QuestLogCache.DebugPrintCache()
print("DebugPrintCache", GetTime())
local count = 0
for questId, q in pairs(cache) do
count = count + 1
print("Quest: ("..questId..") \""..q.title.."\" questTag="..tostring(q.questTag) ,"isComplete="..tostring(q.isComplete))
if not next(q.objectives) then
print(" no objectives")
else
for i, o in ipairs(q.objectives) do
DebugPrintObjective(q, i, o)
end
end
end
print("Total Quests ", count)
end
--- Debug function, prints changes
function QuestLogCache.DebugPrintCacheChanges(cacheMiss, changes)
local highlight = ((not cacheMiss) and (not next(changes))) or (cacheMiss and next(changes)) -- highlight untypical cases. they are okey, but sometimes interesting.
print("DebugPrintCacheChanges", GetTime(), (highlight and "\124cffFF4444CacheMiss:\124r" or "CacheMiss"), cacheMiss)
for questId, objIndexes in pairs(changes) do
local q = cache[questId]
print("Quest: ("..questId..") \""..q.title.."\" questTag="..tostring(q.questTag) ,"isComplete="..tostring(q.isComplete))
if not next(objIndexes) then
print(" no objectives changed (or quest doesn't have objectives)")
else
for _, i in ipairs(objIndexes) do
DebugPrintObjective(q, i, q.objectives[i])
end
end
end
end
+191
View File
@@ -0,0 +1,191 @@
---@class QuestgiverFrame
local QuestgiverFrame = QuestieLoader:CreateModule("QuestgiverFrame")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
---@type QuestieLib
local QuestieLib = QuestieLoader:ImportModule("QuestieLib")
--- COMPATIBILITY ---
local UnitGUID = QuestieCompat.UnitGUID
local _G = _G
local tinsert = tinsert
local MAX_NUM_QUESTS = MAX_NUM_QUESTS
-- This is the logic used for determining which icon we should show for a quest
-- This just determines the "type" of icon shown, not the exact icon file - see Questie.icons
---@param questID number
---@param isActive boolean
---@return string
local function determineAppropriateQuestIcon(questID, isActive)
if questID == 0 then -- if we were fed a questID of 0, the ID bruteforce failed, abort
if isActive == true then
return Questie.icons["complete"]
else
return Questie.icons["available"]
end
end
local icon = Questie.icons["available"] -- fallback icon in case any of the logic below fails
if isActive == true then
icon = Questie.icons["incomplete"] -- fallback icon in case any of the logic below fails
if QuestieDB.IsComplete(questID) == 1 then
if QuestieDB.IsPvPQuest(questID) then
icon = Questie.icons["pvpquest_complete"]
elseif QuestieDB.IsActiveEventQuest(questID) then
icon = Questie.icons["eventquest_complete"]
elseif QuestieDB.IsRepeatable(questID) then
icon = Questie.icons["repeatable_complete"]
else
icon = Questie.icons["complete"]
end
end
else
if QuestieDB.IsPvPQuest(questID) then
icon = Questie.icons["pvpquest"]
elseif QuestieDB.IsActiveEventQuest(questID) then
icon = Questie.icons["eventquest"]
elseif QuestieDB.IsRepeatable(questID) then
icon = Questie.icons["repeatable"]
end
end
return icon
end
-- 9.0.0 API GOSSIP
local function updateGossipFrame()
local numAvailable = GetNumGossipAvailableQuests()
local numActive = GetNumGossipActiveQuests()
local availQuests = {QuestieCompat.GetAvailableQuests()}
local activeQuests = {QuestieCompat.GetActiveQuests()}
local index = 0 -- this variable tracks the GossipTitleButton we should be targeting for icon changes
local questgiver = UnitGUID("npc")
if numAvailable > 0 then
for i=1, numAvailable do
index = index + 1
-- GetGossipAvailableQuests() returns 7 individual values per quest entry...
-- so we have to filter out to every 7th value, starting with 1, 8, 15, etc
local questIndex = (1 + ((i - 1) * 7))
local questname = availQuests[questIndex]
local questid = QuestieDB.GetQuestIDFromName(questname, questgiver, true)
local gossipIcon = _G["GossipTitleButton" .. index .. "GossipIcon"]
gossipIcon:SetTexture(determineAppropriateQuestIcon(questid, false))
end
-- each new section in a gossip frame has an offset of 1, so for instance, with 2 quests shown,
-- 1 active 1 available, the available will be GossipTitleButton1 and the active will be GossipTitleButton3
if numActive > 0 then index = index + 1 end
end
if numActive > 0 then
for i=1, numActive do
index = index + 1
-- GetGossipActiveQuests() returns 6 individual values per quest entry...
-- so we have to filter out to every 6th value, starting with 1, 7, 13, etc
local questIndex = (1 + ((i - 1) * 6))
local questname = activeQuests[questIndex]
local questid = QuestieDB.GetQuestIDFromName(questname, questgiver, false)
local gossipIcon = _G["GossipTitleButton" .. index .. "GossipIcon"]
gossipIcon:SetTexture(determineAppropriateQuestIcon(questid, true))
end
end
end
-- GREETING FRAMES (API independent)
local function updateGreetingFrame()
local titleLines = {}
local questIconTextures = {}
local questgiver = UnitGUID("npc")
for i = 1, MAX_NUM_QUESTS do
local titleLine = _G["QuestTitleButton" .. i]
if titleLine then
tinsert(titleLines, titleLine)
tinsert(questIconTextures, _G[titleLine:GetName() .. "QuestIcon"])
else
Questie:Error("Frame error! Could not obtain Greeting's QuestTitleButton object. Please report this on Github or Discord!")
Questie:Error("Questgiver is: " .. questgiver)
Questie:Error("Client info is: " .. GetBuildInfo() .. "; " .. QuestieLib:GetAddonVersionString())
return
end
end
for i, titleLine in ipairs(titleLines) do
if (titleLine:IsVisible()) then
local lineIcon = questIconTextures[i]
-- determining if the current line is a "Current" quest or "Available" quest is important
-- because we have to use different API calls to obtain their quest titles
if (titleLine.isActive == 1) then
lineIcon:SetTexture(Questie.icons["incomplete"]) -- fallback icon in case any of the logic below fails
local title = GetActiveTitle(titleLine:GetID()) -- obtain plaintext name of quest
local questID = QuestieDB.GetQuestIDFromName(title, questgiver, false)
local icon = determineAppropriateQuestIcon(questID, true)
lineIcon:SetTexture(icon)
else
lineIcon:SetTexture(Questie.icons["available"]) -- fallback icon in case any of the logic below fails
local title = GetAvailableTitle(titleLine:GetID())
local questID = QuestieDB.GetQuestIDFromName(title, questgiver, true)
local icon = determineAppropriateQuestIcon(questID, false)
lineIcon:SetTexture(icon)
end
end
end
end
function QuestgiverFrame.GossipMark()
if Questie.db.profile.enableQuestFrameIcons == true then
if GossipAvailableQuestButtonMixin then -- This call is added with Dragonflight (10.0.0) API, use if available
return -- This call is automatically hooked, no need to run a function
else -- If DF API not available, use Shadowlands (9.0.0) method
updateGossipFrame()
end
end
end
function QuestgiverFrame.GreetingMark()
if Questie.db.profile.enableQuestFrameIcons == true then
updateGreetingFrame()
end
end
-- 10.0.0 API GOSSIP
-- Boy, this code is clean... these DF Gossip APIs sure are great!
-- What a shame that the greeting API hasn't been touched in two decades.
if GossipAvailableQuestButtonMixin then
local oldAvailableSetup = GossipAvailableQuestButtonMixin.Setup
function GossipAvailableQuestButtonMixin:Setup(...)
oldAvailableSetup(self, ...)
if (not Questie.started) then
return
end
if self.GetElementData ~= nil and Questie.db.profile.enableQuestFrameIcons == true then
local id = self.GetElementData().info.questID
if id then
self.Icon:SetTexture(determineAppropriateQuestIcon(id, false))
else
Questie:Error("Frame error! Missing Gossip line item quest ID. Please report this on Github or Discord!")
Questie:Error("Questgiver for available quest is: " .. UnitGUID("npc"))
Questie:Error("Client info is: " .. GetBuildInfo() .. "; " .. QuestieLib:GetAddonVersionString())
return
end
end
end
local oldActiveSetup = GossipActiveQuestButtonMixin.Setup
function GossipActiveQuestButtonMixin:Setup(...)
oldActiveSetup(self, ...)
if (not Questie.started) then
return
end
if self.GetElementData ~= nil and Questie.db.profile.enableQuestFrameIcons == true then
local id = self.GetElementData().info.questID
if id then
self.Icon:SetTexture(determineAppropriateQuestIcon(id, true))
else
Questie:Error("Frame error! Missing Gossip line item quest ID. Please report this on Github or Discord!")
Questie:Error("Questgiver for active quest is: " .. UnitGUID("npc"))
Questie:Error("Client info is: " .. GetBuildInfo() .. "; " .. QuestieLib:GetAddonVersionString())
return
end
end
end
end
File diff suppressed because it is too large Load Diff
+297
View File
@@ -0,0 +1,297 @@
---@type QuestieQuest
local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest")
---@type QuestieQuestPrivate
QuestieQuest.private = QuestieQuest.private or {}
---@class QuestieQuestPrivate
local _QuestieQuest = QuestieQuest.private
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
---@type QuestieCorrections
local QuestieCorrections = QuestieLoader:ImportModule("QuestieCorrections")
local function _GetIconScaleForMonster()
return Questie.db.profile.monsterScale or 1
end
local function _GetIconScaleForObject()
return Questie.db.profile.objectScale or 1
end
local function _GetIconScaleForEvent()
return Questie.db.profile.eventScale or 1.35
end
local function _GetIconScaleForLoot()
return Questie.db.profile.lootScale or 1
end
---@class SpawnListBase
---@field Name string
---@field Spawns table<AreaId, CoordPair[]>>
---@field Icon string @Icon path
---@field GetIconScale function Function to get the iconScale
---@field IconScale number Initial value returned by the GetIconScale function
---@class SpawnListTooltip
---@field TooltipKey string
---@class SpawnListObject : SpawnListBase, SpawnListTooltip
---@field Id ObjectId The ID of the Object
---@class SpawnListNPC : SpawnListBase, SpawnListTooltip
---@field Id NpcId The ID of the NPC
---@field Waypoints table<AreaId, CoordPair[]>
---@field Hostile true|boolean
---@class SpawnListItem : SpawnListBase, SpawnListTooltip, SpawnListNPC, SpawnListObject
---@field Id ObjectId|NpcId The ID of the Object or Npc
---@field ItemId ItemId The ID of the item that the spawn drops
---@class SpawnListEvent : SpawnListBase
---@field Id number The ID of the Event (Is this even used?)
local killcredit, monster, object, event, item, spell
---@type table<"killcredit"|"monster"|"object"|"event"|"item", function>
_QuestieQuest.objectiveSpawnListCallTable = {}
---comment
---@param npcId NpcId
---@param objective any
---@param objectiveData KillObjective
---@return table<NpcId, SpawnListNPC>[]
killcredit = function(npcId, objective, objectiveData)
---@type SpawnListNPC[]
local ret = {}
for npcIdIndex = 1, #objectiveData.IdList do
local killCreditNpcId = objectiveData.IdList[npcIdIndex]
ret[killCreditNpcId] = monster(killCreditNpcId, objective)[killCreditNpcId]
end
return ret
end
---@param npcId any
---@param objective any
---@return table<NpcId, SpawnListNPC>?
monster = function(npcId, objective)
if (not npcId) then
Questie:Error(
"Corrupted objective data handed to objectiveSpawnListCallTable['monster']:",
"'" .. objective.Description .. "' -",
"Please report this error on Discord or GitHub."
)
return nil
end
local name = QuestieDB.QueryNPCSingle(npcId, "name")
if (not name) then
Questie:Debug(Questie.DEBUG_CRITICAL, "Name missing for NPC:", npcId)
return nil
end
local spawns = QuestieDB.QueryNPCSingle(npcId, "spawns")
if (not spawns) then
Questie:Debug(Questie.DEBUG_CRITICAL, "Spawn data missing for NPC:", npcId)
spawns = {}
end
local rank = QuestieDB.QueryNPCSingle(npcId, "rank")
local enableSpawns = not QuestieCorrections.questNPCBlacklist[npcId]
local enableWaypoints = enableSpawns and 2 ~= rank -- a rare mob spawn. todo: option for this
---@type SpawnListNPC
local monster = {
Id = npcId,
Name = name,
Spawns = enableSpawns and spawns or {},
Waypoints = enableWaypoints and QuestieDB.QueryNPCSingle(npcId, "waypoints") or {},
Hostile = true,
Icon = Questie.ICON_TYPE_SLAY,
GetIconScale = _GetIconScaleForMonster,
IconScale = _GetIconScaleForMonster(),
TooltipKey = "m_" .. npcId, -- todo: use ID based keys
}
return {
[npcId] = monster
}
end
---comment
---@param objectId any
---@param objective any
---@return table<ObjectId, SpawnListObject>?
object = function(objectId, objective)
if (not objectId) then
Questie:Error(
"Corrupted objective data handed to objectiveSpawnListCallTable['object']:",
"'" .. objective.Description .. "' -",
"Please report this error on Discord or GitHub."
)
return nil
end
local name = QuestieDB.QueryObjectSingle(objectId, "name")
if (not name) then
Questie:Debug(Questie.DEBUG_CRITICAL, "Name missing for object:", objectId)
return nil
end
local spawns = QuestieDB.QueryObjectSingle(objectId, "spawns")
if (not spawns) then
Questie:Debug(Questie.DEBUG_CRITICAL, "Spawn data missing for object:", objectId)
spawns = {}
end
---@type SpawnListObject
local retObject = {
Id = objectId,
Name = name,
Spawns = spawns,
Icon = Questie.ICON_TYPE_OBJECT,
GetIconScale = _GetIconScaleForObject,
IconScale = _GetIconScaleForObject(),
TooltipKey = "o_" .. objectId,
}
return {
[objectId] = retObject
}
end
---comment
---@param eventId any
---@param objective any
---@return { [1]: SpawnListEvent }?
event = function(eventId, objective)
local spawns = objective.Coordinates
if (not spawns) then
Questie:Error("Missing event data for Objective:", objective.Description, "id:", eventId)
spawns = {}
end
---@type SpawnListEvent
local retEvent = {
Id = eventId or 0,
Name = objective.Description or "Event Trigger",
Spawns = spawns,
Icon = Questie.ICON_TYPE_EVENT,
GetIconScale = _GetIconScaleForEvent,
IconScale = _GetIconScaleForEvent(),
}
return {
[1] = retEvent
}
end
---comment
---@param itemId any
---@param objective any
---@return table<ItemId, SpawnListItem>?
item = function(itemId, objective)
if (not itemId) then
Questie:Error(
"Corrupted objective data handed to objectiveSpawnListCallTable['item']:",
"'" .. objective.Description .. "' -",
"Please report this error on Discord or GitHub."
)
return nil
end
local ret = {}
local item = QuestieDB:GetItem(itemId)
if item and item.Sources and (not item.Hidden) then
for _, source in pairs(item.Sources) do
if _QuestieQuest.objectiveSpawnListCallTable[source.Type] and source.Type ~= "item" then -- anti-recursive-loop check, should never be possible but would be bad if it was
local sourceList = _QuestieQuest.objectiveSpawnListCallTable[source.Type](source.Id, objective)
if not sourceList then
Questie:Error("Missing objective data for", source.Type, "'", objective, "'", source.Id)
else
for id, sourceData in pairs(sourceList) do
if (not ret[id]) then
local icon, GetIconScale
if source.Type == "object" then
icon = Questie.ICON_TYPE_OBJECT
GetIconScale = _GetIconScaleForObject
else
icon = Questie.ICON_TYPE_LOOT
GetIconScale = _GetIconScaleForLoot
end
ret[id] = {
Id = id,
Name = sourceData.Name,
Hostile = true,
ItemId = item.Id,
TooltipKey = sourceData.TooltipKey,
Spawns = {},
Waypoints = {},
Icon = icon,
GetIconScale = GetIconScale,
IconScale = GetIconScale(),
}
end
if sourceData.Spawns then
local itemSpawns = ret[id].Spawns
for zone, spawns in pairs(sourceData.Spawns) do
if (not itemSpawns[zone]) then
itemSpawns[zone] = {}
end
local itemSpawnsInZone = itemSpawns[zone]
for _, spawn in pairs(spawns) do
itemSpawnsInZone[#itemSpawnsInZone+1] = spawn
end
end
end
if sourceData.Waypoints then
local itemWaypoints = ret[id].Waypoints
for zone, spawns in pairs(sourceData.Waypoints) do
if (not itemWaypoints[zone]) then
itemWaypoints[zone] = {}
end
local itemWaypointsInZone = itemWaypoints[zone]
for _, spawn in pairs(spawns) do
itemWaypointsInZone[#itemWaypointsInZone+1] = spawn
end
end
end
end
end
end
end
end
return ret
end
---comment
---@param spellId number
---@param objective any
---@return table<ItemId, SpawnListItem>?
spell = function(spellId, objective, objectiveData)
if (not spellId) then
Questie:Error(
"Corrupted objective data handed to objectiveSpawnListCallTable['spell']:",
"'" .. objective.Description .. "' -",
"Please report this error on Discord or GitHub."
)
return nil
end
local itemSource = objectiveData.ItemSourceId
return item(itemSource, objective)
end
_QuestieQuest.objectiveSpawnListCallTable["killcredit"] = killcredit
_QuestieQuest.objectiveSpawnListCallTable["monster"] = monster
_QuestieQuest.objectiveSpawnListCallTable["object"] = object
_QuestieQuest.objectiveSpawnListCallTable["event"] = event
_QuestieQuest.objectiveSpawnListCallTable["item"] = item
_QuestieQuest.objectiveSpawnListCallTable["spell"] = spell