Files
Questie-X/Modules/Quest/QuestLogCache.lua
T
Xurkon c4d003e58f feat: Questie-X v1.1.4 - plugin architecture, monorepo, rebranding
- Repoint remote to Xurkon/Questie-X
- Add QuestiePluginAPI, QuestieServer, QuestieLearnerComms modules
- Add Questie-X.toc, Questie-X-Classic.toc, Questie-X-TBC.toc, Questie-X-Turtle.toc
- Remove embedded Database/Ascension and Database/Ebonhold (migrated to plugins)
- Add Plugins/ directory with NTFS junctions for Questie-X-AscensionDB and Questie-X-EbonholdDB
- Add LibDeflate, XXH_Lua_Lib, LibDBIcon-1.0, LibDataBroker-1.1
- Update README: Questie-X branding, logo, plugin install guide, plugin API docs
- Update CHANGELOG: v1.1.4 entry documenting all architectural changes
- Update .gitignore: exclude plugin junctions, __pycache__, .agents, debug files
- Various module updates: corrections, map, tracker, tooltips, quest, options
2026-03-14 06:10:17 -05:00

358 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 QuestieCompat = QuestieCompat -- Ensure it's loaded
local GetQuestLogTitle = QuestieCompat.GetQuestLogTitle
local C_QuestLog_GetQuestObjectives = QuestieCompat.C_QuestLog and 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, table.getn(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[table.getn(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 (table.getn(cachedObjectives) == table.getn(newObjectives) and table.getn(cachedObjectives) > 0 and
(cachedQuest.title ~= title or cachedQuest.questTag ~= questTag or cachedQuest.isComplete ~= isComplete)) then
changedObjIds = {}
for i = 1, table.getn(newObjectives) do
changedObjIds[i] = i
end
if isComplete == 1 then
for i = 1, table.getn(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
-- Also evict any live fallback object so stale tracker entries don't persist.
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
if QuestieDB and QuestieDB.InvalidateLiveQuest then
QuestieDB.InvalidateLiveQuest(questId)
end
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, table.getn(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 .. "/" .. table.getn(q.objectives) .. ":",
o.numFulfilled .. "/" .. o.numRequired .. "=" .. tostring(o.finished),
o.type,
"\"" .. o.raw_text .. "\" \"" .. o.text .. "\"")
else
print(" ", i .. "/" .. table.getn(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