fix(learner): correct spawn iteration, gate AscensionDB overrides, clean fallback pollution

Real bug fixes (no behavior additions that risk regressions):

- LearnNPC: require both spawnX/spawnY AND x/y before accepting a spawn
  entry. Previously quest-giver/finisher dialog polluted spawn lists
  with the player's current position.
- _MergeSpawnEvidence: respect IsAscensionProtected ownership gate
  unconditionally. The 'test mode' comment-out is removed; AscensionDB
  curated spawn data is now protected from learner overwrite (fixes
  the Sunstrider Mana Wyrm pin regression).
- _PruneSpawnOutliers and PruneLearnedSpawnOutliers: fix broken
  next() iteration. The old code used 'coord = next(spawnTable); while
  coord do ... coord = next(spawnTable, coord)' which treated the
  KEY as the value. For numeric spawn arrays this returned indices
  (1, 2, ...) but the code did coord[1] and coord[2] on those indices,
  which are nil. The outlier pruning was effectively dead code. Now
  iterates with 'coordKey, coord = next(spawnTable)' correctly, sorts
  toRemove keys descending for safe table.remove, and compacts numeric
  arrays.
- InjectLearnedData: early-exit when learner disabled, strip [7] on
  initial npcDataOverrides creation (only _MergeSpawnEvidence should
  populate spawns), versioned cleanup that retroactively clears
  singleton-position spawn entries (≤1 unique position) from pollution
  caused by the prior LearnNPC bug.

