feat(learner): Phase 2 — GUID-based spawn evidence and outlier pruning

This commit is contained in:
Xurkon
2026-05-24 00:29:47 -05:00
parent 44244f504f
commit 6c121e7bc4
+460 -19
View File
@@ -31,7 +31,6 @@ local string_trim = string.trim
local string_sub = string.sub
local string_len = string.len
local string_upper = string.upper
local select = select
-- WoW API locals
local UnitExists = UnitExists
@@ -125,7 +124,9 @@ local function GetZoneId()
end
return uiMapId -- fallback: no ZoneDB mapping available
end
return select(8, GetInstanceInfo()) or 0
-- Lua 5.0 compat: replace select(8, GetInstanceInfo()) with explicit GetInstanceInfo unpack
local _, _, _, _, _, _, _, instanceMapID = GetInstanceInfo()
return instanceMapID or 0
end
local function GetPlayerCoords()
@@ -313,6 +314,8 @@ local function _GetDB() return Questie.dbLearner.global end
local function _RefreshActiveQuestPins(questIdSet)
if not QuestieQuest or not QuestieQuest.UpdateQuest then return end
if not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end
-- Skip scheduling when the set is empty (avoids no-op timer callbacks)
if not next(questIdSet) then return end
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
for questId in pairs(questIdSet) do
if QuestiePlayer.currentQuestlog[questId] then
@@ -394,9 +397,18 @@ local function _TryInvalidateObjective(objective, npcId, quest)
return shouldInvalidate
end
------------------------------------------------------------------------
-- Debounce table: tracks pending (npcId, questId) invalidate pairs.
-- Prevents scheduling multiple unload+refresh cycles for the same
-- (npcId, questId) within the debounce window.
local _invalidateDebounce = {}
local function _InvalidateSpawnListsForNPC(npcId)
if not QuestieQuest or not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
local now = (GetTime and GetTime()) or 0
local debounceWindow = 0.5 -- seconds; coalesces rapid-fire calls
local questsToRefresh = {}
for questId, _ in pairs(QuestiePlayer.currentQuestlog) do
local quest = QuestieDB.GetQuest and QuestieDB.GetQuest(questId)
@@ -419,15 +431,36 @@ local function _InvalidateSpawnListsForNPC(npcId)
end
end
if needsUnload then
-- Also purge QuestieMap's quest frame registry so no stale refs remain.
if QuestieMap and QuestieMap.UnloadQuestFrames then
QuestieMap:UnloadQuestFrames(questId)
-- Debounce frame unload only. The spawnList is always cleared by
-- _TryInvalidateObjective (runs before this), so a debounced second call
-- clears an already-nil spawnList safely. The refresh, however, must always
-- fire so the map is eventually rebuilt with the latest npcDataOverrides
-- data (already written by LearnNPC before this call).
local debounceKey = npcId .. ":" .. questId
local suppressUnload = _invalidateDebounce[debounceKey]
and (now - _invalidateDebounce[debounceKey]) < debounceWindow
if not suppressUnload then
_invalidateDebounce[debounceKey] = now
if QuestieMap and QuestieMap.UnloadQuestFrames then
QuestieMap:UnloadQuestFrames(questId)
end
end
questsToRefresh[questId] = true
questsToRefresh[questId] = true -- always refresh when data changed
end
end
end
-- _RefreshActiveQuestPins guards against empty set internally
_RefreshActiveQuestPins(questsToRefresh)
-- Prune stale debounce keys (>2x window age) to prevent unbounded growth.
-- Keys expire naturally after their window; this is just cleanup.
if timer then
for key, ts in pairs(_invalidateDebounce) do
if (now - ts) > (debounceWindow * 2) then
_invalidateDebounce[key] = nil
end
end
end
end
------------------------------------------------------------------------
@@ -790,6 +823,103 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
_Learner:BroadcastIfCommsAvailable("NPC", npcId, existing)
end
------------------------------------------------------------------------
-- Phase 2: Per-GUID spawn evidence
------------------------------------------------------------------------
-- Extracts the spawn UID (low 24 bits / last 6 hex chars) from a WoW GUID.
-- This is the per-spawn-instance identifier — different GUIDs for the same
-- npcId indicate different spawn points (e.g. three boars at three corners
-- of a field, not one boar teleporting around).
-- Format: "Creature-0-RR-RI-0-NNNNNNNN" where NNNNNNNN = spawn UID
local function _ExtractSpawnUID(guid)
if not guid or type(guid) ~= "string" then return nil end
-- Dash format: Creature-0-1234-567-89-21878-0000001234
-- Last numeric segment after the 5th dash is the spawn UID
local spawnUID = guid:match("^[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-(%d+)$")
if spawnUID then
return tonumber(spawnUID)
end
-- Fallback for other dash formats: take everything after the last dash
spawnUID = guid:match("^.+%-(%d+)$")
if spawnUID then
return tonumber(spawnUID)
end
return nil
end
-- Ensures entry[8] (guidSpawns) exists on the learned NPC data table.
-- entry[8] schema: { [spawnUID] = { zoneId, x, y, ts, source, confidence } }
local function _GetOrCreateGuidSpawnTable(entry)
if not entry then return nil end
if not entry[8] then entry[8] = {} end
return entry[8]
end
-- Stores a per-GUID kill event under the learned NPC data.
-- Each unique spawn UID (per npcId per zone) gets one evidence entry.
-- On repeated kills at the same spawn UID, the entry is updated (moved to
-- the current position if the NPC has wandered slightly, timestamp refreshed).
-- Storage is bounded: max MAX_GUID_SPAWNS_PER_NPC_PER_ZONE entries per zone;
-- oldest entry evicted when at capacity.
local MAX_GUID_SPAWNS_PER_NPC_PER_ZONE = 8
function QuestieLearner:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, x, y)
if not self:IsEnabled() then return end
if not npcId or npcId <= 0 then return end
if not dstGUID or type(dstGUID) ~= "string" then return end
if not zoneId or zoneId <= 0 then return end
if not x or not y or x <= 0 or y <= 0 then return end
local spawnUID = _ExtractSpawnUID(dstGUID)
if not spawnUID then return end
local learnedNpc = Questie.dbLearner.global.npcs[npcId]
if not learnedNpc then return end
local guidSpawns = _GetOrCreateGuidSpawnTable(learnedNpc)
if not guidSpawns then return end
-- Normalize coords: same format as recentKills so Phase 3 merge is consistent
-- Formula: floor(v * 10000) / 100 — converts normalized 0-1 to scaled 0-100
local nx = floor(x * 10000) / 100
local ny = floor(y * 10000) / 100
if guidSpawns[spawnUID] then
-- Existing spawn UID: update position and timestamp
guidSpawns[spawnUID].x = nx
guidSpawns[spawnUID].y = ny
guidSpawns[spawnUID].ts = time()
else
-- New spawn UID: insert, bounded by npcId+zoneId.
-- Evict oldest entry if at capacity.
local zoneCount = 0
local oldestUID = nil
local oldestTS = nil
for uid in pairs(guidSpawns) do
local e = guidSpawns[uid]
if e and e.zoneId == zoneId then
zoneCount = zoneCount + 1
if not oldestTS or e.ts < oldestTS then
oldestTS = e.ts
oldestUID = uid
end
end
end
if zoneCount >= MAX_GUID_SPAWNS_PER_NPC_PER_ZONE and oldestUID then
guidSpawns[oldestUID] = nil
end
guidSpawns[spawnUID] = {
zoneId = zoneId,
x = nx,
y = ny,
ts = time(),
source = "local",
confidence = 1,
}
end
end
------------------------------------------------------------------------
-- Quest learning
------------------------------------------------------------------------
@@ -2229,11 +2359,61 @@ _Learner.recentKills = _Learner.recentKills or {}
-- Previous objective counts for active quests: questId → {[idx] = count}
_Learner.prevObjCounts = _Learner.prevObjCounts or {}
function QuestieLearner:OnCombatLogEvent(...)
local timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName = ...
-- In some versions (Retail/WotLK), we should use CombatLogGetCurrentEventInfo()
if not timestamp and CombatLogGetCurrentEventInfo then
timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName = CombatLogGetCurrentEventInfo()
function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName)
-- Guard: if combat log was unavailable or disabled, bail fast
if _Learner.combatLogDisabled then return end
-- arg1..arg10 are captured by the frame handler before calling this function.
-- On 3.3.5a these are populated by the client engine.
-- If timestamp was not passed (direct test call), try CombatLogGetCurrentEventInfo.
local path
if not timestamp then
-- Modern path (WoW 3.3.5+): use API, skip arg globals
if CombatLogGetCurrentEventInfo then
local t1, t2, t3, t4, t5, t6, t7, t8, t9, t10 = CombatLogGetCurrentEventInfo()
if t1 then
timestamp, eventType, srcGUID, srcName, srcFlags, dstGUID, dstName, dstFlags, spellId, spellName = t1, t2, t3, t4, t5, t6, t7, t8, t9, t10
path = "modern"
end
end
-- Legacy path (3.3.5a / test): use arg1..arg10 from caller vararg
-- On 3.3.5a the client delivers combat log fields via arg globals in the handler.
-- When OnCombatLogEvent is called directly in test with full args, timestamp
-- is non-nil so this branch never fires.
if not timestamp and arg1 then
timestamp = arg1
eventType = arg2
srcGUID = arg3
srcName = arg4
srcFlags = arg5
dstGUID = arg6
dstName = arg7
dstFlags = arg8
spellId = arg9
spellName = arg10
path = "legacy"
elseif not timestamp then
path = "none"
end
else
-- Args passed by frame handler — 3.3.5a client path
path = "client-arg"
end
-- Permanent minimal log: combat-log path, event, and target
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] combat-log path=", path, " event=", eventType, " dstGUID=", dstGUID, " dstName=", dstName)
-- Vanilla: neither modern API nor legacy args available — throttle warning, do NOT disable permanently
if not timestamp then
-- Only log once per 60 seconds to avoid spam during combat log silence
local now = GetTime and GetTime() or 0
if not _Learner.combatLogSilentUntil or (now - _Learner.combatLogSilentUntil) > 60 then
_Learner.combatLogSilentUntil = now
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] COMBAT_LOG_EVENT_UNFILTERED fired but no args available "
.. "(CombatLogGetCurrentEventInfo=" .. tostring(CombatLogGetCurrentEventInfo ~= nil) .. ", arg1=" .. tostring(arg1) .. ") "
.. "— combat-log kill learning skipped this event (not disabled)")
end
return
end
if eventType == "SPELL_CAST_SUCCESS" then
@@ -2246,6 +2426,23 @@ function QuestieLearner:OnCombatLogEvent(...)
if eventType ~= "PARTY_KILL" and eventType ~= "UNIT_DIED" then return end
if not dstGUID then return end
-- Dedupe: if this GUID was processed within the last 5 seconds, skip.
-- PARTY_KILL and UNIT_DIED can both fire for the same kill; we only need one.
local now = time()
local lastTs = _Learner.killDebounce and _Learner.killDebounce[dstGUID]
if lastTs and (now - lastTs) < 5 then
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID)
return
end
_Learner.killDebounce = _Learner.killDebounce or {}
_Learner.killDebounce[dstGUID] = now
-- Prune entries older than 10 seconds to keep the table bounded
for g, ts in pairs(_Learner.killDebounce) do
if (now - ts) > 10 then
_Learner.killDebounce[g] = nil
end
end
local npcId = GetNpcIdFromGUID(dstGUID)
local name = dstName
@@ -2297,6 +2494,9 @@ function QuestieLearner:OnCombatLogEvent(...)
-- Unconditionally map the spawn position for Ascension DB building
self:LearnNPC(npcId, name, nil, nil, nil, nil, px, py, zoneId)
-- Phase 2: store per-GUID spawn evidence for weighted merge
self:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, px, py)
-- TTL cleanup: drop entries older than 10 minutes
local now = time()
for g, entry in pairs(_Learner.recentKills) do
@@ -2324,6 +2524,243 @@ function QuestieLearner:PruneGuidNpcCache()
end
end
--- Collect coord keys from a spawn table into a sequential array.
--- Uses a two-pass pattern so deletion always happens after traversal.
---@param spawnTable table The [zoneId] sub-table containing learned coords
---@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.
--- Only operates on QuestieLearner learned data (dbLearner.global.npcs / .objects).
--- Never deletes static DB or AscensionDB spawns.
--- Requires at least 4 learned points before evaluating.
--- Prunes in a second pass after identification to avoid mid-iteration deletions.
---@param spawnTable table The [zoneId] sub-table containing learned coords
---@param zoneId number The zone being evaluated
---@param threshold number Max percent-distance from cluster median before pruning (default 15)
---@return boolean True if any points were removed
local function _PruneSpawnOutliers(spawnTable, zoneId, threshold)
threshold = threshold or 15
if not spawnTable then return false end
-- Pass 1: collect all points into a flat array
local learnedPoints = {}
local n = 0
local coord = next(spawnTable)
while coord do
n = n + 1
learnedPoints[n] = { coord[1], coord[2] }
coord = next(spawnTable, coord)
end
if n < 4 then return false end
-- Compute median x and y via insertion sort (Lua 5.0-safe)
local sortedX = {}
local sortedY = {}
for i = 1, n do
sortedX[i] = learnedPoints[i][1]
sortedY[i] = learnedPoints[i][2]
end
for i = 2, n do
local key = sortedX[i]
local j = i - 1
while j >= 1 and sortedX[j] > key do
sortedX[j + 1] = sortedX[j]
j = j - 1
end
sortedX[j + 1] = key
end
for i = 2, n do
local key = sortedY[i]
local j = i - 1
while j >= 1 and sortedY[j] > key do
sortedY[j + 1] = sortedY[j]
j = j - 1
end
sortedY[j + 1] = key
end
local medianX = sortedX[floor(n / 2) + 1]
local medianY = sortedY[floor(n / 2) + 1]
-- Compute mean absolute deviation from median for each axis
local devX, devY = 0, 0
for i = 1, n do
devX = devX + abs(learnedPoints[i][1] - medianX)
devY = devY + abs(learnedPoints[i][2] - medianY)
end
devX = devX / n
devY = devY / n
-- Dynamic axis threshold: 3x mean absolute deviation, floored to threshold
local pruneX = devX * 3
local pruneY = devY * 3
if pruneX < threshold then pruneX = threshold end
if pruneY < threshold then pruneY = threshold end
-- Pass 2: identify outlier keys (collect before deleting)
local toRemove = {}
local rmCount = 0
coord = next(spawnTable)
while coord do
local dx = abs(coord[1] - medianX)
local dy = abs(coord[2] - medianY)
if dx > pruneX or dy > pruneY then
rmCount = rmCount + 1
toRemove[rmCount] = coord
end
coord = next(spawnTable, coord)
end
-- Pass 3: delete in second pass (no mid-iteration table mutation)
local removed = 0
for i = 1, rmCount do
spawnTable[toRemove[i]] = nil
removed = removed + 1
end
if removed > 0 then
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Pruned", removed, "outlier spawns in zone", zoneId)
end
return removed > 0
end
--- Prune outlier learned spawn data for all NPCs and objects.
--- Only touches dbLearner.global.npcs and dbLearner.global.objects (learned data).
--- Guards static data: if a static DB entry exists for the same NPC+zone, use its
--- centroid as the anchor and prune learned entries that deviate > threshold from it.
--- If no static anchor exists, use a learned-median cluster.
--- After pruning, re-injects cleaned data into live overrides so subsequent map/arrow
--- consumers see the corrected pins immediately.
--- Runs on learner import/save/cleanup — NOT on every map draw.
---@param threshold number Max percent-distance from anchor before pruning (default 15)
---@return boolean True if any data was changed
function QuestieLearner:PruneLearnedSpawnOutliers(threshold)
threshold = threshold or 15
local global = Questie.dbLearner and Questie.dbLearner.global
if not global then return false end
local anyChanged = false
-- ── NPCs ──────────────────────────────────────────────────────────────
local npcs = global.npcs
if npcs then
local npcId = next(npcs)
while npcId do
local entry = npcs[npcId]
local spawns = entry and entry[7]
if spawns then
local zoneId = next(spawns)
while zoneId do
local zoneSpawns = spawns[zoneId]
if zoneSpawns then
local changed = false
-- Check if static DB has anchors for this NPC+zone
local staticNPC = nil
if QuestieDB and QuestieDB.QueryNPC then
staticNPC = QuestieDB.QueryNPC(npcId, 1)
end
if staticNPC and staticNPC[7] and staticNPC[7][zoneId] then
-- Static anchor path: build centroid from static spawns
local sc = staticNPC[7][zoneId]
local si = next(sc)
local sn = 0
local sumX, sumY = 0, 0
while si do
sn = sn + 1
sumX = sumX + sc[si][1]
sumY = sumY + sc[si][2]
si = next(sc, si)
end
if sn > 0 then
local anchorX = sumX / sn
local anchorY = sumY / sn
-- Collect outlier keys first, delete second
local toRemove = {}
local rmCount = 0
local coord = next(zoneSpawns)
while coord do
local dx = abs(coord[1] - anchorX)
local dy = abs(coord[2] - anchorY)
if dx > threshold or dy > threshold then
rmCount = rmCount + 1
toRemove[rmCount] = coord
end
coord = next(zoneSpawns, coord)
end
for i = 1, rmCount do
zoneSpawns[toRemove[i]] = nil
changed = true
end
if changed then
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] Pruned", rmCount,
"learned NPC", npcId, "zone", zoneId,
"(deviated from static anchor)")
end
end
else
-- Learned-only path: use median cluster
changed = _PruneSpawnOutliers(zoneSpawns, zoneId, threshold)
end
if changed then anyChanged = true end
end
zoneId = next(spawns, zoneId)
end
end
npcId = next(npcs, npcId)
end
end
-- ── Objects ─────────────────────────────────────────────────────────────
-- Learned objects store spawns in [4], not [7]
local objects = global.objects
if objects then
local objectId = next(objects)
while objectId do
local entry = objects[objectId]
local spawns = entry and entry[4]
if spawns then
local zoneId = next(spawns)
while zoneId do
local zoneSpawns = spawns[zoneId]
if zoneSpawns then
if _PruneSpawnOutliers(zoneSpawns, zoneId, threshold) then
anyChanged = true
end
end
zoneId = next(spawns, zoneId)
end
end
objectId = next(objects, objectId)
end
end
-- Re-inject cleaned data into live overrides so subsequent reads are consistent
if anyChanged then
self:InjectLearnedData()
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Re-injected learned data after outlier pruning")
end
return anyChanged
end
-- Clear objective tracking for a specific quest
function QuestieLearner:ClearQuestObjectiveTracking(questId)
if not questId then return end
@@ -2408,7 +2845,7 @@ function QuestieLearner:RegisterEvents()
frame:RegisterEvent("UNIT_QUEST_LOG_CHANGED")
frame:RegisterEvent("QUEST_REMOVED")
frame:SetScript("OnEvent", function(_, event, ...)
frame:SetScript("OnEvent", function(_, event)
if event == "UPDATE_MOUSEOVER_UNIT" then
self:OnMouseoverUnit()
elseif event == "PLAYER_TARGET_CHANGED" then
@@ -2418,23 +2855,24 @@ function QuestieLearner:RegisterEvents()
elseif event == "QUEST_COMPLETE" then
self:OnQuestComplete()
elseif event == "QUEST_TURNED_IN" then
self:OnQuestTurnedIn(...)
self:OnQuestTurnedIn(arg1, arg2, arg3)
elseif event == "QUEST_ACCEPTED" then
self:OnQuestAccepted(...)
self:OnQuestAccepted(arg1, arg2)
elseif event == "LOOT_OPENED" then
self:OnLootOpened()
elseif event == "GOSSIP_SHOW" then
self:OnGossipShow()
elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
self:OnCombatLogEvent(...)
-- arg1..arg10 must be captured HERE before any secondary call wipes them (3.3.5 behavior)
-- arg1=timestamp, arg2=eventType, arg3=srcGUID, arg4=srcName, arg5=srcFlags,
-- arg6=dstGUID, arg7=dstName, arg8=dstFlags, arg9=spellId, arg10=spellName
self:OnCombatLogEvent(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10)
elseif event == "GET_ITEM_INFO_RECEIVED" then
local itemId = ...
self:OnGetItemInfoReceived(itemId)
self:OnGetItemInfoReceived(arg1)
elseif event == "UNIT_QUEST_LOG_CHANGED" then
self:OnQuestLogUpdate()
elseif event == "QUEST_REMOVED" or event == "QUEST_TURNED_IN" then
local questId = ...
self:ClearQuestObjectiveTracking(questId)
self:ClearQuestObjectiveTracking(arg1)
end
end)
@@ -2461,6 +2899,9 @@ function QuestieLearner:Initialize()
self:PruneGuidNpcCache()
end)
-- Run outlier pruning once at startup after data is loaded and DB is ready
self:PruneLearnedSpawnOutliers()
-- Scan existing quests in log after initialization (deferred to ensure DB is ready)
QuestieCompat.C_Timer.After(1, function()
self:ScanExistingQuestLog()