From 004d8fea346ed21f264bc4b78ab8cc21867a2e2b Mon Sep 17 00:00:00 2001 From: Rov Date: Wed, 15 Jul 2026 13:42:34 +0930 Subject: [PATCH 1/4] Path following steps fix: - Path-following steps now advance navigation to the next unfinished point when you arrive. - Manually completing a path point now immediately points to the next point - completing the final one advances to the next guide step and rebuilds navigation. --- .../ZygorGuidesViewer/Runtime.lua | 25 +++++++- tools/tests/lua/test_runtime_catalog.lua | 58 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua index 778c25b..005bde1 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua @@ -695,23 +695,42 @@ function Runtime:ActivateGoal(stepIndex,goalIndex) end self.manual[self:ManualKey(stepIndex,goalIndex)]=true ZGV:Fire("ZGV_GOAL_UPDATED",guide,stepIndex,goalIndex) + -- Manual completion must have the same navigation result as a live quest + -- or arrival update. If more goals remain in this step, point to the next + -- one; otherwise advance now so the following step's waypoint is visible + -- immediately rather than waiting for the periodic runtime ticker. + if stepIndex==self.currentStep and self:GetStepState(step,stepIndex).complete then + if self:NextStep(true) then return true end + end + self:UpdateWaypoint() return true end function Runtime:UpdateWaypoint() if not ZGV.Navigation then return end local step=self.currentGuide and self.currentGuide.steps[self.currentStep] - local destination,title + local destination,title,goalKey if step and self:IsStepApplicable(step) then for i=1,#step.goals do local goal=step.goals[i] if goal.destination and self:IsGoalApplicable(goal) and not self:IsGoalComplete(goal,self.currentStep,i) then destination=goal.destination title=goal.text + goalKey=table.concat({ + tostring(self.currentGuide.id or self.currentGuide.title or "guide"), + tostring(self.currentStep),tostring(i),tostring(destination.mapKey), + tostring(destination.x),tostring(destination.y), + },":") break end end end + -- Runtime ticks every 0.35 seconds. Avoid rebuilding routes, map pins, and + -- TomTom markers while the selected goal is unchanged, but recover if + -- another component has cleared the navigation waypoint in the meantime. + local hasWaypoint=ZGV.Navigation.waypoint~=nil + if goalKey==self.waypointGoalKey and ((goalKey and hasWaypoint) or (not goalKey and not hasWaypoint)) then return end + self.waypointGoalKey=goalKey if destination then ZGV.Navigation:SetWaypoint(destination,title,step.markers) else ZGV.Navigation:ClearWaypoint() end end @@ -740,6 +759,10 @@ end function Runtime:Tick() if not self.currentGuide then return end local state=self:GetStepState(self.currentGuide.steps[self.currentStep],self.currentStep) + -- A guide step may contain several authored travel points. Re-select after + -- each completion so path-following instructions never leave navigation on + -- an already reached coordinate. + self:UpdateWaypoint() ZGV:Fire("ZGV_RUNTIME_TICK",state) local blocked=self.autoAdvanceBlocked if blocked and blocked.guide==self.currentGuide.id and blocked.step==self.currentStep then diff --git a/tools/tests/lua/test_runtime_catalog.lua b/tools/tests/lua/test_runtime_catalog.lua index 4893c70..8999874 100644 --- a/tools/tests/lua/test_runtime_catalog.lua +++ b/tools/tests/lua/test_runtime_catalog.lua @@ -167,6 +167,64 @@ assertEqual(cityState.required,1,"city transition is a required, completable tra assertEqual(cityState.complete,true,"alternate city entry completes the authored gate step") Runtime.currentGuide = { id = "runtime-test", title = "Runtime test", conditionIssues = {} } +-- A path-following step can contain several `goto` points. Reaching or +-- manually completing the current point must choose the next point without +-- requiring a guide-step change. Completing the final point manually must +-- then advance immediately and rebuild navigation for the following step. +local waypointHistory={} +local arrivedFirst=false +ZGV.Navigation={ + waypoint=nil, + IsArrived=function(_,destination) return arrivedFirst and destination and destination.x==.10 end, + IsMapTransitionComplete=function() return false end, + SetWaypoint=function(self,destination,title) + self.waypoint=destination + waypointHistory[#waypointHistory+1]={destination=destination,title=title} + return true + end, + ClearWaypoint=function(self) self.waypoint=nil end, +} +Runtime.manual={} +Runtime.waypointGoalKey=nil +Runtime.currentGuide={id="movement-path",title="Movement path",conditionIssues={},steps={ + {goals={ + {action="goto",text="Follow the path",destination={mapKey="Nagrand/0",x=.10,y=.10}}, + {action="goto",text="Follow the path up",destination={mapKey="Nagrand/0",x=.20,y=.20}}, + }}, + {goals={ + {action="goto",text="Enter the cave",destination={mapKey="Nagrand/0",x=.30,y=.30}}, + }}, +}} +Runtime.currentStep=1 +Runtime.lastAdvance=0 +Runtime:UpdateWaypoint() +assertEqual(waypointHistory[#waypointHistory].destination.x,.10,"first path point is selected") +arrivedFirst=true +Runtime:Tick() +assertEqual(Runtime.currentStep,1,"reaching an intermediate path point keeps its guide step active") +assertEqual(waypointHistory[#waypointHistory].destination.x,.20,"arrival points navigation at the next path point") + +Runtime.manual={} +Runtime.waypointGoalKey=nil +Runtime.currentStep=1 +arrivedFirst=false +Runtime:UpdateWaypoint() +assert(Runtime:ActivateGoal(1,1),"manual completion accepts the first path point") +assertEqual(Runtime.currentStep,1,"manual intermediate completion keeps its guide step active") +assertEqual(waypointHistory[#waypointHistory].destination.x,.20,"manual completion points to the next path point") +assert(Runtime:ActivateGoal(1,2),"manual completion accepts the final path point") +assertEqual(Runtime.currentStep,2,"manual final path completion advances immediately") +assertEqual(waypointHistory[#waypointHistory].destination.x,.30,"manual final completion points to the next guide step") +ZGV.Navigation={ + IsArrived=function() return false end, + IsMapTransitionComplete=function(_,transition) + return transition and transition.kind=="enter" and transition.mapKey=="Stormwind City/0" + end, + SetWaypoint=function() return true end, + ClearWaypoint=function() end, +} +Runtime.currentGuide = { id = "runtime-test", title = "Runtime test", conditionIssues = {} } + -- Manual Next must honour conditional branch order instead of forcing the -- first retry label. A historical quest can be absent from both the log and -- a private-server completion snapshot, so a non-active Terokkar quest takes From 35bd28233f0e576472be26a537d2f32d829c02e4 Mon Sep 17 00:00:00 2001 From: Rov Date: Wed, 15 Jul 2026 14:33:27 +0930 Subject: [PATCH 2/4] =?UTF-8?q?Update=20for=20profession=20guides:=20-=20s?= =?UTF-8?q?killmax=20now=20checks=20the=20trained=20profession=20cap,=20no?= =?UTF-8?q?t=20current=20points.=20eg:=20Apprentice=20Engineering=20comple?= =?UTF-8?q?tes=20at=201/75.=20-=20SKILL=5FLINES=5FCHANGED=20and=20TRADE=5F?= =?UTF-8?q?SKILL=5FUPDATE=20now=20immediately=20tick=20and=20advance=20the?= =?UTF-8?q?=20guide=20after=20training.=20-=20Profession=20suggestions=20n?= =?UTF-8?q?ow=20respect=20the=20matching=20profession=20and=20proper=20300?= =?UTF-8?q?=E2=80=93375=20range=20for=20both=20factions.=20-=20Added=20reg?= =?UTF-8?q?ression=20coverage=20for=20rank=20caps,=20trainer-event=20advan?= =?UTF-8?q?cement,=20and=20guide=20suggestions.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ZygorGuidesViewer/Conditions.lua | 24 ++++++++++++-- .../ZygorGuidesViewer/Runtime.lua | 23 ++++++++++--- .../ZygorProfessionsAllianceCLASSIC.lua | 12 +++---- .../ZygorProfessionsHordeCLASSIC.lua | 12 +++---- tools/port_dispositions.json | 11 +++++++ tools/tests/lua/test_profession_compat.lua | 11 +++++++ tools/tests/lua/test_runtime_catalog.lua | 33 +++++++++++++++++-- tools/tests/test_wotlk_data.py | 23 +++++++++++++ 8 files changed, 128 insertions(+), 21 deletions(-) diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua index 98e3088..1a442b0 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua @@ -107,6 +107,26 @@ function Conditions:Skill(name) return 0 end +-- `skillmax` is deliberately separate from `skill`: training Apprentice, +-- Journeyman, and later ranks changes a profession's cap without changing +-- its current value. The authored profession guides use it for exactly that +-- trainer-rank check (for example, Engineering 1/75 after Apprentice). +function Conditions:SkillMax(name) + name=tostring(name or ""):lower() + local service=ZGV.Compat and ZGV.Compat.Profession + if service and type(service.GetSkill)=="function" then + local ok,result=pcall(service.GetSkill,service,name) + if ok and type(result)=="table" then + return tonumber(result.maxSkillLevel or result.max or result.maximum) or 0 + end + end + for index=1,(GetNumSkillLines and GetNumSkillLines() or 0) do + local skillName,isHeader,_,_,_,_,maximum=GetSkillLineInfo(index) + if not isHeader and skillName and skillName:lower()==name then return tonumber(maximum) or 0 end + end + return 0 +end + function Conditions:Reputation(name) if ZGV.Faction and ZGV.Faction.GetReputation then local rep=ZGV.Faction:GetReputation(name) @@ -241,7 +261,7 @@ bind("counthaveq","CountHaveQuests") bind("readyq","QuestReady") bind("readyallq","AllQuestsReady") bind("skill","Skill") -bind("skillmax","Skill") +bind("skillmax","SkillMax") bind("rep","Reputation") bind("repval","ReputationValue") bind("itemcount","ItemCount") @@ -347,7 +367,7 @@ _G.raceclass=function(...) return Conditions:RaceClass(...) end _G.completedq=function(...) return Conditions:CompletedQuest(...) end _G.havequest=function(...) return Conditions:HaveQuest(...) end _G.skill=function(...) return Conditions:Skill(...) end -_G.skillmax=function(...) return Conditions:Skill(...) end +_G.skillmax=function(...) return Conditions:SkillMax(...) end _G.rep=function(...) return Conditions:Reputation(...) end _G.itemcount=function(...) return Conditions:ItemCount(...) end _G.knownspell=function(...) return Conditions:KnownSpell(...) end diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua index 005bde1..ddcc105 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua @@ -229,8 +229,11 @@ function Runtime:IsGoalComplete(goal,stepIndex,goalIndex) return ZGV.Navigation:IsMapTransitionComplete(goal.mapTransition),"map-transition" end if ZGV.Navigation then return ZGV.Navigation:IsArrived(goal.destination),"position" end - elseif action=="skill" or action=="reachskill" or action=="skillmax" then + elseif action=="skill" or action=="reachskill" then return ZGV.Conditions:Skill(goal.skillName)>=(goal.skillRank or 1),"skill" + elseif action=="skillmax" then + local condition=ZGV.Conditions.SkillMax or ZGV.Conditions.Skill + return condition(ZGV.Conditions,goal.skillName)>=(goal.skillRank or 1),"skillmax" elseif action=="havebuff" then return ZGV.Conditions:HaveBuff(goal.haveBuff or (goal.modifiers and goal.modifiers.haveBuff) or goal.spellID or goal.objectID),"buff" elseif action=="nobuff" then @@ -486,6 +489,11 @@ function Runtime:GetProgressFingerprint(step,index) part[#part+1]="talk="..(self.talked[self:ManualKey(index,goalIndex)] and "yes" or "no") elseif goal.action=="gossip" then part[#part+1]="gossip="..(self.gossiped[self:ManualKey(index,goalIndex)] and "yes" or "no") + elseif goal.action=="skill" or goal.action=="reachskill" then + part[#part+1]="skill="..tostring(goal.skillName or "")..":"..tostring(ZGV.Conditions:Skill(goal.skillName)) + elseif goal.action=="skillmax" then + local condition=ZGV.Conditions.SkillMax or ZGV.Conditions.Skill + part[#part+1]="skillmax="..tostring(goal.skillName or "")..":"..tostring(condition(ZGV.Conditions,goal.skillName)) end if goal.expression then local result=ZGV.Conditions:Evaluate(goal.expression,self.currentGuide) @@ -756,7 +764,7 @@ function Runtime:ChooseSuggestedGuide() return ZGV.Catalog.sorted[1] end -function Runtime:Tick() +function Runtime:Tick(immediate) if not self.currentGuide then return end local state=self:GetStepState(self.currentGuide.steps[self.currentStep],self.currentStep) -- A guide step may contain several authored travel points. Re-select after @@ -771,7 +779,7 @@ function Runtime:Tick() -- actually changed, or immediately by real kill/talk/gossip credit. return end - if state.complete and GetTime()-self.lastAdvance>=self.autoAdvanceDelay then + if state.complete and (immediate or GetTime()-self.lastAdvance>=self.autoAdvanceDelay) then self.lastAdvance=GetTime() self:NextStep(false) end @@ -1037,11 +1045,16 @@ function Runtime:OnEvent(event,...) if event=="UI_INFO_MESSAGE" then self:RecordDiscovery(...) end if event=="QUEST_COMPLETE" then self:RememberTurnIn() end if event=="QUEST_FINISHED" then self.pendingTurnIn=nil end + local skillChanged=event=="SKILL_LINES_CHANGED" or event=="TRADE_SKILL_UPDATE" if event=="QUEST_LOG_UPDATE" or event=="BAG_UPDATE" or event=="PLAYER_LEVEL_UP" or event=="UNIT_INVENTORY_CHANGED" or event=="ACHIEVEMENT_EARNED" - or (event=="UNIT_AURA" and unit=="player") then + or (event=="UNIT_AURA" and unit=="player") or skillChanged then self:RefreshBlockedProgress(event) end + -- Build 12340 raises this after buying a trainer rank and after gaining a + -- profession point. Check immediately so the completed rank objective + -- selects the following guide stage without waiting for the periodic tick. + if skillChanged then self:Tick(true) end ZGV:Fire("ZGV_GOAL_UPDATED",self.currentGuide,self.currentStep) end @@ -1082,7 +1095,7 @@ function Runtime:OnStartup() end end -for _,event in ipairs({"QUEST_LOG_UPDATE","BAG_UPDATE","PLAYER_LEVEL_UP","UNIT_INVENTORY_CHANGED","ACHIEVEMENT_EARNED","UNIT_AURA","PLAYER_DEAD","PLAYER_UNGHOST","COMBAT_LOG_EVENT_UNFILTERED","GOSSIP_SHOW","QUEST_GREETING","QUEST_DETAIL","QUEST_PROGRESS","QUEST_COMPLETE","QUEST_FINISHED","MERCHANT_SHOW","TRAINER_SHOW","TAXIMAP_OPENED","UI_INFO_MESSAGE"}) do +for _,event in ipairs({"QUEST_LOG_UPDATE","BAG_UPDATE","PLAYER_LEVEL_UP","UNIT_INVENTORY_CHANGED","ACHIEVEMENT_EARNED","UNIT_AURA","PLAYER_DEAD","PLAYER_UNGHOST","COMBAT_LOG_EVENT_UNFILTERED","GOSSIP_SHOW","QUEST_GREETING","QUEST_DETAIL","QUEST_PROGRESS","QUEST_COMPLETE","QUEST_FINISHED","MERCHANT_SHOW","TRAINER_SHOW","TAXIMAP_OPENED","UI_INFO_MESSAGE","SKILL_LINES_CHANGED","TRADE_SKILL_UPDATE"}) do ZGV:RegisterEvent(event,Runtime,"OnEvent") end diff --git a/ZygorGuidesViewer/ZygorGuidesViewer_GuidesAlliance/Professions/ZygorProfessionsAllianceCLASSIC.lua b/ZygorGuidesViewer/ZygorGuidesViewer_GuidesAlliance/Professions/ZygorProfessionsAllianceCLASSIC.lua index bdce5bd..bf69c9a 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer_GuidesAlliance/Professions/ZygorProfessionsAllianceCLASSIC.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer_GuidesAlliance/Professions/ZygorProfessionsAllianceCLASSIC.lua @@ -1629,7 +1629,7 @@ You Reached Skill 375 in Alchemy. ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Blacksmithing\\Blacksmithing (1-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Blacksmithing') > 300 end, +condition_suggested=function() return skill('Blacksmithing') > 0 end, description="This guide will walk you through leveling your Blacksmithing skill from 1-375.", },[[ step @@ -4247,7 +4247,7 @@ Reach Skill 300 in Cooking |skill Cooking,300 |goto Ashenvale/0 36.03,51.62 ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Cooking\\Cooking (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Cooking') > 0 end, +condition_suggested=function() return skill('Cooking') >= 300 and skill('Cooking') < 375 end, description="This guide will walk you through leveling your Cooking skill from 300-375.", },[[ step @@ -8516,7 +8516,7 @@ Reach Skill 300 First in Aid |skill First Aid,300 ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\First Aid\\First Aid (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Alchemy') > 0 end, +condition_suggested=function() return skill('First Aid') >= 300 and skill('First Aid') < 375 end, description="This guide will walk you through leveling your First Aid skill from 300-375.", },[[ step @@ -9000,7 +9000,7 @@ Reach Skill 300 in Herbalism |skill Herbalism,300 ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Herbalism\\Herbalism (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Alchemy') > 0 end, +condition_suggested=function() return skill('Herbalism') >= 300 and skill('Herbalism') < 375 end, description="This guide will walk you through leveling your Herbalism skill from 300-375.", },[[ step @@ -12322,7 +12322,7 @@ Reach Skill 300 in Mining |skill Mining,300 ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Mining\\Mining (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Mining') > 300 end, +condition_suggested=function() return skill('Mining') >= 300 and skill('Mining') < 375 end, description="This guide will walk you through leveling your Mining skill from 300-375.", },[[ step @@ -12840,7 +12840,7 @@ You can find more around: |notinsticky ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Skinning\\Skinning (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Skinning') > 300 end, +condition_suggested=function() return skill('Skinning') >= 300 and skill('Skinning') < 375 end, description="This guide will walk you through leveling your Skinning skill from 300-375.", },[[ step diff --git a/ZygorGuidesViewer/ZygorGuidesViewer_GuidesHorde/Professions/ZygorProfessionsHordeCLASSIC.lua b/ZygorGuidesViewer/ZygorGuidesViewer_GuidesHorde/Professions/ZygorProfessionsHordeCLASSIC.lua index 03e9bdd..ab580f9 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer_GuidesHorde/Professions/ZygorProfessionsHordeCLASSIC.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer_GuidesHorde/Professions/ZygorProfessionsHordeCLASSIC.lua @@ -1692,7 +1692,7 @@ You Reached Skill 375 in Alchemy. ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Blacksmithing\\Blacksmithing (1-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Blacksmithing') > 300 end, +condition_suggested=function() return skill('Blacksmithing') > 0 end, description="This guide will walk you through leveling your Blacksmithing skill from 1-375.", },[[ step @@ -4401,7 +4401,7 @@ Reach Skill 300 in Cooking |skill Cooking,300 |goto Azshara 60.20,40.50 ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Cooking\\Cooking (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Cooking') > 0 end, +condition_suggested=function() return skill('Cooking') >= 300 and skill('Cooking') < 375 end, description="This guide will walk you through leveling your Cooking skill from 300-375.", },[[ step @@ -8377,7 +8377,7 @@ Reach Skill 300 First in Aid |skill First Aid,300 ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\First Aid\\First Aid (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Alchemy') > 0 end, +condition_suggested=function() return skill('First Aid') >= 300 and skill('First Aid') < 375 end, description="This guide will walk you through leveling your First Aid skill from 300-375.", },[[ step @@ -8856,7 +8856,7 @@ Reach Skill 300 in Herbalism |skill Herbalism,300 ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Herbalism\\Herbalism (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Alchemy') > 0 end, +condition_suggested=function() return skill('Herbalism') >= 300 and skill('Herbalism') < 375 end, description="This guide will walk you through leveling your Herbalism skill from 300-375.", },[[ step @@ -12236,7 +12236,7 @@ Reach Skill 300 in Mining |skill Mining,300 ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Mining\\Mining (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Mining') > 300 end, +condition_suggested=function() return skill('Mining') >= 300 and skill('Mining') < 375 end, description="This guide will walk you through leveling your Mining skill from 300-375.", },[[ step @@ -12749,7 +12749,7 @@ You can find more around: |notinsticky ]]) ZygorGuidesViewer:RegisterGuide("Profession Guides\\Skinning\\Skinning (300-375)",{ author="support@zygorguides.com", -condition_suggested=function() return skill('Skinning') > 300 end, +condition_suggested=function() return skill('Skinning') >= 300 and skill('Skinning') < 375 end, description="This guide will walk you through leveling your Skinning skill from 300-375.", },[[ step diff --git a/tools/port_dispositions.json b/tools/port_dispositions.json index f363413..9bb0361 100644 --- a/tools/port_dispositions.json +++ b/tools/port_dispositions.json @@ -713,6 +713,17 @@ "ZygorGuidesViewer_GuidesAlliance/Professions/ZygorProfessionsAllianceCLASSIC.lua" ] }, + { + "id": "adapt-horde-profession-guide", + "disposition": "adapted", + "reason": "The retained full profession payload keeps the authored routes while correcting suggestions to use the character's matching profession and current range.", + "source_paths": [ + "Guides-TBC/Professions/ZygorProfessionsHordeCLASSIC.lua" + ], + "replacement_targets": [ + "ZygorGuidesViewer_GuidesHorde/Professions/ZygorProfessionsHordeCLASSIC.lua" + ] + }, { "id": "adapt-common-profession-guide", "disposition": "adapted", diff --git a/tools/tests/lua/test_profession_compat.lua b/tools/tests/lua/test_profession_compat.lua index d74746e..c729d6d 100644 --- a/tools/tests/lua/test_profession_compat.lua +++ b/tools/tests/lua/test_profession_compat.lua @@ -12,6 +12,8 @@ function Compat:RegisterEvent(event,owner,method) events[event]={owner=owner,met function Compat.Bool(value) return value and true or false end function Compat:Result(ok,code,data) data=data or {} data.ok=ok and true or false data.code=code return data end ZygorGuidesViewer={Compat=Compat} +function ZygorGuidesViewer:RegisterModule(name,module) self[name]=module return module end +function ZygorGuidesViewer:RegisterEvent() end local spellNames={ [2259]={"Alchemy",nil,"alchemy-icon"}, @@ -66,6 +68,15 @@ assertEqual(all[1].maxSkillLevel,450,"legacy tuple maximum") assertEqual(all[1].skillLine,171,"legacy tuple skill line") assertEqual(Profession:GetSkill(773).skillLevel,410,"legacy Inscription lookup") +-- A trained rank updates the profession maximum without requiring the +-- character to earn every point in that rank. The guide DSL exposes both +-- values: `skill` for current points and `skillmax` for trainer ranks. +dofile(repo .. "/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua") +local Conditions=assert(ZygorGuidesViewer.Conditions) +assertEqual(Conditions:Skill("Alchemy"),375,"condition current profession rank") +assertEqual(Conditions:SkillMax("Alchemy"),450,"condition profession maximum") +assertEqual(skillmax("Alchemy"),450,"guide skillmax predicate uses profession maximum") + local cache=Profession:RefreshRecipes() local recipe=assert(cache[53042],"recipe spell cache missing") assertEqual(Profession.recipeByProduct[33447],recipe,"product cache") diff --git a/tools/tests/lua/test_runtime_catalog.lua b/tools/tests/lua/test_runtime_catalog.lua index 8999874..a35fd80 100644 --- a/tools/tests/lua/test_runtime_catalog.lua +++ b/tools/tests/lua/test_runtime_catalog.lua @@ -23,7 +23,8 @@ function ZGV:RegisterModule(name, module) self[name] = module return module end -function ZGV:RegisterEvent() end +local registeredEvents={} +function ZGV:RegisterEvent(event,owner,method) registeredEvents[event]={owner=owner,method=method} end function ZGV:RegisterCallback() end function ZGV:Fire() end function ZGV:LogInfo() end @@ -106,17 +107,45 @@ end function ZGV.Conditions:ItemCount() return 0 end local hasShadowyDisguise=false function ZGV.Conditions:HaveBuff(value) return hasShadowyDisguise and (value==32756 or tostring(value):find("Shadowy Disguise",1,true)~=nil) end -function ZGV.Conditions:Skill() return 0 end +local skillRanks,skillMaximums={},{} +function ZGV.Conditions:Skill(name) return skillRanks[tostring(name or ""):lower()] or 0 end +function ZGV.Conditions:SkillMax(name) return skillMaximums[tostring(name or ""):lower()] or 0 end function ZGV.Conditions:Reputation() return 0 end dofile(addon .. "Runtime.lua") local Runtime = ZGV.Runtime Runtime.currentGuide = { id = "runtime-test", title = "Runtime test", conditionIssues = {} } +assert(registeredEvents.SKILL_LINES_CHANGED and registeredEvents.TRADE_SKILL_UPDATE, + "runtime watches profession changes from the 3.3.5a client") -- Terokkar's disguise retry must remain on its visible `havebuff` objective -- until the player has the actual aura. This is the gate that previously -- auto-ran through blank steps and jumped back to its retry label. dofile(addon .. "Parser.lua") +local trainerGuide=assert(ZGV.Parser:ParseEntry({ + id="engineering-rank",title="Engineering rank",header={},raw=[[ +step +Train Apprentice Engineering |skillmax Engineering,75 |goto Orgrimmar/0 76.18,25.18 +step +Open Your Engineering Crafting Panel +]] +})) +local trainerGoal=trainerGuide.steps[1].goals[1] +assertEqual(trainerGoal.action,"skillmax","trainer rank is parsed as a profession-cap goal") +skillRanks.engineering,skillMaximums.engineering=75,0 +assertEqual(Runtime:IsGoalComplete(trainerGoal,1,1),false, + "current Engineering points do not falsely satisfy an untrained rank") +skillMaximums.engineering=75 +local trainerComplete,trainerReason=Runtime:IsGoalComplete(trainerGoal,1,1) +assertEqual(trainerComplete,true,"Apprentice Engineering completes when its 75-point cap is trained") +assertEqual(trainerReason,"skillmax","trainer completion reports the profession-cap source") +Runtime.currentGuide,Runtime.currentStep=trainerGuide,1 +Runtime.manual={} +Runtime.lastAdvance=GetTime() +Runtime:OnEvent("SKILL_LINES_CHANGED") +assertEqual(Runtime.currentStep,2,"training immediately advances to the next profession stage") +skillRanks.engineering,skillMaximums.engineering=0,0 + local shadowyGuide=assert(ZGV.Parser:ParseEntry({ id="shadowy-runtime",title="Shadowy runtime",header={},raw=[[ step diff --git a/tools/tests/test_wotlk_data.py b/tools/tests/test_wotlk_data.py index ffc4fd5..bdbdae2 100644 --- a/tools/tests/test_wotlk_data.py +++ b/tools/tests/test_wotlk_data.py @@ -64,6 +64,29 @@ def test_live_profession_contract_covers_wrath_recipes(self) -> None: self.assertIn(contract, profession) self.assertIn('Compat:RegisterEvent("TRADE_SKILL_UPDATE"', profession) + def test_profession_guide_suggestions_use_the_matching_skill_range(self) -> None: + expected = { + "Profession Guides\\Blacksmithing\\Blacksmithing (1-375)": "skill('Blacksmithing') > 0", + "Profession Guides\\Cooking\\Cooking (300-375)": "skill('Cooking') >= 300 and skill('Cooking') < 375", + "Profession Guides\\First Aid\\First Aid (300-375)": "skill('First Aid') >= 300 and skill('First Aid') < 375", + "Profession Guides\\Herbalism\\Herbalism (300-375)": "skill('Herbalism') >= 300 and skill('Herbalism') < 375", + "Profession Guides\\Mining\\Mining (300-375)": "skill('Mining') >= 300 and skill('Mining') < 375", + "Profession Guides\\Skinning\\Skinning (300-375)": "skill('Skinning') >= 300 and skill('Skinning') < 375", + } + roots = ( + REPO / "ZygorGuidesViewer" / "ZygorGuidesViewer_GuidesAlliance" / "Professions" / "ZygorProfessionsAllianceCLASSIC.lua", + REPO / "ZygorGuidesViewer" / "ZygorGuidesViewer_GuidesHorde" / "Professions" / "ZygorProfessionsHordeCLASSIC.lua", + ) + for root in roots: + text = root.read_text(encoding="utf-8") + for title, expression in expected.items(): + pattern = ( + r'ZygorGuidesViewer:RegisterGuide\("' + re.escape(title.replace("\\", "\\\\")) + r'",\{\s*' + r'author="[^"]+",\s*condition_suggested=function\(\) return ' + + re.escape(expression) + r' end,' + ) + self.assertRegex(text, pattern, (root.name, title)) + def test_dungeon_preview_uses_wotlk_ids_and_retained_artwork(self) -> None: preview = (ADDON / "DungeonPreview.lua").read_text(encoding="utf-8") self.assertIn('[189]={name="Scarlet Monastery"', preview) From a99fe3012f2d29456224e631ae8c17069abe7f6d Mon Sep 17 00:00:00 2001 From: Rov Date: Wed, 15 Jul 2026 15:08:49 +0930 Subject: [PATCH 3/4] =?UTF-8?q?Navigation=20update:=20-=20Routes=20now=20c?= =?UTF-8?q?hain=20learned=20flight=20paths=20with=20same-map=20boats,=20ze?= =?UTF-8?q?ppelins,=20and=20portals=20across=20continents.=20-=20The=20map?= =?UTF-8?q?=20shows=20the=20actionable=20local=20leg=20instead=20of=20a=20?= =?UTF-8?q?misleading=20cross-zone=20line.=20-=20Ready=20Hearthstone,=20As?= =?UTF-8?q?tral=20Recall,=20and=20supported=20travel=20items=20are=20sugge?= =?UTF-8?q?sted=E2=80=94never=20auto-used.=20-=20Opening=20a=20flight-mast?= =?UTF-8?q?er=20map=20refreshes=20the=20route=20immediately.=20-=20Added?= =?UTF-8?q?=20navigation=20settings=20for=20flight,=20Hearthstone,=20Astra?= =?UTF-8?q?l=20Recall,=20and=20travel-item=20recommendations.=20-=20Incomp?= =?UTF-8?q?lete=20travel=20data=20now=20gives=20a=20clear=20fallback=20rec?= =?UTF-8?q?ommendation=20instead=20of=20silently=20pointing=20across=20zon?= =?UTF-8?q?es.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ZygorGuidesViewer/Database.lua | 5 +- .../ZygorGuidesViewer/ModernArrow.lua | 3 +- .../ZygorGuidesViewer/Navigation.lua | 249 +++++++++++++++--- .../ZygorGuidesViewer/Options.lua | 4 + tools/tests/lua/test_navigation_routes.lua | 57 +++- 5 files changed, 275 insertions(+), 43 deletions(-) diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Database.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Database.lua index e3e3f50..d6b2469 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Database.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Database.lua @@ -18,7 +18,10 @@ local profileDefaults = { minimap={ x=-3, y=-2 }, actionbar={ enabled=true, x=0, y=80, scale=1, locked=false, direction=2, quest=true, talk=true, kill=true, trash=false, hideInCombat=false }, automation={ accept=true, progress=true, turnin=true, gossip=true, repair=true, sellGreys=false, equip=false, autoSelectReward=false, questRewardHint=true }, - navigation={ enabled=true, useTomTom=true, knownTaxi={} }, + -- Travel recommendations are advisory only. They build a route from the + -- character's learned taxi points and ready travel abilities; no protected + -- item, spell or taxi action is ever invoked by the route planner. + navigation={ enabled=true, useTomTom=true, knownTaxi={}, useTaxi=true, useHearth=true, useAstralRecall=true, useTravelItems=true }, map={ showCoords=true, poiEnabled=true, poiMode="quick", hideTypes={rare=false,treasure=false}, mapIcons=true, minimapIcons=true }, pointer={ audio=true, meters=false, freeze=false, showMinimap=true, showWorldMap=true, showLines=true, autoCorpse=true }, -- Questie owns the Blizzard quest-watch list when installed. Zygor keeps diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/ModernArrow.lua b/ZygorGuidesViewer/ZygorGuidesViewer/ModernArrow.lua index 22551e0..170e751 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/ModernArrow.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/ModernArrow.lua @@ -8,7 +8,8 @@ if type(UI) ~= "table" then return end local ROOT = ZGV.ARROWSDIR .. "Starlight\\" local SPECIALS = { arrived = { 1, 1 }, waiting = { 1, 2 }, upstairs = { 2, 1 }, downstairs = { 2, 2 }, - error = { 3, 1 }, unreachable = { 4, 1 }, route = { 5, 1 }, taxi = { 5, 1 }, ship = { 6, 1 }, + error = { 3, 1 }, unreachable = { 4, 1 }, route = { 5, 1 }, taxi = { 5, 1 }, + hearth = { 5, 1 }, astral = { 5, 1 }, useitem = { 5, 1 }, spell = { 5, 1 }, ship = { 6, 1 }, } local function setSpecial(texture, name) diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua index 0f10c80..92e8c3e 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua @@ -6,6 +6,11 @@ local function navigationEnabled() return not (ZGV.db and ZGV.db.profile and ZGV.db.profile.navigation and ZGV.db.profile.navigation.enabled==false) end +local function navigationOption(name) + local navigation=ZGV.db and ZGV.db.profile and ZGV.db.profile.navigation + return not navigation or navigation[name]~=false +end + local function pointerOption(name) local pointer=ZGV.db and ZGV.db.profile and ZGV.db.profile.pointer return not pointer or pointer[name]~=false @@ -130,7 +135,7 @@ function Navigation:GetNavigationTarget() return self.waypoint,nil end -local transportModes={taxi=true,boat=true,zeppelin=true,portal=true,teleport=true,enter=true,leave=true,cross=true} +local transportModes={taxi=true,boat=true,zeppelin=true,portal=true,teleport=true,enter=true,leave=true,cross=true,hearth=true,astral=true,useitem=true,spell=true} -- A route can name one exact gate while the player reaches the destination -- map through another valid entrance. Once the client reports the new map, @@ -183,10 +188,12 @@ function Navigation:GetRouteInstructions() -- Guide-authored movement labels already contain their verb (for example -- "Run up the ramp"). Prefixing them produced the visible "Run to Run -- up the ramp" duplication in the pointer. - if mode=="walk" and destination:lower():match("^(run|ride|go|enter|leave|follow|climb|take|use|continue)%s") then return destination end + if destination:lower():match("^(run|ride|go|enter|leave|follow|climb|take|use|continue|fly)%s") then return destination end local verb={ walk="Run to ",taxi="Fly to ",boat="Take the boat to ",zeppelin="Take the zeppelin to ", - portal="Use the portal to ",teleport="Use the teleport to ",enter="Enter ",leave="Leave ",cross="Enter ", + portal="Use the portal to ",teleport="Use the teleport to ",hearth="Use Hearthstone to ", + astral="Use Astral Recall to ",useitem="Use the travel item to ",spell="Cast the travel spell to ", + enter="Enter ",leave="Leave ",cross="Enter ", } return (verb[mode] or "Travel to ")..destination end @@ -201,10 +208,20 @@ function Navigation:GetRouteInstructions() } end end + if route and route.advisory then + local index=#instructions+1 + instructions[index]={ + index=index,complete=false,active=(self.routeIndex or 1)>#(route.path or {}), + mode=route.advisory.mode or "walk",text=instructionText(route.advisory.mode,route.advisory.label), + target=route.advisory.node, + } + end if self.waypoint then + local index=#instructions+1 + local advisory=route and route.advisory instructions[#instructions+1]={ - index=#instructions+1,complete=(self.routeIndex or 1)>#instructions+1, - active=(self.routeIndex or 1)>=(#instructions+1),mode="walk", + index=index,complete=not advisory and (self.routeIndex or 1)>index, + active=not advisory and (self.routeIndex or 1)>=index,mode="walk", text=instructionText("walk",self.waypoint.title or "the guide destination"),target=self.waypoint, } end @@ -734,6 +751,84 @@ local function allowed(link) return not link[5] or link[5]==faction end +local function compactName(value) + return tostring(value or ""):lower():gsub("[^%w]","") +end + +local function cooldownReady(getter,id) + if type(getter)~="function" then return true end + local start,duration,enabled=getter(id) + if enabled==0 then return false end + return not (tonumber(start) and tonumber(start)>0 and tonumber(duration) and tonumber(duration)>0) +end + +-- The 3.3.5 client reports the character's bind by its inn/city name, not a +-- map key or coordinate. Prefer a matching static flight-master record: it +-- gives the route a real local point after the hearth and keeps the next leg +-- useful. A map-name bind still gets a safe centre-map fallback so the arrow +-- can advise the travel action without inventing a direction across maps. +function Navigation:ResolveHearthLocation() + if type(GetBindLocation)~="function" then return nil end + local bind=GetBindLocation("player") or GetBindLocation() + if type(bind)~="string" or bind=="" then return nil end + local wanted=compactName(bind) + local faction=type(UnitFactionGroup)=="function" and UnitFactionGroup("player") or nil + local factionKey=faction=="Alliance" and "A" or faction=="Horde" and "H" or nil + local static=ZGV.Data and (ZGV.Data.Taxi or ZGV.Data.taxi) or {} + for _,node in pairs(static) do + local matches=compactName(node.name)==wanted + if not matches then + for _,alias in ipairs(node.aliases or {}) do + if compactName(alias)==wanted then matches=true break end + end + end + if matches and (not node.faction or node.faction=="B" or node.faction==factionKey) and node.mapKey and node.x and node.y then + return {mapKey=node.mapKey,x=node.x,y=node.y,title=bind} + end + end + local map=ZGV.Compat and ZGV.Compat.Map and ZGV.Compat.Map:Resolve(bind.."/0") + if map and map.key then return {mapKey=map.key,x=.5,y=.5,title=bind} end + return nil +end + +function Navigation:GetAvailableTravelPorts(data) + local ports={} + if type(data)~="table" or type(data.portkeys)~="table" or (type(UnitOnTaxi)=="function" and UnitOnTaxi("player")) then return ports end + for _,port in ipairs(data.portkeys) do + local isAstral=port.isAstral or port.is_astral + local enabled=(isAstral and navigationOption("useAstralRecall")) + or (port.mode=="hearth" and navigationOption("useHearth")) + or ((not port.mode or port.mode~="hearth") and navigationOption("useTravelItems")) + local available=enabled + if available and port.item then + available=type(GetItemCount)=="function" and (tonumber(GetItemCount(port.item)) or 0)>0 + if available and type(IsUsableItem)=="function" then available=IsUsableItem(port.item) and true or false end + if available then available=cooldownReady(GetItemCooldown,port.item) end + elseif available and port.spell then + available=type(IsSpellKnown)=="function" and IsSpellKnown(port.spell) + if available and type(IsUsableSpell)=="function" then available=IsUsableSpell(port.spell) and true or false end + if available then available=cooldownReady(GetSpellCooldown,port.spell) end + else + available=false + end + if available then + local destination + if port.destination=="_HEARTH" then destination=self:ResolveHearthLocation() + elseif type(port.destination)=="string" then + destination=self:ResolveTarget({mapKey=port.destination,x=port.x,y=port.y,title=port.title}) + end + if destination and destination.mapKey and destination.x and destination.y then + local mode=isAstral and "astral" or port.mode or (port.spell and "spell") or "useitem" + ports[#ports+1]={ + destination=destination,mode=mode,cost=tonumber(port.cost) or 80, + label=port.title or destination.title or "the next route point", + } + end + end + end + return ports +end + function Navigation:FindRoute(from,to) local data=ZGV.Data.routes if not data or not to then return nil end @@ -799,42 +894,82 @@ function Navigation:FindRoute(from,to) -- nearby route node. return math.max(3,math.sqrt(dx*dx+dy*dy)*100) end - -- The client exposes the player's discovered taxi destinations only while - -- a taxi map is opened. Compat.Taxi persists those discoveries and joins - -- them to Data-WotLK/TaxiNodes.lua here. Treat every learned source and - -- destination on the same world continent as a selectable flight; the - -- server itself handles intermediate flight stops for a selected endpoint. - local taxiSources,taxiDestinations={},{} + -- The reference Rover graph adds ready hearths and travel items as edges + -- from the live player position. Keep that outcome, but never take the + -- action: the resulting route only tells the player what is available. + local travelPorts=self:GetAvailableTravelPorts(data) + local travelKeys={} + for index,port in ipairs(travelPorts) do + local key="_travel_"..tostring(index) + local destination=port.destination + nodes[key]={ + mapKey=destination.mapKey,x=destination.x,y=destination.y,title=destination.title, + travel=true, + } + travelKeys[#travelKeys+1]=key + edge("START",key,port.cost,port.mode,port.label) + end + + -- The client exposes learned flight nodes only while a taxi map is open. + -- Compat.Taxi persists them. Join each learned node to a tiny per-world + -- continent hub so a route can fly to a port, take a ship/zeppelin/portal, + -- then continue from a learned flight master on the other side. Hub nodes + -- are graph-only and deliberately omitted from the player-facing route. + local taxiByMap,taxiSources={},{} local taxi=ZGV.Compat and ZGV.Compat.Taxi - if player.key~=to.key and taxi and type(taxi.GetKnownStaticNodes)=="function" then + if player.key~=to.key and navigationOption("useTaxi") and taxi and type(taxi.GetKnownStaticNodes)=="function" then local function taxiContinent(mapKey) local record=ZGV.Compat.Map:Resolve(mapKey) local legacy=record and (record.legacy or record) return legacy and legacy.continent end - local sourceContinent=taxiContinent(player.key) - local destinationContinent=taxiContinent(to.key) - if sourceContinent and sourceContinent==destinationContinent then - for _,taxiNode in ipairs(taxi:GetKnownStaticNodes()) do - local mapKey=taxiNode.mapKey - if mapKey and taxiContinent(mapKey)==sourceContinent and taxiNode.x and taxiNode.y then - local key="_taxi_"..tostring(taxiNode.key or (mapKey..":"..tostring(taxiNode.name))) - nodes[key]={ - mapKey=mapKey,x=taxiNode.x,y=taxiNode.y, - title=taxiNode.title or ((taxiNode.name or "Flight Master").." Flight Master"), - taxi=true, - } - if mapKey==player.key then taxiSources[#taxiSources+1]=key end - if mapKey==to.key then taxiDestinations[#taxiDestinations+1]=key end - end + for _,taxiNode in ipairs(taxi:GetKnownStaticNodes()) do + local mapKey=taxiNode.mapKey + local continent=mapKey and taxiContinent(mapKey) + if continent and taxiNode.x and taxiNode.y then + local key="_taxi_"..tostring(taxiNode.key or (mapKey..":"..tostring(taxiNode.name))) + nodes[key]={ + mapKey=mapKey,x=taxiNode.x,y=taxiNode.y, + title=taxiNode.title or ((taxiNode.name or "Flight Master").." Flight Master"), + taxi=true, + } + taxiByMap[mapKey]=taxiByMap[mapKey] or {} + taxiByMap[mapKey][#taxiByMap[mapKey]+1]=key + if mapKey==player.key then taxiSources[#taxiSources+1]=key end + local hub="_taxi_continent_"..tostring(continent) + if not nodes[hub] then nodes[hub]={virtual=true} end + edge(key,hub,3,"taxi") + edge(hub,key,25,"taxi") + end + end + end + + -- Transport docks/portals and flight masters share a map but are separate + -- authored vertices. Join only those transfer points locally; connecting + -- every authored route node would bypass deliberate city corridors. + local transferByMap={} + for mapKey,keys in pairs(taxiByMap) do transferByMap[mapKey]=keys end + for _,key in ipairs(travelKeys) do + local node=nodes[key] + if node and node.mapKey then + transferByMap[node.mapKey]=transferByMap[node.mapKey] or {} + transferByMap[node.mapKey][#transferByMap[node.mapKey]+1]=key + end + end + for mapKey,transferKeys in pairs(transferByMap) do + for left=1,#transferKeys do + local firstKey=transferKeys[left] + local first=nodes[firstKey] + for right=left+1,#transferKeys do + local secondKey=transferKeys[right] + local second=nodes[secondKey] + edge(firstKey,secondKey,localCost(first,second),"walk") + edge(secondKey,firstKey,localCost(second,first),"walk") end - -- The fixed value represents boarding/flying overhead in the route - -- scorer. Local walking distance to/from the known flight masters is - -- still included by the normal START/FINISH edges, so a distant flight - -- master is not preferred over a nearby legal ground route. - for _,source in ipairs(taxiSources) do - for _,destination in ipairs(taxiDestinations) do - if source~=destination then edge(source,destination,25,"taxi") end + for staticKey,staticNode in pairs(nodes) do + if staticKey~=firstKey and not staticNode.virtual and not staticNode.taxi and not staticNode.travel and staticNode.mapKey==mapKey then + edge(firstKey,staticKey,localCost(first,staticNode),"walk") + edge(staticKey,firstKey,localCost(staticNode,first),"walk") end end end @@ -862,7 +997,35 @@ function Navigation:FindRoute(from,to) edge("START","FINISH",localCost(player,to),"walk") finishLinks=finishLinks+1 end - if #graph.START==0 or finishLinks==0 then return nil end + local function incompleteRoute() + -- A ready hearth is still valuable advice when the character has not yet + -- opened enough taxi maps for a complete continuation. Once the player + -- arrives at the bind location, RefreshRouteProgress rebuilds from live + -- data instead of pretending the rest of the trip is known. + local fallback + for index,port in ipairs(travelPorts) do + local key=travelKeys[index] + local node=key and nodes[key] + if node and node.mapKey~=player.key and (not fallback or port.cost Date: Wed, 15 Jul 2026 21:21:02 +0930 Subject: [PATCH 4/4] Navigation update: - Rebuilds immediately when saved or newly discovered flight paths load. - Rebuilds on all zone transitions. - Re-evaluates after moving more than 50 yards. - Removes misleading straight lines through zones when no valid route exists. - Logs the selected route sequence for diagnostics. --- .../ZygorGuidesViewer/Compat/Taxi.lua | 14 +- ZygorGuidesViewer/ZygorGuidesViewer/Core.lua | 2 +- .../ZygorGuidesViewer/Navigation.lua | 150 ++++++++++++++++-- .../lua/test_navigation_distance_contract.lua | 5 + tools/tests/lua/test_navigation_routes.lua | 42 +++++ tools/tests/lua/test_quest_auction_compat.lua | 12 ++ 6 files changed, 212 insertions(+), 13 deletions(-) diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua index 698a468..92630e5 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua @@ -8,6 +8,7 @@ Taxi.open = Taxi.open or false Taxi.lastSnapshot = Taxi.lastSnapshot or { nodes = {}, byKey = {}, updatedAt = 0 } Taxi.known = Taxi.known or {} Taxi.knownNames = Taxi.knownNames or {} +Taxi.revision = Taxi.revision or 0 -- TaxiNodeName is returned as "Node, Zone" on the 3.3.5 client, while the -- static map-position data uses the node's stable display name. Keep the @@ -22,7 +23,10 @@ end function Taxi:RememberKnownName(name) local key = name_key(name) - if key ~= "" then self.knownNames[key] = true end + if key ~= "" and not self.knownNames[key] then + self.knownNames[key] = true + self.revision = (tonumber(self.revision) or 0) + 1 + end end function Taxi:IsKnownName(name) @@ -213,6 +217,14 @@ function Taxi:Startup(saved) end end self.saved = saved + -- InitialFlightPaths binds SavedVariables after the addon modules have + -- started. Notify Navigation now that the learned-node set is usable; + -- otherwise an already selected guide keeps the route calculated before + -- this cache existed until the next /reload or manual waypoint change. + Compat:Fire("TAXI_CACHE_UPDATED", { + nodes = {}, byKey = {}, updatedAt = Compat.Now(), available = true, + restored = true, revision = self.revision, + }) return true end diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Core.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Core.lua index c8944d6..56feeef 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Core.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Core.lua @@ -17,7 +17,7 @@ ZGV.version = "8.1.0-wotlk.13" -- Bump this whenever a client-facing package is deployed. It is included in -- the first session diagnostic so a reload can be verified from the client -- log rather than inferred from file timestamps. -ZGV.buildRevision = "20260714-navigation-line-renderer-01" +ZGV.buildRevision = "20260715-travel-refresh-01" ZGV.interface = 30300 ZGV.targetBuild = 12340 ZGV.DIR = "Interface\\AddOns\\ZygorGuidesViewer" diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua index 92e8c3e..70a16bb 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua @@ -66,6 +66,7 @@ function Navigation:SetWaypoint(destination,title,markers) self.route=self:FindRoute(nil,target) self.routeIndex=1 self:RefreshRouteProgress() + self:RememberRouteInputs() ZGV:LogInfo("navigation","waypoint "..tostring(target.title)..(self.route and " (route found)" or " (direct)")) self:AddTomTom(target) self:UpdateMapPin() @@ -75,6 +76,7 @@ end function Navigation:ClearWaypoint() self.waypoint=nil self.route=nil self.routeIndex=1 + self.routeInputSignature=nil self.routeOrigin=nil self.routeBuiltAt=nil self.markers={} for _,pin in ipairs(self.mapPins or {}) do pin:Hide() end for _,line in ipairs(self.mapLines or {}) do line:Hide() end @@ -516,13 +518,17 @@ function Navigation:GetRouteLinePoints() end local player=ZGV.Compat.Map:GetPlayerPosition("player") if player and player.valid then append(player) end + local hasRoute=self.route and self.route.path local first=self.routeIndex or 1 for index,entry in ipairs(self.route and self.route.path or {}) do if index>=first and entry.node then append(self:ResolveTarget({mapKey=entry.node.mapKey,x=entry.node.x,y=entry.node.y,title=entry.node.title})) end end - append(self.waypoint) + -- Without a route, joining different map keys is a fabricated straight + -- line through terrain and zone walls. Wait for a real route refresh and + -- show no stroke rather than presenting that line as navigable geometry. + if hasRoute or (player and self.waypoint and player.key==self.waypoint.key) then append(self.waypoint) end return points end @@ -829,6 +835,120 @@ function Navigation:GetAvailableTravelPorts(data) return ports end +local function routePathSummary(route) + local parts={} + for _,entry in ipairs(route and route.path or {}) do + parts[#parts+1]=tostring(entry.mode or "walk")..":"..tostring(entry.node and (entry.node.title or entry.node.mapKey) or entry.key) + end + if route and route.advisory then parts[#parts+1]="advisory:"..tostring(route.advisory.mode) end + return #parts>0 and table.concat(parts," > ") or "direct" +end + +-- Route inputs can change without the selected guide goal changing: the taxi +-- cache is restored late in startup, a flight map discovers another node, a +-- travel cooldown becomes ready, or an option is toggled. Runtime correctly +-- deduplicates unchanged waypoints, so Navigation owns this smaller signature +-- and rebuilds only when the travel graph itself has changed. +function Navigation:GetRouteInputSignature(player) + player=player or (ZGV.Compat and ZGV.Compat.Map and ZGV.Compat.Map:GetPlayerPosition("player")) or {} + local waypoint=self.waypoint or {} + local profile=ZGV.db and ZGV.db.profile and ZGV.db.profile.navigation or {} + local taxi=ZGV.Compat and ZGV.Compat.Taxi + local parts={ + tostring(player.key),tostring(waypoint.key),tostring(waypoint.x),tostring(waypoint.y), + tostring(taxi and taxi.revision or 0), + tostring(profile.useTaxi~=false),tostring(profile.useHearth~=false), + tostring(profile.useAstralRecall~=false),tostring(profile.useTravelItems~=false), + } + local travel={} + for _,port in ipairs(self:GetAvailableTravelPorts(ZGV.Data and ZGV.Data.routes)) do + travel[#travel+1]=tostring(port.mode)..":"..tostring(port.destination and port.destination.mapKey) + end + table.sort(travel) + parts[#parts+1]=table.concat(travel,",") + return table.concat(parts,"|"),player +end + +function Navigation:RememberRouteInputs(player) + local signature,resolvedPlayer=self:GetRouteInputSignature(player) + self.routeInputSignature=signature + player=resolvedPlayer or player + if player and player.key then + self.routeOrigin={ + key=player.key,mapKey=player.key,x=player.x,y=player.y, + continent=player.continent,zone=player.zone,floor=player.floor, + } + end + self.routeBuiltAt=type(GetTime)=="function" and GetTime() or 0 +end + +function Navigation:RebuildRoute(reason,player) + if self.rebuildingRoute or not self.waypoint then return false end + self.rebuildingRoute=true + player=player or ZGV.Compat.Map:GetPlayerPosition("player") + if not player or not player.key then self.rebuildingRoute=nil return false end + self.route=self:FindRoute(player,self.waypoint) + self.routeIndex=1 + self:RefreshRouteProgress() + self:RememberRouteInputs(player) + self.rebuildingRoute=nil + self:UpdateMapPin() + ZGV:LogInfo("navigation","route refreshed: "..tostring(reason or "inputs").."; "..routePathSummary(self.route),{ + from=player and player.key,to=self.waypoint and self.waypoint.key, + taxiRevision=ZGV.Compat and ZGV.Compat.Taxi and ZGV.Compat.Taxi.revision or 0, + }) + ZGV:Fire("ZGV_ARROW_UPDATED",self:GetArrowState()) + return true +end + +function Navigation:QueueRouteRefresh(reason,delay) + if not self.waypoint then return false end + self.pendingRouteReason=self.pendingRouteReason and (self.pendingRouteReason..","..tostring(reason)) or tostring(reason or "event") + if self.routeRefreshTimer then return true end + local function refresh() + Navigation.routeRefreshTimer=nil + local pending=Navigation.pendingRouteReason + Navigation.pendingRouteReason=nil + Navigation:RebuildRoute(pending) + end + local timer=ZGV.Compat and ZGV.Compat.Timer + if timer and type(timer.NewTimer)=="function" then self.routeRefreshTimer=timer:NewTimer(delay or .15,refresh) + else refresh() end + return true +end + +function Navigation:HasMovedEnoughForRoute(player) + local origin=self.routeOrigin + if not origin or not player or not player.key then return false end + if origin.key~=player.key then return true end + local result=ZGV.Compat.Map:GetDistance(origin,player) + if result and result.ok and result.distanceKnown and type(result.distance)=="number" then return result.distance>50 end + if origin.x and origin.y and player.x and player.y then + local dx,dy=player.x-origin.x,player.y-origin.y + return math.sqrt(dx*dx+dy*dy)>.02 + end + return false +end + +function Navigation:MaybeRefreshRoute(now) + if not self.waypoint or self.rebuildingRoute then return false end + now=tonumber(now) or (type(GetTime)=="function" and GetTime() or 0) + local signature,player=self:GetRouteInputSignature() + if not player or not player.key then return false end + if signature~=self.routeInputSignature then return self:RebuildRoute("travel inputs changed",player) end + if now-(tonumber(self.routeBuiltAt) or 0)>=5 and self:HasMovedEnoughForRoute(player) then + return self:RebuildRoute("player moved",player) + end + return false +end + +function Navigation:OnTravelEvent(event) + -- Legacy map state settles just after the zone event. Coalesce the burst + -- from a border/loading transition, then hard-refresh like Classic Rover's + -- UpdateNow handler does for these same events. + self:QueueRouteRefresh(event,.15) +end + function Navigation:FindRoute(from,to) local data=ZGV.Data.routes if not data or not to then return nil end @@ -892,7 +1012,11 @@ function Navigation:FindRoute(from,to) -- Map-normalized distance is only used to select among exits on the same -- map; it intentionally cannot make an inter-zone border cheaper than a -- nearby route node. - return math.max(3,math.sqrt(dx*dx+dy*dy)*100) + -- This value represents travel time, not percentage-of-map distance. + -- The old *100 scale made a run across most of an Outland zone appear + -- cheaper than boarding a learned flight. *600 is a conservative mounted + -- traversal estimate and leaves short city approaches inexpensive. + return math.max(3,math.sqrt(dx*dx+dy*dy)*600) end -- The reference Rover graph adds ready hearths and travel items as edges -- from the live player position. Keep that outcome, but never take the @@ -1053,19 +1177,13 @@ function Navigation:FindRoute(from,to) return {path=path,cost=distance.FINISH,from=player.key,to=to.key} end -function Navigation:OnTaxiCache(snapshot) +function Navigation:OnTaxiCache(_,snapshot) if ZGV.db and snapshot then local known=ZGV.db.profile.navigation.knownTaxi or {} ZGV.db.profile.navigation.knownTaxi=known for key,node in pairs(ZGV.Compat.Taxi.known or {}) do known[key]={name=node.name,map=node.map and node.map.key,x=node.x,y=node.y} end end - if self.waypoint then - self.route=self:FindRoute(nil,self.waypoint) - self.routeIndex=1 - self:RefreshRouteProgress() - self:UpdateMapPin() - ZGV:LogInfo("navigation","taxi cache updated; route replanned") - end + if self.waypoint then self:RebuildRoute(snapshot and snapshot.restored and "saved taxi cache restored" or "taxi cache updated") end end function Navigation:OnStartup() @@ -1077,8 +1195,12 @@ function Navigation:OnStartup() if ZGV.Compat and ZGV.Compat.Timer then self.ticker=ZGV.Compat.Timer:NewTicker(.1,function() if Navigation.waypoint then - if Navigation:RefreshRouteProgress() then Navigation:UpdateMapPin() end local now=GetTime() + if now-(Navigation.lastRouteInputCheck or 0)>=1 then + Navigation.lastRouteInputCheck=now + Navigation:MaybeRefreshRoute(now) + end + if Navigation:RefreshRouteProgress() then Navigation:UpdateMapPin() end -- Animation is intentionally cheap: geometry is refreshed less often, -- while only the highlight texture coordinates advance every tick. Navigation:UpdateMinimapLines() @@ -1114,6 +1236,12 @@ function Navigation:OnMapEvent() end ZGV:RegisterEvent("WORLD_MAP_UPDATE",Navigation,"OnMapEvent") +for _,event in ipairs({"PLAYER_ENTERING_WORLD","ZONE_CHANGED","ZONE_CHANGED_NEW_AREA","ZONE_CHANGED_INDOORS"}) do + ZGV:RegisterEvent(event,Navigation,"OnTravelEvent") +end +if type(ZGV.RegisterCallback)=="function" then + ZGV:RegisterCallback("ZGV_OPTIONS_CHANGED",Navigation,function(self) self:QueueRouteRefresh("options changed",0) end) +end ZGV:AddMessageHandler("SKIN_UPDATED",function() if Navigation.waypoint then Navigation:UpdateMapPin() end end) diff --git a/tools/tests/lua/test_navigation_distance_contract.lua b/tools/tests/lua/test_navigation_distance_contract.lua index 0db2dd3..e6b662f 100644 --- a/tools/tests/lua/test_navigation_distance_contract.lua +++ b/tools/tests/lua/test_navigation_distance_contract.lua @@ -110,6 +110,11 @@ ZGV.db.profile.navigation.enabled = true ZGV.db.profile.pointer = { showWorldMap = true, showMinimap = true, showLines = false } assertEqual(#Navigation:GetRouteLinePoints(), 0, "route-line option suppresses world-map route geometry") ZGV.db.profile.pointer.showLines = true +assertEqual(#Navigation:GetRouteLinePoints(), 2, "same-map direct target keeps route geometry") +local savedWaypoint = Navigation.waypoint +Navigation.waypoint = {key="zone:other",mapKey="zone:other",continent=1,zone=2,x=.7,y=.7,title="Other zone"} +assertEqual(#Navigation:GetRouteLinePoints(), 1, "unrouted cross-map target has no fabricated straight line") +Navigation.waypoint = savedWaypoint local distance, distanceResult = Navigation:GetDistance(target) assertEqual(distance, nil, "unknown distance does not cross navigation API") diff --git a/tools/tests/lua/test_navigation_routes.lua b/tools/tests/lua/test_navigation_routes.lua index 6003fa2..c96f8cc 100644 --- a/tools/tests/lua/test_navigation_routes.lua +++ b/tools/tests/lua/test_navigation_routes.lua @@ -11,6 +11,7 @@ local continents={ ["Terokkar Forest/0"]=3,["Shattrath City/0"]=3, ["Elwynn Forest/0"]=2,["Stormwind City/0"]=2, ["Borean Tundra/0"]=4,["Dragonblight/0"]=4,["Unlinked Zone/0"]=1,["Remote Zone/0"]=1, + ["Nagrand/0"]=3,["Zangarmarsh/0"]=3,["Blade's Edge Mountains/0"]=3, } function map:Resolve(key) if type(key) == "table" then return key end @@ -28,6 +29,8 @@ local taxiNodes={ {key="stormwind",mapKey="Stormwind City/0",name="Stormwind",title="Stormwind Flight Master",x=.66,y=.62}, {key="valiance",mapKey="Borean Tundra/0",name="Valiance Keep",title="Valiance Keep Flight Master",x=.58,y=.68}, {key="wyrmrest",mapKey="Dragonblight/0",name="Wyrmrest Temple",title="Wyrmrest Temple Flight Master",x=.60,y=.55}, + {key="garadar",mapKey="Nagrand/0",name="Garadar",title="Garadar Flight Master",x=.5719,y=.3525}, + {key="zabrajin",mapKey="Zangarmarsh/0",name="Zabra'jin",title="Zabra'jin Flight Master",x=.3307,y=.5107}, } Compat.Map=map Compat.Taxi={GetKnownStaticNodes=function() return taxiNodes end} @@ -40,10 +43,16 @@ ZygorGuidesViewer={ shattrath_gate={mapKey="Shattrath City/0",x=.762,y=.773,title="Shattrath City Gate"}, stormwind_harbor={mapKey="Stormwind City/0",x=.184,y=.254,title="Stormwind Harbor"}, valiance_dock={mapKey="Borean Tundra/0",x=.590,y=.684,title="Valiance Keep"}, + nagrand_blade={mapKey="Nagrand/0",x=.285,y=.060,title="Border to Blade's Edge Mountains"}, + blade_nagrand={mapKey="Blade's Edge Mountains/0",x=.285,y=.939,title="Border to Nagrand"}, + blade_zangar={mapKey="Blade's Edge Mountains/0",x=.520,y=.988,title="Border to Zangarmarsh"}, + zangar_blade={mapKey="Zangarmarsh/0",x=.687,y=.329,title="Border to Blade's Edge Mountains"}, }, links={ {"terokkar_gate","shattrath_gate","enter",38,nil,"leave"}, {"stormwind_harbor","valiance_dock","boat",120,"Alliance"}, + {"nagrand_blade","blade_nagrand","cross",38}, + {"blade_zangar","zangar_blade","cross",38}, }, portkeys={ {item=6948,destination="_HEARTH",cost=80,mode="hearth"}, @@ -55,6 +64,7 @@ ZygorGuidesViewer={ ZGV=ZygorGuidesViewer function ZGV:RegisterModule(name, defaults) self[name]=defaults return defaults end function ZGV:RegisterEvent() end +function ZGV:RegisterCallback() end function ZGV:AddMessageHandler() end function ZGV:Fire() end function ZGV:LogInfo() end @@ -99,6 +109,38 @@ route=assert(Navigation:FindRoute(nil,{key="Stormwind City/0",x=.50,y=.60,title= assertEqual(route.path[1].mode,"astral","ready Astral Recall is preferred over Hearthstone") assertEqual(route.path[1].label,"Stormwind","Astral Recall names the bound destination") +-- Regression from live testing: an active Nagrand goal in Zangarmarsh must +-- prefer the learned Garadar -> Zabra'jin flight over the two-border detour +-- through Blade's Edge Mountains. +player={key="Nagrand/0",valid=true,x=.55,y=.38} +route=assert(Navigation:FindRoute(nil,{key="Zangarmarsh/0",x=.31,y=.54,title="Zangarmarsh quest"}),"Nagrand flight route was not found") +assertEqual(route.path[1].node.title,"Garadar Flight Master","Nagrand route starts at Garadar") +assertEqual(route.path[1].mode,"walk","player is directed to Garadar") +assertEqual(route.path[2].node.title,"Zabra'jin Flight Master","Nagrand route selects Zabra'jin") +assertEqual(route.path[2].mode,"taxi","Garadar to Zabra'jin is a flight leg") + +-- A taxi-cache revision must invalidate an otherwise unchanged Runtime +-- waypoint. This is the automatic refresh that previously required /reload. +Navigation.waypoint={key="Zangarmarsh/0",mapKey="Zangarmarsh/0",x=.31,y=.54,title="Zangarmarsh quest"} +Navigation.route=route +Navigation.routeIndex=1 +Compat.Taxi.revision=1 +Navigation:RememberRouteInputs(player) +local refreshReason +local realRebuild=Navigation.RebuildRoute +function Navigation:RebuildRoute(reason,livePlayer) + refreshReason=reason + return realRebuild(self,reason,livePlayer) +end +Compat.Taxi.revision=2 +Navigation:MaybeRefreshRoute(10) +assertEqual(refreshReason,"travel inputs changed","taxi cache revision refreshes active route") +refreshReason=nil +player={key="Zangarmarsh/0",valid=true,x=.32,y=.53} +Navigation:OnTravelEvent("ZONE_CHANGED_NEW_AREA") +assertEqual(refreshReason,"ZONE_CHANGED_NEW_AREA","zone transition hard-refreshes active route") +Navigation.RebuildRoute=realRebuild + -- Even without a complete route graph, a ready travel ability remains useful -- advice. The final continuation will be rebuilt from the live bind map. player={key="Unlinked Zone/0",valid=true,x=.5,y=.5} diff --git a/tools/tests/lua/test_quest_auction_compat.lua b/tools/tests/lua/test_quest_auction_compat.lua index e158dc0..d2d290c 100644 --- a/tools/tests/lua/test_quest_auction_compat.lua +++ b/tools/tests/lua/test_quest_auction_compat.lua @@ -141,4 +141,16 @@ assertEqual(glyphs[1].spellID, 54321, "Wrath glyph spell ID tuple position") assertEqual(glyphs[1].iconFileID, "Interface\\Icons\\INV_Glyph_MajorMage", "Wrath glyph icon tuple position") assertEqual(glyphs[1].tooltipIndex, nil, "Wrath glyph tuple has no tooltip index") +-- Restoring the saved taxi list happens after modules have started. The +-- service must publish that cache revision so Navigation can rebuild an +-- already selected guide route without requiring /reload. +dofile(repo .. "/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua") +local Taxi = assert(services.Taxi) +local firedBefore = #fired +assertEqual(Taxi:Startup({Garadar=true,["Zabra'jin"]={name="Zabra'jin"}}), true, "saved taxi startup") +assertEqual(Taxi.revision, 2, "saved taxi names advance cache revision") +assertEqual(#fired, firedBefore + 1, "saved taxi startup publishes one update") +assertEqual(fired[#fired].event, "TAXI_CACHE_UPDATED", "saved taxi update topic") +assertEqual(fired[#fired].payload.restored, true, "saved taxi update identifies restoration") + print("quest and auction compatibility tests passed")