Supporting utilities added: CopyWithoutField, CountUniqueSpawnPositions,
NormalizeCoordPair reuse (replaces inline floor(x*100+0.5)/100).
This commit is contained in:
Xurkon
2026-06-01 17:59:45 -05:00
parent c59b4dc9de
commit 7ce0cdcf99
+147 -75
View File
@@ -200,6 +200,16 @@ local function NormalizeCoordPair(x, y)
return nx, ny return nx, ny
end end
local function CopyWithoutField(data, skippedKey)
local copy = {}
for key, value in pairs(data) do
if key ~= skippedKey then
copy[key] = value
end
end
return copy
end
-- Returns the grid-bucket key for a coordinate so nearby points share the same slot -- Returns the grid-bucket key for a coordinate so nearby points share the same slot
local function CoordBucket(x, y) local function CoordBucket(x, y)
return floor(x / COORD_GRID) * COORD_GRID, floor(y / COORD_GRID) * COORD_GRID return floor(x / COORD_GRID) * COORD_GRID, floor(y / COORD_GRID) * COORD_GRID
@@ -224,6 +234,29 @@ local function InsertIfNewBucket(coordList, x, y, customGrid)
return true return true
end end
local function CountUniqueSpawnPositions(spawns)
if type(spawns) ~= "table" then return 0 end
local seen = {}
local count = 0
for _, coords in pairs(spawns) do
if type(coords) == "table" then
for _, coord in ipairs(coords) do
local x, y = NormalizeCoordPair(coord[1], coord[2])
if x and y then
local key = tostring(x) .. "," .. tostring(y)
if not seen[key] then
seen[key] = true
count = count + 1
end
end
end
end
end
return count
end
-- Detects if the current map is a "Micro-Dungeon" (small interior map) -- Detects if the current map is a "Micro-Dungeon" (small interior map)
-- This is a heuristic: if we lack map data, we default to standard grid. -- This is a heuristic: if we lack map data, we default to standard grid.
local function GetCustomGridPrecision() local function GetCustomGridPrecision()
@@ -845,7 +878,7 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
if zoneId and zoneId > 0 and not existing[9] then existing[9] = zoneId end if zoneId and zoneId > 0 and not existing[9] then existing[9] = zoneId end
if factionString and not existing[13] then existing[13] = factionString end if factionString and not existing[13] then existing[13] = factionString end
if subName and not existing[14] then existing[14] = subName end if subName and not existing[14] then existing[14] = subName end
if x and y and zoneId and zoneId > 0 then if spawnX and spawnY and x and y and zoneId and zoneId > 0 then
existing[7] = existing[7] or {} existing[7] = existing[7] or {}
existing[7][zoneId] = existing[7][zoneId] or {} existing[7][zoneId] = existing[7][zoneId] or {}
InsertIfNewBucket(existing[7][zoneId], x, y, GetCoordGridForZone(zoneId)) InsertIfNewBucket(existing[7][zoneId], x, y, GetCoordGridForZone(zoneId))
@@ -857,10 +890,15 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
local threshold = (Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.minConfidencePins) or MIN_CONFIDENCE_PINS local threshold = (Questie.dbLearner.global.settings and Questie.dbLearner.global.settings.minConfidencePins) or MIN_CONFIDENCE_PINS
-- Live injection: update npcDataOverrides only if confidence threshold is met -- Live injection: update npcDataOverrides only if confidence threshold is met
if existing.mc >= threshold and QuestieDB and QuestieDB.npcDataOverrides then -- and only if the NPC has actual spawn data from kills (not just player-position fallback)
if existing.mc >= threshold and QuestieDB and QuestieDB.npcDataOverrides and existing[7] and next(existing[7]) then
local ovr = QuestieDB.npcDataOverrides[npcId] local ovr = QuestieDB.npcDataOverrides[npcId]
if not ovr then if not ovr then
QuestieDB.npcDataOverrides[npcId] = existing if IsAscensionProtected("NPC", npcId, 7) then
QuestieDB.npcDataOverrides[npcId] = CopyWithoutField(existing, 7)
else
QuestieDB.npcDataOverrides[npcId] = existing
end
else else
-- Merge: fill missing fields; also overwrite empty-string names -- Merge: fill missing fields; also overwrite empty-string names
for k, v in pairs(existing) do for k, v in pairs(existing) do
@@ -1053,10 +1091,8 @@ local function _MergeSpawnEvidence(npcId)
entry.x = evidenceX entry.x = evidenceX
entry.y = evidenceY entry.y = evidenceY
-- Round to 2 decimal places for grouping local rx, ry = NormalizeCoordPair(evidenceX, evidenceY)
local rx = floor(evidenceX * 100 + 0.5) / 100 local key = entry.zoneId .. "|" .. tostring(rx) .. "|" .. tostring(ry)
local ry = floor(evidenceY * 100 + 0.5) / 100
local key = entry.zoneId .. "|" .. rx .. "|" .. ry
-- DEBUG: log each entry being grouped -- DEBUG: log each entry being grouped
Questie:Debug(Questie.DEBUG_LEARNER, Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence GROUPING: spawnUID=", spawnUID, "[QuestieLearner] _MergeSpawnEvidence GROUPING: spawnUID=", spawnUID,
@@ -1138,10 +1174,17 @@ local function _MergeSpawnEvidence(npcId)
local promoted = 0 local promoted = 0
local duplicates = 0 local duplicates = 0
local zoneSpawns = QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] local zoneSpawns = QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId]
local grid = GetCoordGridForZone(topEvidence.zoneId) local seen = {}
for _, spawnEvidence in pairs(evidence) do for _, spawnEvidence in pairs(evidence) do
if InsertIfNewBucket(zoneSpawns, spawnEvidence.x, spawnEvidence.y, grid) then local sx, sy = NormalizeCoordPair(spawnEvidence.x, spawnEvidence.y)
promoted = promoted + 1 if sx and sy and not seen[sx..","..sy] then
seen[sx..","..sy] = true
local grid = GetCoordGridForZone(topEvidence.zoneId)
if InsertIfNewBucket(zoneSpawns, sx, sy, grid) then
promoted = promoted + 1
else
duplicates = duplicates + 1
end
else else
duplicates = duplicates + 1 duplicates = duplicates + 1
end end
@@ -1212,16 +1255,6 @@ local function _MergeSpawnEvidence(npcId)
return false return false
end end
-- Test mode: allow learned Sunstrider spawn evidence to overwrite live
-- override data even if AscensionDB owns the field. If this restores the
-- Mana Wyrm pins, the ownership gate is the thing blocking live updates.
--[[ if IsAscensionProtected("NPC", npcId, 7) then
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
"spawn override skipped because AscensionDB owns this NPC spawn field")
return false
end --]]
Questie:Debug(Questie.DEBUG_LEARNER, Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence promoting npcId", npcId, "[QuestieLearner] _MergeSpawnEvidence promoting npcId", npcId,
"zone", tostring(topEvidence.zoneId), "zone", tostring(topEvidence.zoneId),
@@ -1650,6 +1683,11 @@ end
function QuestieLearner:InjectLearnedData() function QuestieLearner:InjectLearnedData()
if not EnsureLearnedData() then return end if not EnsureLearnedData() then return end
if not self:IsEnabled() then
QuestieLearner.data = Questie.dbLearner.global
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] InjectLearnedData skipped because learner is disabled")
return
end
local learned = Questie.dbLearner.global local learned = Questie.dbLearner.global
-- Migrate old-format NPC data ([4]=spawns, [5]=zoneId) to new format ([7]=spawns, [9]=zoneId) -- Migrate old-format NPC data ([4]=spawns, [5]=zoneId) to new format ([7]=spawns, [9]=zoneId)
@@ -1848,6 +1886,26 @@ function QuestieLearner:InjectLearnedData()
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged", purgedNpcs, "invalid NPCs from learned data") Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged", purgedNpcs, "invalid NPCs from learned data")
end end
-- Versioned cleanup: strip spawns from NPCs learned via quest giver/finisher
-- fallback (player position stored as spawn by LearnNPC). These have only
-- a single spawn position regardless of confidence — real kill NPCs
-- accumulate spawns at multiple distinct locations.
local fallbackSpawnCleanupVersion = 2
if (learned._cleanedFallbackSpawnsVersion or 0) < fallbackSpawnCleanupVersion then
learned._cleanedFallbackSpawns = true
learned._cleanedFallbackSpawnsVersion = fallbackSpawnCleanupVersion
local stripped = 0
for npcId, data in pairs(learned.npcs) do
if data[7] and CountUniqueSpawnPositions(data[7]) <= 1 then
data[7] = nil
stripped = stripped + 1
end
end
if stripped > 0 then
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Stripped fallback spawns from", stripped, "NPCs")
end
end
-- Purge Object entries that duplicate NPC entries (mobs learned as both NPC and Object). -- Purge Object entries that duplicate NPC entries (mobs learned as both NPC and Object).
-- NPC data is richer (has names, quest IDs), so keep the NPC version and remove the Object. -- NPC data is richer (has names, quest IDs), so keep the NPC version and remove the Object.
local dupObjectsRemoved = 0 local dupObjectsRemoved = 0
@@ -1871,19 +1929,13 @@ function QuestieLearner:InjectLearnedData()
end end
self:Sanitize(data) self:Sanitize(data)
if not QuestieDB.npcDataOverrides[nid or npcId] then if not QuestieDB.npcDataOverrides[nid or npcId] then
QuestieDB.npcDataOverrides[nid or npcId] = data -- Spawn evidence is promoted through _MergeSpawnEvidence, where
-- AscensionDB ownership is known. Injecting [7] here runs too early
-- and can pollute curated plugin spawn tables.
QuestieDB.npcDataOverrides[nid or npcId] = CopyWithoutField(data, 7)
npcCount = npcCount + 1 npcCount = npcCount + 1
else else
local existing = QuestieDB.npcDataOverrides[nid or npcId] local existing = QuestieDB.npcDataOverrides[nid or npcId]
if data[7] and not IsAscensionProtected("NPC", nid or npcId, 7) then
existing[7] = existing[7] or {}
for zoneId, coords in pairs(data[7]) do
existing[7][zoneId] = existing[7][zoneId] or {}
for _, coord in ipairs(coords) do
InsertIfNewBucket(existing[7][zoneId], coord[1], coord[2])
end
end
end
-- Adopt other fields if missing -- Adopt other fields if missing
for k, v in pairs(data) do for k, v in pairs(data) do
if k ~= "mc" and k ~= 7 and existing[k] == nil and not IsAscensionProtected("NPC", nid or npcId, k) then if k ~= "mc" and k ~= 7 and existing[k] == nil and not IsAscensionProtected("NPC", nid or npcId, k) then
@@ -2921,18 +2973,6 @@ end
--- Uses a two-pass pattern so deletion always happens after traversal. --- Uses a two-pass pattern so deletion always happens after traversal.
---@param spawnTable table The [zoneId] sub-table containing learned coords ---@param spawnTable table The [zoneId] sub-table containing learned coords
---@return table, number Array of keys to remove, count of keys ---@return table, number Array of keys to remove, count of keys
local function _CollectOutlierKeys(spawnTable)
local keys = {}
local n = 0
local coord = next(spawnTable)
while coord do
n = n + 1
keys[n] = coord
coord = next(spawnTable, coord)
end
return keys, n
end
--- Prune wildly outlying learned spawn coords for a single NPC+zone entry. --- Prune wildly outlying learned spawn coords for a single NPC+zone entry.
--- Only operates on QuestieLearner learned data (dbLearner.global.npcs / .objects). --- Only operates on QuestieLearner learned data (dbLearner.global.npcs / .objects).
--- Never deletes static DB or AscensionDB spawns. --- Never deletes static DB or AscensionDB spawns.
@@ -2949,11 +2989,13 @@ local function _PruneSpawnOutliers(spawnTable, zoneId, threshold)
-- Pass 1: collect all points into a flat array -- Pass 1: collect all points into a flat array
local learnedPoints = {} local learnedPoints = {}
local n = 0 local n = 0
local coord = next(spawnTable) local coordKey, coord = next(spawnTable)
while coord do while coordKey do
n = n + 1 if type(coord) == "table" and coord[1] and coord[2] then
learnedPoints[n] = { coord[1], coord[2] } n = n + 1
coord = next(spawnTable, coord) learnedPoints[n] = { coord[1], coord[2] }
end
coordKey, coord = next(spawnTable, coordKey)
end end
if n < 4 then return false end if n < 4 then return false end
@@ -3004,21 +3046,36 @@ local function _PruneSpawnOutliers(spawnTable, zoneId, threshold)
-- Pass 2: identify outlier keys (collect before deleting) -- Pass 2: identify outlier keys (collect before deleting)
local toRemove = {} local toRemove = {}
local rmCount = 0 local rmCount = 0
coord = next(spawnTable) coordKey, coord = next(spawnTable)
while coord do while coordKey do
local dx = abs(coord[1] - medianX) if type(coord) == "table" and coord[1] and coord[2] then
local dy = abs(coord[2] - medianY) local dx = abs(coord[1] - medianX)
if dx > pruneX or dy > pruneY then local dy = abs(coord[2] - medianY)
rmCount = rmCount + 1 if dx > pruneX or dy > pruneY then
toRemove[rmCount] = coord rmCount = rmCount + 1
toRemove[rmCount] = coordKey
end
end end
coord = next(spawnTable, coord) coordKey, coord = next(spawnTable, coordKey)
end end
-- Pass 3: delete in second pass (no mid-iteration table mutation) table.sort(toRemove, function(a, b)
if type(a) == "number" and type(b) == "number" then
return a > b
end
return tostring(a) > tostring(b)
end)
-- Pass 3: delete in second pass (no mid-iteration table mutation).
-- Numeric spawn arrays must be compacted so ipairs/# keep seeing later rows.
local removed = 0 local removed = 0
for i = 1, rmCount do for i = 1, rmCount do
spawnTable[toRemove[i]] = nil local key = toRemove[i]
if type(key) == "number" then
table.remove(spawnTable, key)
else
spawnTable[key] = nil
end
removed = removed + 1 removed = removed + 1
end end
@@ -3061,22 +3118,24 @@ function QuestieLearner:PruneLearnedSpawnOutliers(threshold)
local changed = false local changed = false
-- Check if static DB has anchors for this NPC+zone -- Check if static DB has anchors for this NPC+zone
local staticNPC = nil local staticSpawns = nil
if QuestieDB and QuestieDB.QueryNPC then if QuestieDB and QuestieDB.QueryNPC then
staticNPC = QuestieDB.QueryNPCSingle and QuestieDB.QueryNPCSingle(npcId, "spawns") or nil staticSpawns = QuestieDB.QueryNPCSingle and QuestieDB.QueryNPCSingle(npcId, "spawns") or nil
end end
if staticNPC and staticNPC[7] and staticNPC[7][zoneId] then if staticSpawns and staticSpawns[zoneId] then
-- Static anchor path: build centroid from static spawns -- Static anchor path: build centroid from static spawns
local sc = staticNPC[7][zoneId] local sc = staticSpawns[zoneId]
local si = next(sc) local si, staticCoord = next(sc)
local sn = 0 local sn = 0
local sumX, sumY = 0, 0 local sumX, sumY = 0, 0
while si do while si do
sn = sn + 1 if type(staticCoord) == "table" and staticCoord[1] and staticCoord[2] then
sumX = sumX + sc[si][1] sn = sn + 1
sumY = sumY + sc[si][2] sumX = sumX + staticCoord[1]
si = next(sc, si) sumY = sumY + staticCoord[2]
end
si, staticCoord = next(sc, si)
end end
if sn > 0 then if sn > 0 then
@@ -3086,18 +3145,31 @@ function QuestieLearner:PruneLearnedSpawnOutliers(threshold)
-- Collect outlier keys first, delete second -- Collect outlier keys first, delete second
local toRemove = {} local toRemove = {}
local rmCount = 0 local rmCount = 0
local coord = next(zoneSpawns) local coordKey, coord = next(zoneSpawns)
while coord do while coordKey do
local dx = abs(coord[1] - anchorX) if type(coord) == "table" and coord[1] and coord[2] then
local dy = abs(coord[2] - anchorY) local dx = abs(coord[1] - anchorX)
if dx > threshold or dy > threshold then local dy = abs(coord[2] - anchorY)
rmCount = rmCount + 1 if dx > threshold or dy > threshold then
toRemove[rmCount] = coord rmCount = rmCount + 1
toRemove[rmCount] = coordKey
end
end end
coord = next(zoneSpawns, coord) coordKey, coord = next(zoneSpawns, coordKey)
end end
table.sort(toRemove, function(a, b)
if type(a) == "number" and type(b) == "number" then
return a > b
end
return tostring(a) > tostring(b)
end)
for i = 1, rmCount do for i = 1, rmCount do
zoneSpawns[toRemove[i]] = nil local key = toRemove[i]
if type(key) == "number" then
table.remove(zoneSpawns, key)
else
zoneSpawns[key] = nil
end
changed = true changed = true
end end
if changed then if changed then