352 lines
15 KiB
Lua
352 lines
15 KiB
Lua
--- 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, level, questTag, isHeader, isCollapsed, isComplete, isDaily, 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, level, questTag, isHeader, isCollapsed, isComplete, isDaily, questId = GetQuestLogTitle(
|
|
questLogIndex)
|
|
if title and questId and (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
|