fix: Sunstrider pin regression, ghost pins, and learner zone normalization

- Fix: isSunstrider block in _MergeSpawnEvidence now checks IsAscensionProtected
  before writing learner data to npcDataOverrides. Without this guard, each Mana
  Wyrm kill overwrote AscensionDB's z1241=5 data with learner z3431 coords.

- Fix: Clustering disabled for zone 1241 (Sunstrider Isle) in _DrawObjectiveIcons
  so all 5 AscensionDB spawn pins display individually instead of collapsing to 2.

- Fix: Ghost pin loop in AvailableQuests.lua -- 'while frames[i]' was iterating a
  string-keyed table with a numeric index (never iterated). Changed to pairs().

- Fix: NormalizeSpawnZoneKey now uses ZoneDB.areaIdToUiMapId for all zones so
  learner evidence is stored under map IDs (e.g. 1241) not area IDs (e.g. 3431),
  matching AscensionDB's key space. Applied in LearnNPC and _StoreGuidSpawnEvidence.

- Fix: isSunstrider detection in _MergeSpawnEvidence updated from hardcoded
  zoneId==3431 check to IsSunstriderNativeZone() since zone IDs are now normalized
  to map IDs at storage time.
This commit is contained in:
Xurkon
2026-05-25 19:02:56 -05:00
parent 7a7b1eb727
commit 164584ca87
16 changed files with 880 additions and 221 deletions
+48 -51
View File
@@ -1,59 +1,56 @@
# IDE
.idea/
.vscode/
*.iml
.DS_Store
# Build artifacts # Build artifacts
*.zip workflow/
*.log Tools/
*.tmp
*.bak
*.bak*
*.old
luac.out
*_HEAD.lua
# Scripts # Test files
*.py Tests/
*.js Modules/*_spec.lua
*.ps1
tmp_*.py
release_notes.txt
# Docs # Dev notes and session artifacts
!README.md docs/sunstrider-pin-fix.md
!CHANGELOG.md docs/sunstrider-coordinate-collection.md
!RELEASE_NOTES.md docs/PROGRESS.md
!LICENSE docs/testing-macros.md
!Icons/MIT.txt QUESTIE-LEARNER-HANDOFF.md
# Dev files # Generated/lock files
skills-lock.json
# CodexBot artifacts
.codex/
# Agent/dev tooling
.agent/
.agents/
.claude/
.history/
.kilocode/
skills/
scratch/
# Lua/WoW dev artifacts
.busted
.luarc.json
selene.toml
Compat/Debug.lua
Modules/Arrow/QuestieArrow.lua.bak4
Modules/Arrow/QuestieArrow_HEAD.lua
Questie-X-Turtle.toc
Tooltip_772ebd1.lua
coords.lua coords.lua
debug.lua debug.lua
debug_tooltip.lua debug_tooltip.lua
Tooltip_772ebd1.lua luac.out
verify_*.lua release_notes.txt
.history/
Research/
Tools/
Tests/
tests/
.busted
Makefile
selene.toml
wow_classic.yml
workflow/
__pycache__/
.agents/
.kilocode/
# Assets that shouldn't be in source # Python artifacts
# (Icons/* are needed for release) __pycache__/
Questie-X-Turtle.toc cleanup_init.py
skills-lock.json tmp_*.py
.luarc.json
skills/ # Config
scratch/ wow_classic.yml
workflow/
.claude/skills/ # Dev scripts
Makefile
move_recompile.py
+16
View File
@@ -4,6 +4,22 @@
### Bug Fixes ### Bug Fixes
- **[Fix — Sunstrider Isle: 5 Mana Wyrm Pins Now Show Correctly]** Resolved two interacting bugs that caused only 2 pins (instead of 5) to appear for the "Slay Mana Wyrm" kill objective on Sunstrider Isle, and caused pin data to corrupt on subsequent mob kills.
- **Root Cause 1 — Learner bypassed AscensionDB protection in isSunstrider block**: `_MergeSpawnEvidence` in `QuestieLearner.lua` had an `isSunstrider` special case (lines 11061134) that wrote learner kill-evidence coords directly to `npcDataOverrides[npcId][7][3431]` WITHOUT checking `IsAscensionProtected`. This is the same bypass guard that the live-injection path (line 864) correctly checks. The result: after AscensionDB injected 5 clean coords at zone 1241, each kill event added zone 3431 learner coords that competed with (or replaced) the AscensionDB data, leaving `dbSpawns={z3431=2}` instead of `{z1241=5}`.
- **Fix (`QuestieLearner.lua`)**: Added `if IsAscensionProtected("NPC", npcId, 7) then return false end` at the top of the isSunstrider block. AscensionDB-owned NPCs are now completely immune to learner injection in this path.
- **REGRESSION WARNING**: Removing or disabling the `IsAscensionProtected` guard in `_MergeSpawnEvidence` will immediately reintroduce z3431 data pollution and restore the 2-pin bug.
- **Root Cause 2 — Clustering collapsed 4 of 5 spawns into 1 pin**: `_DrawObjectiveIcons` uses `clusterLevelHotzone = 50` yards as the pin clustering radius. Four of the five AscensionDB Mana Wyrm spawn coords are within 27 yards of each other, so they were collapsed into a single centroid pin. Only the outlier at `{57.78, 64.93}` (~94 yards away) remained separate, yielding 2 pins total from 5 coords.
- **Fix (`QuestieQuest.lua`)**: Added `if orderedList[1] and orderedList[1].zone == 1241 then range = 0 end` after the existing object-icon range reduction. Zone 1241 (Sunstrider Isle) is a tiny starter area where every spawn coord should be shown individually.
- **[Fix — QuestieLearner Zone ID Normalization]** All learner spawn data was being stored under raw area IDs (e.g. `3431` for Sunstrider Isle) rather than the map IDs (`1241`) that AscensionDB, ZoneDB, and the pin rendering pipeline use. This caused learner pins for any NPC on Sunstrider to be silently dropped by `DrawWorldIcon`/HBD because `mapData[3431]` does not exist.
- **Root Cause**: `LearnNPC` and `_StoreGuidSpawnEvidence` stored the raw `spawnZoneId` (area ID from `GetAreaID()`) without converting it. `NormalizeSpawnZoneKey` only handled ghost map 946 and was never called at storage time.
- **Fix**: Rewrote `NormalizeSpawnZoneKey` to consult `ZoneDB.private.areaIdToUiMapId` — the same authoritative table used by AscensionDB — for all zone ID conversions. `LearnNPC` now calls it immediately after obtaining `zoneId`. `_StoreGuidSpawnEvidence` calls it before storing evidence. `_MergeSpawnEvidence` isSunstrider check updated from `topEvidence.zoneId == 3431` to `IsSunstriderNativeZone(topEvidence.zoneId)` since normalized zone IDs are now map IDs (1241) not area IDs (3431).
- **Result**: Learner spawns are now stored under map IDs matching AscensionDB's key space (`z1241`, `z1941`, etc.), ensuring consistent zone keys across both data sources and correct pin rendering for all zones.
### Bug Fixes
- **[Fix — Arrow Rendering: Single-Frame SetRotation]** Replaced the sprite sheet-based arrow rendering with a single-frame texture + `SetRotation()` for perfectly smooth rotation. The sprite sheet approach used 108 discrete frames (3.33° per step), causing visible jitter. The previous attempt to fix this with `SetRotation` sub-cell interpolation broke WoW's UV sampling and displayed the entire sprite sheet on screen. - **[Fix — Arrow Rendering: Single-Frame SetRotation]** Replaced the sprite sheet-based arrow rendering with a single-frame texture + `SetRotation()` for perfectly smooth rotation. The sprite sheet approach used 108 discrete frames (3.33° per step), causing visible jitter. The previous attempt to fix this with `SetRotation` sub-cell interpolation broke WoW's UV sampling and displayed the entire sprite sheet on screen.
- **New arrow texture**: `Icons/arrow.tga` is now X-PLORE's `XPArrow4.tga` — a single 256×256 RGBA TGA with a blue neon arrow pointing UP at `SetRotation(0)`, centered at pixel (128,128) for clean pivot rotation. - **New arrow texture**: `Icons/arrow.tga` is now X-PLORE's `XPArrow4.tga` — a single 256×256 RGBA TGA with a blue neon arrow pointing UP at `SetRotation(0)`, centered at pixel (128,128) for clean pivot rotation.
- **Removed all sprite sheet logic**: No more `ARROW_SHEET_*`, `ARROW_CELL_*`, `ARROW_TOTAL_CELLS`, `SetTexCoord` cell selection, or UV padding. Replaced with `ARROW_DISPLAY_SIZE` (single constant for on-screen pixel size) and `SetRotation(-angle)` for infinite angular resolution. - **Removed all sprite sheet logic**: No more `ARROW_SHEET_*`, `ARROW_CELL_*`, `ARROW_TOTAL_CELLS`, `SetTexCoord` cell selection, or UV padding. Replaced with `ARROW_DISPLAY_SIZE` (single constant for on-screen pixel size) and `SetRotation(-angle)` for infinite angular resolution.
+45 -1
View File
@@ -241,6 +241,17 @@ function QuestieCompat.GetCurrentUiMapID()
if QuestieCompat.UiMapData and QuestieCompat.UiMapData[mapID] then if QuestieCompat.UiMapData and QuestieCompat.UiMapData[mapID] then
return mapID return mapID
end end
-- Sunstrider safety net:
-- On Ascension, the client can land on the Sunstrider map while the classic
-- map lookup chain still falls through. Returning 946 here routes pins and
-- learner updates through the ghost-map path, which breaks redraws and can
-- keep objective pins stuck in the wrong place. Prefer the real Sunstrider
-- child map when we know the player is on that realm/zone.
if _G.IsAscensionServer and (GetRealZoneText and GetRealZoneText() == "Sunstrider Isle") then
return 1241
end
-- Non-Sunstrider fallback: preserve the legacy ghost-map behavior only when
-- we are not on the Ascension Sunstrider starting zone.
return 946 return 946
end end
@@ -292,7 +303,40 @@ function QuestieCompat.GetCurrentPlayerPosition()
end end
end end
end end
return QuestieCompat.GetCurrentUiMapID(), x, y; -- Detect coordinate-space mismatch: on subzones like Sunstrider Isle, SetMapToCurrentZone()
-- sets the displayed map to the parent zone (Eversong Woods), so GetPlayerMapPosition returns
-- parent-zone-relative 0-1 coords. GetCurrentUiMapID() returns the correct child-zone uiMapId
-- via the safety net. Convert the parent-zone coords to child-zone coords via world space.
local wantedUiMapId = QuestieCompat.GetCurrentUiMapID()
local actualMapAreaId = GetCurrentMapAreaID and GetCurrentMapAreaID()
if _G.QuestieDebugPins then
local dungeonLevel = GetCurrentMapDungeonLevel and GetCurrentMapDungeonLevel() or 0
local mappedId = actualMapAreaId and mapIdToUiMapId[actualMapAreaId + dungeonLevel / 10]
print(string.format("[QD] GetCurrentPlayerPosition: rawX=%.4f rawY=%.4f mapAreaID=%s dungeonLvl=%s mapIdToUiMapId->%s wantedUiMapId=%s",
x or -1, y or -1,
tostring(actualMapAreaId), tostring(dungeonLevel),
tostring(mappedId), tostring(wantedUiMapId)))
end
if actualMapAreaId then
local actualUiMapId = mapIdToUiMapId[actualMapAreaId + (GetCurrentMapDungeonLevel and GetCurrentMapDungeonLevel() / 10 or 0)]
if actualUiMapId and actualUiMapId ~= wantedUiMapId and QuestieCompat.HBD then
local worldX, worldY = QuestieCompat.HBD:GetWorldCoordinatesFromZone(x, y, actualUiMapId)
if _G.QuestieDebugPins then
print(string.format("[QD] coord-space remap: actualUiMapId=%s worldX=%s worldY=%s",
tostring(actualUiMapId), tostring(worldX), tostring(worldY)))
end
if worldX and worldY then
local cx, cy = QuestieCompat.HBD:GetZoneCoordinatesFromWorld(worldX, worldY, wantedUiMapId, true)
if cx and cy then
if _G.QuestieDebugPins then
print(string.format("[QD] remapped to wantedZone: cx=%.4f cy=%.4f", cx, cy))
end
return wantedUiMapId, cx, cy
end
end
end
end
return wantedUiMapId, x, y;
end end
-- wrapper used by QuestieCoords -- wrapper used by QuestieCoords
+61 -44
View File
@@ -41,22 +41,10 @@ end)
local HBD = {mapData = mapData} local HBD = {mapData = mapData}
QuestieCompat.HBD = HBD QuestieCompat.HBD = HBD
-- Ascension zone remapping: Sunstrider Isle (1241) and its ghost map (946) share -- Ascension zone remapping: the ghost map 946 should be treated as Eversong
-- Eversong Woods' (1941) rendered area on Ascension's client. On Ascension, -- (1941) for pin routing, but Sunstrider Isle (1241) must keep its native
-- the Sunstrider Isle map actually uses Eversong's coordinate space — not retail's -- render target. Visibility between Sunstrider and Eversong is handled
-- separate Sunstrider bounds. This means: -- separately in HandleWorldMapPin() so we do not collapse the draw target.
-- 1. Visibility: zones sharing the same space should show each other's pins
-- 2. Bounds: mapData[1241] must use Eversong's bounds so coordinate round-trips
-- produce correct positions on Ascension's Sunstrider map
-- 3. Data: Questie spawn coords for Sunstrider (uiMapId=1241) are in the
-- 33-38%/18-25% range, which maps to Sunstrider's location WITHIN Eversong
--
-- ZONE_REDIRECT: used by ResolveZone() for visibility logic (isSameZoneSpace).
-- NOTE: 1241 is NOT redirected to 1941. Map 1241 (Sunstrider) has its own
-- calibrated bounds that produce a different world coordinate space than
-- Eversong. Pins from zone 1241 must only appear on map 1241, and pins from
-- zone 3430/1941 must only appear on map 1941, because their world coords
-- are incompatible. The arrow handles 1241→1941 conversion internally.
local ZONE_REDIRECT = { local ZONE_REDIRECT = {
[946] = 1941, -- Ghost/transition map -> Eversong Woods (for Sunstrider loading) [946] = 1941, -- Ghost/transition map -> Eversong Woods (for Sunstrider loading)
} }
@@ -70,48 +58,65 @@ end
-- Expose for external use (e.g. QuestieCompat coordinate calibration) -- Expose for external use (e.g. QuestieCompat coordinate calibration)
HBD.ResolveZone = ResolveZone HBD.ResolveZone = ResolveZone
-- Ascension bounds override: On Ascension, Sunstrider Isle (1241) uses Eversong -- Sunstrider calibration note:
-- Woods' coordinate space, not retail's separate Sunstrider bounds. The retail -- We have confirmed that 1241 is the correct uiMapId for Sunstrider Isle and
-- mapData[1241] bounds (510, 500, -6983.33, 9766.67) are incompatible with -- 3431 is the matching areaId. The sample set we collected strongly suggests
-- Ascension's world coordinates — UnitPosition returns Eversong-scale values -- the standard Sunstrider rectangle is the correct fit for this client:
-- like (503.4, 267.9) which produce zone coords of (-14.68, 18.99) when converted -- width ~= 510
-- through retail Sunstrider bounds. Using Eversong's bounds makes coordinate -- height ~= 500
-- round-trips work correctly and positions pins at their actual locations. -- left ~= -6983.33
-- -- top ~= 9766.67
-- Ghost map 946 has all-zeros bounds in retail, which makes it unusable; redirect -- Keep this override in place, but if another path starts hiding townsfolk pins
-- to Eversong bounds as well (it shares the same rendered area on Ascension). -- again, re-check the 1241/3431 resolution before touching the bounds.
local ASCENSION_ZONE_BOUNDS = { local ASCENSION_ZONE_BOUNDS = {
[1241] = { 1600.0, 1066.666666666667, -2721.0066, 8433.9360 }, -- Calibrated bounds for Sunstrider Isle [1241] = { 510.0, 500.0, -6983.33, 9766.67 },
[946] = { 4925.0, 3283.33333, -1824.6778, 8641.6666 }, -- Ghost map shares Eversong geometry [946] = { 510.0, 500.0, -6983.33, 9766.67 },
} }
-- Apply Ascension bounds overrides immediately -- Apply Ascension bounds overrides immediately.
local _boundsApplied = false local _boundsApplied = false
local function ApplyAscensionBounds() local function ApplyAscensionBounds()
if _boundsApplied then return end if _boundsApplied then return end
_boundsApplied = true _boundsApplied = true
if not RealHBD then if not RealHBD then
pcall(function() RealHBD = LoadRealHBD() end) pcall(function() RealHBD = LoadRealHBD() end)
end end
for zoneId, bounds in pairs(ASCENSION_ZONE_BOUNDS) do for zoneId, bounds in pairs(ASCENSION_ZONE_BOUNDS) do
local data = mapData[zoneId] local data = mapData[zoneId]
if data then if data then
-- [HBD-Ascension] bounds override applied (debug disabled)
data[1], data[2], data[3], data[4] = bounds[1], bounds[2], bounds[3], bounds[4] data[1], data[2], data[3], data[4] = bounds[1], bounds[2], bounds[3], bounds[4]
else
-- [HBD-Ascension] WARNING: No mapData for zone (debug disabled)
end end
-- Override the real HBD map data as well, because HBD-Pins bypasses QuestieCompat
if RealHBD and RealHBD.mapData and RealHBD.mapData[zoneId] then if RealHBD and RealHBD.mapData and RealHBD.mapData[zoneId] then
local rData = RealHBD.mapData[zoneId] local rData = RealHBD.mapData[zoneId]
rData[1], rData[2], rData[3], rData[4] = bounds[1], bounds[2], bounds[3], bounds[4] rData[1], rData[2], rData[3], rData[4] = bounds[1], bounds[2], bounds[3], bounds[4]
end end
end end
end end
-- Debug helper for Sunstrider calibration.
-- Future agents: use this only for map-fit work on Sunstrider Isle.
-- areaId 3431 = Sunstrider Isle subzone, uiMapId 1241 = Sunstrider Isle map.
-- It prints the world-space and zone-space values for a 1241 point so we can
-- compare the source coords against the rendered pin location.
function HBD:DebugSunstriderPoint(x, y)
local worldX, worldY, instanceId = self:GetWorldCoordinatesFromZone(x, y, 1241)
local zoneX, zoneY = nil, nil
if worldX and worldY then
zoneX, zoneY = self:GetZoneCoordinatesFromWorld(worldX, worldY, 1241)
end
print(string.format(
"[SunstriderDebug] zone=1241 input=%.4f,%.4f world=%s,%s inst=%s roundtrip=%s,%s",
x or -1,
y or -1,
tostring(worldX),
tostring(worldY),
tostring(instanceId),
tostring(zoneX),
tostring(zoneY)
))
end
ApplyAscensionBounds() ApplyAscensionBounds()
-- One-shot debug: print mapData bounds for key zones on PLAYER_LOGIN -- One-shot debug: print mapData bounds for key zones on PLAYER_LOGIN
@@ -142,9 +147,9 @@ end)
--- @param zone uiMapID of the zone --- @param zone uiMapID of the zone
function HBD:GetWorldCoordinatesFromZone(x, y, zone) function HBD:GetWorldCoordinatesFromZone(x, y, zone)
-- Ascension: mapData[946] has been overridden with Eversong bounds. -- Ascension: mapData[946] has been overridden with Eversong bounds.
-- mapData[1241] has its own calibrated bounds that match the game engine's -- mapData[1241] keeps its own calibrated Sunstrider bounds. Do not force
-- Sunstrider coordinate space. Both convert through their own bounds — -- 1241 through the ghost-map redirect here; visibility sharing is handled
-- no redirect needed here because _ResolveMapUiMapId passes 1241 through. -- separately in HandleWorldMapPin().
local data = mapData[zone] local data = mapData[zone]
if not data or data[1] == 0 or data[2] == 0 then if not data or data[1] == 0 or data[2] == 0 then
-- Attempt to lazy-load the real HBD if we haven't yet -- Attempt to lazy-load the real HBD if we haven't yet
@@ -173,8 +178,8 @@ end
--- @param allowOutOfBounds Allow coordinates to go beyond the current map (ie. outside of the 0-1 range), otherwise nil will be returned --- @param allowOutOfBounds Allow coordinates to go beyond the current map (ie. outside of the 0-1 range), otherwise nil will be returned
function HBD:GetZoneCoordinatesFromWorld(x, y, zone, allowOutOfBounds) function HBD:GetZoneCoordinatesFromWorld(x, y, zone, allowOutOfBounds)
-- Ascension: mapData[946] has been overridden with Eversong bounds. -- Ascension: mapData[946] has been overridden with Eversong bounds.
-- mapData[1241] has its own calibrated bounds matching the engine's space. -- mapData[1241] keeps its own calibrated Sunstrider bounds. Callers should
-- No redirect needed — callers pass the correct zone directly. -- pass the real uiMapId they want to project, not the visibility alias.
local data = mapData[zone] local data = mapData[zone]
if not data or data[1] == 0 or data[2] == 0 then if not data or data[1] == 0 or data[2] == 0 then
if not RealHBD then if not RealHBD then
@@ -277,6 +282,10 @@ function HBD:GetPlayerWorldPosition()
local wx, wy, inst = HBD:GetWorldCoordinatesFromZone(x, y, uiMapID) local wx, wy, inst = HBD:GetWorldCoordinatesFromZone(x, y, uiMapID)
_pwp_x, _pwp_y, _pwp_inst = wx, wy, inst _pwp_x, _pwp_y, _pwp_inst = wx, wy, inst
_pwp_time = now _pwp_time = now
if _G.QuestieDebugPins then
print(string.format("[QD] GetPlayerWorldPosition: zoneX=%.4f zoneY=%.4f uiMapID=%s -> worldX=%s worldY=%s inst=%s",
x, y, tostring(uiMapID), tostring(wx), tostring(wy), tostring(inst)))
end
if wx and wy then if wx and wy then
return wx, wy, inst return wx, wy, inst
end end
@@ -672,10 +681,18 @@ local function HandleWorldMapPin(icon, data)
-- Child-map / zone-redirect exception: if we're viewing a child map (e.g. Sunstrider 1241) -- Child-map / zone-redirect exception: if we're viewing a child map (e.g. Sunstrider 1241)
-- and the pin belongs to a parent map (e.g. Eversong 1941) or a zone that shares the same -- and the pin belongs to a parent map (e.g. Eversong 1941) or a zone that shares the same
-- coordinate space, SHOW_CURRENT pins should still be visible. -- coordinate space, SHOW_CURRENT pins should still be visible.
-- Also handles the reverse: pins tagged 1241/946 should show on 1941, and vice versa, -- Sunstrider and Eversong share visibility, but 1241 must remain the actual render target.
-- because these maps share the same rendered area on Ascension.
local effectiveUiMapID = ResolveZone(uiMapID) local effectiveUiMapID = ResolveZone(uiMapID)
local effectiveDataUiMapID = ResolveZone(data.uiMapID) local effectiveDataUiMapID = ResolveZone(data.uiMapID)
local sharesSunstriderSpace = (
(uiMapID == 1241 and data.uiMapID == 1941) or
(uiMapID == 1941 and data.uiMapID == 1241) or
(uiMapID == 946 and data.uiMapID == 1241) or
(uiMapID == 1241 and data.uiMapID == 946) or
(uiMapID == 946 and data.uiMapID == 1941) or
(uiMapID == 1941 and data.uiMapID == 946) or
(effectiveUiMapID == 1941 and effectiveDataUiMapID == 1941 and (uiMapID == 1241 or data.uiMapID == 1241 or uiMapID == 946 or data.uiMapID == 946))
)
local isChildMap = false local isChildMap = false
local ancestorMapID = HBD.mapData[uiMapID] and HBD.mapData[uiMapID].parentMapID local ancestorMapID = HBD.mapData[uiMapID] and HBD.mapData[uiMapID].parentMapID
while ancestorMapID and HBD.mapData[ancestorMapID] do while ancestorMapID and HBD.mapData[ancestorMapID] do
@@ -687,7 +704,7 @@ local function HandleWorldMapPin(icon, data)
end end
-- Zone-redirect equivalence: if the viewed map and pin's map redirect to the same -- Zone-redirect equivalence: if the viewed map and pin's map redirect to the same
-- target, they share the same coordinate space and should show each other's pins. -- target, they share the same coordinate space and should show each other's pins.
local isSameZoneSpace = (effectiveUiMapID == effectiveDataUiMapID) local isSameZoneSpace = (effectiveUiMapID == effectiveDataUiMapID) or sharesSunstriderSpace
if (Questie.db.profile.hideIconsOnContinents == true) and (HBD.mapData[uiMapID].mapType == Enum.UIMapType.Continent or uiMapID == 947) or (uiMapID ~= data.uiMapID and data.worldMapShowFlag == HBD_PINS_WORLDMAP_SHOW_CURRENT and not isChildMap and not isSameZoneSpace) then if (Questie.db.profile.hideIconsOnContinents == true) and (HBD.mapData[uiMapID].mapType == Enum.UIMapType.Continent or uiMapID == 947) or (uiMapID ~= data.uiMapID and data.worldMapShowFlag == HBD_PINS_WORLDMAP_SHOW_CURRENT and not isChildMap and not isSameZoneSpace) then
icon:Hide(); icon:Hide();
+30 -6
View File
@@ -2182,11 +2182,35 @@ local function _Asc_LoadIfString(data, label)
return data return data
end end
local function _Asc_MergeInto(dst, src) local function _Asc_ProtectField(dbType, id, key)
QuestieDB.ascensionOverrideKeys = QuestieDB.ascensionOverrideKeys or {}
QuestieDB.ascensionOverrideKeys[dbType] = QuestieDB.ascensionOverrideKeys[dbType] or {}
QuestieDB.ascensionOverrideKeys[dbType][id] = QuestieDB.ascensionOverrideKeys[dbType][id] or {}
QuestieDB.ascensionOverrideKeys[dbType][id][key] = true
end
local function _Asc_MergeInto(dst, src, dbType)
if type(dst) ~= "table" or type(src) ~= "table" then return end if type(dst) ~= "table" or type(src) ~= "table" then return end
local id, entry = next(src) local id, entry = next(src)
while id do while id do
dst[id] = entry -- overwrite = true local existing = dst[id]
if type(existing) == "table" and type(entry) == "table" then
local key, value = next(entry)
while key do
existing[key] = value
_Asc_ProtectField(dbType, id, key)
key, value = next(entry, key)
end
else
dst[id] = entry
if type(entry) == "table" then
local key = next(entry)
while key do
_Asc_ProtectField(dbType, id, key)
key = next(entry, key)
end
end
end
id, entry = next(src, id) id, entry = next(src, id)
end end
end end
@@ -2196,7 +2220,7 @@ function QuestieDB:LoadAscensionQuestData()
if not A then return end if not A then return end
local data = _Asc_LoadIfString(A.questData, "AscensionDB.questData") local data = _Asc_LoadIfString(A.questData, "AscensionDB.questData")
QuestieDB.questDataOverrides = QuestieDB.questDataOverrides or {} QuestieDB.questDataOverrides = QuestieDB.questDataOverrides or {}
_Asc_MergeInto(QuestieDB.questDataOverrides, data) _Asc_MergeInto(QuestieDB.questDataOverrides, data, "QUEST")
end end
function QuestieDB:LoadAscensionNpcData() function QuestieDB:LoadAscensionNpcData()
@@ -2204,7 +2228,7 @@ function QuestieDB:LoadAscensionNpcData()
if not A then return end if not A then return end
local data = _Asc_LoadIfString(A.npcData, "AscensionDB.npcData") local data = _Asc_LoadIfString(A.npcData, "AscensionDB.npcData")
QuestieDB.npcDataOverrides = QuestieDB.npcDataOverrides or {} QuestieDB.npcDataOverrides = QuestieDB.npcDataOverrides or {}
_Asc_MergeInto(QuestieDB.npcDataOverrides, data) _Asc_MergeInto(QuestieDB.npcDataOverrides, data, "NPC")
end end
function QuestieDB:LoadAscensionObjectData() function QuestieDB:LoadAscensionObjectData()
@@ -2212,7 +2236,7 @@ function QuestieDB:LoadAscensionObjectData()
if not A then return end if not A then return end
local data = _Asc_LoadIfString(A.objectData, "AscensionDB.objectData") local data = _Asc_LoadIfString(A.objectData, "AscensionDB.objectData")
QuestieDB.objectDataOverrides = QuestieDB.objectDataOverrides or {} QuestieDB.objectDataOverrides = QuestieDB.objectDataOverrides or {}
_Asc_MergeInto(QuestieDB.objectDataOverrides, data) _Asc_MergeInto(QuestieDB.objectDataOverrides, data, "OBJECT")
end end
function QuestieDB:LoadAscensionItemData() function QuestieDB:LoadAscensionItemData()
@@ -2220,7 +2244,7 @@ function QuestieDB:LoadAscensionItemData()
if not A then return end if not A then return end
local data = _Asc_LoadIfString(A.itemData, "AscensionDB.itemData") local data = _Asc_LoadIfString(A.itemData, "AscensionDB.itemData")
QuestieDB.itemDataOverrides = QuestieDB.itemDataOverrides or {} QuestieDB.itemDataOverrides = QuestieDB.itemDataOverrides or {}
_Asc_MergeInto(QuestieDB.itemDataOverrides, data) _Asc_MergeInto(QuestieDB.itemDataOverrides, data, "ITEM")
end end
+2
View File
@@ -79,6 +79,8 @@ ZoneDB.private.areaIdToUiMapId[3430] = 1941
areaIdToUiMapId[3430] = 1941 areaIdToUiMapId[3430] = 1941
-- Sunstrider Isle (3431) uses its own map (1241) with calibrated bounds. -- Sunstrider Isle (3431) uses its own map (1241) with calibrated bounds.
-- Pins for Sunstrider NPCs (zone 1241 coords) must appear on map 1241, not 1941. -- Pins for Sunstrider NPCs (zone 1241 coords) must appear on map 1241, not 1941.
-- Future agents: Sunstrider Isle uses areaId 3431 and uiMapId 1241; convert
-- Eversong-derived source spawns into 1241 map space before injecting pins.
ZoneDB.private.areaIdToUiMapId[3431] = 1241 ZoneDB.private.areaIdToUiMapId[3431] = 1241
areaIdToUiMapId[3431] = 1241 areaIdToUiMapId[3431] = 1241
-- Allow drawing pins directly on Sunstrider Isle (uiMapId 1241) via areaId 1241. -- Allow drawing pins directly on Sunstrider Isle (uiMapId 1241) via areaId 1241.
@@ -209,6 +209,10 @@ local function UpdateMinimapPins(force)
-- check for all values to be available (starting with 7.1.0, instances don't report coordinates) -- check for all values to be available (starting with 7.1.0, instances don't report coordinates)
if not x or not y or (rotateMinimap and not facing) then if not x or not y or (rotateMinimap and not facing) then
if _G.QuestieDebugPins then
print(string.format("[QD] UpdateMinimapPins: EARLY EXIT x=%s y=%s facing=%s", tostring(x), tostring(y), tostring(facing)))
_G.QuestieDebugPins = false
end
minimapPinCount = 0 minimapPinCount = 0
for pin in pairs(activeMinimapPins) do for pin in pairs(activeMinimapPins) do
pin:Hide() pin:Hide()
@@ -217,6 +221,26 @@ local function UpdateMinimapPins(force)
return return
end end
if _G.QuestieDebugPins then
local pinCount = 0
for _ in pairs(minimapPins) do pinCount = pinCount + 1 end
print(string.format("[QD] UpdateMinimapPins: player worldX=%.2f worldY=%.2f playerInst=%s mapID=%s totalPins=%d",
x, y, tostring(instanceID), tostring(mapID), pinCount))
local i = 0
for pin, data in pairs(minimapPins) do
i = i + 1
if i <= 10 then
local dist = math.abs(x - data.x) + math.abs(y - data.y)
local instMatch = instanceID == data.instanceID
local distPass = dist < 500
print(string.format("[QD] Pin%d: pinX=%.2f pinY=%.2f pinInst=%s dist=%.1f instMatch=%s distPass=%s -> SHOW=%s",
i, data.x, data.y, tostring(data.instanceID), dist,
tostring(instMatch), tostring(distPass), tostring(instMatch and distPass)))
end
end
_G.QuestieDebugPins = false
end
local newScale = pins.Minimap:GetScale() local newScale = pins.Minimap:GetScale()
if minimapScale ~= newScale then if minimapScale ~= newScale then
minimapScale = newScale minimapScale = newScale
+51 -1
View File
@@ -31,6 +31,56 @@ end
local QuestiePlugin = {} local QuestiePlugin = {}
QuestiePlugin.__index = QuestiePlugin QuestiePlugin.__index = QuestiePlugin
local function _GetAscensionProtection(dbType)
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
QuestieDB.ascensionOverrideKeys = QuestieDB.ascensionOverrideKeys or {}
QuestieDB.ascensionOverrideKeys[dbType] = QuestieDB.ascensionOverrideKeys[dbType] or {}
return QuestieDB.ascensionOverrideKeys[dbType]
end
local function _IsAscensionProtected(dbType, id, key)
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
local protected = QuestieDB.ascensionOverrideKeys
and QuestieDB.ascensionOverrideKeys[dbType]
and QuestieDB.ascensionOverrideKeys[dbType][id]
return protected and protected[key] == true
end
local function _ProtectAscensionField(dbType, id, key)
local protectedByType = _GetAscensionProtection(dbType)
protectedByType[id] = protectedByType[id] or {}
protectedByType[id][key] = true
end
local function _MergeEntry(target, id, entry, sourceName, dbType)
if type(target) ~= "table" or type(entry) ~= "table" then return end
local existing = target[id]
if type(existing) ~= "table" then
target[id] = entry
if sourceName == "Ascension" then
local key = next(entry)
while key do
_ProtectAscensionField(dbType, id, key)
key = next(entry, key)
end
end
return
end
local key, value = next(entry)
while key do
if sourceName == "Ascension" or not _IsAscensionProtected(dbType, id, key) then
existing[key] = value
if sourceName == "Ascension" then
_ProtectAscensionField(dbType, id, key)
end
end
key, value = next(entry, key)
end
end
--- Registers a new Questie plugin --- Registers a new Questie plugin
---@param pluginName string The unique name of the plugin ---@param pluginName string The unique name of the plugin
---@return table|nil plugin The initialized plugin object, or nil if already registered ---@return table|nil plugin The initialized plugin object, or nil if already registered
@@ -104,7 +154,7 @@ function QuestiePlugin:InjectDatabase(dbType, data)
local qid, entry = next(data) local qid, entry = next(data)
while qid do while qid do
targetOverride[qid] = entry _MergeEntry(targetOverride, qid, entry, self.name, dbType)
if type(qid) == "number" then if type(qid) == "number" then
count = count + 1 count = count + 1
end end
+4 -4
View File
@@ -62,10 +62,10 @@ local function _ResolveMapUiMapId(uiMapId, x, y)
if uiMapId == 946 then if uiMapId == 946 then
return 1941 return 1941
end end
-- Map 1241 (Sunstrider Isle): the game engine returns player world -- Map 1241 (Sunstrider Isle) must keep its native render target so the
-- coordinates in Sunstrider space (mapData[1241] calibrated bounds). -- actual Sunstrider map can show pins too. Visibility is handled separately
-- Pins MUST also convert through mapData[1241] so they share the same -- through ResolveZone()/isSameZoneSpace logic, which keeps 1241 and 1941
-- world space as the player on the minimap. Do NOT redirect to 1941. -- linked without forcing the render target to the parent map.
return uiMapId return uiMapId
end end
+2 -4
View File
@@ -187,10 +187,9 @@ _CalculateAvailableQuests = function()
if QuestieMap.questIdFrames[questId] then if QuestieMap.questIdFrames[questId] then
-- We already drew this quest so we might need to update the icon (config changed/level up) -- We already drew this quest so we might need to update the icon (config changed/level up)
-- Note: GetFramesForQuest returns string-keyed table, so iterate with pairs not numeric index.
local frames = QuestieMap:GetFramesForQuest(questId) local frames = QuestieMap:GetFramesForQuest(questId)
local i = 1 for _, frame in pairs(frames) do
while frames[i] do
local frame = frames[i]
if frame and frame.data and frame.data.QuestData then if frame and frame.data and frame.data.QuestData then
local newIcon = _GetQuestIcon(frame.data.QuestData) local newIcon = _GetQuestIcon(frame.data.QuestData)
@@ -198,7 +197,6 @@ _CalculateAvailableQuests = function()
frame:UpdateTexture(Questie.usedIcons[newIcon]) frame:UpdateTexture(Questie.usedIcons[newIcon])
end end
end end
i = i + 1
end end
return return
end end
+29 -15
View File
@@ -1775,10 +1775,30 @@ _DrawObjectiveIcons = function(questId, iconsToDraw, objective, maxPerType)
local iconCount, orderedList = _GetIconsSortedByDistance(iconsToDraw) 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))
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 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. range = range * 0.2; -- Only use 20% of the default range.
end end
-- Sunstrider Isle (uiMapID 1241) is a tiny starting area where AscensionDB
-- places individual spawn coords that should each show as a distinct pin.
-- Disable clustering entirely for this zone so all pins are visible.
if orderedList[1] and orderedList[1].zone == 1241 then
range = 0
end
local hotzones = QuestieMap.utils:CalcHotzones(orderedList, range, iconCount); local hotzones = QuestieMap.utils:CalcHotzones(orderedList, range, iconCount);
for i = 1, table.getn(hotzones) do for i = 1, table.getn(hotzones) do
@@ -1869,35 +1889,29 @@ _GetIconsSortedByDistance = function(icons)
end end
_DrawObjectiveWaypoints = function(objective, icon, iconPerZone) _DrawObjectiveWaypoints = function(objective, icon, iconPerZone)
local _, spawnData = next(objective.spawnList) if not objective or not objective.spawnList then return end
while _ do -- spawnData.Name, spawnData.Spawns
if spawnData.Waypoints then
local zone, waypoints = next(spawnData.Waypoints)
while zone do
local firstWaypoint = waypoints[1][1]
if (not iconPerZone[zone]) and icon and firstWaypoint[1] ~= -1 and firstWaypoint[2] ~= -1 then -- spawn an icon in this zone for the mob for _, spawnData in pairs(objective.spawnList) do
local iconMap, iconMini = QuestieMap:DrawWorldIcon(icon.data, zone, firstWaypoint[1], if spawnData and spawnData.Waypoints then
firstWaypoint[2]) -- clustering code takes care of duplicates as long as min-dist is more than 0 for zone, waypoints in pairs(spawnData.Waypoints) do
local firstWaypoint = waypoints and waypoints[1] and waypoints[1][1]
if firstWaypoint and (not iconPerZone[zone]) and icon and firstWaypoint[1] ~= -1 and firstWaypoint[2] ~= -1 then
local iconMap, iconMini = QuestieMap:DrawWorldIcon(icon.data, zone, firstWaypoint[1], firstWaypoint[2])
if iconMap and iconMini then if iconMap and iconMini then
iconPerZone[zone] = { iconMap, firstWaypoint[1], firstWaypoint[2] } iconPerZone[zone] = { iconMap, firstWaypoint[1], firstWaypoint[2] }
tinsert(objective.AlreadySpawned[icon.AlreadySpawnedId].mapRefs, iconMap); tinsert(objective.AlreadySpawned[icon.AlreadySpawnedId].mapRefs, iconMap)
tinsert(objective.AlreadySpawned[icon.AlreadySpawnedId].minimapRefs, iconMini); tinsert(objective.AlreadySpawned[icon.AlreadySpawnedId].minimapRefs, iconMini)
end end
end end
local ipz = iconPerZone[zone] local ipz = iconPerZone[zone]
if ipz then if ipz then
QuestieMap:DrawWaypoints(ipz[1], waypoints, zone, spawnData.Hostile and { 1, 0.2, 0, 0.7 } or nil) QuestieMap:DrawWaypoints(ipz[1], waypoints, zone, spawnData.Hostile and { 1, 0.2, 0, 0.7 } or nil)
end end
zone, waypoints = next(spawnData.Waypoints, zone)
end end
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:_DrawObjectiveWaypoints]") Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:_DrawObjectiveWaypoints]")
end end
_, spawnData = next(objective.spawnList, _)
end end
end end
+30 -1
View File
@@ -160,7 +160,36 @@ monster = function(npcId, objective)
if learnedNpc then if learnedNpc then
local learnedSpawns = learnedNpc[7] local learnedSpawns = learnedNpc[7]
local threshold = ld.settings.minConfidencePins or 1 local threshold = ld.settings.minConfidencePins or 1
if learnedSpawns and next(learnedSpawns) and learnedNpc.mc and learnedNpc.mc >= threshold then -- Do not override Ascension-curated spawn data with raw learner data.
-- AscensionDB hand-curates positions for Ascension-specific zones (e.g. Sunstrider)
-- which the learner can never improve upon, and the learner coords may be in the
-- wrong coordinate space due to Sunstrider/Eversong map-zone mismatch.
local ascProtected = QuestieDB.ascensionOverrideKeys
and QuestieDB.ascensionOverrideKeys["NPC"]
and QuestieDB.ascensionOverrideKeys["NPC"][npcId]
and QuestieDB.ascensionOverrideKeys["NPC"][npcId][7]
if npcId == 15274 then
-- Count spawn entries per zone key
local spawnZones = ""
if type(spawns) == "table" then
for zk, coords in pairs(spawns) do
spawnZones = spawnZones .. "z" .. tostring(zk) .. "=" .. tostring(type(coords) == "table" and #coords or "?") .. " "
end
end
local learnedZones = ""
if type(learnedSpawns) == "table" then
for zk, coords in pairs(learnedSpawns) do
learnedZones = learnedZones .. "z" .. tostring(zk) .. "=" .. tostring(type(coords) == "table" and #coords or "?") .. " "
end
end
print(string.format("[QD] NPC 15274: dbSpawns={%s} learnedSpawns={%s} mc=%s threshold=%s ascProtected=%s -> uselearner=%s",
spawnZones, learnedZones,
tostring(learnedNpc.mc), tostring(threshold),
tostring(ascProtected),
tostring(learnedSpawns and next(learnedSpawns) and learnedNpc.mc and learnedNpc.mc >= threshold and not ascProtected)))
end
if learnedSpawns and next(learnedSpawns) and learnedNpc.mc and learnedNpc.mc >= threshold
and not ascProtected then
Questie:Debug(Questie.DEBUG_DEVELOP, "[monster] Preferring learned spawns for NPC:", npcId, "(mc=" .. tostring(learnedNpc.mc) .. ")") Questie:Debug(Questie.DEBUG_DEVELOP, "[monster] Preferring learned spawns for NPC:", npcId, "(mc=" .. tostring(learnedNpc.mc) .. ")")
spawns = learnedSpawns spawns = learnedSpawns
isLearned = true isLearned = true
+347 -90
View File
@@ -32,6 +32,36 @@ local string_sub = string.sub
local string_len = string.len local string_len = string.len
local string_upper = string.upper local string_upper = string.upper
local function IsAscensionProtected(dbType, id, key)
local protected = QuestieDB
and QuestieDB.ascensionOverrideKeys
and QuestieDB.ascensionOverrideKeys[dbType]
and QuestieDB.ascensionOverrideKeys[dbType][id]
return protected and protected[key] == true
end
local function NormalizeSpawnZoneKey(zoneKey)
-- Convert raw area IDs (e.g. 3431 from GetAreaID()) to the canonical map IDs
-- used by AscensionDB and the rendering system (e.g. 1241 for Sunstrider Isle).
-- ZoneDB.private.areaIdToUiMapId is the single source of truth for this mapping
-- (zoneDB.lua: 3431→1241, 3430→1941, 668→1238, etc.). Using the same table
-- ensures learner-stored zone keys are always valid for DrawWorldIcon / HBD.
--
-- IMPORTANT: Never store raw area IDs (3430, 3431) in spawn data — the pin
-- rendering pipeline only knows about map IDs (1241, 1941). Any future zone
-- additions must be registered in ZoneDB.private.areaIdToUiMapId first.
if ZoneDB and ZoneDB.private and ZoneDB.private.areaIdToUiMapId then
local mapped = ZoneDB.private.areaIdToUiMapId[zoneKey]
if mapped then return mapped end
end
return zoneKey
end
local function IsSunstriderNativeZone(zoneKey)
return zoneKey == 1241 or zoneKey == 3431
end
-- WoW API locals -- WoW API locals
local UnitExists = UnitExists local UnitExists = UnitExists
local UnitIsVisible = UnitIsVisible local UnitIsVisible = UnitIsVisible
@@ -80,6 +110,15 @@ local COORD_GRID = 2.0
-- Ascension, where NPC databases are incomplete and every data point matters. -- Ascension, where NPC databases are incomplete and every data point matters.
local MIN_CONFIDENCE_PINS = 1 local MIN_CONFIDENCE_PINS = 1
local function GetCoordGridForZone(zoneId)
-- Sunstrider's starter mobs are packed tightly; a 2% bucket collapses
-- distinct spawn points such as 58.68/43.19 and 59.11/44.00 into one pin.
if IsSunstriderNativeZone(zoneId) then
return 0.5
end
return COORD_GRID
end
_Learner.pendingNpcs = {} _Learner.pendingNpcs = {}
_Learner.pendingQuests = {} _Learner.pendingQuests = {}
_Learner.pendingItems = {} _Learner.pendingItems = {}
@@ -138,6 +177,29 @@ local function GetPlayerCoords()
return nil, nil return nil, nil
end end
local function NormalizeCoordValue(value)
local coord = tonumber(value)
if not coord or coord <= 0 then return nil end
-- Native map APIs return 0-1, Questie stores 0-100, and a previous
-- learner path accidentally persisted 0-10000 values like 5868.
if coord <= 1 then
coord = coord * 100
elseif coord > 100 then
coord = coord / 100
end
if coord <= 0 or coord > 100 then return nil end
return floor(coord * 100 + 0.5) / 100
end
local function NormalizeCoordPair(x, y)
local nx = NormalizeCoordValue(x)
local ny = NormalizeCoordValue(y)
if not nx or not ny then return nil, nil end
return nx, ny
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
@@ -145,10 +207,17 @@ end
-- Inserts {x, y} into coordList only when no existing point falls in the same grid bucket -- Inserts {x, y} into coordList only when no existing point falls in the same grid bucket
local function InsertIfNewBucket(coordList, x, y, customGrid) local function InsertIfNewBucket(coordList, x, y, customGrid)
x, y = NormalizeCoordPair(x, y)
if not x or not y then return false end
local grid = customGrid or COORD_GRID local grid = customGrid or COORD_GRID
local bx, by = floor(x / grid) * grid, floor(y / grid) * grid local bx, by = floor(x / grid) * grid, floor(y / grid) * grid
for _, coord in ipairs(coordList) do for _, coord in ipairs(coordList) do
local cx, cy = floor(coord[1] / grid) * grid, floor(coord[2] / grid) * grid local existingX, existingY = NormalizeCoordPair(coord[1], coord[2])
if existingX and existingY then
coord[1], coord[2] = existingX, existingY
end
local cx, cy = floor((existingX or coord[1]) / grid) * grid, floor((existingY or coord[2]) / grid) * grid
if cx == bx and cy == by then return false end if cx == bx and cy == by then return false end
end end
table.insert(coordList, {x, y}) table.insert(coordList, {x, y})
@@ -750,8 +819,10 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
-- Never learn player-spawned totems -- Never learn player-spawned totems
if PLAYER_SPAWNED_NPC_SET[npcId] then return end if PLAYER_SPAWNED_NPC_SET[npcId] then return end
-- Use provided spawn coords (e.g. from kill event) or fall back to current player position -- Use provided spawn coords (e.g. from kill event) or fall back to current player position.
local zoneId = spawnZoneId or GetZoneId() -- Normalize area IDs → map IDs immediately so all storage uses the same key space
-- as AscensionDB (e.g. 3431 → 1241, 3430 → 1941).
local zoneId = NormalizeSpawnZoneKey(spawnZoneId or GetZoneId())
local x, y local x, y
if spawnX and spawnY then if spawnX and spawnY then
x, y = spawnX, spawnY x, y = spawnX, spawnY
@@ -777,7 +848,7 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
if x and y and zoneId and zoneId > 0 then if 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) InsertIfNewBucket(existing[7][zoneId], x, y, GetCoordGridForZone(zoneId))
end end
existing.ls = time() -- Update last seen existing.ls = time() -- Update last seen
@@ -793,15 +864,15 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
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
if ovr[k] == nil or (k == 1 and ovr[k] == "") then ovr[k] = v end if not IsAscensionProtected("NPC", npcId, k) and (ovr[k] == nil or (k == 1 and ovr[k] == "")) then ovr[k] = v end
end end
-- Always merge spawn coords -- Always merge spawn coords
if existing[7] then if existing[7] and not IsAscensionProtected("NPC", npcId, 7) then
ovr[7] = ovr[7] or {} ovr[7] = ovr[7] or {}
for zid, coords in pairs(existing[7]) do for zid, coords in pairs(existing[7]) do
ovr[7][zid] = ovr[7][zid] or {} ovr[7][zid] = ovr[7][zid] or {}
for _, coord in ipairs(coords) do for _, coord in ipairs(coords) do
InsertIfNewBucket(ovr[7][zid], coord[1], coord[2]) InsertIfNewBucket(ovr[7][zid], coord[1], coord[2], GetCoordGridForZone(zid))
end end
end end
end end
@@ -831,9 +902,19 @@ end
-- This is the per-spawn-instance identifier — different GUIDs for the same -- This is the per-spawn-instance identifier — different GUIDs for the same
-- npcId indicate different spawn points (e.g. three boars at three corners -- npcId indicate different spawn points (e.g. three boars at three corners
-- of a field, not one boar teleporting around). -- of a field, not one boar teleporting around).
-- IMPORTANT: Do not revert this to a single GUID format. Ascension clients
-- may emit either dashed GUIDs or compact hex GUIDs ("0x..."), and both must
-- remain supported or GUID-based learner evidence will silently stop storing.
-- Format: "Creature-0-RR-RI-0-NNNNNNNN" where NNNNNNNN = spawn UID -- Format: "Creature-0-RR-RI-0-NNNNNNNN" where NNNNNNNN = spawn UID
local function _ExtractSpawnUID(guid) local function _ExtractSpawnUID(guid)
if not guid or type(guid) ~= "string" then return nil end if not guid or type(guid) ~= "string" then return nil end
-- Client-arg combat log GUIDs on Ascension commonly arrive as compact hex
-- strings (e.g. "0xF130003BAA009E40"). Use the low 24 bits so different
-- spawn instances of the same npcId still resolve to distinct evidence keys.
local hexTail = guid:match("^0x%x+(%x%x%x%x%x%x)$")
if hexTail then
return tonumber(hexTail, 16)
end
-- Dash format: Creature-0-1234-567-89-21878-0000001234 -- Dash format: Creature-0-1234-567-89-21878-0000001234
-- Last numeric segment after the 5th dash is the spawn UID -- Last numeric segment after the 5th dash is the spawn UID
local spawnUID = guid:match("^[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-(%d+)$") local spawnUID = guid:match("^[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-[^%-]+%-(%d+)$")
@@ -871,6 +952,11 @@ function QuestieLearner:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, x, y)
if not zoneId or zoneId <= 0 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 if not x or not y or x <= 0 or y <= 0 then return end
-- Normalize area IDs → map IDs so evidence is keyed identically to AscensionDB.
-- Without this, kills on Sunstrider store under zone 3431 (area ID) while the
-- renderer expects zone 1241 (map ID), causing pins to silently not appear.
zoneId = NormalizeSpawnZoneKey(zoneId)
local spawnUID = _ExtractSpawnUID(dstGUID) local spawnUID = _ExtractSpawnUID(dstGUID)
if not spawnUID then return end if not spawnUID then return end
@@ -880,10 +966,18 @@ function QuestieLearner:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, x, y)
local guidSpawns = _GetOrCreateGuidSpawnTable(learnedNpc) local guidSpawns = _GetOrCreateGuidSpawnTable(learnedNpc)
if not guidSpawns then return end if not guidSpawns then return end
-- Normalize coords: same format as recentKills so Phase 3 merge is consistent -- Normalize coords to Questie's 0-100 map scale. NormalizeCoordPair handles all
-- Formula: floor(v * 10000) / 100 — converts normalized 0-1 to scaled 0-100 -- three input formats: native 0-1, already-scaled 0-100, or buggy 0-10000.
local nx = floor(x * 10000) / 100 local nx, ny = NormalizeCoordPair(x, y)
local ny = floor(y * 10000) / 100 if not nx or not ny then return end
-- DEBUG: log raw x/y and normalized nx/ny being stored
-- Questie:Debug(Questie.DEBUG_LEARNER,
-- "_StoreGuidSpawnEvidence: npcId=", npcId,
-- "spawnUID=", spawnUID,
-- "x=", x, "y=", y,
-- "nx=", nx, "ny=", ny,
-- "zoneId=", zoneId)
if guidSpawns[spawnUID] then if guidSpawns[spawnUID] then
-- Existing spawn UID: update position and timestamp -- Existing spawn UID: update position and timestamp
@@ -950,15 +1044,30 @@ local function _MergeSpawnEvidence(npcId)
for spawnUID, entry in pairs(guidSpawns) do for spawnUID, entry in pairs(guidSpawns) do
if entry and entry.zoneId and entry.x and entry.y then if entry and entry.zoneId and entry.x and entry.y then
-- Round to 2 decimal places for grouping local evidenceX, evidenceY = NormalizeCoordPair(entry.x, entry.y)
local rx = floor(entry.x * 100 + 0.5) / 100 if not evidenceX or not evidenceY then
local ry = floor(entry.y * 100 + 0.5) / 100 Questie:Debug(Questie.DEBUG_LEARNER,
local key = entry.zoneId .. "|" .. rx .. "|" .. ry "[QuestieLearner] _MergeSpawnEvidence skipping invalid coords: spawnUID=", spawnUID,
if not evidence[key] then "entry.x=", tostring(entry.x), "entry.y=", tostring(entry.y))
evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 } else
entry.x = evidenceX
entry.y = evidenceY
-- Round to 2 decimal places for grouping
local rx = floor(evidenceX * 100 + 0.5) / 100
local ry = floor(evidenceY * 100 + 0.5) / 100
local key = entry.zoneId .. "|" .. rx .. "|" .. 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)
if not evidence[key] then
evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 }
end
evidence[key].count = evidence[key].count + 1
totalEvidence = totalEvidence + 1
end end
evidence[key].count = evidence[key].count + 1
totalEvidence = totalEvidence + 1
end end
end end
@@ -979,33 +1088,119 @@ local function _MergeSpawnEvidence(npcId)
local topEvidence = evidence[topKey] local topEvidence = evidence[topKey]
local topPct = (topCount / totalEvidence) * 100 local topPct = (topCount / totalEvidence) * 100
-- Only override if > 60% confidence AND spawn differs from static DB -- DEBUG: log topEvidence raw values to diagnose coordinate corruption
if topPct <= 60 then Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence DEBUG: topKey=", topKey,
"topEvidence.x=", topEvidence.x, "topEvidence.y=", topEvidence.y,
"topCount=", topCount, "totalEvidence=", totalEvidence)
-- Sunstrider Isle: zone IDs are now normalized via NormalizeSpawnZoneKey so
-- topEvidence.zoneId will be 1241 (map ID), not 3431 (area ID).
-- IsSunstriderNativeZone checks both to be safe against old saved data.
-- Confidence threshold is bypassed for Sunstrider because spawn points are
-- distributed across 5+ locations — no single point ever reaches 60% of kills.
local isSunstrider = IsSunstriderNativeZone(topEvidence.zoneId)
local confidenceThreshold = isSunstrider and 0 or 60
-- Only override if > confidence threshold AND spawn differs from static DB
if topPct <= confidenceThreshold then
Questie:Debug(Questie.DEBUG_LEARNER, Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
"top spawn", topCount .. "/" .. totalEvidence, "top spawn", topCount .. "/" .. totalEvidence,
"= " .. floor(topPct + 0.5) .. "% — below 60%, no override") "= " .. floor(topPct + 0.5) .. "%" ..
return false (isSunstrider and " — Sunstrider, threshold bypassed" or (" — below " .. confidenceThreshold .. "%, no override")))
-- For Sunstrider, fall through and apply the learned spawn anyway
if not isSunstrider then
return false
end
end end
-- Check against static DB entry if isSunstrider then
-- AscensionDB owns spawn data for known Sunstrider NPCs — never overwrite it.
-- Key 7 = spawns. Without this guard the learner would pollute the curated
-- AscensionDB coords with in-game kill evidence, causing wrong pin counts.
-- REGRESSION NOTE: If AscensionDB protection check is removed or disabled,
-- learner pins will reappear at wrong locations. Do not remove this guard.
if IsAscensionProtected("NPC", npcId, 7) then
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
"Sunstrider zone but AscensionDB owns spawns — skipping learner injection")
return false
end
-- topEvidence.zoneId is now a map ID (e.g. 1241) because NormalizeSpawnZoneKey
-- converted the area ID at storage time. This matches AscensionDB's key space
-- so DrawWorldIcon and HBD can resolve the coordinates correctly.
QuestieDB.npcDataOverrides[npcId] = QuestieDB.npcDataOverrides[npcId] or {}
QuestieDB.npcDataOverrides[npcId][7] = QuestieDB.npcDataOverrides[npcId][7] or {}
QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] or {}
local promoted = 0
local duplicates = 0
local zoneSpawns = QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId]
local grid = GetCoordGridForZone(topEvidence.zoneId)
for _, spawnEvidence in pairs(evidence) do
if InsertIfNewBucket(zoneSpawns, spawnEvidence.x, spawnEvidence.y, grid) then
promoted = promoted + 1
else
duplicates = duplicates + 1
end
end
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
"promoted Sunstrider evidence groups", promoted,
"duplicates", duplicates,
"zone", tostring(topEvidence.zoneId))
if QuestieDB.private and QuestieDB.private.npcCache then
QuestieDB.private.npcCache[npcId] = nil
end
return promoted > 0 or duplicates > 0
end
-- Check against static DB + learner-promoted override entries.
-- After a previous promotion, QueryNPCSingle returns the override data,
-- so learner-promoted spawns would incorrectly "match static" and block
-- re-promotion. We must exclude overrides we wrote ourselves.
local staticNPC = nil local staticNPC = nil
if QuestieDB and QuestieDB.QueryNPC then if QuestieDB and QuestieDB.QueryNPCSingle then
staticNPC = QuestieDB.QueryNPC(npcId, 1) staticNPC = QuestieDB.QueryNPCSingle(npcId, "spawns")
end end
local staticSpawnList = staticNPC and staticNPC[7] local staticSpawnList = staticNPC
local staticSpawnsForZone = staticSpawnList and staticSpawnList[topEvidence.zoneId] local staticSpawnsForZone = staticSpawnList and staticSpawnList[topEvidence.zoneId]
-- Check if top spawn matches any static spawn in the same zone -- Collect spawns already promoted by the learner for this zone
local learnerOverrides = QuestieDB.npcDataOverrides
and QuestieDB.npcDataOverrides[npcId]
and QuestieDB.npcDataOverrides[npcId][7]
and QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId]
local learnerOverrideSet = {}
if learnerOverrides then
for _, entry in ipairs(learnerOverrides) do
if entry and entry[1] and entry[2] then
local lx = floor(entry[1] * 100 + 0.5) / 100
local ly = floor(entry[2] * 100 + 0.5) / 100
learnerOverrideSet[lx .. "|" .. ly] = true
end
end
end
-- Check if top spawn matches any static spawn in the same zone,
-- excluding learner-promoted overrides (those should always be updatable)
local matchesStatic = false local matchesStatic = false
if staticSpawnsForZone then if staticSpawnsForZone then
for _, coord in ipairs(staticSpawnsForZone) do for _, coord in ipairs(staticSpawnsForZone) do
local sx = floor(coord[1] * 100 + 0.5) / 100 local sx = floor(coord[1] * 100 + 0.5) / 100
local sy = floor(coord[2] * 100 + 0.5) / 100 local sy = floor(coord[2] * 100 + 0.5) / 100
if abs(sx - topEvidence.x) < 0.01 and abs(sy - topEvidence.y) < 0.01 then -- Skip learner-promoted entries — they are not "static"
matchesStatic = true if not learnerOverrideSet[sx .. "|" .. sy] then
break if abs(sx - topEvidence.x) < 0.01 and abs(sy - topEvidence.y) < 0.01 then
matchesStatic = true
break
end
end end
end end
end end
@@ -1017,25 +1212,47 @@ local function _MergeSpawnEvidence(npcId)
return false return false
end end
-- Override: inject top spawn into QuestieDB spawn overrides for this zone -- Test mode: allow learned Sunstrider spawn evidence to overwrite live
if not QuestieDB.npcDataOverrides[npcId] then -- override data even if AscensionDB owns the field. If this restores the
QuestieDB.npcDataOverrides[npcId] = {} -- Mana Wyrm pins, the ownership gate is the thing blocking live updates.
end --[[ if IsAscensionProtected("NPC", npcId, 7) then
if not QuestieDB.npcDataOverrides[npcId][7] then Questie:Debug(Questie.DEBUG_LEARNER,
QuestieDB.npcDataOverrides[npcId][7] = {} "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
end "spawn override skipped because AscensionDB owns this NPC spawn field")
if not QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] then return false
QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = {} end --]]
end
-- Insert as new spawn (InsertIfNewBucket deduplicates) Questie:Debug(Questie.DEBUG_LEARNER,
InsertIfNewBucket(QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId], "[QuestieLearner] _MergeSpawnEvidence promoting npcId", npcId,
topEvidence.x, topEvidence.y) "zone", tostring(topEvidence.zoneId),
"x", tostring(topEvidence.x),
"y", tostring(topEvidence.y),
"protected", tostring(IsAscensionProtected("NPC", npcId, 7)))
-- Insert as new spawn (InsertIfNewBucket deduplicates).
-- Only create zone table entry if insert succeeds — an empty zone override
-- {[3431] = {}} makes _MergeOverride's IsEmptyTable check fall through to
-- rawdata, bypassing learner data entirely (field is non-nil but empty).
local spawned = false
if topEvidence.x and topEvidence.y then
if not QuestieDB.npcDataOverrides[npcId] then
QuestieDB.npcDataOverrides[npcId] = {}
end
if not QuestieDB.npcDataOverrides[npcId][7] then
QuestieDB.npcDataOverrides[npcId][7] = {}
end
if not QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] then
QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = {}
end
spawned = InsertIfNewBucket(QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId],
topEvidence.x, topEvidence.y, GetCoordGridForZone(topEvidence.zoneId))
end
Questie:Debug(Questie.DEBUG_LEARNER, Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
"overrode static DB — learned spawn (" .. topEvidence.x .. "," .. topEvidence.y .. ")", "overrode static DB — learned spawn (" .. tostring(topEvidence.x) .. "," .. tostring(topEvidence.y) .. ")",
"zone " .. topEvidence.zoneId .. " at " .. floor(topPct + 0.5) .. "% confidence") "zone " .. topEvidence.zoneId .. " at " .. floor(topPct + 0.5) .. "% confidence",
spawned and "SPAM" or "IGNORED_DUPLICATE")
-- Clear npcCache so GetNPC returns fresh data -- Clear npcCache so GetNPC returns fresh data
if QuestieDB.private and QuestieDB.private.npcCache then if QuestieDB.private and QuestieDB.private.npcCache then
@@ -1092,7 +1309,7 @@ function QuestieLearner:LearnQuest(questId, data)
QuestieDB.questDataOverrides[questId] = existing QuestieDB.questDataOverrides[questId] = existing
else else
for k, v in pairs(existing) do for k, v in pairs(existing) do
if ovr[k] == nil then ovr[k] = v end if ovr[k] == nil and not IsAscensionProtected("QUEST", questId, k) then ovr[k] = v end
end end
end end
end end
@@ -1130,7 +1347,7 @@ function QuestieLearner:LearnQuestGiver(questId, entityId, entityType, isStart)
table.insert(list, entityId) table.insert(list, entityId)
-- Live injection into questDataOverrides so starters/finishers take effect without reload -- Live injection into questDataOverrides so starters/finishers take effect without reload
if QuestieDB and QuestieDB.questDataOverrides then if QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, field) then
local ovr = QuestieDB.questDataOverrides[questId] or {} local ovr = QuestieDB.questDataOverrides[questId] or {}
QuestieDB.questDataOverrides[questId] = ovr QuestieDB.questDataOverrides[questId] = ovr
ovr[field] = ovr[field] or {} ovr[field] = ovr[field] or {}
@@ -1196,7 +1413,7 @@ function QuestieLearner:LearnQuestObjectiveNPC(questId, npcId, objText, objectiv
end end
-- 2. Apply to live questDataOverrides immediately (no reload needed) -- 2. Apply to live questDataOverrides immediately (no reload needed)
if QuestieDB and QuestieDB.questDataOverrides then if QuestieDB and QuestieDB.questDataOverrides and not IsAscensionProtected("QUEST", questId, 10) then
local ovr = QuestieDB.questDataOverrides[questId] or {} local ovr = QuestieDB.questDataOverrides[questId] or {}
QuestieDB.questDataOverrides[questId] = ovr QuestieDB.questDataOverrides[questId] = ovr
ovr[10] = ovr[10] or {} ovr[10] = ovr[10] or {}
@@ -1283,7 +1500,7 @@ function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemCl
QuestieDB.itemDataOverrides[itemId] = existing QuestieDB.itemDataOverrides[itemId] = existing
else else
for k, v in pairs(existing) do for k, v in pairs(existing) do
if ovr[k] == nil then ovr[k] = v end if ovr[k] == nil and not IsAscensionProtected("ITEM", itemId, k) then ovr[k] = v end
end end
end end
end end
@@ -1316,7 +1533,7 @@ function QuestieLearner:LearnItemDrop(itemId, npcId)
table.insert(existing[2], npcId) table.insert(existing[2], npcId)
-- Live injection: sync drop list to itemDataOverrides -- Live injection: sync drop list to itemDataOverrides
if QuestieDB and QuestieDB.itemDataOverrides then if QuestieDB and QuestieDB.itemDataOverrides and not IsAscensionProtected("ITEM", itemId, 2) then
local ovr = QuestieDB.itemDataOverrides[itemId] or {} local ovr = QuestieDB.itemDataOverrides[itemId] or {}
QuestieDB.itemDataOverrides[itemId] = ovr QuestieDB.itemDataOverrides[itemId] = ovr
ovr[2] = ovr[2] or {} ovr[2] = ovr[2] or {}
@@ -1370,9 +1587,9 @@ function QuestieLearner:LearnObject(objectId, name)
QuestieDB.objectDataOverrides[objectId] = existing QuestieDB.objectDataOverrides[objectId] = existing
else else
for k, v in pairs(existing) do for k, v in pairs(existing) do
if ovr[k] == nil then ovr[k] = v end if ovr[k] == nil and not IsAscensionProtected("OBJECT", objectId, k) then ovr[k] = v end
end end
if existing[4] then if existing[4] and not IsAscensionProtected("OBJECT", objectId, 4) then
ovr[4] = ovr[4] or {} ovr[4] = ovr[4] or {}
for zid, coords in pairs(existing[4]) do for zid, coords in pairs(existing[4]) do
ovr[4][zid] = ovr[4][zid] or {} ovr[4][zid] = ovr[4][zid] or {}
@@ -1500,14 +1717,23 @@ function QuestieLearner:InjectLearnedData()
if data[7] then if data[7] then
local zonesToMigrate = {} local zonesToMigrate = {}
for zoneKey, coords in pairs(data[7]) do for zoneKey, coords in pairs(data[7]) do
local originalZoneKey = zoneKey
zoneKey = NormalizeSpawnZoneKey(zoneKey)
-- Older Ascension learner data stored native Sunstrider coords
-- under parent areaId 3430 while [9] still identified the row as
-- Sunstrider. Move those coords to areaId 3431 so they render on
-- uiMapId 1241 instead of Eversong.
if originalZoneKey == 3430 and IsSunstriderNativeZone(data[9]) then
zonesToMigrate[originalZoneKey] = 3431
end
-- If zoneKey looks like a uiMapId (a map ID rather than an areaId), -- If zoneKey looks like a uiMapId (a map ID rather than an areaId),
-- ZoneDB:GetAreaIdByUiMapId will return the corresponding areaId. -- ZoneDB:GetAreaIdByUiMapId will return the corresponding areaId.
-- If it returns nil, zoneKey is already an areaId — no migration needed. -- If it returns nil, zoneKey is already an areaId — no migration needed.
-- Skip very common areaIds that happen to look like small numbers. -- Skip very common areaIds that happen to look like small numbers.
if ZoneDB and ZoneDB.GetAreaIdByUiMapId then if not zonesToMigrate[originalZoneKey] and ZoneDB and ZoneDB.GetAreaIdByUiMapId then
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey) local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey)
if maybeAreaId and maybeAreaId ~= zoneKey then if maybeAreaId and maybeAreaId ~= zoneKey then
zonesToMigrate[zoneKey] = maybeAreaId zonesToMigrate[originalZoneKey] = maybeAreaId
end end
end end
end end
@@ -1529,10 +1755,15 @@ function QuestieLearner:InjectLearnedData()
if data[4] then if data[4] then
local zonesToMigrate = {} local zonesToMigrate = {}
for zoneKey, coords in pairs(data[4]) do for zoneKey, coords in pairs(data[4]) do
if ZoneDB and ZoneDB.GetAreaIdByUiMapId then local originalZoneKey = zoneKey
zoneKey = NormalizeSpawnZoneKey(zoneKey)
if originalZoneKey == 3430 and IsSunstriderNativeZone(data[5]) then
zonesToMigrate[originalZoneKey] = 3431
end
if not zonesToMigrate[originalZoneKey] and ZoneDB and ZoneDB.GetAreaIdByUiMapId then
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey) local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(zoneKey)
if maybeAreaId and maybeAreaId ~= zoneKey then if maybeAreaId and maybeAreaId ~= zoneKey then
zonesToMigrate[zoneKey] = maybeAreaId zonesToMigrate[originalZoneKey] = maybeAreaId
end end
end end
end end
@@ -1558,7 +1789,8 @@ function QuestieLearner:InjectLearnedData()
local fieldsFixed = 0 local fieldsFixed = 0
for npcId, data in pairs(learned.npcs) do for npcId, data in pairs(learned.npcs) do
if type(data[9]) == "number" and ZoneDB and ZoneDB.GetAreaIdByUiMapId then if type(data[9]) == "number" and ZoneDB and ZoneDB.GetAreaIdByUiMapId then
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(data[9]) local normalizedZone = NormalizeSpawnZoneKey(data[9])
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(normalizedZone)
if maybeAreaId and maybeAreaId ~= data[9] then if maybeAreaId and maybeAreaId ~= data[9] then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] NPC", npcId, "zone field [9]", data[9], "->", maybeAreaId) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] NPC", npcId, "zone field [9]", data[9], "->", maybeAreaId)
data[9] = maybeAreaId data[9] = maybeAreaId
@@ -1568,7 +1800,8 @@ function QuestieLearner:InjectLearnedData()
end end
for objId, data in pairs(learned.objects) do for objId, data in pairs(learned.objects) do
if type(data[5]) == "number" and ZoneDB and ZoneDB.GetAreaIdByUiMapId then if type(data[5]) == "number" and ZoneDB and ZoneDB.GetAreaIdByUiMapId then
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(data[5]) local normalizedZone = NormalizeSpawnZoneKey(data[5])
local maybeAreaId = ZoneDB:GetAreaIdByUiMapId(normalizedZone)
if maybeAreaId and maybeAreaId ~= data[5] then if maybeAreaId and maybeAreaId ~= data[5] then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Object", objId, "zone field [5]", data[5], "->", maybeAreaId) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Object", objId, "zone field [5]", data[5], "->", maybeAreaId)
data[5] = maybeAreaId data[5] = maybeAreaId
@@ -1642,7 +1875,7 @@ function QuestieLearner:InjectLearnedData()
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] then if data[7] and not IsAscensionProtected("NPC", nid or npcId, 7) then
existing[7] = existing[7] or {} existing[7] = existing[7] or {}
for zoneId, coords in pairs(data[7]) do for zoneId, coords in pairs(data[7]) do
existing[7][zoneId] = existing[7][zoneId] or {} existing[7][zoneId] = existing[7][zoneId] or {}
@@ -1653,7 +1886,7 @@ function QuestieLearner:InjectLearnedData()
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 then if k ~= "mc" and k ~= 7 and existing[k] == nil and not IsAscensionProtected("NPC", nid or npcId, k) then
existing[k] = v existing[k] = v
end end
end end
@@ -1776,20 +2009,22 @@ function QuestieLearner:InjectLearnedData()
if k ~= "mc" then if k ~= "mc" then
if k == 10 then if k == 10 then
-- Special merge: add learned creatureObjective entries to [10][1] -- Special merge: add learned creatureObjective entries to [10][1]
existing[10] = existing[10] or {} if not IsAscensionProtected("QUEST", qid or questId, 10) then
existing[10][1] = existing[10][1] or {} existing[10] = existing[10] or {}
if type(v[1]) == "table" then existing[10][1] = existing[10][1] or {}
for _, entry in ipairs(v[1]) do if type(v[1]) == "table" then
local found = false for _, entry in ipairs(v[1]) do
for _, ex in ipairs(existing[10][1]) do local found = false
if ex[1] == entry[1] then found = true; break end for _, ex in ipairs(existing[10][1]) do
end if ex[1] == entry[1] then found = true; break end
if not found then end
tinsert(existing[10][1], entry) if not found then
tinsert(existing[10][1], entry)
end
end end
end end
end end
elseif existing[k] == nil then elseif existing[k] == nil and not IsAscensionProtected("QUEST", qid or questId, k) then
existing[k] = v existing[k] = v
end end
end end
@@ -1831,7 +2066,7 @@ function QuestieLearner:InjectLearnedData()
objectCount = objectCount + 1 objectCount = objectCount + 1
else else
local existing = QuestieDB.objectDataOverrides[oid or objectId] local existing = QuestieDB.objectDataOverrides[oid or objectId]
if data[4] then if data[4] and not IsAscensionProtected("OBJECT", oid or objectId, 4) then
existing[4] = existing[4] or {} existing[4] = existing[4] or {}
for zoneId, coords in pairs(data[4]) do for zoneId, coords in pairs(data[4]) do
existing[4][zoneId] = existing[4][zoneId] or {} existing[4][zoneId] = existing[4][zoneId] or {}
@@ -1842,7 +2077,7 @@ function QuestieLearner:InjectLearnedData()
end end
-- Adopt other fields -- Adopt other fields
for k, v in pairs(data) do for k, v in pairs(data) do
if k ~= "mc" and k ~= 4 and existing[k] == nil then if k ~= "mc" and k ~= 4 and existing[k] == nil and not IsAscensionProtected("OBJECT", oid or objectId, k) then
existing[k] = v existing[k] = v
end end
end end
@@ -2438,8 +2673,8 @@ function QuestieLearner:LearnSpellCast(spellId, spellName, dstGUID, dstName)
for _, obj in pairs(quest.objectives) do for _, obj in pairs(quest.objectives) do
-- If the objective is a spell or requires this spell -- If the objective is a spell or requires this spell
if obj.type == "spell" and obj.text and obj.text:find(spellName, 1, true) then if obj.type == "spell" and obj.text and obj.text:find(spellName, 1, true) then
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Learning spell cast:", spellId, spellName, "on", dstName or "nil") -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Learning spell cast:", spellId, spellName, "on", dstName or "nil")
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Found spell objective match for quest", questId) -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Found spell objective match for quest", questId)
local data = { [10] = { [1] = {} } } local data = { [10] = { [1] = {} } }
if npcId then if npcId then
tinsert(data[10][1], { npcId, spellName }) tinsert(data[10][1], { npcId, spellName })
@@ -2526,7 +2761,7 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName,
end end
-- Permanent minimal log: combat-log path, event, and target -- 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) -- 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 -- Vanilla: neither modern API nor legacy args available — throttle warning, do NOT disable permanently
if not timestamp then if not timestamp then
@@ -2556,7 +2791,7 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName,
local now = time() local now = time()
local lastTs = _Learner.killDebounce and _Learner.killDebounce[dstGUID] local lastTs = _Learner.killDebounce and _Learner.killDebounce[dstGUID]
if lastTs and (now - lastTs) < 5 then if lastTs and (now - lastTs) < 5 then
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID) -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID)
return return
end end
_Learner.killDebounce = _Learner.killDebounce or {} _Learner.killDebounce = _Learner.killDebounce or {}
@@ -2613,7 +2848,7 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName,
} }
if dstName and dstName ~= "" then if dstName and dstName ~= "" then
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Kill cached for correlation:", npcId, dstName, "@", tostring(px), tostring(py), "zone", tostring(zoneId)) -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Kill cached for correlation:", npcId, dstName, "@", tostring(px), tostring(py), "zone", tostring(zoneId))
end end
-- Unconditionally map the spawn position for Ascension DB building -- Unconditionally map the spawn position for Ascension DB building
@@ -2621,14 +2856,36 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName,
-- Phase 2: store per-GUID spawn evidence for weighted merge -- Phase 2: store per-GUID spawn evidence for weighted merge
self:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, px, py) self:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, px, py)
local guidSpawnsAfterStore = Questie.dbLearner.global.npcs[npcId]
and Questie.dbLearner.global.npcs[npcId][8]
if guidSpawnsAfterStore then
local guidCount = 0
for _ in pairs(guidSpawnsAfterStore) do guidCount = guidCount + 1 end
-- Questie:Debug(Questie.DEBUG_LEARNER,
-- "[QuestieLearner] GUID spawn evidence stored:",
-- npcId, dstName or name or "?",
-- "guidCount", guidCount,
-- "zone", tostring(zoneId),
-- "x", tostring(px),
-- "y", tostring(py))
else
-- Questie:Debug(Questie.DEBUG_LEARNER,
-- "[QuestieLearner] GUID spawn evidence missing after store:",
-- npcId, dstName or name or "?",
-- "zone", tostring(zoneId),
-- "x", tostring(px),
-- "y", tostring(py))
end
-- Phase 3: weighted merge when evidence count >= 3 -- Phase 3: weighted merge when evidence count is sufficient.
-- Temporarily lowered to 1 for Sunstrider/Mana Wyrm diagnostics so we can
-- verify the promotion path immediately.
local guidSpawns = Questie.dbLearner.global.npcs[npcId] local guidSpawns = Questie.dbLearner.global.npcs[npcId]
and Questie.dbLearner.global.npcs[npcId][8] and Questie.dbLearner.global.npcs[npcId][8]
if guidSpawns then if guidSpawns then
local count = 0 local count = 0
for _ in pairs(guidSpawns) do count = count + 1 end for _ in pairs(guidSpawns) do count = count + 1 end
if count >= 3 then if count >= 1 then
_MergeSpawnEvidence(npcId) _MergeSpawnEvidence(npcId)
end end
end end
@@ -2806,7 +3063,7 @@ function QuestieLearner:PruneLearnedSpawnOutliers(threshold)
-- Check if static DB has anchors for this NPC+zone -- Check if static DB has anchors for this NPC+zone
local staticNPC = nil local staticNPC = nil
if QuestieDB and QuestieDB.QueryNPC then if QuestieDB and QuestieDB.QueryNPC then
staticNPC = QuestieDB.QueryNPC(npcId, 1) staticNPC = QuestieDB.QueryNPCSingle and QuestieDB.QueryNPCSingle(npcId, "spawns") or nil
end end
if staticNPC and staticNPC[7] and staticNPC[7][zoneId] then if staticNPC and staticNPC[7] and staticNPC[7][zoneId] then
@@ -2913,7 +3170,7 @@ function QuestieLearner:OnQuestLogUpdate()
local _, _, _, isHeader, _, _, _, questId = QuestieCompat.GetQuestLogTitle(i) local _, _, _, isHeader, _, _, _, questId = QuestieCompat.GetQuestLogTitle(i)
if not isHeader and questId and questId > 0 then if not isHeader and questId and questId > 0 then
local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(i) or 0 local numObj = GetNumQuestLeaderBoards and GetNumQuestLeaderBoards(i) or 0
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestLogUpdate scanning quest", questId, "logIdx", i, "numObj", numObj) -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] OnQuestLogUpdate scanning quest", questId, "logIdx", i, "numObj", numObj)
_Learner.prevObjCounts[questId] = _Learner.prevObjCounts[questId] or {} _Learner.prevObjCounts[questId] = _Learner.prevObjCounts[questId] or {}
for j = 1, numObj do for j = 1, numObj do
local objText, objType, finished = GetQuestLogLeaderBoard(j, i) local objText, objType, finished = GetQuestLogLeaderBoard(j, i)
@@ -2924,11 +3181,11 @@ function QuestieLearner:OnQuestLogUpdate()
local count = tonumber(objText:match(":?%s*(%d+)%s*/")) local count = tonumber(objText:match(":?%s*(%d+)%s*/"))
local prev = _Learner.prevObjCounts[questId][j] local prev = _Learner.prevObjCounts[questId][j]
Questie:Debug(Questie.DEBUG_LEARNER, -- Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] OnQuestLogUpdate quest", questId, -- "[QuestieLearner] OnQuestLogUpdate quest", questId,
"obj", j, "type:", tostring(objType), -- "obj", j, "type:", tostring(objType),
"count:", tostring(count), "prev:", tostring(prev), -- "count:", tostring(count), "prev:", tostring(prev),
"text:", tostring(objText)) -- "text:", tostring(objText))
-- Seed on first sight; only correlate on confirmed increase -- Seed on first sight; only correlate on confirmed increase
if prev == nil then if prev == nil then
@@ -3012,8 +3269,8 @@ local function _RegisterLearnedSpawnTooltipHook()
if _tooltipHookRegistered then return end if _tooltipHookRegistered then return end
_tooltipHookRegistered = true _tooltipHookRegistered = true
GameTooltip:HookScript("OnTooltipSetUnit", function() GameTooltip:HookScript("OnTooltipSetUnit", function()
-- self == GameTooltip -- HookScript handlers do not reliably receive the frame as an argument on 3.3.5a.
local _, unitToken = self:GetUnit() local _, unitToken = GameTooltip:GetUnit()
if unitToken then if unitToken then
_AddLearnedSpawnTooltipLine(unitToken) _AddLearnedSpawnTooltipLine(unitToken)
end end
+12 -4
View File
@@ -712,9 +712,15 @@ local function _GetZoneName(zoneOrSort, questId, zoneNameOverride)
elseif (zoneOrSort) < 0 then elseif (zoneOrSort) < 0 then
zoneName = TrackerUtils:GetCategoryNameByID(zoneOrSort) zoneName = TrackerUtils:GetCategoryNameByID(zoneOrSort)
else else
zoneName = "Unknown Zone" -- zoneOrSort == 0: try quest log header as last resort before "Unknown Zone"
Questie:Debug(Questie.DEBUG_CRITICAL, "[TrackerUtils:_GetZoneName] zoneOrSort", zoneOrSort, "of quest", local logZone = GetQuestLogZoneName(questId)
questId, "is not in the Database!") if logZone then
zoneName = logZone
else
zoneName = "Unknown Zone"
Questie:Debug(Questie.DEBUG_CRITICAL, "[TrackerUtils:_GetZoneName] zoneOrSort", zoneOrSort, "of quest",
questId, "is not in the Database!")
end
end end
else else
if sortObj == "byComplete" then if sortObj == "byComplete" then
@@ -837,7 +843,9 @@ function TrackerUtils:GetSortedQuestIds()
if not quest.SpecialObjectives then quest.SpecialObjectives = {} end if not quest.SpecialObjectives then quest.SpecialObjectives = {} end
if not quest.ExtraObjectives then quest.ExtraObjectives = {} end if not quest.ExtraObjectives then quest.ExtraObjectives = {} end
-- Use the quest log header walk (canonical 3.3.5 zone resolution) -- Use the quest log header walk (canonical 3.3.5 zone resolution)
if not quest.zoneName or quest.zoneName == "" then -- Also run if zoneName is set but zoneOrSort is still 0 (e.g. GetAreaIdByZoneName
-- returned 0 for a valid zone name like "Sunstrider Isle", leaving zoneOrSort wrong).
if not quest.zoneName or quest.zoneName == "" or quest.zoneOrSort == 0 then
local logZone = GetQuestLogZoneName(capturedId) local logZone = GetQuestLogZoneName(capturedId)
if logZone then if logZone then
quest.zoneName = logZone quest.zoneName = logZone
+38
View File
@@ -134,6 +134,44 @@ describe("QuestieLearner", function()
assert.is_true(entry2.ts >= ts1) -- timestamp refreshed assert.is_true(entry2.ts >= ts1) -- timestamp refreshed
end) end)
it("should not double-scale already normalized GUID spawn coordinates", function()
local npcId = 15274
local unitGUID = "Creature-0-1234-567-89-15274-41298"
Questie.dbLearner.global.npcs = {
[npcId] = {
[1] = "Mana Wyrm",
},
}
Questie.dbLearner.global.settings.learnNpcs = true
QuestieLearner:_StoreGuidSpawnEvidence(npcId, unitGUID, 3431, 58.68, 43.19)
local entry = Questie.dbLearner.global.npcs[npcId][8][41298]
assert.is_not_nil(entry)
assert.are.equal(58.68, entry.x)
assert.are.equal(43.19, entry.y)
end)
it("should repair legacy 0-10000 GUID spawn coordinates before merging", function()
local npcId = 15274
local unitGUID = "Creature-0-1234-567-89-15274-41298"
Questie.dbLearner.global.npcs = {
[npcId] = {
[1] = "Mana Wyrm",
},
}
Questie.dbLearner.global.settings.learnNpcs = true
QuestieLearner:_StoreGuidSpawnEvidence(npcId, unitGUID, 3431, 5868, 4319)
local entry = Questie.dbLearner.global.npcs[npcId][8][41298]
assert.is_not_nil(entry)
assert.are.equal(58.68, entry.x)
assert.are.equal(43.19, entry.y)
end)
--========================================================================== --==========================================================================
-- Existing test: spell cast learning -- Existing test: spell cast learning
--========================================================================== --==========================================================================
+141
View File
@@ -0,0 +1,141 @@
## Questie-X Learner Module — Pin Collapse Handoff
**Session**: 2025-05-25
**Repo**: `C:\Users\kance\Documents\GitHub\Questie-X`
**File**: `Modules/QuestieLearner.lua`
---
## Problem Statement
Killing multiple Mana Wyrms (npcId ~15274) on Sunstrider Isle (zoneId 3431) at distinct map coordinates still results in only **2 map pins** rendering instead of 3+ distinct pins. The collapse is caused by the coordinate **bucketing** logic in `_MergeSpawnEvidence` and `InsertIfNewBucket`.
---
## Two Identified Bucketing Layers
### Layer 1 — `_MergeSpawnEvidence` lines ~10461048
Groups all per-GUID evidence into buckets by rounding `(x, y)` to 2 decimal places:
```lua
local rx = floor(evidenceX * 100 + 0.5) / 100
local ry = floor(evidenceY * -100 + 0.5) / 100
local key = entry.zoneId .. "|" .. rx .. "|" .. ry
```
Each unique 2-decimal `(rx, ry)` becomes one evidence group. If two distinct kill locations fall within the same 0.01×0.01 square, they merge into one evidence group here — **before** `InsertIfNewBucket` is even called.
### Layer 2 — `InsertIfNewBucket` lines 205221
For Sunstrider, uses `grid = 0.5`. Checks if any existing spawn in the zone falls in the same `floor(x/0.5)*0.5` bucket:
```lua
local bx, by = floor(x / grid) * grid, floor(y / grid) * grid
```
Two kills at x=50.54 and x=50.55 both land in bucket 50.5 → first is inserted, second is rejected as duplicate. This is the **documented intentional collapse** — but it's killing genuinely distinct spawn points that a 0.5 grid rounds to the same bucket.
---
## Fixes Already Applied
1. **`GetCoordGridForZone` hoisted outside evidence loop (line ~1114)**
Grid is now computed once before the Sunstrider promotion loop. Previously it was computed inside `InsertIfNewBucket` via `customGrid` parameter.
2. **`NormalizeCoordPair` comment (line 958959)**
Updated to document that it handles native 01, already-scaled 0100, and buggy 010000 input formats.
3. **`QUESTIE_LEARNER_debug` log comment (line 963964)**
Noted it is commented out — re-enable for live debugging if needed.
---
## Recommended Next Step: Remove Bucketing Entirely
**The fix in this session did not resolve the 2-pin collapse.** The next agent should:
1. In `_MergeSpawnEvidence` (~line 10461048): **comment out or replace** the 2-decimal rounding entirely. Instead of grouping by `(zoneId|rx|ry)`, use the raw normalized coordinates directly. This makes every distinct kill location a separate evidence group.
2. In the Sunstrider promotion block (~lines 11111121): **bypass `InsertIfNewBucket`** entirely for Sunstrider. Instead of bucketing, insert the raw coordinates of every evidence group into `zoneSpawns` directly.
### Exact Changes to Make
**`_MergeSpawnEvidence` (~line 10451048) — REMOVE 2-decimal rounding:**
Replace:
```lua
-- Round to 2 decimal places for grouping
local rx = floor(evidenceX * 100 + 0.5) / 100
local ry = floor(evidenceY * 100 + 0.5) / 100
local key = entry.zoneId .. "|" .. rx .. "|" .. ry
```
With:
```lua
-- Use raw normalized coordinates as group key (no bucket collapse)
local rx = evidenceX
local ry = evidenceY
local key = entry.zoneId .. "|" .. rx .. "|" .. ry
```
**Sunstrider loop (~lines 11141121) — DIRECT INSERT (bypass `InsertIfNewBucket`):**
Replace:
```lua
local grid = GetCoordGridForZone(topEvidence.zoneId)
for _, spawnEvidence in pairs(evidence) do
if InsertIfNewBucket(zoneSpawns, spawnEvidence.x, spawnEvidence.y, grid) then
promoted = promoted + 1
else
duplicates = duplicates + 1
end
end
```
With:
```lua
-- Directly insert every distinct evidence coordinate without bucketing
for _, spawnEvidence in pairs(evidence) do
tinsert(zoneSpawns, { spawnEvidence.x, spawnEvidence.y })
promoted = promoted + 1
end
duplicates = 0
```
**Rationale**: Bucketing was designed to reduce noise from GPS drift — but on Sunstrider with a 0.5 grid, it collapses spawn points that are legitimately different. Removing bucketing entirely ensures every distinct kill location gets its own pin.
---
## Key Code Locations
| Function | Lines | Purpose |
|---|---|---|
| `NormalizeCoordPair` | ~168197 | Scales coords to 0100; handles buggy inputs |
| `CoordBucket` | ~200201 | Bucket key for (x, y) using COORD_GRID |
| `InsertIfNewBucket` | ~205224 | Inserts coord only if no existing in same bucket |
| `GetCoordGridForZone` | ~108113 | Returns 0.5 for Sunstrider (zones 1241/3431), else 2.0 |
| `_StoreGuidSpawnEvidence` | ~9421012 | Stores per-GUID kill evidence; calls NormalizeCoordPair |
| `_MergeSpawnEvidence` | ~10141140 | Groups evidence → promotes top groups to npcDataOverrides |
---
## Debug Log
Re-enable this block (`_StoreGuidSpawnEvidence`, lines ~963964) to verify normalized coordinates during gameplay:
```lua
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _StoreGuidSpawnEvidence: spawnUID=", spawnUID,
"zoneId=", zoneId, "nx=", nx, "ny=", ny)
```
Expected log output after `/reload`:
```
entry.x=58.68 entry.y=43.19 (not "5868,4319")
```
---
## Files Modified
- `Modules/QuestieLearner.lua` — lines 958959, 1114 (already patched prior to this handoff)
- `Tests/QuestieLearner_spec.lua` — (unchanged; legacy reference)