diff --git a/.gitignore b/.gitignore index e19192e..cded71c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,59 +1,56 @@ -# IDE -.idea/ -.vscode/ -*.iml -.DS_Store - # Build artifacts -*.zip -*.log -*.tmp -*.bak -*.bak* -*.old -luac.out -*_HEAD.lua +workflow/ +Tools/ -# Scripts -*.py -*.js -*.ps1 -tmp_*.py -release_notes.txt +# Test files +Tests/ +Modules/*_spec.lua -# Docs -!README.md -!CHANGELOG.md -!RELEASE_NOTES.md -!LICENSE -!Icons/MIT.txt +# Dev notes and session artifacts +docs/sunstrider-pin-fix.md +docs/sunstrider-coordinate-collection.md +docs/PROGRESS.md +docs/testing-macros.md +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 debug.lua debug_tooltip.lua -Tooltip_772ebd1.lua -verify_*.lua -.history/ -Research/ -Tools/ -Tests/ -tests/ -.busted -Makefile -selene.toml -wow_classic.yml -workflow/ -__pycache__/ -.agents/ -.kilocode/ +luac.out +release_notes.txt -# Assets that shouldn't be in source -# (Icons/* are needed for release) -Questie-X-Turtle.toc -skills-lock.json -.luarc.json -skills/ -scratch/ -workflow/ -.claude/skills/ +# Python artifacts +__pycache__/ +cleanup_init.py +tmp_*.py + +# Config +wow_classic.yml + +# Dev scripts +Makefile +move_recompile.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0591a0c..8a4ec79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ ### 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 1106–1134) 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. - **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. diff --git a/Compat/Compat.lua b/Compat/Compat.lua index f8ecc04..05b377a 100644 --- a/Compat/Compat.lua +++ b/Compat/Compat.lua @@ -241,6 +241,17 @@ function QuestieCompat.GetCurrentUiMapID() if QuestieCompat.UiMapData and QuestieCompat.UiMapData[mapID] then return mapID 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 end @@ -292,7 +303,40 @@ function QuestieCompat.GetCurrentPlayerPosition() 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 -- wrapper used by QuestieCoords diff --git a/Compat/HBD.lua b/Compat/HBD.lua index 33396a0..9ca8b5c 100644 --- a/Compat/HBD.lua +++ b/Compat/HBD.lua @@ -41,22 +41,10 @@ end) local HBD = {mapData = mapData} QuestieCompat.HBD = HBD --- Ascension zone remapping: Sunstrider Isle (1241) and its ghost map (946) share --- Eversong Woods' (1941) rendered area on Ascension's client. On Ascension, --- the Sunstrider Isle map actually uses Eversong's coordinate space — not retail's --- separate Sunstrider bounds. This means: --- 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. +-- Ascension zone remapping: the ghost map 946 should be treated as Eversong +-- (1941) for pin routing, but Sunstrider Isle (1241) must keep its native +-- render target. Visibility between Sunstrider and Eversong is handled +-- separately in HandleWorldMapPin() so we do not collapse the draw target. local ZONE_REDIRECT = { [946] = 1941, -- Ghost/transition map -> Eversong Woods (for Sunstrider loading) } @@ -70,48 +58,65 @@ end -- Expose for external use (e.g. QuestieCompat coordinate calibration) HBD.ResolveZone = ResolveZone --- Ascension bounds override: On Ascension, Sunstrider Isle (1241) uses Eversong --- Woods' coordinate space, not retail's separate Sunstrider bounds. The retail --- mapData[1241] bounds (510, 500, -6983.33, 9766.67) are incompatible with --- Ascension's world coordinates — UnitPosition returns Eversong-scale values --- like (503.4, 267.9) which produce zone coords of (-14.68, 18.99) when converted --- through retail Sunstrider bounds. Using Eversong's bounds makes coordinate --- round-trips work correctly and positions pins at their actual locations. --- --- Ghost map 946 has all-zeros bounds in retail, which makes it unusable; redirect --- to Eversong bounds as well (it shares the same rendered area on Ascension). +-- Sunstrider calibration note: +-- We have confirmed that 1241 is the correct uiMapId for Sunstrider Isle and +-- 3431 is the matching areaId. The sample set we collected strongly suggests +-- the standard Sunstrider rectangle is the correct fit for this client: +-- width ~= 510 +-- height ~= 500 +-- left ~= -6983.33 +-- top ~= 9766.67 +-- Keep this override in place, but if another path starts hiding townsfolk pins +-- again, re-check the 1241/3431 resolution before touching the bounds. local ASCENSION_ZONE_BOUNDS = { - [1241] = { 1600.0, 1066.666666666667, -2721.0066, 8433.9360 }, -- Calibrated bounds for Sunstrider Isle - [946] = { 4925.0, 3283.33333, -1824.6778, 8641.6666 }, -- Ghost map shares Eversong geometry + [1241] = { 510.0, 500.0, -6983.33, 9766.67 }, + [946] = { 510.0, 500.0, -6983.33, 9766.67 }, } --- Apply Ascension bounds overrides immediately +-- Apply Ascension bounds overrides immediately. local _boundsApplied = false local function ApplyAscensionBounds() if _boundsApplied then return end _boundsApplied = true - + if not RealHBD then pcall(function() RealHBD = LoadRealHBD() end) end - for zoneId, bounds in pairs(ASCENSION_ZONE_BOUNDS) do local data = mapData[zoneId] 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] - else - -- [HBD-Ascension] WARNING: No mapData for zone (debug disabled) end - - -- Override the real HBD map data as well, because HBD-Pins bypasses QuestieCompat if RealHBD and RealHBD.mapData and RealHBD.mapData[zoneId] then local rData = RealHBD.mapData[zoneId] rData[1], rData[2], rData[3], rData[4] = bounds[1], bounds[2], bounds[3], bounds[4] 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() -- One-shot debug: print mapData bounds for key zones on PLAYER_LOGIN @@ -142,9 +147,9 @@ end) --- @param zone uiMapID of the zone function HBD:GetWorldCoordinatesFromZone(x, y, zone) -- Ascension: mapData[946] has been overridden with Eversong bounds. - -- mapData[1241] has its own calibrated bounds that match the game engine's - -- Sunstrider coordinate space. Both convert through their own bounds — - -- no redirect needed here because _ResolveMapUiMapId passes 1241 through. + -- mapData[1241] keeps its own calibrated Sunstrider bounds. Do not force + -- 1241 through the ghost-map redirect here; visibility sharing is handled + -- separately in HandleWorldMapPin(). local data = mapData[zone] if not data or data[1] == 0 or data[2] == 0 then -- 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 function HBD:GetZoneCoordinatesFromWorld(x, y, zone, allowOutOfBounds) -- Ascension: mapData[946] has been overridden with Eversong bounds. - -- mapData[1241] has its own calibrated bounds matching the engine's space. - -- No redirect needed — callers pass the correct zone directly. + -- mapData[1241] keeps its own calibrated Sunstrider bounds. Callers should + -- pass the real uiMapId they want to project, not the visibility alias. local data = mapData[zone] if not data or data[1] == 0 or data[2] == 0 then if not RealHBD then @@ -277,6 +282,10 @@ function HBD:GetPlayerWorldPosition() local wx, wy, inst = HBD:GetWorldCoordinatesFromZone(x, y, uiMapID) _pwp_x, _pwp_y, _pwp_inst = wx, wy, inst _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 return wx, wy, inst 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) -- 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. - -- Also handles the reverse: pins tagged 1241/946 should show on 1941, and vice versa, - -- because these maps share the same rendered area on Ascension. + -- Sunstrider and Eversong share visibility, but 1241 must remain the actual render target. local effectiveUiMapID = ResolveZone(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 ancestorMapID = HBD.mapData[uiMapID] and HBD.mapData[uiMapID].parentMapID while ancestorMapID and HBD.mapData[ancestorMapID] do @@ -687,7 +704,7 @@ local function HandleWorldMapPin(icon, data) end -- 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. - 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 icon:Hide(); diff --git a/Database/QuestieDB.lua b/Database/QuestieDB.lua index 9331963..0d6feca 100644 --- a/Database/QuestieDB.lua +++ b/Database/QuestieDB.lua @@ -2182,11 +2182,35 @@ local function _Asc_LoadIfString(data, label) return data 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 local id, entry = next(src) 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) end end @@ -2196,7 +2220,7 @@ function QuestieDB:LoadAscensionQuestData() if not A then return end local data = _Asc_LoadIfString(A.questData, "AscensionDB.questData") QuestieDB.questDataOverrides = QuestieDB.questDataOverrides or {} - _Asc_MergeInto(QuestieDB.questDataOverrides, data) + _Asc_MergeInto(QuestieDB.questDataOverrides, data, "QUEST") end function QuestieDB:LoadAscensionNpcData() @@ -2204,7 +2228,7 @@ function QuestieDB:LoadAscensionNpcData() if not A then return end local data = _Asc_LoadIfString(A.npcData, "AscensionDB.npcData") QuestieDB.npcDataOverrides = QuestieDB.npcDataOverrides or {} - _Asc_MergeInto(QuestieDB.npcDataOverrides, data) + _Asc_MergeInto(QuestieDB.npcDataOverrides, data, "NPC") end function QuestieDB:LoadAscensionObjectData() @@ -2212,7 +2236,7 @@ function QuestieDB:LoadAscensionObjectData() if not A then return end local data = _Asc_LoadIfString(A.objectData, "AscensionDB.objectData") QuestieDB.objectDataOverrides = QuestieDB.objectDataOverrides or {} - _Asc_MergeInto(QuestieDB.objectDataOverrides, data) + _Asc_MergeInto(QuestieDB.objectDataOverrides, data, "OBJECT") end function QuestieDB:LoadAscensionItemData() @@ -2220,7 +2244,7 @@ function QuestieDB:LoadAscensionItemData() if not A then return end local data = _Asc_LoadIfString(A.itemData, "AscensionDB.itemData") QuestieDB.itemDataOverrides = QuestieDB.itemDataOverrides or {} - _Asc_MergeInto(QuestieDB.itemDataOverrides, data) + _Asc_MergeInto(QuestieDB.itemDataOverrides, data, "ITEM") end diff --git a/Database/Zones/zoneDB.lua b/Database/Zones/zoneDB.lua index d615d85..1787bb4 100644 --- a/Database/Zones/zoneDB.lua +++ b/Database/Zones/zoneDB.lua @@ -79,6 +79,8 @@ ZoneDB.private.areaIdToUiMapId[3430] = 1941 areaIdToUiMapId[3430] = 1941 -- 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. +-- 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 areaIdToUiMapId[3431] = 1241 -- Allow drawing pins directly on Sunstrider Isle (uiMapId 1241) via areaId 1241. diff --git a/Libs/HereBeDragons/HereBeDragons-Pins-2.0.lua b/Libs/HereBeDragons/HereBeDragons-Pins-2.0.lua index aa16c71..a880e99 100644 --- a/Libs/HereBeDragons/HereBeDragons-Pins-2.0.lua +++ b/Libs/HereBeDragons/HereBeDragons-Pins-2.0.lua @@ -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) 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 for pin in pairs(activeMinimapPins) do pin:Hide() @@ -217,6 +221,26 @@ local function UpdateMinimapPins(force) return 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() if minimapScale ~= newScale then minimapScale = newScale diff --git a/Modules/Libs/QuestiePluginAPI.lua b/Modules/Libs/QuestiePluginAPI.lua index 803fe81..9ef4e45 100644 --- a/Modules/Libs/QuestiePluginAPI.lua +++ b/Modules/Libs/QuestiePluginAPI.lua @@ -31,6 +31,56 @@ end local 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 ---@param pluginName string The unique name of the plugin ---@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) while qid do - targetOverride[qid] = entry + _MergeEntry(targetOverride, qid, entry, self.name, dbType) if type(qid) == "number" then count = count + 1 end diff --git a/Modules/Map/QuestieMap.lua b/Modules/Map/QuestieMap.lua index fb098b1..3693ba4 100644 --- a/Modules/Map/QuestieMap.lua +++ b/Modules/Map/QuestieMap.lua @@ -62,10 +62,10 @@ local function _ResolveMapUiMapId(uiMapId, x, y) if uiMapId == 946 then return 1941 end - -- Map 1241 (Sunstrider Isle): the game engine returns player world - -- coordinates in Sunstrider space (mapData[1241] calibrated bounds). - -- Pins MUST also convert through mapData[1241] so they share the same - -- world space as the player on the minimap. Do NOT redirect to 1941. + -- Map 1241 (Sunstrider Isle) must keep its native render target so the + -- actual Sunstrider map can show pins too. Visibility is handled separately + -- through ResolveZone()/isSameZoneSpace logic, which keeps 1241 and 1941 + -- linked without forcing the render target to the parent map. return uiMapId end diff --git a/Modules/Quest/AvailableQuests.lua b/Modules/Quest/AvailableQuests.lua index ee3d5d3..387fbcc 100644 --- a/Modules/Quest/AvailableQuests.lua +++ b/Modules/Quest/AvailableQuests.lua @@ -187,10 +187,9 @@ _CalculateAvailableQuests = function() if QuestieMap.questIdFrames[questId] then -- 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 i = 1 - while frames[i] do - local frame = frames[i] + for _, frame in pairs(frames) do if frame and frame.data and frame.data.QuestData then local newIcon = _GetQuestIcon(frame.data.QuestData) @@ -198,7 +197,6 @@ _CalculateAvailableQuests = function() frame:UpdateTexture(Questie.usedIcons[newIcon]) end end - i = i + 1 end return end diff --git a/Modules/Quest/QuestieQuest.lua b/Modules/Quest/QuestieQuest.lua index 6246df0..db596e9 100644 --- a/Modules/Quest/QuestieQuest.lua +++ b/Modules/Quest/QuestieQuest.lua @@ -1775,10 +1775,30 @@ _DrawObjectiveIcons = function(questId, iconsToDraw, objective, maxPerType) local iconCount, orderedList = _GetIconsSortedByDistance(iconsToDraw) + -- Dense kill objectives (like Sunstrider Isle mana wyrms) previously used + -- a lower clustering hotzone here. Leave the old behavior commented so we + -- can restore it quickly if we need to revisit consolidation again. + --[[ + if iconCount >= 20 then + range = math.max(6, math.floor(range * 0.25)) + elseif iconCount >= 12 then + range = math.max(10, math.floor(range * 0.4)) + elseif iconCount >= 6 then + range = math.max(16, math.floor(range * 0.65)) + end + --]] + if orderedList[1] and orderedList[1].Icon == Questie.ICON_TYPE_OBJECT then -- new clustering / limit code should prevent problems, always show all object notes range = range * 0.2; -- Only use 20% of the default range. 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); for i = 1, table.getn(hotzones) do @@ -1869,35 +1889,29 @@ _GetIconsSortedByDistance = function(icons) end _DrawObjectiveWaypoints = function(objective, icon, iconPerZone) - local _, spawnData = next(objective.spawnList) - 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 objective or not objective.spawnList then return end - if (not iconPerZone[zone]) and icon and firstWaypoint[1] ~= -1 and firstWaypoint[2] ~= -1 then -- spawn an icon in this zone for the mob - local iconMap, iconMini = QuestieMap:DrawWorldIcon(icon.data, zone, firstWaypoint[1], - firstWaypoint[2]) -- clustering code takes care of duplicates as long as min-dist is more than 0 + for _, spawnData in pairs(objective.spawnList) do + if spawnData and spawnData.Waypoints then + 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 iconPerZone[zone] = { iconMap, firstWaypoint[1], firstWaypoint[2] } - tinsert(objective.AlreadySpawned[icon.AlreadySpawnedId].mapRefs, iconMap); - tinsert(objective.AlreadySpawned[icon.AlreadySpawnedId].minimapRefs, iconMini); + tinsert(objective.AlreadySpawned[icon.AlreadySpawnedId].mapRefs, iconMap) + tinsert(objective.AlreadySpawned[icon.AlreadySpawnedId].minimapRefs, iconMini) end end local ipz = iconPerZone[zone] - if ipz then QuestieMap:DrawWaypoints(ipz[1], waypoints, zone, spawnData.Hostile and { 1, 0.2, 0, 0.7 } or nil) end - zone, waypoints = next(spawnData.Waypoints, zone) end - Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:_DrawObjectiveWaypoints]") end - _, spawnData = next(objective.spawnList, _) end end diff --git a/Modules/Quest/QuestieQuestPrivates.lua b/Modules/Quest/QuestieQuestPrivates.lua index 665065d..75c6767 100644 --- a/Modules/Quest/QuestieQuestPrivates.lua +++ b/Modules/Quest/QuestieQuestPrivates.lua @@ -160,7 +160,36 @@ monster = function(npcId, objective) if learnedNpc then local learnedSpawns = learnedNpc[7] 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) .. ")") spawns = learnedSpawns isLearned = true diff --git a/Modules/QuestieLearner.lua b/Modules/QuestieLearner.lua index 8ec15e4..88a7eff 100644 --- a/Modules/QuestieLearner.lua +++ b/Modules/QuestieLearner.lua @@ -32,6 +32,36 @@ local string_sub = string.sub local string_len = string.len 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 local UnitExists = UnitExists local UnitIsVisible = UnitIsVisible @@ -80,6 +110,15 @@ local COORD_GRID = 2.0 -- Ascension, where NPC databases are incomplete and every data point matters. 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.pendingQuests = {} _Learner.pendingItems = {} @@ -138,6 +177,29 @@ local function GetPlayerCoords() return nil, nil 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 local function CoordBucket(x, y) 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 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 bx, by = floor(x / grid) * grid, floor(y / grid) * grid 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 end table.insert(coordList, {x, y}) @@ -750,8 +819,10 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS -- Never learn player-spawned totems 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 - local zoneId = spawnZoneId or GetZoneId() + -- Use provided spawn coords (e.g. from kill event) or fall back to current player position. + -- 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 if spawnX and spawnY then 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 existing[7] = existing[7] or {} existing[7][zoneId] = existing[7][zoneId] or {} - InsertIfNewBucket(existing[7][zoneId], x, y) + InsertIfNewBucket(existing[7][zoneId], x, y, GetCoordGridForZone(zoneId)) end existing.ls = time() -- Update last seen @@ -793,15 +864,15 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS else -- Merge: fill missing fields; also overwrite empty-string names 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 -- Always merge spawn coords - if existing[7] then + if existing[7] and not IsAscensionProtected("NPC", npcId, 7) then ovr[7] = ovr[7] or {} for zid, coords in pairs(existing[7]) do ovr[7][zid] = ovr[7][zid] or {} 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 @@ -831,9 +902,19 @@ end -- This is the per-spawn-instance identifier — different GUIDs for the same -- npcId indicate different spawn points (e.g. three boars at three corners -- of a field, not one boar teleporting around). +-- 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 local function _ExtractSpawnUID(guid) 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 -- Last numeric segment after the 5th dash is the spawn UID 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 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) if not spawnUID then return end @@ -880,10 +966,18 @@ function QuestieLearner:_StoreGuidSpawnEvidence(npcId, dstGUID, zoneId, x, y) local guidSpawns = _GetOrCreateGuidSpawnTable(learnedNpc) if not guidSpawns then return end - -- Normalize coords: same format as recentKills so Phase 3 merge is consistent - -- Formula: floor(v * 10000) / 100 — converts normalized 0-1 to scaled 0-100 - local nx = floor(x * 10000) / 100 - local ny = floor(y * 10000) / 100 + -- Normalize coords to Questie's 0-100 map scale. NormalizeCoordPair handles all + -- three input formats: native 0-1, already-scaled 0-100, or buggy 0-10000. + local nx, ny = NormalizeCoordPair(x, y) + 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 -- Existing spawn UID: update position and timestamp @@ -950,15 +1044,30 @@ local function _MergeSpawnEvidence(npcId) for spawnUID, entry in pairs(guidSpawns) do if entry and entry.zoneId and entry.x and entry.y then - -- Round to 2 decimal places for grouping - local rx = floor(entry.x * 100 + 0.5) / 100 - local ry = floor(entry.y * 100 + 0.5) / 100 - local key = entry.zoneId .. "|" .. rx .. "|" .. ry - if not evidence[key] then - evidence[key] = { zoneId = entry.zoneId, x = rx, y = ry, count = 0 } + 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 + 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 - evidence[key].count = evidence[key].count + 1 - totalEvidence = totalEvidence + 1 end end @@ -979,33 +1088,119 @@ local function _MergeSpawnEvidence(npcId) local topEvidence = evidence[topKey] local topPct = (topCount / totalEvidence) * 100 - -- Only override if > 60% confidence AND spawn differs from static DB - if topPct <= 60 then + -- DEBUG: log topEvidence raw values to diagnose coordinate corruption + 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, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, "top spawn", topCount .. "/" .. totalEvidence, - "= " .. floor(topPct + 0.5) .. "% — below 60%, no override") - return false + "= " .. 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 - -- 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 - if QuestieDB and QuestieDB.QueryNPC then - staticNPC = QuestieDB.QueryNPC(npcId, 1) + if QuestieDB and QuestieDB.QueryNPCSingle then + staticNPC = QuestieDB.QueryNPCSingle(npcId, "spawns") end - local staticSpawnList = staticNPC and staticNPC[7] + local staticSpawnList = staticNPC 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 if staticSpawnsForZone then for _, coord in ipairs(staticSpawnsForZone) do local sx = floor(coord[1] * 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 - matchesStatic = true - break + -- 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 + matchesStatic = true + break + end end end end @@ -1017,25 +1212,47 @@ local function _MergeSpawnEvidence(npcId) return false end - -- Override: inject top spawn into QuestieDB spawn overrides for this zone - 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 + -- 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 --]] - -- Insert as new spawn (InsertIfNewBucket deduplicates) - InsertIfNewBucket(QuestieDB.npcDataOverrides[npcId][7][topEvidence.zoneId], - topEvidence.x, topEvidence.y) + 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 + 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, "[QuestieLearner] _MergeSpawnEvidence: npcId", npcId, - "overrode static DB — learned spawn (" .. topEvidence.x .. "," .. topEvidence.y .. ")", - "zone " .. topEvidence.zoneId .. " at " .. floor(topPct + 0.5) .. "% confidence") + "overrode static DB — learned spawn (" .. tostring(topEvidence.x) .. "," .. tostring(topEvidence.y) .. ")", + "zone " .. topEvidence.zoneId .. " at " .. floor(topPct + 0.5) .. "% confidence", + spawned and "SPAM" or "IGNORED_DUPLICATE") -- Clear npcCache so GetNPC returns fresh data if QuestieDB.private and QuestieDB.private.npcCache then @@ -1092,7 +1309,7 @@ function QuestieLearner:LearnQuest(questId, data) QuestieDB.questDataOverrides[questId] = existing else 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 @@ -1130,7 +1347,7 @@ function QuestieLearner:LearnQuestGiver(questId, entityId, entityType, isStart) table.insert(list, entityId) -- 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 {} QuestieDB.questDataOverrides[questId] = ovr ovr[field] = ovr[field] or {} @@ -1196,7 +1413,7 @@ function QuestieLearner:LearnQuestObjectiveNPC(questId, npcId, objText, objectiv end -- 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 {} QuestieDB.questDataOverrides[questId] = ovr ovr[10] = ovr[10] or {} @@ -1283,7 +1500,7 @@ function QuestieLearner:LearnItem(itemId, name, itemLevel, requiredLevel, itemCl QuestieDB.itemDataOverrides[itemId] = existing else 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 @@ -1316,7 +1533,7 @@ function QuestieLearner:LearnItemDrop(itemId, npcId) table.insert(existing[2], npcId) -- 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 {} QuestieDB.itemDataOverrides[itemId] = ovr ovr[2] = ovr[2] or {} @@ -1370,9 +1587,9 @@ function QuestieLearner:LearnObject(objectId, name) QuestieDB.objectDataOverrides[objectId] = existing else 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 - if existing[4] then + if existing[4] and not IsAscensionProtected("OBJECT", objectId, 4) then ovr[4] = ovr[4] or {} for zid, coords in pairs(existing[4]) do ovr[4][zid] = ovr[4][zid] or {} @@ -1500,14 +1717,23 @@ function QuestieLearner:InjectLearnedData() if data[7] then local zonesToMigrate = {} 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), -- ZoneDB:GetAreaIdByUiMapId will return the corresponding areaId. -- If it returns nil, zoneKey is already an areaId — no migration needed. -- 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) if maybeAreaId and maybeAreaId ~= zoneKey then - zonesToMigrate[zoneKey] = maybeAreaId + zonesToMigrate[originalZoneKey] = maybeAreaId end end end @@ -1529,10 +1755,15 @@ function QuestieLearner:InjectLearnedData() if data[4] then local zonesToMigrate = {} 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) if maybeAreaId and maybeAreaId ~= zoneKey then - zonesToMigrate[zoneKey] = maybeAreaId + zonesToMigrate[originalZoneKey] = maybeAreaId end end end @@ -1558,7 +1789,8 @@ function QuestieLearner:InjectLearnedData() local fieldsFixed = 0 for npcId, data in pairs(learned.npcs) do 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 Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] NPC", npcId, "zone field [9]", data[9], "->", maybeAreaId) data[9] = maybeAreaId @@ -1568,7 +1800,8 @@ function QuestieLearner:InjectLearnedData() end for objId, data in pairs(learned.objects) do 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 Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Object", objId, "zone field [5]", data[5], "->", maybeAreaId) data[5] = maybeAreaId @@ -1642,7 +1875,7 @@ function QuestieLearner:InjectLearnedData() npcCount = npcCount + 1 else 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 {} for zoneId, coords in pairs(data[7]) do existing[7][zoneId] = existing[7][zoneId] or {} @@ -1653,7 +1886,7 @@ function QuestieLearner:InjectLearnedData() end -- Adopt other fields if missing 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 end end @@ -1776,20 +2009,22 @@ function QuestieLearner:InjectLearnedData() if k ~= "mc" then if k == 10 then -- Special merge: add learned creatureObjective entries to [10][1] - existing[10] = existing[10] or {} - existing[10][1] = existing[10][1] or {} - if type(v[1]) == "table" then - for _, entry in ipairs(v[1]) do - local found = false - for _, ex in ipairs(existing[10][1]) do - if ex[1] == entry[1] then found = true; break end - end - if not found then - tinsert(existing[10][1], entry) + if not IsAscensionProtected("QUEST", qid or questId, 10) then + existing[10] = existing[10] or {} + existing[10][1] = existing[10][1] or {} + if type(v[1]) == "table" then + for _, entry in ipairs(v[1]) do + local found = false + for _, ex in ipairs(existing[10][1]) do + if ex[1] == entry[1] then found = true; break end + end + if not found then + tinsert(existing[10][1], entry) + 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 end end @@ -1831,7 +2066,7 @@ function QuestieLearner:InjectLearnedData() objectCount = objectCount + 1 else 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 {} for zoneId, coords in pairs(data[4]) do existing[4][zoneId] = existing[4][zoneId] or {} @@ -1842,7 +2077,7 @@ function QuestieLearner:InjectLearnedData() end -- Adopt other fields 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 end end @@ -2438,8 +2673,8 @@ function QuestieLearner:LearnSpellCast(spellId, spellName, dstGUID, dstName) for _, obj in pairs(quest.objectives) do -- 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 - 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] Learning spell cast:", spellId, spellName, "on", dstName or "nil") + -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] Found spell objective match for quest", questId) local data = { [10] = { [1] = {} } } if npcId then tinsert(data[10][1], { npcId, spellName }) @@ -2526,7 +2761,7 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, end -- Permanent minimal log: combat-log path, event, and target - Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] combat-log path=", path, " event=", eventType, " dstGUID=", dstGUID, " dstName=", dstName) + -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] combat-log path=", path, " event=", eventType, " dstGUID=", dstGUID, " dstName=", dstName) -- Vanilla: neither modern API nor legacy args available — throttle warning, do NOT disable permanently if not timestamp then @@ -2556,7 +2791,7 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, local now = time() local lastTs = _Learner.killDebounce and _Learner.killDebounce[dstGUID] if lastTs and (now - lastTs) < 5 then - Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID) + -- Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] kill dedupe suppressed duplicate event=", eventType, " dstGUID=", dstGUID) return end _Learner.killDebounce = _Learner.killDebounce or {} @@ -2613,7 +2848,7 @@ function QuestieLearner:OnCombatLogEvent(timestamp, eventType, srcGUID, srcName, } 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 -- 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 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] and Questie.dbLearner.global.npcs[npcId][8] if guidSpawns then local count = 0 for _ in pairs(guidSpawns) do count = count + 1 end - if count >= 3 then + if count >= 1 then _MergeSpawnEvidence(npcId) end end @@ -2806,7 +3063,7 @@ function QuestieLearner:PruneLearnedSpawnOutliers(threshold) -- Check if static DB has anchors for this NPC+zone local staticNPC = nil if QuestieDB and QuestieDB.QueryNPC then - staticNPC = QuestieDB.QueryNPC(npcId, 1) + staticNPC = QuestieDB.QueryNPCSingle and QuestieDB.QueryNPCSingle(npcId, "spawns") or nil end if staticNPC and staticNPC[7] and staticNPC[7][zoneId] then @@ -2913,7 +3170,7 @@ function QuestieLearner:OnQuestLogUpdate() local _, _, _, isHeader, _, _, _, questId = QuestieCompat.GetQuestLogTitle(i) if not isHeader and questId and questId > 0 then 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 {} for j = 1, numObj do local objText, objType, finished = GetQuestLogLeaderBoard(j, i) @@ -2924,11 +3181,11 @@ function QuestieLearner:OnQuestLogUpdate() local count = tonumber(objText:match(":?%s*(%d+)%s*/")) local prev = _Learner.prevObjCounts[questId][j] - Questie:Debug(Questie.DEBUG_LEARNER, - "[QuestieLearner] OnQuestLogUpdate quest", questId, - "obj", j, "type:", tostring(objType), - "count:", tostring(count), "prev:", tostring(prev), - "text:", tostring(objText)) + -- Questie:Debug(Questie.DEBUG_LEARNER, + -- "[QuestieLearner] OnQuestLogUpdate quest", questId, + -- "obj", j, "type:", tostring(objType), + -- "count:", tostring(count), "prev:", tostring(prev), + -- "text:", tostring(objText)) -- Seed on first sight; only correlate on confirmed increase if prev == nil then @@ -3012,8 +3269,8 @@ local function _RegisterLearnedSpawnTooltipHook() if _tooltipHookRegistered then return end _tooltipHookRegistered = true GameTooltip:HookScript("OnTooltipSetUnit", function() - -- self == GameTooltip - local _, unitToken = self:GetUnit() + -- HookScript handlers do not reliably receive the frame as an argument on 3.3.5a. + local _, unitToken = GameTooltip:GetUnit() if unitToken then _AddLearnedSpawnTooltipLine(unitToken) end diff --git a/Modules/Tracker/TrackerUtils.lua b/Modules/Tracker/TrackerUtils.lua index 6cfa7c6..cc612c2 100644 --- a/Modules/Tracker/TrackerUtils.lua +++ b/Modules/Tracker/TrackerUtils.lua @@ -712,9 +712,15 @@ local function _GetZoneName(zoneOrSort, questId, zoneNameOverride) elseif (zoneOrSort) < 0 then zoneName = TrackerUtils:GetCategoryNameByID(zoneOrSort) else - zoneName = "Unknown Zone" - Questie:Debug(Questie.DEBUG_CRITICAL, "[TrackerUtils:_GetZoneName] zoneOrSort", zoneOrSort, "of quest", - questId, "is not in the Database!") + -- zoneOrSort == 0: try quest log header as last resort before "Unknown Zone" + local logZone = GetQuestLogZoneName(questId) + 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 else if sortObj == "byComplete" then @@ -837,7 +843,9 @@ function TrackerUtils:GetSortedQuestIds() if not quest.SpecialObjectives then quest.SpecialObjectives = {} end if not quest.ExtraObjectives then quest.ExtraObjectives = {} end -- 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) if logZone then quest.zoneName = logZone diff --git a/Tests/QuestieLearner_spec.lua b/Tests/QuestieLearner_spec.lua index f15a621..b6f3522 100644 --- a/Tests/QuestieLearner_spec.lua +++ b/Tests/QuestieLearner_spec.lua @@ -134,6 +134,44 @@ describe("QuestieLearner", function() assert.is_true(entry2.ts >= ts1) -- timestamp refreshed 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 --========================================================================== diff --git a/handoff.md b/handoff.md new file mode 100644 index 0000000..1d0a5b3 --- /dev/null +++ b/handoff.md @@ -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 ~1046–1048 +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 205–221 +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 958–959)** + Updated to document that it handles native 0–1, already-scaled 0–100, and buggy 0–10000 input formats. + +3. **`QUESTIE_LEARNER_debug` log comment (line 963–964)** + 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 1046–1048): **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 1111–1121): **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 1045–1048) — 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 1114–1121) — 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` | ~168–197 | Scales coords to 0–100; handles buggy inputs | +| `CoordBucket` | ~200–201 | Bucket key for (x, y) using COORD_GRID | +| `InsertIfNewBucket` | ~205–224 | Inserts coord only if no existing in same bucket | +| `GetCoordGridForZone` | ~108–113 | Returns 0.5 for Sunstrider (zones 1241/3431), else 2.0 | +| `_StoreGuidSpawnEvidence` | ~942–1012 | Stores per-GUID kill evidence; calls NormalizeCoordPair | +| `_MergeSpawnEvidence` | ~1014–1140 | Groups evidence → promotes top groups to npcDataOverrides | + +--- + +## Debug Log + +Re-enable this block (`_StoreGuidSpawnEvidence`, lines ~963–964) 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 958–959, 1114 (already patched prior to this handoff) +- `Tests/QuestieLearner_spec.lua` — (unchanged; legacy reference)