From cc3ac5fd7a8801c513c391f2f491379a51734411 Mon Sep 17 00:00:00 2001 From: bash Date: Sun, 10 May 2026 00:29:34 +0200 Subject: [PATCH] feat(Core/Travel): Match cmangos MoveFarTo flow (mmap-first, 25y reach, single-point fallback) --- src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp | 240 ++++++------------- src/Ai/World/Rpg/Action/NewRpgBaseAction.h | 10 - 2 files changed, 69 insertions(+), 181 deletions(-) diff --git a/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp b/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp index 60312cec2..a0fdeed2b 100644 --- a/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp +++ b/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp @@ -52,34 +52,11 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest) if (IsWaitingForLastMove(MovementPriority::MOVEMENT_NORMAL)) return false; - // Let previously committed movement finish before recomputing. - // - // MoveTo internally caps its stored delay at maxWaitForMove - // (default 5s), but a long path (200+ yd routed around a - // mountain) takes 30+ seconds to walk. After 5s - // IsWaitingForLastMove returns false and MoveFarTo re-enters. - // Without this gate, DoMovePoint would call mm->Clear() and - // reissue MovePoint from the new bot position — and from a new - // position mmap's partial-path endpoint often differs, so the - // bot gets clobbered mid-walk and ends up oscillating around an - // unreachable destination. - // - // If the bot is still actively walking toward its last - // committed point on the same map, just let the current spline - // finish. - { - LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); - if (bot->isMoving() && lastMove.lastMoveToMapId == bot->GetMapId()) - { - float remaining = bot->GetExactDist(lastMove.lastMoveToX, lastMove.lastMoveToY, lastMove.lastMoveToZ); - if (remaining > 10.0f) - { - EmitDebugMove("MoveFar", "spline-plan", - lastMove.lastMoveToX, lastMove.lastMoveToY, lastMove.lastMoveToZ); - return true; - } - } - } + // Already-at-dest short-stop. Below targetPosRecalcDistance + // (default 0.1y) the move is effectively done — no need to + // recompute or dispatch. + if (bot->GetExactDist(dest) < sPlayerbotAIConfig.targetPosRecalcDistance) + return false; // 10% lastPath reuse — if the cached path's endpoint is still // close (within 10%) to the new dest, trim the cached path to @@ -145,71 +122,25 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest) } } - float disToDest = bot->GetDistance(dest); - float dis = bot->GetExactDist(dest); + float const dis = bot->GetExactDist(dest); - // Decision tree (cmangos ResolveMovePath order — travel nodes first): + // Decision tree: // - // 1. Active node plan? Ride it. - // - // 2. Long-distance move (>= nodeFirstDis) and travel nodes - // enabled: try the node graph FIRST. The graph holds - // curated waypoints that avoid known bad terrain. - // - // 3. If no node plan returned: run the 40-step chained mmap - // probe and dispatch its waypoint chain. - // - // 4. Empty / non-progressing probe: fall back to single- - // waypoint spline at dest. - bool tryNodes = (dis >= nodeFirstDis && sPlayerbotAIConfig.enableTravelNodes); + // 1. 40-step chained mmap probe FIRST. + // 2. Regression guard — if cached lastPath ends ≤ as close to + // dest as the new probe, ride the cached path instead. + // 3. If probe reaches dest within 25y, dispatch probe waypoints. + // 4. Else if travel nodes enabled, try the node graph as fallback. + // 5. Else dispatch the destination as a single-waypoint spline + // via MoveTo — engine MovePoint(generatePath=true) resolves + // the local route via PathGenerator. - // If a node plan is already active, ride it — but only if its - // destination still matches the requested dest. Otherwise the - // old plan (e.g. built toward a quest objective POI) would keep - // driving the bot after the caller switched targets (e.g. to a - // turn-in NPC). cmangos's ResolveMovePath dodges this by being - // stateless; we have a long-lived plan flag, so check explicitly. - if (tryNodes && botAI->rpgInfo.HasActiveTravelPlan()) - { - if (botAI->rpgInfo.travelPlan.destination.distance(dest) > 10.0f) - botAI->rpgInfo.ClearTravel(); - else - return UpdateTravelPlan(); - } - - // PRIORITY: try the travel-node graph FIRST when the move is - // long enough to need it. - if (tryNodes) - { - StartTravelPlan(dest); - if (botAI->rpgInfo.HasActiveTravelPlan()) - { - LOG_INFO("playerbots", "[MoveFar] {} nodetravel | dis={:.0f}", - bot->GetName(), dis); - EmitDebugMove("MoveFar", "travelplan", - dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); - return UpdateTravelPlan(); - } - // Graph returned no plan — fall through to mmap probe. - } - else if (botAI->rpgInfo.HasActiveTravelPlan()) - { - // Move dropped below node-first threshold — drop any leftover plan. - botAI->rpgInfo.ClearTravel(); - } - - // 40-step chained mmap probe — fallback when the node graph - // returned no plan (or for short moves below nodeFirstDis). + // 40-step chained mmap probe. WorldPosition botPos(bot); std::vector probe = botPos.getPathTo(dest, bot); - // Regression guard (cmangos ResolveMovePath parity): if a cached - // lastPath ends at least as close to dest as the new probe's - // endpoint, prefer the cached path. The 10% reuse block above - // already returned early when cached was within 10% of dest; - // this catches "cached is far (>10%) but still better than the - // probe" — typically when the probe got blocked by geometry and - // ended much farther from dest than where cached had reached. + // Regression guard: if a cached lastPath ends at least as close + // to dest as the new probe's endpoint, ride the cached path. { LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); if (!lastMove.lastPath.empty() && !probe.empty() && probe.size() >= 2) @@ -259,107 +190,74 @@ bool NewRpgBaseAction::MoveFarTo(WorldPosition dest) } } - // Walk the chained probe's full waypoint chain via MoveSplinePath. - // Handing the full waypoint vector to the motion master removes - // its discretion to introduce a straight-line shortcut between - // intermediate points. - if (!probe.empty() && probe.size() >= 2) + // Probe dispatch — only if the probe reaches dest within 25y. + // Partial probes that fall short go through the graph fallback + // below instead of being dispatched as-is. + constexpr float PROBE_REACH = 25.0f; + if (!probe.empty() && probe.size() >= 2 && dest.isPathTo(probe, PROBE_REACH)) { - WorldPosition stepDest = probe.back(); - float endDistToDest = dest.GetExactDist(stepDest.GetPositionX(), - stepDest.GetPositionY(), stepDest.GetPositionZ()); - if (endDistToDest + 5.0f < disToDest) + Movement::PointsArray points; + points.reserve(probe.size()); + for (auto const& wp : probe) + points.emplace_back(wp.GetPositionX(), wp.GetPositionY(), wp.GetPositionZ()); + + for (auto& pt : points) + bot->UpdateAllowedPositionZ(pt.x, pt.y, pt.z); + + if (points.size() >= 2) { - // Convert WorldPosition probe to G3D::Vector3 array. - Movement::PointsArray points; - points.reserve(probe.size()); - for (auto const& wp : probe) - points.emplace_back(wp.GetPositionX(), wp.GetPositionY(), wp.GetPositionZ()); + LOG_INFO("playerbots", "[MoveFar] {} mmap-path | dis={:.0f} | wp={}", + bot->GetName(), dis, (uint32)points.size()); + EmitDebugMove("MoveFar", "mmap", + points.back().x, points.back().y, points.back().z); - // Per-waypoint Z-snap to current ground. - for (auto& pt : points) - bot->UpdateAllowedPositionZ(pt.x, pt.y, pt.z); + if (!bot->IsMounted() && !bot->IsInCombat() && bot->IsOutdoors() && bot->IsAlive()) + botAI->DoSpecificAction("check mount state", Event(), true); - if (points.size() >= 2) - { - LOG_INFO("playerbots", "[MoveFar] {} mmap-path | dis={:.0f} | endDist={:.0f} | wp={}", - bot->GetName(), dis, endDistToDest, (uint32)points.size()); - EmitDebugMove("MoveFar", "mmap", - points.back().x, points.back().y, points.back().z); + bot->GetMotionMaster()->Clear(); + bot->GetMotionMaster()->MoveSplinePath(&points, FORCED_MOVEMENT_RUN); - // Mount up if outdoors and not in combat. - if (!bot->IsMounted() && !bot->IsInCombat() && bot->IsOutdoors() && bot->IsAlive()) - botAI->DoSpecificAction("check mount state", Event(), true); + G3D::Vector3 const& last = points.back(); + float totalDist = 0.f; + for (size_t i = 1; i < points.size(); ++i) + totalDist += (points[i] - points[i - 1]).length(); + float speed = std::max(bot->GetSpeed(MOVE_RUN), 0.1f); + uint32 expectedMs = static_cast((totalDist / speed) * IN_MILLISECONDS); + uint32 cappedMs = std::min(expectedMs, (uint32)sPlayerbotAIConfig.maxWaitForMove); + LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); + lastMove.Set(bot->GetMapId(), last.x, last.y, last.z, + bot->GetOrientation(), cappedMs, MovementPriority::MOVEMENT_NORMAL); - // Bulk dispatch: hand the full waypoint chain to the - // motion master via MoveSplinePath. Motion master plays - // every point in sequence — no per-tick re-dispatching. - bot->GetMotionMaster()->Clear(); - bot->GetMotionMaster()->MoveSplinePath(&points, FORCED_MOVEMENT_RUN); + std::vector wpts; + wpts.reserve(points.size()); + for (auto const& pt : points) + wpts.emplace_back(bot->GetMapId(), pt.x, pt.y, pt.z); + lastMove.setPath(TravelPath(wpts)); - // Update LastMovement to the chain endpoint so spline- - // active early-exit at the top of MoveFarTo silences - // recompute attempts during the walk. - G3D::Vector3 const& last = points.back(); - float totalDist = 0.f; - for (size_t i = 1; i < points.size(); ++i) - totalDist += (points[i] - points[i - 1]).length(); - float speed = std::max(bot->GetSpeed(MOVE_RUN), 0.1f); - uint32 expectedMs = static_cast((totalDist / speed) * IN_MILLISECONDS); - uint32 cappedMs = std::min(expectedMs, (uint32)sPlayerbotAIConfig.maxWaitForMove); - LastMovement& lastMove = AI_VALUE(LastMovement&, "last movement"); - lastMove.Set(bot->GetMapId(), last.x, last.y, last.z, - bot->GetOrientation(), cappedMs, MovementPriority::MOVEMENT_NORMAL); - - // Cache full chain for downstream consumers - // (LastLongMoveValue) and the lastPath reuse check. - std::vector wpts; - wpts.reserve(points.size()); - for (auto const& pt : points) - wpts.emplace_back(bot->GetMapId(), pt.x, pt.y, pt.z); - lastMove.setPath(TravelPath(wpts)); - - return true; - } + return true; } } - // Probe failed or didn't progress — emit visibility whisper so - // the user can see WHY mmap didn't dispatch. + // Travel-node graph fallback — fires when the probe didn't reach + // dest within PROBE_REACH and the graph is enabled. + if (sPlayerbotAIConfig.enableTravelNodes) { - bool const probeProgressed = !probe.empty() && probe.size() >= 2 && - (dest.GetExactDist(probe.back().GetPositionX(), - probe.back().GetPositionY(), probe.back().GetPositionZ()) + 5.0f < disToDest); - if (!probeProgressed) + StartTravelPlan(dest); + if (botAI->rpgInfo.HasActiveTravelPlan()) { - char const* reason = (probe.empty() || probe.size() < 2) ? "mmap-empty" : "mmap-noprogress"; - EmitDebugMove("MoveFar", reason, - dest.GetPositionX(), dest.GetPositionY(), - dest.GetPositionZ()); + LOG_INFO("playerbots", "[MoveFar] {} nodetravel | dis={:.0f}", + bot->GetName(), dis); + EmitDebugMove("MoveFar", "travelplan", + dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); + return UpdateTravelPlan(); } } - // Empty / non-progressing path falls back to dispatching the - // destination as a single waypoint. Spline only when target is - // line-of-sight: dispatching a straight line through walls - // produces visible clipping/glitching. If LOS is blocked we - // refuse and let UnstuckAction (5/10 min) catch the stuck. - bool const inLOS = bot->IsWithinLOS(dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); - LOG_INFO("playerbots", "[MoveFar] {} spline | dis={:.0f} | probe.empty={} | LOS={}", - bot->GetName(), dis, - probe.empty() ? "y" : "n", - inLOS ? "y" : "n"); - if (!inLOS) - { - EmitDebugMove("MoveFar", "spline-blocked", - dest.GetPositionX(), dest.GetPositionY(), - dest.GetPositionZ()); - return false; // Refuse to dispatch a straight line through geometry. - } + // Final fallback: dispatch the destination as a single waypoint. + // MoveTo's MovePoint(generatePath=true) lets the engine resolve the + // local route via PathGenerator. Nothing dispatched if MoveTo refuses. EmitDebugMove("MoveFar", "spline", dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ()); - // Same exact_waypoint=false rationale as the mmap branch — terrain- - // following spline, not a straight diagonal. return MoveTo(dest.GetMapId(), dest.GetPositionX(), dest.GetPositionY(), dest.GetPositionZ(), false, false, false, false); } diff --git a/src/Ai/World/Rpg/Action/NewRpgBaseAction.h b/src/Ai/World/Rpg/Action/NewRpgBaseAction.h index 2a9df1692..c720473f9 100644 --- a/src/Ai/World/Rpg/Action/NewRpgBaseAction.h +++ b/src/Ai/World/Rpg/Action/NewRpgBaseAction.h @@ -76,16 +76,6 @@ protected: bool RandomChangeStatus(std::vector candidateStatus); bool CheckRpgStatusAvailable(NewRpgStatus status); -protected: - /* FOR MOVE FAR */ - // Distance at which MoveFarTo considers the travel-node graph as - // a routing option. Below this, the move is short enough that - // mmap handles it directly. Above this, mmap is *still probed - // first* via the 40-step chained pathfinder; the node graph - // only takes over if mmap can't get within spellDistance of - // the destination. - const float nodeFirstDis = 75.0f; - private: void StartTravelPlan(WorldPosition dest); bool UpdateTravelPlan();