Files
Questie-Octo/Map/Map.lua
T
2026-08-18 15:07:39 -05:00

1449 lines
50 KiB
Lua

QuestieOcto.Map = QuestieOcto.Map or {}
local M = QuestieOcto.Map
local function DisplaySettings()
return QuestieOcto.MinimapSettings
end
local function IsPermanentRole(role)
return role=="flightMaster" or role=="auctioneer" or role=="banker"
or role=="mailbox" or role=="battlemaster" or role=="innkeeper"
or role=="meetingStone" or role=="repair" or role=="spiritHealer"
or role=="stableMaster" or role=="vendor" or role=="rareMob"
end
local function IsSpecialQuestNode(node)
return node and (node.pvp or node.repeatable or node.event) and true or false
end
local function IsQuestMarkerNodeEnabled(node)
local settings=DisplaySettings()
if IsSpecialQuestNode(node) then
return settings:Get("showSpecialQuestsWorldMap") and true or false
end
return settings:Get("showAllQuestsWorldMap") and true or false
end
local function IsPvPQuestNodeEnabled(node)
if node and node.pvp then
return DisplaySettings():Get("showPvPRelatedQuests") and true or false
end
return true
end
local function IsRoleEnabled(role)
local settings=DisplaySettings()
if role=="auctioneer" then return settings:Get("showMapAuctioneer") and true or false end
if role=="banker" then return settings:Get("showMapBanker") and true or false end
if role=="flightMaster" then return settings:Get("showMapFlightMaster") and true or false end
if role=="mailbox" then return settings:Get("showMapMailbox") and true or false end
if role=="battlemaster" then return settings:Get("showMapBattlemaster") and true or false end
if role=="innkeeper" then return settings:Get("showMapInnkeeper") and true or false end
if role=="meetingStone" then return settings:Get("showMapMeetingStone") and true or false end
if role=="repair" then return settings:Get("showMapRepair") and true or false end
if role=="spiritHealer" then return settings:Get("showMapSpiritHealer") and true or false end
if role=="stableMaster" then return settings:Get("showMapStableMaster") and true or false end
if role=="vendor" then return settings:Get("showMapVendor") and true or false end
if role=="rareMob" then return settings:Get("showMapRareMonsters") and true or false end
if not settings:Get("enableMapIcons") then return false end
if role=="itemStart" then
return settings:Get("enableAvailable")
and settings:Get("showItemStartQuests")
and settings:Get("showItemStartMap")
and true or false
elseif role=="available" then
return settings:Get("enableAvailable") and true or false
elseif role=="turnin" then
return settings:Get("enableTurnins") and true or false
else
return settings:Get("enableObjectives") and true or false
end
end
M.enabled=true
M.mapID=nil
M.generation=0
M.syncing=false
M.resync=false
M.prune=false
M.frames={}
M.activeFrames={}
M.buildActiveFrames=nil
M.renderedPreparedPlan=nil
M.syncPreparedPlan=nil
M.renderedNodeRevision=0
M.syncNodeRevision=nil
M.stats={active=0,created=0,reused=0,hidden=0,exact=0,objectiveAreas=0,itemStartAreas=0,syncs=0,visibleAvailable=0,visibleItemStart=0,visibleObjective=0,visibleTurnin=0,inputNodes=0,multiEntryPins=0,itemStartRawNodes=0,itemStartAreaPins=0,preparedHits=0,preparedMisses=0,preparedDescriptors=0,mapPriorityRequests=0}
local ICON_ROOT="Interface\\AddOns\\Questie-Octo\\UI\\Icons\\"
local TEX_AVAILABLE=ICON_ROOT.."available"
local TEX_AVAILABLE_GRAY=ICON_ROOT.."available_gray"
local TEX_MOBDROP=ICON_ROOT.."available_mobdrop"
local TEX_OBJECTSTART=ICON_ROOT.."available_object"
local TEX_COMPLETE=ICON_ROOT.."complete"
local TEX_EVENT_AVAILABLE=ICON_ROOT.."eventquest"
local TEX_EVENT_COMPLETE=ICON_ROOT.."eventquest_complete"
local TEX_REPEATABLE_AVAILABLE=ICON_ROOT.."repeatable"
local TEX_PVP_AVAILABLE=ICON_ROOT.."pvp_available"
local TEX_PVP_COMPLETE=ICON_ROOT.."pvp_complete"
local TEX_INCOMPLETE=ICON_ROOT.."incomplete"
local TEX_SLAY=ICON_ROOT.."slay"
local TEX_LOOT=ICON_ROOT.."loot"
local TEX_OBJECT=ICON_ROOT.."object"
local TEX_EVENT=ICON_ROOT.."event"
local TEX_INTERACT=ICON_ROOT.."interact"
local TEX_FLIGHT=ICON_ROOT.."flight"
local TEX_AUCTIONEER=ICON_ROOT.."auctioneer"
local TEX_BANKER=ICON_ROOT.."banker"
local TEX_MAILBOX=ICON_ROOT.."mailbox"
local TEX_BATTLEMASTER=ICON_ROOT.."battlemaster"
local TEX_INNKEEPER=ICON_ROOT.."innkeeper"
local TEX_MEETINGSTONE=ICON_ROOT.."meetingstone"
local TEX_REPAIR=ICON_ROOT.."repair"
local TEX_SPIRITHEALER=ICON_ROOT.."spirithealer"
local TEX_STABLEMASTER=ICON_ROOT.."stablemaster"
local TEX_VENDOR=ICON_ROOT.."vendor"
local TEX_RARE=ICON_ROOT.."rares"
-- Turtle deliberately keeps full quest XP through 25 levels above the quest
-- (Tortoise src/game/QuestDef.cpp). The server file is XP logic rather than a
-- client icon-color function, but it matches the observed Turtle native quest
-- presentation and is the project's accepted low-level gray-marker boundary.
-- Exactly +25 stays normal; +26 and beyond becomes gray.
local TURTLE_GRAY_QUEST_DELTA=25
local function IsGrayAvailableQuest(node)
if not node or (node.role~="available" and node.role~="itemStart") then return false end
local questID=tonumber(node.questID)
if not questID then return false end
local q=QuestieOcto.QuestModel and QuestieOcto.QuestModel:Get(questID) or nil
if not q or q.presentationAlwaysNormal then return false end
local questLevel=tonumber(q.level)
local playerLevel=UnitLevel and tonumber(UnitLevel("player")) or nil
if not questLevel or questLevel<=0 or not playerLevel or playerLevel<=0 then return false end
return playerLevel>questLevel+TURTLE_GRAY_QUEST_DELTA
end
local function TextureForNode(node)
if node.role=="flightMaster" then return TEX_FLIGHT end
if node.role=="auctioneer" then return TEX_AUCTIONEER end
if node.role=="banker" then return TEX_BANKER end
if node.role=="mailbox" then return TEX_MAILBOX end
if node.role=="battlemaster" then return TEX_BATTLEMASTER end
if node.role=="innkeeper" then return TEX_INNKEEPER end
if node.role=="meetingStone" then return TEX_MEETINGSTONE end
if node.role=="repair" then return TEX_REPAIR end
if node.role=="spiritHealer" then return TEX_SPIRITHEALER end
if node.role=="stableMaster" then return TEX_STABLEMASTER end
if node.role=="vendor" then return TEX_VENDOR end
if node.role=="rareMob" then return TEX_RARE end
if node.role=="itemStart" then
-- Presentation priority: PvP > Repeatable > Event > Turtle low-level gray > Normal.
if node.pvp then return TEX_PVP_AVAILABLE end
if node.repeatable then return TEX_REPEATABLE_AVAILABLE end
if node.event then return TEX_EVENT_AVAILABLE end
if IsGrayAvailableQuest(node) then return TEX_AVAILABLE_GRAY end
return TEX_AVAILABLE
end
if node.role=="available" then
if node.pvp then return TEX_PVP_AVAILABLE end
if node.repeatable then return TEX_REPEATABLE_AVAILABLE end
if node.event then return TEX_EVENT_AVAILABLE end
if IsGrayAvailableQuest(node) then return TEX_AVAILABLE_GRAY end
return TEX_AVAILABLE
end
if node.role=="turnin" then
if node.pvp then return TEX_PVP_COMPLETE end
-- Questie 6 uses its ordinary completion question mark for repeatable
-- turn-ins. Repeatability therefore wins over event presentation here too.
if node.repeatable then return TEX_COMPLETE end
if node.event then return TEX_EVENT_COMPLETE end
return TEX_COMPLETE
end
if node.role=="objectiveItemSource" then
if node.sourceKind=="gameObject" then return TEX_OBJECT end
return TEX_LOOT
end
if node.role=="objectiveObject" then return TEX_OBJECT end
if node.role=="objectiveCreature" then return TEX_SLAY end
if node.role=="objectiveArea" then return TEX_EVENT end
return TEX_INCOMPLETE
end
local function RolePriority(role)
if IsPermanentRole(role) then return role=="rareMob" and 6 or 5 end
-- A quest that can be picked up should be visually dominant when the
-- exact same source/area also participates in active objective/loot data.
if role=="turnin" then return 50 end
if role=="available" or role=="itemStart" then return 40 end
-- At a shared Full Nodes coordinate, a direct objective should own the
-- displayed quest color over an indirect item-drop source. The merged pin
-- still retains every quest/objective entry for its tooltip.
if role=="objectiveObject" or role=="objectiveCreature" or role=="objectiveArea" then return 20 end
if role=="objectiveItemSource" then return 15 end
return 10
end
local function VariantPriority(node)
if node and node.pvp then return 3 end
if node and node.repeatable then return 2 end
if node and node.event then return 1 end
return 0
end
local function VisualPriority(node)
return RolePriority(node.role)*10+VariantPriority(node)
end
local function ScaleKeyForRole(role)
return nil
end
local function DrawSublevelForRole(role)
if IsPermanentRole(role) then return role=="rareMob" and 2 or 1 end
-- Questie 5.2.3/6.0.0/3.3.5 map utils:
-- available = OVERLAY 5, complete = OVERLAY 6, objectives = OVERLAY 0.
if role=="turnin" then return 6 end
if role=="available" or role=="itemStart" then return 5 end
return 0
end
local function ApplyVisualRole(pin,node)
local priority=VisualPriority(node)
if not pin.visualPriority or priority>pin.visualPriority then
pin.visualPriority=priority
pin.role=node.role
pin.questID=node.questID
pin.event=node.event
pin.pvp=node.pvp and true or false
pin.repeatable=node.repeatable and true or false
pin.sourceKind=node.sourceKind
pin.sourceID=node.sourceID
pin.displayName=node.sourceName or pin.displayName or "Quest source"
pin.iconScaleKey=node.iconScaleKey or ScaleKeyForRole(node.role)
pin.texture:SetTexture(TextureForNode(node))
pin.texture:SetDrawLayer("OVERLAY",DrawSublevelForRole(node.role))
-- Miscellaneous/rare markers stay below quest pins so an overlapping
-- quest objective, starter or turn-in remains visible and clickable.
if pin.SetFrameLevel and WorldMapButton then
if IsPermanentRole(node.role) then
pin:SetFrameLevel(WorldMapButton:GetFrameLevel()+7)
else
pin:SetFrameLevel(WorldMapButton:GetFrameLevel()+8)
end
end
if QuestieOcto.Visuals then QuestieOcto.Visuals:ApplyPin(pin,node,false,1) end
end
end
function M:GetTextureForNode(node)
return TextureForNode(node)
end
function M:IsGrayAvailableQuest(node)
return IsGrayAvailableQuest(node)
end
function M:GetRolePriority(role)
return RolePriority(role)
end
function M:GetVisualPriority(node)
return VisualPriority(node)
end
function M:GetDrawSublevelForRole(role)
return DrawSublevelForRole(role)
end
function M:GetScaleKeyForRole(role)
return ScaleKeyForRole(role)
end
function M:GetIconTypeScale(node)
return 1
end
function M:GetPinScale(pin)
-- Keep every townsfolk/service marker on the same compact footprint.
-- Rare Monsters intentionally stay on their dedicated 12px footprint below.
-- The player-facing global map/minimap scale remains unchanged.
if pin and (pin.role=="auctioneer" or pin.role=="banker" or pin.role=="flightMaster" or
pin.role=="mailbox" or pin.role=="battlemaster" or pin.role=="innkeeper" or
pin.role=="meetingStone" or pin.role=="repair" or pin.role=="spiritHealer" or
pin.role=="stableMaster" or pin.role=="vendor") then
return 0.9
end
return 1
end
function M:ResizePin(pin)
if not pin then return end
local globalScale=tonumber(DisplaySettings():Get("globalScale")) or 1
local typeScale=self:GetPinScale(pin)
-- pfQuest renders tracking/rares.tga inside a 14px node with a 1px inset,
-- leaving a 12px visible star. Our texture fills the pin, so use 12px
-- for the miscellaneous rare marker to match that less-intrusive footprint.
local baseSize=pin.fullNode and 14 or ((pin.role=="rareMob") and 12 or 16)
local size=baseSize*globalScale*typeScale
pin:SetWidth(size)
pin:SetHeight(size)
if QuestieOcto.Visuals then QuestieOcto.Visuals:ResizeGlow(pin) end
pin.questieOctoScaleSize=size
self.stats.scaleResizes=(self.stats.scaleResizes or 0)+1
self.stats.lastScaleSize=size
end
local function IsExactRole(role)
return role=="available" or role=="turnin" or IsPermanentRole(role)
end
local function EntryKey(node)
return tostring(node.questID)..":"..tostring(node.role)..":"..
tostring(node.sourceKind)..":"..tostring(node.sourceID)..":"..tostring(node.itemID or 0)
end
local function AddEntry(pin,node)
pin.entries=pin.entries or {}
local key=EntryKey(node)
if not pin.entries[key] then
pin.entries[key]={node=node}
end
ApplyVisualRole(pin,node)
end
local function UpdatePosition(pin,x,y,offsetX,offsetY)
offsetX=offsetX or 0
offsetY=offsetY or 0
if pin.x==x and pin.y==y and pin.offsetX==offsetX and pin.offsetY==offsetY then
return
end
pin.x=x
pin.y=y
pin.offsetX=offsetX
pin.offsetY=offsetY
pin:ClearAllPoints()
pin:SetPoint(
"CENTER",WorldMapButton,"TOPLEFT",
WorldMapButton:GetWidth()*(x/100)+offsetX,
-WorldMapButton:GetHeight()*(y/100)+offsetY
)
end
local function DisplayedMapID()
-- Vanilla selected-zone map -> name -> canonical DB map ID.
local cid=GetCurrentMapContinent and GetCurrentMapContinent() or 0
local zid=GetCurrentMapZone and GetCurrentMapZone() or 0
if not cid or cid<=0 or not zid or zid<=0 then return nil end
if not GetMapZones then return nil end
local zones={GetMapZones(cid)}
local name=zones[zid]
if not name then return nil end
-- Derive ID by looking at existing canonical node map indexes.
-- Zone DB identity remains hidden behind generated node coordinates.
if QuestieOcto.DatabaseAPI.GetMapIDByName then
return QuestieOcto.DatabaseAPI:GetMapIDByName(name)
end
return nil
end
local function DisplayedContinentMapID()
local cid=GetCurrentMapContinent and GetCurrentMapContinent() or 0
local zid=GetCurrentMapZone and GetCurrentMapZone() or 0
if not cid or cid<=0 or (zid and zid>0) then return nil end
if not QuestieOcto.ContinentProjection then return nil end
return QuestieOcto.ContinentProjection:GetClientContinentMapID(cid)
end
local function DisplayedContextKey()
local mapID=DisplayedMapID()
if mapID then return tonumber(mapID) end
local continentMapID=DisplayedContinentMapID()
if continentMapID~=nil then return -1000-tonumber(continentMapID) end
return nil
end
local function OpenContinentZoneForPin(pin)
if not pin or not pin.continentZoneMapID or not SetMapZoom or not GetMapZones then return false end
local continent=GetCurrentMapContinent and GetCurrentMapContinent() or 0
if not continent or continent<=0 then return false end
local target=tonumber(pin.continentZoneMapID)
if not target then return false end
local zones={GetMapZones(continent)}
local index,name
for index,name in ipairs(zones) do
if QuestieOcto.DatabaseAPI:GetMapIDByName(name)==target then
if QuestieOcto.Tooltips then QuestieOcto.Tooltips:Hide(pin) end
SetMapZoom(continent,index)
return true
end
end
return false
end
local function AttachWorldMapPinInput(pin)
if not pin then return end
pin:EnableMouse(true)
pin:RegisterForClicks("LeftButtonUp")
pin:SetScript("OnEnter",function() QuestieOcto.Tooltips:Show(this) end)
pin:SetScript("OnLeave",function() QuestieOcto.Tooltips:Hide(this) end)
-- Continent-map markers should behave as zone-entry targets instead of
-- swallowing the click that would otherwise select the zone underneath.
pin:SetScript("OnClick",function() OpenContinentZoneForPin(this) end)
end
function M:GetOrCreate(key,node,x,y,clusterCount,generation,kind)
if not IsRoleEnabled(node.role) or not IsPvPQuestNodeEnabled(node) then return nil end
if QuestieOcto.QuestMenu and QuestieOcto.QuestMenu:IsNodeHidden(node) then return nil end
local pin=self.frames[key]
if not pin then
pin=CreateFrame("Button",nil,WorldMapButton)
pin:SetWidth(16)
pin:SetHeight(16)
pin:SetFrameLevel(WorldMapButton:GetFrameLevel()+8)
local tex=pin:CreateTexture(nil,"OVERLAY")
tex:SetAllPoints(pin)
pin.texture=tex
AttachWorldMapPinInput(pin)
self.frames[key]=pin
self.stats.created=self.stats.created+1
else
self.stats.reused=self.stats.reused+1
end
pin.itemStartArea=nil
if pin.seenGeneration~=generation then
pin.seenGeneration=generation
if self.buildActiveFrames then table.insert(self.buildActiveFrames,pin) end
end
if pin.entryGeneration~=generation then
pin.entryGeneration=generation
pin.entries={}
pin.displayName=nil
pin.clusterCount=1
pin.visualPriority=nil
pin.role=nil
pin.event=nil
pin.pvp=nil
pin.repeatable=nil
pin.fullNode=nil
pin.fullNodeNode=nil
pin.iconScaleKey=nil
pin.sourceKind=node.sourceKind
pin.continentZoneMapID=nil
if QuestieOcto.Visuals then QuestieOcto.Visuals:ClearPin(pin,1) end
end
pin.clusterCount=math.max(pin.clusterCount or 1,clusterCount or 1)
-- Place at canonical coordinates first. Final layout resolves overlap
-- after the complete visible pin set is known.
UpdatePosition(pin,x,y,0,0)
AddEntry(pin,node)
if kind=="objectiveFull" or kind=="itemStartFull" then
local current=pin.fullNodeNode
if not current or self:GetVisualPriority(node)>self:GetVisualPriority(current) then
pin.fullNodeNode=node
end
if QuestieOcto.Visuals and QuestieOcto.Visuals.ApplyFullNode then
QuestieOcto.Visuals:ApplyFullNode(pin,pin.fullNodeNode,false,1)
end
end
self:ResizePin(pin)
if not pin:IsShown() then pin:Show() end
if kind=="exact" then
self.stats.exact=self.stats.exact+1
elseif kind=="itemStart" then
self.stats.itemStartAreas=self.stats.itemStartAreas+1
else
self.stats.objectiveAreas=self.stats.objectiveAreas+1
end
return pin
end
local function RefreshPinVisual(pin)
local wasFull=pin.fullNode and true or false
pin.visualPriority=nil
pin.role=nil
pin.questID=nil
pin.sourceID=nil
pin.iconScaleKey=nil
pin.fullNode=nil
local fullNode=nil
for _,entry in pairs(pin.entries or {}) do
if entry.node then
ApplyVisualRole(pin,entry.node)
if wasFull and (not fullNode or M:GetVisualPriority(entry.node)>M:GetVisualPriority(fullNode)) then
fullNode=entry.node
end
end
end
pin.fullNodeNode=fullNode
if fullNode and QuestieOcto.Visuals and QuestieOcto.Visuals.ApplyFullNode then
QuestieOcto.Visuals:ApplyFullNode(pin,fullNode,false,1)
end
M:ResizePin(pin)
end
function M:RemoveQuest(questID)
questID=tonumber(questID)
if not questID then return 0 end
local removed=0
for _,pin in pairs(self.activeFrames or {}) do
local changed=false
if pin.itemStartArea and tonumber(pin.itemStartArea.questID)==questID then
pin.itemStartArea=nil
pin.entries={}
changed=true
else
for key,entry in pairs(pin.entries or {}) do
if entry.node and tonumber(entry.node.questID)==questID then
pin.entries[key]=nil
removed=removed+1
changed=true
end
end
end
if changed then
if pin.itemStartArea or next(pin.entries or {}) then
RefreshPinVisual(pin)
else
if pin:IsShown() then
pin:Hide()
self.stats.hidden=self.stats.hidden+1
end
end
end
end
-- Questie 5.2.3/6.0.0 UnloadQuestFrames removes every frame belonging
-- to the quest, including both world-map and minimap copies.
if QuestieOcto.Minimap and QuestieOcto.Minimap.RemoveQuest then
removed=removed+QuestieOcto.Minimap:RemoveQuest(questID)
end
return removed
end
function M:HideAll()
for _,pin in pairs(self.activeFrames or {}) do
if pin:IsShown() then
pin:Hide()
self.stats.hidden=self.stats.hidden+1
end
end
self.activeFrames={}
self.buildActiveFrames=nil
self.stats.active=0
end
function M:GetDisplayedMapID()
return DisplayedMapID()
end
function M:GetNearbyQuestTooltipPins(pin,maxPixels)
local result={}
if not pin or not pin:IsShown() then return result end
maxPixels=tonumber(maxPixels) or 5
local width=WorldMapButton and WorldMapButton:GetWidth() or 0
local height=WorldMapButton and WorldMapButton:GetHeight() or 0
if width<=0 or height<=0 then
result[1]=pin
return result
end
local px=(tonumber(pin.x) or 0)*width/100+(tonumber(pin.offsetX) or 0)
local py=(tonumber(pin.y) or 0)*height/100+(tonumber(pin.offsetY) or 0)
local _,other
for _,other in pairs(self.activeFrames or {}) do
if other and other:IsShown() and not IsPermanentRole(other.role) and (other.itemStartArea or next(other.entries or {})) then
local ox=(tonumber(other.x) or 0)*width/100+(tonumber(other.offsetX) or 0)
local oy=(tonumber(other.y) or 0)*height/100+(tonumber(other.offsetY) or 0)
local dx=px-ox
local dy=py-oy
if dx*dx+dy*dy<=maxPixels*maxPixels then
result[table.getn(result)+1]=other
end
end
end
if table.getn(result)==0 then result[1]=pin end
table.sort(result,function(a,b)
local ay=tonumber(a.y) or 0
local by=tonumber(b.y) or 0
if ay==by then return (tonumber(a.x) or 0)<(tonumber(b.x) or 0) end
return ay<by
end)
return result
end
function M:SetMap(mapID)
mapID=tonumber(mapID)
if tonumber(self.mapID)==mapID then return end
self.mapID=mapID
self.generation=self.generation+1
self.syncing=false
self.resync=false
self.prune=false
self.renderedPreparedPlan=nil
self.syncPreparedPlan=nil
self:HideAll()
end
function M:RenderItemStartArea(area,generation,continentZoneMapID)
if not IsRoleEnabled("itemStart") then return end
local itemQuest=QuestieOcto.QuestModel:Get(area.questID)
if itemQuest and itemQuest.pvp and not DisplaySettings():Get("showPvPRelatedQuests") then return end
local itemEvent=itemQuest and itemQuest.eventID and QuestieOcto.EventAvailability and QuestieOcto.EventAvailability:IsPresentationEvent(itemQuest.eventID) or false
local itemPvP=itemQuest and itemQuest.pvp or false
local itemRepeatable=itemQuest and itemQuest.presentationRepeatable or false
local key="itemarea:"..tostring(area.key)
local pin=self.frames[key]
if not pin then
pin=CreateFrame("Button",nil,WorldMapButton)
pin:SetWidth(16)
pin:SetHeight(16)
pin:SetFrameLevel(WorldMapButton:GetFrameLevel()+8)
local tex=pin:CreateTexture(nil,"OVERLAY")
tex:SetAllPoints(pin)
pin.texture=tex
AttachWorldMapPinInput(pin)
self.frames[key]=pin
self.stats.created=self.stats.created+1
else
self.stats.reused=self.stats.reused+1
end
if pin.seenGeneration~=generation then
pin.seenGeneration=generation
if self.buildActiveFrames then table.insert(self.buildActiveFrames,pin) end
end
pin.itemStartArea=area
pin.entries={}
pin.continentZoneMapID=tonumber(continentZoneMapID)
pin.visualPriority=40
pin.role="itemStart"
pin.questID=area.questID
pin.event=itemEvent
pin.pvp=itemPvP
pin.repeatable=itemRepeatable
pin.iconScaleKey=nil
pin.sourceKind="area"
pin.displayName=area.displayName
pin.clusterCount=area.n
pin.texture:SetTexture(TextureForNode({role="itemStart",questID=area.questID,event=pin.event,pvp=pin.pvp,repeatable=pin.repeatable}))
pin.texture:SetDrawLayer("OVERLAY",5)
if QuestieOcto.Visuals then
QuestieOcto.Visuals:ApplyPin(pin,{role="itemStart",questID=area.questID,pvp=pin.pvp,repeatable=pin.repeatable},false,1)
end
self:ResizePin(pin)
UpdatePosition(pin,area.x,area.y,0,0)
if not pin:IsShown() then pin:Show() end
self.stats.itemStartAreaPins=self.stats.itemStartAreaPins+1
return pin
end
function M:RenderNode(node,generation)
if not self.mapID then return end
-- Clustered item-start sources are represented by geographic area pins.
-- Full Nodes intentionally renders their raw spawn coordinates.
if node.role=="itemStart" and DisplaySettings():Get("itemStartDensity")~="full" then return end
local radius=QuestieOcto.Clustering.objectiveRadius
local kind="objective"
if node.role=="itemStart" then
radius=QuestieOcto.Clustering.itemStartRadius
kind="itemStart"
end
if IsExactRole(node.role) then
local points=QuestieOcto.Clustering:PointsForNodeOnMap(node,self.mapID)
for _,p in pairs(points) do
local key="exact:"..tostring(node.sourceKind)..":"..tostring(node.sourceID)..":"..
string.format("%.2f",p.x)..":"..string.format("%.2f",p.y)
self:GetOrCreate(key,node,p.x,p.y,1,generation,"exact")
end
return
end
local points=QuestieOcto.Clustering:PointsForNodeOnMap(node,self.mapID)
local areas=QuestieOcto.Clustering:BuildAreas(points,radius)
for _,area in pairs(areas) do
local key="area:"..tostring(node.sourceKind)..":"..tostring(node.sourceID)..":"..tostring(area.key)
self:GetOrCreate(key,node,area.x,area.y,area.n,generation,kind)
end
end
local function ResetVisibleOffsets(generation,frames)
local groups={}
for _,pin in pairs(frames or {}) do
if pin:IsShown() and pin.seenGeneration==generation and pin.x and pin.y then
local key=string.format("%.2f:%.2f",tonumber(pin.x) or 0,tonumber(pin.y) or 0)
groups[key]=groups[key] or {}
table.insert(groups[key],pin)
end
end
local offsets={{0,0},{10,0},{-10,0},{0,10},{0,-10},{8,8},{-8,8},{8,-8},{-8,-8}}
for _,group in pairs(groups) do
table.sort(group,function(a,b)
local ap=IsPermanentRole(a.role) and 1 or 0
local bp=IsPermanentRole(b.role) and 1 or 0
if ap~=bp then return ap<bp end
if tostring(a.role)~=tostring(b.role) then return tostring(a.role)<tostring(b.role) end
return tonumber(a.sourceID or 0)<tonumber(b.sourceID or 0)
end)
for index,pin in ipairs(group) do
local off=offsets[math.mod(index-1,table.getn(offsets))+1]
UpdatePosition(pin,pin.x,pin.y,off[1],off[2])
end
end
end
function M:Finish(generation,doPrune)
if generation~=self.generation then return end
local nextActive=self.buildActiveFrames or {}
local seen={}
for _,pin in pairs(nextActive) do seen[pin]=true end
-- A completed sync is authoritative for the displayed map. Hide only frames
-- from the previous active set that are no longer used; never scan the entire
-- historical frame cache accumulated across zones.
for _,pin in pairs(self.activeFrames or {}) do
if not seen[pin] and pin:IsShown() then
pin:Hide()
self.stats.hidden=self.stats.hidden+1
end
end
self.activeFrames=nextActive
self.buildActiveFrames=nil
ResetVisibleOffsets(generation,self.activeFrames)
local active=0
local visibleAvailable=0
local visibleItemStart=0
local visibleObjective=0
local visibleTurnin=0
local multiEntryPins=0
for _,pin in pairs(self.activeFrames) do
if pin:IsShown() then
active=active+1
if pin.role=="available" then visibleAvailable=visibleAvailable+1
elseif pin.role=="itemStart" then visibleItemStart=visibleItemStart+1
elseif pin.role=="turnin" then visibleTurnin=visibleTurnin+1
else visibleObjective=visibleObjective+1 end
local entries=0
for _ in pairs(pin.entries or {}) do entries=entries+1 end
if entries>1 then multiEntryPins=multiEntryPins+1 end
end
end
self.stats.active=active
self.stats.visibleAvailable=visibleAvailable
self.stats.visibleItemStart=visibleItemStart
self.stats.visibleObjective=visibleObjective
self.stats.visibleTurnin=visibleTurnin
self.stats.multiEntryPins=multiEntryPins
self.stats.syncs=self.stats.syncs+1
self.renderedPreparedPlan=self.syncPreparedPlan
if self.syncNodeRevision~=nil then
self.renderedNodeRevision=self.syncNodeRevision
end
self.syncPreparedPlan=nil
self.syncNodeRevision=nil
self.syncing=false
if self.resync then
local p=self.prune
self.resync=false
self.prune=false
self:RequestSync(p)
end
end
function M:RenderPreparedDescriptor(desc,generation,renderItemStarts)
if desc.type=="itemStartArea" then
if renderItemStarts then M:RenderItemStartArea(desc.area,generation) end
return
end
if desc.type=="nodeSlot" then
for _,entry in pairs(desc.entries or {}) do
if entry.node and (renderItemStarts or entry.node.role~="itemStart") then
M:GetOrCreate(
desc.key,
entry.node,
desc.x,
desc.y,
entry.clusterCount or 1,
generation,
entry.kind or "objective"
)
end
end
return
end
-- Backward compatibility for a prepared map published by an older cache
-- during an in-session update/reload boundary.
if desc.type=="node" and desc.node
and (renderItemStarts or desc.node.role~="itemStart") then
M:GetOrCreate(desc.key,desc.node,desc.x,desc.y,desc.clusterCount or 1,generation,desc.kind or "objective")
end
end
local function IsContinentQuestRole(role)
return role=="available" or role=="turnin" or role=="itemStart"
end
local function ContinentPinKey(node,mapID,x,y)
if IsContinentQuestRole(node.role) then
return "continent:quest-source:"..tostring(node.sourceKind)..":"..tostring(node.sourceID)..":"..
tostring(mapID)..":"..string.format("%.2f",x)..":"..string.format("%.2f",y)
end
return "continent:"..tostring(node.role)..":"..tostring(node.sourceKind)..":"..tostring(node.sourceID)..":"..
tostring(mapID)..":"..string.format("%.2f",x)..":"..string.format("%.2f",y)
end
function M:RenderContinentNode(node,mapID,generation)
if not node or not IsRoleEnabled(node.role) or not IsPvPQuestNodeEnabled(node) then return 0 end
if QuestieOcto.QuestMenu and QuestieOcto.QuestMenu:IsNodeHidden(node) then return 0 end
if node.role=="itemStart" and QuestieOcto.ItemStartAreas:IsZoneWideRareChance(node.chance) then return 0 end
-- World Map Visibility toggles apply only to continent/world overviews.
-- Selected zone and city maps keep normal/special quest markers visible and
-- are controlled by Enable Available/Completed Quest Icons instead.
if IsContinentQuestRole(node.role) and not IsQuestMarkerNodeEnabled(node) then return 0 end
-- The continent overview intentionally shows only quest start/turn-in markers
-- plus Flight Masters. Objective/slay/full-node/cluster data remains zone-only.
if not IsContinentQuestRole(node.role) and node.role~="flightMaster" then return 0 end
local projection=QuestieOcto.ContinentProjection
if not projection then return 0 end
local points=QuestieOcto.Clustering:PointsForNodeOnMap(node,mapID)
if not points or table.getn(points)==0 then return 0 end
local rendered=0
if node.role=="itemStart" then
-- Item-start sources can have dozens of spawn points. On a continent map
-- they represent one available quest, so use a single centroid marker per
-- source/zone instead of turning the continent into an objective-node map.
local sx,sy,n=0,0,0
for _,point in pairs(points) do
local px,py=projection:Project(mapID,point.x,point.y)
if px and py then sx=sx+px; sy=sy+py; n=n+1 end
end
if n>0 then
local x,y=sx/n,sy/n
local pin=self:GetOrCreate(ContinentPinKey(node,mapID,x,y),node,x,y,1,generation,"exact")
if pin then pin.continentZoneMapID=mapID end
return 1
end
return 0
end
for _,point in pairs(points) do
local x,y=projection:Project(mapID,point.x,point.y)
if x and y then
local pin=self:GetOrCreate(ContinentPinKey(node,mapID,x,y),node,x,y,1,generation,"exact")
if pin then pin.continentZoneMapID=mapID end
rendered=rendered+1
end
end
return rendered
end
local function AddContinentRareItemStart(groups,node,mapID)
if not node or node.role~="itemStart" or not QuestieOcto.ItemStartAreas:IsZoneWideRareChance(node.chance) then return false end
-- Consume ultra-rare nodes even when their world-map category is disabled so
-- they do not fall back to the ordinary per-source continent renderer.
if not IsRoleEnabled(node.role) or not IsPvPQuestNodeEnabled(node) or not IsQuestMarkerNodeEnabled(node) then return true end
local projection=QuestieOcto.ContinentProjection
if not projection then return false end
local key=tostring(node.questID)..":"..tostring(node.itemID or 0)
local group=groups[key]
if not group then
group={
questID=node.questID,itemID=node.itemID,itemName=node.itemName,
sx=0,sy=0,n=0,sources={}
}
groups[key]=group
end
local points=QuestieOcto.Clustering:PointsForNodeOnMap(node,mapID)
for _,point in pairs(points or {}) do
local x,y=projection:Project(mapID,point.x,point.y)
if x and y then
group.sx=group.sx+x
group.sy=group.sy+y
group.n=group.n+1
local source=group.sources[node.sourceID]
if not source then
source={
id=node.sourceID,name=node.sourceName,count=0,chance=node.chance,
rank=node.sourceRank,respawnSeconds=node.respawnSeconds
}
group.sources[node.sourceID]=source
end
source.count=source.count+1
end
end
return true
end
local function RenderContinentRareItemStarts(groups,mapID,generation)
for _,group in pairs(groups or {}) do
if group.n and group.n>0 then
local sourceList={}
for _,source in pairs(group.sources or {}) do table.insert(sourceList,source) end
table.sort(sourceList,function(a,b)
if a.count==b.count then return tostring(a.name)<tostring(b.name) end
return a.count>b.count
end)
local first=sourceList[1]
local area={
x=group.sx/group.n,y=group.sy/group.n,n=group.n,
questID=group.questID,itemID=group.itemID,itemName=group.itemName,
sourceList=sourceList,zoneWideRare=true,
rareThreshold=QuestieOcto.ItemStartAreas.zoneWideRareThreshold,
displayName=first and first.name or "Rare item-start source",
key="continent:"..tostring(mapID)..":"..tostring(group.questID)..":"..tostring(group.itemID or 0)..":zone-rare"
}
M:RenderItemStartArea(area,generation,mapID)
end
end
end
function M:StartContinentSync(continentMapID,doPrune)
continentMapID=tonumber(continentMapID)
if continentMapID==nil or not QuestieOcto.ContinentProjection then return end
local contextKey=-1000-continentMapID
if tonumber(self.mapID)~=contextKey then self:SetMap(contextKey) end
if not QuestieOcto.Nodes.ready then self.syncing=false; return end
self.generation=self.generation+1
local generation=self.generation
self.syncing=true
self.syncPreparedPlan=nil
self.syncNodeRevision=QuestieOcto.Nodes.stateRevision or 0
self.buildActiveFrames={}
self.stats.reused=0
self.stats.exact=0
self.stats.objectiveAreas=0
self.stats.itemStartAreas=0
self.stats.inputNodes=0
local mapIDs=QuestieOcto.ContinentProjection:GetZoneMapIDs(continentMapID)
local mapPos=1
local nodePos=1
local nodes=nil
local rareGroups={}
local function step()
if generation~=M.generation then return end
local budget=96
while budget>0 and mapPos<=table.getn(mapIDs) do
if not nodes then
nodes=QuestieOcto.Nodes:GetMapNodes(mapIDs[mapPos]) or {}
nodePos=1
rareGroups={}
M.stats.inputNodes=M.stats.inputNodes+table.getn(nodes)
end
if nodePos<=table.getn(nodes) then
local node=nodes[nodePos]
if not AddContinentRareItemStart(rareGroups,node,mapIDs[mapPos]) then
M:RenderContinentNode(node,mapIDs[mapPos],generation)
end
nodePos=nodePos+1
budget=budget-1
else
RenderContinentRareItemStarts(rareGroups,mapIDs[mapPos],generation)
nodes=nil
rareGroups={}
mapPos=mapPos+1
end
end
if mapPos<=table.getn(mapIDs) then
QuestieOcto.Scheduler:Enqueue(step,"map-continent-render")
else
M:Finish(generation,doPrune)
end
end
QuestieOcto.Scheduler:Enqueue(step,"map-continent-render")
end
function M:StartSync(doPrune)
if not self.enabled or not WorldMapButton then return end
-- Quest icon visibility is separate from townsfolk/service markers.
-- Always build the map pass; IsRoleEnabled filters each semantic role.
local mapID=DisplayedMapID()
if not mapID then
local continentMapID=DisplayedContinentMapID()
if continentMapID~=nil then
self:StartContinentSync(continentMapID,doPrune)
return
end
self:SetMap(nil)
return
end
if not QuestieOcto.PreparedMap:Get(mapID) then
-- Any map the player actually opens becomes top priority immediately.
-- This works even before the global Nodes build has completed.
if QuestieOcto.ZoneBootstrap then
QuestieOcto.ZoneBootstrap:Request(mapID,0.01)
self.stats.mapPriorityRequests=(self.stats.mapPriorityRequests or 0)+1
end
if not QuestieOcto.Nodes.ready then
return
end
end
if tonumber(self.mapID)~=tonumber(mapID) then self:SetMap(mapID) end
self.generation=self.generation+1
local generation=self.generation
self.syncing=true
self.buildActiveFrames={}
self.stats.reused=0
self.stats.exact=0
self.stats.objectiveAreas=0
self.stats.itemStartAreas=0
local nodes=QuestieOcto.Nodes:GetMapNodes(mapID)
self.stats.inputNodes=table.getn(nodes)
self.stats.itemStartRawNodes=0
self.stats.itemStartAreaPins=0
for _,node in pairs(nodes) do
if node.role=="itemStart" then
self.stats.itemStartRawNodes=self.stats.itemStartRawNodes+1
end
end
local prepared=QuestieOcto.PreparedMap:Get(mapID)
self.syncPreparedPlan=prepared
if prepared then
self.stats.preparedHits=self.stats.preparedHits+1
self.stats.preparedDescriptors=table.getn(prepared)
-- Prepared descriptors contain no DB discovery or clustering work.
-- Typical zone maps can therefore appear in one rendering tick.
local worldItemStarts=QuestieOcto.PreparedMap:GetWorldItemStarts(mapID) or {}
local pos=1
local itemPos=1
local function preparedStep()
if generation~=M.generation then return end
local count=0
while pos<=table.getn(prepared) and count<128 do
M:RenderPreparedDescriptor(prepared[pos],generation,false)
pos=pos+1
count=count+1
end
while pos>table.getn(prepared) and itemPos<=table.getn(worldItemStarts) and count<128 do
M:RenderPreparedDescriptor(worldItemStarts[itemPos],generation,true)
itemPos=itemPos+1
count=count+1
end
if pos<=table.getn(prepared) or itemPos<=table.getn(worldItemStarts) then
QuestieOcto.Scheduler:Enqueue(preparedStep,"map-prepared-render")
else
M:Finish(generation,doPrune)
end
end
QuestieOcto.Scheduler:Enqueue(preparedStep,"map-prepared-render")
return
end
self.stats.preparedMisses=self.stats.preparedMisses+1
-- First visit before background preparation reached this zone:
-- prepare just this map, then render it on the next scheduler turn.
QuestieOcto.Scheduler:Enqueue(function()
if generation~=M.generation then return end
QuestieOcto.PreparedMap:BuildMap(mapID)
local ready=QuestieOcto.PreparedMap:Get(mapID)
M.syncPreparedPlan=ready
if not ready then
M.syncing=false
return
end
local worldItemStarts=QuestieOcto.PreparedMap:GetWorldItemStarts(mapID) or {}
local pos=1
local itemPos=1
local function fallbackPreparedStep()
if generation~=M.generation then return end
local count=0
while pos<=table.getn(ready) and count<128 do
M:RenderPreparedDescriptor(ready[pos],generation,false)
pos=pos+1
count=count+1
end
while pos>table.getn(ready) and itemPos<=table.getn(worldItemStarts) and count<128 do
M:RenderPreparedDescriptor(worldItemStarts[itemPos],generation,true)
itemPos=itemPos+1
count=count+1
end
if pos<=table.getn(ready) or itemPos<=table.getn(worldItemStarts) then
QuestieOcto.Scheduler:Enqueue(fallbackPreparedStep,"map-first-prepare-render")
else
M:Finish(generation,doPrune)
end
end
fallbackPreparedStep()
end,"map-first-prepare")
end
function M:RefreshVisualSettings()
for _,pin in pairs(self.activeFrames or {}) do
if pin.itemStartArea then
if QuestieOcto.Visuals then QuestieOcto.Visuals:ClearPin(pin,1) end
elseif pin.entries and next(pin.entries) then
pin.visualPriority=nil
pin.role=nil
pin.iconScaleKey=nil
RefreshPinVisual(pin)
if QuestieOcto.Visuals then QuestieOcto.Visuals:SetAlpha(pin,1) end
end
end
if QuestieOcto.Minimap and QuestieOcto.Minimap.RefreshVisualSettings then
QuestieOcto.Minimap:RefreshVisualSettings()
end
end
function M:RescaleIcons(changedKey,changedValue)
self.stats.rescalePasses=(self.stats.rescalePasses or 0)+1
-- Scaling is presentation-only. Do not rebuild a pin's semantic visual here:
-- doing so used to replace Full Nodes with the normal Questie objective
-- texture until the next map sync. The global slider now changes size only.
for _,pin in pairs(self.activeFrames or {}) do
self:ResizePin(pin)
end
end
function M:ApplySettings()
self:RescaleIcons()
self:RequestSync(true)
end
function M:OnSettingChanged(key,value)
if key=="enableMapIcons" or key=="showAllQuestsWorldMap" or key=="showSpecialQuestsWorldMap" or key=="showPvPRelatedQuests" or key=="enableObjectives" or key=="enableTurnins" or
key=="enableAvailable" or key=="showItemStartQuests" or key=="showItemStartMap" or
key=="showMapAuctioneer" or key=="showMapBanker" or
key=="showMapFlightMaster" or key=="showMapMailbox" or
key=="showMapBattlemaster" or key=="showMapInnkeeper" or key=="showMapMeetingStone" or
key=="showMapRepair" or key=="showMapSpiritHealer" or key=="showMapStableMaster" or key=="showMapVendor" or
key=="showMapRareMonsters" then
self:RequestSync(true)
end
end
function M:RequestSync(doPrune)
if self.syncing then
self.resync=true
if doPrune then self.prune=true end
return
end
QuestieOcto.Scheduler:After(0.01,function()
M:StartSync(doPrune and true or false)
end,"map-sync")
end
function M:PatchContinentQuests(changedQuests)
if not WorldMapFrame or not WorldMapFrame:IsVisible() then return false end
local continentMapID=DisplayedContinentMapID()
if continentMapID==nil or not QuestieOcto.ContinentProjection then return false end
-- If a full continent render is already in flight, let it finish against the
-- newest canonical Nodes snapshot rather than mutating its frame set midway.
if self.syncing then
self.resync=true
self.prune=true
return true
end
local changed={}
for questID in pairs(changedQuests or {}) do
questID=tonumber(questID)
if questID and questID>0 then changed[questID]=true end
end
if not next(changed) then return true end
local contextKey=-1000-tonumber(continentMapID)
if tonumber(self.mapID)~=contextKey then return false end
-- Remove only changed quest relationships from the currently visible pins,
-- but do not hide them yet. A quest can disappear and reappear on the same
-- physical source key during a filter change; deferring visibility decisions
-- until after additions prevents an off/on frame flash.
local touched={}
for _,pin in pairs(self.activeFrames or {}) do
if pin.itemStartArea and changed[tonumber(pin.itemStartArea.questID)] then
pin.itemStartArea=nil
touched[pin]=true
end
for key,entry in pairs(pin.entries or {}) do
if entry and entry.node and changed[tonumber(entry.node.questID)] then
pin.entries[key]=nil
touched[pin]=true
end
end
end
-- A pin hidden by an earlier incremental filter patch can be reused later in
-- this same continent generation. Reset only those empty hidden frames so a
-- stale visual priority cannot prevent a newly visible quest from owning it.
for _,pin in pairs(self.frames or {}) do
if pin.seenGeneration==self.generation and not pin:IsShown()
and not pin.itemStartArea and not next(pin.entries or {}) then
pin.visualPriority=nil
pin.role=nil
pin.questID=nil
pin.sourceID=nil
pin.event=nil
pin.pvp=nil
pin.repeatable=nil
pin.fullNode=nil
pin.fullNodeNode=nil
pin.iconScaleKey=nil
end
end
-- Add only the new semantic state for the changed quests. Unrelated continent
-- icons never get rebound, cleared or recreated when Low-Level Quest range
-- changes, so they remain visually stable throughout the update.
local mapIDs=QuestieOcto.ContinentProjection:GetZoneMapIDs(continentMapID)
for _,mapID in ipairs(mapIDs or {}) do
local rareGroups={}
for _,node in pairs(QuestieOcto.Nodes:GetMapNodes(mapID) or {}) do
if changed[tonumber(node.questID)] then
if not AddContinentRareItemStart(rareGroups,node,mapID) then
self:RenderContinentNode(node,mapID,self.generation)
end
end
end
RenderContinentRareItemStarts(rareGroups,mapID,self.generation)
end
-- Re-evaluate only pins that lost old relationships. AddEntry already handles
-- newly added relationships, but a removed high-priority entry can otherwise
-- leave its old visual owner cached on a shared coordinate.
for pin in pairs(touched) do
if pin.itemStartArea then
-- RenderItemStartArea already refreshed this pin's complete visual state.
elseif next(pin.entries or {}) then
RefreshPinVisual(pin)
else
if pin:IsShown() then
pin:Hide()
self.stats.hidden=self.stats.hidden+1
end
end
end
-- Rebuild the small active-frame index from already-bound frames. Frames from
-- older map contexts have a different generation and stay excluded.
local active={}
for _,pin in pairs(self.frames or {}) do
if pin.seenGeneration==self.generation and (pin.itemStartArea or next(pin.entries or {})) then
if not pin:IsShown() then pin:Show() end
table.insert(active,pin)
end
end
self.activeFrames=active
ResetVisibleOffsets(self.generation,self.activeFrames)
local visibleAvailable,visibleItemStart,visibleObjective,visibleTurnin=0,0,0,0
for _,pin in pairs(self.activeFrames) do
if pin.role=="available" then visibleAvailable=visibleAvailable+1
elseif pin.role=="itemStart" then visibleItemStart=visibleItemStart+1
elseif pin.role=="turnin" then visibleTurnin=visibleTurnin+1
else visibleObjective=visibleObjective+1 end
end
self.stats.active=table.getn(self.activeFrames)
self.stats.visibleAvailable=visibleAvailable
self.stats.visibleItemStart=visibleItemStart
self.stats.visibleObjective=visibleObjective
self.stats.visibleTurnin=visibleTurnin
self.stats.incrementalContinentPatches=(self.stats.incrementalContinentPatches or 0)+1
self.renderedNodeRevision=QuestieOcto.Nodes.stateRevision or self.renderedNodeRevision
return true
end
function M:EnsureDisplayedContextCurrent()
if not WorldMapFrame or not WorldMapFrame:IsVisible() then return end
local contextKey=DisplayedContextKey()
if tonumber(contextKey)~=tonumber(self.mapID) then
self:SetMap(contextKey)
self:RequestSync(false)
return
end
local mapID=DisplayedMapID()
if mapID then
local prepared=QuestieOcto.PreparedMap:Get(mapID)
-- PREPARED_MAP_READY can fire while the World Map is hidden. Comparing the
-- actual published plan makes reopening the SAME zone self-healing even
-- when WORLD_MAP_UPDATE does not change the map context. Do not queue a
-- duplicate redraw if this exact plan is already the one being rendered.
if prepared and prepared~=self.renderedPreparedPlan and prepared~=self.syncPreparedPlan then
self:RequestSync(true)
end
elseif DisplayedContinentMapID()~=nil then
-- NODES_CHANGED is intentionally ignored while the World Map is hidden.
-- Remember the canonical Nodes revision rendered by the continent view so
-- reopening the SAME continent after a quest completes self-heals just like
-- selected zone maps already do through PreparedMap identity.
local revision=QuestieOcto.Nodes and QuestieOcto.Nodes.stateRevision or 0
if tonumber(self.renderedNodeRevision or 0)~=tonumber(revision) then
self:RequestSync(true)
end
end
end
function M:OnNodesChanged(mapSet,changedQuests)
if not WorldMapFrame or not WorldMapFrame:IsVisible() then return end
-- Zone maps consume PreparedMap's transactional replacement and refresh on
-- PREPARED_MAP_READY. Continent maps do not use PreparedMap, so patch their
-- changed quest relationships directly instead of starting a full re-render.
if DisplayedMapID() then return end
if DisplayedContinentMapID()~=nil then
if not self:PatchContinentQuests(changedQuests) then self:RequestSync(true) end
end
end
function M:OnNodesReady()
-- Full node publications still drive continent maps directly. Selected zone
-- maps must wait for PREPARED_MAP_READY; syncing here would consume the old
-- prepared plan once and then the replacement plan moments later, producing
-- an unnecessary visible rebind.
if WorldMapFrame and WorldMapFrame:IsVisible()
and not DisplayedMapID() and DisplayedContinentMapID()~=nil then
self:RequestSync(true)
end
end
QuestieOcto:RegisterMessage("NODES_CHANGED",M,"OnNodesChanged")
QuestieOcto:RegisterMessage("NODES_READY",M,"OnNodesReady")
function M:OnPreparedMapReady(mapID)
if WorldMapFrame and WorldMapFrame:IsVisible()
and tonumber(mapID)==tonumber(DisplayedMapID()) then
self:RequestSync(true)
end
end
QuestieOcto:RegisterMessage("PREPARED_MAP_READY",M,"OnPreparedMapReady")
local f=CreateFrame("Frame","QuestieOctoWorldMapEvents",UIParent)
f:RegisterEvent("WORLD_MAP_UPDATE")
f:RegisterEvent("PLAYER_LEVEL_UP")
f:SetScript("OnEvent",function()
if event=="WORLD_MAP_UPDATE" then
if WorldMapFrame and WorldMapFrame:IsVisible() then
M:EnsureDisplayedContextCurrent()
end
elseif event=="PLAYER_LEVEL_UP" then
-- Gray classification depends only on the current player/quest levels.
-- Rebind the visible map presentation; do not rebuild geometry or quest truth.
QuestieOcto.Scheduler:After(0.01,function()
if WorldMapFrame and WorldMapFrame:IsVisible() then M:RequestSync(true) end
end,"map-gray-level-refresh")
end
end)
-- WORLD_MAP_UPDATE is not guaranteed to change context when the map is reopened
-- after a density change made while hidden. Revalidate the published prepared
-- plan on every show as a second, deterministic refresh boundary.
if WorldMapFrame and not M.worldMapShowHooked then
M.worldMapShowHooked=true
local function OnWorldMapShow()
QuestieOcto.Scheduler:After(0.01,function()
M:EnsureDisplayedContextCurrent()
end,"map-show-density-sync")
end
-- HookScript is additive: UI replacements can keep their own OnShow handler
-- without taking ownership away from Questie-Octo (and vice versa). Keep a
-- forwarding SetScript fallback for older clients that do not expose it.
if WorldMapFrame.HookScript then
WorldMapFrame:HookScript("OnShow",OnWorldMapShow)
else
local previousOnShow=WorldMapFrame:GetScript("OnShow")
WorldMapFrame:SetScript("OnShow",function()
if previousOnShow then previousOnShow() end
OnWorldMapShow()
end)
end
end