feat: release v1.3.0 - QuestieLearner Confidence & Tiered Pruning Engine

This commit is contained in:
Xurkon
2026-03-17 22:33:01 -05:00
parent 7f16d31294
commit b0f8ba22e7
20 changed files with 1540 additions and 199 deletions
+13 -4
View File
@@ -11,7 +11,16 @@ local AceComm = LibStub("AceComm-3.0")
local addonPrefix = "QuestieLearner"
local hiddenChannelName = "questiecomm"
local ProtocolVersion = 1
local ProtocolVersion = 2 -- Increment protocol version for enhanced/sanitized data
local time = time
local GetTime = GetTime
local math_min = math.min
local math_floor = math.floor
local math_random = math.random
local table_insert = table.insert
local table_remove = table.remove
local table_getn = table.getn
-- Dev Logging Flags — defined first so all functions below can call DebugLog
local LOG_CRITICAL = true
@@ -123,10 +132,10 @@ function QuestieLearnerComms:Initialize()
end
-- Process incoming/outgoing queues
C_Timer.NewTicker(0.2, function() _QuestieLearnerComms:ProcessQueues() end)
QuestieCompat.C_Timer.NewTicker(0.2, function() _QuestieLearnerComms:ProcessQueues() end)
-- Start Reinforcement Loop (every 60 seconds)
C_Timer.NewTicker(60, function() _QuestieLearnerComms:ProcessReinforcement() end)
QuestieCompat.C_Timer.NewTicker(60, function() _QuestieLearnerComms:ProcessReinforcement() end)
end
function _QuestieLearnerComms:ProcessReinforcement()
@@ -270,5 +279,5 @@ function _QuestieLearnerComms:ProcessRawMessage(encodedMsg, sender)
DebugLog("DEVELOP", "Received " .. tostring(op) .. " " .. tostring(typ) .. " " .. tostring(id) .. " from " .. tostring(sender))
QuestieLearner:HandleNetworkData(typ, id, d)
QuestieLearner:HandleNetworkData(typ, id, d, op)
end
@@ -421,19 +421,7 @@ function QuestieOptions.tabs.advanced:Initialize()
order = 6,
name = l10n('3.3.5 Compatibility Settings'),
},
plugin_header = {
type = "header",
order = 7,
name = "|cFF5EBAF3Loaded Questie-X Plugins|r",
},
plugin_status_desc = {
type = "description",
order = 7.01,
fontSize = "medium",
name = function()
return "|cFF888888Plugin stats have moved to the |r|cFFFFFFFFDatabase|r|cFF888888 tab.|r"
end,
},
},
}
end
@@ -366,9 +366,34 @@ function QuestieOptions.tabs.database:Initialize()
end,
},
stale_threshold = {
type = "range",
order = 5.2,
name = function() return l10n("Stale Data Threshold (Days)") end,
desc = function() return l10n("Unconfirmed learned data (seen only once) will be pruned if it hasn't been seen in this many days. Verified data is permanent.") end,
min = 1,
max = 180,
step = 1,
get = function() return (Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.staleThreshold) or 90 end,
set = function(_, val)
Questie.db.global.learnedData.settings.staleThreshold = val
end,
},
prune_verified = {
type = "toggle",
order = 5.3,
name = function() return l10n("Include Verified Data in Pruning") end,
desc = function() return l10n("If enabled, even high-confidence (Verified) data will be subject to redundancy pruning (e.g., if it's already in the official DB). Time-based pruning still only affects unconfirmed data.") end,
get = function() return (Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.pruneVerified) or false end,
set = function(_, val)
Questie.db.global.learnedData.settings.pruneVerified = val
end,
},
prune_dry_btn = {
type = "execute",
order = 5.2,
order = 5.4,
name = function() return l10n("Dry Run (Preview)") end,
desc = function() return "Print a summary of entries that would be removed, without deleting anything." end,
func = function()
@@ -384,10 +409,10 @@ function QuestieOptions.tabs.database:Initialize()
end
end,
},
prune_btn = {
type = "execute",
order = 5.3,
order = 5.5,
name = function() return l10n("Prune Now") end,
desc = function() return "|cFFFF8800Removes stale entries. Cannot be undone. Export first if you want a backup.|r" end,
func = function()
@@ -400,10 +425,10 @@ function QuestieOptions.tabs.database:Initialize()
))
end,
},
prune_all_btn = {
type = "execute",
order = 5.4,
order = 5.6,
name = function() return "|cFFFF4444" .. l10n("Reset All Learned Data") .. "|r" end,
desc = function() return "|cFFFF0000DANGER: Wipes ALL learned data for ALL servers. Export first.|r" end,
confirm = true,
+37 -12
View File
@@ -985,22 +985,27 @@ function QuestieQuest:UpdateObjectiveNotes(quest)
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest] UpdateObjectiveNotes:", quest.Id)
for objectiveIndex, objective in pairs(quest.Objectives) do
local result, err = xpcall(QuestieQuest.PopulateObjective, ERR_FUNCTION, QuestieQuest, quest, objectiveIndex,
objective, false)
if (not result) then
Questie:Debug(Questie.DEBUG_ELEVATED, "[QuestieQuest] There was an error populating objectives for",
quest.name, quest.Id, objectiveIndex, err)
-- Skip tracker-only fallback objectives — they have no DB Id and can't be populated
if objective.Type ~= "fallback" then
local result, err = xpcall(QuestieQuest.PopulateObjective, ERR_FUNCTION, QuestieQuest, quest, objectiveIndex,
objective, false)
if (not result) then
Questie:Debug(Questie.DEBUG_ELEVATED, "[QuestieQuest] There was an error populating objectives for",
quest.name, quest.Id, objectiveIndex, err)
end
end
end
if quest.SpecialObjectives and next(quest.SpecialObjectives) then
for _, objective in pairs(quest.SpecialObjectives) do
local result, err = xpcall(QuestieQuest.PopulateObjective, ERR_FUNCTION, QuestieQuest, quest, 0, objective,
true)
if not result then
Questie:Error("[QuestieQuest]: [SpecialObjectives] " ..
l10n("There was an error populating objectives for %s %s %s %s", quest.name or "No quest name",
quest.Id or "No quest id", 0 or "No objective", err or "No error"));
if objective.Type ~= "fallback" then
local result, err = xpcall(QuestieQuest.PopulateObjective, ERR_FUNCTION, QuestieQuest, quest, 0, objective,
true)
if not result then
Questie:Error("[QuestieQuest]: [SpecialObjectives] " ..
l10n("There was an error populating objectives for %s %s %s %s", quest.name or "No quest name",
quest.Id or "No quest id", 0 or "No objective", err or "No error"));
end
end
end
end
@@ -1318,6 +1323,26 @@ function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockI
objectiveCenter = { x = x, y = y }
end
-- Filter static spawns if prioritizeMyData is enabled and we have high-confidence learned data
if Questie.db.global.learnedData and Questie.db.global.learnedData.settings and Questie.db.global.learnedData.settings.prioritizeMyData then
for zone in pairs(zones) do
local suppressed = (objectiveData.Type == "monster" and QuestieDB.GetSuppressedNPCs(zone)) or (objectiveData.Type == "object" and QuestieDB.GetSuppressedObjects(zone))
if suppressed then
for id, spawnData in pairs(objective.spawnList) do
if suppressed[id] and spawnData.Spawns and spawnData.Spawns[zone] then
-- Only suppress if this isn't a learned spawn (learned spawns have .isLearned)
if not spawnData.isLearned then
spawnData.Spawns[zone] = nil
if not next(spawnData.Spawns) then
objective.spawnList[id] = nil
end
end
end
end
end
end
end
local iconsToDraw, _ = _DetermineIconsToDraw(quest, objective, objectiveIndex, objectiveCenter)
local icon, iconPerZone = _DrawObjectiveIcons(quest.Id, iconsToDraw, objective, maxPerType)
_DrawObjectiveWaypoints(objective, icon, iconPerZone)
@@ -1341,7 +1366,7 @@ _RegisterObjectiveTooltips = function(objective, questId, blockItemTooltips)
-- No spawnList and no Id means there is nothing Questie can draw for this objective.
-- This covers server-tracked trigger objectives (e.g. "complete N quests in zone" for
-- quest 50150) which may have any objectiveType from the server, not just "event".
if not objective.Id then
if not objective.Id or objective.Id == 0 then
objective.hasRegisteredTooltips = true
return
end
+1005 -80
View File
File diff suppressed because it is too large Load Diff
+47 -16
View File
@@ -319,42 +319,73 @@ local QuestieDB -- lazily imported to avoid circular dep
function _Export:RunPrune(dryRun)
if not QuestieDB then QuestieDB = QuestieLoader:ImportModule("QuestieDB") end
local serverKey = GetServerKey()
local bucket = GetServerBucket(serverKey)
local result = { npcs = 0, quests = 0, items = 0, objects = 0, total = 0, reasons = {} }
if not bucket then return result end
local settings = (Questie.db.global.learnedData and Questie.db.global.learnedData.settings) or {}
local thresholdDays = settings.staleThreshold or 90
local thresholdSeconds = thresholdDays * 86400
local minConfidence = settings.minConfidencePins or 2
local pruneVerified = settings.pruneVerified
local now = time()
local function ShouldPruneNPC(id, entry)
if CountTable(entry) == 0 then return "empty entry" end
if (entry.mc or 0) < 2 and not entry[7] then return "unverified with no coords" end
local isVerified = (entry.mc or 0) >= minConfidence
if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then
return "unconfirmed and stale (> " .. thresholdDays .. " days)"
end
if pruneVerified or not isVerified then
if (entry.mc or 0) < 2 and not entry[7] then return "unverified with no coords" end
end
return nil
end
local function ShouldPruneQuest(id, entry)
if CountTable(entry) == 0 then return "empty entry" end
if QuestieDB and QuestieDB.GetQuest then
local dbEntry = QuestieDB:GetQuest(id)
if dbEntry and (entry.mc or 0) < 2 then
return "fully covered by official DB, mc < 2"
local isVerified = (entry.mc or 0) >= minConfidence
if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then
return "unconfirmed and stale (> " .. thresholdDays .. " days)"
end
if pruneVerified or not isVerified then
if QuestieDB and QuestieDB.GetQuest then
local dbEntry = QuestieDB.GetQuest(id)
if dbEntry and (entry.mc or 0) < 2 then
return "fully covered by official DB, mc < 2"
end
end
end
return nil
end
local function ShouldPruneItem(id, entry)
if CountTable(entry) == 0 then return "empty entry" end
if (entry.mc or 0) < 1 then return "zero match count" end
local isVerified = (entry.mc or 0) >= minConfidence
if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then
return "unconfirmed and stale (> " .. thresholdDays .. " days)"
end
if pruneVerified or not isVerified then
if (entry.mc or 0) < 1 then return "zero match count" end
end
return nil
end
local function ShouldPruneObject(id, entry)
if CountTable(entry) == 0 then return "empty entry" end
if (entry.mc or 0) < 2 and not entry[4] then return "unverified with no coords" end
local isVerified = (entry.mc or 0) >= minConfidence
if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then
return "unconfirmed and stale (> " .. thresholdDays .. " days)"
end
if pruneVerified or not isVerified then
if (entry.mc or 0) < 2 and not entry[4] then return "unverified with no coords" end
end
return nil
end
local function PruneStore(store, checkFn, typeName)
if not store then return end
for id, entry in pairs(store) do
@@ -371,11 +402,11 @@ function _Export:RunPrune(dryRun)
end
end
end
PruneStore(bucket.npcs, ShouldPruneNPC, "npcs")
PruneStore(bucket.quests, ShouldPruneQuest, "quests")
PruneStore(bucket.items, ShouldPruneItem, "items")
PruneStore(bucket.objects, ShouldPruneObject, "objects")
return result
end
+59 -1
View File
@@ -219,6 +219,64 @@ function QuestieTooltips:GetTooltip(key)
local tooltipData = {}
local tooltipLines = {}
if (not QuestieTooltips.lookupByKey[key]) then
local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner")
local QuestLogCache = QuestieLoader:ImportModule("QuestLogCache")
if QuestieLearner and QuestieLearner.data then
-- Try to find in learned NPCs or objects
local id = tonumber(key:sub(3))
if id then
if key:sub(1,2) == "m_" then
local learnedNpc = QuestieLearner.data.npcs[id]
if learnedNpc and learnedNpc[10] then -- check questObjectives
for questId, objList in pairs(learnedNpc[10]) do
for _, objText in ipairs(objList) do
local needed, collected
local objectives = QuestLogCache.GetQuestObjectives(questId)
if objectives then
for _, obj in pairs(objectives) do
if obj.text and objText and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then
needed = obj.numRequired
collected = obj.numFulfilled
break
end
end
end
QuestieTooltips:RegisterObjectiveTooltip(questId, key, { Index = 0, Description = objText, Needed = needed, Collected = collected, Update = function() end })
end
end
if learnedNpc.mc then
tinsert(tooltipLines, "|cFF5EBAF3(Learned - Confidence: " .. tostring(learnedNpc.mc) .. ")|r")
end
end
elseif key:sub(1,2) == "o_" then
local learnedObj = QuestieLearner.data.objects[id]
if learnedObj and learnedObj[10] then
for questId, objList in pairs(learnedObj[10]) do
for _, objText in ipairs(objList) do
local needed, collected
local objectives = QuestLogCache.GetQuestObjectives(questId)
if objectives then
for _, obj in pairs(objectives) do
if obj.text and objText and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then
needed = obj.numRequired
collected = obj.numFulfilled
break
end
end
end
QuestieTooltips:RegisterObjectiveTooltip(questId, key, { Index = 0, Description = objText, Needed = needed, Collected = collected, Update = function() end })
end
end
if learnedObj.mc then
tinsert(tooltipLines, "|cFF5EBAF3(Learned - Confidence: " .. tostring(learnedObj.mc) .. ")|r")
end
end
end
end
end
end
if QuestieTooltips.lookupByKey[key] then
local playerName = UnitName("player")
for k, tooltip in pairs(QuestieTooltips.lookupByKey[key]) do
@@ -229,7 +287,7 @@ function QuestieTooltips:GetTooltip(key)
end
else
local objective = tooltip.objective
if not (objective.IsSourceItem or objective.IsRequiredSourceItem) then
if objective and not (objective.IsSourceItem or objective.IsRequiredSourceItem) and objective.Update then
-- Tooltip was registered for a sourceItem or requiredSourceItem and not a real "objective"
objective:Update()
end
+21 -9
View File
@@ -914,7 +914,17 @@ function QuestieTracker:Update()
-- Set Quest Title - This handles the "Auto Minimize Completed Quests" option but we don't auto-minimize timed quests.
local coloredQuestName
if timedQuest then
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)
if Questie.db.profile.trackerShowQuestLevel and quest.level and quest.level > 0 then
questName = "[" .. quest.level .. "] " .. questName
end
if Questie.db.profile.enableTooltipsQuestID then
questName = questName .. " (" .. quest.Id .. ")"
end
coloredQuestName = "|cFFFFFF00" .. questName .. "|r"
elseif timedQuest then
coloredQuestName = QuestieLib:GetColoredQuestName(quest.Id,
Questie.db.profile.trackerShowQuestLevel, false, false)
else
@@ -2290,10 +2300,15 @@ end
function QuestieTracker:UntrackQuestId(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieTracker:UntrackQuestId] - ", questId)
-- Always remove from tracked quests when manually untracking
Questie.db.char.TrackedQuests[questId] = nil
-- Also remove from auto-untracked so it doesn't get re-tracked
Questie.db.char.AutoUntrackedQuests[questId] = nil
if Questie.db.profile.autoTrackQuests then
-- In auto-track mode, mark the quest as explicitly hidden
Questie.db.char.AutoUntrackedQuests[questId] = true
Questie.db.char.TrackedQuests[questId] = nil
else
-- In manual-track mode, remove from tracked list
Questie.db.char.TrackedQuests[questId] = nil
Questie.db.char.AutoUntrackedQuests[questId] = nil
end
if Questie.db.profile.hideUntrackedQuestsMapIcons then
-- Hides objective icons for untracked quests.
@@ -2346,11 +2361,8 @@ function QuestieTracker:AQW_Insert(index, expire)
end
else
if Questie.db.char.AutoUntrackedQuests[questId] then
-- Quest was manually hidden — shift-click re-tracks it
Questie.db.char.AutoUntrackedQuests[questId] = nil
-- Add quest to the tracker
elseif IsShiftKeyDown() and QuestLogFrame:IsShown() then
Questie.db.char.AutoUntrackedQuests[questId] = true
end
end
+62 -3
View File
@@ -698,6 +698,41 @@ end
-- Intentionally NOT stored in QuestiePlayer.currentQuestlog so arrow/map/other modules
-- don't try to call DB-only methods on them.
TrackerUtils._fallbackQuests = TrackerUtils._fallbackQuests or {}
-- Reverse-lookup: given a localized zone name string, find the area ID from l10n.zoneLookup.
local function GetAreaIdByZoneName(zoneName)
if not zoneName or zoneName == "" then return 0 end
for _, zoneTable in pairs(l10n.zoneLookup) do
for areaId, name in pairs(zoneTable) do
if name == zoneName then return areaId end
end
end
return 0
end
-- Walk the quest log to find the zone header for a given questId.
-- In 3.3.5, zone names appear as isHeader=true entries above their quests.
-- Returns the header title string, or nil if not found.
local function GetQuestLogZoneName(questId)
local targetIndex = nil
local total = GetNumQuestLogEntries and GetNumQuestLogEntries() or 0
for i = 1, total do
local _, _, _, isHeader, _, _, _, logId = GetQuestLogTitle(i)
if not isHeader and logId == questId then
targetIndex = i
break
end
end
if not targetIndex then return nil end
for i = targetIndex, 1, -1 do
local title, _, _, isHeader = GetQuestLogTitle(i)
if isHeader and title and title ~= "" then
return title
end
end
return nil
end
-- Returns nil if the quest is not currently in the quest log.
function TrackerUtils:BuildFallbackQuest(questId)
for i = 1, GetNumQuestLogEntries() do
@@ -723,13 +758,24 @@ function TrackerUtils:BuildFallbackQuest(questId)
end
end
local zoneId = GetCurrentMapAreaID and GetCurrentMapAreaID() or 0
-- 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 quest = {
Id = questId,
name = title or ("Quest " .. questId),
level = level or 0,
zoneOrSort = zoneId,
zoneName = zoneText,
Objectives = objectives,
SpecialObjectives = {},
isFallback = true,
@@ -774,7 +820,7 @@ function TrackerUtils:GetSortedQuestIds()
local capturedId = qid
quest.IsComplete = function(self)
for i = 1, GetNumQuestLogEntries() do
local _, _, _, _, isHeader, _, isCompleteFlag, _, logId = GetQuestLogTitle(i)
local _, _, _, isHeader, _, isCompleteFlag, _, logId = GetQuestLogTitle(i)
if not isHeader and logId == capturedId then
return (isCompleteFlag == 1 or isCompleteFlag == true) and 1 or 0
end
@@ -784,10 +830,23 @@ function TrackerUtils:GetSortedQuestIds()
if not quest.Objectives then quest.Objectives = {} end
if not quest.SpecialObjectives then quest.SpecialObjectives = {} end
if not quest.ExtraObjectives then quest.ExtraObjectives = {} end
-- Use the quest log header walk (canonical 3.3.5 zone resolution)
if not quest.zoneName or quest.zoneName == "" then
local logZone = GetQuestLogZoneName(capturedId)
if logZone then
quest.zoneName = logZone
quest.zoneOrSort = GetAreaIdByZoneName(logZone) or 0
end
end
QuestiePlayer.currentQuestlog[qid] = quest
else
-- No object at all — build one from the log
local fallback = TrackerUtils._fallbackQuests[qid]
-- Re-build if cached without zone info (e.g. was built before log was ready)
if fallback and not fallback.zoneName then
TrackerUtils._fallbackQuests[qid] = nil
fallback = nil
end
if not fallback then
fallback = TrackerUtils:BuildFallbackQuest(qid)
if fallback then
@@ -807,7 +866,7 @@ function TrackerUtils:GetSortedQuestIds()
-- Create questDetails table keys and insert values
questDetails[qid] = {}
questDetails[qid].quest = quest
questDetails[qid].zoneName = _GetZoneName(quest.zoneOrSort, qid)
questDetails[qid].zoneName = quest.zoneName or _GetZoneName(quest.zoneOrSort, qid)
if quest:IsComplete() == 1 or (not next(quest.Objectives)) then
questDetails[qid].questCompletePercent = 1