Compare commits

...

2 Commits

Author SHA1 Message Date
Narcasung 30fe4b83a9 fix(tracker): name and group quests built from partial records
Quests assembled from a Learner entry or a questDataOverrides entry that
never captured every field reach the tracker with nil fields, since GetQuest
copies rawdata key by key. Two of those show: a nil name printed as the
quest id, and a nil zoneOrSort that sent _GetZoneName down its very first
line, `if not zoneOrSort then return "Unknown Zone" end`, before it could
consult the quest log header the earlier fix added. Those objects are cached
for the session, so neither repaired itself.

GetQuest now fills a missing name from the quest log and defaults zoneOrSort
to 0, the value every caller already reads as "no zone on file" -- and which
some of them require, `quest.zoneOrSort > 0` erroring outright on nil.
_GetZoneName treats nil the same way rather than short-circuiting, which
also stops a nil quest from labelling its group Unknown Zone under the sort
modes that do not group by zone at all.

The tracker asks the quest log for a title before printing an id, so quests
already cached without a name come out right too, and the live-fallback
builder stops discarding the override data it just looked up.
2026-08-17 15:57:17 +02:00
Narcasung 26a135a2ca fix(tracker): show and update objectives on quests missing from the DB
Quests with no QuestieDB entry get a fallback object built from the quest
log, and three things were wrong with it.

Its objectives carried the leaderboard line in a `text` field, but the
tracker's objective loop skips any objective without a `Description`, so
those quests drew a title and nothing under it. Split the line into a
description and its counts instead, and set the same `Index`/`Completed`
fields a DB objective carries.

The object was then cached for the session and only rebuilt when it had no
zone info, so its counts stayed at whatever the log said when the quest was
first seen. `IsComplete` had the same problem one level down: it closed over
the completion flag read at build time, so the quest never went (Complete).
Both now re-read the log on every draw, in place, so the objective tables
the drawn lines hold stay valid.

