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
+60 -43
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,23 +58,23 @@ 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
@@ -95,23 +83,40 @@ local function ApplyAscensionBounds()
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
+320 -63
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,10 +1044,24 @@ 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
local evidenceX, evidenceY = NormalizeCoordPair(entry.x, entry.y)
if not evidenceX or not evidenceY then
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence skipping invalid coords: spawnUID=", spawnUID,
"entry.x=", tostring(entry.x), "entry.y=", tostring(entry.y))
else
entry.x = evidenceX
entry.y = evidenceY
-- Round to 2 decimal places for grouping -- Round to 2 decimal places for grouping
local rx = floor(entry.x * 100 + 0.5) / 100 local rx = floor(evidenceX * 100 + 0.5) / 100
local ry = floor(entry.y * 100 + 0.5) / 100 local ry = floor(evidenceY * 100 + 0.5) / 100
local key = entry.zoneId .. "|" .. rx .. "|" .. ry 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 if not evidence[key] then
evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 } evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 }
end end
@@ -961,6 +1069,7 @@ local function _MergeSpawnEvidence(npcId)
totalEvidence = totalEvidence + 1 totalEvidence = totalEvidence + 1
end end
end end
end
if totalEvidence < 3 then return false end if totalEvidence < 3 then return false end
@@ -979,36 +1088,122 @@ 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) .. "%" ..
(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
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 return false
end end
-- Check against static DB entry -- topEvidence.zoneId is now a map ID (e.g. 1241) because NormalizeSpawnZoneKey
local staticNPC = nil -- converted the area ID at storage time. This matches AscensionDB's key space
if QuestieDB and QuestieDB.QueryNPC then -- so DrawWorldIcon and HBD can resolve the coordinates correctly.
staticNPC = QuestieDB.QueryNPC(npcId, 1) 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 end
local staticSpawnList = staticNPC and staticNPC[7] 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
if QuestieDB and QuestieDB.QueryNPCSingle then
staticNPC = QuestieDB.QueryNPCSingle(npcId, "spawns")
end
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
-- Skip learner-promoted entries — they are not "static"
if not learnerOverrideSet[sx .. "|" .. sy] then
if abs(sx - topEvidence.x) < 0.01 and abs(sy - topEvidence.y) < 0.01 then if abs(sx - topEvidence.x) < 0.01 and abs(sy - topEvidence.y) < 0.01 then
matchesStatic = true matchesStatic = true
break break
end end
end end
end end
end
if matchesStatic then if matchesStatic then
Questie:Debug(Questie.DEBUG_LEARNER, Questie:Debug(Questie.DEBUG_LEARNER,
@@ -1017,7 +1212,29 @@ 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
-- override data even if AscensionDB owns the field. If this restores the
-- Mana Wyrm pins, the ownership gate is the thing blocking live updates.
--[[ if IsAscensionProtected("NPC", npcId, 7) then
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence: npcId", npcId,
"spawn override skipped because AscensionDB owns this NPC spawn field")
return false
end --]]
Questie:Debug(Questie.DEBUG_LEARNER,
"[QuestieLearner] _MergeSpawnEvidence promoting npcId", npcId,
"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 if not QuestieDB.npcDataOverrides[npcId] then
QuestieDB.npcDataOverrides[npcId] = {} QuestieDB.npcDataOverrides[npcId] = {}
end end
@@ -1027,15 +1244,15 @@ local function _MergeSpawnEvidence(npcId)
if not QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] then if not QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] then
QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = {} QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId] = {}
end end
spawned = InsertIfNewBucket(QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId],
-- Insert as new spawn (InsertIfNewBucket deduplicates) topEvidence.x, topEvidence.y, GetCoordGridForZone(topEvidence.zoneId))
InsertIfNewBucket(QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId], end
topEvidence.x, topEvidence.y)
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,6 +2009,7 @@ 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]
if not IsAscensionProtected("QUEST", qid or questId, 10) then
existing[10] = existing[10] or {} existing[10] = existing[10] or {}
existing[10][1] = existing[10][1] or {} existing[10][1] = existing[10][1] or {}
if type(v[1]) == "table" then if type(v[1]) == "table" then
@@ -1789,7 +2023,8 @@ function QuestieLearner:InjectLearnedData()
end end
end end
end end
elseif existing[k] == nil then end
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
+9 -1
View File
@@ -711,11 +711,17 @@ local function _GetZoneName(zoneOrSort, questId, zoneNameOverride)
end end
elseif (zoneOrSort) < 0 then elseif (zoneOrSort) < 0 then
zoneName = TrackerUtils:GetCategoryNameByID(zoneOrSort) zoneName = TrackerUtils:GetCategoryNameByID(zoneOrSort)
else
-- zoneOrSort == 0: try quest log header as last resort before "Unknown Zone"
local logZone = GetQuestLogZoneName(questId)
if logZone then
zoneName = logZone
else else
zoneName = "Unknown Zone" zoneName = "Unknown Zone"
Questie:Debug(Questie.DEBUG_CRITICAL, "[TrackerUtils:_GetZoneName] zoneOrSort", zoneOrSort, "of quest", Questie:Debug(Questie.DEBUG_CRITICAL, "[TrackerUtils:_GetZoneName] zoneOrSort", zoneOrSort, "of quest",
questId, "is not in the Database!") questId, "is not in the Database!")
end end
end
else else
if sortObj == "byComplete" then if sortObj == "byComplete" then
zoneName = "Quests (By %% Complete)" zoneName = "Quests (By %% Complete)"
@@ -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)