diff --git a/src/server/game/World/ClientlessActionExecutorMgr.cpp b/src/server/game/World/ClientlessActionExecutorMgr.cpp index 59ca741dab..0fa228b534 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.cpp +++ b/src/server/game/World/ClientlessActionExecutorMgr.cpp @@ -694,9 +694,19 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation( // Location, so directRadius must stay independent from anchorRadius. if (directDistance <= directRadius) { - record.FinalLocalLegPending = true; - record.Active = true; - return StartFinalLocalLeg(player, characterGuid, record); + // C8.20: do not bypass the macro graph when the apparent direct + // distance is short but the actual local MMAP route is blocked. + Position directTarget; + directTarget.Relocate(point->X, point->Y, point->Z, point->O); + ClientlessPathDiagnostics directNav; + if (sClientlessTravelMgr->AnalyzePath(player, directTarget, directNav) && + directNav.Complete && directNav.FinalDistance <= 1.5f && + directNav.PathLength <= 500.0f) + { + record.FinalLocalLegPending = true; + record.Active = true; + return StartFinalLocalLeg(player, characterGuid, record); + } } record.Route.clear(); @@ -707,61 +717,48 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation( record.TaxiSourceNodeId = 0; record.TaxiDestinationNodeId = 0; + // VCORE_C820_VIRTUAL_EXIT_NODES: the same cost-based adaptive search + // evaluates node anchors, virtual exits, forward entries and node bridges. std::string anchorNode; std::string error; - if (sClientlessTravelGraphMgr->PlanRouteToPosition( - player, - point->MapId, - point->X, - point->Y, - point->Z, - startRadius, - anchorRadius, - record.Route, - anchorNode, - error, - &record.FailedEdgeDatabaseIds)) + float proxyDistance = std::numeric_limits::infinity(); + if (!sClientlessTravelGraphMgr->FindNearestNodeToPosition( + point->MapId, point->X, point->Y, point->Z, anchorNode, proxyDistance)) { - record.RouteComplete = true; - record.AdaptiveReachedNode = anchorNode; - record.FinalLocalLegPending = true; + record.Status.State = record.HardDestination + ? ClientlessActionExecutionState::Failed + : ClientlessActionExecutionState::Completed; + record.Status.Detail = "no graph node exists on destination map"; + record.Active = false; + return !record.HardDestination; } - else + record.PlannedDestinationNode = anchorNode; + ClientlessTravelLocationGoal goal; + goal.MapId = point->MapId; + goal.X = point->X; + goal.Y = point->Y; + goal.Z = point->Z; + goal.AnchorRadius = anchorRadius; + if (!sClientlessTravelGraphMgr->PlanAdaptiveRoute( + player, anchorNode, startRadius, record.LocalBridgeRadius, + record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal)) { - float proxyDistance = std::numeric_limits::infinity(); - if (!sClientlessTravelGraphMgr->FindNearestNodeToPosition( - point->MapId, point->X, point->Y, point->Z, anchorNode, proxyDistance)) - { - record.Status.State = record.HardDestination - ? ClientlessActionExecutionState::Failed - : ClientlessActionExecutionState::Completed; - record.Status.Detail = "no graph node exists on destination map"; - record.Active = false; - return !record.HardDestination; - } - - record.PlannedDestinationNode = anchorNode; - if (!sClientlessTravelGraphMgr->PlanAdaptiveRoute( - player, anchorNode, startRadius, record.LocalBridgeRadius, - record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, - &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys)) - { - TC_LOG_WARN("server.adventurebot.navigation", - "TRAVEL_PLAN_FAILED guid=%u name='%s' action='travel_to_location' location='%s' reason='%s'", - characterGuid, player->GetName().c_str(), locationId.c_str(), error.c_str()); - - record.Status.State = record.HardDestination - ? ClientlessActionExecutionState::Failed - : ClientlessActionExecutionState::Completed; - record.Status.Detail = record.HardDestination ? error : "no useful travel progress available"; - record.Active = false; - return !record.HardDestination; - } - - if (record.RouteComplete && proxyDistance > anchorRadius) - record.RouteComplete = false; - record.FinalLocalLegPending = record.RouteComplete; + TC_LOG_WARN("server.adventurebot.navigation", + "C820_LOCATION_PLAN_FAILED location='%s' reason='%s'", + locationId.c_str(), error.c_str()); + record.Status.State = record.HardDestination + ? ClientlessActionExecutionState::Failed + : ClientlessActionExecutionState::Completed; + record.Status.Detail = record.HardDestination ? error : "no useful travel progress available"; + record.Active = false; + return !record.HardDestination; } + record.FinalLocalLegPending = record.RouteComplete; + TC_LOG_INFO("server.adventurebot.navigation", + "C820_LOCATION_PLAN location='%s' routeComplete=%u reached='%s' steps=%u", + locationId.c_str(), record.RouteComplete ? 1u : 0u, + record.AdaptiveReachedNode.c_str(), uint32(record.Route.size())); record.Active = true; @@ -1244,6 +1241,25 @@ bool ClientlessActionExecutorMgr::StartRouteStep( record.EdgePath = *points; if (step.EdgeReverse) std::reverse(record.EdgePath.begin(), record.EdgePath.end()); + // VCORE_C820_VIRTUAL_EXIT_NODES: select an inclusive end-point + // on the original execution-direction spline before trimming start. + if (step.GroundEndIndex >= 0) + { + std::size_t const end = std::size_t(step.GroundEndIndex); + if (end >= record.EdgePath.size() || + (step.GroundStartIndex >= 0 && + std::size_t(step.GroundStartIndex) >= end)) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = "virtual ground exit indices invalid"; + record.Active = false; + return false; + } + record.EdgePath.resize(end + 1); + TC_LOG_INFO("server.adventurebot.navigation", + "C820_EXIT_EXEC edgeDbId=%u endIndex=%u points=%u", + step.EdgeDatabaseId, uint32(end), uint32(record.EdgePath.size())); + } // VCORE_VIRTUAL_GROUND_EDGE_ENTRY_V1: keep original recorded points, // only discard the portion before the validated virtual entry. if (step.GroundStartIndex >= 0) @@ -1632,38 +1648,30 @@ bool ClientlessActionExecutorMgr::RecoverTravel( if (record.Status.ActionName == "travel_to_location") { std::string anchorNode; - planned = sClientlessTravelGraphMgr->PlanRouteToPosition( - player, - record.FinalMapId, - record.FinalX, - record.FinalY, - record.FinalZ, - radius, - record.PlannedAnchorRadius, - record.Route, - anchorNode, - error, - &record.FailedEdgeDatabaseIds); - - if (planned) + float proxyDistance = std::numeric_limits::infinity(); + if (sClientlessTravelGraphMgr->FindNearestNodeToPosition( + record.FinalMapId, record.FinalX, record.FinalY, record.FinalZ, + anchorNode, proxyDistance)) { - record.RouteComplete = true; - record.AdaptiveReachedNode = anchorNode; - } - else if (!record.PlannedDestinationNode.empty()) + record.PlannedDestinationNode = anchorNode; + ClientlessTravelLocationGoal goal; + goal.MapId = record.FinalMapId; + goal.X = record.FinalX; + goal.Y = record.FinalY; + goal.Z = record.FinalZ; + goal.AnchorRadius = record.PlannedAnchorRadius; planned = sClientlessTravelGraphMgr->PlanAdaptiveRoute( - player, record.PlannedDestinationNode, radius, record.LocalBridgeRadius, + player, anchorNode, radius, record.LocalBridgeRadius, record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, - &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys); - + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal); + } + else + error = "no graph node exists on destination map"; if (planned) TC_LOG_WARN("server.adventurebot.navigation", - "RECOVERY_REPLAN guid=%u name='%s' action='travel_to_location' " - "location='%s' anchor='%s' radius=%.1f steps=%u blockedEdges=%u", - characterGuid, player->GetName().c_str(), - record.FinalLocationId.c_str(), anchorNode.c_str(), radius, - uint32(record.Route.size()), - uint32(record.FailedEdgeDatabaseIds.size())); + "C820_EXIT_RECOVERY location='%s' anchor='%s' complete=%u steps=%u", + record.FinalLocationId.c_str(), anchorNode.c_str(), + record.RouteComplete ? 1u : 0u, uint32(record.Route.size())); } else if (record.Status.ActionName == "travel_to_node") { @@ -2077,6 +2085,19 @@ void ClientlessActionExecutorMgr::UpdateTravelToLocation( record.FinalLocalLegActive = false; record.FinalLocalLegPending = true; + if (!record.Route.empty()) + { + ClientlessTravelRouteStep const& last = record.Route.back(); + if (last.GroundEndIndex >= 0 && last.EdgeDatabaseId) + { + std::string const exitKey = "exit:" + std::to_string(last.EdgeDatabaseId) + + ":" + std::to_string(last.GroundEndIndex) + (last.EdgeReverse ? "R" : "F"); + record.FailedBridgeKeys.insert(exitKey); + TC_LOG_WARN("server.adventurebot.navigation", + "C820_EXIT_BLOCKED edgeDbId=%u endIndex=%d reason='%s'", + last.EdgeDatabaseId, last.GroundEndIndex, reason.c_str()); + } + } RecoverTravel(player, characterGuid, record, 0, reason); } diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index 2b1346e35e..e9c7b59fab 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -387,7 +387,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( std::string& reachedNode, std::string& error, std::unordered_set const* blockedEdgeIds, - std::unordered_set const* blockedBridgeKeys) + std::unordered_set const* blockedBridgeKeys, + ClientlessTravelLocationGoal const* locationGoal) { // VCORE_C819_DEFERRED_START_NODES: run the unchanged C8.18 search in two tiers. // A partial ordinary route must not suppress a complete route via a deferred root. @@ -418,7 +419,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( // C8.18: this cap applies ONLY to virtual node-to-node bridges. // Start access, destination location access and virtual ground entries retain // their distinct travel/search limits. - constexpr float MaxVirtualNodeBridge = 100.0f; + constexpr float MaxVirtualNodeBridge = 200.0f; // C8.20 empirical cap if (bridgeRadius <= 0.0f) bridgeRadius = MaxVirtualNodeBridge; bridgeRadius = std::min(bridgeRadius, MaxVirtualNodeBridge); @@ -468,6 +469,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( uint32 PointIndex = 0; // original order of _edgePoints bool Reverse = false; ClientlessTravelEdgePoint Point; + float AccessCost = 0.0f; }; std::unordered_map virtualRoots; @@ -642,7 +644,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( persistentHops[key] = 1; previous.erase(key); virtualRoots[key] = { candidate.Edge.DatabaseId, candidate.Index, - candidate.Edge.Reverse, candidate.Point }; + candidate.Edge.Reverse, candidate.Point, + alreadyThere ? 0.0f : nav.PathLength }; open.push({ newCost, state }); ++accepted; } @@ -657,44 +660,12 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( return false; } - // C8.18: do not create intermediate shortcuts when a complete path already - // exists through persistent graph edges. All valid START roots (ordinary - // nodes and virtual ground entries) participate in this reachability test. - // The repair mode below is only enabled when this baseline has no goal. - std::unordered_set persistentReachable; - std::queue persistentOpen; - for (auto const& pair : distance) - { - if (previous.find(pair.first) == previous.end() && - std::isfinite(pair.second) && persistentReachable.insert(states.at(pair.first).Node).second) - persistentOpen.push(states.at(pair.first).Node); - } - while (!persistentOpen.empty()) - { - std::string const nodeKey = persistentOpen.front(); - persistentOpen.pop(); - auto outgoing = _edges.find(nodeKey); - if (outgoing == _edges.end()) - continue; - for (ClientlessTravelEdge const& edge : outgoing->second) - { - // A portal trigger source reached as a transit node may only - // continue through its explicitly stored portal edge. - bool const sourcePortal = nodeKey.rfind("portal_at_", 0) == 0 && - nodeKey.size() >= 7 && - nodeKey.compare(nodeKey.size() - 7, 7, "_source") == 0; - if (sourcePortal && edge.Type != "portal") - continue; - if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId)) - continue; - if (persistentReachable.insert(edge.ToNode).second) - persistentOpen.push(edge.ToNode); - } - } - bool const repairRequired = !persistentReachable.count(destinationNode); + // C8.20: virtual node bridges are equal-cost ground alternatives and + // participate even when stored graph routes also exist. + bool const repairRequired = true; TC_LOG_INFO("server.adventurebot.navigation", "C818_ROUTE_MODE destination='%s' mode='%s' roots=%u", - destinationNode.c_str(), repairRequired ? "repair" : "persistent", + destinationNode.c_str(), locationGoal ? "location" : "all-edges", uint32(startCount + accepted)); // Dynamic candidates may be examined many times during Dijkstra. Avoid @@ -719,7 +690,198 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( player->GetInstanceId(), a, b, NAV_GROUND); }; + // VCORE_C820_VIRTUAL_EXIT_NODES: preflight a bounded, geographical + // shortlist of directed recorded-road exits. Candidate generation does + // not mutate nodes, edges or stored path points. Costs use travelled + // arclength, not a flat per-bridge penalty or waypoint index fraction. + struct ExitCandidate + { + std::string FromNode; + uint32 EdgeId = 0; + bool Reverse = false; + uint32 EndIndex = 0; // in execution-direction original road points + float RoadCost = 0.0f; + float LocalCost = 0.0f; + float DistanceSq = 0.0f; + ClientlessTravelEdgePoint Point; + }; + std::vector rawExitCandidates; + std::unordered_map> exitsByFrom; + uint32 exitTests = 0, exitAccepted = 0; + if (locationGoal) + { + float const radius = std::min(300.0f, locationGoal->AnchorRadius); + std::unordered_set scannedDirected; + for (auto const& edgePair : _edges) + { + for (ClientlessTravelEdge const& edge : edgePair.second) + { + if (edge.Type != "ground" || !edge.DatabaseId || + (blockedEdgeIds && blockedEdgeIds->count(edge.DatabaseId))) + continue; + std::string const identity = std::to_string(edge.DatabaseId) + + (edge.Reverse ? "R" : "F"); + if (!scannedDirected.insert(identity).second) + continue; + auto pointsItr = _edgePoints.find(edge.DatabaseId); + if (pointsItr == _edgePoints.end() || pointsItr->second.size() < 3) + continue; + std::vector ordered = pointsItr->second; + if (edge.Reverse) + std::reverse(ordered.begin(), ordered.end()); + float totalLength = 0.0f; + for (std::size_t i = 1; i < ordered.size(); ++i) + { + float dx = ordered[i].X - ordered[i - 1].X; + float dy = ordered[i].Y - ordered[i - 1].Y; + float dz = ordered[i].Z - ordered[i - 1].Z; + totalLength += std::sqrt(dx * dx + dy * dy + dz * dz); + } + if (totalLength < 0.1f) + continue; + float travelled = 0.0f; + for (uint32 i = 1; i + 1 < ordered.size(); ++i) + { + float dx = ordered[i].X - ordered[i - 1].X; + float dy = ordered[i].Y - ordered[i - 1].Y; + float dz = ordered[i].Z - ordered[i - 1].Z; + travelled += std::sqrt(dx * dx + dy * dy + dz * dz); + ClientlessTravelEdgePoint const& point = ordered[i]; + if (point.MapId != locationGoal->MapId) + continue; + dx = point.X - locationGoal->X; + dy = point.Y - locationGoal->Y; + dz = point.Z - locationGoal->Z; + float const dsq = dx * dx + dy * dy + dz * dz; + if (dsq > radius * radius) + continue; + std::string const exitKey = "exit:" + std::to_string(edge.DatabaseId) + + ":" + std::to_string(i) + (edge.Reverse ? "R" : "F"); + if (blockedBridgeKeys && blockedBridgeKeys->count(exitKey)) + continue; + ExitCandidate candidate; + candidate.FromNode = edge.FromNode; + candidate.EdgeId = edge.DatabaseId; + candidate.Reverse = edge.Reverse; + candidate.EndIndex = i; + candidate.RoadCost = edge.Cost * (travelled / totalLength); + candidate.DistanceSq = dsq; + candidate.Point = point; + rawExitCandidates.push_back(std::move(candidate)); + } + } + } + std::sort(rawExitCandidates.begin(), rawExitCandidates.end(), + [](ExitCandidate const& a, ExitCandidate const& b) + { return a.DistanceSq < b.DistanceSq; }); + // Bound Detour preflight work, not the number of ground edges. + constexpr uint32 MaxExitPreflights = 96; + for (ExitCandidate candidate : rawExitCandidates) + { + if (exitTests >= MaxExitPreflights) + break; + ++exitTests; + Position fromPosition, goalPosition; + fromPosition.Relocate(candidate.Point.X, candidate.Point.Y, + candidate.Point.Z, candidate.Point.O); + goalPosition.Relocate(locationGoal->X, locationGoal->Y, + locationGoal->Z, 0.0f); + if (candidate.DistanceSq <= 2.25f) + candidate.LocalCost = 0.0f; + else + { + VcorePositionPathResult const probe = PathGenerator::AnalyzePositions( + locationGoal->MapId, locationGoal->MapId, + player->GetInstanceId(), fromPosition, goalPosition, NAV_GROUND); + if (!probe.IsComplete() || probe.FinalDistance > 1.5f || + !std::isfinite(probe.PathLength) || probe.PathLength > 500.0f) + continue; + candidate.LocalCost = probe.PathLength; + } + exitsByFrom[candidate.FromNode].push_back(std::move(candidate)); + ++exitAccepted; + } + TC_LOG_INFO("server.adventurebot.navigation", + "C820_EXIT_PREFLIGHT candidates=%u tested=%u accepted=%u radius=%.1f", + uint32(rawExitCandidates.size()), exitTests, exitAccepted, radius); + } + // Exact road-section cost from recorded arclength; handles reverse + // travel as well as virtual entry + exit within the SAME edge. + auto roadSectionCost = [&](uint32 edgeId, bool reverse, uint32 begin, + uint32 end, float fullCost) -> float + { + auto itr = _edgePoints.find(edgeId); + if (itr == _edgePoints.end() || end >= itr->second.size() || begin > end) + return std::numeric_limits::infinity(); + std::vector ordered = itr->second; + if (reverse) + std::reverse(ordered.begin(), ordered.end()); + float total = 0.0f, section = 0.0f; + for (uint32 i = 1; i < ordered.size(); ++i) + { + float dx = ordered[i].X - ordered[i - 1].X; + float dy = ordered[i].Y - ordered[i - 1].Y; + float dz = ordered[i].Z - ordered[i - 1].Z; + float const segment = std::sqrt(dx * dx + dy * dy + dz * dz); + total += segment; + if (i > begin && i <= end) + section += segment; + } + return total > 0.1f ? fullCost * section / total + : std::numeric_limits::infinity(); + }; + struct ExitSelection + { + bool Active = false; + bool RootPartial = false; + ExitCandidate Candidate; + } bestExit; + float bestGoalCost = std::numeric_limits::infinity(); std::string bestGoalState; + // C8.20: a virtual entry is represented in the queue by its FAR + // endpoint. Check exits BEFORE that endpoint independently: otherwise + // an exit halfway along the road would pay for the entire remaining edge. + if (locationGoal) + { + for (auto const& root : virtualRoots) + { + if (previous.find(root.first) != previous.end()) + continue; + for (auto const& exitGroup : exitsByFrom) + { + for (ExitCandidate const& exit : exitGroup.second) + { + if (exit.EdgeId != root.second.EdgeId || + exit.Reverse != root.second.Reverse) + continue; + auto pointsItr = _edgePoints.find(exit.EdgeId); + if (pointsItr == _edgePoints.end()) + continue; + uint32 const startIndex = root.second.Reverse ? + uint32(pointsItr->second.size() - 1 - root.second.PointIndex) : + root.second.PointIndex; + if (exit.EndIndex <= startIndex) + continue; + float fullEdgeCost = 0.0f; + auto fromNodeItr = _edges.find(exit.FromNode); + if (fromNodeItr != _edges.end()) + for (ClientlessTravelEdge const& edge : fromNodeItr->second) + if (edge.DatabaseId == exit.EdgeId && edge.Reverse == exit.Reverse) + fullEdgeCost = edge.Cost; + if (fullEdgeCost <= 0.0f) + continue; + float const partialCost = roadSectionCost(exit.EdgeId, + exit.Reverse, startIndex, exit.EndIndex, fullEdgeCost); + float const score = root.second.AccessCost + partialCost + exit.LocalCost; + if (!std::isfinite(score) || score >= bestGoalCost) + continue; + bestGoalCost = score; + bestGoalState = root.first; + bestExit = { true, true, exit }; + } + } + } + } while (!open.empty()) { @@ -731,12 +893,71 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( if (distanceItr == distance.end() || current.Cost != distanceItr->second) continue; - if (current.Value.Node == destinationNode) + if (!locationGoal && current.Value.Node == destinationNode) { bestGoalState = currentKey; break; } + // C8.20: location goal is a first-class search terminator. + // Node endpoints and road exits compete by the SAME full route cost. + if (locationGoal) + { + if (current.Cost >= bestGoalCost) + break; // Dijkstra lower bound; all remaining edges are nonnegative + ClientlessTravelNode const& candidateNode = _nodes.at(current.Value.Node); + bool const sourcePortal = candidateNode.Key.rfind("portal_at_", 0) == 0 && + candidateNode.Key.size() >= 7 && + candidateNode.Key.compare(candidateNode.Key.size() - 7, 7, "_source") == 0; + if (!sourcePortal && candidateNode.MapId == locationGoal->MapId) + { + float dx = candidateNode.X - locationGoal->X; + float dy = candidateNode.Y - locationGoal->Y; + float dz = candidateNode.Z - locationGoal->Z; + float const direct = std::sqrt(dx*dx + dy*dy + dz*dz); + if (direct <= locationGoal->AnchorRadius) + { + Position a, b; + a.Relocate(candidateNode.X, candidateNode.Y, candidateNode.Z, candidateNode.O); + b.Relocate(locationGoal->X, locationGoal->Y, locationGoal->Z, 0.0f); + float finalCost = std::numeric_limits::infinity(); + if (direct <= 1.5f) + finalCost = 0.0f; + else + { + VcorePositionPathResult const probe = PathGenerator::AnalyzePositions( + candidateNode.MapId, candidateNode.MapId, + player->GetInstanceId(), a, b, NAV_GROUND); + if (probe.IsComplete() && probe.FinalDistance <= 1.5f && + std::isfinite(probe.PathLength) && probe.PathLength <= 500.0f) + finalCost = probe.PathLength; + } + if (current.Cost + finalCost < bestGoalCost) + { + bestGoalCost = current.Cost + finalCost; + bestGoalState = currentKey; + bestExit = {}; + } + } + } + if (!sourcePortal) + { + auto exitsItr = exitsByFrom.find(current.Value.Node); + if (exitsItr != exitsByFrom.end()) + { + for (ExitCandidate const& exit : exitsItr->second) + { + float const score = current.Cost + exit.RoadCost + exit.LocalCost; + if (score >= bestGoalCost) + continue; + bestGoalCost = score; + bestGoalState = currentKey; + bestExit = { true, false, exit }; + } + } + } + } + // VCORE_C816_PORTAL_MOUNT_GUARD: a source node is not ordinary // ground transit. Once reached during a planned route, its only legal // continuation is the portal edge itself. Physical AreaTriggers may @@ -960,9 +1181,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( State next{ to.Key, true }; std::string const nextKey = stateKey(next); - // Large fixed penalty makes a real graph path preferable whenever - // one exists; distance then ranks alternative local bridges. - float const candidate = current.Cost + 1000.0f + measuredLength; + // C8.20: dynamic node-to-node MMAP bridges are ordinary local + // ground connections, not penalized fallback routes. + float const candidate = current.Cost + measuredLength; auto old = distance.find(nextKey); if (old != distance.end() && candidate >= old->second) continue; @@ -977,6 +1198,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( } } + if (locationGoal && bestExit.Active && bestExit.RootPartial && + !bestGoalState.empty()) + previous.erase(bestGoalState); std::string selectedState = bestGoalState; if (!selectedState.empty()) complete = true; @@ -1170,6 +1394,57 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( route.push_back(std::move(step)); } + if (locationGoal && complete && bestExit.Active) + { + ExitCandidate const& exit = bestExit.Candidate; + if (bestExit.RootPartial) + { + // The reconstructed root already contains the virtual entry + // waypoint and the matching road. Stop that SAME edge early. + if (route.empty() || route.back().EdgeType != "ground" || + route.back().EdgeDatabaseId != exit.EdgeId) + { + error = "virtual entry/exit reconstruction mismatch"; + route.clear(); + return false; + } + route.back().GroundEndIndex = int32(exit.EndIndex); + route.back().NodeKey = "virtual_ground_exit"; + route.back().MapId = exit.Point.MapId; + route.back().X = exit.Point.X; + route.back().Y = exit.Point.Y; + route.back().Z = exit.Point.Z; + route.back().O = exit.Point.O; + } + else + { + ClientlessTravelRouteStep end; + end.NodeKey = "virtual_ground_exit"; + end.NodeType = "road"; + end.EdgeType = "ground"; + end.EdgeDatabaseId = exit.EdgeId; + end.EdgeReverse = exit.Reverse; + end.GroundEndIndex = int32(exit.EndIndex); + end.MapId = exit.Point.MapId; + end.X = exit.Point.X; + end.Y = exit.Point.Y; + end.Z = exit.Point.Z; + end.O = exit.Point.O; + route.push_back(std::move(end)); + } + reachedNode = "virtual_ground_exit"; + TC_LOG_INFO("server.adventurebot.navigation", + "C820_EXIT_SELECTED edgeDbId=%u reverse=%u endIndex=%u " + "road=%.1f local=%.1f total=%.1f rootPartial=%u", + exit.EdgeId, exit.Reverse ? 1u : 0u, exit.EndIndex, + exit.RoadCost, exit.LocalCost, bestGoalCost, + bestExit.RootPartial ? 1u : 0u); + } + else if (locationGoal && complete) + TC_LOG_INFO("server.adventurebot.navigation", + "C820_NODE_FINISH node='%s' fullCost=%.1f", + reachedNode.c_str(), bestGoalCost); + TC_LOG_INFO("server.adventurebot.navigation", "C818_PREFLIGHT complete=%u bridgeTests=%u bridgeAccepted=%u bridgeRejected=%u " "distanceRejected=%u forwardTests=%u forwardAccepted=%u steps=%u", diff --git a/src/server/game/World/ClientlessTravelGraphMgr.h b/src/server/game/World/ClientlessTravelGraphMgr.h index 75fc73cf33..15d8a880c2 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.h +++ b/src/server/game/World/ClientlessTravelGraphMgr.h @@ -58,6 +58,17 @@ struct TC_GAME_API ClientlessTravelEdgePoint float O = 0.0f; }; +// VCORE_C820_VIRTUAL_EXIT_NODES: a location target used by the adaptive +// graph search. Node and road exits compete by complete travel cost. +struct TC_GAME_API ClientlessTravelLocationGoal +{ + uint32 MapId = 0; + float X = 0.0f; + float Y = 0.0f; + float Z = 0.0f; + float AnchorRadius = 1000.0f; +}; + struct TC_GAME_API ClientlessTravelRouteStep { std::string NodeKey; @@ -69,6 +80,9 @@ struct TC_GAME_API ClientlessTravelRouteStep // VCORE_VIRTUAL_GROUND_EDGE_ENTRY_V1: index in execution-direction edge points. // -1 means replay the entire recorded ground edge as before. int32 GroundStartIndex = -1; + // C8.20: inclusive index in the ORIGINAL execution-direction road points. + // -1 keeps the stored edge endpoint. Applied before GroundStartIndex. + int32 GroundEndIndex = -1; uint32 MapId = 0; float X = 0.0f; @@ -121,7 +135,8 @@ public: std::string& reachedNode, std::string& error, std::unordered_set const* blockedEdgeIds = nullptr, - std::unordered_set const* blockedBridgeKeys = nullptr); + std::unordered_set const* blockedBridgeKeys = nullptr, + ClientlessTravelLocationGoal const* locationGoal = nullptr); bool FindNearestNodeToPosition( uint32 mapId, diff --git a/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua b/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua index 5f23650e5a..e5c61a799f 100644 --- a/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua +++ b/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua @@ -7,12 +7,12 @@ log("Arthoran C8.13b bridge test loaded") -- approach_distance = 2 --}) ---queue_action("travel_to_location", { --- id = "kharanos_tavern" ---}) +queue_action("travel_to_location", { + id = "the_gilded_rose" +}) -queue_action("travel_to_node", { - node = "lakeshire", - bridge_radius = 400, - bridge_path_max = 600 -}) \ No newline at end of file +--queue_action("travel_to_node", { +-- node = "lakeshire", +-- bridge_radius = 400, +-- bridge_path_max = 600 +--}) \ No newline at end of file