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
+4
View File
@@ -19,6 +19,10 @@
- **[Error Suppression - Debug Modes]** Moved missing quest and other non-fatal database/error spam out of normal chat output and into Questie debug-critical/developer output. Fatal startup failures remain loud.
- **[Tooltip Data Precedence]** Updated tooltip handling so QuestieLearner defers to AscensionDB-owned tooltip/objective data instead of hiding or replacing server-plugin data for active quests.
- **[QuestieQuest - Unavailable Quest Guard]** Guarded the available-quest draw thread so unresolved quest IDs are skipped safely instead of crashing the thread, and deduped the skip log so the same missing quest does not spam every redraw.
- **[QuestieLearner - Data Source Mode Cohesion]** Reworked the Auto / Learner / Static / Neither data-source modes so switching between them applies live and consistently. A single missing static sub-table (npc/object/quest/item) no longer locks the whole addon into learner mode — only the genuinely-missing store falls back. Static and Neither no longer silently fall back to learner records, switching modes now clears the per-zone quest cache, and the mode switch drives a full pin/tracker redraw through `QuestieQuest:SmoothReset()` (the previous redraw call imported a mis-named module and silently did nothing).
- **[QuestieLearner - Pin Refresh Latency]** Collapsed a redundant second debounce stage on the live-learn pin-refresh path. Newly learned spawns now redraw within a single debounce window instead of waiting out both the NPC live-update delay and a separate pin-refresh delay, roughly halving perceived pin-update latency in the Balanced and Low presets. Also fixed a latent infinite timer re-arm that could occur once the pin flush was triggered directly.
- **[Map - Dense Pin Clustering Aggressiveness Knob]** Re-implemented density-adaptive clustering for crowded kill objectives, now controlled by a new "Dense pin clustering aggressiveness" slider on the Advanced tab (0 = show every pin, higher = tighter consolidation where many pins share a zone). Coincident pins are always deduplicated regardless of the clustering settings, and the intentional per-zone (Sunstrider Isle) and object-icon range overrides are preserved.
- **[QuestieLearner - One Pin Per Spawn, Not Per Kill]** Fixed learner kill evidence rendering a separate pin for every kill. Because kill coordinates are the player's position at kill time (and respawns carry fresh GUIDs), repeated kills at the same spot drifted just enough to dodge the exact-match dedup. The immediate learner-mode spawn builder now merges evidence within a small radius into one pin per physical spawn, and the weighted spawn-evidence merge groups kills by coordinate bucket so a single spot can actually accumulate enough evidence to clear the confidence threshold. The merge distance is exposed as a new "Spawn Pin Dedup Radius" slider on the Advanced tab (0 = show every distinct position, higher = fewer/tighter pins per spawn) which redraws live.
### Branch / Release Notes
+78 -10
View File
@@ -210,8 +210,45 @@ QuestieDB.ItemPointers = _dummyHandle.pointers
QuestieDB.baseDatabaseMissing = false
QuestieDB.baseDatabaseMissingKeys = {}
-- The four core static stores. The base DB is only considered fully "missing"
-- (which forces learner mode) when EVERY one of these failed to load.
QuestieDB._baseDatabaseStores = { "npcData", "objectData", "questData", "itemData" }
-- True only when a specific static store failed to load. Reads use this so a
-- single missing/mismatched store falls back to learner data for that store
-- alone, instead of locking the whole addon into learner mode.
function QuestieDB:IsStoreMissing(storeKey)
return QuestieDB.baseDatabaseMissingKeys and QuestieDB.baseDatabaseMissingKeys[storeKey] == true
end
-- Clears every per-entity cache. Used when the data source mode changes so that
-- quest/npc/item/object AND per-zone results are rebuilt against the new mode.
function QuestieDB:ClearModeCaches()
_QuestieDB.questCache = {}
_QuestieDB.itemCache = {}
_QuestieDB.npcCache = {}
_QuestieDB.objectCache = {}
_QuestieDB.zoneCache = {}
end
function QuestieDB:IsBaseDatabaseMissing()
return QuestieDB.baseDatabaseMissing == true
if QuestieDB.baseDatabaseMissing ~= true then
return false
end
-- Only report the base DB as missing when EVERY core store failed. A partial
-- failure (e.g. itemData missing but npcData present) must not override the
-- user's Data Source Mode selection — per-read fallback handles the gaps.
local keys = QuestieDB.baseDatabaseMissingKeys
if not keys then
return false
end
local stores = QuestieDB._baseDatabaseStores
for i = 1, 4 do
if not keys[stores[i]] then
return false
end
end
return true
end
local function _GetLearnerSettings()
@@ -233,11 +270,32 @@ local function _GetLearnerRecord(storeName, id)
return store[id] or store[tostring(id)]
end
-- Map-percent radius used to collapse per-GUID kill evidence into one pin per
-- physical spawn. Kill coordinates are the player's position at kill time, so
-- repeated kills (and respawns, which carry fresh GUIDs) land on slightly
-- different coords. Without this merge, every kill would render its own pin.
-- A distance test (rather than a grid bucket) avoids the boundary artifact where
-- two near-identical coords straddle a cell edge and split into separate pins.
-- The radius is user-tunable via the learner "Spawn Pin Dedup Radius" knob; 0
-- disables merging (every distinct coord shown).
local GUID_SPAWN_DEDUP_RADIUS = 4.0
local function _GetSpawnDedupRadius()
local settings = _GetLearnerSettings()
local r = settings and tonumber(settings.spawnDedupRadius)
if r and r >= 0 then
return r
end
return GUID_SPAWN_DEDUP_RADIUS
end
local function _BuildSpawnTableFromGuidEvidence(evidence)
if type(evidence) ~= "table" then
return nil
end
local radius = _GetSpawnDedupRadius()
local radiusSq = radius * radius
local spawns = {}
local hasEntries = false
for _, entry in pairs(evidence) do
@@ -250,7 +308,9 @@ local function _BuildSpawnTableFromGuidEvidence(evidence)
local zoneSpawns = spawns[zoneId]
local exists = false
for _, coord in ipairs(zoneSpawns) do
if coord[1] == x and coord[2] == y then
local dx = coord[1] - x
local dy = coord[2] - y
if (dx * dx + dy * dy) <= radiusSq then
exists = true
break
end
@@ -604,12 +664,14 @@ function QuestieDB:GetObject(objectId)
local rawdata
local override
if mode == "learner" or QuestieDB:IsBaseDatabaseMissing() then
if mode == "learner" or QuestieDB:IsStoreMissing("objectData") then
rawdata = learnerRecord
override = nil
else
rawdata = QuestieDB.QueryObject(objectId, QuestieDB._objectAdapterQueryOrder)
if not rawdata and learnerRecord then
if not rawdata and learnerRecord and mode == "auto" then
-- Only "auto" overlays learner data on top of the static DB. "static"
-- and "none" must never silently fall back to learner records.
rawdata = learnerRecord
end
override = QuestieDB.objectDataOverrides and (QuestieDB.objectDataOverrides[objectId] or QuestieDB.objectDataOverrides[tostring(objectId)])
@@ -660,12 +722,14 @@ function QuestieDB:GetItem(itemId)
local learnerRecord = _GetLearnerRecord("items", itemId)
local rawdata
local override
if mode == "learner" or QuestieDB:IsBaseDatabaseMissing() then
if mode == "learner" or QuestieDB:IsStoreMissing("itemData") then
rawdata = learnerRecord
override = nil
else
rawdata = QuestieDB.QueryItem(itemId, QuestieDB._itemAdapterQueryOrder)
if not rawdata and learnerRecord then
if not rawdata and learnerRecord and mode == "auto" then
-- Only "auto" overlays learner data on top of the static DB. "static"
-- and "none" must never silently fall back to learner records.
rawdata = learnerRecord
end
override = QuestieDB.itemDataOverrides and (QuestieDB.itemDataOverrides[itemId] or QuestieDB.itemDataOverrides[tostring(itemId)])
@@ -1542,12 +1606,14 @@ function QuestieDB.GetQuest(questId, ...) -- /dump QuestieDB.GetQuest(867)
local learnerRecord = _GetLearnerRecord("quests", questId)
local rawdata
local overrideData
if mode == "learner" or QuestieDB:IsBaseDatabaseMissing() then
if mode == "learner" or QuestieDB:IsStoreMissing("questData") then
rawdata = learnerRecord
overrideData = nil
else
rawdata = QuestieDB.QueryQuest(questId, QuestieDB._questAdapterQueryOrder)
if not rawdata and learnerRecord then
if not rawdata and learnerRecord and mode == "auto" then
-- Only "auto" overlays learner data on top of the static DB. "static"
-- and "none" must never silently fall back to learner records.
rawdata = learnerRecord
end
overrideData = QuestieDB.questDataOverrides and (QuestieDB.questDataOverrides[questId] or QuestieDB.questDataOverrides[tostring(questId)])
@@ -2155,12 +2221,14 @@ function QuestieDB:GetNPC(npcId)
local learnerRecord = _GetLearnerRecord("npcs", npcId)
local rawdata
local override
if mode == "learner" or QuestieDB:IsBaseDatabaseMissing() then
if mode == "learner" or QuestieDB:IsStoreMissing("npcData") then
rawdata = learnerRecord
override = nil
else
rawdata = QuestieDB.QueryNPC(npcId, QuestieDB._npcAdapterQueryOrder)
if not rawdata and learnerRecord then
if not rawdata and learnerRecord and mode == "auto" then
-- Only "auto" overlays learner data on top of the static DB. "static"
-- and "none" must never silently fall back to learner records.
rawdata = learnerRecord
end
override = QuestieDB.npcDataOverrides and (QuestieDB.npcDataOverrides[npcId] or QuestieDB.npcDataOverrides[tostring(npcId)])
+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
+166
View File
@@ -188,6 +188,78 @@ describe("QuestieDB learner source fallback", function()
assert.equals(27.25, npc.spawns[44][1][2])
end)
it("collapses many nearby kills (distinct GUIDs) into one spawn pin", function()
-- Five kills of respawns at the same spot: distinct GUID keys, slightly
-- drifting player coords. This must render as ONE pin, not five.
Questie.dbLearner.global.npcs[9005] = {
[1] = "Respawning Boar",
[8] = {
[201] = { zoneId = 44, x = 50.0, y = 50.0 },
[202] = { zoneId = 44, x = 50.4, y = 50.3 },
[203] = { zoneId = 44, x = 49.7, y = 50.6 },
[204] = { zoneId = 44, x = 50.9, y = 49.8 },
[205] = { zoneId = 44, x = 50.2, y = 50.1 },
},
}
local npc = QuestieDB:GetNPC(9005)
assert.is_table(npc)
assert.is_table(npc.spawns[44])
assert.equals(1, #npc.spawns[44])
end)
it("keeps genuinely separate spawn locations as distinct pins", function()
Questie.dbLearner.global.npcs[9006] = {
[1] = "Field Boars",
[8] = {
[301] = { zoneId = 44, x = 20.0, y = 20.0 },
[302] = { zoneId = 44, x = 20.3, y = 20.2 }, -- same spot as 301
[303] = { zoneId = 44, x = 70.0, y = 65.0 }, -- far corner
},
}
local npc = QuestieDB:GetNPC(9006)
assert.is_table(npc)
assert.is_table(npc.spawns[44])
assert.equals(2, #npc.spawns[44])
end)
it("honors the spawn dedup radius knob (0 disables proximity merge)", function()
Questie.dbLearner.global.settings.spawnDedupRadius = 0
Questie.dbLearner.global.npcs[9007] = {
[1] = "Drifting Kills",
[8] = {
[401] = { zoneId = 44, x = 50.0, y = 50.0 },
[402] = { zoneId = 44, x = 50.4, y = 50.3 },
[403] = { zoneId = 44, x = 49.7, y = 50.6 },
[404] = { zoneId = 44, x = 50.9, y = 49.8 },
[405] = { zoneId = 44, x = 50.2, y = 50.1 },
},
}
local npc = QuestieDB:GetNPC(9007)
assert.is_table(npc)
assert.is_table(npc.spawns[44])
-- With merging off, each distinct kill coordinate stays its own pin.
assert.equals(5, #npc.spawns[44])
end)
it("widens merging when the dedup radius is increased", function()
Questie.dbLearner.global.settings.spawnDedupRadius = 12
Questie.dbLearner.global.npcs[9008] = {
[1] = "Loose Cluster",
[8] = {
[501] = { zoneId = 44, x = 40.0, y = 40.0 },
[502] = { zoneId = 44, x = 48.0, y = 46.0 }, -- ~10 away: merges at radius 12
},
}
local npc = QuestieDB:GetNPC(9008)
assert.is_table(npc)
assert.is_table(npc.spawns[44])
assert.equals(1, #npc.spawns[44])
end)
it("returns learner object data when static queries are unavailable", function()
local obj = QuestieDB:GetObject(9002)
assert.is_table(obj)
@@ -196,3 +268,97 @@ describe("QuestieDB learner source fallback", function()
assert.equals(44, obj.zoneID)
end)
end)
describe("QuestieDB partial base DB missing", function()
before_each(function()
dofile("Tests/wow_api_mock.lua")
dofile("Database/QuestieDB.lua")
end)
it("does not report the base DB missing when only one store failed", function()
QuestieDB.baseDatabaseMissing = true
QuestieDB.baseDatabaseMissingKeys = { itemData = true }
assert.is_false(QuestieDB:IsBaseDatabaseMissing())
assert.is_true(QuestieDB:IsStoreMissing("itemData"))
assert.is_false(QuestieDB:IsStoreMissing("npcData"))
end)
it("reports the base DB missing only when every core store failed", function()
QuestieDB.baseDatabaseMissing = true
QuestieDB.baseDatabaseMissingKeys = {
npcData = true, objectData = true, questData = true, itemData = true,
}
assert.is_true(QuestieDB:IsBaseDatabaseMissing())
end)
it("honors the static selection for a present store even when another is missing", function()
QuestieDB.baseDatabaseMissing = true
QuestieDB.baseDatabaseMissingKeys = { itemData = true }
Questie.dbLearner.global.settings.dataSourceMode = "static"
Questie.dbLearner.global.npcs = {
[9100] = { [1] = "Should Not Win", [7] = { [44] = { { 1, 2 } } }, [9] = 44 },
}
QuestieDB.private.npcCache = {}
local queried = false
QuestieDB.QueryNPC = function() queried = true; return nil end
pcall(function() QuestieDB:GetNPC(9100) end)
assert.is_true(queried)
end)
end)
describe("QuestieDB mode cohesion", function()
before_each(function()
dofile("Tests/wow_api_mock.lua")
dofile("Database/QuestieDB.lua")
dofile("Database/npcDB.lua")
Questie.dbLearner.global.settings.enabled = true
Questie.dbLearner.global.npcs = {
[9200] = { [1] = "Learner Only NPC", [7] = { [44] = { { 5, 6 } } }, [9] = 44 },
}
QuestieDB.QueryNPC = function() return nil end
QuestieDB.baseDatabaseMissing = false
QuestieDB.baseDatabaseMissingKeys = {}
QuestieDB.private.npcCache = {}
end)
it("does NOT leak learner data into static mode when the store is present", function()
Questie.dbLearner.global.settings.dataSourceMode = "static"
QuestieDB.private.npcCache = {}
assert.is_nil(QuestieDB:GetNPC(9200))
end)
it("does NOT leak learner data into none mode when the store is present", function()
Questie.dbLearner.global.settings.dataSourceMode = "none"
QuestieDB.private.npcCache = {}
assert.is_nil(QuestieDB:GetNPC(9200))
end)
it("DOES overlay learner data in auto mode when the static DB lacks it", function()
Questie.dbLearner.global.settings.dataSourceMode = "auto"
QuestieDB.private.npcCache = {}
local npc = QuestieDB:GetNPC(9200)
assert.is_table(npc)
assert.equals("Learner Only NPC", npc.name)
end)
it("clears every cache including the zone cache on mode switch", function()
QuestieDB.private.questCache[1] = {}
QuestieDB.private.zoneCache[1] = {}
QuestieDB:ClearModeCaches()
assert.is_nil(QuestieDB.private.questCache[1])
assert.is_nil(QuestieDB.private.zoneCache[1])
end)
end)
describe("QuestieLearner mode switch redraw wiring", function()
it("drives a full real-time refresh via SmoothReset, not the mis-named event handler", function()
local function read(path)
local f = assert(io.open(path, "r")); local c = f:read("*a"); f:close(); return c
end
local dbOptions = read("Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua")
assert.is_true(string.find(dbOptions, "QuestieQuest:SmoothReset()", 1, true) ~= nil)
-- The old import name resolved to nil and silently skipped the redraw.
assert.is_nil(string.find(dbOptions, "ImportModule(\"QuestieEventHandler\")", 1, true))
end)
end)
+36
View File
@@ -105,6 +105,42 @@ describe("QuestieLearner kill-path batching", function()
assert.is_true(table.getn(queuedTimers) >= 2)
end)
it("force-flushes active quest pins within the NPC live-update flush (no second debounce)", function()
local updateCount = 0
local originalUpdateQuest = QuestieQuest.UpdateQuest
QuestieQuest.UpdateQuest = function(_, questId)
updateCount = updateCount + 1
end
QuestiePlayer.currentQuestlog = { [5500] = true }
local originalGetQuest = QuestieDB.GetQuest
QuestieDB.GetQuest = function(questId)
if questId == 5500 then
return { Objectives = { [1] = { Id = 7500, spawnList = { [7500] = {} } } } }
end
return nil
end
QuestieLearner:LearnNPC(7500, "Quest Boar", nil, nil, nil, nil, 41.0, 52.0, 44)
-- Drain exactly ONE timer round (the NPC live-update flush). The pin refresh
-- must happen inside that same flush, not in a later pinRefreshDelay cycle.
local firstRound = queuedTimers
queuedTimers = {}
for i = 1, table.getn(firstRound) do
simulatedTime = simulatedTime + 1
firstRound[i]()
end
assert.is_true(updateCount >= 1)
-- And draining the rest must terminate (no infinite self-re-arming timer).
drainQueuedTimers()
QuestieDB.GetQuest = originalGetQuest
QuestieQuest.UpdateQuest = originalUpdateQuest
end)
it("coalesces repeated quest-pin refreshes into one flush", function()
local updateCount = 0
local originalUpdateQuest = QuestieQuest.UpdateQuest
+4
View File
@@ -188,6 +188,10 @@
<li><strong>[Arrow &mdash; Low-End Performance Controls]</strong> Added live Arrow update throttles to reduce repeated nearest-target and coordinate work while preserving existing arrow behavior.</li>
<li><strong>[Measured Hot Paths &mdash; Phase 3]</strong> Landed measured optimizations on the phase 3 branch for literal localization caching, available quest redraw batching, <code>QuestieDB.IsDoable</code> batch reads, hot profile aliases, <code>GetTime()</code> hoists, NPC fallback lookup caching, and validate-cache allocation cleanup.</li>
<li><strong>[QuestieQuest &mdash; Unavailable Quest Guard]</strong> Guarded the available-quest draw thread so unresolved quest IDs are skipped safely instead of crashing the thread, and deduped the skip log so the same missing quest does not spam every redraw.</li>
<li><strong>[QuestieLearner &mdash; Data Source Mode Cohesion]</strong> Reworked the Auto / Learner / Static / Neither data-source modes so switching between them applies live and consistently. A single missing static sub-table (npc/object/quest/item) no longer locks the whole addon into learner mode &mdash; only the genuinely-missing store falls back. Static and Neither no longer silently fall back to learner records, switching modes now clears the per-zone quest cache, and the mode switch drives a full pin/tracker redraw through <code>QuestieQuest:SmoothReset()</code> (the previous redraw call imported a mis-named module and silently did nothing).</li>
<li><strong>[QuestieLearner &mdash; Pin Refresh Latency]</strong> Collapsed a redundant second debounce stage on the live-learn pin-refresh path. Newly learned spawns now redraw within a single debounce window instead of waiting out both the NPC live-update delay and a separate pin-refresh delay, roughly halving perceived pin-update latency in the Balanced and Low presets. Also fixed a latent infinite timer re-arm that could occur once the pin flush was triggered directly.</li>
<li><strong>[Map &mdash; Dense Pin Clustering Aggressiveness Knob]</strong> Re-implemented density-adaptive clustering for crowded kill objectives, now controlled by a new <em>Dense pin clustering aggressiveness</em> slider on the Advanced tab (0 = show every pin, higher = tighter consolidation where many pins share a zone). Coincident pins are always deduplicated regardless of the clustering settings, and the intentional per-zone (Sunstrider Isle) and object-icon range overrides are preserved.</li>
<li><strong>[QuestieLearner &mdash; One Pin Per Spawn, Not Per Kill]</strong> Fixed learner kill evidence rendering a separate pin for every kill. Because kill coordinates are the player's position at kill time (and respawns carry fresh GUIDs), repeated kills at the same spot drifted just enough to dodge the exact-match dedup. The immediate learner-mode spawn builder now merges evidence within a small radius into one pin per physical spawn, and the weighted spawn-evidence merge groups kills by coordinate bucket so a single spot can actually accumulate enough evidence to clear the confidence threshold. The merge distance is exposed as a new <em>Spawn Pin Dedup Radius</em> slider on the Advanced tab (0 = show every distinct position, higher = fewer/tighter pins per spawn) which redraws live.</li>
<li><strong>[Error Suppression &mdash; Debug Modes]</strong> Moved missing quest and other non-fatal database/error spam out of normal chat output and into Questie debug-critical/developer output. Fatal startup failures remain loud.</li>
<li><strong>[Tooltip Data Precedence]</strong> Updated tooltip handling so QuestieLearner defers to AscensionDB-owned tooltip/objective data instead of hiding or replacing server-plugin data for active quests.</li>
</ul>
@@ -6027,3 +6027,129 @@ Learner kill evidence now turns into visible spawn coordinates quickly enough
to spawn pins in learner mode, and the available quest scanner now fails
closed when a quest record is missing instead of flooding chat or crashing the
draw thread.
---
# 2026-06-05 — Data Source Mode Cohesion, Pin Refresh Latency, and Pin Clustering Knob
## Summary
Three related areas were addressed after the immediate-spawn work above:
1. Switching the Data Source Mode (Auto / Learner / Static / Neither) did not
reliably take effect — including a case where it could never switch back to
the static database even across `/reload`.
2. Newly learned spawns were slow to redraw pins because two trailing-debounce
stages were stacked on the live-learn path.
3. The density-adaptive pin clustering that was previously commented out needed
re-implementing as a user-tunable knob, plus a guaranteed coincident-pin
deduplication baseline.
## Root causes
- **Mode lock-in.** `QuestieDB.baseDatabaseMissing` was a single global flag set
to `true` when ANY one of the four core stores (`npcData`, `objectData`,
`questData`, `itemData`) failed to load. Both the per-read DB functions and
`GetDataSourceMode()` force learner mode whenever `IsBaseDatabaseMissing()` is
true, so a single missing/format-mismatched sub-table pinned the entire addon
to learner data and survived `/reload` (the flag is recomputed identically at
load).
- **Learner leak into Static/Neither.** The per-read fallback
(`if not rawdata and learnerRecord then rawdata = learnerRecord`) ran in the
`else` branch for every non-learner mode, so Static and Neither silently used
learner records when the static DB lacked an entry.
- **Dead redraw on mode switch.** `ApplyLearnerMode` imported
`"QuestieEventHandler"` (the module is registered as `"QuestEventHandler"`) and
called `UpdateAllQuests`, which lives on the private table — so the import
returned nil and available-quest pins were never redrawn on a mode switch.
- **Stale zone cache.** `ApplyDataSourceMode` cleared the quest/npc/item/object
caches but not `zoneCache`, so per-zone quest results lingered after a switch.
- **Stacked pin-refresh debounce.** A learned kill flowed through
`liveNpcUpdateDelay` (NPC live-update flush) and THEN a separate
`pinRefreshDelay` gate before `UpdateQuest`. Because the only caller of the
pin-invalidation path is the already-debounced NPC flush, the second stage was
pure added latency (~1.5s Balanced, ~4s Low), with two independent
`pinRefreshMaxWait` caps under sustained kills.
## What changed
- `Database/QuestieDB.lua`
- `IsBaseDatabaseMissing()` now reports missing only when ALL four core stores
failed; added `IsStoreMissing(storeKey)` for per-store checks.
- Each read (`GetNPC` / `GetQuest` / `GetItem` / `GetObject`) gates force-learner
on its own store and only overlays a learner-record fallback in Auto mode.
- Added `ClearModeCaches()` which clears quest/item/npc/object AND zone caches.
- `Modules/QuestieLearner.lua`
- `ApplyDataSourceMode` now calls `ClearModeCaches()`.
- Split the active-quest pin flush into a debounce-gate wrapper plus a
`_DoFlushActiveQuestPins` body. The NPC live-update flush now suppresses the
redundant second debounce and force-flushes pins once, immediately.
- Hardened the flush gate so it only defers while there is genuine pending
activity (fixes an infinite timer re-arm exposed by the direct flush).
- `Modules/Options/DatabaseTab/QuestieOptionsDatabase.lua`
- Mode switches now refresh through `QuestieQuest:SmoothReset()` (clears notes
and tooltips, recalculates and redraws available quests, re-updates active
quests, refreshes the tracker).
- `Modules/Quest/QuestieQuest.lua`
- Re-implemented density-adaptive clustering, scaled by a new
`clusterDensityAggressiveness` profile knob. The intentional per-zone
(Sunstrider Isle, uiMapID 1241, range 0) and object-icon (`range * 0.2`)
overrides are preserved and still take precedence.
- `Modules/Map/QuestieMapUtils.lua`
- `CalcHotzones` now always merges coincident pins
(within `COINCIDENT_EPSILON`), independent of the clustering range, so two
icons never stack on the same spot even when clustering is disabled.
- `Modules/Options/QuestieOptionsDefaults.lua`,
`Modules/Options/AdvancedTab/QuestieOptionsAdvanced.lua`
- Added the `clusterDensityAggressiveness` default (35) and a real-time
Advanced-tab slider that redraws via `QuestieOptions:ClusterRedraw`.
- Tests
- `Tests/QuestieLearnerDataSourceMode_spec.lua`: partial-missing store handling,
Static/Neither no-leak, Auto overlay, full cache clear, SmoothReset wiring.
- `Tests/QuestieLearner_performance_spec.lua`: pins force-flush within a single
NPC live-update round and draining terminates (infinite-loop guard).
- `Tests/QuestiePinClustering_spec.lua` (new): coincident dedup, distinct-pin
preservation at range 0, range-based clustering, cross-map isolation, and
knob wiring.
## Verification
- `luac5.1 -p` on every changed Lua file.
- `busted` full suite: 100 successes / 4 failures (the 4 failures are the
pre-existing, unrelated `QuestieArrowAssets_spec` asset checks).
## Result
All four data-source modes now switch live and cohesively (and partial static
DB failures no longer trap the addon in learner mode). Newly learned spawns
redraw within a single debounce window. Dense kill objectives can be
consolidated to taste via the new aggressiveness knob, while coincident pins are
always deduplicated and the curated per-zone/object overrides remain intact.
### Follow-up — learner pin-per-kill fix
Learner kill evidence was rendering one pin per kill. Kill coordinates come from
the player's position at kill time and respawns carry fresh GUIDs, so repeated
kills at the same spawn drifted just enough to defeat the exact-match dedup in
`_BuildSpawnTableFromGuidEvidence` (`Database/QuestieDB.lua`) and the
full-precision grouping key in `_MergeSpawnEvidence` (`Modules/QuestieLearner.lua`).
- `_BuildSpawnTableFromGuidEvidence` now merges evidence coords within a small
radius (distance test, not a grid bucket — avoids the cell-boundary split where
two near-identical coords land in different buckets) into one pin per spawn.
- `_MergeSpawnEvidence` now groups kills by a per-zone coordinate bucket
(`GetCoordGridForZone`) instead of exact coords, so one location accumulates
enough evidence to clear the >60% confidence threshold instead of every kill
registering as its own single-count "location".
- The `[7]` spawn path already deduped via `InsertIfNewBucket`; only the GUID
evidence (`[8]`) consumers needed the fix.
- The merge radius is user-tunable via the new `spawnDedupRadius` learner setting
(default 4.0, map-percent) and a "Spawn Pin Dedup Radius" slider on the
Advanced tab. `_BuildSpawnTableFromGuidEvidence` reads it through
`_GetSpawnDedupRadius` (0 = exact-match only / every distinct position shown);
changing it clears the NPC spawn cache and triggers a live redraw.
- Regressions added in `Tests/QuestieLearnerDataSourceMode_spec.lua`: many nearby
kills collapse to one pin, genuinely separate spawns stay distinct, radius 0
disables proximity merge, and a larger radius widens merging.
`Tests/QuestiePinClustering_spec.lua` asserts the knob wiring (default + UI +
cache-clear redraw).