diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua index 92630e5..769d7d0 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Compat/Taxi.lua @@ -204,7 +204,7 @@ function Taxi:GetTaxis() return paths end Taxi.GetTaxisEnglish = Taxi.GetTaxis -function Taxi:Startup(saved) +function Taxi:Startup(saved, options) if type(saved) ~= "table" then return false end for name, value in pairs(saved) do if type(name) == "string" then self:RememberKnownName(name) end @@ -221,10 +221,12 @@ function Taxi:Startup(saved) -- 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, - }) + if not (type(options) == "table" and options.silent) then + Compat:Fire("TAXI_CACHE_UPDATED", { + nodes = {}, byKey = {}, updatedAt = Compat.Now(), available = true, + restored = true, revision = self.revision, + }) + end return true end diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Core.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Core.lua index 267a8df..306016d 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 = "20260716-hearth-bind-awareness-01" +ZGV.buildRevision = "20260717-flight-arrival-loop-01" ZGV.interface = 30300 ZGV.targetBuild = 12340 ZGV.DIR = "Interface\\AddOns\\ZygorGuidesViewer" diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/ModernActionBar.lua b/ZygorGuidesViewer/ZygorGuidesViewer/ModernActionBar.lua index 04731b5..989a23c 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/ModernActionBar.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/ModernActionBar.lua @@ -218,7 +218,9 @@ function ActionBar:Create() -- viewer update an unprotected guide action after the hardware click. button:SetScript("PostClick",function(self) local action=self.action - if action and not self.secure then + if action and action.kind=="item" and action.entry then + ZGV.Runtime:RecordItemUse(action.identity,"zygor-action-bar") + elseif action and not self.secure then if action.kind=="trash" and ZGV.Inventory then ZGV.Inventory:SellGreys() elseif action.entry then ZGV.Runtime:ActivateGoal(action.entry.stepIndex,action.entry.goalIndex) end end diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua index 09a9c70..2e2b4aa 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Navigation.lua @@ -150,7 +150,21 @@ function Navigation:GetTransportArrivalIndex(player) for index=self.routeIndex or 1,#self.route.path do local entry=self.route.path[index] if entry and entry.node and entry.node.mapKey==player.key and transportModes[entry.mode] then - return index + -- A same-zone flight has the same map key before and after boarding. + -- Treating its destination as an arrival while the player is still at + -- the departure flight master forces FindRoute on every arrow update. + -- Only a transport that crosses from a different preceding map can be + -- completed solely from the current map identity; same-zone legs use + -- their normal destination-arrival check after the flight lands. + local previousMap=self.route.from + for previous=index-1,self.routeIndex or 1,-1 do + local prior=self.route.path[previous] + if prior and prior.node and prior.node.mapKey then + previousMap=prior.node.mapKey + break + end + end + if previousMap and previousMap~=entry.node.mapKey then return index end end end return nil @@ -1046,7 +1060,22 @@ function Navigation:FindRoute(from,to) -- 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 navigationOption("useTaxi") and taxi and type(taxi.GetKnownStaticNodes)=="function" then + -- Code-TBC's InitialFlightPaths facade normally restores this cache at + -- ZGV_STARTED. Navigation must not rely on that optional facade, though: + -- a route can be rebuilt during login, after a module reload, or by another + -- consumer before that callback runs. Bind the profile-owned table here so + -- every guide gets the character's learned flight network. This repair is + -- intentionally silent because FindRoute may be running from the taxi-cache + -- notification itself. + local savedTaxi=ZGV.db and ZGV.db.profile and ZGV.db.profile.navigation and ZGV.db.profile.navigation.knownTaxi + if taxi and type(savedTaxi)=="table" and taxi.saved~=savedTaxi and type(taxi.Startup)=="function" then + taxi:Startup(savedTaxi,{silent=true}) + end + -- Flight masters can also save time inside one large zone. Keep the + -- ordinary same-map START→FINISH edge below, then let Dijkstra compare it + -- with the learned flight network instead of excluding taxis solely because + -- the two coordinates share a map key. + if 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) @@ -1197,6 +1226,14 @@ function Navigation:OnStartup() hooksecurefunc("WorldMapFrame_Update",function() Navigation:UpdateMapPin() end) end if ZGV.Compat and ZGV.Compat.On then ZGV.Compat:On("TAXI_CACHE_UPDATED",self,"OnTaxiCache") end + -- Bind after subscribing so an active restored guide is immediately + -- replanned with its learned flight points, even when the legacy facade did + -- not run (for example after a partial addon reload). + local taxi=ZGV.Compat and ZGV.Compat.Taxi + local savedTaxi=ZGV.db and ZGV.db.profile and ZGV.db.profile.navigation and ZGV.db.profile.navigation.knownTaxi + if taxi and type(savedTaxi)=="table" and taxi.saved~=savedTaxi and type(taxi.Startup)=="function" then + taxi:Startup(savedTaxi) + end if ZGV.Compat and ZGV.Compat.Timer then self.ticker=ZGV.Compat.Timer:NewTicker(.1,function() if Navigation.waypoint then diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Parser.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Parser.lua index d39f15c..fc1dc2a 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Parser.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Parser.lua @@ -1354,6 +1354,37 @@ function Parser:_ParseGuide(guide, stack) goal.npcID=goal.npcID or lastGoal.npcID goal.destination=goal.destination or lastGoal.destination end + -- Imported guides frequently describe the NPC interaction as a + -- bare `talk` row, then put the coordinate on the immediately + -- following accept/turn-in row. They are one interaction at one + -- NPC, not two navigation stops. Give the talk (and any intervening + -- gossip choice) that same destination so navigation continues from + -- path points to the instruction the player is actually reading. + -- An explicit hand-in coordinate is authoritative; otherwise an + -- explicitly located talk row supplies the shared location. + if (goal.action=="accept" or goal.action=="turnin") and current then + local interactionGoals={} + local index=#current.goals + while index>=1 do + local previous=current.goals[index] + if previous.action=="gossip" then + interactionGoals[#interactionGoals+1]=previous + elseif previous.action=="talk" and not previous.questID then + interactionGoals[#interactionGoals+1]=previous + local destination=goal.destination or previous.destination + if destination then + goal.destination=destination + for _,interactionGoal in ipairs(interactionGoals) do + interactionGoal.destination=destination + end + end + break + else + break + end + index=index-1 + end + end if hadAsterisk then goal.showInBrief=true; goal.showinbrief=true end if #indent>0 then goal.indent=#indent end addGoal(goal,mods) diff --git a/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua b/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua index 84f98f3..29b16f9 100644 --- a/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua +++ b/ZygorGuidesViewer/ZygorGuidesViewer/Runtime.lua @@ -36,6 +36,29 @@ local function itemCount(id) return ZGV.Conditions and ZGV.Conditions:ItemCount(id,true) or 0 end +local function isItemUseGoal(goal) + return goal and goal.itemID and (goal.action=="use" or (goal.modifiers and goal.modifiers.useItem)) +end + +-- A reusable quest item often sits beside the one quest objective that +-- counts its repeated uses (for example, curse several buildings with one +-- fetish). GetStepState already joins those companion actions to that +-- objective once it is finished. Do not manually complete the item on its +-- first click or the secure action button disappears before the objective is +-- done. A direct item-use goal without that quest-progress contract still +-- receives immediate credit. +function Runtime:ItemUseTracksQuestProgress(step,goal) + if goal and goal.questID and goal.objective then return true end + local objectives=0 + for _,candidate in ipairs((step and step.goals) or {}) do + if candidate.questID and candidate.objective then + objectives=objectives+1 + if objectives>1 then return false end + end + end + return objectives==1 +end + local function questObjective(goal) if not goal or not goal.questID then return nil end local entry=questEntry(goal.questID) @@ -721,6 +744,73 @@ function Runtime:ResetCurrentGuide() self:SetStep(1,true) end +-- A secure item button is deliberately owned by Blizzard's protected click +-- handler, so its successful use never passes through ActivateGoal. Record +-- the interaction here instead, regardless of whether it came from a bag, +-- the normal action bar, or Zygor's secure action bar. Matching by item ID +-- keeps this scoped to the active guide's authored `use` instructions. +function Runtime:RecordItemUse(itemID,source) + itemID=tonumber(itemID) + local guide=self.currentGuide + if not itemID or not guide then return false end + local changed,matched=false,false + for _,entry in ipairs(self:GetActiveSteps()) do + for goalIndex,goal in ipairs(entry.step.goals) do + if isItemUseGoal(goal) and tonumber(goal.itemID)==itemID and self:IsGoalApplicable(goal) then + matched=true + if not self:ItemUseTracksQuestProgress(entry.step,goal) then + local key=self:ManualKey(entry.index,goalIndex) + if not self.manual[key] then + self.manual[key]={source=source or "item-use",itemID=itemID,time=GetTime()} + changed=true + end + end + end + end + end + if not changed then return matched end + self.autoAdvanceBlocked=nil + ZGV:LogInfo("progress","item-use credit for item "..tostring(itemID).." via "..tostring(source or "unknown")) + ZGV:Fire("ZGV_GOAL_UPDATED",guide,self.currentStep) + local step=guide.steps[self.currentStep] + if step and self:GetStepState(step,self.currentStep).complete then + self:NextStep(true) + else + self:UpdateWaypoint() + end + return true +end + +-- ITEM_LOCKED is raised while a bag item's pre-use identity is still +-- available. Keep it briefly because a consumable can disappear before the +-- post-use hook below gets to inspect its slot. +function Runtime:RememberItemLock(bag,slot) + if bag==nil or slot==nil then return end + local container=ZGV.Compat and ZGV.Compat.Container + local itemID=container and container.GetItemID and container:GetItemID(bag,slot) + if not itemID then return end + self.itemLocks=self.itemLocks or {} + self.itemLocks[tostring(bag)..":"..tostring(slot)]={itemID=tonumber(itemID),time=GetTime()} +end + +function Runtime:RecordContainerItemUse(bag,slot) + if bag==nil or slot==nil then return false end + local key=tostring(bag)..":"..tostring(slot) + local container=ZGV.Compat and ZGV.Compat.Container + local itemID=container and container.GetItemID and container:GetItemID(bag,slot) + local locked=self.itemLocks and self.itemLocks[key] + if self.itemLocks then self.itemLocks[key]=nil end + if not itemID and locked and GetTime()-locked.time<=5 then itemID=locked.itemID end + return self:RecordItemUse(itemID,"bag") +end + +function Runtime:RecordActionUse(slot) + if type(GetActionInfo)~="function" then return false end + local action,itemID=GetActionInfo(slot) + if action~="item" then return false end + return self:RecordItemUse(itemID,"action-bar") +end + function Runtime:ActivateGoal(stepIndex,goalIndex) local guide=self.currentGuide local step=guide and guide.steps[stepIndex] @@ -733,9 +823,12 @@ function Runtime:ActivateGoal(stepIndex,goalIndex) local destination=self:ResolveStepJump(guide,stepIndex,goal.nextJump or goal.nextLabel) if destination then return self:SetStep(destination,true) end end - if goal.itemID and (goal.action=="use" or goal.modifiers.useItem) and ZGV.Inventory then + if isItemUseGoal(goal) and ZGV.Inventory then local used=ZGV.Inventory:UseItem(goal.itemID) - if used then return true end + -- The bag-use hook normally records this before Inventory:UseItem + -- returns. Calling the matcher again also covers clients where that + -- event is delayed, and is harmless after the first credit. + if used then return self:RecordItemUse(goal.itemID,"guide-action") or true end end self.manual[self:ManualKey(stepIndex,goalIndex)]=true ZGV:Fire("ZGV_GOAL_UPDATED",guide,stepIndex,goalIndex) @@ -897,7 +990,6 @@ function Runtime:RecordTalk(event) -- opening. Mouseover is a useful fallback for click-to-interact users. local npcID=npcIDFromGUID(UnitGUID and UnitGUID("target")) or npcIDFromGUID(UnitGUID and UnitGUID("mouseover")) - if not npcID then return end local changed=false for _,entry in ipairs(self:GetActiveSteps()) do for goalIndex,goal in ipairs(entry.step.goals) do @@ -908,7 +1000,14 @@ function Runtime:RecordTalk(event) changed=true end end - if (goal.action=="vendor" or goal.action=="trainer") and tonumber(goal.npcID)==npcID and self:IsGoalApplicable(goal) then + -- TRAINER_SHOW is emitted only after the player opens a trainer window. + -- Build 12340 can clear the selected NPC before that event reaches an + -- addon, which made class-training steps depend on an unavailable GUID. + -- The active guide's trainer instruction is enough context to credit + -- the visit; vendor interactions still require an exact NPC match. + local trainerVisit=goal.action=="trainer" and event=="TRAINER_SHOW" + local matchedNpc=npcID and tonumber(goal.npcID)==npcID + if (trainerVisit or (goal.action=="vendor" and matchedNpc)) and self:IsGoalApplicable(goal) then local key=self:ManualKey(entry.index,goalIndex) if not self.interacted[key] then self.interacted[key]={npcID=npcID,event=event,time=time()} @@ -919,7 +1018,7 @@ function Runtime:RecordTalk(event) end if changed then self.autoAdvanceBlocked=nil - ZGV:LogInfo("progress","talk credit for npc "..tostring(npcID).." via "..tostring(event)) + ZGV:LogInfo("progress","talk credit for npc "..tostring(npcID or "trainer").." via "..tostring(event)) ZGV:Fire("ZGV_GOAL_UPDATED",guide,self.currentStep) end end @@ -1072,6 +1171,7 @@ function Runtime:OnEvent(event,...) if not self.currentGuide then return end local unit=... if event=="COMBAT_LOG_EVENT_UNFILTERED" then self:RecordKillFromCombatLog(...) end + if event=="ITEM_LOCKED" then self:RememberItemLock(...) end if event=="GOSSIP_SHOW" or event=="QUEST_GREETING" or event=="QUEST_DETAIL" or event=="QUEST_PROGRESS" or event=="QUEST_COMPLETE" or event=="MERCHANT_SHOW" or event=="TRAINER_SHOW" or event=="TAXIMAP_OPENED" then @@ -1116,6 +1216,15 @@ function Runtime:OnStartup() hooksecurefunc("GetQuestReward",function() Runtime:RecordTurnIn(nil,nil,"GetQuestReward") end) self.rewardHooked=true end + if not self.itemUseHooked and type(hooksecurefunc)=="function" then + if type(UseContainerItem)=="function" then + hooksecurefunc("UseContainerItem",function(bag,slot) Runtime:RecordContainerItemUse(bag,slot) end) + end + if type(UseAction)=="function" then + hooksecurefunc("UseAction",function(slot) Runtime:RecordActionUse(slot) end) + end + self.itemUseHooked=true + end local quest=ZGV.Compat and ZGV.Compat.Quest if quest then quest:RefreshLog() quest:RefreshCompleted(false) end local saved=ZGV.db.profile.currentGuide @@ -1132,7 +1241,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","SKILL_LINES_CHANGED","TRADE_SKILL_UPDATE"}) do +for _,event in ipairs({"QUEST_LOG_UPDATE","BAG_UPDATE","ITEM_LOCKED","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/tools/tests/lua/test_navigation_routes.lua b/tools/tests/lua/test_navigation_routes.lua index 200611c..3eba564 100644 --- a/tools/tests/lua/test_navigation_routes.lua +++ b/tools/tests/lua/test_navigation_routes.lua @@ -12,6 +12,7 @@ local continents={ ["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, + ["The Barrens/0"]=1, } function map:Resolve(key) if type(key) == "table" then return key end @@ -31,9 +32,17 @@ local taxiNodes={ {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}, + {key="thunderlord",mapKey="Blade's Edge Mountains/0",name="Thunderlord Stronghold",title="Thunderlord Flight Master",x=.5205,y=.5413}, + {key="moknathal",mapKey="Blade's Edge Mountains/0",name="Mok'Nathal Village",title="Mok'Nathal Flight Master",x=.7637,y=.6593}, + {key="crossroads",mapKey="The Barrens/0",name="The Crossroads",title="Crossroads Flight Master",x=.515,y=.303}, + {key="taurajo",mapKey="The Barrens/0",name="Camp Taurajo",title="Camp Taurajo Flight Master",x=.445,y=.592}, + {key="unupe",mapKey="Borean Tundra/0",name="Unu'pe",title="Unu'pe Flight Master",x=.785,y=.515}, } Compat.Map=map -Compat.Taxi={GetKnownStaticNodes=function() return taxiNodes end} +Compat.Taxi={ + GetKnownStaticNodes=function() return taxiNodes end, + Startup=function(self,saved) self.saved=saved return true end, +} ZygorGuidesViewer={ Compat=Compat, @@ -86,6 +95,8 @@ local Navigation=assert(ZGV.Navigation,"navigation module did not load") -- explicit actions expected by the arrow: reach the flight master, then fly. player={key="Terokkar Forest/0",valid=true,x=.59,y=.55} local route=assert(Navigation:FindRoute(nil,{key="Shattrath City/0",x=.60,y=.42,title="Shattrath objective"}),"known taxi route was not found") +local crossZoneTaxiRoute=route +assertEqual(Compat.Taxi.saved,ZGV.db.profile.navigation.knownTaxi,"route planning restores the saved taxi cache before selecting a route") assertEqual(#route.path,2,"taxi route vertex count") assertEqual(route.path[1].mode,"walk","taxi route starts by walking to the flight master") assertEqual(route.path[2].mode,"taxi","taxi route includes a flight action") @@ -135,6 +146,39 @@ 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") +-- Same-zone flights are map-agnostic. Exercise a long leg in each world +-- region so this cannot become a Blade's Edge-only exception. The planner +-- must compare the usual direct route with its learned taxi network and use +-- the latter only when it is quicker. +local sameZoneFlightCases={ + {mapKey="The Barrens/0",fromX=.515,fromY=.303,toX=.445,toY=.592,from="Crossroads Flight Master",to="Camp Taurajo Flight Master"}, + {mapKey="Blade's Edge Mountains/0",fromX=.5205,fromY=.5413,toX=.7637,toY=.6593,from="Thunderlord Flight Master",to="Mok'Nathal Flight Master"}, + {mapKey="Borean Tundra/0",fromX=.58,fromY=.68,toX=.785,toY=.515,from="Valiance Keep Flight Master",to="Unu'pe Flight Master"}, +} +for _,case in ipairs(sameZoneFlightCases) do + player={key=case.mapKey,valid=true,x=case.fromX+.01,y=case.fromY+.01} + route=assert(Navigation:FindRoute(nil,{key=case.mapKey,x=case.toX,y=case.toY,title="same-zone objective"}),"same-zone flight route was not found for "..case.mapKey) + assertEqual(route.path[1].node.title,case.from,"same-zone route starts at the nearest flight master for "..case.mapKey) + assertEqual(route.path[1].mode,"walk","same-zone route walks to the departure flight master for "..case.mapKey) + assertEqual(route.path[2].node.title,case.to,"same-zone route chooses the destination flight master for "..case.mapKey) + assertEqual(route.path[2].mode,"taxi","same-zone long-distance route recommends the flight for "..case.mapKey) +end + +-- A same-zone taxi's destination shares the current map key, but it has not +-- been reached until the flight ends. Replanning it on every arrow update +-- caused a continuous route-build loop and severe frame-rate drops. +Navigation.route=route +Navigation.routeIndex=1 +Navigation.waypoint={key="Borean Tundra/0",mapKey="Borean Tundra/0",x=.785,y=.515,title="same-zone objective"} +assertEqual(Navigation:GetTransportArrivalIndex(player),nil,"same-zone taxi is not mistaken for an arrived transport") + +-- Cross-zone flights still replan as soon as the client reports arrival on +-- the new map, avoiding a stale source-zone route after a taxi flight. +Navigation.route=crossZoneTaxiRoute +Navigation.routeIndex=1 +player={key="Shattrath City/0",valid=true,x=.60,y=.42} +assertEqual(Navigation:GetTransportArrivalIndex(player),2,"cross-zone taxi arrival replans from the destination map") + -- 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"} diff --git a/tools/tests/lua/test_runtime_catalog.lua b/tools/tests/lua/test_runtime_catalog.lua index 7517611..89bdeb9 100644 --- a/tools/tests/lua/test_runtime_catalog.lua +++ b/tools/tests/lua/test_runtime_catalog.lua @@ -204,6 +204,12 @@ 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") +local curseTalk,curseTurnIn=curseGuide.steps[2].goals[1],curseGuide.steps[2].goals[2] +assertEqual(curseTalk.action,"talk","interaction step retains the visible talk instruction") +assert(curseTalk.destination==curseTurnIn.destination, + "a bare talk instruction shares its following hand-in navigation point") +assertEqual(curseTalk.destination.x,.4497,"talk inherits the hand-in x coordinate") +assertEqual(curseTalk.destination.y,.723,"talk inherits the hand-in y coordinate") log[10544]=nil Runtime.currentGuide = { id = "runtime-test", title = "Runtime test", conditionIssues = {} } @@ -319,6 +325,113 @@ 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") + +-- Item-use goals are fulfilled by the protected item action itself, not by +-- an item-count change: quest starters can vanish immediately, while masks +-- and other reusable quest items remain in the bag. Any path that reports +-- the item ID must mark the matching active use goal and replan at once. +Runtime.manual={} +Runtime.arrivals={} +Runtime.waypointGoalKey=nil +local itemUseGuide={id="item-use",title="Item use",conditionIssues={},steps={ + {goals={{action="use",itemID=31120,text="Use Meeting Note",destination={mapKey="Blade's Edge Mountains/0",x=.40,y=.40}}}}, + {goals={{action="goto",text="Continue after using the note",destination={mapKey="Blade's Edge Mountains/0",x=.50,y=.50}}}}, +}} +Runtime.currentGuide=itemUseGuide +Runtime.currentStep=1 +Runtime.lastAdvance=0 +currentArrivalX=nil +Runtime:UpdateWaypoint() +assertEqual(Runtime:RecordItemUse(99999,"test"),false,"unrelated item use cannot complete a guide goal") +assert(Runtime:RecordItemUse(31120,"test"),"matching item use receives guide credit") +local usedNote=Runtime:GetStepState(Runtime.currentGuide.steps[1],1) +assertEqual(usedNote.goals[1].complete,true,"Meeting Note use is marked complete") +assertEqual(Runtime.currentStep,2,"item use advances immediately to the following step") +assertEqual(waypointHistory[#waypointHistory].destination.x,.50,"item use immediately updates the waypoint") + +-- Reusable quest items must remain actionable until their linked objective +-- completes. The Blades Edge Wicked Strong Fetish is used repeatedly to +-- curse buildings; treating its first click as manual completion removed the +-- secure action button before the quest objective could finish. +Runtime.manual={} +Runtime.arrivals={} +Runtime.waypointGoalKey=nil +log[90501]={objectives={{current=1,required=5,finished=false}},complete=false} +Runtime.currentGuide={id="repeat-item-use",title="Repeat item use",conditionIssues={},steps={ + {goals={ + {action="use",itemID=30479,text="Use Wicked Strong Fetish",destination={mapKey="Blade's Edge Mountains/0",x=.42,y=.58}}, + {action="goal",questID=90501,objective=1,text="Curse 5 buildings",destination={mapKey="Blade's Edge Mountains/0",x=.42,y=.58}}, + }}, + {goals={{action="goto",text="Continue after cursing",destination={mapKey="Blade's Edge Mountains/0",x=.50,y=.50}}}}, +}} +Runtime.currentStep=1 +Runtime.lastAdvance=0 +Runtime:UpdateWaypoint() +assert(Runtime:RecordItemUse(30479,"test"),"reusable quest item use is recognised") +assertEqual(Runtime.manual[Runtime:ManualKey(1,1)],nil,"first reusable item use is not manually completed") +local repeatItemState=Runtime:GetStepState(Runtime.currentGuide.steps[1],1) +assertEqual(repeatItemState.goals[1].complete,false,"reusable item remains incomplete while its quest objective is partial") +assertEqual(Runtime:GetDisplayGoals(1)[1].state.complete,false,"reusable item remains eligible for the quest action bar") +assertEqual(Runtime.currentStep,1,"partial reusable item use does not advance the guide") +log[90501].objectives[1].current=5 +log[90501].objectives[1].finished=true +repeatItemState=Runtime:GetStepState(Runtime.currentGuide.steps[1],1) +assertEqual(repeatItemState.goals[1].complete,true,"completed quest objective finishes the reusable item instruction") +Runtime:Tick(true) +assertEqual(Runtime.currentStep,2,"completed reusable item objective advances the guide") +log[90501]=nil + +-- A consumable can have already disappeared from its bag slot by the time +-- UseContainerItem's safe post-hook runs. ITEM_LOCKED supplies the pre-use +-- identity so clicking that same item directly in the bag still completes it. +Runtime.manual={} +Runtime.currentGuide=itemUseGuide +Runtime.currentStep=1 +Runtime.waypointGoalKey=nil +local lockedItemID=31120 +ZGV.Compat.Container={GetItemID=function() return lockedItemID end} +Runtime:RememberItemLock(0,1) +lockedItemID=nil +assert(Runtime:RecordContainerItemUse(0,1),"bag use retains a consumable item's pre-use identity") +assertEqual(Runtime.currentStep,2,"bag item use advances without waiting for BAG_UPDATE") +ZGV.Compat.Container=nil + +Runtime.manual={} +Runtime.currentStep=1 +Runtime.waypointGoalKey=nil +local usedByGuideAction=0 +ZGV.Inventory={UseItem=function(_,itemID) + assertEqual(itemID,31120,"guide action uses its authored item") + usedByGuideAction=usedByGuideAction+1 + return true,"used" +end} +assert(Runtime:ActivateGoal(1,1),"guide item action completes after the protected use succeeds") +assertEqual(usedByGuideAction,1,"guide item action invokes the inventory use path once") +assertEqual(Runtime.currentStep,2,"guide item action advances without waiting for a timer") +ZGV.Inventory=nil + +-- TRAINER_SHOW is the reliable confirmation that a class trainer was opened. +-- On build 12340 the target GUID can already be cleared at that point, so a +-- trainer instruction must not require a second target lookup to receive +-- credit or advance the guide. +Runtime.manual={} +Runtime.interacted={} +Runtime.currentGuide={id="trainer-visit",title="Trainer visit",conditionIssues={},steps={ + {goals={{action="trainer",npcID=2126,text="Train Abilities",destination={mapKey="Tirisfal Glades/0",x=.3091,y=.6634}}}}, + {goals={{action="goto",text="Continue after training",destination={mapKey="Tirisfal Glades/0",x=.32,y=.66}}}}, +}} +Runtime.currentStep=1 +Runtime.lastAdvance=0 +local originalUnitGUID=UnitGUID +UnitGUID=function() return nil end +time=function() return 0 end +Runtime:OnEvent("TRAINER_SHOW") +local trained=Runtime:GetStepState(Runtime.currentGuide.steps[1],1) +assertEqual(trained.goals[1].complete,true,"opening a trainer credits the active trainer goal without a target GUID") +assertEqual(Runtime.interacted[Runtime:ManualKey(1,1)].event,"TRAINER_SHOW","trainer credit records the trainer-window event") +Runtime:Tick(true) +assertEqual(Runtime.currentStep,2,"trainer visit advances to the following guide step") +UnitGUID=originalUnitGUID ZGV.Navigation={ IsArrived=function() return false end, IsMapTransitionComplete=function(_,transition)