QuestieOcto.Nodes = QuestieOcto.Nodes or {} local N = QuestieOcto.Nodes N.ready=false N.running=false N.generation=0 N.nodes={} N.byMap={} N.questMaps={} N.nodeSequence=0 N.stateRevision=0 N.stats={ total=0, availableCreature=0, availableObject=0, itemStart=0, objectiveCreature=0, objectiveObject=0, objectiveItemSource=0, turnin=0, flightMaster=0, auctioneer=0, banker=0, mailbox=0, battlemaster=0, innkeeper=0, meetingStone=0, repair=0, spiritHealer=0, stableMaster=0, vendor=0, rareMob=0, } local function NewStats() return { total=0, availableCreature=0, availableObject=0, itemStart=0, objectiveCreature=0, objectiveObject=0, objectiveItemSource=0, turnin=0, flightMaster=0, auctioneer=0, banker=0, mailbox=0, battlemaster=0, innkeeper=0, meetingStone=0, repair=0, spiritHealer=0, stableMaster=0, vendor=0, rareMob=0, } end local function ResetStats() N.stats=NewStats() end local function CurrentStats() return N.buildStats or N.stats end local function CurrentNodes() return N.buildNodes or N.nodes end local function CurrentByMap() return N.buildByMap or N.byMap end local function CurrentQuestMaps() return N.buildQuestMaps or N.questMaps end local function ApplyIconScaleKey(node) return node end local function IsPresentationEvent(q) if not q or not q.eventID or not QuestieOcto.EventAvailability then return false end if QuestieOcto.EventAvailability.IsPresentationEventForQuest then return QuestieOcto.EventAvailability:IsPresentationEventForQuest(q) and true or false end return QuestieOcto.EventAvailability:IsPresentationEvent(q.eventID) and true or false end local function AddNode(node) node=ApplyIconScaleKey(node) CurrentStats().total=CurrentStats().total+1 N.nodeSequence=(N.nodeSequence or 0)+1 node.nodeID=N.nodeSequence table.insert(CurrentNodes(),node) local coords=node.coords if coords then -- A canonical node belongs to a map once, even if the source has dozens -- of spawn coordinates on that map. Clustering handles those coordinates -- later. 0.1.5 incorrectly inserted the same node once per coordinate, -- causing hundreds of duplicate render work items. local seenMaps={} for _,coord in pairs(coords) do if type(coord)=="table" and tonumber(coord[3]) then local mapID=tonumber(coord[3]) if not seenMaps[mapID] then seenMaps[mapID]=true local byMap=CurrentByMap() byMap[mapID]=byMap[mapID] or {} table.insert(byMap[mapID],node) if tonumber(node.questID) and tonumber(node.questID)>0 then local questMaps=CurrentQuestMaps() questMaps[node.questID]=questMaps[node.questID] or {} questMaps[node.questID][mapID]=true end end end end end end local function ApplyObjectiveState(node,state) if state then node.objectiveIndex=state.objectiveIndex node.objectiveText=state.objectiveText node.objectiveType=state.objectiveType node.current=state.current node.required=state.required node.objectiveComplete=state.complete and true or false end return node end local function AddCreatureNode(questID,role,creatureID,itemID,chance,objectiveState,vendor) local q=QuestieOcto.QuestModel:Get(questID) local node={ questID=questID,role=role,event=IsPresentationEvent(q),eventID=q and q.eventID or nil,pvp=q and q.pvp or false,repeatable=q and q.repeatable or false,sourceKind="creature",sourceID=creatureID, sourceName=QuestieOcto.DatabaseAPI:GetCreatureName(creatureID), sourceRank=QuestieOcto.DatabaseAPI:GetCreatureRank(creatureID), respawnSeconds=QuestieOcto.DatabaseAPI:GetCreatureRespawnSeconds(creatureID), itemID=itemID,itemName=itemID and QuestieOcto.DatabaseAPI:GetItemName(itemID) or nil, chance=chance,vendor=vendor and true or false,coords=QuestieOcto.DatabaseAPI:GetCreatureCoords(creatureID) } AddNode(ApplyObjectiveState(node,objectiveState)) end local function AddObjectNode(questID,role,objectID,itemID,chance,objectiveState) local q=QuestieOcto.QuestModel:Get(questID) local node={ questID=questID,role=role,event=IsPresentationEvent(q),eventID=q and q.eventID or nil,pvp=q and q.pvp or false,repeatable=q and q.repeatable or false,sourceKind="gameObject",sourceID=objectID, sourceName=QuestieOcto.DatabaseAPI:GetObjectName(objectID), itemID=itemID,itemName=itemID and QuestieOcto.DatabaseAPI:GetItemName(itemID) or nil, chance=chance,coords=QuestieOcto.DatabaseAPI:GetObjectCoords(objectID) } AddNode(ApplyObjectiveState(node,objectiveState)) end local function BuildAvailableQuestNodes(questID) local q=QuestieOcto.QuestModel:Get(questID) if not q then return end if q.starts.creature then for _,id in pairs(q.starts.creature) do AddCreatureNode(questID,"available",id,nil,nil) CurrentStats().availableCreature=CurrentStats().availableCreature+1 end end if q.starts.gameObject then for _,id in pairs(q.starts.gameObject) do AddObjectNode(questID,"available",id,nil,nil) CurrentStats().availableObject=CurrentStats().availableObject+1 end end end local function BuildItemStartQuestNodes(questID,resolved,availableSet) -- ItemStarts is a derived cache; the published AvailableQuests snapshot is -- the authoritative visibility gate. Never let an older item-start cache -- bypass completion/event/repeatable filtering during an async refresh. if not availableSet[questID] or not resolved then return end for _,item in pairs(resolved.items or {}) do for _,src in pairs(item.creatureSources or {}) do AddCreatureNode(questID,"itemStart",src.id,item.itemID,src.chance,nil,src.vendor) CurrentStats().itemStart=CurrentStats().itemStart+1 end for _,src in pairs(item.objectSources or {}) do AddObjectNode(questID,"itemStart",src.id,item.itemID,src.chance) CurrentStats().itemStart=CurrentStats().itemStart+1 end end end local function PlayerFactionCode() local playerFaction=UnitFactionGroup and UnitFactionGroup("player") or nil if playerFaction=="Alliance" then return "A" end if playerFaction=="Horde" then return "H" end return nil end local function FactionAllows(allowed,factionCode) return type(allowed)=="string" and factionCode and string.find(allowed,factionCode,1,true) and true or false end local function TrackingMeta(metaKey) return QuestieOcto.DatabaseAPI and QuestieOcto.DatabaseAPI.GetTrackingMeta and QuestieOcto.DatabaseAPI:GetTrackingMeta(metaKey) or nil end local function BuildServiceCreatureNodes(metaKey,role,statKey) local list=TrackingMeta(metaKey) if not list then return end local factionCode=PlayerFactionCode() if not factionCode then return end for creatureID,allowed in pairs(list) do creatureID=tonumber(creatureID) if creatureID and creatureID>0 and FactionAllows(allowed,factionCode) then local coords=QuestieOcto.DatabaseAPI:GetCreatureCoords(creatureID) if coords and next(coords) then AddNode({ questID=0, role=role, sourceKind="creature", sourceID=creatureID, sourceName=QuestieOcto.DatabaseAPI:GetCreatureName(creatureID), coords=coords, serviceFaction=allowed }) CurrentStats()[statKey]=(CurrentStats()[statKey] or 0)+1 end end end end local function BuildServiceObjectNodes(metaKey,role,statKey) local list=TrackingMeta(metaKey) if not list then return end local factionCode=PlayerFactionCode() if not factionCode then return end for signedObjectID,allowed in pairs(list) do local objectID=math.abs(tonumber(signedObjectID) or 0) if objectID>0 and FactionAllows(allowed,factionCode) then local coords=QuestieOcto.DatabaseAPI:GetObjectCoords(objectID) if coords and next(coords) then AddNode({ questID=0, role=role, sourceKind="gameObject", sourceID=objectID, sourceName=QuestieOcto.DatabaseAPI:GetObjectName(objectID), coords=coords, serviceFaction=allowed }) CurrentStats()[statKey]=(CurrentStats()[statKey] or 0)+1 end end end end local function BuildMailboxNodes() BuildServiceObjectNodes("mailbox","mailbox","mailbox") end local optionalServiceSettings={ battlemaster={"showMapBattlemaster","showMinimapBattlemaster"}, innkeeper={"showMapInnkeeper","showMinimapInnkeeper"}, meetingStone={"showMapMeetingStone","showMinimapMeetingStone"}, repair={"showMapRepair","showMinimapRepair"}, spiritHealer={"showMapSpiritHealer","showMinimapSpiritHealer"}, stableMaster={"showMapStableMaster","showMinimapStableMaster"}, vendor={"showMapVendor","showMinimapVendor"}, } local function OptionalServiceEnabled(role) local keys=optionalServiceSettings[role] if not keys or not QuestieOcto.MinimapSettings then return true end return QuestieOcto.MinimapSettings:Get(keys[1]) or QuestieOcto.MinimapSettings:Get(keys[2]) end local function BuildOptionalServiceCreatureNodes(metaKey,role,statKey) if OptionalServiceEnabled(role) then BuildServiceCreatureNodes(metaKey,role,statKey) end end local function BuildOptionalServiceObjectNodes(metaKey,role,statKey) if OptionalServiceEnabled(role) then BuildServiceObjectNodes(metaKey,role,statKey) end end local function ProcessServiceCreatureSlice(metaKey,role,statKey,cursor,limit) local list=TrackingMeta(metaKey) if not list then return nil,true end local factionCode=PlayerFactionCode() if not factionCode then return nil,true end local count=0 while count<(limit or 128) do local rawID,allowed=next(list,cursor) if rawID==nil then return nil,true end cursor=rawID local creatureID=tonumber(rawID) if creatureID and creatureID>0 and FactionAllows(allowed,factionCode) then local coords=QuestieOcto.DatabaseAPI:GetCreatureCoords(creatureID) if coords and next(coords) then AddNode({ questID=0, role=role, sourceKind="creature", sourceID=creatureID, sourceName=QuestieOcto.DatabaseAPI:GetCreatureName(creatureID), coords=coords, serviceFaction=allowed }) CurrentStats()[statKey]=(CurrentStats()[statKey] or 0)+1 end end count=count+1 end return cursor,false end local function ProcessServiceObjectSlice(metaKey,role,statKey,cursor,limit) local list=TrackingMeta(metaKey) if not list then return nil,true end local factionCode=PlayerFactionCode() if not factionCode then return nil,true end local count=0 while count<(limit or 128) do local rawID,allowed=next(list,cursor) if rawID==nil then return nil,true end cursor=rawID local objectID=math.abs(tonumber(rawID) or 0) if objectID>0 and FactionAllows(allowed,factionCode) then local coords=QuestieOcto.DatabaseAPI:GetObjectCoords(objectID) if coords and next(coords) then AddNode({ questID=0, role=role, sourceKind="gameObject", sourceID=objectID, sourceName=QuestieOcto.DatabaseAPI:GetObjectName(objectID), coords=coords, serviceFaction=allowed }) CurrentStats()[statKey]=(CurrentStats()[statKey] or 0)+1 end end count=count+1 end return cursor,false end local function ProcessRareMobSlice(cursor,limit) local list=TrackingMeta("rares") if not list then return nil,true end local count=0 while count<(limit or 128) do local rawID,rareLevel=next(list,cursor) if rawID==nil then return nil,true end cursor=rawID local creatureID=tonumber(rawID) if creatureID and creatureID>0 then local coords=QuestieOcto.DatabaseAPI:GetCreatureCoords(creatureID) if coords and next(coords) then AddNode({ questID=0, role="rareMob", sourceKind="creature", sourceID=creatureID, sourceName=QuestieOcto.DatabaseAPI:GetCreatureName(creatureID), sourceRank=QuestieOcto.DatabaseAPI:GetCreatureRank(creatureID), respawnSeconds=QuestieOcto.DatabaseAPI:GetCreatureRespawnSeconds(creatureID), rareLevel=tonumber(rareLevel), coords=coords }) CurrentStats().rareMob=CurrentStats().rareMob+1 end end count=count+1 end return cursor,false end local function BuildRareMobNodes() local list=TrackingMeta("rares") if not list then return end for creatureID,rareLevel in pairs(list) do creatureID=tonumber(creatureID) if creatureID and creatureID>0 then local coords=QuestieOcto.DatabaseAPI:GetCreatureCoords(creatureID) if coords and next(coords) then AddNode({ questID=0, role="rareMob", sourceKind="creature", sourceID=creatureID, sourceName=QuestieOcto.DatabaseAPI:GetCreatureName(creatureID), sourceRank=QuestieOcto.DatabaseAPI:GetCreatureRank(creatureID), respawnSeconds=QuestieOcto.DatabaseAPI:GetCreatureRespawnSeconds(creatureID), rareLevel=tonumber(rareLevel), coords=coords }) CurrentStats().rareMob=CurrentStats().rareMob+1 end end end end local function BuildPermanentMapNodes() -- Questie 3.3.5/7/8 model Auctioneer, Banker and Flight Master as -- townsfolk map categories. pfQuest supplies the Vanilla/Turtle-compatible -- faction lists, mailbox object IDs, rare-mob list and spawn coordinates. BuildServiceCreatureNodes("flight","flightMaster","flightMaster") BuildServiceCreatureNodes("auctioneer","auctioneer","auctioneer") BuildServiceCreatureNodes("banker","banker","banker") BuildMailboxNodes() BuildOptionalServiceCreatureNodes("battlemaster","battlemaster","battlemaster") BuildOptionalServiceCreatureNodes("innkeeper","innkeeper","innkeeper") BuildOptionalServiceObjectNodes("meetingstone","meetingStone","meetingStone") BuildOptionalServiceCreatureNodes("repair","repair","repair") BuildOptionalServiceCreatureNodes("spirithealer","spiritHealer","spiritHealer") BuildOptionalServiceCreatureNodes("stablemaster","stableMaster","stableMaster") BuildOptionalServiceCreatureNodes("vendor","vendor","vendor") BuildRareMobNodes() end local function BuildActiveQuestNodes(questID) local state=QuestieOcto.QuestLog.active[questID] local q=QuestieOcto.QuestModel:Get(questID) if not state or not q then return end if state.complete then for _,id in pairs(q.finishes.creature or {}) do AddCreatureNode(questID,"turnin",id,nil,nil) CurrentStats().turnin=CurrentStats().turnin+1 end for _,id in pairs(q.finishes.gameObject or {}) do AddObjectNode(questID,"turnin",id,nil,nil) CurrentStats().turnin=CurrentStats().turnin+1 end return end local resolved=QuestieOcto.Objectives.byQuest[questID] if not resolved then return end for _,src in pairs(resolved.creature or {}) do if not src.complete then AddCreatureNode(questID,"objectiveCreature",src.id,src.itemID,nil,src) CurrentStats().objectiveCreature=CurrentStats().objectiveCreature+1 end end for _,src in pairs(resolved.gameObject or {}) do if not src.complete then AddObjectNode(questID,"objectiveObject",src.id,src.itemID,nil,src) CurrentStats().objectiveObject=CurrentStats().objectiveObject+1 end end for _,item in pairs(resolved.item or {}) do if not item.complete then for _,src in pairs(item.sources or {}) do if src.kind=="creature" then AddCreatureNode(questID,"objectiveItemSource",src.id,item.itemID,src.chance,item,src.vendor) else AddObjectNode(questID,"objectiveItemSource",src.id,item.itemID,src.chance,item) end CurrentStats().objectiveItemSource=CurrentStats().objectiveItemSource+1 end end end end local function BuildActiveNodes() for questID in pairs(QuestieOcto.QuestLog.active or {}) do BuildActiveQuestNodes(questID) end end local function StatKeyForNode(node) if not node then return nil end if node.role=="available" then return node.sourceKind=="gameObject" and "availableObject" or "availableCreature" end if node.role=="itemStart" then return "itemStart" end if node.role=="objectiveCreature" then return "objectiveCreature" end if node.role=="objectiveObject" then return "objectiveObject" end if node.role=="objectiveItemSource" then return "objectiveItemSource" end if node.role=="turnin" then return "turnin" end if node.role=="flightMaster" then return "flightMaster" end if node.role=="auctioneer" then return "auctioneer" end if node.role=="banker" then return "banker" end if node.role=="mailbox" then return "mailbox" end if node.role=="battlemaster" then return "battlemaster" end if node.role=="innkeeper" then return "innkeeper" end if node.role=="meetingStone" then return "meetingStone" end if node.role=="repair" then return "repair" end if node.role=="spiritHealer" then return "spiritHealer" end if node.role=="stableMaster" then return "stableMaster" end if node.role=="vendor" then return "vendor" end if node.role=="rareMob" then return "rareMob" end return nil end local function DecrementNodeStats(node) N.stats.total=math.max(0,(N.stats.total or 0)-1) local key=StatKeyForNode(node) if key then N.stats[key]=math.max(0,(N.stats[key] or 0)-1) end end local function NormalizeChangedQuests(changedQuests) local changed={} for questID in pairs(changedQuests or {}) do questID=tonumber(questID) if questID and questID>0 then changed[questID]=true end end return changed end local function RemoveChangedQuestNodes(changed) local affectedMaps={} -- Update global node/stat accounting once per canonical node. A canonical -- source can belong to more than one map, so decrementing while filtering -- per-map arrays would count the same node more than once. local keptGlobal={} for _,node in pairs(N.nodes or {}) do if changed[tonumber(node.questID)] then DecrementNodeStats(node) else table.insert(keptGlobal,node) end end N.nodes=keptGlobal -- Remove only changed quests from map indexes. Keep the old map IDs in the -- affected set so a quest that disappears entirely can still remove its -- prepared/map presentation from those zones. for questID in pairs(changed) do for mapID in pairs(N.questMaps[questID] or {}) do affectedMaps[mapID]=true local kept={} for _,node in pairs(N.byMap[mapID] or {}) do if tonumber(node.questID)~=questID then table.insert(kept,node) end end N.byMap[mapID]=kept end N.questMaps[questID]=nil end return affectedMaps end local function SortAffectedMaps(affectedMaps) for mapID in pairs(affectedMaps or {}) do local mapNodes=N.byMap[mapID] if mapNodes then table.sort(mapNodes,function(a,b) if a.questID~=b.questID then return a.questID