fix(arrow): Sunstrider zoneId 3431, rotation CW, collection distance, native map pins

- zoneId detection: accept 3430 OR 3431 OR uiMapId 1241 (4 locations)
- SetRotation is CW-positive: rotAngle=relative (was -relative)
- Collection functions: override target->1241 when player on Sunstrider
- NPC 15281: spawn zone 1241 (not 3430) for correct coord space
- _ResolveMapUiMapId: removed 1241->1941 redirect
- zoneDB: added areaIdToUiMapId[1241]=1241
- Updated CHANGELOG, README, docs
This commit is contained in:
Xurkon
2026-05-21 19:22:40 -05:00
parent 7f99e7228d
commit 24ed0f48b3
19 changed files with 1170 additions and 469 deletions
+25 -3
View File
@@ -4,6 +4,24 @@
### Bug Fixes ### 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.
- **Direction math**: WoW's `SetRotation` is clockwise-positive (CW for positive r). The arrow angle `0` = target ahead (north), positive = clockwise, so we apply `SetRotation(angle)`.
- **Arrow anchor**: Changed from `SetPoint("TOP")` to `SetPoint("CENTER")` so the rotation pivot aligns with the frame center.
- **No color tint**: `SetVertexColor(1, 1, 1)` preserves the original blue neon appearance.
- **[Fix — Arrow Target Selection: Mixed Coordinate Systems]** Fixed the GPS arrow not pointing to the nearest objective when quests span calibrated (Sunstrider) and normal zones. Three interacting bugs caused incorrect distance sorting:
- **_CollectFinisherSpawns used HBD exclusively**: On Sunstrider, objective distances used calibrated pseudo-world coordinates (0-1353 range) while finisher distances used HBD world coordinates (tens of thousands range). These incomparable units caused quests with both objectives and turn-in NPCs on the same map to sort incorrectly — a Sunstrider finisher NPC at calibrated distance 500 (≈500 yards) could lose to an objective at HBD distance 300 because 300 < 500, even though the finisher was closer. Now `_CollectFinisherSpawns` uses `QuestieCompat.GetCalibratedWorldCoordinatesFromZone` and calibrated Euclidean distance for calibrated targets, matching `_CollectObjective`.
- **Player position in HBD distance calls**: On Sunstrider, `_arrow_playerX/Y` held calibrated pseudo-world coordinates (0-1353). Both `_CollectObjective` and `_CollectFinisherSpawns` passed these to `HBD:GetWorldDistance(pInst, pX, pY, tX, tY)` for non-calibrated targets, mixing pseudo-world player coords with real-world target coords. Now both functions use `_arrow_hbdPlayerX/Y` (always in HBD world coordinate space) for HBD distance calculations, ensuring the coordinate systems match.
- **Cross-instance penalty for calibrated targets**: The `tInst ~= pInst` penalty (`500000 + dist * 100`) applied to calibrated targets on Sunstrider where `tInst = 0` (from `GetCalibratedWorldCoordinatesFromZone`) and `pInst` was the real instance ID. Now gated behind `(not calibratedTargetGroup)` so calibrated targets are never penalized for instance mismatch.
- **[Fix — Complete-Abandon-Reaccept Pin Lifecycle]** Fixed quest map pins and GPS arrow not reappearing after completing quest objectives, abandoning the quest, and re-accepting it from the quest giver. Five interacting bugs caused stale state to survive the quest reset:
- **MarkQuestAsAbandoned**: The `objectivesWereComplete` path called `CompleteQuest` without clearing `quest.Objectives`, `quest.WasComplete`, or `quest.isComplete`. Stale `Completed=true` and `isUpdated=true` flags caused `PopulateObjectiveNotes` to skip drawing pins on re-accept. Now clears objectives/flags and calls `SetObjectivesDirty(questId)` before `CompleteQuest`.
- **CompleteQuest**: Did not clear `quest.Objectives` (unlike `AbandonedQuest` which does). Now adds `quest.Objectives = {}` with type guard as defense-in-depth.
- **QUEST_TURNED_IN dead code**: `questLog[questId] = {}` wiped state before the QUEST_TURNED_IN state check could read it, making the auto-complete cleanup unreachable. Moved the check before the wipe.
- **AcceptQuest reset**: Added `SetObjectivesDirty(questId)` in the quest re-accept reset block for defense-in-depth, ensuring `isUpdated` flags are reset even if stale objectives somehow survive.
- **Arrow spawnList gap**: The GPS arrow's `_CollectObjective` silently skipped objectives with nil/empty `spawnList`. After quest re-accept, `PopulateQuestLogInfo` creates objectives without `spawnList`; `PopulateObjectiveNotes` builds it later in the TaskQueue. Added `QuestieQuest:BuildObjectiveSpawnList(objective, objectiveData)` public API that lazily builds `spawnList` from `objectiveSpawnListCallTable` handlers (which include QuestieLearner-injected NPC/object/item spawn data from SavedVariables). The arrow now calls this when `spawnList` is missing.
- **[Fix — MapIconTooltip _GetLevelString Guard]** Resolved `attempt to concatenate local 'minLevel' (a nil value)` crash in `MapIconTooltip.lua:494` (`_GetLevelString` function). The creature name "Uneasy Citizen" existed in `creatureLevels` as an empty table `{}` rather than the expected `[1]=minLevel, [2]=maxLevel, [3]=rank` tuple, causing `creatureLevels[name][1]` to return nil. Added an early-return guard at the top of `_GetLevelString`: if `creatureLevels[name]` is falsy or not a table with a numeric level at index `[1]`, return the name unmodified. - **[Fix — MapIconTooltip _GetLevelString Guard]** Resolved `attempt to concatenate local 'minLevel' (a nil value)` crash in `MapIconTooltip.lua:494` (`_GetLevelString` function). The creature name "Uneasy Citizen" existed in `creatureLevels` as an empty table `{}` rather than the expected `[1]=minLevel, [2]=maxLevel, [3]=rank` tuple, causing `creatureLevels[name][1]` to return nil. Added an early-return guard at the top of `_GetLevelString`: if `creatureLevels[name]` is falsy or not a table with a numeric level at index `[1]`, return the name unmodified.
- **[Fix — Tooltip Learned Data Schema Mismatch]** Reworked learned NPC/object tooltip registration in `Modules/Tooltips/Tooltip.lua` to match the actual `QuestieLearner` storage format. - **[Fix — Tooltip Learned Data Schema Mismatch]** Reworked learned NPC/object tooltip registration in `Modules/Tooltips/Tooltip.lua` to match the actual `QuestieLearner` storage format.
- **Player-Facing Symptom**: Fixed the Stormwind mouseover crash reported on Bronzebeard while hovering city guards and other learned tooltip targets: `Questie-X\\Modules\\Tooltips\\Tooltip.lua:240: attempt to index local 'objList' (a number value)`. - **Player-Facing Symptom**: Fixed the Stormwind mouseover crash reported on Bronzebeard while hovering city guards and other learned tooltip targets: `Questie-X\\Modules\\Tooltips\\Tooltip.lua:240: attempt to index local 'objList' (a number value)`.
@@ -15,7 +33,7 @@
- **Root Cause (Player Position)**: `HBD:GetPlayerWorldPosition()` returns a non-nil value on Sunstrider Isle, but those coords are Eastern Kingdoms world position (wrong), not Sunstrider's actual position. Because a non-nil value is returned, the fallback chain never fires. The arrow then calculates distance using wrong player coords vs correct target coords, giving a wildly incorrect distance. - **Root Cause (Player Position)**: `HBD:GetPlayerWorldPosition()` returns a non-nil value on Sunstrider Isle, but those coords are Eastern Kingdoms world position (wrong), not Sunstrider's actual position. Because a non-nil value is returned, the fallback chain never fires. The arrow then calculates distance using wrong player coords vs correct target coords, giving a wildly incorrect distance.
- **Root Cause (Ghost Map Player Position)**: `C_Map.GetPlayerMapPosition(946, "player")` where `946` is Ascension's ghost/cosmic map for Sunstrider Isle returns `(0, 0)` because the ghost map has no valid coordinate data. The correct map for player position on Sunstrider is `1941` (Eversong Woods parent), which shares the same world coordinate space and returns valid zone coords. - **Root Cause (Ghost Map Player Position)**: `C_Map.GetPlayerMapPosition(946, "player")` where `946` is Ascension's ghost/cosmic map for Sunstrider Isle returns `(0, 0)` because the ghost map has no valid coordinate data. The correct map for player position on Sunstrider is `1941` (Eversong Woods parent), which shares the same world coordinate space and returns valid zone coords.
- **Root Cause (OnUpdate same-map check)**: `OnUpdate` was using `_ResolveArrowUiMapId(_arrow_playerUiMapId)` (which resolves 1241→1941) for the player-side uiMapId, making it equal to `target.uiMapId` (also 1941). This caused the same-map branch to fire, which then called `C_Map.GetPlayerMapPosition(_arrow_playerUiMapId)` with 946, getting `(0, 0)` and computing wrong distance. - **Root Cause (OnUpdate same-map check)**: `OnUpdate` was using `_ResolveArrowUiMapId(_arrow_playerUiMapId)` (which resolves 1241→1941) for the player-side uiMapId, making it equal to `target.uiMapId` (also 1941). This caused the same-map branch to fire, which then called `C_Map.GetPlayerMapPosition(_arrow_playerUiMapId)` with 946, getting `(0, 0)` and computing wrong distance.
- **Fix — QuestieArrow.lua UpdateNearestTargets**: Added Sunstrider detection (`zoneId == 3430`) BEFORE calling `HBD:GetPlayerWorldPosition()` to force the C_Map fallback path. The fallback now calls `C_Map.GetPlayerMapPosition(1241, "player")` to get Sunstrider map-space coords, then converts through `HBD:GetWorldCoordinatesFromZone(..., 1941)` using Eversong Woods bounds — which share Sunstrider's world coordinate space. - **Fix — QuestieArrow.lua UpdateNearestTargets**: Added Sunstrider detection (`zoneId == 3430 OR 3431 OR uiMapId == 1241`) BEFORE calling `HBD:GetPlayerWorldPosition()` to force the C_Map fallback path. All 4 Sunstrider detection checks in QuestieArrow.lua now accept zoneId 3430 OR 3431 OR uiMapId 1241 (lines 354, 395, 747, 782), since `GetCurrentZoneId()` can return either 3430 or 3431 when the player is physically on uiMap 1241. The fallback now calls `C_Map.GetPlayerMapPosition(1241, "player")` to get Sunstrider map-space coords, then converts through `HBD:GetWorldCoordinatesFromZone(..., 1941)` using Eversong Woods bounds — which share Sunstrider's world coordinate space.
- **Fix — QuestieArrow.lua OnUpdate same-map branch**: Changed `C_Map.GetPlayerMapPosition(1941, "player")` for player zone coords (not `_arrow_playerUiMapId` which is 946). Normalizes both `playerUiMapId` and `targetUiMapId` through `_ResolveArrowUiMapId()` before same-map comparison. Uses `zoneScale = 13.53` yards/zone-unit for distance. Declares `worldPlayerX/Y` before the if-else to prevent nil in debug prints. - **Fix — QuestieArrow.lua OnUpdate same-map branch**: Changed `C_Map.GetPlayerMapPosition(1941, "player")` for player zone coords (not `_arrow_playerUiMapId` which is 946). Normalizes both `playerUiMapId` and `targetUiMapId` through `_ResolveArrowUiMapId()` before same-map comparison. Uses `zoneScale = 13.53` yards/zone-unit for distance. Declares `worldPlayerX/Y` before the if-else to prevent nil in debug prints.
- **Debug Output**: Added debug prints showing UnitPosition vs HBD player coords comparison, raw vs resolved uiMapIds, branch selection, and per-frame distance calculation inputs. - **Debug Output**: Added debug prints showing UnitPosition vs HBD player coords comparison, raw vs resolved uiMapIds, branch selection, and per-frame distance calculation inputs.
- **[Fix — Sunstrider Isle Map Pins Not Appearing]** Resolved quest objective icons (map pins) not appearing on the world map when zoomed into Sunstrider Isle (uiMapId 1241), even though they appeared correctly when zoomed out to Eversong Woods (uiMapId 1941). - **[Fix — Sunstrider Isle Map Pins Not Appearing]** Resolved quest objective icons (map pins) not appearing on the world map when zoomed into Sunstrider Isle (uiMapId 1241), even though they appeared correctly when zoomed out to Eversong Woods (uiMapId 1941).
@@ -27,8 +45,12 @@
- **[Fix — QuestieLearner Icon Preservation]** Preserved the learned objective icon when registering with the tooltip system so nameplates can render the correct learned slay/loot/talk marker. Previously the icon was always nil on fresh registration. - **[Fix — QuestieLearner Icon Preservation]** Preserved the learned objective icon when registering with the tooltip system so nameplates can render the correct learned slay/loot/talk marker. Previously the icon was always nil on fresh registration.
- **[Fix — Override Data Invisible to GetNPC/GetObject/GetItem]** — CRITICAL bug where ALL override data (from wotlkNPCFixes, AscensionDB, and QuestieLearner) was silently ignored by the three entity getter functions. Root cause: GetNPC/GetObject/GetItem iterated `npcKeys/objectKeys/itemKeys` using string keys (e.g. override["spawns"]) but ALL override sources store data using numeric keys via the key constants (e.g. override[npcKeys.spawns] = override[7]). In Lua, t["spawns"] ≠ t[7]. Fix: Added `_MergeOverride(result, override, rawdata, keyMap)` helper that tries both string and numeric keys before falling back to rawdata. Applied to GetNPC, GetObject, and GetItem. This ensures AscensionDB spawn data, wotlkNPCFixes corrections, and QuestieLearner-learned spawns are all visible through the database API. - **[Fix — Override Data Invisible to GetNPC/GetObject/GetItem]** — CRITICAL bug where ALL override data (from wotlkNPCFixes, AscensionDB, and QuestieLearner) was silently ignored by the three entity getter functions. Root cause: GetNPC/GetObject/GetItem iterated `npcKeys/objectKeys/itemKeys` using string keys (e.g. override["spawns"]) but ALL override sources store data using numeric keys via the key constants (e.g. override[npcKeys.spawns] = override[7]). In Lua, t["spawns"] ≠ t[7]. Fix: Added `_MergeOverride(result, override, rawdata, keyMap)` helper that tries both string and numeric keys before falling back to rawdata. Applied to GetNPC, GetObject, and GetItem. This ensures AscensionDB spawn data, wotlkNPCFixes corrections, and QuestieLearner-learned spawns are all visible through the database API.
- **[Fix — QuestieLearner Spawn Zone Tracking]** All LearnNPC call sites now explicitly pass zoneId as the spawnZoneId parameter, ensuring learned spawn data is stored under the correct areaId key (3430 for Eversong) instead of leaving it nil for the fallback GetZoneId() which previously returned the wrong uiMapId. Affected call sites: OnMouseoverUnit (passes areaId from l10n), OnQuestDetail, OnQuestComplete, OnQuestAccepted, OnQuestTurnedIn, and GOSSIP_SHOW handler. OnQuestComplete also now captures zoneId via GetZoneId() before using it. - **[Fix — QuestieLearner Spawn Zone Tracking]** All LearnNPC call sites now explicitly pass zoneId as the spawnZoneId parameter, ensuring learned spawn data is stored under the correct areaId key (3430 for Eversong) instead of leaving it nil for the fallback GetZoneId() which previously returned the wrong uiMapId. Affected call sites: OnMouseoverUnit (passes areaId from l10n), OnQuestDetail, OnQuestComplete, OnQuestAccepted, OnQuestTurnedIn, and GOSSIP_SHOW handler. OnQuestComplete also now captures zoneId via GetZoneId() before using it.
- **[Fix — Sunstrider Isle Zone Mapping]** Changed `areaIdToUiMapId[3430]` from 1241 to 1941. Both WotLKDB and AscensionDB store Eversong-wide coordinates under zone 3430; mapping 3430→1241 forced those coordinates onto the Sunstrider sub-map, placing pins in mountains. Now 3430→1941 renders pins correctly on Eversong, and ZONE_REDIRECT provides cross-visibility to Sunstrider (1241). Removed redundant `UiMapIdOverrides[1241]=3430`. - **[Fix — Sunstrider Isle Zone Mapping]** Changed `areaIdToUiMapId[3430]` from 1241 to 1941. Both WotLKDB and AscensionDB store Eversong-wide coordinates under zone 3430; mapping 3430→1241 forced those coordinates onto the Sunstrider sub-map, placing pins in mountains. Now 3430→1941 renders pins correctly on Eversong, and ZONE_REDIRECT provides cross-visibility to Sunstrider (1241). Removed redundant `UiMapIdOverrides[1241]=3430`. Additionally, removed the 1241→1941 redirect in `_ResolveMapUiMapId()` so pins targeting Sunstrider render natively using Ascension-calibrated bounds. Added `areaIdToUiMapId[1241] = 1241` mapping in zoneDB.lua so `DrawWorldIcon` can place pins directly on the Sunstrider sub-map.
- **[Fix — Sunstrider Map Pin Redirect]** All map pins targeting uiMapId 1241 (Sunstrider) now redirect to 1941 (Eversong) via `_ResolveMapUiMapId()` in QuestieMap.lua. This applies to DrawManualIcon, DrawWorldIcon, FindClosestStarter, GetNearestSpawn, and GetNearestQuestSpawn. Sunstrider pins render on the Eversong coordinate space and appear on the Sunstrider sub-map via HBD ZONE_REDIRECT visibility. - **[Fix — Sunstrider Map Pin System]** Removed the 1241→1941 redirect in `_ResolveMapUiMapId()`. Pins on 1241 now render natively using Ascension-calibrated bounds instead of being forced to 1941's coordinate space. Added `areaIdToUiMapId[1241] = 1241` mapping in zoneDB.lua so `DrawWorldIcon` can place pins directly on the Sunstrider sub-map. HBD's ZONE_REDIRECT visibility (`ResolveZone(1241)=1941`) ensures pins on 1241 are also visible on the Eversong map.
- **[Fix — Sunstrider zoneId 3431 Detection (QuestieArrow.lua)]** `GetCurrentZoneId()` returns 3431 (Eversong Woods) when the player is physically on uiMap 1241 (Sunstrider Isle), not 3430 as previously assumed. All 4 Sunstrider detection checks in QuestieArrow.lua now accept zoneId 3430 OR 3431 OR uiMapId 1241 (lines 354, 395, 747, 782). Without this fix, NONE of the Sunstrider coordinate overrides triggered, causing the arrow to compute player and target positions in different coordinate spaces (858 yard offset).
- **[Fix — Arrow Rotation Direction (QuestieArrow.lua)]** WoW's `Texture:SetRotation(r)` rotates CW for positive r, NOT CCW as the code comment claimed. Changed `rotAngle = -relative` to `rotAngle = relative` (line 441). The arrow was rotating in the opposite direction of the target, pointing away instead of toward it.
- **[Fix — Collection Function Distance Mismatch (QuestieArrow.lua)]** `_CollectFinisherSpawns` and `_CollectObjective` converted targets through 1941 (Eversong) bounds while player coordinates were in 1241 (Sunstrider) bounds. Added `sunOverride = (pMap == 1241)` variable and forced target conversion through 1241 bounds at all 4 conversion sites in both functions. Without this fix, sortedTargets showed dist=1261 instead of the correct ~48 yards.
- **[Fix — NPC Spawn Zone for Sunstrider (AscensionDB)]** NPC 15281 (Lanthan Perilon) spawn data changed from zone 3430 to zone 1241. Coordinates gathered via `GetPlayerMapPosition` on uiMap 1241 are in 1241's normalized space, NOT 1941's. Using zone 3430 (→1941) produced world coordinates outside 1241's 0-1 range, making map pins invisible on the Sunstrider sub-map. HBD's ZONE_REDIRECT visibility (`ResolveZone(1241)=1941`) ensures pins on 1241 are also visible on the Eversong map.
### Notes ### Notes
+52 -27
View File
@@ -304,33 +304,6 @@ end
QuestieCompat.C_Map = { QuestieCompat.C_Map = {
GetPlayerMapPosition = function(uiMapID, unitToken) GetPlayerMapPosition = function(uiMapID, unitToken)
unitToken = unitToken or "player"
if uiMapID and QuestieCompat.UiMapData and QuestieCompat.UiMapData[uiMapID] then
local originalMapAreaID = GetCurrentMapAreaID()
local originalDungeonLevel = GetCurrentMapDungeonLevel and GetCurrentMapDungeonLevel() or 0
local mapID = QuestieCompat.UiMapData[uiMapID].mapID
local dungeonLevel = QuestieCompat.Round(math.mod(mapID, 1) * 10)
SetMapByID(math.floor(mapID) - 1)
if dungeonLevel > 0 and SetDungeonMapLevel then
SetDungeonMapLevel(dungeonLevel)
end
local x, y = GetPlayerMapPosition(unitToken)
if originalMapAreaID and originalMapAreaID > 0 then
SetMapByID(originalMapAreaID - 1)
if originalDungeonLevel and originalDungeonLevel > 0 and SetDungeonMapLevel then
SetDungeonMapLevel(originalDungeonLevel)
end
else
SetMapToCurrentZone()
end
return { uiMapID = uiMapID, x = x, y = y }, uiMapID
end
return QuestieCompat.GetPlayerMapPosition() return QuestieCompat.GetPlayerMapPosition()
end, end,
-- Returns map information. -- Returns map information.
@@ -880,6 +853,58 @@ function QuestieCompat.GetFactionInfo(factionIndex)
canToggleAtWar, isHeader, isCollapsed, hasRep, isWatched, isChild, QuestieCompat.FactionId[name:trim()] canToggleAtWar, isHeader, isCollapsed, hasRep, isWatched, isChild, QuestieCompat.FactionId[name:trim()]
end end
-- Returns faction info by factionID.
-- https://wowpedia.fandom.com/wiki/API_GetFactionInfoByID
-- Patch 4.0.1 (Cataclysm): Added.
-- On 3.3.5, this API does not exist, so we iterate GetFactionInfo indices and use
-- our FactionId reverse lookup to match by ID.
local _factionIdReverse = nil
function QuestieCompat.GetFactionInfoByID(factionID)
if not factionID then return nil end
-- Build reverse map once (id -> name) from QuestieCompat.FactionId (name -> id)
if not _factionIdReverse then
_factionIdReverse = {}
for name, id in next, QuestieCompat.FactionId do
_factionIdReverse[id] = name
end
end
-- Fast path: try reverse lookup from our hardcoded faction data
local name = _factionIdReverse[factionID]
if name then
-- Find the faction in the reputation UI to get full info
local numFactions = GetNumFactions()
for i = 1, numFactions do
local fName, description, standingId, bottomValue, topValue, earnedValue,
atWarWith, canToggleAtWar, isHeader, isCollapsed, hasRep, isWatched, isChild = GetFactionInfo(i)
if fName and fName:trim() == name then
return fName, description, standingId, bottomValue, topValue, earnedValue,
atWarWith, canToggleAtWar, isHeader, isCollapsed, hasRep, isWatched, isChild, factionID
end
end
-- Faction is in our DB but not in the reputation UI yet — return name-only
return name
end
-- Slow path: iterate all visible factions and check their ID via our name->id table
local numFactions = GetNumFactions()
for i = 1, numFactions do
local fName, description, standingId, bottomValue, topValue, earnedValue,
atWarWith, canToggleAtWar, isHeader, isCollapsed, hasRep, isWatched, isChild = GetFactionInfo(i)
if fName then
local fId = QuestieCompat.FactionId[fName:trim()]
if fId == factionID then
return fName, description, standingId, bottomValue, topValue, earnedValue,
atWarWith, canToggleAtWar, isHeader, isCollapsed, hasRep, isWatched, isChild, factionID
end
end
end
-- Faction not found
return nil
end
-- Returns true if the unit is a member of your party -- Returns true if the unit is a member of your party
-- https://wowpedia.fandom.com/wiki/API_UnitInParty -- https://wowpedia.fandom.com/wiki/API_UnitInParty
-- As of 2.0.3, UnitInParty("player") always returns 1, even when you are not in a party. -- As of 2.0.3, UnitInParty("player") always returns 1, even when you are not in a party.
+6 -9
View File
@@ -54,7 +54,7 @@ QuestieCompat.HBD = HBD
-- ZONE_REDIRECT: used by ResolveZone() for visibility logic (isSameZoneSpace). -- ZONE_REDIRECT: used by ResolveZone() for visibility logic (isSameZoneSpace).
local ZONE_REDIRECT = { local ZONE_REDIRECT = {
[1241] = 1941, -- Sunstrider Isle -> Eversong Woods (shared visibility space) [1241] = 1941, -- Sunstrider Isle -> Eversong Woods (shared visibility space)
[946] = 1941, -- Ghost map -> Eversong Woods (shared visibility space) [946] = 1941, -- Ghost/transition map -> Eversong Woods (for Sunstrider loading)
} }
--- Resolve a zone ID through the redirect table. --- Resolve a zone ID through the redirect table.
@@ -465,14 +465,11 @@ local function drawMinimapPin(pin, data)
end end
local function _GetEffectiveMinimapPlayerWorldPosition() local function _GetEffectiveMinimapPlayerWorldPosition()
if QuestieCompat and QuestieCompat.GetCurrentPlayerPosition and QuestieCompat.GetCalibratedPlayerPosition then -- For minimap pins we MUST use HBD's native world coordinate space.
local uiMapID = QuestieCompat.GetCurrentPlayerPosition() -- Minimap pins store their positions in HBD world coords, so the player
local worldX, worldY, instanceID = QuestieCompat.GetCalibratedPlayerPosition(uiMapID, nil, "player") -- position must also be in HBD world coords. The calibrated pseudo-world
if worldX and worldY then -- space (used by the arrow) has a different origin/scale on Ascension and
return worldX, worldY, instanceID -- must never be mixed with minimap pin positions.
end
end
return HBD:GetPlayerWorldPosition() return HBD:GetPlayerWorldPosition()
end end
+6 -2
View File
@@ -693,7 +693,9 @@ function QuestieDB.GetSuppressedNPCs(zoneId)
local threshold = ld.settings.minConfidencePins or 2 local threshold = ld.settings.minConfidencePins or 2
local npcId, entry = next(ld.npcs) local npcId, entry = next(ld.npcs)
while npcId do while npcId do
if entry.mc and entry.mc >= threshold and entry[7] and entry[7][zoneId] then -- Support both old format ([4]=spawns) and new format ([7]=spawns)
local spawns = entry[7] or entry[4]
if entry.mc and entry.mc >= threshold and spawns and spawns[zoneId] then
suppressed[npcId] = true suppressed[npcId] = true
end end
npcId, entry = next(ld.npcs, npcId) npcId, entry = next(ld.npcs, npcId)
@@ -713,7 +715,9 @@ function QuestieDB.GetSuppressedObjects(zoneId)
local threshold = ld.settings.minConfidencePins or 2 local threshold = ld.settings.minConfidencePins or 2
local objId, entry = next(ld.objects) local objId, entry = next(ld.objects)
while objId do while objId do
if entry.mc and entry.mc >= threshold and entry[4] and entry[4][zoneId] then -- Support both old format ([4]=spawns) and new format ([7]=spawns)
local spawns = entry[4] or entry[7]
if entry.mc and entry.mc >= threshold and spawns and spawns[zoneId] then
suppressed[objId] = true suppressed[objId] = true
end end
objId, entry = next(ld.objects, objId) objId, entry = next(ld.objects, objId)
+18 -5
View File
@@ -62,25 +62,38 @@ local UiMapIdOverrides = {
-- Sunstrider Isle overrides (separate from Northshire since they're different Ascension realms) -- Sunstrider Isle overrides (separate from Northshire since they're different Ascension realms)
ZoneDB.private.uiMapIdToAreaId = ZoneDB.private.uiMapIdToAreaId or {} ZoneDB.private.uiMapIdToAreaId = ZoneDB.private.uiMapIdToAreaId or {}
ZoneDB.private.uiMapIdToAreaId[1241] = 3430 ZoneDB.private.uiMapIdToAreaId[1241] = 3430
ZoneDB.private.uiMapIdToAreaId[946] = 3430 -- Ghost map for Sunstrider Isle (different zone than Northshire's 946) -- 946 is a ghost/transition map. On Horde (Sunstrider), it resolves to areaId 3430.
-- On Alliance (Northshire), it resolves to areaId 12 (Elwynn). The AscensionDB
-- zone table handles both via runtime loading; we set the default here for
-- the Sunstrider case since that's where pins break without it.
ZoneDB.private.uiMapIdToAreaId[946] = 3430
-- Reverse mapping: areaId → uiMapId for GetUiMapIdByAreaId lookups. -- Reverse mapping: areaId → uiMapId for GetUiMapIdByAreaId lookups.
-- Northshire Valley (areaId 668) uses uiMapId 1238. -- Northshire Valley (areaId 668) uses uiMapId 1238.
ZoneDB.private.areaIdToUiMapId[668] = 1238 ZoneDB.private.areaIdToUiMapId[668] = 1238
areaIdToUiMapId[668] = 1238 areaIdToUiMapId[668] = 1238
-- Eversong Woods (areaId 3430) maps to the Eversong Woods map (uiMapId 1941). -- Eversong Woods (areaId 3430) maps to the Eversong Woods map (uiMapId 1941).
-- Sunstrider Isle (uiMapId 1241) is a child map that shares Eversong's coordinate space. -- Sunstrider Isle (areaId 3431) is a subzone of Eversong Woods — same map (1941).
-- Pins for zone 3430 render on map 1941 and appear on map 1241 via ZONE_REDIRECT visibility. -- Sunstrider Isle (uiMapId 1241) is a child map of Eversong Woods.
-- Pins for zones 3430 and 3431 render on map 1941 (Eversong).
-- Pins for zone 1241 render on map 1241 (Sunstrider) using Ascension-calibrated bounds.
ZoneDB.private.areaIdToUiMapId[3430] = 1941 ZoneDB.private.areaIdToUiMapId[3430] = 1941
areaIdToUiMapId[3430] = 1941 areaIdToUiMapId[3430] = 1941
ZoneDB.private.areaIdToUiMapId[3431] = 1941
areaIdToUiMapId[3431] = 1941
-- Allow drawing pins directly on Sunstrider Isle (uiMapId 1241) via areaId 1241.
ZoneDB.private.areaIdToUiMapId[1241] = 1241
areaIdToUiMapId[1241] = 1241
-- Also populate the cache so the fast path works without a lazy lookup. -- Also populate the cache so the fast path works without a lazy lookup.
if uiMapIdToAreaIdCache[1238] == nil then if uiMapIdToAreaIdCache[1238] == nil then
uiMapIdToAreaIdCache[1238] = 668 uiMapIdToAreaIdCache[1238] = 668
end end
-- Sunstrider Isle uiMapId 1241 → areaId 3431 (subzone, not parent 3430).
-- Questie resolves 3431→3430 via GetParentZoneId() automatically when needed.
if uiMapIdToAreaIdCache[1241] == nil then if uiMapIdToAreaIdCache[1241] == nil then
uiMapIdToAreaIdCache[1241] = 3430 uiMapIdToAreaIdCache[1241] = 3431
end end
if uiMapIdToAreaIdCache[946] == nil then if uiMapIdToAreaIdCache[946] == nil then
uiMapIdToAreaIdCache[946] = 3430 -- Ghost map for Sunstrider Isle (different zone than Northshire's 946) uiMapIdToAreaIdCache[946] = 3430
end end
local parentZoneToSubZone = {} -- Generated local parentZoneToSubZone = {} -- Generated
local zoneMap = {} -- Generated local zoneMap = {} -- Generated
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.
+271 -267
View File
@@ -26,12 +26,12 @@ local abs = math.abs
local max = math.max local max = math.max
local min = math.min local min = math.min
local ARROW_SHEET_SIZE = 512 -- Single-frame arrow with SetRotation for perfectly smooth rotation.
local ARROW_CELL_W = 56 -- Texture is a 256x256 SQUARE TGA with arrow 2x horizontally stretched to fill
local ARROW_CELL_H = 42 -- ~70% of canvas. SQUARE is critical: SetRotation rotates UVs inside the display
local ARROW_SHEET_COLS = 9 -- rect, so non-square textures distort at every diagonal angle. No SetTexCoord.
local ARROW_SHEET_ROWS = 12 local ARROW_DISPLAY_WIDTH = 160
local ARROW_TOTAL_CELLS = ARROW_SHEET_COLS * ARROW_SHEET_ROWS local ARROW_DISPLAY_HEIGHT = 160
local UPDATE_THROTTLE_SECONDS = 0.05 local UPDATE_THROTTLE_SECONDS = 0.05
local RECALC_NEAREST_SECONDS = 1.0 local RECALC_NEAREST_SECONDS = 1.0
@@ -49,10 +49,10 @@ local hasManualTarget = false
-- Shared context written by UpdateNearestTargets, read by hoisted helpers. -- Shared context written by UpdateNearestTargets, read by hoisted helpers.
-- Avoids closure allocation on every call. -- Avoids closure allocation on every call.
local _arrow_playerX, _arrow_playerY, _arrow_playerInstance local _arrow_playerX, _arrow_playerY, _arrow_playerInstance
local _arrow_playerCalibratedX, _arrow_playerCalibratedY, _arrow_playerCalibratedGroup
local _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId local _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId
local _arrow_quest -- current quest being processed by the hoisted helpers local _arrow_quest -- current quest being processed by the hoisted helpers
local lastPopulateByQuestId = {} local lastPopulateByQuestId = {}
local function _IsArrowEnabled() local function _IsArrowEnabled()
@@ -134,36 +134,24 @@ local function ResolveIconTexture(icon)
end end
local function _ResolveArrowUiMapId(uiMapId) local function _ResolveArrowUiMapId(uiMapId)
-- Sunstrider Isle (1241) and its ghost map (946) both resolve to Eversong Woods (1941) -- Ghost map 946 has no real coordinate data; redirect to Eversong (1941).
-- for arrow calculations. This normalizes both player and target uiMapIds so the -- Ascension custom map 1241 (Sunstrider Isle) must also redirect to 1941
-- same-map branch fires and zone-relative coord math works consistently. -- so target world coords match player world coords (both in Eversong space).
-- NOTE: _ResolveMapUiMapId in QuestieMap.lua also redirects 1241→1941 because if uiMapId == 946 or uiMapId == 1241 then
-- on Ascension, map 1241 shares Eversong's coordinate space. Zone 3430 data
-- now maps to uiMapId 1941 via GetUiMapIdByAreaId(3430)=1941, so quest items
-- render on the Eversong map and appear on Sunstrider via ZONE_REDIRECT.
if uiMapId == 1241 or uiMapId == 946 then
return 1941 return 1941
end end
return uiMapId return uiMapId
end end
local function _GetSunstriderPlayerMapPosition(debugArrow) local function _GetSunstriderPlayerMapPosition(debugArrow)
local worldX, worldY, _, mapX, mapY, group = QuestieCompat.GetCalibratedPlayerPosition(_arrow_playerUiMapId, _arrow_playerZoneId, "player") -- NEVER use the calibrated branch here. It returns Eversong-wide normalized coords
if group and mapX and mapY then -- (~0.60, 0.44) which are in a completely different coordinate space from
if debugArrow then -- Sunstrider-local target coords (~0.38, 0.21). Mixing them causes bogus distances.
print(string.format("Sunstrider helper: calibrated primary=%s -> mapX=%.4f mapY=%.4f worldX=%.1f worldY=%.1f", -- Always obtain Sunstrider-local coords via the 1241 map lookup, then convert
tostring(group.primaryUiMapId), mapX, mapY, worldX or 0, worldY or 0)) -- through HBD using Eversong bounds (1941) to get real comparable world coords.
end
return mapX, mapY
end
local mapX2, mapY2 local mapX2, mapY2
if QuestieCompat and QuestieCompat.C_Map and QuestieCompat.C_Map.GetPlayerMapPosition then if QuestieCompat and QuestieCompat.C_Map and QuestieCompat.C_Map.GetPlayerMapPosition then
-- For local player map coords on Sunstrider, ask for the actual child map (1241).
-- Asking for parent 1941 returns parent-relative coords (~60/44) which recreates
-- the classic 433-yard / backwards-arrow bug when compared against Sunstrider-local
-- target coords (~38/21).
local mapPos = QuestieCompat.C_Map.GetPlayerMapPosition(1241, "player") local mapPos = QuestieCompat.C_Map.GetPlayerMapPosition(1241, "player")
if type(mapPos) == "table" then if type(mapPos) == "table" then
mapX2, mapY2 = mapPos.x, mapPos.y mapX2, mapY2 = mapPos.x, mapPos.y
@@ -193,6 +181,8 @@ local function _GetSunstriderPlayerMapPosition(debugArrow)
return mapX2, mapY2 return mapX2, mapY2
end end
local function _ApplyOutline(fontString) local function _ApplyOutline(fontString)
if not fontString or not fontString.GetFont or not fontString.SetFont then if not fontString or not fontString.GetFont or not fontString.SetFont then
return return
@@ -235,9 +225,9 @@ local function EnsureArrowFrame()
-- Store whether we should use saved position or default -- Store whether we should use saved position or default
arrowFrame._useDefaultPosition = not (pos and pos.point) arrowFrame._useDefaultPosition = not (pos and pos.point)
-- Make room for the objective icon below the arrow (no overlap) -- Make room for arrow (square) plus icon and text below
arrowFrame:SetWidth(56) arrowFrame:SetWidth(ARROW_DISPLAY_WIDTH)
arrowFrame:SetHeight(64) arrowFrame:SetHeight(ARROW_DISPLAY_HEIGHT + 60)
arrowFrame:SetScale(_GetArrowScale()) arrowFrame:SetScale(_GetArrowScale())
arrowFrame:SetClampedToScreen(true) arrowFrame:SetClampedToScreen(true)
arrowFrame:SetMovable(true) arrowFrame:SetMovable(true)
@@ -278,20 +268,19 @@ local function EnsureArrowFrame()
self:SetScale(scale) self:SetScale(scale)
end) end)
-- Arrow sprite sheet texture (108 cells: 9 columns, 12 rows) -- Single arrow texture with SetRotation for smooth rotation
arrowFrame.arrow = arrowFrame:CreateTexture(nil, "MEDIUM") arrowFrame.arrow = arrowFrame:CreateTexture(nil, "MEDIUM")
arrowFrame.arrow:SetTexture(QuestieLib.AddonPath .. "Icons\\arrow.tga") arrowFrame.arrow:SetTexture(QuestieLib.AddonPath .. "Icons\\arrow.tga")
-- Render at native cell size; use frame scaling if you want it larger. arrowFrame.arrow:SetWidth(ARROW_DISPLAY_WIDTH)
arrowFrame.arrow:SetWidth(ARROW_CELL_W) arrowFrame.arrow:SetHeight(ARROW_DISPLAY_HEIGHT)
arrowFrame.arrow:SetHeight(ARROW_CELL_H) arrowFrame.arrow:SetPoint("CENTER", arrowFrame, "CENTER", 0, 0)
arrowFrame.arrow:SetPoint("TOP", arrowFrame, "TOP", 0, 0) arrowFrame.arrow:SetRotation(0) -- 0 = pointing up (north)
arrowFrame.arrow:SetTexCoord(0, 0.109375, 0, 0.08203125) -- First cell
-- Quest icon texture at bottom (pfQuest style) -- Quest icon texture at bottom (pfQuest style)
arrowFrame.icon = arrowFrame:CreateTexture(nil, "OVERLAY") arrowFrame.icon = arrowFrame:CreateTexture(nil, "OVERLAY")
arrowFrame.icon:SetWidth(28) arrowFrame.icon:SetWidth(28)
arrowFrame.icon:SetHeight(28) arrowFrame.icon:SetHeight(28)
arrowFrame.icon:SetPoint("BOTTOM", arrowFrame.arrow, "BOTTOM", 0, -20) arrowFrame.icon:SetPoint("BOTTOM", arrowFrame.arrow, "BOTTOM", 0, 0)
arrowFrame.title = arrowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal") arrowFrame.title = arrowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
arrowFrame.title:SetPoint("TOP", arrowFrame.icon, "BOTTOM", 0, -2) arrowFrame.title:SetPoint("TOP", arrowFrame.icon, "BOTTOM", 0, -2)
@@ -336,211 +325,129 @@ arrowFrame:SetScript("OnUpdate", function(self)
end end
self._lastUpdate = now self._lastUpdate = now
-- Persistent debug: print every frame so we can see what OnUpdate sees
local debugArrow = Questie and Questie.db and Questie.db.profile and Questie.db.profile.debugArrow local debugArrow = Questie and Questie.db and Questie.db.profile and Questie.db.profile.debugArrow
local target = sortedTargets[1]
-- -----------------------------------------------------------------
-- Get fresh player world position every frame for smooth arrow
-- rotation. UnitPosition updates every frame via HBD, which is
-- essential — cached values (updated only every 1s) make the
-- arrow rotate with the character since only GetPlayerFacing()
-- changes per frame when position is stale.
-- On Sunstrider, HBD may return Eastern Kingdoms coords. We
-- detect this and override with the cached corrected position.
-- -----------------------------------------------------------------
local playerX, playerY, playerInstance = HBD:GetPlayerWorldPosition()
if not playerX or not playerY or not playerInstance then
self.distance:SetText("Distance: --")
return
end
-- On Sunstrider, HBD returns Eastern Kingdoms continent coords
-- which are outside Eversong bounds. Instead of replacing fresh
-- per-frame coords with stale 1-second cache, compute fresh
-- Sunstrider-local coords via C_Map on EVERY frame so that
-- both position AND facing update per-frame.
-- PITFALL: zoneId can be 3431 (Eversong) while player is on uiMap 1241 (Sunstrider).
-- Check both zoneId and cached uiMapId for robust detection.
local _curZoneId = QuestiePlayer:GetCurrentZoneId()
local _sunOnUpdate = (_curZoneId == 3430 or _curZoneId == 3431
or _arrow_playerUiMapId == 1241)
if _sunOnUpdate then
local EXMIN, EXMAX, EYMIN, EYMAX = -2000, 3200, 5300, 8700
if playerX < EXMIN or playerX > EXMAX or playerY < EYMIN or playerY > EYMAX then
-- Fresh per-frame computation instead of stale cache
local mapX, mapY = _GetSunstriderPlayerMapPosition(debugArrow)
if mapX and mapY and mapX > 0 and mapY > 0 then
local wX, wY, wInst = HBD:GetWorldCoordinatesFromZone(mapX, mapY, 1241) -- use Ascension-calibrated 1241 bounds
if wX and wY then
if debugArrow then if debugArrow then
print(string.format("QuestieArrow OnUpdate: frameShown=%s target=%s pX=%s pY=%s pInst=%s _playerUiMapId=%s targetUiMapId=%s", print(string.format("OnUpdate: Sunstrider fresh HBD(%.0f,%.0f) -> computed(%.0f,%.0f)",
tostring(self:IsShown()), tostring(target and target.title), playerX, playerY, wX, wY))
tostring(_arrow_playerX), tostring(_arrow_playerY), tostring(_arrow_playerInstance), end
tostring(_arrow_playerUiMapId), tostring(target and target.uiMapId))) playerX, playerY, playerInstance = wX, wY, wInst or playerInstance
elseif _arrow_playerX and _arrow_playerY then
-- Last resort: stale cache only if fresh calc fails
if debugArrow then
print(string.format("OnUpdate: Sunstrider fresh calc FAILED, using stale cache(%.0f,%.0f)",
_arrow_playerX, _arrow_playerY))
end
playerX, playerY, playerInstance = _arrow_playerX, _arrow_playerY, _arrow_playerInstance
end
elseif _arrow_playerX and _arrow_playerY then
-- _GetSunstriderPlayerMapPosition failed, stale cache fallback
if debugArrow then
print(string.format("OnUpdate: Sunstrider map pos failed, using stale cache(%.0f,%.0f)",
_arrow_playerX, _arrow_playerY))
end
playerX, playerY, playerInstance = _arrow_playerX, _arrow_playerY, _arrow_playerInstance
end
end
end end
local pX, pY, pInst = _arrow_playerX, _arrow_playerY, _arrow_playerInstance -- Convert target spawn coords to world coordinates.
if not pX or not pY or not pInst then -- Resolve uiMapId again here as a safety net for targets that may
-- Fallback to HBD if upvalues aren't set yet -- have raw custom map IDs (1241) bypassing PopulateTargets resolution.
pX, pY, pInst = HBD:GetPlayerWorldPosition() local targetUiMapId = _ResolveArrowUiMapId(target.uiMapId)
end -- On Sunstrider, convert target through Ascension-calibrated 1241 bounds
if debugArrow then -- so target world coords match player world coords (both through 1241).
local hbPX, hbPY, hbPInst = HBD:GetPlayerWorldPosition() -- PITFALL: zoneId can be 3431 while player is on uiMap 1241.
print(string.format("DEBUG PLAYER COMPARE: UnitPos pX=%.1f pY=%.1f | HBD pX=%.1f pY=%.1f | inst=%s", if _sunOnUpdate and targetUiMapId == 1941 then
pX or 0, pY or 0, hbPX or 0, hbPY or 0, tostring(hbPInst))) targetUiMapId = 1241
print(string.format("DEBUG MAP RELATIVE: pX=%.4f pY=%.4f (from _arrow_playerX/Y, UnitPosition world coords)", pX or 0, pY or 0))
print(string.format("DEBUG OnUpdate: rawUiMapId=%s resolved=%s targetUiMapId=%s", tostring(_arrow_playerUiMapId), tostring(playerUiMapId), tostring(targetUiMapId)))
print(string.format("DEBUG SPAWN COORDS: target worldX=%.1f worldY=%.1f rawMapX=%.2f rawMapY=%.2f",
targetX or 0, targetY or 0, rawMapX or 0, rawMapY or 0))
end
if not pX or not pY or not pInst then
local debugArrow = Questie and Questie.db and Questie.db.profile and Questie.db.profile.debugArrow
if debugArrow then
print(string.format("QuestieArrow OnUpdate: player position nil (x=%s y=%s inst=%s)", tostring(pX), tostring(pY), tostring(pInst)))
end end
local targetX, targetY, targetInstance = HBD:GetWorldCoordinatesFromZone(target.x / 100.0, target.y / 100.0, targetUiMapId)
if not targetX or not targetY or not targetInstance then
self.distance:SetText("Distance: --") self.distance:SetText("Distance: --")
return
end
if targetInstance ~= playerInstance then
self:Hide() self:Hide()
return return
end end
-- Player's ACTUAL uiMapId (1241 on Sunstrider) vs target's uiMapId. -- Arrow direction from pure world-coordinate math.
-- _arrow_playerUiMapId is the real map the player is on (1241 on Sunstrider). -- HBD world coords: X decreases going EAST (more negative = more west).
-- target.uiMapId is the resolved uiMapId for the spawn (1941 for Sunstrider targets). -- Y increases going NORTH (larger = more north).
-- Use the player's REAL uiMapId for the same-map check — not the resolved one. -- GetPlayerFacing: 0=North, π/2=East, π=South, 3π/2=West (CW from N).
-- _ResolveArrowUiMapId normalizes both player and target uiMapIds to the same -- SetRotation(r): rotates texture CW (positive = clockwise).
-- coordinate system: 1241→1941, 946→1941. This lets the same-map branch work -- Arrow image tip is at TOP of file → points UP at SetRotation(0).
-- on Sunstrider Isle where player is on 946 but targets resolve to 1941. --
local playerUiMapId = _ResolveArrowUiMapId(_arrow_playerUiMapId) or 0 -- dx = targetX - playerX:
local targetUiMapId = _ResolveArrowUiMapId(target.uiMapId) or 0 -- targetX > playerX (numerically) → target LESS negative → target EAST
-- So dx>0 = target EAST of player
-- Declare world coords outside the branches so they're in scope for direction calc -- dy = targetY - playerY:
local playerWorldX, playerWorldY -- targetY > playerY → target MORE north
-- So dy>0 = target NORTH of player
-- If target and player are on the same uiMapId, they're in the same instance. --
-- If they're on different maps, we need to compare instances via HBD. -- Bearing CW from North: atan2(dx, dy)
if playerUiMapId ~= targetUiMapId then -- N: dx=0, dy>0 → atan2(0,+) = 0
-- Different maps: just skip instance check since UnitPosition gives us -- E: dx>0, dy=0 → atan2(+,0) = π/2
-- the real instance — if player and target instances differ, HBD will return -- S: dx=0, dy<0 → atan2(0,-) = π
-- nil for distance anyway. Fall through to direction calc with player coords. -- W: dx<0, dy=0 → atan2(-,0) = -π/2 → 3π/2
end --
-- Screen direction relative to facing:
-- Calculate arrow direction using zone-relative coords when on same map (avoids -- relative = bearing - facing (0 = target ahead)
-- world-coordinate mismatch on Sunstrider Isle where player is in EK world space). --
-- For cross-map, fall back to world coords via HBD. -- SetRotation wants CW; relative is CW: use it directly.
-- The key insight: on Sunstrider, _arrow_playerX/Y (world EK coords) and target.x/y -- SetRotation(relative) → arrow points at target on screen.
-- (zone coords in Sunstrider space) are incompatible. We use C_Map to get the player's local dx = targetX - playerX
-- zone coords in Sunstrider space so they align with target zone coords. local dy = targetY - playerY
local targetX, targetY, targetInstance local bearing = atan2(dx, dy)
local useZoneAngle = false if bearing < 0 then bearing = bearing + (pi * 2) end
local zoneAngle = nil local facing = GetPlayerFacing and GetPlayerFacing() or 0
local zoneBasedDist = nil local relative = bearing - facing
-- Declare worldPlayerX/Y here so they're in scope for the debug print at line 478 if relative < 0 then relative = relative + (pi * 2) end
-- even when the same-map branch takes the zone-relative path (useZoneAngle=true). local rotAngle = relative -- CW rotation for SetRotation
local worldPlayerX, worldPlayerY
if debugArrow then
print(string.format("DEBUG BRANCH CHECK: playerUiMapId=%s targetUiMapId=%s sameMap=%s",
tostring(playerUiMapId), tostring(targetUiMapId),
tostring(playerUiMapId == targetUiMapId and playerUiMapId ~= 0)))
end
if playerUiMapId == targetUiMapId and playerUiMapId ~= 0 then
-- Same uiMapId on Sunstrider/Eversong: use zone-relative coordinates for BOTH
-- distance and direction. Mixing player UnitPosition world coords with HBD target
-- coords recreates the classic 433-yard / backwards-arrow bug.
local pZoneX, pZoneY = _GetSunstriderPlayerMapPosition(debugArrow)
local playerZoneX, playerZoneY = pZoneX * 100, pZoneY * 100
local targetZoneX, targetZoneY = target.x, target.y
local zoneDist = sqrt((playerZoneX - targetZoneX) ^ 2 + (playerZoneY - targetZoneY) ^ 2)
-- Eversong/Sunstrider: 100 zone-units ≈ 1353 yards (full map width from HBD bounds)
local zoneScale = 13.53 -- yards per zone-unit
zoneBasedDist = zoneDist * zoneScale
local zoneXDelta = (playerZoneX - targetZoneX) * 1.5
local zoneYDelta = -(playerZoneY - targetZoneY)
zoneAngle = atan2(zoneXDelta, -zoneYDelta)
zoneAngle = zoneAngle > 0 and (pi * 2) - zoneAngle or -zoneAngle
if zoneAngle < 0 then zoneAngle = zoneAngle + (pi * 2) end
targetX, targetY, targetInstance = targetZoneX, targetZoneY, 0
useZoneAngle = true
if debugArrow then if debugArrow then
local dbg1941x, dbg1941y = HBD:GetZoneCoordinatesFromWorld(pX, pY, 1941, true) print(string.format("QuestieArrow OnUpdate: target=%s pX=%.1f pY=%.1f tX=%.1f tY=%.1f dx=%.1f dy=%.1f bearing=%.2f facing=%.2f relative=%.2f rotAng=%.2f inst=%s",
local dbg1241x, dbg1241y = HBD:GetZoneCoordinatesFromWorld(pX, pY, 1241, true) tostring(target.title), playerX, playerY, targetX, targetY, dx, dy, bearing, facing, relative, rotAngle, tostring(playerInstance)))
print(string.format("DEBUG SAME_MAP: zoneDist=%.4f zoneYards≈%.1f | pZone(%.2f,%.2f) tZone(%.2f,%.2f) angle=%.2f | world->1941(%.4f,%.4f) world->1241(%.4f,%.4f)",
zoneDist, zoneBasedDist, playerZoneX, playerZoneY, targetZoneX, targetZoneY,
zoneAngle or 0,
(dbg1941x or -1), (dbg1941y or -1),
(dbg1241x or -1), (dbg1241y or -1)))
end end
else
-- Different maps: convert target to world coords using its uiMapId (works for 1941).
-- Player is already in world coords from UnitPosition via pX/pY.
local tWX, tWY, tInst = HBD:GetWorldCoordinatesFromZone(target.x / 100.0, target.y / 100.0, targetUiMapId)
if tWX and tWY then
targetX, targetY, targetInstance = tWX, tWY, tInst or pInst or 0
if debugArrow then
print(string.format("DEBUG OnUpdate else-branch: tWX=%.4f tWY=%.4f tInst=%s using HBD", tWX, tWY, tostring(tInst)))
end
else
-- HBD failed: fall back to zone coords (target.x, target.y) for direction only.
-- Distance will be wrong (zone units instead of yards) but arrow will point correctly.
targetX, targetY = target.x, target.y
targetInstance = pInst or 0
if debugArrow then
print(string.format("DEBUG OnUpdate else-branch: HBD failed for target uiMapId=%s, falling back to zone coords (%.2f, %.2f)", tostring(targetUiMapId), targetX, targetY))
end
end
end
-- If targetX is still nil at this point, bail out
if not targetX or not targetY then
self.distance:SetText("Distance: --")
return
end
-- Skip world direction calc when same-map; zoneAngle already computed above
if useZoneAngle then
-- zoneAngle already set; apply facing and proceed.
-- Sunstrider calibrated zone-space currently resolves to the inverse heading
-- relative to the arrow sprite sheet, so flip by 180 degrees after facing.
angle = zoneAngle - (GetPlayerFacing and GetPlayerFacing() or 0) + pi
if angle < 0 then angle = angle + (pi * 2) end
if angle >= (pi * 2) then angle = angle - (pi * 2) end
else
-- Use world coords for direction: both player and target must be in world coordinate space.
-- UnitPosition("player") gives world coords directly. For same-uiMapId: pX/pY from
-- UpdateNearestTargets are world coords from UnitPosition — use directly.
if playerWorldX then
worldPlayerX, worldPlayerY = playerWorldX, playerWorldY
else
worldPlayerX, worldPlayerY = pX, pY
end
if not targetX or not targetY then
self.distance:SetText("Distance: --")
return
end
local xDelta = (worldPlayerX - targetX) * 1.5
local yDelta = (worldPlayerY - targetY)
angle = atan2(xDelta, -(yDelta))
angle = angle > 0 and (pi * 2) - angle or -angle
if angle < 0 then angle = angle + (pi * 2) end
angle = angle - (GetPlayerFacing and GetPlayerFacing() or 0)
end
-- Calculate color gradient based on direction
local perc = abs(((pi - abs(angle)) / pi))
local r, g, b = GetColorGradient(perc)
-- Select sprite sheet cell
local cell = modulo(floor(angle / (pi * 2) * ARROW_TOTAL_CELLS + 0.5), ARROW_TOTAL_CELLS)
local column = modulo(cell, ARROW_SHEET_COLS)
local row = floor(cell / ARROW_SHEET_COLS)
local xstart = (column * ARROW_CELL_W) / ARROW_SHEET_SIZE
local ystart = (row * ARROW_CELL_H) / ARROW_SHEET_SIZE
local xend = ((column + 1) * ARROW_CELL_W) / ARROW_SHEET_SIZE
local yend = ((row + 1) * ARROW_CELL_H) / ARROW_SHEET_SIZE
-- Avoid bleeding from neighboring cells when texture filtering is enabled.
local padX = 0.5 / ARROW_SHEET_SIZE
local padY = 0.5 / ARROW_SHEET_SIZE
xstart = xstart + padX
ystart = ystart + padY
xend = xend - padX
yend = yend - padY
-- Calculate distance and alpha -- Calculate distance and alpha
-- Override distance with zone-based when same-map (avoids cross-world-system mismatch) local dist = HBD:GetWorldDistance(targetInstance, playerX, playerY, targetX, targetY)
local dist = nil
if useZoneAngle and zoneBasedDist then
dist = zoneBasedDist
else
dist = HBD:GetWorldDistance(targetInstance, worldPlayerX, worldPlayerY, targetX, targetY)
end
if debugArrow then
local dbgDist = dist or 0
local dbgPX = worldPlayerX or 0
local dbgPY = worldPlayerY or 0
print(string.format("DEBUG DIST: dist=%s inst=%s pX=%.1f pY=%.1f tX=%.1f tY=%.1f rawMapX=%s rawMapY=%s",
tostring(dbgDist), tostring(targetInstance),
dbgPX, dbgPY,
targetX or 0, targetY or 0,
tostring(target.x), tostring(target.y)))
end
if dist then if dist then
if debugArrow then
local dbgWorldPX = worldPlayerX or targetX or 0
local dbgWorldPY = worldPlayerY or targetY or 0
print(string.format("QuestieArrow OnUpdate: dist=%.1f worldPlayerX=%.1f worldPlayerY=%.1f targetX=%.1f targetY=%.1f targetInst=%s title='%s' rawMapX=%s rawMapY=%s", dist, dbgWorldPX, dbgWorldPY, targetX or 0, targetY or 0, tostring(targetInstance), tostring(target.title), tostring(target.x), tostring(target.y)))
end
local area = 1 local area = 1
local alpha = dist - area local alpha = dist - area
alpha = alpha > 1 and 1 or alpha alpha = alpha > 1 and 1 or alpha
@@ -550,10 +457,8 @@ local pX, pY, pInst = _arrow_playerX, _arrow_playerY, _arrow_playerInstance
texalpha = texalpha > 1 and 1 or texalpha texalpha = texalpha > 1 and 1 or texalpha
texalpha = texalpha < 0 and 0 or texalpha texalpha = texalpha < 0 and 0 or texalpha
r, g, b = r + texalpha, g + texalpha, b + texalpha self.arrow:SetRotation(rotAngle)
self.arrow:SetVertexColor(1, 1, 1)
self.arrow:SetTexCoord(xstart, xend, ystart, yend)
self.arrow:SetVertexColor(r, g, b)
self.arrow:SetAlpha(alpha) self.arrow:SetAlpha(alpha)
local distText = string.format("%.1f", dist) local distText = string.format("%.1f", dist)
@@ -638,6 +543,8 @@ local function _CollectFinisherSpawns(finisher, quest)
if not finisher then return end if not finisher then return end
local pX, pY, pInst = _arrow_playerX, _arrow_playerY, _arrow_playerInstance local pX, pY, pInst = _arrow_playerX, _arrow_playerY, _arrow_playerInstance
local autoLogic, pZone, pMap = _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId local autoLogic, pZone, pMap = _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId
-- On Sunstrider, force target conversion through 1241 bounds to match player coords
local sunOverride = (pMap == 1241)
local iconPath = ResolveIconTexture(_GetCompleteIconType(quest)) local iconPath = ResolveIconTexture(_GetCompleteIconType(quest))
if finisher.spawns then if finisher.spawns then
for finisherZone, spawns in pairs(finisher.spawns) do for finisherZone, spawns in pairs(finisher.spawns) do
@@ -656,6 +563,7 @@ local function _CollectFinisherSpawns(finisher, quest)
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone) local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId and x and y then if uiMapId and x and y then
local resolvedUiMapId = _ResolveArrowUiMapId(uiMapId) local resolvedUiMapId = _ResolveArrowUiMapId(uiMapId)
if sunOverride and resolvedUiMapId == 1941 then resolvedUiMapId = 1241 end
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, resolvedUiMapId) local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, resolvedUiMapId)
if tX and tY and tInst then if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY) local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
@@ -678,6 +586,7 @@ local function _CollectFinisherSpawns(finisher, quest)
local uiMapId = ZoneDB:GetUiMapIdByAreaId(finisherZone) local uiMapId = ZoneDB:GetUiMapIdByAreaId(finisherZone)
if uiMapId then if uiMapId then
local resolvedUiMapId = _ResolveArrowUiMapId(uiMapId) local resolvedUiMapId = _ResolveArrowUiMapId(uiMapId)
if sunOverride and resolvedUiMapId == 1941 then resolvedUiMapId = 1241 end
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, resolvedUiMapId) local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, resolvedUiMapId)
if tX and tY and tInst then if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY) local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
@@ -706,6 +615,7 @@ local function _CollectFinisherSpawns(finisher, quest)
local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone) local uiMapId = ZoneDB:GetUiMapIdByAreaId(zone)
if uiMapId and x and y then if uiMapId and x and y then
local resolvedUiMapId = _ResolveArrowUiMapId(uiMapId) local resolvedUiMapId = _ResolveArrowUiMapId(uiMapId)
if sunOverride and resolvedUiMapId == 1941 then resolvedUiMapId = 1241 end
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, resolvedUiMapId) local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(x / 100.0, y / 100.0, resolvedUiMapId)
if tX and tY and tInst then if tX and tY and tInst then
local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY) local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
@@ -724,9 +634,38 @@ local function _CollectFinisherSpawns(finisher, quest)
end end
local function _CollectObjective(objective, quest) local function _CollectObjective(objective, quest)
if not objective or not objective.spawnList then return end if not objective or not objective.spawnList then
-- spawnList can be nil if _TryInvalidateObjective cleared it (QuestieLearner
-- learned new data) but the rebuild via UpdateQuest hasn't happened yet, or if
-- stale quest.isComplete prevented PopulateObjective from rebuilding it.
-- Proactively trigger UpdateQuest to rebuild the spawnList for this quest.
if quest and quest.Id and QuestieQuest and QuestieQuest.UpdateQuest then
local dbComplete = QuestieDB.IsComplete(quest.Id)
-- Only request rebuild if quest is NOT complete in the DB
if dbComplete ~= 1 and not quest.isComplete then
local now = GetTime()
local last = lastPopulateByQuestId[quest.Id] or 0
-- Throttle rebuilds to every 5 seconds per quest
if (last + 5.0) < now then
lastPopulateByQuestId[quest.Id] = now
Questie:Debug(Questie.DEBUG_DEVELOP, "[Arrow] _CollectObjective: spawnList nil for quest", quest.Id, "- triggering UpdateQuest rebuild")
QuestieQuest:UpdateQuest(quest.Id)
end
end
end
if debugCollect then
print(string.format(" _CollectObjective SKIP: obj=%s spawnList=%s (quest=%s)",
tostring(objective), objective and tostring(objective.spawnList) or "nil", quest and tostring(quest.name) or "?"))
end
return
end
if QuestieQuest.ShouldHideObjective(objective) then return end if QuestieQuest.ShouldHideObjective(objective) then return end
if objective.Completed == true or objective.Completed == 1 then return end if objective.Completed == true or objective.Completed == 1 then
if debugCollect then
print(string.format(" _CollectObjective SKIP: Completed=%s (quest=%s)", tostring(objective.Completed), quest and tostring(quest.name) or "?"))
end
return
end
if objective.Needed and objective.Collected if objective.Needed and objective.Collected
and type(objective.Needed) == "number" and type(objective.Collected) == "number" and type(objective.Needed) == "number" and type(objective.Collected) == "number"
and objective.Collected >= objective.Needed then and objective.Collected >= objective.Needed then
@@ -734,6 +673,8 @@ local function _CollectObjective(objective, quest)
end end
local pX, pY, pInst = _arrow_playerX, _arrow_playerY, _arrow_playerInstance local pX, pY, pInst = _arrow_playerX, _arrow_playerY, _arrow_playerInstance
local autoLogic, pZone, pMap = _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId local autoLogic, pZone, pMap = _arrow_usingAutoLogic, _arrow_playerZoneId, _arrow_playerUiMapId
-- On Sunstrider, force target conversion through 1241 bounds to match player coords
local sunOverride = (pMap == 1241)
local debugCollect = Questie and Questie.db and Questie.db.profile and Questie.db.profile.debugArrow local debugCollect = Questie and Questie.db and Questie.db.profile and Questie.db.profile.debugArrow
if debugCollect then if debugCollect then
print(string.format(" _CollectObjective: spawnList=%s", objective.spawnList and "yes" or "nil")) print(string.format(" _CollectObjective: spawnList=%s", objective.spawnList and "yes" or "nil"))
@@ -760,16 +701,12 @@ local function _CollectObjective(objective, quest)
end end
if uiMapId then if uiMapId then
local resolvedUiMapId = _ResolveArrowUiMapId(uiMapId) local resolvedUiMapId = _ResolveArrowUiMapId(uiMapId)
local tX, tY, tInst, calibratedTargetGroup = QuestieCompat.GetCalibratedWorldCoordinatesFromZone(spawn[1] / 100.0, spawn[2] / 100.0, resolvedUiMapId, zone) if sunOverride and resolvedUiMapId == 1941 then resolvedUiMapId = 1241 end
local tX, tY, tInst = HBD:GetWorldCoordinatesFromZone(spawn[1] / 100.0, spawn[2] / 100.0, resolvedUiMapId)
if tX and tY and tInst then if tX and tY and tInst then
local dist local dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
if calibratedTargetGroup and _arrow_playerCalibratedGroup and _arrow_playerCalibratedX and _arrow_playerCalibratedY then
dist = sqrt((_arrow_playerCalibratedX - tX) ^ 2 + (_arrow_playerCalibratedY - tY) ^ 2)
else
dist = HBD:GetWorldDistance(tInst, pX, pY, tX, tY)
end
if dist then if dist then
if (not calibratedTargetGroup) and tInst ~= pInst then dist = 500000 + dist * 100 end if tInst ~= pInst then dist = 500000 + dist * 100 end
if debugCollect then if debugCollect then
print(string.format(" ADDED dist=%.0f", dist)) print(string.format(" ADDED dist=%.0f", dist))
end end
@@ -813,22 +750,14 @@ sortedTargets = {}
-- On Sunstrider Isle (areaId 3430, uiMapId 1241), HBD:GetPlayerWorldPosition() returns -- On Sunstrider Isle (areaId 3430, uiMapId 1241), HBD:GetPlayerWorldPosition() returns
-- Eastern Kingdoms world coords because that's the continent HBD thinks the player is on. -- Eastern Kingdoms world coords because that's the continent HBD thinks the player is on.
-- We must use C_Map.GetPlayerMapPosition(1241) + HBD:GetWorldCoordinatesFromZone(..., 1941). -- We must use C_Map.GetPlayerMapPosition(1241) + HBD:GetWorldCoordinatesFromZone(..., 1941).
-- NOTE: GetCurrentUiMapId() returns 946 (ghost map) on Sunstrider, not 1241 or 1941, -- NOTE: GetCurrentUiMapId() returns 1241 on Sunstrider Isle (not 946 or 1941).
-- so we check zoneId == 3430 as the primary indicator. -- PITFALL: zoneId can be 3431 (Eversong) while player is on uiMap 1241 (Sunstrider).
local useSunstriderFix = (zoneId == 3430) local useSunstriderFix = (zoneId == 3430 or zoneId == 3431 or pUiMapId == 1241)
-- Get player position from the calibrated transform layer first for broken/custom maps. -- Get player position — always try HBD's direct method first (works when map is OPEN).
local playerX, playerY, playerInstance, calibratedMapX, calibratedMapY, calibratedGroup = QuestieCompat.GetCalibratedPlayerPosition(pUiMapId, zoneId, "player") -- If that returns nil (map closed or Sunstrider), fall back to C_Map.GetPlayerMapPosition +
-- Get player position — first try HBD's direct method (works when map is OPEN).
-- If that returns nil (map closed), fall back to C_Map.GetPlayerMapPosition +
-- HBD:GetWorldCoordinatesFromZone which works regardless of map open/closed state. -- HBD:GetWorldCoordinatesFromZone which works regardless of map open/closed state.
-- Also skip HBD directly on Sunstrider since it returns wrong coords. local playerX, playerY, playerInstance = HBD:GetPlayerWorldPosition()
if useSunstriderFix and calibratedGroup then
-- already resolved via calibrated transform layer
elseif not playerX or not playerY or not playerInstance then
playerX, playerY, playerInstance = HBD:GetPlayerWorldPosition()
end
if not playerX or not playerY or not playerInstance then if not playerX or not playerY or not playerInstance then
-- Fallback: get map-relative position then convert to world coords via HBD. -- Fallback: get map-relative position then convert to world coords via HBD.
-- IMPORTANT: never use 946/947 (world/cosmic maps) — they have no world coord data. -- IMPORTANT: never use 946/947 (world/cosmic maps) — they have no world coord data.
@@ -858,7 +787,7 @@ sortedTargets = {}
-- map-space coords. We must look up with the actual Sunstrider uiMapId (1241) and -- map-space coords. We must look up with the actual Sunstrider uiMapId (1241) and
-- then convert through Eversong's 1941 bounds to get correct world coords. -- then convert through Eversong's 1941 bounds to get correct world coords.
local lookupUiMapId = pUiMapId local lookupUiMapId = pUiMapId
if zoneId == 3430 then if zoneId == 3430 or zoneId == 3431 or pUiMapId == 1241 then
lookupUiMapId = 1241 -- always use Sunstrider's real uiMapId for C_Map lookupUiMapId = 1241 -- always use Sunstrider's real uiMapId for C_Map
end end
if debugArrow then if debugArrow then
@@ -873,11 +802,18 @@ sortedTargets = {}
print(string.format("UpdateNearestTargets: _GetSunstriderPlayerMapPosition() -> mapX=%.4f mapY=%.4f", mapX or -1, mapY or -1)) print(string.format("UpdateNearestTargets: _GetSunstriderPlayerMapPosition() -> mapX=%.4f mapY=%.4f", mapX or -1, mapY or -1))
end end
if mapX and mapY and mapX > 0 and mapY > 0 then if mapX and mapY and mapX > 0 and mapY > 0 then
if calibratedGroup then if useSunstriderFix then
playerX, playerY, playerInstance = QuestieCompat.GetCalibratedWorldCoordinatesFromZone(mapX, mapY, lookupUiMapId, zoneId) -- Sunstrider: use 1941 (Eversong) for coordinate conversion so that
-- player world coords are in the same space as target world coords.
-- Targets always use 1941 (via ZoneDB:GetUiMapIdByAreaId(3430)→1941),
-- so the player must also use 1941 for consistent distance/direction.
-- Ascension-calibrated 1241 bounds now match Eversong world space.
playerX, playerY, playerInstance = HBD:GetWorldCoordinatesFromZone(mapX, mapY, 1241)
else else
local worldUiMapId = 1941 -- always use Eversong bounds for world coord conversion -- Normal zone: convert the player's map coords through the ACTUAL zone's
playerX, playerY, playerInstance = HBD:GetWorldCoordinatesFromZone(mapX, mapY, worldUiMapId) -- uiMapId bounds. This was hardcoded to 1941 in the original Sunstrider fix,
-- which broke every zone except Eversong.
playerX, playerY, playerInstance = HBD:GetWorldCoordinatesFromZone(mapX, mapY, lookupUiMapId)
end end
if debugArrow then if debugArrow then
print(string.format("UpdateNearestTargets: HBD via lookupUiMapId=%s mapX=%.4f mapY=%.4f -> worldX=%.4f worldY=%.4f", print(string.format("UpdateNearestTargets: HBD via lookupUiMapId=%s mapX=%.4f mapY=%.4f -> worldX=%.4f worldY=%.4f",
@@ -886,6 +822,51 @@ sortedTargets = {}
playerInstance = playerInstance or 0 playerInstance = playerInstance or 0
end end
end end
-- Sunstrider override: HBD:GetPlayerWorldPosition() may return incorrect
-- (Eastern Kingdoms offset) coords on Sunstrider Isle. Only override if
-- the HBD coords appear wrong — specifically, if they fall outside the
-- Eversong bounding box. Eversong world bounds: X ∈ [-1825, 3100],
-- Y ∈ [5358, 8642]. If HBD coords are outside this range, they're EK coords.
-- NOTE: _GetSunstriderPlayerMapPosition returns 1241-local normalized coords.
-- We convert through 1941 (Eversong) so that player world coords share the
-- same coordinate space as targets (which always use uiMapId 1941 via ZoneDB).
if useSunstriderFix and playerX and playerY then
-- Eversong bounding box in world coordinates (with some margin).
-- A player on Sunstrider/Eversong should be within these bounds.
local EVERSENG_XMIN, EVERSENG_XMAX = -2000, 3200
local EVERSENG_YMIN, EVERSENG_YMAX = 5300, 8700
local coordsOutsideEversong = (playerX < EVERSENG_XMIN or playerX > EVERSENG_XMAX
or playerY < EVERSENG_YMIN or playerY > EVERSENG_YMAX)
if debugArrow then
print(string.format("UpdateNearestTargets: Sunstrider check outsideEv=%s px=%.0f py=%.0f bounds=[%d..%d,%d..%d]",
tostring(coordsOutsideEversong), playerX, playerY,
EVERSENG_XMIN, EVERSENG_XMAX, EVERSENG_YMIN, EVERSENG_YMAX))
end
if coordsOutsideEversong then
local mapX, mapY = _GetSunstriderPlayerMapPosition(debugArrow)
if debugArrow then
print(string.format("UpdateNearestTargets: Sunstrider mapPos mapX=%.4f mapY=%.4f", mapX or -1, mapY or -1))
end
if mapX and mapY and mapX > 0 and mapY > 0 then
-- Use Ascension-calibrated 1241 bounds for consistent world space.
local wX, wY, wInst = HBD:GetWorldCoordinatesFromZone(mapX, mapY, 1241)
if debugArrow then
print(string.format("UpdateNearestTargets: Sunstrider HBD1941 wX=%s wY=%s wInst=%s", tostring(wX), tostring(wY), tostring(wInst)))
end
if wX and wY then
if debugArrow then
print(string.format("UpdateNearestTargets: Sunstrider override HBD(%.4f,%.4f) -> HBD1941(%.4f,%.4f)", playerX, playerY, wX, wY))
end
playerX, playerY, playerInstance = wX, wY, wInst or 0
else
-- HBD 1941 conversion failed — no fallback available.
if debugArrow then
print("UpdateNearestTargets: Sunstrider HBD1941 conversion FAILED")
end
end
end
end
end
if debugArrow then if debugArrow then
print(string.format("UpdateNearestTargets: HBD.GetPlayerWorldPosition() = x=%.4f y=%.4f inst=%s", playerX or 0, playerY or 0, tostring(playerInstance))) print(string.format("UpdateNearestTargets: HBD.GetPlayerWorldPosition() = x=%.4f y=%.4f inst=%s", playerX or 0, playerY or 0, tostring(playerInstance)))
end end
@@ -923,17 +904,22 @@ sortedTargets = {}
-- Publish context for hoisted helper functions (avoids closure allocation every call) -- Publish context for hoisted helper functions (avoids closure allocation every call)
_arrow_playerX, _arrow_playerY, _arrow_playerInstance = playerX, playerY, playerInstance _arrow_playerX, _arrow_playerY, _arrow_playerInstance = playerX, playerY, playerInstance
_arrow_playerCalibratedX, _arrow_playerCalibratedY, _arrow_playerCalibratedGroup = playerX, playerY, calibratedGroup
_arrow_usingAutoLogic = usingAutoLogic _arrow_usingAutoLogic = usingAutoLogic
_arrow_playerZoneId, _arrow_playerUiMapId = playerZoneId, playerUiMapId _arrow_playerZoneId, _arrow_playerUiMapId = playerZoneId, playerUiMapId
local function _CollectQuestTargets(quest) local function _CollectQuestTargets(quest)
if not quest then return end if not quest then return end
local debugCollect = Questie and Questie.db and Questie.db.profile and Questie.db.profile.debugArrow local debugCollect = Questie and Questie.db and Questie.db.profile and Questie.db.profile.debugArrow
-- Avoid spamming QuestieQuest:PopulateQuestLogInfo (it can trigger marker rebuilds and flicker). -- Periodic quest state verification: call PopulateQuestLogInfo when
-- Only populate when objective completion flags are missing, and throttle per quest id. -- (a) objectives/completion flags are missing (original logic), OR
-- (b) the quest hasn't been populated in the last 30 seconds
-- (c) quest.isComplete is stale (does not match DB)
-- This ensures the arrow always has accurate completion data, catching
-- cases where events didn't fire or stale flags survived a reload.
if QuestieQuest and QuestieQuest.PopulateQuestLogInfo and quest.Id then if QuestieQuest and QuestieQuest.PopulateQuestLogInfo and quest.Id then
local needsPopulate = false local needsPopulate = false
if not quest.Objectives and not quest.SpecialObjectives then if not quest.Objectives and not quest.SpecialObjectives then
@@ -941,9 +927,17 @@ sortedTargets = {}
elseif _HasMissingCompletedFlag(quest.Objectives) or _HasMissingCompletedFlag(quest.SpecialObjectives) then elseif _HasMissingCompletedFlag(quest.Objectives) or _HasMissingCompletedFlag(quest.SpecialObjectives) then
needsPopulate = true needsPopulate = true
end end
if needsPopulate then
-- Periodic force-refresh: if more than 30 seconds since last populate, re-sync
-- quest state from live quest log. This catches stale isComplete/WasComplete
-- that survived event-driven updates (e.g., Ascension missing QUEST_LOG_UPDATE).
local now = GetTime() local now = GetTime()
local last = lastPopulateByQuestId[quest.Id] or 0 local last = lastPopulateByQuestId[quest.Id] or 0
if (last + 30.0) < now then
needsPopulate = true
end
if needsPopulate then
if (last + 2.0) < now then if (last + 2.0) < now then
lastPopulateByQuestId[quest.Id] = now lastPopulateByQuestId[quest.Id] = now
QuestieQuest:PopulateQuestLogInfo(quest) QuestieQuest:PopulateQuestLogInfo(quest)
@@ -951,12 +945,22 @@ sortedTargets = {}
end end
end end
local isComplete = quest.isComplete or (QuestieDB.IsComplete(quest.Id) == 1) local dbComplete = QuestieDB.IsComplete(quest.Id)
if isComplete then quest.isComplete = true end -- Defensive: if quest.isComplete is stale from a prior complete-then-abandon, and
-- QuestLogCache says the quest is NOT complete (0 or nil), clear the stale flag so
-- objective pins are drawn. AcceptQuest normally resets this, but this guards
-- against edge cases where AcceptQuest's reset didn't fire.
if quest.isComplete and dbComplete ~= 1 then
quest.isComplete = nil
end
local isComplete = quest.isComplete or (dbComplete == 1)
if debugCollect then if debugCollect then
print(string.format(" _CollectQuestTargets: %s isComplete=%s hasObjectives=%s hasSpecialObjectives=%s hasFinisher=%s", local objCount = quest.Objectives and #quest.Objectives or 0
print(string.format(" _CollectQuestTargets: %s isComplete=%s (quest.isComplete=%s dbComplete=%s) objCount=%d hasObjectives=%s hasSpecialObjectives=%s hasFinisher=%s",
tostring(quest.name), tostring(isComplete), tostring(quest.name), tostring(isComplete),
tostring(quest.isComplete), tostring(dbComplete),
objCount,
tostring(quest.Objectives ~= nil), tostring(quest.Objectives ~= nil),
tostring(quest.SpecialObjectives ~= nil), tostring(quest.SpecialObjectives ~= nil),
tostring(quest.Finisher ~= nil))) tostring(quest.Finisher ~= nil)))
+5 -11
View File
@@ -58,18 +58,12 @@ local fadeLogicTimerShown
local fadeLogicCoroutine local fadeLogicCoroutine
local function _ResolveMapUiMapId(uiMapId, x, y) local function _ResolveMapUiMapId(uiMapId, x, y)
-- Ascension zone mapping: On Ascension, Sunstrider Isle (uiMapId 1241) shares -- Ghost map 946 has no real coordinate data; redirect to Eversong (1941).
-- Eversong Woods' (1941) coordinate space. Zone 3430 (Eversong Woods) data if uiMapId == 946 then
-- now correctly maps to uiMapId 1941 via GetUiMapIdByAreaId(3430)=1941.
-- These Eversong-map pins appear on the Sunstrider sub-map (1241) via
-- ZONE_REDIRECT visibility in HBD.lua.
--
-- Redirect any remaining map-1241 pins to 1941. On Ascension, map 1241
-- does not have its own independent coordinate space — it IS Eversong's
-- space. Pins destined for 1241 must use 1941's bounds for correct rendering.
if uiMapId == 1241 then
return 1941 return 1941
end end
-- Map 1241 (Sunstrider Isle) now has its own Ascension-calibrated bounds
-- and pins on 1241 render correctly on the Sunstrider sub-map. No redirect.
return uiMapId return uiMapId
end end
@@ -678,7 +672,7 @@ end
local closestStarter = {} local closestStarter = {}
function QuestieMap:FindClosestStarter() function QuestieMap:FindClosestStarter()
local playerX, playerY, _ = HBD:GetPlayerWorldPosition(); local playerX, playerY, _ = HBD:GetPlayerWorldPosition();
local playerZone = HBD:GetPlayerWorldPosition(); local playerZone = QuestiePlayer:GetCurrentUiMapId();
for questId in pairs(QuestiePlayer.currentQuestlog) do for questId in pairs(QuestiePlayer.currentQuestlog) do
if (not closestStarter[questId]) then if (not closestStarter[questId]) then
local quest = QuestieDB.GetQuest(questId); local quest = QuestieDB.GetQuest(questId);
+48 -15
View File
@@ -58,6 +58,14 @@ local deletedQuestItem = false
local _bagUpdateDebounceTimer = nil local _bagUpdateDebounceTimer = nil
local _bagUpdateFollowUpTimer = nil local _bagUpdateFollowUpTimer = nil
-- Periodic quest state verification timer.
-- Ascension server events (QUEST_LOG_UPDATE, UNIT_QUEST_LOG_CHANGED) can be
-- unreliable or delayed, causing stale quest.isComplete / quest.WasComplete flags
-- to persist across reload/reaccept cycles. This timer forces a full quest log
-- reconciliation every 30 seconds so pins and arrows stay accurate.
local _periodicRefreshTimer = nil
local PERIODIC_REFRESH_SECONDS = 30
--- Registers all events that are required for questing (accepting, removing, objective updates, ...) --- Registers all events that are required for questing (accepting, removing, objective updates, ...)
function QuestEventHandler:RegisterEvents() function QuestEventHandler:RegisterEvents()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] RegisterEvents") Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] RegisterEvents")
@@ -77,6 +85,18 @@ function QuestEventHandler:RegisterEvents()
eventFrame:RegisterEvent("CHAT_MSG_COMBAT_FACTION_CHANGE") eventFrame:RegisterEvent("CHAT_MSG_COMBAT_FACTION_CHANGE")
eventFrame:SetScript("OnEvent", _QuestEventHandler.OnEvent) eventFrame:SetScript("OnEvent", _QuestEventHandler.OnEvent)
-- Start periodic quest state verification timer.
-- On Ascension, QUEST_LOG_UPDATE events can be unreliable. This timer forces
-- a full reconciliation every 30 seconds, catching stale isComplete/WasComplete
-- flags and ensuring objective spawnLists stay populated for active quests.
if not _periodicRefreshTimer then
_periodicRefreshTimer = C_Timer.NewTicker(PERIODIC_REFRESH_SECONDS, function()
Questie:Debug(Questie.DEBUG_DEVELOP, "[Quest Event] Periodic refresh: forcing full quest log scan")
doFullQuestLogScan = true
_QuestEventHandler:QuestLogUpdate()
end)
end
-- StaticPopup dialog hooks. Deleteing Quest items do not always trigger a Quest Log Update. -- StaticPopup dialog hooks. Deleteing Quest items do not always trigger a Quest Log Update.
hooksecurefunc("StaticPopup_Show", function(...) hooksecurefunc("StaticPopup_Show", function(...)
-- Hook StaticPopup_Show. If we find the "DELETE_ITEM" dialog, check for Quest Items and notify the player. -- Hook StaticPopup_Show. If we find the "DELETE_ITEM" dialog, check for Quest Items and notify the player.
@@ -230,6 +250,20 @@ function _QuestEventHandler:QuestAccepted(questLogIndex, questId)
end) end)
end end
-- If the quest was already in questLog as QUEST_TURNED_IN (e.g. Ebonhold Call Board repeatable
-- quests that vanish without a proper QUEST_REMOVED event), clean up before re-accepting so the
-- tracker doesn't show it as already complete.
-- NOTE: Must check BEFORE wiping questLog[questId] below, otherwise the state check is always false.
local wasTurnedIn = questLog[questId] and questLog[questId].state == QUEST_LOG_STATES.QUEST_TURNED_IN
if wasTurnedIn then
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "re-accepted after auto-complete, clearing stale state")
QuestLogCache.RemoveQuest(questId)
QuestieQuest:CompleteQuest(questId) -- clears per-quest data
QuestieJourney:CompleteQuest(questId)
QuestieAnnounce:CompletedQuest(questId)
QuestieTracker:RemoveQuest(questId)
end
questLog[questId] = {} questLog[questId] = {}
-- Timed quests do not need a full Quest Log Update. -- Timed quests do not need a full Quest Log Update.
@@ -241,19 +275,6 @@ function _QuestEventHandler:QuestAccepted(questLogIndex, questId)
skipNextUQLCEvent = true skipNextUQLCEvent = true
end end
-- If the quest was already in questLog as QUEST_TURNED_IN (e.g. Ebonhold Call Board repeatable
-- quests that vanish without a proper QUEST_REMOVED event), clean up before re-accepting so the
-- tracker doesn't show it as already complete.
if questLog[questId] and questLog[questId].state == QUEST_LOG_STATES.QUEST_TURNED_IN then
Questie:Debug(Questie.DEBUG_INFO, "Quest:", questId, "re-accepted after auto-complete, clearing stale state")
QuestLogCache.RemoveQuest(questId)
QuestieQuest:CompleteQuest(questId) -- clears per-quest data
QuestieJourney:CompleteQuest(questId)
QuestieAnnounce:CompletedQuest(questId)
QuestieTracker:RemoveQuest(questId)
questLog[questId] = nil
end
QuestieCombatQueue:Queue(function() QuestieCombatQueue:Queue(function()
QuestieLib:CacheItemNames(questId) QuestieLib:CacheItemNames(questId)
_QuestEventHandler:HandleQuestAccepted(questId) _QuestEventHandler:HandleQuestAccepted(questId)
@@ -266,8 +287,10 @@ end
---@param questId number ---@param questId number
---@return boolean true @if the function was successful, false otherwise ---@return boolean true @if the function was successful, false otherwise
function _QuestEventHandler:HandleQuestAccepted(questId) function _QuestEventHandler:HandleQuestAccepted(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestEventHandler] HandleQuestAccepted - questId:", questId)
local idx = QuestieCompat.GetQuestLogIndexByID(questId) local idx = QuestieCompat.GetQuestLogIndexByID(questId)
if not idx then if not idx then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestEventHandler] HandleQuestAccepted - NO quest log index yet, retrying for questId:", questId)
_QuestLogUpdateQueue:Insert(function() _QuestLogUpdateQueue:Insert(function()
return _QuestEventHandler:HandleQuestAccepted(questId) return _QuestEventHandler:HandleQuestAccepted(questId)
end) end)
@@ -277,7 +300,7 @@ function _QuestEventHandler:HandleQuestAccepted(questId)
local cacheMiss, changes = QuestLogCache.CheckForChanges({ [questId] = true }) local cacheMiss, changes = QuestLogCache.CheckForChanges({ [questId] = true })
if cacheMiss then if cacheMiss then
-- if cacheMiss, no need to check changes as only 1 questId -- if cacheMiss, no need to check changes as only 1 questId
Questie:Debug(Questie.DEBUG_INFO, "Objectives are not cached yet") Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestEventHandler] HandleQuestAccepted - CACHE MISS for questId:", questId, "- retrying later")
_QuestLogUpdateQueue:Insert(function() _QuestLogUpdateQueue:Insert(function()
return _QuestEventHandler:HandleQuestAccepted(questId) return _QuestEventHandler:HandleQuestAccepted(questId)
end) end)
@@ -285,7 +308,7 @@ function _QuestEventHandler:HandleQuestAccepted(questId)
return false return false
end end
Questie:Debug(Questie.DEBUG_INFO, "Objectives are correct. Calling accept logic. quest:", questId) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestEventHandler] HandleQuestAccepted - cache ready, calling AcceptQuest for questId:", questId)
questLog[questId].state = QUEST_LOG_STATES.QUEST_ACCEPTED questLog[questId].state = QUEST_LOG_STATES.QUEST_ACCEPTED
QuestieQuest:SetObjectivesDirty(questId) QuestieQuest:SetObjectivesDirty(questId)
@@ -447,6 +470,16 @@ function _QuestEventHandler:MarkQuestAsAbandoned(questId)
"objectives were complete - treating as completed (auto-complete quest)") "objectives were complete - treating as completed (auto-complete quest)")
questEntry.state = QUEST_LOG_STATES.QUEST_TURNED_IN questEntry.state = QUEST_LOG_STATES.QUEST_TURNED_IN
-- Clear stale objective data so re-accepting this quest later doesn't
-- inherit Cached "Completed = true" / "isUpdated = true" flags that
-- would cause PopulateObjectiveNotes to skip drawing map pins.
if quest then
quest.Objectives = {}
quest.WasComplete = nil
quest.isComplete = nil
end
QuestieQuest:SetObjectivesDirty(questId)
QuestLogCache.RemoveQuest(questId) QuestLogCache.RemoveQuest(questId)
QuestieQuest:CompleteQuest(questId) QuestieQuest:CompleteQuest(questId)
QuestieJourney:CompleteQuest(questId) QuestieJourney:CompleteQuest(questId)
+220 -18
View File
@@ -472,28 +472,63 @@ function QuestieQuest:AcceptQuest(questId)
local quest = QuestieDB.GetQuest(questId) local quest = QuestieDB.GetQuest(questId)
if quest then if quest then
local complete = QuestieDB.IsComplete(questId) -- If any of these flags exist, this quest was previously accepted and may
-- If any of these flags exsist then this quest has already once been accepted and is probobly in a failed state -- have stale completion state (e.g. complete-then-abandon-then-reaccept leaves
if (quest.WasComplete or quest.isComplete or complete == 0 or complete == -1) and (QuestiePlayer.currentQuestlog[questId]) then -- quest.isComplete=true, WasComplete=true). Only check quest-object flags
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest] Accepted Quest:", questId, -- (WasComplete, isComplete), NOT QuestieDB.IsComplete, because IsComplete
" Warning: This quest was once accepted and needs to be reset.") -- returns 0 for any incomplete quest — including brand new acceptances.
-- The original code also checked complete==0 and complete==-1, but those
-- combined with removing the currentQuestlog guard caused every quest
-- acceptance to enter this block and unload freshly-drawn pins.
-- DEBUG: Log stale flag state on accept
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] AcceptQuest state check - questId:", questId,
" WasComplete:", tostring(quest.WasComplete),
" isComplete:", tostring(quest.isComplete),
" currentQuestlog:", tostring(QuestiePlayer.currentQuestlog[questId] and "present" or "nil"))
-- Reset quest log if quest.WasComplete or quest.isComplete then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] AcceptQuest RESET BLOCK firing for questId:", questId)
-- Reset quest log (may already be nil after QUEST_REMOVED)
QuestiePlayer.currentQuestlog[questId] = nil QuestiePlayer.currentQuestlog[questId] = nil
-- Reset quest objectives -- Reset quest objectives (clear stale Completed/isUpdated flags that
-- would prevent map pins from being drawn on re-accept)
if type(quest.Objectives) == "table" then
for k in pairs(quest.Objectives) do
quest.Objectives[k] = nil
end
else
quest.Objectives = {} quest.Objectives = {}
end
-- Reset SpecialObjectives too
if type(quest.SpecialObjectives) == "table" then
for k in pairs(quest.SpecialObjectives) do
quest.SpecialObjectives[k] = nil
end
end
-- Reset quest flags -- Reset quest flags
quest.WasComplete = nil quest.WasComplete = nil
quest.isComplete = nil quest.isComplete = nil
-- Ensure isUpdated flags are reset so ObjectiveUpdate will refresh
-- objective state from the quest log instead of short-circuiting
QuestieQuest:SetObjectivesDirty(questId)
-- Reset tooltips -- Reset tooltips
QuestieTooltips:RemoveQuest(questId) QuestieTooltips:RemoveQuest(questId)
-- Unload map pins from previous acceptance so they don't linger
QuestieMap:UnloadQuestFrames(questId)
end end
if not QuestiePlayer.currentQuestlog[questId] then if not QuestiePlayer.currentQuestlog[questId] then
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest] Accepted Quest:", questId) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] AcceptQuest NORMAL path - questId:", questId,
" quest.isComplete:", tostring(quest.isComplete),
" quest.WasComplete:", tostring(quest.WasComplete),
" quest.Objectives:", quest.Objectives and next(quest.Objectives) and "has entries" or "empty")
QuestiePlayer.currentQuestlog[questId] = quest QuestiePlayer.currentQuestlog[questId] = quest
@@ -537,8 +572,8 @@ function QuestieQuest:AcceptQuest(questId)
end end
) )
else else
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest] Accepted Quest:", questId, Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] AcceptQuest DUPLICATE - questId:", questId,
" Warning: Quest already exists, not adding") " Warning: Quest already exists in currentQuestlog, not processing!")
end end
end end
end end
@@ -575,6 +610,14 @@ function QuestieQuest:CompleteQuest(questId)
Questie.db.char.complete[13701] = true -- Horde Champion Marker Questie.db.char.complete[13701] = true -- Horde Champion Marker
Questie.db.char.complete[13687] = nil -- Horde Tournament Eligibility Marker Questie.db.char.complete[13687] = nil -- Horde Tournament Eligibility Marker
end end
-- Clear stale objective data so a subsequent re-accept doesn't inherit
-- Cached Completed=true / isUpdated=true flags that would cause
-- PopulateObjectiveNotes to skip drawing map pins (bug: complete-abandon-reaccept).
local quest = QuestieDB.GetQuest(questId)
if quest and type(quest.Objectives) == "table" then
quest.Objectives = {}
end
QuestieMap:UnloadQuestFrames(questId) QuestieMap:UnloadQuestFrames(questId)
-- Clear the pending-complete guard now that frames are unloaded -- Clear the pending-complete guard now that frames are unloaded
@@ -620,6 +663,11 @@ function QuestieQuest:AbandonedQuest(questId)
local quest = QuestieDB.GetQuest(questId) local quest = QuestieDB.GetQuest(questId)
if quest then if quest then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] AbandonedQuest - questId:", questId,
" WasComplete:", tostring(quest.WasComplete),
" isComplete:", tostring(quest.isComplete),
" Objectives:", quest.Objectives and next(quest.Objectives) and "has entries" or "empty")
-- Reset quest objectives -- Reset quest objectives
quest.Objectives = {} quest.Objectives = {}
@@ -686,9 +734,31 @@ function QuestieQuest:UpdateQuest(questId)
end end
QuestieQuest:PopulateQuestLogInfo(quest) QuestieQuest:PopulateQuestLogInfo(quest)
if QuestieQuest:ShouldShowQuestNotes(questId) then -- Defensive: clear stale isComplete/WasComplete after PopulateQuestLogInfo
-- syncs quest log state. This catches cases where PopulateQuestLogInfo ran
-- but the quest log cache didn't have the isComplete field (nil), in which
-- case the else branch above wouldn't clear the flags.
if quest.isComplete then
local dbComplete = QuestieDB.IsComplete(questId)
if dbComplete ~= 1 then
quest.isComplete = nil
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:UpdateQuest] Cleared stale isComplete for quest:", questId)
end
end
if quest.WasComplete then
local dbComplete = QuestieDB.IsComplete(questId)
if dbComplete ~= 1 then
quest.WasComplete = nil
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:UpdateQuest] Cleared stale WasComplete for quest:", questId)
end
end
local showNotes = QuestieQuest:ShouldShowQuestNotes(questId)
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:UpdateQuest] ShouldShowQuestNotes:", tostring(showNotes), "questId:", questId)
if showNotes then
QuestieQuest:UpdateObjectiveNotes(quest) QuestieQuest:UpdateObjectiveNotes(quest)
else else
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:UpdateQuest] ShouldShowQuestNotes=false, removing tooltips for questId:", questId)
QuestieTooltips:RemoveQuest(questId) QuestieTooltips:RemoveQuest(questId)
end end
@@ -760,6 +830,34 @@ function QuestieQuest:UpdateQuest(questId)
QuestieQuest:PopulateObjectiveNotes(quest) QuestieQuest:PopulateObjectiveNotes(quest)
AvailableQuests.CalculateAndDrawAll() AvailableQuests.CalculateAndDrawAll()
else else
-- Robustness: ensure objective pins are present for incomplete quests.
-- On Ascension, quest log cache can be stale after reload/abandon-reaccept,
-- leaving objectives empty or AlreadySpawned pointing to dead frames.
if showNotes then
local hasObjectives = quest.Objectives and table.getn(quest.Objectives) > 0
local hasFrames = QuestieMap.questIdFrames[questId] ~= nil
if not hasObjectives then
Questie:Debug(Questie.DEBUG_DEVELOP,
"[QuestieQuest:UpdateQuest] Objectives missing for incomplete quest, re-populating:", questId)
QuestieQuest:PopulateQuestLogInfo(quest)
hasObjectives = quest.Objectives and table.getn(quest.Objectives) > 0
if hasObjectives then
QuestieQuest:PopulateObjectiveNotes(quest)
end
elseif not hasFrames then
-- Objectives exist but no frames on map: AlreadySpawned may be stale
Questie:Debug(Questie.DEBUG_DEVELOP,
"[QuestieQuest:UpdateQuest] Incomplete quest has objectives but no map frames, clearing AlreadySpawned:", questId)
for _, objective in pairs(quest.Objectives) do
if objective.AlreadySpawned then
objective.AlreadySpawned = {}
end
end
QuestieQuest:PopulateObjectiveNotes(quest)
end
end
-- Sometimes objective(s) are all complete but the quest doesn't get flagged as "1". So far the only -- Sometimes objective(s) are all complete but the quest doesn't get flagged as "1". So far the only
-- quests I've found that does this are quests involving an item(s). Checks all objective(s) and if they -- quests I've found that does this are quests involving an item(s). Checks all objective(s) and if they
-- are all complete, simulate a "Complete Quest" so the quest finisher appears on the map. -- are all complete, simulate a "Complete Quest" so the quest finisher appears on the map.
@@ -1048,7 +1146,12 @@ end
-- iterate all notes, update / remove as needed -- iterate all notes, update / remove as needed
---@param quest Quest ---@param quest Quest
function QuestieQuest:UpdateObjectiveNotes(quest) function QuestieQuest:UpdateObjectiveNotes(quest)
-- DEBUG: Log currentQuestlog state
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] UpdateObjectiveNotes - questId:", quest.Id,
" currentQuestlog present:", tostring(QuestiePlayer.currentQuestlog and QuestiePlayer.currentQuestlog[quest.Id] and "yes" or "no"))
if (not QuestiePlayer.currentQuestlog) or (not QuestiePlayer.currentQuestlog[quest.Id]) then if (not QuestiePlayer.currentQuestlog) or (not QuestiePlayer.currentQuestlog[quest.Id]) then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] UpdateObjectiveNotes - EARLY RETURN: quest not in currentQuestlog, questId:", quest.Id)
return return
end end
@@ -1342,14 +1445,17 @@ end
---@param objective QuestObjective ---@param objective QuestObjective
---@param blockItemTooltips any ---@param blockItemTooltips any
function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockItemTooltips) -- must be p-called function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockItemTooltips) -- must be p-called
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:PopulateObjective]", objective.Description) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:PopulateObjective] questId:", quest.Id,
" objIdx:", objectiveIndex, " Desc:", objective.Description,
" Completed:", tostring(objective.Completed),
" quest.isComplete:", tostring(quest.isComplete))
if (not objective.Update) then if (not objective.Update) then
-- No Update function means static/pre-populated objective. -- No Update function means static/pre-populated objective.
-- Still check completion state so icons are removed if this objective was -- Still check completion state so icons are removed if this objective was
-- already marked complete from a previous update cycle. -- already marked complete from a previous update cycle.
if objective.Completed or quest.isComplete then if objective.Completed or quest.isComplete then
Questie:Debug(Questie.DEBUG_INFO, Questie:Debug(Questie.DEBUG_DEVELOP,
"[QuestieQuest:PopulateObjective] - No Update fn but objective is complete, unloading icons.") "[QuestieQuest:PopulateObjective] - No Update fn but objective is complete, unloading icons.")
_UnloadAlreadySpawnedIcons(objective) _UnloadAlreadySpawnedIcons(objective)
end end
@@ -1358,7 +1464,24 @@ function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockI
objective:Update() objective:Update()
-- Defensive: clear stale quest.isComplete if quest DB says the quest is NOT
-- complete. The DB cache persists quest objects across acceptance cycles
-- (complete → abandon → reaccept), and PopulateQuestLogInfo may not have
-- run yet for this specific path (e.g., Learner invalidation).
if quest.isComplete then
local dbComplete = QuestieDB.IsComplete(quest.Id)
if dbComplete ~= 1 then
quest.isComplete = nil
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:PopulateObjective] Cleared stale isComplete for quest:", quest.Id)
end
end
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:PopulateObjective] POST-Update questId:", quest.Id,
" objIdx:", objectiveIndex, " Completed:", tostring(objective.Completed),
" isComplete:", tostring(quest.isComplete), " HasUpdate:", tostring(objective.Update ~= nil))
if QuestieQuest.ShouldHideObjective(objective) then if QuestieQuest.ShouldHideObjective(objective) then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:PopulateObjective] HIDDEN by ShouldHideObjective, unloading icons for objIdx:", objectiveIndex)
_UnloadAlreadySpawnedIcons(objective) _UnloadAlreadySpawnedIcons(objective)
return return
end end
@@ -1377,6 +1500,8 @@ function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockI
_RegisterObjectiveTooltips(objective, quest.Id, blockItemTooltips) _RegisterObjectiveTooltips(objective, quest.Id, blockItemTooltips)
if completed or quest.isComplete then if completed or quest.isComplete then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:PopulateObjective] SKIPPING objective (completed):",
objective.Description, "completed:", tostring(completed), "quest.isComplete:", tostring(quest.isComplete))
_UnloadAlreadySpawnedIcons(objective) _UnloadAlreadySpawnedIcons(objective)
return return
end end
@@ -1386,6 +1511,8 @@ function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockI
end end
if objective.spawnList and next(objective.spawnList) then if objective.spawnList and next(objective.spawnList) then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:PopulateObjective] spawnList present for quest:", quest.Id,
"objIdx:", objectiveIndex, "spawnList entries:", (function() local n=0 for _ in pairs(objective.spawnList) do n=n+1 end return n end)())
local maxPerType = 300 local maxPerType = 300
if Questie.db.profile.enableIconLimit and Questie.db.profile.iconLimit < maxPerType then if Questie.db.profile.enableIconLimit and Questie.db.profile.iconLimit < maxPerType then
@@ -1448,6 +1575,9 @@ function QuestieQuest:PopulateObjective(quest, objectiveIndex, objective, blockI
local iconsToDraw, _ = _DetermineIconsToDraw(quest, objective, objectiveIndex, objectiveCenter) local iconsToDraw, _ = _DetermineIconsToDraw(quest, objective, objectiveIndex, objectiveCenter)
local icon, iconPerZone = _DrawObjectiveIcons(quest.Id, iconsToDraw, objective, maxPerType) local icon, iconPerZone = _DrawObjectiveIcons(quest.Id, iconsToDraw, objective, maxPerType)
_DrawObjectiveWaypoints(objective, icon, iconPerZone) _DrawObjectiveWaypoints(objective, icon, iconPerZone)
else
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:PopulateObjective] NO spawnList for quest:", quest.Id,
"objIdx:", objectiveIndex, "spawnList:", tostring(objective.spawnList))
end end
end end
@@ -1519,7 +1649,10 @@ end
---@param objectiveIndex ObjectiveIndex ---@param objectiveIndex ObjectiveIndex
---@param objectiveCenter {x:X, y:Y} ---@param objectiveCenter {x:X, y:Y}
_DetermineIconsToDraw = function(quest, objective, objectiveIndex, objectiveCenter) _DetermineIconsToDraw = function(quest, objective, objectiveIndex, objectiveCenter)
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:_DetermineIconsToDraw]") Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:_DetermineIconsToDraw] quest:", quest.Id,
"objective:", objective.Description, "spawnList:", objective.spawnList and next(objective.spawnList) and "has entries" or "nil/empty",
"AlreadySpawned:", objective.AlreadySpawned and next(objective.AlreadySpawned) and "has entries" or "empty",
"Completed:", tostring(objective.Completed), "enableObjectives:", tostring(Questie.db.profile.enableObjectives))
local iconsToDraw = {} local iconsToDraw = {}
local spawnItemId local spawnItemId
@@ -1535,6 +1668,7 @@ _DetermineIconsToDraw = function(quest, objective, objectiveIndex, objectiveCent
end end
if (not objective.AlreadySpawned[id]) and (not objective.Completed) and Questie.db.profile.enableObjectives then if (not objective.AlreadySpawned[id]) and (not objective.Completed) and Questie.db.profile.enableObjectives then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:_DetermineIconsToDraw] CREATING icon entry for id:", id, "quest:", quest.Id)
local data = { local data = {
Id = quest.Id, Id = quest.Id,
ObjectiveIndex = objectiveIndex, ObjectiveIndex = objectiveIndex,
@@ -1605,7 +1739,15 @@ _DetermineIconsToDraw = function(quest, objective, objectiveIndex, objectiveCent
end end
_DrawObjectiveIcons = function(questId, iconsToDraw, objective, maxPerType) _DrawObjectiveIcons = function(questId, iconsToDraw, objective, maxPerType)
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:_DrawObjectiveIcons] Adding Icons for quest:", questId) local iconCount = 0
local _, _ = next(iconsToDraw)
if _ then
local n = 0
for _ in pairs(iconsToDraw) do n = n + 1 end
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:_DrawObjectiveIcons] Drawing", n, "icon groups for quest:", questId)
else
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:_DrawObjectiveIcons] NO icons to draw for quest:", questId)
end
local spawnedIconCount = 0 local spawnedIconCount = 0
local icon local icon
@@ -1747,7 +1889,23 @@ function QuestieQuest:PopulateObjectiveNotes(quest) -- this should be renamed to
return return
end end
if QuestieDB.IsComplete(quest.Id) == 1 then local dbComplete = QuestieDB.IsComplete(quest.Id)
-- DEBUG: Log PopulateObjectiveNotes state
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] PopulateObjectiveNotes - questId:", quest.Id,
" quest.isComplete:", tostring(quest.isComplete),
" dbComplete:", tostring(dbComplete),
" Objectives count:", quest.Objectives and next(quest.Objectives) and "has entries" or "empty")
-- Defensive: clear stale quest.isComplete flag if the quest log doesn't confirm
-- completion. Without this, a re-accepted quest that was previously completed can
-- have isComplete=true while the quest log shows it as incomplete.
if quest.isComplete and dbComplete ~= 1 then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] PopulateObjectiveNotes - clearing stale isComplete for questId:", quest.Id)
quest.isComplete = nil
end
if dbComplete == 1 then
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:PopulateObjectiveNotes] Quest Complete! Adding Finisher for:", Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:PopulateObjectiveNotes] Quest Complete! Adding Finisher for:",
quest.Id) quest.Id)
@@ -1778,14 +1936,33 @@ function QuestieQuest:PopulateQuestLogInfo(quest)
return nil return nil
end end
Questie:Debug(Questie.DEBUG_INFO, "[QuestieQuest:PopulateQuestLogInfo] ", quest.Id) Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] PopulateQuestLogInfo - questId:", quest.Id,
" Objectives:", quest.Objectives and next(quest.Objectives) and "has entries" or "empty",
" isComplete:", tostring(quest.isComplete),
" WasComplete:", tostring(quest.WasComplete))
local questLogEngtry = QuestLogCache.GetQuest(quest.Id) -- DO NOT MODIFY THE RETURNED TABLE local questLogEngtry = QuestLogCache.GetQuest(quest.Id) -- DO NOT MODIFY THE RETURNED TABLE
if (not questLogEngtry) then return end if (not questLogEngtry) then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest] PopulateQuestLogInfo - CACHE MISS for questId:", quest.Id, "- returning early!")
return
end
-- Sync quest.isComplete with quest log state. The cache in QuestieDB persists
-- quest objects across acceptance cycles (complete → abandon → reaccept), so
-- stale isComplete/WasComplete flags from a previous acceptance must be cleared
-- when the quest log says the quest is no longer complete.
if questLogEngtry.isComplete ~= nil and questLogEngtry.isComplete == 1 then if questLogEngtry.isComplete ~= nil and questLogEngtry.isComplete == 1 then
quest.isComplete = true quest.isComplete = true
else
quest.isComplete = nil
-- Also clear WasComplete when quest log confirms the quest is NOT complete.
-- Without this, the isComplete==0 branch in UpdateQuest sees stale
-- WasComplete=true and triggers an unnecessary reset sequence.
if quest.WasComplete then
quest.WasComplete = nil
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieQuest:PopulateQuestLogInfo] Cleared stale WasComplete for quest:", quest.Id)
end
end end
-- Live fallback quests (no static DB entry) manage their own Objectives. -- Live fallback quests (no static DB entry) manage their own Objectives.
@@ -1980,6 +2157,31 @@ function _QuestieQuest.ObjectiveUpdate(self)
end end
end end
--- Lazily build an objective's spawnList from the spawn call table (includes
--- QuestieLearner-injected data). Used by QuestieArrow to resolve targets when
--- the spawnList hasn't been populated yet (e.g. after a quest re-accept cycle).
---@param objective table @An objective entry from quest.Objectives or quest.SpecialObjectives
---@param objectiveData table? @Corresponding ObjectiveData entry (falls back to the objective itself for SpecialObjectives)
---@return table|nil @The built spawnList, or nil if no handler matched
function QuestieQuest:BuildObjectiveSpawnList(objective, objectiveData)
if not objective then return nil end
-- Already populated — nothing to do
if objective.spawnList and next(objective.spawnList) then
return objective.spawnList
end
-- Try the explicit ObjectiveData first (normal objectives)
if objectiveData and objectiveData.Type and _QuestieQuest.objectiveSpawnListCallTable[objectiveData.Type] then
objective.spawnList = _QuestieQuest.objectiveSpawnListCallTable[objectiveData.Type](objective.Id, objective, objectiveData)
return objective.spawnList
end
-- SpecialObjectives and fallback: use the objective's own Type
if objective.Type and _QuestieQuest.objectiveSpawnListCallTable[objective.Type] then
objective.spawnList = _QuestieQuest.objectiveSpawnListCallTable[objective.Type](objective.Id, objective, objective)
return objective.spawnList
end
return nil
end
---@param questId number ---@param questId number
---@return table<ObjectiveIndex, QuestLogCacheObjectiveData>|nil @DO NOT EDIT RETURNED TABLE ---@return table<ObjectiveIndex, QuestLogCacheObjectiveData>|nil @DO NOT EDIT RETURNED TABLE
function QuestieQuest:GetAllLeaderBoardDetails(questId) function QuestieQuest:GetAllLeaderBoardDetails(questId)
+36 -1
View File
@@ -120,7 +120,23 @@ monster = function(npcId, objective)
end end
local name = QuestieDB.QueryNPCSingle(npcId, "name") local name = QuestieDB.QueryNPCSingle(npcId, "name")
if (not name) then if not name or name == "" then
-- Last resort: extract NPC name from objective description text.
-- This mirrors the name-parsing logic in the killcredit function.
if objective then
local desc = objective.Description or objective.text
if desc then
name = desc:match("^%d+/%d+%s+(.+)$") or desc:match("^(.-):%s*%d+/%d+$")
if not name then
name = desc:gsub("%d+/%d+", ""):gsub("[:!?,.%(%)%[%]]", ""):gsub("^%s+", ""):gsub("%s+$", "")
end
if name and name ~= "" then
Questie:Debug(Questie.DEBUG_DEVELOP, "[monster] Using objective description as name fallback for NPC:", npcId, name)
end
end
end
end
if not name or name == "" then
Questie:Debug(Questie.DEBUG_CRITICAL, "Name missing for NPC:", npcId) Questie:Debug(Questie.DEBUG_CRITICAL, "Name missing for NPC:", npcId)
return nil return nil
end end
@@ -131,6 +147,25 @@ monster = function(npcId, objective)
spawns = {} spawns = {}
end end
-- Learner safety net: when prioritizeMyData is enabled and the Learner has
-- verified spawn data for this NPC, prefer it over compiled DB spawns.
-- This catches edge cases where the npcDataOverrides chain doesn't fully
-- replace retail positions (e.g. format migration gaps, timing issues).
if Questie.IsAscension and Questie.dbLearner and Questie.dbLearner.global then
local ld = Questie.dbLearner.global
if ld.settings and ld.settings.prioritizeMyData then
local learnedNpc = ld.npcs and ld.npcs[npcId]
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
Questie:Debug(Questie.DEBUG_DEVELOP, "[monster] Preferring learned spawns for NPC:", npcId, "(mc=" .. tostring(learnedNpc.mc) .. ")")
spawns = learnedSpawns
end
end
end
end
local rank = QuestieDB.QueryNPCSingle(npcId, "rank") local rank = QuestieDB.QueryNPCSingle(npcId, "rank")
local enableSpawns = not QuestieCorrections.questNPCBlacklist[npcId] local enableSpawns = not QuestieCorrections.questNPCBlacklist[npcId]
+314 -22
View File
@@ -95,11 +95,28 @@ QuestieLearner.data = nil
------------------------------------------------------------------------ ------------------------------------------------------------------------
local function GetZoneId() local function GetZoneId()
-- On WotLK/Ascension, C_Map.GetBestMapForUnit returns a uiMapId (e.g. 1241 for Sunstrider). -- Prefer the most specific zone available: GetRealZoneText() returns the
-- Questie NPC spawns are keyed by areaId (e.g. 3430), so we must convert. -- sub-zone name when the player is on a child map (e.g. "Sunstrider Isle"
-- on map 1241 → areaId 3431), and the parent zone name otherwise (e.g.
-- "Eversong Woods" → areaId 3430). Using the sub-zone is correct because
-- Questie resolves subzones to parents via GetParentZoneId() automatically.
local zoneText = GetRealZoneText and GetRealZoneText() or ""
if zoneText ~= "" then
local areaId = _Learner.zoneCache[zoneText]
if not areaId and l10n and l10n.GetAreaIdByLocalName then
areaId = l10n:GetAreaIdByLocalName(zoneText)
if areaId and areaId > 0 then
_Learner.zoneCache[zoneText] = areaId
end
end
if areaId and areaId > 0 then
return areaId
end
end
-- Fallback: uiMapId-based conversion (e.g. 1241→3431 for Sunstrider).
local uiMapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") local uiMapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player")
if uiMapId then if uiMapId then
-- Try ZoneDB reverse lookup first (covers Ascension overrides like 1241→3430)
if ZoneDB and ZoneDB.GetAreaIdByUiMapId then if ZoneDB and ZoneDB.GetAreaIdByUiMapId then
local areaId = ZoneDB:GetAreaIdByUiMapId(uiMapId) local areaId = ZoneDB:GetAreaIdByUiMapId(uiMapId)
if areaId and areaId > 0 then if areaId and areaId > 0 then
@@ -312,15 +329,10 @@ end
-- reference the given npcId. Unloads existing world/minimap icons, resets -- reference the given npcId. Unloads existing world/minimap icons, resets
-- tooltip registration, and forces the map system to rebuild spawn lists from -- tooltip registration, and forces the map system to rebuild spawn lists from
-- QuestieDB on the next update — picking up newly learned coordinates in real time. -- QuestieDB on the next update — picking up newly learned coordinates in real time.
local function _InvalidateSpawnListsForNPC(npcId) -- Helper: check if a single objective references the given npcId and, if so,
if not QuestieQuest or not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end -- unload its icons and reset its cached spawnList so the map rebuilds it.
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer) -- Returns true if the objective was invalidated.
local questsToRefresh = {} local function _TryInvalidateObjective(objective, npcId, quest)
for questId, _ in pairs(QuestiePlayer.currentQuestlog) do
local quest = QuestieDB.GetQuest and QuestieDB.GetQuest(questId)
if quest and quest.Objectives then
local needsUnload = false
for _, objective in pairs(quest.Objectives) do
local shouldInvalidate = false local shouldInvalidate = false
-- Monster objectives reference NPCs directly in spawnList keys -- Monster objectives reference NPCs directly in spawnList keys
if objective.spawnList then if objective.spawnList then
@@ -333,6 +345,22 @@ local function _InvalidateSpawnListsForNPC(npcId)
if id == npcId then shouldInvalidate = true; break end if id == npcId then shouldInvalidate = true; break end
end end
end end
else
-- spawnList is nil (first-ever encounter, never populated).
-- Check the quest's ObjectiveData for NPC references so killcredit
-- and item objectives are still invalidated on the first kill.
if quest and quest.ObjectiveData and objective.Index then
local objData = quest.ObjectiveData[objective.Index]
if objData and objData.IdList then
for _, id in ipairs(objData.IdList) do
if id == npcId then shouldInvalidate = true; break end
end
end
-- Also match the primary objective Id (e.g. single-target monster objectives)
if not shouldInvalidate and objData and objData.Id == npcId then
shouldInvalidate = true
end
end
end end
-- Fallback: if objective Id matches the NPC (some objectives use NPC as their primary Id) -- Fallback: if objective Id matches the NPC (some objectives use NPC as their primary Id)
if not shouldInvalidate and objective.Id == npcId then if not shouldInvalidate and objective.Id == npcId then
@@ -362,9 +390,34 @@ local function _InvalidateSpawnListsForNPC(npcId)
objective.AlreadySpawned = {} -- empty table, NOT nil (_DetermineIconsToDraw indexes this) objective.AlreadySpawned = {} -- empty table, NOT nil (_DetermineIconsToDraw indexes this)
objective.hasRegisteredTooltips = false objective.hasRegisteredTooltips = false
objective.registeredItemTooltips = false objective.registeredItemTooltips = false
end
return shouldInvalidate
end
local function _InvalidateSpawnListsForNPC(npcId)
if not QuestieQuest or not QuestiePlayer or not QuestiePlayer.currentQuestlog then return end
local timer = (C_Timer) or (QuestieCompat and QuestieCompat.C_Timer)
local questsToRefresh = {}
for questId, _ in pairs(QuestiePlayer.currentQuestlog) do
local quest = QuestieDB.GetQuest and QuestieDB.GetQuest(questId)
if quest then
local needsUnload = false
-- Scan standard Objectives
if quest.Objectives then
for _, objective in pairs(quest.Objectives) do
if _TryInvalidateObjective(objective, npcId, quest) then
needsUnload = true needsUnload = true
end end
end end
end
-- Scan SpecialObjectives (demonic runestones, custom Ascension objectives, etc.)
if quest.SpecialObjectives then
for _, objective in pairs(quest.SpecialObjectives) do
if _TryInvalidateObjective(objective, npcId, quest) then
needsUnload = true
end
end
end
if needsUnload then if needsUnload then
-- Also purge QuestieMap's quest frame registry so no stale refs remain. -- Also purge QuestieMap's quest frame registry so no stale refs remain.
if QuestieMap and QuestieMap.UnloadQuestFrames then if QuestieMap and QuestieMap.UnloadQuestFrames then
@@ -648,10 +701,21 @@ end
-- NPC learning -- NPC learning
------------------------------------------------------------------------ ------------------------------------------------------------------------
-- Player-spawned NPCs that should never be learned (totems, guardians, etc.)
local PLAYER_SPAWNED_NPC_SET = {
[2523] = true, -- Searing Totem
[2630] = true, -- Earthbind Totem
[10183] = true, -- Moonflare Totem
[1103907] = true, -- Healing Stream Totem III
[1107398] = true, -- Stoneclaw Totem V
}
function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString, spawnX, spawnY, spawnZoneId) function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionString, spawnX, spawnY, spawnZoneId)
if not self:IsEnabled() then return end if not self:IsEnabled() then return end
if not Questie.dbLearner.global.settings.learnNpcs then return end if not Questie.dbLearner.global.settings.learnNpcs then return end
if not npcId or npcId <= 0 then return end if not npcId or npcId <= 0 then return end
-- 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 -- Use provided spawn coords (e.g. from kill event) or fall back to current player position
local zoneId = spawnZoneId or GetZoneId() local zoneId = spawnZoneId or GetZoneId()
@@ -669,7 +733,7 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
Questie.dbLearner.global.npcs[npcId] = existing Questie.dbLearner.global.npcs[npcId] = existing
end end
if name and not existing[1] then existing[1] = name end if name and name ~= "" and (not existing[1] or existing[1] == "") then existing[1] = name end
if level then if level then
if not existing[4] or level < existing[4] then existing[4] = level end if not existing[4] or level < existing[4] then existing[4] = level end
if not existing[5] or level > existing[5] then existing[5] = level end if not existing[5] or level > existing[5] then existing[5] = level end
@@ -694,9 +758,9 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
if not ovr then if not ovr then
QuestieDB.npcDataOverrides[npcId] = existing QuestieDB.npcDataOverrides[npcId] = existing
else else
-- Merge: fill missing fields only -- 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 then ovr[k] = v end if 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] then
@@ -718,12 +782,11 @@ function QuestieLearner:LearnNPC(npcId, name, level, subName, npcFlags, factionS
if isNew then if isNew then
Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New NPC learned:", npcId, name or "?") Questie:Debug(Questie.DEBUG_LEARNER, "[QuestieLearner] New NPC learned:", npcId, name or "?")
CrossLinkAfterNPC(npcId) CrossLinkAfterNPC(npcId)
else end
-- Existing NPC got new spawn data: invalidate cached spawnLists -- Existing NPC got new spawn data: invalidate cached spawnLists
-- for any active quest objective that references this NPC so the -- for any active quest objective that references this NPC so the
-- map system rebuilds them with fresh data on next update. -- map system rebuilds them with fresh data on next update.
_InvalidateSpawnListsForNPC(npcId) _InvalidateSpawnListsForNPC(npcId)
end
_Learner:BroadcastIfCommsAvailable("NPC", npcId, existing) _Learner:BroadcastIfCommsAvailable("NPC", npcId, existing)
end end
@@ -1117,11 +1180,65 @@ function QuestieLearner:InjectLearnedData()
if not EnsureLearnedData() then return end if not EnsureLearnedData() then return end
local learned = Questie.dbLearner.global local learned = Questie.dbLearner.global
-- Migrate old-format NPC data ([4]=spawns, [5]=zoneId) to new format ([7]=spawns, [9]=zoneId)
-- Always merge [4] into [7], even when [7] already has partial data from a recent session.
for npcId, data in pairs(learned.npcs) do
if type(data[4]) == "table" then
if data[7] == nil then
-- Simple move: no [7] exists yet
data[7] = data[4]
else
-- Merge: [7] has partial data, consolidate [4] coordinates into it
for zoneId, coords in pairs(data[4]) do
data[7][zoneId] = data[7][zoneId] or {}
for _, coord in ipairs(coords) do
InsertIfNewBucket(data[7][zoneId], coord[1], coord[2])
end
end
end
data[4] = nil
end
if type(data[5]) == "number" and data[9] == nil then
data[9] = data[5]
data[5] = nil
end
end
-- Merge character-specific learned NPC data into global pool
if Questie.db and Questie.db.char and Questie.db.char.npcs then
for npcId, data in pairs(Questie.db.char.npcs) do
local globalData = learned.npcs[npcId]
if not globalData then
learned.npcs[npcId] = data
else
-- Merge spawns: char data may be old ([4]) or new ([7]) format
local charSpawns = data[7] or data[4]
local globalSpawns = globalData[7] or globalData[4]
if charSpawns then
if not globalSpawns then
globalData[7] = {}
globalSpawns = globalData[7]
end
for zoneId, coords in pairs(charSpawns) do
globalSpawns[zoneId] = globalSpawns[zoneId] or {}
for _, coord in ipairs(coords) do
InsertIfNewBucket(globalSpawns[zoneId], coord[1], coord[2])
end
end
end
-- Adopt zoneId if missing
if not globalData[9] and data[9] then globalData[9] = data[9] end
if not globalData[5] and data[5] then globalData[5] = data[5] end
end
end
Questie.db.char.npcs = nil
end
local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0 local npcCount, questCount, itemCount, objectCount = 0, 0, 0, 0
-- Migration: fix spawn zone keys that were stored as uiMapId instead of areaId. -- Migration: fix spawn zone keys that were stored as uiMapId instead of areaId.
-- Before the GetZoneId() fix, kills on maps like Sunstrider Isle (uiMapId 1241) -- Before the GetZoneId() fix, kills on maps like Sunstrider Isle (uiMapId 1241)
-- were stored under key 1241 instead of the correct areaId 3430. -- were stored under key 1241 instead of the correct areaId 3431.
-- Convert any uiMapId keys to areaId using ZoneDB. -- Convert any uiMapId keys to areaId using ZoneDB.
local zonesFixed = 0 local zonesFixed = 0
for npcId, data in pairs(learned.npcs) do for npcId, data in pairs(learned.npcs) do
@@ -1181,6 +1298,82 @@ function QuestieLearner:InjectLearnedData()
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Migrated", zonesFixed, "spawn zone keys from uiMapId to areaId") Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Migrated", zonesFixed, "spawn zone keys from uiMapId to areaId")
end end
-- Also fix [9] zone field for NPCs and [5] zone field for Objects
-- that were stored as uiMapId instead of areaId (e.g. 1241 → 3431).
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])
if maybeAreaId and maybeAreaId ~= data[9] then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] NPC", npcId, "zone field [9]", data[9], "->", maybeAreaId)
data[9] = maybeAreaId
fieldsFixed = fieldsFixed + 1
end
end
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])
if maybeAreaId and maybeAreaId ~= data[5] then
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Object", objId, "zone field [5]", data[5], "->", maybeAreaId)
data[5] = maybeAreaId
fieldsFixed = fieldsFixed + 1
end
end
end
if fieldsFixed > 0 then
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Fixed", fieldsFixed, "zone fields from uiMapId to areaId")
end
-- Purge player-spawned totems from learned NPCs (they are not real world spawns)
-- Also purge NPCs with entirely empty spawn data (stale learner artifacts)
local purgedNpcs = 0
local PLAYER_SPAWNED_NPCS = {
[2523] = true, -- Searing Totem
[2630] = true, -- Earthbind Totem
[10183] = true, -- Moonflare Totem
[1103907] = true, -- Healing Stream Totem III
[1107398] = true, -- Stoneclaw Totem V
}
for npcId, data in pairs(learned.npcs) do
if PLAYER_SPAWNED_NPCS[npcId] then
learned.npcs[npcId] = nil
purgedNpcs = purgedNpcs + 1
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged player-spawned NPC", npcId, data[1] or "?")
elseif data[7] then
-- Check for empty spawn table (no coords at all = stale learner artifact)
local hasCoords = false
for zoneKey, coords in pairs(data[7]) do
if type(coords) == "table" and #coords > 0 then
hasCoords = true
break
end
end
if not hasCoords then
learned.npcs[npcId] = nil
purgedNpcs = purgedNpcs + 1
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged NPC with empty spawns", npcId, data[1] or "?")
end
end
end
if purgedNpcs > 0 then
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged", purgedNpcs, "invalid NPCs from learned data")
end
-- Purge Object entries that duplicate NPC entries (mobs learned as both NPC and Object).
-- NPC data is richer (has names, quest IDs), so keep the NPC version and remove the Object.
local dupObjectsRemoved = 0
for objId, _ in pairs(learned.objects) do
if learned.npcs[objId] then
learned.objects[objId] = nil
dupObjectsRemoved = dupObjectsRemoved + 1
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Removed duplicate Object", objId, "(NPC version exists)")
end
end
if dupObjectsRemoved > 0 then
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Removed", dupObjectsRemoved, "Object entries that duplicate NPC entries")
end
-- 1. NPCs -- 1. NPCs
local npcIdsToFix = {} local npcIdsToFix = {}
for npcId, data in pairs(learned.npcs) do for npcId, data in pairs(learned.npcs) do
@@ -1216,6 +1409,87 @@ function QuestieLearner:InjectLearnedData()
learned.npcs[old] = nil learned.npcs[old] = nil
end end
-- Diagnostic: log how many NPCs were injected with spawn overrides
local spawnOverrideCount = 0
for nid, ovr in pairs(QuestieDB.npcDataOverrides) do
if ovr[7] and next(ovr[7]) then
spawnOverrideCount = spawnOverrideCount + 1
end
end
Questie:Debug(Questie.DEBUG_CRITICAL, "[QuestieLearner] InjectLearnedData: injected", npcCount, "NPCs (", spawnOverrideCount, "with spawn overrides)")
-- Purge garbage quest entries: quests with no name [1] and only mc/ls metadata.
-- These are Ascension internal tracking artifacts (hash-like IDs) with no real quest data.
-- Also purge quest 788 ("Mottled Boar slain") which is an objective text, not a quest name.
local purgedQuests = 0
local OBJECTIVE_TEXT_QUEST_IDS = {
[788] = true, -- "Mottled Boar slain" — objective text, not a quest
}
for questId, data in pairs(learned.quests) do
local hasRealData = false
-- Check if quest has any meaningful data beyond mc/ls metadata
if type(data[1]) == "string" and data[1] ~= "" then
hasRealData = true
end
-- Also check for objective data, level, zone, etc.
if not hasRealData then
for k, v in pairs(data) do
if k ~= "mc" and k ~= "ls" then
hasRealData = true
break
end
end
end
local qid = tonumber(questId)
if not hasRealData or (qid and OBJECTIVE_TEXT_QUEST_IDS[qid]) then
local reason = not hasRealData and "garbage" or "objective-text"
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Purged", reason, "quest", questId, data[1] or "?")
learned.quests[questId] = nil
purgedQuests = purgedQuests + 1
end
end
if purgedQuests > 0 then
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Purged", purgedQuests, "invalid quests (garbage/objective-text)")
end
-- Infer sortKey [17] from zone data [3] for quests that have a name but no sortKey.
-- Field [3] contains zone/area info (e.g. {{470}} for Ghostlands, {{414}} for Zul'Drak).
-- The sortKey should be the areaId from [3] so the quest appears in the correct zone in the UI.
local sortKeysInferred = 0
for questId, data in pairs(learned.quests) do
if data[1] and not data[17] and data[3] then
-- [3] can be a table of tables: {{414}} or nested {{414, ...}, ...}
-- Extract the first numeric areaId from it
local sortKey = nil
if type(data[3]) == "table" then
-- Walk into nested tables to find the first numeric value
local function findFirstNumber(t)
if type(t) ~= "table" then return nil end
for i = 1, #t do
if type(t[i]) == "number" then
return t[i]
elseif type(t[i]) == "table" then
local result = findFirstNumber(t[i])
if result then return result end
end
end
return nil
end
sortKey = findFirstNumber(data[3])
elseif type(data[3]) == "number" then
sortKey = data[3]
end
if sortKey then
data[17] = sortKey
sortKeysInferred = sortKeysInferred + 1
Questie:Debug(Questie.DEBUG_DEVELOP, "[QuestieLearner] Inferred sortKey", sortKey, "for quest", questId, data[1])
end
end
end
if sortKeysInferred > 0 then
Questie:Debug(Questie.DEBUG_INFO, "[QuestieLearner] Inferred sortKey for", sortKeysInferred, "quests from zone data")
end
-- 2. Quests -- 2. Quests
local questIdsToFix = {} local questIdsToFix = {}
for questId, data in pairs(learned.quests) do for questId, data in pairs(learned.quests) do
@@ -1512,7 +1786,7 @@ function QuestieLearner:OnMouseoverUnit()
local level = UnitLevel("mouseover") local level = UnitLevel("mouseover")
local zoneText = GetRealZoneText() local zoneText = GetRealZoneText()
local areaId = _Learner.zoneCache[zoneText] local areaId = _Learner.zoneCache[zoneText]
if not areaId then if not areaId and l10n and l10n.GetAreaIdByLocalName then
areaId = l10n:GetAreaIdByLocalName(zoneText) areaId = l10n:GetAreaIdByLocalName(zoneText)
if areaId then if areaId then
_Learner.zoneCache[zoneText] = areaId _Learner.zoneCache[zoneText] = areaId
@@ -1707,7 +1981,7 @@ function QuestieLearner:OnQuestAccepted(firstArg, secondArg)
-- Zone: reverse-lookup from GetRealZoneText() which is always accurate on 3.3.5. -- Zone: reverse-lookup from GetRealZoneText() which is always accurate on 3.3.5.
local zoneText = GetRealZoneText() local zoneText = GetRealZoneText()
if zoneText and zoneText ~= "" then if zoneText and zoneText ~= "" and l10n and l10n.GetAreaIdByLocalName then
local areaId = l10n:GetAreaIdByLocalName(zoneText) local areaId = l10n:GetAreaIdByLocalName(zoneText)
if areaId and areaId > 0 then if areaId and areaId > 0 then
data[17] = areaId data[17] = areaId
@@ -1973,12 +2247,27 @@ function QuestieLearner:OnCombatLogEvent(...)
if not dstGUID then return end if not dstGUID then return end
local npcId = GetNpcIdFromGUID(dstGUID) local npcId = GetNpcIdFromGUID(dstGUID)
local name = dstName
-- Fallback chain for mob name: combat-log dstName → cached target/mouseover → current target unit
if not name or name == "" then
if _Learner.guidNpcCache then
local cached = _Learner.guidNpcCache[dstGUID]
if cached and cached.name and cached.name ~= "" then
name = cached.name
end
end
end
if not name or name == "" then
if UnitGUID("target") == dstGUID then
name = UnitName("target")
end
end
if not npcId and _Learner.guidNpcCache then if not npcId and _Learner.guidNpcCache then
local cached = _Learner.guidNpcCache[dstGUID] local cached = _Learner.guidNpcCache[dstGUID]
if cached then if cached then
npcId = cached.npcId npcId = cached.npcId
if not dstName then dstName = cached.name end
end end
end end
@@ -1993,7 +2282,7 @@ function QuestieLearner:OnCombatLogEvent(...)
local zoneText = GetRealZoneText and GetRealZoneText() or "" local zoneText = GetRealZoneText and GetRealZoneText() or ""
_Learner.recentKills[dstGUID] = { _Learner.recentKills[dstGUID] = {
npcId = npcId, npcId = npcId,
name = dstName or "", name = name or "",
x = px, x = px,
y = py, y = py,
zoneId = zoneId, zoneId = zoneId,
@@ -2005,6 +2294,9 @@ function QuestieLearner:OnCombatLogEvent(...)
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
self:LearnNPC(npcId, name, nil, nil, nil, nil, px, py, zoneId)
-- TTL cleanup: drop entries older than 10 minutes -- TTL cleanup: drop entries older than 10 minutes
local now = time() local now = time()
for g, entry in pairs(_Learner.recentKills) do for g, entry in pairs(_Learner.recentKills) do
+23 -12
View File
@@ -31,24 +31,28 @@ local GetClassColor = QuestieCompat.GetClassColor
-- Silent quest log index lookup (prevents warning spam for auto-complete/achievement "quests") -- Silent quest log index lookup (prevents warning spam for auto-complete/achievement "quests")
-- Uses QuestieCompat wrapper which normalizes return values across API versions.
local function _Questie_SilentGetQuestLogIndexByID(questId) local function _Questie_SilentGetQuestLogIndexByID(questId)
questId = tonumber(questId) questId = tonumber(questId)
if not questId then return 0 end if not questId then return 0 end
if _G.GetQuestLogIndexByID then -- QuestieCompat.GetQuestLogIndexByID returns nil when not found,
return _G.GetQuestLogIndexByID(questId) or 0 -- coerce to 0 for callers that check > 0.
local idx = QuestieCompat.GetQuestLogIndexByID(questId)
if idx then
return idx
end end
local n = (GetNumQuestLogEntries and select(1, GetNumQuestLogEntries())) or 0 -- Fallback: use the compat-normalized GetQuestLogTitle (always 8 returns)
if n > 0 and GetQuestLogTitle then local GetQuestLogTitleCompat = QuestieCompat.GetQuestLogTitle
local n = select(1, GetNumQuestLogEntries()) or 0
for i = 1, n do for i = 1, n do
local _, _, _, isHeader, _, _, _, qid = GetQuestLogTitle(i) local _, _, _, isHeader, _, _, _, qid = GetQuestLogTitleCompat(i)
qid = tonumber(qid) qid = tonumber(qid)
if (not isHeader) and qid and qid == questId then if (not isHeader) and qid and qid == questId then
return i return i
end end
end end
end
return 0 return 0
end end
@@ -223,11 +227,17 @@ function MapIconTooltip:Show()
for icon, _ in next, HBDPins.activeMinimapPins do for icon, _ in next, HBDPins.activeMinimapPins do
handleMapIcon(icon) handleMapIcon(icon)
end end
else
if QuestieCompat.Is335 then
for icon, _ in next, HBDPins.worldmapPins do
handleMapIcon(icon)
end
else else
for pin in HBDPins.worldmapProvider:GetMap():EnumeratePinsByTemplate("HereBeDragonsPinsTemplateQuestie") do for pin in HBDPins.worldmapProvider:GetMap():EnumeratePinsByTemplate("HereBeDragonsPinsTemplateQuestie") do
handleMapIcon(pin.icon) handleMapIcon(pin.icon)
end end
end end
end
Tooltip.npcAndObjectOrder = npcAndObjectOrder Tooltip.npcAndObjectOrder = npcAndObjectOrder
Tooltip.questOrder = questOrder Tooltip.questOrder = questOrder
@@ -402,7 +412,7 @@ function MapIconTooltip:Show()
break break
end end
factionName = select(1, GetFactionInfoByID(factionId)) factionName = select(1, QuestieCompat.GetFactionInfoByID(factionId))
if factionName then if factionName then
rewardValue = rewardPair[2] rewardValue = rewardPair[2]
@@ -423,10 +433,10 @@ function MapIconTooltip:Show()
end end
if aldorPenalty then if aldorPenalty then
factionName = select(1, GetFactionInfoByID(932)) factionName = select(1, QuestieCompat.GetFactionInfoByID(932))
tinsert(rewardTable, aldorPenalty .. " " .. factionName) tinsert(rewardTable, aldorPenalty .. " " .. factionName)
elseif scryersPenalty then elseif scryersPenalty then
factionName = select(1, GetFactionInfoByID(934)) factionName = select(1, QuestieCompat.GetFactionInfoByID(934))
tinsert(rewardTable, scryersPenalty .. " " .. factionName) tinsert(rewardTable, scryersPenalty .. " " .. factionName)
end end
@@ -513,6 +523,7 @@ function MapIconTooltip:Show()
end end
return levelString return levelString
end end
-- Used to get the white color for the quests which don't have anything to collect -- Used to get the white color for the quests which don't have anything to collect
local defaultQuestColor = QuestieLib:GetRGBForObjective({}) local defaultQuestColor = QuestieLib:GetRGBForObjective({})
local creatureLevels = QuestieDB:GetCreatureLevels(quest) -- Data for min and max level local creatureLevels = QuestieDB:GetCreatureLevels(quest) -- Data for min and max level
@@ -585,7 +596,7 @@ function MapIconTooltip:Show()
break break
end end
factionName = select(1, GetFactionInfoByID(factionId)) factionName = select(1, QuestieCompat.GetFactionInfoByID(factionId))
if factionName then if factionName then
rewardValue = rewardPair[2] rewardValue = rewardPair[2]
@@ -606,10 +617,10 @@ function MapIconTooltip:Show()
end end
if aldorPenalty then if aldorPenalty then
factionName = select(1, GetFactionInfoByID(932)) factionName = select(1, QuestieCompat.GetFactionInfoByID(932))
tinsert(rewardTable, aldorPenalty .. " " .. factionName) tinsert(rewardTable, aldorPenalty .. " " .. factionName)
elseif scryersPenalty then elseif scryersPenalty then
factionName = select(1, GetFactionInfoByID(934)) factionName = select(1, QuestieCompat.GetFactionInfoByID(934))
tinsert(rewardTable, scryersPenalty .. " " .. factionName) tinsert(rewardTable, scryersPenalty .. " " .. factionName)
end end
-6
View File
@@ -248,13 +248,11 @@ function QuestieTooltips:GetTooltip(key)
local objText = objEntry[2] local objText = objEntry[2]
local needed, collected local needed, collected
local objectives = QuestLogCache.GetQuestObjectives(questId) local objectives = QuestLogCache.GetQuestObjectives(questId)
local objectiveIcon
if objectives then if objectives then
for _, obj in next, objectives do for _, obj in next, objectives do
if obj.text and objText and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then if obj.text and objText and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then
needed = obj.numRequired needed = obj.numRequired
collected = obj.numFulfilled collected = obj.numFulfilled
objectiveIcon = obj.Icon
break break
end end
end end
@@ -264,7 +262,6 @@ function QuestieTooltips:GetTooltip(key)
Description = objText, Description = objText,
Needed = needed, Needed = needed,
Collected = collected, Collected = collected,
Icon = objectiveIcon,
Update = function(self) Update = function(self)
local objs = QuestLogCache.GetQuestObjectives(questId) local objs = QuestLogCache.GetQuestObjectives(questId)
if objs then if objs then
@@ -304,13 +301,11 @@ function QuestieTooltips:GetTooltip(key)
local objText = objEntry[2] local objText = objEntry[2]
local needed, collected local needed, collected
local objectives = QuestLogCache.GetQuestObjectives(questId) local objectives = QuestLogCache.GetQuestObjectives(questId)
local objectiveIcon
if objectives then if objectives then
for _, obj in next, objectives do for _, obj in next, objectives do
if obj.text and objText and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then if obj.text and objText and (obj.text == objText or string.find(obj.text, objText, 1, true) or string.find(objText, obj.text, 1, true)) then
needed = obj.numRequired needed = obj.numRequired
collected = obj.numFulfilled collected = obj.numFulfilled
objectiveIcon = obj.Icon
break break
end end
end end
@@ -320,7 +315,6 @@ function QuestieTooltips:GetTooltip(key)
Description = objText, Description = objText,
Needed = needed, Needed = needed,
Collected = collected, Collected = collected,
Icon = objectiveIcon,
Update = function(self) Update = function(self)
local objs = QuestLogCache.GetQuestObjectives(questId) local objs = QuestLogCache.GetQuestObjectives(questId)
if objs then if objs then
+1 -1
View File
@@ -220,7 +220,7 @@ If your server uses non-standard map data, enable **Options → Advanced → Use
- Fixed arrow pointing to previously completed objective locations instead of the current finisher. - Fixed arrow pointing to previously completed objective locations instead of the current finisher.
- Fixed nil error in `_CollectObjective` when processing incomplete quests. - Fixed nil error in `_CollectObjective` when processing incomplete quests.
- Fixed arrow direction for quests that require speaking to an NPC as a prerequisite step. - Fixed arrow direction for quests that require speaking to an NPC as a prerequisite step.
- **Sunstrider Isle (Ascension starting zone)**: Resolved arrow not appearing when the world map is closed. `C_Map.GetBestMapForUnit("player")` returns a ghost/loading map uiMapId (946) instead of Sunstrider Isle's real uiMapId (1241) with the map closed. Added `UiMapIdOverrides` entries for both 946 and 1241 mapping to Sunstrider Isle's areaId (3430). Updated the arrow's `UpdateNearestTargets` fallback to use `ZoneDB` lookups when the ghost map is detected, ensuring the arrow gets real world coordinates regardless of map state. - **Sunstrider Isle (Ascension starting zone)**: Resolved arrow distance, direction, and map pin issues on Sunstrider Isle. `GetCurrentZoneId()` can return 3430 OR 3431 when the player is on uiMap 1241 — all 4 Sunstrider detection checks now accept both zoneIds plus uiMapId 1241. Fixed arrow rotation direction (`SetRotation` is CW-positive, not CCW). Fixed collection function distance mismatch where targets were converted through 1941 (Eversong) bounds while player coords were in 1241 bounds. NPC 15281 spawn zone corrected from 3430 to 1241 so coords land in Sunstrider's normalized space. Removed the 1241→1941 redirect in `_ResolveMapUiMapId()`; pins on 1241 now render natively via `areaIdToUiMapId[1241] = 1241`.
### Nameplates ### Nameplates
+7 -2
View File
@@ -193,7 +193,7 @@
<li><strong>Root Cause (Player Position)</strong>: <code>HBD:GetPlayerWorldPosition()</code> returns a non-nil value on Sunstrider Isle, but those coords are Eastern Kingdoms world position (wrong), not Sunstrider's actual position. Because a non-nil value is returned, the fallback chain never fires. The arrow then calculates distance using wrong player coords vs correct target coords, giving a wildly incorrect distance.</li> <li><strong>Root Cause (Player Position)</strong>: <code>HBD:GetPlayerWorldPosition()</code> returns a non-nil value on Sunstrider Isle, but those coords are Eastern Kingdoms world position (wrong), not Sunstrider's actual position. Because a non-nil value is returned, the fallback chain never fires. The arrow then calculates distance using wrong player coords vs correct target coords, giving a wildly incorrect distance.</li>
<li><strong>Root Cause (Ghost Map Player Position)</strong>: <code>C_Map.GetPlayerMapPosition(946, "player")</code> where <code>946</code> is Ascension's ghost/cosmic map for Sunstrider Isle returns <code>(0, 0)</code> because the ghost map has no valid coordinate data. The correct map for player position on Sunstrider is <code>1941</code> (Eversong Woods parent), which shares the same world coordinate space and returns valid zone coords.</li> <li><strong>Root Cause (Ghost Map Player Position)</strong>: <code>C_Map.GetPlayerMapPosition(946, "player")</code> where <code>946</code> is Ascension's ghost/cosmic map for Sunstrider Isle returns <code>(0, 0)</code> because the ghost map has no valid coordinate data. The correct map for player position on Sunstrider is <code>1941</code> (Eversong Woods parent), which shares the same world coordinate space and returns valid zone coords.</li>
<li><strong>Root Cause (OnUpdate same-map check)</strong>: <code>OnUpdate</code> was using <code>_ResolveArrowUiMapId(_arrow_playerUiMapId)</code> (which resolves 1241&rarr;1941) for the player-side uiMapId, making it equal to <code>target.uiMapId</code> (also 1941). This caused the same-map branch to fire, which then called <code>C_Map.GetPlayerMapPosition(_arrow_playerUiMapId)</code> with 946, getting <code>(0, 0)</code> and computing wrong distance.</li> <li><strong>Root Cause (OnUpdate same-map check)</strong>: <code>OnUpdate</code> was using <code>_ResolveArrowUiMapId(_arrow_playerUiMapId)</code> (which resolves 1241&rarr;1941) for the player-side uiMapId, making it equal to <code>target.uiMapId</code> (also 1941). This caused the same-map branch to fire, which then called <code>C_Map.GetPlayerMapPosition(_arrow_playerUiMapId)</code> with 946, getting <code>(0, 0)</code> and computing wrong distance.</li>
<li><strong>Fix &mdash; QuestieArrow.lua UpdateNearestTargets</strong>: Added Sunstrider detection (<code>zoneId == 3430</code>) <em>before</em> calling <code>HBD:GetPlayerWorldPosition()</code> to force the C_Map fallback path. The fallback now calls <code>C_Map.GetPlayerMapPosition(1241, "player")</code> to get Sunstrider map-space coords, then converts through <code>HBD:GetWorldCoordinatesFromZone(..., 1941)</code> using Eversong Woods bounds &mdash; which share Sunstrider's world coordinate space.</li> <li><strong>Fix &mdash; QuestieArrow.lua UpdateNearestTargets</strong>: Added Sunstrider detection (<code>zoneId == 3430 OR 3431 OR uiMapId == 1241</code>) <em>before</em> calling <code>HBD:GetPlayerWorldPosition()</code> to force the C_Map fallback path. All 4 Sunstrider detection checks in QuestieArrow.lua now accept zoneId 3430 OR 3431 OR uiMapId 1241 (lines 354, 395, 747, 782), since <code>GetCurrentZoneId()</code> can return either 3430 or 3431 when the player is physically on uiMap 1241. The fallback now calls <code>C_Map.GetPlayerMapPosition(1241, "player")</code> to get Sunstrider map-space coords, then converts through <code>HBD:GetWorldCoordinatesFromZone(..., 1941)</code> using Eversong Woods bounds &mdash; which share Sunstrider's world coordinate space.</li>
<li><strong>Fix &mdash; QuestieArrow.lua OnUpdate same-map branch</strong>: Changed <code>C_Map.GetPlayerMapPosition(1941, "player")</code> for player zone coords (not <code>_arrow_playerUiMapId</code> which is 946). Normalizes both <code>playerUiMapId</code> and <code>targetUiMapId</code> through <code>_ResolveArrowUiMapId()</code> before same-map comparison. Uses <code>zoneScale = 13.53</code> yards/zone-unit for distance. Declares <code>worldPlayerX/Y</code> before the if-else to prevent nil in debug prints.</li> <li><strong>Fix &mdash; QuestieArrow.lua OnUpdate same-map branch</strong>: Changed <code>C_Map.GetPlayerMapPosition(1941, "player")</code> for player zone coords (not <code>_arrow_playerUiMapId</code> which is 946). Normalizes both <code>playerUiMapId</code> and <code>targetUiMapId</code> through <code>_ResolveArrowUiMapId()</code> before same-map comparison. Uses <code>zoneScale = 13.53</code> yards/zone-unit for distance. Declares <code>worldPlayerX/Y</code> before the if-else to prevent nil in debug prints.</li>
<li><strong>Debug Output</strong>: Added debug prints showing UnitPosition vs HBD player coords comparison, raw vs resolved uiMapIds, branch selection, and per-frame distance calculation inputs.</li> <li><strong>Debug Output</strong>: Added debug prints showing UnitPosition vs HBD player coords comparison, raw vs resolved uiMapIds, branch selection, and per-frame distance calculation inputs.</li>
</ul> </ul>
@@ -203,9 +203,14 @@
<li><strong>Root Cause</strong>: <code>HBDPins:HandlePin</code> (<code>HereBeDragons-Pins-2.0:424</code>) has an early-return guard: <code>if not HBD.mapData[uiMapID] then return end</code>. When the player zooms into Sunstrider Isle, <code>uiMapID</code> is 1241, but <code>HBD.mapData[1241]</code> is nil &mdash; no mapData entry existed for Sunstrider's custom child map. The icon was silently dropped before any coordinate conversion occurred.</li> <li><strong>Root Cause</strong>: <code>HBDPins:HandlePin</code> (<code>HereBeDragons-Pins-2.0:424</code>) has an early-return guard: <code>if not HBD.mapData[uiMapID] then return end</code>. When the player zooms into Sunstrider Isle, <code>uiMapID</code> is 1241, but <code>HBD.mapData[1241]</code> is nil &mdash; no mapData entry existed for Sunstrider's custom child map. The icon was silently dropped before any coordinate conversion occurred.</li>
<li><strong>Fix &mdash; Compat/HBD.lua</strong>: Added <code>mapData[1241] = mapData[1941]</code> alias and <code>mapData[946] = mapData[1941]</code> alias. Sunstrider Isle (1241) and its ghost map (946) share Eversong Woods' (1941) world coordinate space for these conversions.</li> <li><strong>Fix &mdash; Compat/HBD.lua</strong>: Added <code>mapData[1241] = mapData[1941]</code> alias and <code>mapData[946] = mapData[1941]</code> alias. Sunstrider Isle (1241) and its ghost map (946) share Eversong Woods' (1941) world coordinate space for these conversions.</li>
<li><strong>Fix &mdash; HBD fallback loading</strong>: Added a lazy fallback to the real HBD library's <code>mapData</code> for maps not present in Questie's compat table, so custom/private-server maps can still resolve world and zone coordinates when <code>QuestieCompat</code> lacks a local entry.</li> <li><strong>Fix &mdash; HBD fallback loading</strong>: Added a lazy fallback to the real HBD library's <code>mapData</code> for maps not present in Questie's compat table, so custom/private-server maps can still resolve world and zone coordinates when <code>QuestieCompat</code> lacks a local entry.</li>
<li><strong>Fix &mdash; Modules/Map/QuestieMap.lua</strong>: Added <code>_ResolveMapUiMapId()</code> helper (1241&rarr;1941) applied across <code>FadeLogic</code>, <code>FindClosestStarter</code>, <code>GetNearestSpawn</code>, and <code>GetNearestQuestSpawn</code>. <code>DrawWorldIcon</code> and <code>DrawManualIcon</code> now also store and render Sunstrider map icons against the resolved parent map coordinate space.</li> <li><strong>Fix &mdash; Modules/Map/QuestieMap.lua</strong>: Originally added <code>_ResolveMapUiMapId()</code> helper (1241&rarr;1941) to redirect Sunstrider pins to Eversong's coordinate space. This redirect was later <strong>removed</strong> so pins on 1241 render natively using Ascension-calibrated bounds. Added <code>areaIdToUiMapId[1241] = 1241</code> mapping in zoneDB.lua so <code>DrawWorldIcon</code> can place pins directly on the Sunstrider sub-map. HBD's ZONE_REDIRECT visibility (<code>ResolveZone(1241)=1941</code>) ensures pins on 1241 are also visible on the Eversong map.</li>
</ul> </ul>
</li> </li>
<li><strong>[Fix &mdash; Sunstrider zoneId 3431 Detection]</strong> <code>GetCurrentZoneId()</code> returns 3431 (Eversong Woods) when the player is physically on uiMap 1241 (Sunstrider Isle), not 3430 as previously assumed. All 4 Sunstrider detection checks in QuestieArrow.lua now accept zoneId 3430 OR 3431 OR uiMapId 1241 (lines 354, 395, 747, 782). Without this fix, NONE of the Sunstrider coordinate overrides triggered, causing the arrow to compute player and target positions in different coordinate spaces (858 yard offset).</li>
<li><strong>[Fix &mdash; Arrow Rotation Direction]</strong> WoW's <code>Texture:SetRotation(r)</code> rotates CW for positive r, NOT CCW as the code comment claimed. Changed <code>rotAngle = -relative</code> to <code>rotAngle = relative</code> (line 441). The arrow was rotating in the opposite direction of the target, pointing away instead of toward it.</li>
<li><strong>[Fix &mdash; Collection Function Distance Mismatch]</strong> <code>_CollectFinisherSpawns</code> and <code>_CollectObjective</code> converted targets through 1941 (Eversong) bounds while player coordinates were in 1241 (Sunstrider) bounds. Added <code>sunOverride = (pMap == 1241)</code> variable and forced target conversion through 1241 bounds at all 4 conversion sites in both functions. Without this fix, sortedTargets showed dist=1261 instead of the correct ~48 yards.</li>
<li><strong>[Fix &mdash; NPC Spawn Zone for Sunstrider]</strong> NPC 15281 (Lanthan Perilon) spawn data changed from zone 3430 to zone 1241. Coordinates gathered via <code>GetPlayerMapPosition</code> on uiMap 1241 are in 1241's normalized space, NOT 1941's. Using zone 3430 (&rarr;1941) produced world coordinates outside 1241's 0-1 range, making map pins invisible on the Sunstrider sub-map.</li>
<li><strong>[Fix &mdash; Sunstrider Map Pin System]</strong> Removed the 1241&rarr;1941 redirect in <code>_ResolveMapUiMapId()</code>. Pins on 1241 now render natively using Ascension-calibrated bounds instead of being forced to 1941's coordinate space. Added <code>areaIdToUiMapId[1241] = 1241</code> mapping in zoneDB.lua so <code>DrawWorldIcon</code> can place pins directly on the Sunstrider sub-map.</li>
<li><strong>[Fix &mdash; Northshire Valley UiMapData Registration]</strong> Added explicit <code>QuestieCompat.UiMapData[1238]</code> for Northshire Valley so custom/private-server zone lookups have concrete geometry for the child map instead of relying on incomplete parent fallbacks.</li> <li><strong>[Fix &mdash; Northshire Valley UiMapData Registration]</strong> Added explicit <code>QuestieCompat.UiMapData[1238]</code> for Northshire Valley so custom/private-server zone lookups have concrete geometry for the child map instead of relying on incomplete parent fallbacks.</li>
<li><strong>[Fix &mdash; QuestieLearner Icon Preservation]</strong> Preserved the learned objective icon when registering with the tooltip system so nameplates can render the correct learned slay/loot/talk marker. Previously the icon was always nil on fresh registration.</li> <li><strong>[Fix &mdash; QuestieLearner Icon Preservation]</strong> Preserved the learned objective icon when registering with the tooltip system so nameplates can render the correct learned slay/loot/talk marker. Previously the icon was always nil on fresh registration.</li>
<li><strong>[Notes &mdash; Failed Approaches Documented]</strong> The unreleased notes now explicitly capture the Sunstrider approaches that did <em>not</em> work: <li><strong>[Notes &mdash; Failed Approaches Documented]</strong> The unreleased notes now explicitly capture the Sunstrider approaches that did <em>not</em> work:
+6 -6
View File
@@ -224,8 +224,8 @@
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<h4>Sunstrider Coordinate Normalization</h4> <h4>Sunstrider Coordinate Normalization (Final)</h4>
<p>Arrow and world-map code now normalize Sunstrider's child map <code>1241</code>, ghost map <code>946</code>, and parent Eversong map <code>1941</code> into a consistent coordinate path so distance, direction, and icon placement all use the same world space.</p> <p>Arrow and world-map code now normalize Sunstrider's child map <code>1241</code>, ghost map <code>946</code>, and parent Eversong map <code>1941</code> into a consistent coordinate path. <code>GetCurrentZoneId()</code> can return 3430 <em>or</em> 3431 on Sunstrider &mdash; all 4 detection checks now accept both values plus uiMapId 1241. The 1241&rarr;1941 redirect in <code>_ResolveMapUiMapId()</code> was removed; pins render natively on 1241 via <code>areaIdToUiMapId[1241] = 1241</code>.</p>
</div> </div>
<div class="card"> <div class="card">
<h4>Learned Tooltip Schema Fix</h4> <h4>Learned Tooltip Schema Fix</h4>
@@ -235,12 +235,12 @@
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<h4>Custom Map Data Aliases</h4> <h4>Arrow Rotation &amp; Collection Fixes</h4>
<p><code>Compat/HBD.lua</code> now aliases Sunstrider map data (<code>1241</code> and <code>946</code>) to Eversong (<code>1941</code>) and can fall back to the real HBD library's <code>mapData</code> for maps not present in Questie's local compat table.</p> <p>Fixed arrow rotation direction (<code>SetRotation</code> is CW-positive, not CCW) and collection function distance mismatch where targets were converted through 1941 bounds while player coords were in 1241 bounds, causing 1261-yard errors instead of ~48 yards.</p>
</div> </div>
<div class="card"> <div class="card">
<h4>QuestieLearner Icon Preservation</h4> <h4>NPC Spawn Zone &amp; Native Map Pins</h4>
<p>Learned objectives now preserve the resolved quest-log icon during immediate tooltip registration so nameplates can render the correct learned slay/loot/talk marker instead of a missing icon state.</p> <p>NPC 15281 (Lanthan Perilon) spawn zone corrected from 3430 to 1241 so coords land in Sunstrider's 0-1 space. Pins on 1241 now render natively using Ascension-calibrated bounds with <code>areaIdToUiMapId[1241] = 1241</code> instead of redirecting to 1941.</p>
</div> </div>
</div> </div>
+72 -2
View File
@@ -17,7 +17,8 @@ NPC spawn data: zone 3430 (Eversong) → GetUiMapIdByAreaId(3430) → uiMapId 19
| Lookup | From | To | Purpose | | Lookup | From | To | Purpose |
|--------|------|----|---------| |--------|------|----|---------|
| `GetUiMapIdByAreaId(3430)` | areaId 3430 | uiMapId 1941 | Pin placement on Eversong map | | `GetUiMapIdByAreaId(3430)` | areaId 3430 | uiMapId 1941 | Pin placement on Eversong map |
| `GetAreaIdByUiMapId(1241)` | uiMapId 1241 | areaId 3430 | Zone ID for spawn data keys | | `GetUiMapIdByAreaId(3431)` | areaId 3431 | uiMapId 1941 | Pin placement on Eversong map (Sunstrider subzone) |
| `GetAreaIdByUiMapId(1241)` | uiMapId 1241 | areaId 3431 | Zone ID for spawn data keys (Sunstrider subzone) |
| `_ResolveMapUiMapId(1241)` | uiMapId 1241 | uiMapId 1941 | Normalize pin rendering | | `_ResolveMapUiMapId(1241)` | uiMapId 1241 | uiMapId 1941 | Normalize pin rendering |
| `_ResolveArrowUiMapId(1241)` | uiMapId 1241 | uiMapId 1941 | Arrow math normalization | | `_ResolveArrowUiMapId(1241)` | uiMapId 1241 | uiMapId 1941 | Arrow math normalization |
| `ZONE_REDIRECT[1241]` | uiMapId 1241 | uiMapId 1941 | Cross-visibility | | `ZONE_REDIRECT[1241]` | uiMapId 1241 | uiMapId 1941 | Cross-visibility |
@@ -32,7 +33,7 @@ percentages (e.g., NPC 15278 at 38.02%, 21.01%). These render correctly on
the Eversong map (1941). Mapping 3430→1241 would place Eversong-wide the Eversong map (1941). Mapping 3430→1241 would place Eversong-wide
coordinates on the Sunstrider sub-map, producing wrong positions. coordinates on the Sunstrider sub-map, producing wrong positions.
Pins from zone 3430 render on uiMapId 1941 (Eversong) and appear on uiMapId Pins from zones 3430 and 3431 render on uiMapId 1941 (Eversong) and appear on uiMapId
1241 (Sunstrider) via ZONE_REDIRECT visibility, which works because 1241 (Sunstrider) via ZONE_REDIRECT visibility, which works because
`ResolveZone(1241) == ResolveZone(1941) == 1941`. `ResolveZone(1241) == ResolveZone(1941) == 1941`.
@@ -54,6 +55,7 @@ Pins from zone 3430 render on uiMapId 1941 (Eversong) and appear on uiMapId
- `_ResolveArrowUiMapId(1241)` → 1941 - `_ResolveArrowUiMapId(1241)` → 1941
- `_ResolveArrowUiMapId(946)` → 1941 - `_ResolveArrowUiMapId(946)` → 1941
- Comment updated to match new approach - Comment updated to match new approach
- **Arrow rendering**: Replaced sprite sheet (108-frame) with single-frame texture + `SetRotation(-angle)` for infinite angular resolution and zero jitter. Arrow texture is now X-PLORE's `XPArrow4.tga` (256×256 RGBA, arrow pointing UP centered at 128,128). Removed all `ARROW_SHEET_*`, `ARROW_CELL_*`, UV math, and `SetTexCoord` cell selection logic. Arrow uses `ARROW_DISPLAY_SIZE=96` for on-screen pixel size and `SetPoint("CENTER")` anchor for clean rotation pivot. `SetVertexColor(1,1,1)` preserves original blue color.
### Modules/QuestieLearner.lua ### Modules/QuestieLearner.lua
- `GetZoneId()`: Returns areaId via `ZoneDB:GetAreaIdByUiMapId(uiMapId)` with fallback - `GetZoneId()`: Returns areaId via `ZoneDB:GetAreaIdByUiMapId(uiMapId)` with fallback
@@ -234,3 +236,71 @@ Expected: `ResolveZone(1241)= 1941 ResolveZone(946)= 1941`
- [ ] After 1+ kill, verify learned pin auto-appears at correct position - [ ] After 1+ kill, verify learned pin auto-appears at correct position
- [ ] Verify Eversong Woods NPCs NOT on Sunstrider show correctly on Eversong map - [ ] Verify Eversong Woods NPCs NOT on Sunstrider show correctly on Eversong map
- [ ] Check no regressions on other zones - [ ] Check no regressions on other zones
- [ ] **Complete-abandon-reaccept cycle**: Complete a quest's objectives → abandon → re-accept → verify pins appear for fresh 0/X objectives
- [ ] **Arrow rendering**: Verify arrow shows a single blue arrow (not sprite sheet), smooth rotation with no visible frame transitions, correct direction toward quest objectives, and correct display size
- [ ] **Learner data in arrow**: Verify arrow targets point to QuestieLearner-injected NPC spawn locations correctly
- [ ] **QUEST_TURNED_IN auto-complete**: Verify quests that auto-complete on turn-in clean up state properly (no orphan pins)
## Complete-Abandon-Reaccept Pin Lifecycle Fix (Session 2026-05-17)
### Bug Chain
Four interacting bugs prevented map pins and GPS arrow from reappearing after
completing quest objectives, abandoning the quest, and re-accepting it:
1. **MarkQuestAsAbandoned `objectivesWereComplete` path** — called `CompleteQuest`
without clearing `quest.Objectives`, `quest.WasComplete`, or `quest.isComplete`.
Stale `Completed=true` + `isUpdated=true` flags caused `PopulateObjectiveNotes`
to skip drawing pins on re-accept.
2. **CompleteQuest** — did not clear `quest.Objectives` (unlike `AbandonedQuest`
which does). Now adds `quest.Objectives = {}` with type guard as defense-in-depth.
3. **QUEST_TURNED_IN dead code**`questLog[questId] = {}` wiped state before the
QUEST_TURNED_IN state check could read it, making auto-complete cleanup unreachable.
Moved the check before the wipe.
4. **AcceptQuest reset** — added `SetObjectivesDirty(questId)` in the re-accept block
to ensure `isUpdated` flags are reset even if stale objectives survive.
5. **Arrow spawnList gap**`_CollectObjective` silently skipped objectives with
nil/empty `spawnList`. After quest re-accept, `PopulateQuestLogInfo` creates
objectives without `spawnList`; `PopulateObjectiveNotes` builds it later in the
TaskQueue. Added `QuestieQuest:BuildObjectiveSpawnList(objective, objectiveData)`
public API that lazily builds `spawnList` from `objectiveSpawnListCallTable` handlers.
The arrow now calls this when `spawnList` is missing.
### Files Changed
- **QuestEventHandler.lua** (~line 443-461): MarkQuestAsAbandoned — clear stale
objectives/flags + SetObjectivesDirty before CompleteQuest
- **QuestEventHandler.lua** (~line 233): QUEST_TURNED_IN — moved state check before
questLog[questId] = {} wipe
- **QuestieQuest.lua** (~line 492): AcceptQuest reset — added SetObjectivesDirty(questId)
- **QuestieQuest.lua** (~line 583): CompleteQuest — added `quest.Objectives = {}`
- **QuestieQuest.lua** (~line 1996-2018): New `BuildObjectiveSpawnList` public API
- **QuestieArrow.lua** (~line 726-760): _CollectObjective — lazy spawnList building
via `QuestieQuest:BuildObjectiveSpawnList()`
## UpdateQuest Pin Refresher Fallback (Session 2026-05-17)
### Problem
After reload or abandon-reaccept, incomplete quests sometimes have no objective pins
on the map even though they are in the quest log. This happens when:
1. `PopulateQuestLogInfo` hits a cache miss and leaves `quest.Objectives` empty.
2. `UnloadQuestFrames` removes map frames but `AlreadySpawned` is not cleared,
so `_DetermineIconsToDraw` skips recreating icons on the next refresh.
### Fix
Added a robustness fallback in `QuestieQuest:UpdateQuest()` (incomplete branch):
- If `quest.Objectives` is empty → re-call `PopulateQuestLogInfo()`, then
`PopulateObjectiveNotes()` if objectives were created.
- If objectives exist but `QuestieMap.questIdFrames[questId]` is nil → clear
`objective.AlreadySpawned = {}` for all objectives, then re-call
`PopulateObjectiveNotes()` to force icon recreation.
This ensures that ANY incomplete quest in the log gets its pins re-added on the
next periodic refresh (30s) or `QUEST_LOG_UPDATE` if they were lost.
### Files Changed
- **QuestieQuest.lua** (~line 833): Added `hasObjectives` / `hasFrames` fallback
in the `isComplete == 0` branch of `UpdateQuest`.