feat: refine learner data source and pin rendering

This commit is contained in:
Xurkon
2026-06-06 00:04:50 -05:00
parent 1532b70af3
commit 6597d20b7d
12 changed files with 576 additions and 58 deletions
+10 -1
View File
@@ -10,6 +10,11 @@ local HBD = QuestieCompat.HBD or LibStub("HereBeDragonsQuestie-2.0")
local ZOOM_MODIFIER = 1;
-- Pins whose world-coordinate distance is within this epsilon are treated as the
-- same physical spot and always merged, independent of the clustering range. This
-- is the baseline pin deduplication that runs even when clustering is turned off.
QuestieMap.utils.COINCIDENT_EPSILON = 0.2;
-- All the speed we can get is worth it.
local tinsert = table.insert
local next = next
@@ -146,7 +151,11 @@ function QuestieMap.utils:CalcHotzones(points, rangeR, count)
-- Do not cluster icons if they have no coordinates
and aX ~= 0 and aY ~= 0 and point2.worldX ~= 0 and point2.worldY ~= 0 then
local distance = QuestieLib:Euclid(aX, aY, point2.worldX, point2.worldY)
if (distance < movingRange) then
-- Always deduplicate pins that sit on (essentially) the same spot,
-- even when clustering is disabled (movingRange == 0). This keeps two
-- icons from stacking on an identical coordinate regardless of the
-- clusterDensityAggressiveness / clusterLevelHotzone settings.
if (distance < movingRange) or (distance <= QuestieMap.utils.COINCIDENT_EPSILON) then
point2.touched = true
tinsert(notes, point2)
end
@@ -19,6 +19,8 @@ local IsleOfQuelDanas = QuestieLoader:ImportModule("IsleOfQuelDanas");
local l10n = QuestieLoader:ImportModule("l10n")
---@type QuestieCompat
local QuestieCompat = QuestieLoader:ImportModule("QuestieCompat")
---@type QuestieDB
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
QuestieOptions.tabs.advanced = {}
local optionsDefaults = QuestieOptionsDefaults:Load()
@@ -50,6 +52,9 @@ local function GetLearnerSettings()
if settings.minConfidencePins == nil then
settings.minConfidencePins = 1
end
if settings.spawnDedupRadius == nil then
settings.spawnDedupRadius = 4.0
end
return settings
end
@@ -168,6 +173,22 @@ function QuestieOptions.tabs.advanced:Initialize()
QuestieOptionsUtils.DetermineTheme()
end,
},
clusterDensityAggressiveness = {
type = "range",
order = 1.41,
name = function() return l10n('Dense pin clustering aggressiveness'); end,
desc = function() return l10n('How aggressively crowded kill objectives are consolidated into fewer pins. 0 shows every pin; higher values tighten clustering where many pins share a zone. Coincident pins are always deduplicated.'); end,
width = 1.5,
disabled = function() return (not Questie.db.profile.enabled); end,
min = 0,
max = 100,
step = 5,
get = function(info) return QuestieOptions:GetProfileValue(info); end,
set = function(info, value)
QuestieOptions:SetProfileValue(info, value)
QuestieOptionsUtils:Delay(0.5, QuestieOptions.ClusterRedraw, l10n('Setting dense pin clustering aggressiveness to %s : Redrawing!', value))
end,
},
quelDanasSpacer1 = QuestieOptionsUtils:Spacer(1.45, (not Questie.IsTBC)),
npcrules_group = {
type = "group",
@@ -323,6 +344,26 @@ function QuestieOptions.tabs.advanced:Initialize()
settings.performanceMode = "manual"
end,
},
learnerSpawnDedupRadius = {
type = "range",
order = 2.55,
name = function() return l10n('Spawn Pin Dedup Radius'); end,
desc = function() return l10n('How close two learned kill positions must be (in map %) to merge into a single pin. Higher values show fewer, tighter pins per spawn; 0 shows every distinct position.'); end,
min = 0,
max = 15,
step = 0.5,
width = 1.5,
get = function() return GetLearnerSettings().spawnDedupRadius or 4.0 end,
set = function(_, value)
GetLearnerSettings().spawnDedupRadius = value
-- Spawn tables are cached per NPC; clear so the new radius is
-- applied on the redraw instead of serving stale merged coords.
if QuestieDB and QuestieDB.ClearModeCaches then
QuestieDB:ClearModeCaches()
end
QuestieOptionsUtils:Delay(0.5, QuestieQuest.SmoothReset, l10n('Setting spawn pin dedup radius to %s : Redrawing!', value))
end,
},
learnerCommsIntensity = {
type = "select",
order = 2.6,
@@ -52,14 +52,18 @@ local function ApplyLearnerMode()
QuestieLearner:ApplyDataSourceMode()
end
local QuestieEventHandler = QuestieLoader:ImportModule("QuestieEventHandler")
if QuestieEventHandler and QuestieEventHandler.UpdateAllQuests then
QuestieEventHandler:UpdateAllQuests()
end
local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker")
if QuestieTracker and QuestieTracker.Update then
QuestieTracker:Update()
-- SmoothReset is the canonical full refresh: it clears all map/minimap notes
-- and tooltips, recalculates and redraws available quests, re-updates every
-- active quest, and refreshes the tracker. This makes a data-source-mode
-- switch (auto/learner/static/none) take effect everywhere in real time.
local QuestieQuest = QuestieLoader:ImportModule("QuestieQuest")
if QuestieQuest and QuestieQuest.SmoothReset then
QuestieQuest:SmoothReset()
else
local QuestieTracker = QuestieLoader:ImportModule("QuestieTracker")
if QuestieTracker and QuestieTracker.Update then
QuestieTracker:Update()
end
end
end
@@ -9,6 +9,11 @@ function QuestieOptionsDefaults:Load()
ascensionScalingAsked = false,
--Ascension
clusterLevelHotzone = 50,
-- How aggressively dense objectives (many pins in one zone) get
-- consolidated. 0 = off (every pin shown); higher tightens the
-- clustering range for crowded kill objectives. Coincident pins are
-- always deduplicated regardless of this value. See QuestieQuest:_DrawObjectiveIcons.
clusterDensityAggressiveness = 35,
enableIconLimit = false,
iconLimit = 200,
availableScale = 1.2,
+23 -11
View File
@@ -1847,18 +1847,30 @@ _DrawObjectiveIcons = function(questId, iconsToDraw, objective, maxPerType)
local iconCount, orderedList = _GetIconsSortedByDistance(iconsToDraw)
-- Dense kill objectives (like Sunstrider Isle mana wyrms) previously used
-- a lower clustering hotzone here. Leave the old behavior commented so we
-- can restore it quickly if we need to revisit consolidation again.
--[[
if iconCount >= 20 then
range = math.max(6, math.floor(range * 0.25))
elseif iconCount >= 12 then
range = math.max(10, math.floor(range * 0.4))
elseif iconCount >= 6 then
range = math.max(16, math.floor(range * 0.65))
-- Dense kill objectives (like Sunstrider Isle mana wyrms) consolidate more
-- aggressively the more pins share a zone. This is now user-tunable via the
-- clusterDensityAggressiveness knob (0 = off / show every pin, 100 = the
-- original aggressive consolidation). The per-zone and object overrides below
-- still take precedence, and coincident pins are always deduped in CalcHotzones.
local densityAggression = Questie.db.profile.clusterDensityAggressiveness or 0
if densityAggression > 0 then
local factor = densityAggression / 100
if factor > 1 then factor = 1 end
local baseMult
if iconCount >= 20 then
baseMult = 0.25
elseif iconCount >= 12 then
baseMult = 0.4
elseif iconCount >= 6 then
baseMult = 0.65
end
if baseMult then
-- Interpolate between no reduction (factor 0) and the full base
-- multiplier (factor 1) so the knob scales smoothly.
local mult = 1 - (1 - baseMult) * factor
range = math.max(1, math.floor(range * mult))
end
end
--]]
if orderedList[1] and orderedList[1].Icon == Questie.ICON_TYPE_OBJECT then -- new clustering / limit code should prevent problems, always show all object notes
range = range * 0.2; -- Only use 20% of the default range.
+71 -28
View File
@@ -409,6 +409,7 @@ local function EnsureLearnedData()
learnItems = true,
learnObjects = true,
minConfidencePins = 1,
spawnDedupRadius = 4.0,
prioritizeMyData = true,
dataSourceMode = "auto",
staleThreshold = 90, -- days
@@ -434,6 +435,7 @@ local function EnsureLearnedData()
if s.learnItems == nil then s.learnItems = true end
if s.learnObjects == nil then s.learnObjects = true end
if s.minConfidencePins == nil then s.minConfidencePins = 1 end
if s.spawnDedupRadius == nil then s.spawnDedupRadius = 4.0 end
if s.prioritizeMyData == nil then s.prioritizeMyData = true end
if s.dataSourceMode == nil then
if s.prioritizeMyData == false then
@@ -517,11 +519,16 @@ function QuestieLearner:ApplyDataSourceMode()
CaptureStaticOverrideSnapshot()
RestoreStaticOverridesForMode()
self:InjectLearnedData()
if QuestieDB and QuestieDB.private then
-- Rebuild every per-entity cache (including the per-zone quest cache) so the
-- mode switch takes effect immediately for reads, pins, and zone lookups.
if QuestieDB and QuestieDB.ClearModeCaches then
QuestieDB:ClearModeCaches()
elseif QuestieDB and QuestieDB.private then
QuestieDB.private.questCache = {}
QuestieDB.private.itemCache = {}
QuestieDB.private.npcCache = {}
QuestieDB.private.objectCache = {}
QuestieDB.private.zoneCache = {}
end
QuestieLearner.data = Questie.dbLearner.global
end
@@ -618,27 +625,11 @@ local _pendingQuestFrameUnloads = {}
-- caps the worst-case latency: once that many seconds have elapsed since the first
-- pending change, the flush fires even if kills are still coming (0 = pure debounce,
-- never force). Only "batched" mode debounces; "immediate" flushes on first fire.
local function _FlushActiveQuestPins()
if GetTime and GetLearnerSetting("pinRefreshMode", "batched") == "batched" then
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
local now = GetTime()
local delay = GetLearnerSetting("pinRefreshDelay", 0.75)
local maxWait = GetLearnerSetting("pinRefreshMaxWait", 5.0)
local quiet = now - (_pendingQuestPinLastActivity or now)
local waited = now - (_pendingQuestPinFirstDirty or now)
if timer and timer.After and quiet < delay and (maxWait <= 0 or waited < maxWait) then
local remaining = delay - quiet
if maxWait > 0 then
local capRemaining = maxWait - waited
if capRemaining < remaining then remaining = capRemaining end
end
if remaining < 0 then remaining = 0 end
-- _pendingQuestPinRefreshTimer stays true so concurrent queues don't double-arm.
timer.After(remaining, _FlushActiveQuestPins)
return
end
end
-- Performs the actual pin rebuild for every pending quest. No debounce gate — the
-- caller is responsible for deciding when to fire (either the trailing-debounce
-- wrapper below, or an immediate flush from the already-debounced NPC live-update
-- flush, which makes the redundant second debounce stage unnecessary).
local function _DoFlushActiveQuestPins()
local questIdSet = _pendingQuestPinRefreshes
_pendingQuestPinRefreshes = {}
_pendingQuestPinRefreshTimer = nil
@@ -664,6 +655,42 @@ local function _FlushActiveQuestPins()
_pendingQuestFrameUnloads = {}
end
local function _FlushActiveQuestPins()
-- Only defer while there is genuine pending activity. If the timestamps were
-- already cleared (e.g. the NPC live-update flush force-flushed the pins via
-- _DoFlushActiveQuestPins), a leftover timer must NOT treat the nil timestamp
-- as "now" and re-arm forever — it should fall through and flush (a no-op when
-- the pending set is empty).
if GetTime and GetLearnerSetting("pinRefreshMode", "batched") == "batched"
and _pendingQuestPinLastActivity then
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
local now = GetTime()
local delay = GetLearnerSetting("pinRefreshDelay", 0.75)
local maxWait = GetLearnerSetting("pinRefreshMaxWait", 5.0)
local quiet = now - _pendingQuestPinLastActivity
local waited = now - (_pendingQuestPinFirstDirty or _pendingQuestPinLastActivity)
if timer and timer.After and quiet < delay and (maxWait <= 0 or waited < maxWait) then
local remaining = delay - quiet
if maxWait > 0 then
local capRemaining = maxWait - waited
if capRemaining < remaining then remaining = capRemaining end
end
if remaining < 0 then remaining = 0 end
-- _pendingQuestPinRefreshTimer stays true so concurrent queues don't double-arm.
timer.After(remaining, _FlushActiveQuestPins)
return
end
end
_DoFlushActiveQuestPins()
end
-- When true, _RefreshActiveQuestPins only accumulates pending quests and does NOT
-- arm its own trailing-debounce timer. Set by _FlushNpcLiveUpdates, which already
-- debounced via liveNpcUpdateDelay and force-flushes the pins itself afterwards —
-- so the second debounce stage would only add redundant latency.
local _deferPinRefreshScheduling = false
local function _RefreshActiveQuestPins(questIdSet)
-- Skip scheduling when the set is empty (avoids no-op timer callbacks)
if not next(questIdSet) then return end
@@ -684,6 +711,11 @@ local function _RefreshActiveQuestPins(questIdSet)
_pendingQuestPinFirstDirty = now
end
-- Caller (NPC live-update flush) will force-flush; don't arm a redundant timer.
if _deferPinRefreshScheduling then
return
end
if _pendingQuestPinRefreshTimer then
return
end
@@ -916,11 +948,17 @@ local function _FlushNpcLiveUpdates()
_Learner.pendingNpcLiveUpdateFirstDirty = nil
_Learner.pendingNpcLiveUpdateLastActivity = nil
-- This flush already coalesced kills over liveNpcUpdateDelay. Suppress the
-- per-NPC pin-refresh debounce while invalidating, then flush all affected
-- quests once, immediately — instead of waiting out a second pinRefreshDelay.
_deferPinRefreshScheduling = true
for npcId in pairs(pending) do
if _ApplyNpcLiveUpdate(npcId) then
_InvalidateSpawnListsForNPC(npcId)
end
end
_deferPinRefreshScheduling = false
_DoFlushActiveQuestPins()
end
local function _QueueNpcLiveUpdate(npcId)
@@ -1496,12 +1534,17 @@ local function _MergeSpawnEvidence(npcId)
entry.y = evidenceY
local rx, ry = NormalizeCoordPair(evidenceX, evidenceY)
local key = entry.zoneId .. "|" .. tostring(rx) .. "|" .. tostring(ry)
-- DEBUG: log each entry being grouped
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence GROUPING: spawnUID=", spawnUID,
"entry.x=", entry.x, "entry.y=", entry.y,
"rx=", rx, "ry=", ry, "key=", key)
-- Group kills by a coordinate bucket, not by exact coords. Kill
-- evidence is the player's position at kill time, which drifts a
-- little every kill, so exact keys would treat each kill as its own
-- "location" — producing one pin per kill and never letting any
-- single spot accumulate enough evidence to clear the confidence
-- threshold. Bucketing collapses repeated kills at the same spawn
-- into one location (matching the [7] InsertIfNewBucket behavior).
local grid = GetCoordGridForZone(entry.zoneId)
local bx = floor(rx / grid)
local by = floor(ry / grid)
local key = entry.zoneId .. "|" .. bx .. "|" .. by
if not evidence[key] then
evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 }
end