From 7e048c4326063d3ab3eacfb1529850ac43562d0c Mon Sep 17 00:00:00 2001 From: Rov Date: Thu, 16 Jul 2026 12:06:35 +0930 Subject: [PATCH] =?UTF-8?q?bind-aware=20Hearthstone=20instructions=20-=20T?= =?UTF-8?q?he=20addon=20reads=20the=20actual=20Hearthstone=20destination?= =?UTF-8?q?=20using=20GetBindLocation().=20-=20=E2=80=9CHearth=20to=20Thun?= =?UTF-8?q?derlord=20Stronghold=E2=80=9D=20is=20skipped=20when=20bound=20t?= =?UTF-8?q?o=20another=20location.=20-=20Navigation=20proceeds=20to=20the?= =?UTF-8?q?=20next=20objective=20and=20calculates=20an=20appropriate=20fli?= =?UTF-8?q?ght/run=20route.=20-=20Hearth=20instructions=20remain=20availab?= =?UTF-8?q?le=20when=20the=20bind=20location=20matches.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug Fixes: - The striped square duplicate legacy quest-item button displaying the entire action-bar texture. - A conditional nohearth instruction was incorrectly being applied to the entire quest step. Pathing Improvements: - Path arrivals are now remembered until the guide advances. - Reaching the final “Follow the path…” point immediately advances navigation to the next talk/accept target. - Manually completing the final path point behaves the same way. - Stabilized travel routing so the global cooldown does not repeatedly invalidate Astral Recall and rebuild the route graph. --- .../ZygorGuidesViewer/Automation.lua | 34 +++----- .../ZygorGuidesViewer/Conditions.lua | 18 ++++ ZygorGuidesViewer/ZygorGuidesViewer/Core.lua | 2 +- .../ZygorGuidesViewer/Navigation.lua | 11 ++- .../ZygorGuidesViewer/Parser.lua | 26 +++++- .../ZygorGuidesViewer/Runtime.lua | 47 ++++++++-- tools/tests/lua/test_buff_tag_conditions.lua | 15 ++++ tools/tests/lua/test_navigation_routes.lua | 16 ++++ tools/tests/lua/test_runtime_catalog.lua | 85 +++++++++++++++++-- tools/tests/test_root_runtime_contracts.py | 4 + 10 files changed, 216 insertions(+), 42 deletions(-) diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Automation.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Automation.lua index e8e234f..0a66117 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Automation.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Automation.lua @@ -134,21 +134,17 @@ function Automation:ExecuteBundledScript(goal) end function Automation:ConfigureActionButton(goal) - if not self.actionButton then return end - local attributes={type=nil,item=nil,macrotext=nil} - if goal and goal.itemID then attributes.type="item" attributes.item="item:"..goal.itemID - elseif goal and goal.script and goal.script:match("VehicleExit") then attributes.type="macro" attributes.macrotext="/leavevehicle" end - ZGV.Compat.UI:RunOutOfCombat("zygor-action-button",function() - Automation.actionButton:SetAttribute("type",attributes.type) - Automation.actionButton:SetAttribute("item",attributes.item) - Automation.actionButton:SetAttribute("macrotext",attributes.macrotext) - if attributes.type then Automation.actionButton:Show() else Automation.actionButton:Hide() end - end) + -- ModernActionBar owns the one authoritative secure quest-action surface. + -- The old automation button duplicated item actions at UIParent center and + -- displayed the complete actionbar sprite sheet as a striped square. + if ZGV.ActionBar and type(ZGV.ActionBar.Refresh)=="function" then + return ZGV.ActionBar:Refresh() + end + return false,"actionbar-unavailable" end function Automation:RefreshActionButton() - local goal=self:FindGoal() - self:ConfigureActionButton(goal) + return self:ConfigureActionButton(self:FindGoal()) end function Automation:CaptureAbandon() @@ -178,16 +174,6 @@ end function Automation:OnStartup() self.abandoned=ZGV.db.char.abandoned or {} - self.actionButton=ZGV.Compat.UI:CreateSecureActionButton("ZygorGuidesViewerActionButton",UIParent) - if self.actionButton then - self.actionButton:SetWidth(36) self.actionButton:SetHeight(36) - self.actionButton:SetPoint("CENTER",UIParent,"CENTER",0,-180) - local texture=self.actionButton:CreateTexture(nil,"ARTWORK") - texture:SetAllPoints(self.actionButton) - texture:SetTexture(ZGV.SKINDIR.."actionbar.tga") - self.actionButton.texture=texture - self.actionButton:Hide() - end if type(hooksecurefunc)=="function" and type(SetAbandonQuest)=="function" then hooksecurefunc("SetAbandonQuest",function() Automation:CaptureAbandon() end) end @@ -196,8 +182,8 @@ function Automation:OnStartup() -- after the confirmed AbandonQuest call, so cancelling the dialog is safe. hooksecurefunc("AbandonQuest",function() Automation:ConfirmAbandon() end) end - ZGV:RegisterCallback("ZGV_STEP_CHANGED",self,"RefreshActionButton") - ZGV:RegisterCallback("ZGV_GUIDE_CHANGED",self,"RefreshActionButton") + -- ModernActionBar registers the guide/step callbacks. Keeping a second set + -- here would rebuild the same protected buttons twice for every change. self:RefreshActionButton() end diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua index 1a442b0..535d924 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Conditions.lua @@ -48,6 +48,22 @@ function Conditions:Faction(value) return tostring(value or ""):gsub("[_%s%-]",""):lower()==self.factionName end +-- GetBindLocation is the authoritative 3.3.5a source for the Hearthstone's +-- destination. Return nil when the client cannot provide it so old clients do +-- not lose guide steps merely because the capability is unavailable. +function Conditions:Bound(name) + if type(GetBindLocation)~="function" then return nil end + local current=GetBindLocation("player") + if not current or current=="" then current=GetBindLocation() end + if not current or current=="" then return nil end + local function compactLocation(value) + return tostring(value or ""):lower():gsub("[^%a%d]","") + end + local wanted=compactLocation(name) + if wanted=="" then return current end + return compactLocation(current)==wanted +end + function Conditions:CompletedQuest(...) for i=1,select("#",...) do if questCall("IsCompleted",select(i,...)) then return true end end return false @@ -276,6 +292,8 @@ bind("money","Money") bind("havebuff","HaveBuff") bind("hasbuff","HaveBuff") bind("faction","Faction") +bind("bound","Bound") +bind("hearthbound","Bound") safe.level=function() return UnitLevel("player") or 0 end safe.isdead=function() return UnitIsDeadOrGhost("player") and true or false end safe.ontaxi=function() return UnitOnTaxi and UnitOnTaxi("player") or false end diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Core.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Core.lua index 56feeef..267a8df 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 = "20260715-travel-refresh-01" +ZGV.buildRevision = "20260716-hearth-bind-awareness-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 70a16bb..09a9c70 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua @@ -765,7 +765,14 @@ 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) + start,duration=tonumber(start) or 0,tonumber(duration) or 0 + -- Spell cooldown APIs report the shared global cooldown as if every spell + -- were unavailable. It must not continuously remove/re-add Astral Recall + -- from the route graph whenever the player casts an unrelated ability. + if duration>0 and duration<=1.6 then return true end + if start<=0 or duration<=0 then return true end + local now=type(GetTime)=="function" and GetTime() or 0 + return start+duration<=now+.05 end -- The 3.3.5 client reports the character's bind by its inn/city name, not a @@ -808,11 +815,9 @@ function Navigation:GetAvailableTravelPorts(data) 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 diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Parser.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Parser.lua index cad6e14..d39f15c 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Parser.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Parser.lua @@ -762,6 +762,11 @@ function Parser:ParseGoal(base, mods, inheritedMap) text=action=="text" and displayText(content) or actionText(action,displayBody,count), target=name, targetID=id, sourceBody=actionBody, } + -- Authored hearth steps often use a separate `use Hearthstone` goal plus a + -- prose/condition line. Preserve the named destination so Runtime can + -- compare it with the live GetBindLocation result before offering the item. + local hearthDestination=content:match("^[Hh]earth%s+to%s+(.+)$") + if hearthDestination then goal.hearthDestination=displayText(hearthDestination) end if QUEST_ACTIONS[action] then goal.questID=id @@ -1115,6 +1120,7 @@ function Parser:_ParseGuide(guide, stack) goal.num=#current.goals+1 current.goals[#current.goals+1]=goal lastGoal=goal + if goal.hearthDestination then current.hearthDestination=goal.hearthDestination end if goal.questID then if goal.daily then ZGV.dailyQuests=ZGV.dailyQuests or {} @@ -1301,12 +1307,24 @@ function Parser:_ParseGuide(guide, stack) -- A leading pipe makes a standalone modifier a step modifier. This -- is the old parser's chunk-count behaviour and is vital for the -- legacy Wrath packs' standalone "only Race" lines. - current.onlyIf=addCondition(current.onlyIf,mods.onlyIf) - current.stickyIf=addCondition(current.stickyIf,mods.stickyIf) + -- `|nohearth |only if ...` is different: the condition scopes the + -- travel restriction, not the quest goal or the whole step. If it + -- leaks into either applicability check, leaving the named subzone + -- can skip an unfinished objective (notably the Bladespire half of + -- A Curse Upon Both of Your Clans) and jump directly to turn-in. + local travelOnly=mods.noHearth and true or false + if not travelOnly then + current.onlyIf=addCondition(current.onlyIf,mods.onlyIf) + current.stickyIf=addCondition(current.stickyIf,mods.stickyIf) + end if mods.delay then current.delay=mods.delay end - if mods.noHearth then current.travelConfig=current.travelConfig or {}; current.travelConfig.use_hearth=false end + if mods.noHearth then + current.travelConfig=current.travelConfig or {} + current.travelConfig.use_hearth=false + current.travelConfig.noHearthIf=mods.onlyIf + end addMarkers(mods) - if lastGoal then + if lastGoal and not travelOnly then applyModifiers(lastGoal,mods) if lastGoal.questID then if lastGoal.daily then ZGV.dailyQuests=ZGV.dailyQuests or {}; ZGV.dailyQuests[lastGoal.questID]=true end diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua index ddcc105..84f98f3 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua @@ -1,6 +1,6 @@ local ZGV = ZygorGuidesViewer local Runtime = ZGV:RegisterModule("Runtime",{ - currentGuide=nil,currentStep=1,manual={},killCounts={},collectionBaselines={},talked={},gossiped={},interacted={},discoveries={},autoHistory={},lastAdvance=0,autoAdvanceDelay=0.65, + currentGuide=nil,currentStep=1,manual={},arrivals={},killCounts={},collectionBaselines={},talked={},gossiped={},interacted={},discoveries={},autoHistory={},lastAdvance=0,autoAdvanceDelay=0.65, }) local function questEntry(id) @@ -51,6 +51,33 @@ function Runtime:ManualKey(stepIndex,goalIndex) return (self.currentGuide and self.currentGuide.id or "none")..":"..tostring(stepIndex)..":"..tostring(goalIndex) end +function Runtime:ClearStepArrivals(stepIndex,guide) + guide=guide or self.currentGuide + if not guide or not stepIndex then return end + local prefix=tostring(guide.id or "none")..":"..tostring(stepIndex)..":" + for key in pairs(self.arrivals or {}) do + if key:sub(1,#prefix)==prefix then self.arrivals[key]=nil end + end +end + +function Runtime:CompleteArrival(goal,stepIndex,goalIndex,reason) + self.arrivals=self.arrivals or {} + local key=self:ManualKey(stepIndex,goalIndex) + if self.arrivals[key] then return true,"arrival" end + local complete=false + if reason=="map-transition" and ZGV.Navigation and ZGV.Navigation.IsMapTransitionComplete then + complete=ZGV.Navigation:IsMapTransitionComplete(goal.mapTransition) + elseif ZGV.Navigation and goal.destination then + complete=ZGV.Navigation:IsArrived(goal.destination) + end + if complete then + self.arrivals[key]={time=type(GetTime)=="function" and GetTime() or 0,reason=reason} + ZGV:LogInfo("progress","arrival credit for "..key.." at "..tostring(goal.text or goal.destination and goal.destination.mapKey or "waypoint")) + return true,"arrival" + end + return false,reason or "position" +end + function Runtime:UsesCollectionBaseline(goal) if not goal or goal.questID or goal.objective or goal.allowExistingItems or goal.allowexistingitems then return false end return goal.action=="collect" or goal.action=="buy" or goal.action=="create" @@ -135,7 +162,12 @@ end -- class/race branches disappear atomically and cannot leave a talk or gossip -- objective behind to block the guide. function Runtime:IsStepApplicable(step) - if not step or not step.onlyIf or step.onlyIf=="" then return true end + if not step then return true end + if step.hearthDestination and ZGV.Conditions and type(ZGV.Conditions.Bound)=="function" then + local matches=ZGV.Conditions:Bound(step.hearthDestination) + if matches==false then return false end + end + if not step.onlyIf or step.onlyIf=="" then return true end local result,err=ZGV.Conditions:Evaluate(step.onlyIf,self.currentGuide) if err then self.currentGuide.conditionIssues=self.currentGuide.conditionIssues or {} @@ -226,9 +258,9 @@ function Runtime:IsGoalComplete(goal,stepIndex,goalIndex) -- such as Terokkar's Shadowy Disguise never reaches its own aura check. elseif action=="goto" then if goal.mapTransition and ZGV.Navigation and ZGV.Navigation.IsMapTransitionComplete then - return ZGV.Navigation:IsMapTransitionComplete(goal.mapTransition),"map-transition" + return self:CompleteArrival(goal,stepIndex,goalIndex,"map-transition") end - if ZGV.Navigation then return ZGV.Navigation:IsArrived(goal.destination),"position" end + if ZGV.Navigation then return self:CompleteArrival(goal,stepIndex,goalIndex,"position") end elseif action=="skill" or action=="reachskill" then return ZGV.Conditions:Skill(goal.skillName)>=(goal.skillRank or 1),"skill" elseif action=="skillmax" then @@ -300,7 +332,7 @@ function Runtime:IsGoalComplete(goal,stepIndex,goalIndex) end return true,"equipment" elseif action=="text" and goal.mapTransition and ZGV.Navigation and ZGV.Navigation.IsMapTransitionComplete then - return ZGV.Navigation:IsMapTransitionComplete(goal.mapTransition),"map-transition" + return self:CompleteArrival(goal,stepIndex,goalIndex,"map-transition") elseif action=="text" or action=="map" or action=="label" or action=="info" or action=="image" or action=="webheader" or action=="webinfo" or action=="webimage" then return true,"informational" end @@ -576,6 +608,7 @@ function Runtime:SelectGuide(value,step) if not guide then ZGV:Print("Guide not found: "..tostring(value)) return false end local parsed,err=ZGV.Parser:ParseGuide(guide) if not parsed then ZGV:LogError("guide parse",err) return false end + self.arrivals={} self.currentGuide=parsed local selected=tonumber(step) or self:ResolveStepJump(parsed,0,step) or 1 self.currentStep=math.max(1,math.min(selected,#parsed.steps)) @@ -618,6 +651,8 @@ function Runtime:SetStep(index,manual,loopGuard) return self:SetStep(index,true,true) end end + local previousStep=self.currentStep + self:ClearStepArrivals(previousStep) self.currentStep=index ZGV.db.profile.currentStep=index self.lastAdvance=GetTime() @@ -676,6 +711,7 @@ function Runtime:ResetCurrentGuide() if not self.currentGuide then return end local prefix=self.currentGuide.id..":" for key in pairs(self.manual) do if key:sub(1,#prefix)==prefix then self.manual[key]=nil end end + for key in pairs(self.arrivals or {}) do if key:sub(1,#prefix)==prefix then self.arrivals[key]=nil end end for key in pairs(self.killCounts) do if key:sub(1,#prefix)==prefix then self.killCounts[key]=nil end end for key in pairs(self.collectionBaselines) do if key:sub(1,#prefix)==prefix then self.collectionBaselines[key]=nil end end for key in pairs(self.talked) do if key:sub(1,#prefix)==prefix then self.talked[key]=nil end end @@ -1059,6 +1095,7 @@ function Runtime:OnEvent(event,...) end function Runtime:OnStartup() + self.arrivals={} self.killCounts=ZGV.db.char.killProgress or self.killCounts or {} ZGV.db.char.killProgress=self.killCounts self.collectionBaselines=ZGV.db.char.collectionBaselines or self.collectionBaselines or {} diff --git a/tools/tests/lua/test_buff_tag_conditions.lua b/tools/tests/lua/test_buff_tag_conditions.lua index cdff50e..077a3c0 100644 --- a/tools/tests/lua/test_buff_tag_conditions.lua +++ b/tools/tests/lua/test_buff_tag_conditions.lua @@ -14,11 +14,15 @@ end function GetSpellInfo(id) if id==32756 then return "Shadowy Disguise" end end +local bindLocation="Shattrath City" +function GetBindLocation() return bindLocation end dofile(addon.."Conditions.lua") local Conditions=assert(ZygorGuidesViewer.Conditions) assert(Conditions:HaveBuff("Shadowy Disguise##32756"),"name-plus-ID buff tags resolve through UnitBuff") assert(Conditions:HaveBuff(32756),"numeric buff tags resolve through GetSpellInfo") +assert(Conditions:Bound("Shattrath City"),"bind condition accepts the live Hearthstone destination") +assert(not Conditions:Bound("Thunderlord Stronghold"),"bind condition rejects a different authored destination") dofile(addon.."Parser.lua") local Parser=assert(ZygorGuidesViewer.Parser) @@ -26,6 +30,17 @@ local modifiers=Parser:ParseModifiers({"havebuff Shadowy Disguise##32756"}) local goal=Parser:ParseGoal("talk Scout Neftis##18714",modifiers) assert(goal.haveBuff==32756,"havebuff modifier preserves its spell ID") assert(goal.spellID==32756,"havebuff modifier exposes the spell ID to Runtime") +local hearthGuide=assert(Parser:ParseEntry({ + id="hearth-bind",title="Hearth bind",header={},raw=[[ +step +use Hearthstone##6948 +Hearth to Thunderlord Stronghold |complete subzone("Thunderlord Stronghold") |q 10505 +step +talk Gor'drek##21117 |goto Blade's Edge Mountains/0 52.32,57.75 +]] +})) +assert(hearthGuide.steps[1].hearthDestination=="Thunderlord Stronghold", + "hearth instruction preserves its expected bind destination on the step") -- Reproduce the critical part of the Terokkar "Who Are They?" branch. The -- player must stop on the actual buff objective until the disguise is active; diff --git a/tools/tests/lua/test_navigation_routes.lua b/tools/tests/lua/test_navigation_routes.lua index c96f8cc..200611c 100644 --- a/tools/tests/lua/test_navigation_routes.lua +++ b/tools/tests/lua/test_navigation_routes.lua @@ -77,6 +77,7 @@ GetItemCooldown=function() return 0,0,1 end IsSpellKnown=function(spell) return spell==556 end IsUsableSpell=function() return true end GetSpellCooldown=function() return 0,0,1 end +GetTime=function() return 100 end dofile(repo.."/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua") local Navigation=assert(ZGV.Navigation,"navigation module did not load") @@ -109,6 +110,21 @@ 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") +-- The spell API exposes the global cooldown through GetSpellCooldown. It is +-- not Astral Recall's actual cooldown and must not make the route graph churn +-- every time the player casts an unrelated spell. +GetSpellCooldown=function() return 99.5,1.5,1 end +local ports=Navigation:GetAvailableTravelPorts(ZGV.Data.routes) +local sawAstral=false +for _,port in ipairs(ports) do if port.mode=="astral" then sawAstral=true end end +assertEqual(sawAstral,true,"global cooldown does not remove Astral Recall from routing") +GetSpellCooldown=function() return 50,900,1 end +ports=Navigation:GetAvailableTravelPorts(ZGV.Data.routes) +sawAstral=false +for _,port in ipairs(ports) do if port.mode=="astral" then sawAstral=true end end +assertEqual(sawAstral,false,"real Astral Recall cooldown removes the travel option") +GetSpellCooldown=function() return 0,0,1 end + -- 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. diff --git a/tools/tests/lua/test_runtime_catalog.lua b/tools/tests/lua/test_runtime_catalog.lua index a35fd80..7517611 100644 --- a/tools/tests/lua/test_runtime_catalog.lua +++ b/tools/tests/lua/test_runtime_catalog.lua @@ -111,6 +111,10 @@ 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 +local bindLocation="Shattrath City" +function ZGV.Conditions:Bound(name) + return tostring(name or ""):lower()==bindLocation:lower() +end dofile(addon .. "Runtime.lua") local Runtime = ZGV.Runtime @@ -146,6 +150,63 @@ Runtime:OnEvent("SKILL_LINES_CHANGED") assertEqual(Runtime.currentStep,2,"training immediately advances to the next profession stage") skillRanks.engineering,skillMaximums.engineering=0,0 +-- A Hearthstone instruction is useful only when the character is actually +-- bound to the authored destination. Otherwise it must skip to the following +-- goal so regular route planning can direct the player there. +local hearthGuide=assert(ZGV.Parser:ParseEntry({ + id="hearth-runtime",title="Hearth runtime",header={},raw=[[ +step +use Hearthstone##6948 +Hearth to Thunderlord Stronghold |complete subzone("Thunderlord Stronghold") |q 10505 +step +talk Gor'drek##21117 |goto Blade's Edge Mountains/0 52.32,57.75 +]] +})) +assertEqual(hearthGuide.steps[1].hearthDestination,"Thunderlord Stronghold", + "runtime receives the authored hearth destination") +Runtime.currentGuide,Runtime.currentStep=hearthGuide,1 +Runtime.lastAdvance=0 +assertEqual(Runtime:IsStepApplicable(hearthGuide.steps[1]),false, + "Shattrath bind makes the Thunderlord hearth step inapplicable") +Runtime:Tick(true) +assertEqual(Runtime.currentStep,2,"mismatched hearth step advances to normal travel target") +bindLocation="Thunderlord Stronghold" +assertEqual(Runtime:IsStepApplicable(hearthGuide.steps[1]),true, + "matching bind keeps the authored hearth instruction available") +bindLocation="Shattrath City" +Runtime.currentGuide = { id = "runtime-test", title = "Runtime test", conditionIssues = {} } + +-- A standalone no-hearth directive may itself be conditional, but that +-- condition must never make the preceding quest objective or its whole step +-- inapplicable. In Blade's Edge this previously skipped the unfinished +-- Bladespire half of quest 10544 as soon as the player left Bloodmaul. +local curseGuide=assert(ZGV.Parser:ParseEntry({ + id="curse-both-clans",title="A Curse Upon Both of Your Clans",header={},raw=[[ +step +use Wicked Strong Fetish##30479 +kill Bladespire Evil Spirit##21446+ +Curse #5# Bladespire Hold Buildings |q 10544/1 |goto Blade's Edge Mountains/0 41.98,57.50 +|nohearth |only if subzone("Southmaul Tower") or subzone("Bloodmaul Outpost") or subzone("Bloodmaul Ravine") +step +talk T'chali the Witch Doctor##21349 +turnin A Curse Upon Both of Your Clans!##10544 |goto Blade's Edge Mountains/0 44.97,72.30 +]] +})) +local curseStep=curseGuide.steps[1] +local curseObjective=curseStep.goals[#curseStep.goals] +assertEqual(curseStep.onlyIf,nil,"conditional no-hearth does not condition the quest step") +assertEqual(curseObjective.onlyIf,nil,"conditional no-hearth does not condition the quest objective") +assertEqual(curseStep.travelConfig.use_hearth,false,"standalone no-hearth still disables hearth routing") +assert(curseStep.travelConfig.noHearthIf and curseStep.travelConfig.noHearthIf:find("Bloodmaul Outpost",1,true), + "the no-hearth condition remains available as travel metadata") +log[10544]={objectives={{finished=false,current=0,required=5}},complete=false} +Runtime.currentGuide=curseGuide +local curseState=Runtime:GetStepState(curseStep,1) +assertEqual(curseState.complete,false,"unfinished Bladespire buildings block automatic progression") +assertEqual(curseState.skipped,false,"leaving Bloodmaul does not skip the Bladespire objective") +log[10544]=nil +Runtime.currentGuide = { id = "runtime-test", title = "Runtime test", conditionIssues = {} } + local shadowyGuide=assert(ZGV.Parser:ParseEntry({ id="shadowy-runtime",title="Shadowy runtime",header={},raw=[[ step @@ -201,10 +262,10 @@ Runtime.currentGuide = { id = "runtime-test", title = "Runtime test", conditionI -- 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 +local currentArrivalX ZGV.Navigation={ waypoint=nil, - IsArrived=function(_,destination) return arrivedFirst and destination and destination.x==.10 end, + IsArrived=function(_,destination) return destination and destination.x==currentArrivalX end, IsMapTransitionComplete=function() return false end, SetWaypoint=function(self,destination,title) self.waypoint=destination @@ -214,6 +275,7 @@ ZGV.Navigation={ ClearWaypoint=function(self) self.waypoint=nil end, } Runtime.manual={} +Runtime.arrivals={} Runtime.waypointGoalKey=nil Runtime.currentGuide={id="movement-path",title="Movement path",conditionIssues={},steps={ {goals={ @@ -221,22 +283,34 @@ Runtime.currentGuide={id="movement-path",title="Movement path",conditionIssues={ {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}}, + {action="talk",text="Talk to the quest giver",npcID=123,destination={mapKey="Nagrand/0",x=.30,y=.30}}, + {action="accept",text="Accept the next quest",questID=999,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 +currentArrivalX=.10 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") +currentArrivalX=nil +local firstComplete=Runtime:IsGoalComplete(Runtime.currentGuide.steps[1].goals[1],1,1) +assertEqual(firstComplete,true,"reached path point remains complete after the player moves away") +Runtime:UpdateWaypoint() +assertEqual(waypointHistory[#waypointHistory].destination.x,.20,"moving away does not reactivate the reached path point") +currentArrivalX=.20 +Runtime:Tick(true) +assertEqual(Runtime.currentStep,2,"reaching the final path point advances immediately") +assertEqual(waypointHistory[#waypointHistory].destination.x,.30,"automatic path completion points to the next talk target") +assertEqual(waypointHistory[#waypointHistory].title,"Talk to the quest giver","automatic completion rebuilds the next goal title") Runtime.manual={} +Runtime.arrivals={} Runtime.waypointGoalKey=nil Runtime.currentStep=1 -arrivedFirst=false +currentArrivalX=nil 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") @@ -244,6 +318,7 @@ assertEqual(waypointHistory[#waypointHistory].destination.x,.20,"manual completi 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") +assertEqual(waypointHistory[#waypointHistory].title,"Talk to the quest giver","manual completion rebuilds the next goal title") ZGV.Navigation={ IsArrived=function() return false end, IsMapTransitionComplete=function(_,transition) diff --git a/tools/tests/test_root_runtime_contracts.py b/tools/tests/test_root_runtime_contracts.py index ec9bb55..1c98429 100644 --- a/tools/tests/test_root_runtime_contracts.py +++ b/tools/tests/test_root_runtime_contracts.py @@ -134,6 +134,7 @@ def test_diagnostics_are_bounded_persistent_and_user_accessible(self) -> None: def test_secure_action_bar_preserves_blizzards_click_handler(self) -> None: action_bar = source(self.closure, "ModernActionBar.lua") + automation = source(self.closure, "Automation.lua") self.assertIn('attributes.type="pet"; attributes.action=slot', action_bar) self.assertIn('button:SetScript("PostClick"', action_bar) self.assertNotIn('button:SetScript("OnClick"', action_bar) @@ -141,6 +142,9 @@ def test_secure_action_bar_preserves_blizzards_click_handler(self) -> None: self.assertIn('RegisterStateDriver(frame,"visibility","[combat] hide; show")', action_bar) self.assertIn('retryOutOfCombat("refresh"', action_bar) self.assertNotIn("config.hideInCombat and InCombatLockdown()", action_bar) + self.assertNotIn('CreateSecureActionButton("ZygorGuidesViewerActionButton"', automation) + self.assertNotIn('SetPoint("CENTER",UIParent,"CENTER",0,-180)', automation) + self.assertIn('return ZGV.ActionBar:Refresh()', automation) def test_guide_menu_uses_the_classic_grouped_options_layout(self) -> None: menu = source(self.closure, "ModernGuideMenu.lua")