Refreshing per draw needs the quest's log index, so the walk that already
builds the header map records it too.
2026-08-17 14:11:49 +02:00
3 changed files with 251 additions and 64 deletions
+43 -5
View File
@@ -1785,16 +1785,36 @@ function QuestieDB.GetQuest(questId, ...) -- /dump QuestieDB.GetQuest(867)
end
return nil
end
-- Build a minimal live-fallback quest from the quest log so the tracker still works
-- Build a minimal live-fallback quest from the quest log so the tracker still works.
-- The override that got us here is partial by nature -- a Learner record of a single
-- field, a correction -- but whatever it does carry beats guessing, and the quest object
-- built here is cached for the session, so anything left blank stays blank.
local logEntry = QuestLogCache.GetQuest(questId)
if not logEntry then return nil end
local function NonEmpty(text)
if text and text ~= "" then return text end
return nil
end
local overrideName = NonEmpty(overrideData[QuestieDB.questKeys.name])
local overrideLevel = overrideData[QuestieDB.questKeys.questLevel]
local overrideZone = overrideData[QuestieDB.questKeys.zoneOrSort]
local cachedTitle = NonEmpty(logEntry.title)
local liveTitle
if (not overrideName) and (not cachedTitle) and GetQuestLogIndexByID and GetQuestLogTitle then
-- QuestLogCache is a snapshot and can be missing the title of a quest the client
-- has since filled in, which is what leaves a quest showing as its own id.
local questLogIndex = GetQuestLogIndexByID(questId)
if questLogIndex and questLogIndex > 0 then
liveTitle = NonEmpty(GetQuestLogTitle(questLogIndex))
end
end
local fallback = {
Id = questId,
name = logEntry.title or tostring(questId),
level = logEntry.level or 0,
questLevel = logEntry.level or 0,
name = overrideName or cachedTitle or liveTitle or tostring(questId),
level = overrideLevel or logEntry.level or 0,
questLevel = overrideLevel or logEntry.level or 0,
requiredLevel = 0,
zoneOrSort = 0,
zoneOrSort = overrideZone or 0,
questFlags = 0,
specialFlags = 0,
Starts = { CreatureStarts = {}, ObjectStarts = {}, ItemStarts = {} },
@@ -1916,6 +1936,24 @@ function QuestieDB.GetQuest(questId, ...) -- /dump QuestieDB.GetQuest(867)
if learnerRecord.objIndex and not QO.objIndex then QO.objIndex = learnerRecord.objIndex end
end
-- A record that never captured a name -- a Learner entry written from an objective update, an
-- override carrying a single key -- leaves QO.name nil, and this object is cached for the rest
-- of the session, so everything downstream ends up printing the quest id. Ask the client.
if ((not QO.name) or QO.name == "") and GetQuestLogIndexByID and GetQuestLogTitle then
local questLogIndex = GetQuestLogIndexByID(questId)
if questLogIndex and questLogIndex > 0 then
local logTitle = GetQuestLogTitle(questLogIndex)
if logTitle and logTitle ~= "" then
QO.name = logTitle
end
end
end
-- Same story for the zone: partial records leave it nil, and callers all treat it as a number
-- (`quest.zoneOrSort > 0` errors outright on nil). 0 is the value everything already reads as
-- "no zone on file", which sends the tracker to the quest log for one.
QO.zoneOrSort = QO.zoneOrSort or 0
local questLevel, requiredLevel = QuestieLib.GetTbcLevel(questId)
QO.level = questLevel
QO.requiredLevel = requiredLevel
+18 -2
View File
@@ -63,6 +63,22 @@ local function GetWrappedWidth(label)
return label:GetWidth()
end
-- A quest built from partial data can reach the tracker with no name, or with the id standing in
-- for one, and it is cached that way for the session. Ask the quest log before printing a number.
local function GetDisplayableQuestName(quest)
local questName = quest.name
if (not questName) or questName == "" or questName == tostring(quest.Id) then
questName = TrackerUtils:GetQuestLogTitleById(quest.Id) or questName
end
if (not questName) or questName == "" then
questName = tostring(quest.Id)
end
return questName
end
local LSM30 = LibStub and LibStub("LibSharedMedia-3.0", true)
-- Local Vars
@@ -1031,7 +1047,7 @@ function QuestieTracker:Update()
if quest.isFallback or quest._isLogFallback then
-- Quest not in DB: use the name stored on the fallback object
local questName = quest.name or tostring(quest.Id)
local questName = GetDisplayableQuestName(quest)
if Questie.db.profile.trackerShowQuestLevel and quest.level and quest.level > 0 then
questName = "[" .. quest.level .. "] " .. questName
end
@@ -1065,7 +1081,7 @@ function QuestieTracker:Update()
-- which queries the static DB only: for a quest with no DB entry it
-- silently defaults to level 1, and Ascension's scaling then scales
-- that wrong base instead of the quest's real level.
local fallbackName = quest.name or tostring(quest.Id)
local fallbackName = GetDisplayableQuestName(quest)
if Questie.db.profile.trackerShowQuestLevel and quest.level and quest.level > 0 then
fallbackName = QuestieLib:GetQuestString(quest.Id, fallbackName, quest.level, false)
end
+190 -57
View File
@@ -685,9 +685,14 @@ local questLogHeaders = {}
-- and this is what says whether the player still has a given quest.
local questLogQuestIds = {}
-- questId -> its row in the quest log, so re-reading a quest's leaderboard costs a lookup
-- instead of another walk. Verified before use, since the log renumbers on every change.
local questLogIndexes = {}
local function BuildQuestLogHeaders()
local headers = {}
local questIds = {}
local indexes = {}
local header
for i = 1, (GetNumQuestLogEntries and GetNumQuestLogEntries() or 0) do
@@ -698,6 +703,7 @@ local function BuildQuestLogHeaders()
end
elseif logId then
questIds[logId] = true
indexes[logId] = i
if header then
headers[logId] = header
end
@@ -706,6 +712,87 @@ local function BuildQuestLogHeaders()
questLogHeaders = headers
questLogQuestIds = questIds
questLogIndexes = indexes
end
-- Returns the quest's current row in the quest log, or nil if the player no longer has it.
local function GetQuestLogIndexForQuest(questId)
local index = questLogIndexes[questId]
if index then
local _, _, _, isHeader, _, _, _, logId = GetQuestLogTitle(index)
if (not isHeader) and logId == questId then
return index
end
end
for i = 1, (GetNumQuestLogEntries and GetNumQuestLogEntries() or 0) do
local _, _, _, isHeader, _, _, _, logId = GetQuestLogTitle(i)
if (not isHeader) and logId == questId then
questLogIndexes[questId] = i
return i
end
end
return nil
end
-- 3.3.5 hands over a leaderboard line as one string -- "Icefang slain: 3/8" -- while the tracker
-- prints the description and the counts separately, so they have to come back apart here.
-- Objectives with nothing to count arrive as bare text and stand in as a single 0/1 step.
local function ParseLeaderBoardText(text, finished)
-- Greedy on purpose: a description of its own may hold a colon, and the counter is the last one.
local description, collected, needed = string.match(text, "^(.*):%s*(%d+)%s*/%s*(%d+)%s*$")
if not description then
-- Servers writing their own objective strings do not always keep the colon.
description, collected, needed = string.match(text, "^(.-)%s*(%d+)%s*/%s*(%d+)%s*$")
end
if (not description) or description == "" then
return text, (finished and 1 or 0), 1
end
collected = tonumber(collected) or 0
needed = tonumber(needed) or 1
if needed <= 0 then
needed = 1
end
return description, collected, needed
end
-- Fills `objectives` from the quest's leaderboard, in place: the tracker hands these very tables
-- to the lines it draws, so a redraw has to update them rather than swap in new ones.
local function ReadFallbackObjectives(questLogIndex, questId, objectives)
local numObjectives = (GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(questLogIndex)) or 0
for j = 1, numObjectives do
local text, objectiveType, finished = GetQuestLogLeaderBoard(j, questLogIndex)
if text then
local description, collected, needed = ParseLeaderBoardText(text, finished)
local objective = objectives[j]
if not objective then
objective = {}
objectives[j] = objective
end
objective.questId = questId
objective.Index = j
objective.Description = description
objective.text = text
objective.Collected = collected
objective.Needed = needed
objective.Completed = (finished or collected >= needed) and true or false
objective.baseType = objectiveType
-- Nothing here came from the DB, so map/tooltip code has to leave it alone.
objective.Type = "fallback"
end
end
for j = #objectives, numObjectives + 1, -1 do
objectives[j] = nil
end
return objectives
end
-- Returns the header title string, or nil if the quest is not in the log under one.
@@ -722,7 +809,13 @@ local function _GetZoneName(zoneOrSort, questId, zoneNameOverride)
if zoneNameOverride and zoneNameOverride ~= "" then
return zoneNameOverride
end
if not zoneOrSort then return "Unknown Zone" end
-- A quest assembled from a partial record -- a Learner entry that never captured the field,
-- an override carrying a single key -- reaches here with no zoneOrSort at all. That is the
-- same "nothing to look up" case as 0, and the quest log below still knows where the client
-- files the quest, so it must not short-circuit to Unknown Zone ahead of that.
zoneOrSort = zoneOrSort or 0
local zoneName
local sortObj = Questie.db.profile.trackerSortObjectives
if sortObj == "byZone" or sortObj == "byZoneComplete" or sortObj == "byZoneCompleteReversed" or sortObj == "byZonePlayerProximity" or sortObj == "byZonePlayerProximityReversed" then
@@ -778,67 +871,102 @@ local function _GetZoneName(zoneOrSort, questId, zoneNameOverride)
return zoneName
end
-- The client's own title for a quest, or nil if the player does not have it. Quest objects can
-- reach the tracker without a usable name -- a questDataOverrides entry that carries no name
-- field, a QuestLogCache row read before the client had filled the title in -- and since those
-- objects are cached for the session, the name never repairs itself. The log always knows.
function TrackerUtils:GetQuestLogTitleById(questId)
local questLogIndex = GetQuestLogIndexForQuest(questId)
if not questLogIndex then return nil end
local title = GetQuestLogTitle(questLogIndex)
if title and title ~= "" then
return title
end
return nil
end
-- IsComplete must be a method (called as quest:IsComplete()), and it reads the state the last
-- refresh stored rather than closing over the state at build time -- these quests outlive many
-- redraws, and a captured value would still claim the quest is unfinished after it is turned in.
local function FallbackQuestIsComplete(self)
if self.logIsComplete == 1 or self.logIsComplete == true then
return 1
end
if QuestiePlayer.currentQuestlog[self.Id] and IsQuestFlaggedCompleted and IsQuestFlaggedCompleted(self.Id) then
return 1
end
return 0
end
-- Re-reads everything the quest log owns: title, level, completion and the objectives. The
-- tracker keeps fallback quests between draws, so without this they stay frozen at whatever
-- the log said when the quest was first seen.
---@return boolean @false if the player no longer has the quest
function TrackerUtils:RefreshFallbackQuest(quest)
if not quest then return false end
local questLogIndex = GetQuestLogIndexForQuest(quest.Id)
if not questLogIndex then return false end
local title, level, _, _, _, isComplete = GetQuestLogTitle(questLogIndex)
if title and title ~= "" then
quest.name = title
end
if level and level > 0 then
quest.level = level
end
quest.logIsComplete = isComplete
quest.isComplete = (isComplete == 1)
ReadFallbackObjectives(questLogIndex, quest.Id, quest.Objectives)
return true
end
-- Returns nil if the quest is not currently in the quest log.
function TrackerUtils:BuildFallbackQuest(questId)
for i = 1, GetNumQuestLogEntries() do
local title, level, _, isHeader, _, isComplete, _, logQuestId = GetQuestLogTitle(i)
if not isHeader and logQuestId == questId then
-- Parse objectives from the leaderboard
local objectives = {}
local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(i) or 0
for j = 1, numObj do
local text, _, finished = GetQuestLogLeaderBoard(j, i)
if text then
-- Parse "Description: X/Y" or just "Description"
local collected, needed = string.match(text, ":.-(%d+)/(%d+)%s*$")
collected = tonumber(collected) or (finished and 1 or 0)
needed = tonumber(needed) or 1
objectives[j] = {
text = text,
Needed = needed,
Collected = collected,
Finished = finished or (collected >= needed),
Type = "fallback",
}
end
end
local questLogIndex = GetQuestLogIndexForQuest(questId)
if not questLogIndex then return nil end
-- Walk backwards from i in the quest log to find the zone header.
-- This is the canonical 3.3.5 method: zone headers sit above their quests.
local zoneText = nil
for h = i, 1, -1 do
local hTitle, _, _, hIsHeader = GetQuestLogTitle(h)
if hIsHeader and hTitle and hTitle ~= "" then
zoneText = hTitle
break
end
end
local zoneId = (zoneText and GetAreaIdByZoneName(zoneText)) or 0
local zoneNameOverride = nil
if zoneText and (not zoneId or zoneId == 0) then
zoneNameOverride = zoneText
end
local quest = {
Id = questId,
name = title or ("Quest " .. questId),
level = level or 0,
zoneOrSort = zoneId,
zoneName = zoneText,
zoneNameOverride = zoneNameOverride,
Objectives = objectives,
SpecialObjectives = {},
isFallback = true,
}
-- IsComplete must be a method (called as quest:IsComplete())
quest.IsComplete = function(self)
return (isComplete == 1 or (QuestiePlayer.currentQuestlog[questId] and IsQuestFlaggedCompleted and IsQuestFlaggedCompleted(questId))) and 1 or 0
end
return quest
-- Walk backwards from the quest in the log to find the zone header.
-- This is the canonical 3.3.5 method: zone headers sit above their quests.
local zoneText = nil
for h = questLogIndex, 1, -1 do
local hTitle, _, _, hIsHeader = GetQuestLogTitle(h)
if hIsHeader and hTitle and hTitle ~= "" then
zoneText = hTitle
break
end
end
return nil
local zoneId = (zoneText and GetAreaIdByZoneName(zoneText)) or 0
local zoneNameOverride = nil
if zoneText and (not zoneId or zoneId == 0) then
zoneNameOverride = zoneText
end
local quest = {
Id = questId,
name = "Quest " .. questId,
level = 0,
zoneOrSort = zoneId,
zoneName = zoneText,
zoneNameOverride = zoneNameOverride,
Objectives = {},
SpecialObjectives = {},
isFallback = true,
IsComplete = FallbackQuestIsComplete,
}
-- Same read the tracker does on every later draw, so a quest built here and one refreshed
-- from the cache carry exactly the same fields.
TrackerUtils:RefreshFallbackQuest(quest)
return quest
end
function TrackerUtils:GetSortedQuestIds()
@@ -921,6 +1049,11 @@ function TrackerUtils:GetSortedQuestIds()
if fallback then
TrackerUtils._fallbackQuests[qid] = fallback
end
else
-- Cached by an earlier draw, so its objectives and completion state are as
-- old as the cache. Nothing else updates them -- these quests have no DB
-- entry, so QuestieQuest's populate path skips them entirely.
TrackerUtils:RefreshFallbackQuest(fallback)
end
if fallback then
quest = fallback