QuestieOcto.PreparedMap = QuestieOcto.PreparedMap or {} local P = QuestieOcto.PreparedMap P.cache={} P.readyMaps={} P.cacheRevision={} P.worldItemStartCache={} P.worldItemStartRevision={} P.cacheDensity={} P.stateRevision=1 P.running=false P.generation=0 P.dirtyGeneration=0 P.stats={preparedMaps=0,descriptors=0,currentMap=nil,currentReady=false,stateRevision=1,revisionBumps=0,densitySignature="unknown"} local function CurrentDensitySignature() local settings=QuestieOcto.MinimapSettings if not settings or not settings.Get then return "clustered:clustered" end return tostring(settings:Get("objectiveNodeDensity") or "clustered")..":".. tostring(settings:Get("itemStartDensity") or "clustered") end function P:GetDensitySignature(mapID) mapID=tonumber(mapID) if not mapID then return nil end return self.cacheDensity[mapID] end function P:GetCurrentDensitySignature() return CurrentDensitySignature() end local function ExactRole(role) return role=="available" or role=="turnin" or role=="objectiveArea" or 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=="classTrainer" or role=="professionTrainer" or role=="rareMob" end local function DescriptorKey(node,x,y) -- Questie 5/6/3.3.5 places available and complete frames on the same source -- coordinate, with the complete texture one draw level above available. -- pfQuest likewise resolves coincident quest nodes by visual layer. Collapse -- those two semantic entries into one prepared pin so the tooltip retains -- both quests while Questie-Octo's turn-in priority selects the complete icon. if node.role=="available" or node.role=="turnin" then return "exact:quest-source:"..tostring(node.sourceKind)..":"..tostring(node.sourceID)..":".. string.format("%.2f",x)..":"..string.format("%.2f",y) end return "exact:"..tostring(node.questID)..":"..tostring(node.role)..":".. tostring(node.sourceKind)..":"..tostring(node.sourceID)..":".. string.format("%.2f",x)..":"..string.format("%.2f",y) end local function AreaKey(node,area) return "area:"..tostring(node.sourceKind)..":"..tostring(node.sourceID)..":".. tostring(area.key) end local function FullPointKey(prefix,node,x,y) -- Full Nodes means every UNIQUE spawn coordinate. Multiple active quests or -- item objectives can reference the exact same spawn; pfQuest stores those -- references in one coordinate slot and combines their tooltip metadata. -- Keep item-start and objective namespaces compatible by sharing the same -- quest-coordinate key so one physical spawn never allocates duplicate pins. return "full:quest:"..string.format("%.2f",x)..":"..string.format("%.2f",y) end local function AddNormal(plan,slots,node,x,y,clusterCount,kind,key) local slot=slots[key] if not slot then slot={ type="nodeSlot", x=x, y=y, key=key, coordKey=string.format("%.2f:%.2f",x,y), entries={} } slots[key]=slot table.insert(plan,slot) end table.insert(slot.entries,{ node=node, clusterCount=clusterCount or 1, kind=kind }) end function P:BuildPlanFromNodes(mapID,nodes) mapID=tonumber(mapID) if not mapID then return nil end local plan={} local slots={} for _,node in pairs(nodes or {}) do if node.role~="itemStart" then if ExactRole(node.role) then local points=QuestieOcto.Clustering:PointsForNodeOnMap(node,mapID) for _,point in pairs(points) do AddNormal( plan,slots,node,point.x,point.y,1,"exact", DescriptorKey(node,point.x,point.y) ) end else local points=QuestieOcto.Clustering:PointsForNodeOnMap(node,mapID) if QuestieOcto.MinimapSettings:Get("objectiveNodeDensity")=="full" then for _,point in pairs(points) do AddNormal( plan,slots,node,point.x,point.y,1,"objectiveFull", FullPointKey("objective-full",node,point.x,point.y) ) end else local areas=QuestieOcto.Clustering:BuildAreas( points,QuestieOcto.Clustering.objectiveRadius ) for _,area in pairs(areas) do AddNormal(plan,slots,node,area.x,area.y,area.n,"objective",AreaKey(node,area)) end end end end end if QuestieOcto.MinimapSettings:Get("itemStartDensity")=="full" then for _,node in pairs(nodes or {}) do if node.role=="itemStart" then local points=QuestieOcto.Clustering:PointsForNodeOnMap(node,mapID) for _,point in pairs(points) do AddNormal( plan,slots,node,point.x,point.y,1,"itemStartFull", FullPointKey("itemstart-full",node,point.x,point.y) ) end end end else local itemAreas=QuestieOcto.ItemStartAreas:BuildForMap(nodes or {},mapID) for _,area in pairs(itemAreas) do table.insert(plan,{ type="itemStartArea", area=area, key="itemarea:"..tostring(area.key) }) end end table.sort(plan,function(a,b) return tostring(a.key) Full Nodes changes are immediate on each -- presentation instead of waiting for the displayed World Map zone to be -- reached by the background PrepareAll pass. local displayed=nil if QuestieOcto.Map then if WorldMapFrame and WorldMapFrame:IsVisible() and QuestieOcto.Map.GetDisplayedMapID then displayed=QuestieOcto.Map:GetDisplayedMapID() elseif tonumber(QuestieOcto.Map.mapID) and tonumber(QuestieOcto.Map.mapID)>0 then -- The options window can be used while the World Map is hidden. Preserve -- the last selected zone as a priority target so reopening that same zone -- never sees the previous density plan. displayed=tonumber(QuestieOcto.Map.mapID) end end local mapSet={} for mapID in pairs(QuestieOcto.Nodes.byMap or {}) do mapSet[tonumber(mapID)]=true end -- Include formerly populated maps so a rebuild that legitimately removes -- every node from a map publishes an empty plan rather than leaving stale pins. for mapID in pairs(self.readyMaps or {}) do mapSet[tonumber(mapID)]=true end local ids={} for mapID in pairs(mapSet) do if mapID then table.insert(ids,mapID) end end table.sort(ids) local function publishMap(mapID) if generation~=P.generation then return false end local nodes=QuestieOcto.Nodes:GetMapNodes(mapID) local plan=P:BuildPlanFromNodes(mapID,nodes) or {} local worldItemStartPlan=P:BuildWorldItemStartPlanFromNodes(mapID,nodes) or {} P:SetPreparedMap(mapID,plan,worldItemStartPlan,densitySignature) return true end -- Publish the open World Map zone first, then the player's current zone for -- the minimap. Either may be the same map. SetPreparedMap broadcasts -- PREPARED_MAP_READY, so both renderers immediately consume the replacement. if displayed and mapSet[tonumber(displayed)] then publishMap(tonumber(displayed)) end if current and tonumber(current)~=tonumber(displayed) and mapSet[tonumber(current)] then publishMap(tonumber(current)) end local pos=1 local function step() if generation~=P.generation then return end local requested=QuestieOcto.ZoneBootstrap and QuestieOcto.ZoneBootstrap.requestedMapID if requested and not P.readyMaps[tonumber(requested)] and QuestieOcto.ZoneBootstrap.running then QuestieOcto.Scheduler:Enqueue(step,"prepare-maps-yield") return end local count=0 while pos<=table.getn(ids) and count<1 do local mapID=ids[pos] pos=pos+1 if tonumber(mapID)~=tonumber(current) and tonumber(mapID)~=tonumber(displayed) then publishMap(mapID) count=count+1 end end if pos<=table.getn(ids) then QuestieOcto.Scheduler:Enqueue(step,"prepare-maps") else P.running=false QuestieOcto:SendMessage("PREPARED_MAPS_COMPLETE") end end QuestieOcto.Scheduler:Enqueue(step,"prepare-maps") end function P:OnNodesReady() self:PrepareAll("nodes-ready") end QuestieOcto:RegisterMessage("NODES_READY",P,"OnNodesReady") local function DescriptorKeyValue(desc) return desc and tostring(desc.key or "") or "" end local function ClonePreparedDescriptor(desc) if not desc then return nil end if desc.type~="nodeSlot" then return desc end -- PatchMaps removes quest entries from a descriptor. Clone the slot first so -- the currently published PreparedMap (which the minimap may still be using) -- is never mutated before its replacement is complete and published. local copy={} for key,value in pairs(desc) do if key~="entries" then copy[key]=value end end copy.entries={} for _,entry in pairs(desc.entries or {}) do table.insert(copy.entries,entry) end return copy end local function MergePreparedDescriptor(byKey,plan,desc) if not desc then return end local key=DescriptorKeyValue(desc) local existing=byKey[key] if existing and existing.type=="nodeSlot" and desc.type=="nodeSlot" then for _,entry in pairs(desc.entries or {}) do table.insert(existing.entries,entry) end return end -- Item-start areas are quest-specific and exact/full node-slot keys are -- canonical. A same-key non-slot descriptor is safely replaced. if existing then for i,current in ipairs(plan) do if current==existing then plan[i]=desc; break end end byKey[key]=desc return end byKey[key]=desc table.insert(plan,desc) end function P:PatchMaps(mapSet,changedQuests) if not QuestieOcto.Nodes.ready then return 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 -- Compatibility/fallback for an older producer that did not provide the -- reverse quest set: rebuilding just the affected maps is still bounded. return self:RebuildMaps(mapSet) end local ids={} for mapID in pairs(mapSet or {}) do mapID=tonumber(mapID) if mapID then table.insert(ids,mapID) end end table.sort(ids) if table.getn(ids)==0 then return end -- Incremental node publications may arrive close together (for example two -- quests completing in adjacent frames). They all read the latest canonical -- Nodes state and are safe to interleave. Do NOT advance dirtyGeneration here: -- doing so cancelled the older patch outright and could leave one quest's map -- state stale until an unrelated option forced a rebuild. Full PrepareAll / -- Invalidate still advance dirtyGeneration and therefore remain authoritative. local generation=self.dirtyGeneration local pos=1 local function step() if generation~=P.dirtyGeneration then return end local mapID=ids[pos] pos=pos+1 if mapID then local existing=P:Get(mapID) if not existing then -- First visit / stale cache: build the authoritative map once. P:BuildMap(mapID) else -- pfQuest-style reverse update: remove only the changed quests' metadata -- from existing coordinate slots, then merge descriptors produced by -- those quests' new semantic state. Unrelated coordinates/clusters are -- never rediscovered or reclustered. local plan={} local byKey={} for _,desc in pairs(existing) do local candidate=ClonePreparedDescriptor(desc) local drop=false for questID in pairs(changed) do local remove=RemoveQuestFromDescriptor(candidate,questID) if remove then drop=true end end if not drop then table.insert(plan,candidate) byKey[DescriptorKeyValue(candidate)]=candidate end end local changedNodes={} for _,node in pairs(QuestieOcto.Nodes:GetMapNodes(mapID) or {}) do if changed[tonumber(node.questID)] then table.insert(changedNodes,node) end end local delta=P:BuildPlanFromNodes(mapID,changedNodes) or {} for _,desc in pairs(delta) do MergePreparedDescriptor(byKey,plan,desc) end table.sort(plan,function(a,b) return DescriptorKeyValue(a)