#include "ClientlessTravelGraphMgr.h" #include "ClientlessTransportMgr.h" #include "ClientlessFlightMgr.h" #include "DatabaseEnv.h" #include "Log.h" #include "Player.h" #include "ClientlessTravelMgr.h" #include "PathGenerator.h" // VCORE_C818_PREDICTIVE_GRAPH #include "ObjectMgr.h" #include "SpellMgr.h" #include "SpellInfo.h" #include #include #include #include #include ClientlessTravelGraphMgr* ClientlessTravelGraphMgr::instance() { static ClientlessTravelGraphMgr instance; return &instance; } bool ClientlessTravelGraphMgr::EnsureLoaded() { if (_loaded) return true; return Reload(); } bool ClientlessTravelGraphMgr::ResolvePortalUse(uint32 spawnId, ClientlessPortalUse& portal, std::string& error) { portal = {}; auto const* spawn = sObjectMgr->GetGameObjectData(spawnId); auto const* info = spawn ? sObjectMgr->GetGameObjectTemplate(spawn->id) : nullptr; if (!info || info->type != GAMEOBJECT_TYPE_SPELLCASTER) { error = "spawn must be a static SPELLCASTER GameObject (type 22)"; return false; } SpellInfo const* spell = sSpellMgr->GetSpellInfo(info->spellcaster.spell); SpellTargetPosition const* destination = nullptr; if (spell) for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i) { auto const& effect = spell->Effects[i]; if (effect.Effect != SPELL_EFFECT_TELEPORT_UNITS) continue; // Only explicit database destinations are unambiguous. Homebind, // script-selected destinations and triggered spell chains need // their own interaction definitions, not guessed target nodes. if (destination || (effect.TargetA.GetTarget() != TARGET_DEST_DB && effect.TargetB.GetTarget() != TARGET_DEST_DB)) { error = "portal spell has an ambiguous or non-DB destination"; return false; } destination = sSpellMgr->GetSpellTargetPosition(spell->Id, SpellEffIndex(i)); if (!destination) { error = "portal teleport effect has no native spell_target_position"; return false; } } if (!destination) { error = "portal requires a direct teleport spell with a fixed DB destination"; return false; } portal.SpawnId = spawnId; portal.Entry = spawn->id; portal.SpellId = spell->Id; portal.Source.Key = "portal_go_" + std::to_string(spawnId) + "_source"; portal.Source.Type = "portal"; portal.Source.WorldPointKey = portal.Source.Key; portal.Source.MapId = spawn->mapId; portal.Source.X = spawn->spawnPoint.GetPositionX(); portal.Source.Y = spawn->spawnPoint.GetPositionY(); portal.Source.Z = spawn->spawnPoint.GetPositionZ(); portal.Source.O = spawn->spawnPoint.GetOrientation(); portal.Target.Key = "portal_go_" + std::to_string(spawnId) + "_target"; portal.Target.Type = "portal"; portal.Target.WorldPointKey = portal.Target.Key; portal.Target.MapId = destination->target_mapId; portal.Target.X = destination->target_X; portal.Target.Y = destination->target_Y; portal.Target.Z = destination->target_Z; portal.Target.O = destination->target_Orientation; return true; } ClientlessPortalUse const* ClientlessTravelGraphMgr::GetPortalUse(uint32 edgeDatabaseId) const { auto itr = _portalUses.find(edgeDatabaseId); return itr != _portalUses.end() ? &itr->second : nullptr; } bool ClientlessTravelGraphMgr::Reload() { _nodes.clear(); _edges.clear(); _edgePoints.clear(); _portalUses.clear(); _edgeCount = 0; QueryResult nodeResult = WorldDatabase.Query( "SELECT n.id, n.node_key, n.node_type, n.world_point_key, " "w.map, w.position_x, w.position_y, w.position_z, w.orientation " "FROM vcore_clientless_travel_node n " "INNER JOIN vcore_clientless_world_point w " "ON w.point_key = n.world_point_key " "WHERE n.enabled = 1 AND w.enabled = 1 " "ORDER BY n.id"); if (nodeResult) { do { Field* fields = nodeResult->Fetch(); ClientlessTravelNode node; node.DatabaseId = fields[0].GetUInt32(); node.Key = fields[1].GetString(); node.Type = fields[2].GetString(); node.WorldPointKey = fields[3].GetString(); node.MapId = fields[4].GetUInt16(); node.X = fields[5].GetFloat(); node.Y = fields[6].GetFloat(); node.Z = fields[7].GetFloat(); node.O = fields[8].GetFloat(); if (!node.Key.empty()) _nodes[node.Key] = std::move(node); } while (nodeResult->NextRow()); } std::unordered_map portalBindings; // The schema is opt-in, so existing installations retain their graph even // before applying the interactive-portal migration. if (WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_portal_use'")) if (QueryResult bindings = WorldDatabase.Query( "SELECT edge_id,gameobject_spawn_id,gameobject_entry,spell_id FROM vcore_clientless_portal_use")) do { Field* fields = bindings->Fetch(); ClientlessPortalUse portal; std::string error; if (!ResolvePortalUse(fields[1].GetUInt32(), portal, error) || portal.Entry != fields[2].GetUInt32() || portal.SpellId != fields[3].GetUInt32()) { TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_BINDING_INVALID edge=%u reason='%s'", fields[0].GetUInt32(), error.c_str()); continue; } portalBindings.emplace(fields[0].GetUInt32(), std::move(portal)); } while (bindings->NextRow()); QueryResult edgeResult = WorldDatabase.Query( "SELECT id, from_node_key, to_node_key, edge_type, cost, bidirectional " "FROM vcore_clientless_travel_edge " "WHERE enabled = 1 " "ORDER BY id"); if (edgeResult) { do { Field* fields = edgeResult->Fetch(); ClientlessTravelEdge edge; edge.DatabaseId = fields[0].GetUInt32(); edge.FromNode = fields[1].GetString(); edge.ToNode = fields[2].GetString(); edge.Type = fields[3].GetString(); edge.Cost = fields[4].GetFloat(); bool const bidirectional = fields[5].GetBool(); if (_nodes.find(edge.FromNode) == _nodes.end() || _nodes.find(edge.ToNode) == _nodes.end()) { TC_LOG_WARN("server.adventurebot", "[AdventureBot] ignoring travel edge id=%u from='%s' to='%s': node missing", edge.DatabaseId, edge.FromNode.c_str(), edge.ToNode.c_str()); continue; } if (edge.Type.empty()) edge.Type = "ground"; if (edge.Cost <= 0.0f) edge.Cost = 1.0f; if (edge.Type == "portal_use") { auto binding = portalBindings.find(edge.DatabaseId); auto const* portal = binding != portalBindings.end() ? &binding->second : nullptr; auto const& target = _nodes.at(edge.ToNode); if (!portal || bidirectional || edge.FromNode != portal->Source.Key || edge.ToNode != portal->Target.Key || _nodes.at(edge.FromNode).MapId != portal->Source.MapId || target.MapId != portal->Target.MapId || std::hypot(std::hypot(target.X - portal->Target.X, target.Y - portal->Target.Y), target.Z - portal->Target.Z) > 1.0f) { TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_EDGE_UNAVAILABLE edge=%u", edge.DatabaseId); continue; } _portalUses.emplace(edge.DatabaseId, *portal); } if (ClientlessTransportMgr::IsTransportEdge(edge.Type) && !sClientlessTransportMgr->HasConnection(edge.FromNode, edge.ToNode)) { TC_LOG_WARN("server.adventurebot.navigation", "TRANSPORT_EDGE_UNAVAILABLE edge=%u from='%s' to='%s'", edge.DatabaseId, edge.FromNode.c_str(), edge.ToNode.c_str()); continue; } _edges[edge.FromNode].push_back(edge); ++_edgeCount; if (bidirectional) { ClientlessTravelEdge reverse = edge; reverse.FromNode = edge.ToNode; reverse.ToNode = edge.FromNode; reverse.Reverse = true; _edges[reverse.FromNode].push_back(std::move(reverse)); ++_edgeCount; } } while (edgeResult->NextRow()); } QueryResult pointResult = WorldDatabase.Query( "SELECT edge_id, point_index, map, position_x, position_y, position_z, orientation " "FROM vcore_clientless_travel_edge_point " "ORDER BY edge_id, point_index"); if (pointResult) { do { Field* fields = pointResult->Fetch(); ClientlessTravelEdgePoint point; uint32 edgeId = fields[0].GetUInt32(); point.MapId = fields[2].GetUInt16(); point.X = fields[3].GetFloat(); point.Y = fields[4].GetFloat(); point.Z = fields[5].GetFloat(); point.O = fields[6].GetFloat(); _edgePoints[edgeId].push_back(point); } while (pointResult->NextRow()); } _loaded = true; TC_LOG_INFO("server.adventurebot", "[AdventureBot] loaded travel graph: %u node(s), %u directed edge(s).", uint32(_nodes.size()), _edgeCount); return !_nodes.empty(); } std::string ClientlessTravelGraphMgr::FindNearestStartNode( Player const* player, float maxDistance) const { if (!player || !player->IsInWorld()) return {}; std::string bestKey; float bestDistance = std::numeric_limits::max(); for (auto const& pair : _nodes) { ClientlessTravelNode const& node = pair.second; if (node.MapId != player->GetMapId()) continue; float const dx = node.X - player->GetPositionX(); float const dy = node.Y - player->GetPositionY(); float const dz = node.Z - player->GetPositionZ(); float const distance = std::sqrt(dx * dx + dy * dy + dz * dz); if (distance > maxDistance || distance >= bestDistance) continue; bestDistance = distance; bestKey = pair.first; } return bestKey; } bool ClientlessTravelGraphMgr::PlanRoute( Player const* player, std::string const& destinationNode, float startRadius, std::vector& route, std::string& error, std::unordered_set const* blockedEdgeIds) { route.clear(); error.clear(); EnsureLoaded(); if (!player || !player->IsInWorld()) { error = "player is not in world"; return false; } if (_nodes.find(destinationNode) == _nodes.end()) { error = "unknown destination node: " + destinationNode; return false; } if (startRadius <= 0.0f) startRadius = 500.0f; std::string const startNode = FindNearestStartNode(player, startRadius); if (startNode.empty()) { error = "no travel node near current position"; return false; } struct QueueItem { float Cost; std::string Node; bool operator>(QueueItem const& other) const { return Cost > other.Cost; } }; struct Previous { std::string Node; std::string EdgeType; uint32 EdgeDatabaseId = 0; bool EdgeReverse = false; }; std::priority_queue, std::greater> open; std::unordered_map distance; std::unordered_map previous; for (auto const& pair : _nodes) distance[pair.first] = std::numeric_limits::infinity(); distance[startNode] = 0.0f; open.push({ 0.0f, startNode }); while (!open.empty()) { QueueItem current = open.top(); open.pop(); if (current.Cost != distance[current.Node]) continue; if (current.Node == destinationNode) break; auto edgeItr = _edges.find(current.Node); if (edgeItr == _edges.end()) continue; for (ClientlessTravelEdge const& edge : edgeItr->second) { if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->find(edge.DatabaseId) != blockedEdgeIds->end()) continue; float const candidate = current.Cost + edge.Cost; if (candidate >= distance[edge.ToNode]) continue; distance[edge.ToNode] = candidate; previous[edge.ToNode] = { current.Node, edge.Type, edge.DatabaseId, edge.Reverse }; open.push({ candidate, edge.ToNode }); } } if (!std::isfinite(distance[destinationNode])) { error = "no known route from " + startNode + " to " + destinationNode; return false; } std::vector nodeKeys; std::string cursor = destinationNode; nodeKeys.push_back(cursor); while (cursor != startNode) { auto previousItr = previous.find(cursor); if (previousItr == previous.end()) { error = "route reconstruction failed"; return false; } cursor = previousItr->second.Node; nodeKeys.push_back(cursor); } std::reverse(nodeKeys.begin(), nodeKeys.end()); route.reserve(nodeKeys.size()); for (std::size_t i = 0; i < nodeKeys.size(); ++i) { ClientlessTravelNode const& node = _nodes.at(nodeKeys[i]); ClientlessTravelRouteStep step; step.NodeKey = node.Key; step.NodeType = node.Type; step.WorldPointKey = node.WorldPointKey; step.MapId = node.MapId; step.X = node.X; step.Y = node.Y; step.Z = node.Z; step.O = node.O; if (i == 0) step.EdgeType = "start"; else { Previous const& prev = previous.at(nodeKeys[i]); step.EdgeType = prev.EdgeType; step.EdgeDatabaseId = prev.EdgeDatabaseId; step.EdgeReverse = prev.EdgeReverse; } route.push_back(std::move(step)); } return true; } namespace { std::string MakeLocalBridgeKey(std::string const& from, std::string const& to) { return from + "->" + to; } } bool ClientlessTravelGraphMgr::FindNearestNodeToPosition( uint32 mapId, float x, float y, float z, std::string& nodeKey, float& resultDistance) const { nodeKey.clear(); resultDistance = std::numeric_limits::infinity(); for (auto const& pair : _nodes) { ClientlessTravelNode const& node = pair.second; if (node.MapId != mapId) continue; float const dx = node.X - x; float const dy = node.Y - y; float const dz = node.Z - z; float const distance = std::sqrt(dx * dx + dy * dy + dz * dz); if (distance >= resultDistance) continue; resultDistance = distance; nodeKey = node.Key; } return !nodeKey.empty(); } bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( Player const* player, std::string const& destinationNode, float startRadius, float bridgeRadius, std::vector& route, bool& complete, std::string& reachedNode, std::string& error, std::unordered_set const* blockedEdgeIds, std::unordered_set const* blockedBridgeKeys, ClientlessTravelLocationGoal const* locationGoal) { sClientlessFlightMgr->BeginPlanning(); struct FlightPlanningScope { ~FlightPlanningScope() { sClientlessFlightMgr->BeginPlanning(); } } flightPlanningScope; // 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. auto runSearch = [&](bool allowDeferred) -> bool { route.clear(); reachedNode.clear(); error.clear(); complete = false; EnsureLoaded(); if (!player || !player->IsInWorld()) { error = "player is not in world"; return false; } auto destinationItr = _nodes.find(destinationNode); if (destinationItr == _nodes.end()) { error = "unknown destination node: " + destinationNode; return false; } bool const flightEnabled = sClientlessFlightMgr->Enabled(player); auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z) { if (!flightEnabled || from.MapId != map) return std::numeric_limits::infinity(); Position a,b; a.Relocate(from.X,from.Y,from.Z,from.O); b.Relocate(x,y,z); return sClientlessFlightMgr->Estimate(player,map,a,b); }; if (startRadius <= 0.0f) startRadius = 500.0f; // 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 = 200.0f; // C8.20 empirical cap if (bridgeRadius <= 0.0f) bridgeRadius = MaxVirtualNodeBridge; bridgeRadius = std::min(bridgeRadius, MaxVirtualNodeBridge); struct State { std::string Node; bool LastWasBridge = false; // Preserve each validated start entry until same-edge exits are scored. std::string EntryKey; }; auto stateKey = [](State const& state) { return state.Node + (state.LastWasBridge ? "\x1f" "B" : "\x1f" "R") + state.EntryKey; }; struct QueueItem { float Cost = 0.0f; State Value; bool operator>(QueueItem const& other) const { return Cost > other.Cost; } }; struct Previous { std::string StateKey; std::string Node; std::string EdgeType; uint32 EdgeDatabaseId = 0; bool EdgeReverse = false; // An intermediate virtual entry is represented by the landing waypoint // followed by the remaining portion of its REAL recorded ground edge. bool VirtualGroundEntry = false; uint32 GroundStartIndex = 0; ClientlessTravelEdgePoint EntryPoint; }; std::priority_queue, std::greater> open; std::unordered_map distance; std::unordered_map previous; std::unordered_map states; std::unordered_map persistentHops; // VCORE_VIRTUAL_GROUND_EDGE_ENTRY_V1: a root may be a virtual recorded-road entry. struct VirtualEntry { uint32 EdgeId = 0; uint32 PointIndex = 0; // original order of _edgePoints bool Reverse = false; ClientlessTravelEdgePoint Point; float AccessCost = 0.0f; }; std::unordered_map virtualRoots; // 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(); }; uint32 startCount = 0; for (auto const& pair : _nodes) { ClientlessTravelNode const& node = pair.second; if (node.MapId != player->GetMapId()) continue; float const dx = node.X - player->GetPositionX(); float const dy = node.Y - player->GetPositionY(); float const dz = node.Z - player->GetPositionZ(); float const localDistance = std::sqrt(dx * dx + dy * dy + dz * dz); if (localDistance > startRadius) continue; float startCost = localDistance; // VCORE_VIRTUAL_EDGE_V11_PORTAL_APPROACH: never seed an unreachable node. if (blockedBridgeKeys && blockedBridgeKeys->count("start:" + node.Key)) continue; if (localDistance > 1.5f) { Position entryTarget; entryTarget.Relocate(node.X, node.Y, node.Z, node.O); ClientlessPathDiagnostics entryNav; sClientlessTravelMgr->AnalyzePath(const_cast(player), entryTarget, entryNav); bool const reachable = entryNav.Calculated && entryNav.Complete && entryNav.FinalDistance <= 1.5f && entryNav.MaxSegmentLength <= 8.0f && entryNav.MaxZDelta <= 5.0f && entryNav.PathLength <= 500.0f; // VCORE_C819_DEFERRED_START_NODES: incomplete is not the same as unsafe. // The endpoint is handled by the executor, not counted as arrival at node. bool const partial = entryNav.Calculated && (entryNav.PathType & uint32(PATHFIND_INCOMPLETE)) != 0 && (entryNav.PathType & uint32(PATHFIND_NOPATH)) == 0 && entryNav.PointCount >= 2 && entryNav.PathLength >= 2.0f && entryNav.PathLength <= 500.0f && entryNav.MaxSegmentLength <= 8.0f && entryNav.MaxZDelta <= 5.0f && entryNav.FinalDistance + 1.0f < entryNav.DirectDistance; if (!reachable && !(allowDeferred && partial)) { if (partial && !allowDeferred) TC_LOG_INFO("server.adventurebot.navigation", "C819_START_NODE_DEFER node='%s' path=%.2f final=%.2f", node.Key.c_str(), entryNav.PathLength, entryNav.FinalDistance); else if (!partial) TC_LOG_INFO("server.adventurebot.navigation", "START_NODE_REJECT node='%s' distance=%.2f reason='%s'", node.Key.c_str(), localDistance, entryNav.Reason.c_str()); continue; } startCost = entryNav.PathLength; if (!reachable) startCost += 100000.0f; // Tier 2 only: deferred roots rank after reachable roots } State state{ node.Key, false }; std::string const key = stateKey(state); auto found = distance.find(key); if (found != distance.end() && found->second <= startCost) continue; distance[key] = startCost; states[key] = state; persistentHops[key] = 0; open.push({ startCost, state }); ++startCount; } // Use the nearest ten nodes merely to shortlist real recorded ground edges. // All distances below are on the player's current map; no database mutation. std::vector> nearestNodes; for (auto const& pair : _nodes) { ClientlessTravelNode const& node = pair.second; if (node.MapId != player->GetMapId()) continue; float dx = node.X - player->GetPositionX(); float dy = node.Y - player->GetPositionY(); float dz = node.Z - player->GetPositionZ(); nearestNodes.emplace_back(dx * dx + dy * dy + dz * dz, pair.first); } std::sort(nearestNodes.begin(), nearestNodes.end()); if (nearestNodes.size() > 10) nearestNodes.resize(10); struct EntryCandidate { float DistanceSq; ClientlessTravelEdge Edge; uint32 Index; ClientlessTravelEdgePoint Point; uint32 Count; }; std::vector entryCandidates; std::unordered_set visitedDirectedEdges; for (auto const& near : nearestNodes) { // Both outgoing and incoming records are considered for spatial discovery. // A directed entry still follows only the direction present in _edges. for (auto const& edgePair : _edges) { for (ClientlessTravelEdge const& edge : edgePair.second) { if (edge.Type != "ground" || !edge.DatabaseId || (edge.FromNode != near.second && edge.ToNode != near.second)) continue; if (blockedEdgeIds && blockedEdgeIds->count(edge.DatabaseId)) continue; std::string key = std::to_string(edge.DatabaseId) + (edge.Reverse ? "R" : "F"); if (!visitedDirectedEdges.insert(key).second) continue; auto pointIt = _edgePoints.find(edge.DatabaseId); if (pointIt == _edgePoints.end() || pointIt->second.size() < 3) continue; std::vector const& points = pointIt->second; // Candidates are stored in original point order, even for reverse travel. for (uint32 i = 0; i < points.size(); ++i) { if ((!edge.Reverse && i + 1 >= points.size()) || (edge.Reverse && i == 0)) continue; ClientlessTravelEdgePoint const& point = points[i]; if (point.MapId != player->GetMapId()) continue; float dx = point.X - player->GetPositionX(); float dy = point.Y - player->GetPositionY(); float dz = point.Z - player->GetPositionZ(); float dsq = dx * dx + dy * dy + dz * dz; // Keep local MMAP checks bounded; startRadius is a planning limit. if (dsq > startRadius * startRadius) continue; entryCandidates.push_back({ dsq, edge, i, point, uint32(points.size()) }); } } } } std::sort(entryCandidates.begin(), entryCandidates.end(), [](EntryCandidate const& a, EntryCandidate const& b) { return a.DistanceSq < b.DistanceSq; }); uint32 validated = 0; uint32 accepted = 0; for (EntryCandidate const& candidate : entryCandidates) { if (validated >= 24) break; ++validated; Position target; target.Relocate(candidate.Point.X, candidate.Point.Y, candidate.Point.Z, candidate.Point.O); ClientlessPathDiagnostics nav; // A point on top of the player needs no pathfinder round trip. bool const alreadyThere = candidate.DistanceSq <= 2.25f; if (!alreadyThere) { sClientlessTravelMgr->AnalyzePath(const_cast(player), target, nav); if (!nav.Calculated || !nav.Complete || nav.Suspicious || nav.FinalDistance > 1.5f || nav.PathLength > 500.0f) continue; } State state{ candidate.Edge.ToNode, false, ":entry:" + std::to_string(candidate.Edge.DatabaseId) + (candidate.Edge.Reverse ? "R:" : "F:") + std::to_string(candidate.Index) }; std::string key = stateKey(state); if (blockedBridgeKeys && blockedBridgeKeys->count("start:" + candidate.Edge.ToNode)) continue; uint32 const startIndex = candidate.Edge.Reverse ? candidate.Count - 1 - candidate.Index : candidate.Index; float newCost = (alreadyThere ? 0.0f : nav.PathLength) + roadSectionCost(candidate.Edge.DatabaseId, candidate.Edge.Reverse, startIndex, candidate.Count - 1, candidate.Edge.Cost); auto old = distance.find(key); if (old != distance.end() && old->second <= newCost) continue; distance[key] = newCost; states[key] = state; persistentHops[key] = 1; previous.erase(key); virtualRoots[key] = { candidate.Edge.DatabaseId, candidate.Index, candidate.Edge.Reverse, candidate.Point, alreadyThere ? 0.0f : nav.PathLength }; open.push({ newCost, state }); ++accepted; } if (validated) TC_LOG_INFO("server.adventurebot.navigation", "VIRTUAL_EDGE_CANDIDATES map=%u tested=%u accepted=%u", player->GetMapId(), validated, accepted); if (!startCount && !accepted) { error = "no reachable travel node or recorded ground edge near current position"; return false; } // 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(), locationGoal ? "location" : "all-edges", uint32(startCount + accepted)); // Dynamic candidates may be examined many times during Dijkstra. Avoid // duplicating costly Detour queries for identical directed connections. std::unordered_map validBridgeLengths; std::unordered_set rejectedBridges; uint32 bridgeTests = 0, bridgeAccepted = 0, bridgeRejected = 0; uint32 bridgeDistanceRejected = 0; uint32 forwardEntryTests = 0, forwardEntryAccepted = 0; auto analyze = [&](ClientlessTravelNode const& from, uint32 targetMap, float x, float y, float z) { Position a, b; a.Relocate(from.X, from.Y, from.Z, from.O); b.Relocate(x, y, z, 0.0f); // The map is deliberately taken from the future graph node, never // from Arthoran's current position. No Player/WorldObject is required. if (targetMap != from.MapId) return VcorePositionPathResult{}; return PathGenerator::AnalyzePositions(from.MapId, from.MapId, player->GetInstanceId(), a, b, NAV_GROUND); }; // Select the closest eligible waypoint per directed road geometrically. // Future destination tiles need not be loaded: local access is estimated // here and validated by the executor only after reaching the exit. 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; bool Flight = false; }; float const locationAccessRadius = locationGoal ? std::min(300.0f, locationGoal->AnchorRadius) : 0.0f; std::vector rawExitCandidates; std::unordered_map> exitsByFrom; if (locationGoal || flightEnabled) { auto const& targetNode = destinationItr->second; uint32 const goalMap = locationGoal ? locationGoal->MapId : targetNode.MapId; float const goalX = locationGoal ? locationGoal->X : targetNode.X; float const goalY = locationGoal ? locationGoal->Y : targetNode.Y; float const goalZ = locationGoal ? locationGoal->Z : targetNode.Z; float const radius = flightEnabled ? 20000.0f : locationAccessRadius; 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; ExitCandidate nearest; nearest.DistanceSq = std::numeric_limits::infinity(); 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 != goalMap) continue; dx = point.X - goalX; dy = point.Y - goalY; dz = point.Z - goalZ; 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; candidate.LocalCost = locationGoal && dsq <= locationAccessRadius*locationAccessRadius ? std::sqrt(dsq) : std::numeric_limits::infinity(); ClientlessTravelNode exitNode; exitNode.MapId=point.MapId;exitNode.X=point.X;exitNode.Y=point.Y;exitNode.Z=point.Z; float const air = blockedBridgeKeys && blockedBridgeKeys->count("flight_exit:"+exitKey) ? std::numeric_limits::infinity() : flightCost(exitNode,goalMap,goalX,goalY,goalZ); if (air < candidate.LocalCost) { candidate.LocalCost=air;candidate.Flight=true; } if (!std::isfinite(candidate.LocalCost)) continue; // Never replace a node goal with an unvalidated ground exit. if (!locationGoal && !candidate.Flight) continue; if (std::isfinite(nearest.DistanceSq) && candidate.RoadCost+candidate.LocalCost >= nearest.RoadCost+nearest.LocalCost) continue; nearest = std::move(candidate); } if (std::isfinite(nearest.DistanceSq)) rawExitCandidates.push_back(std::move(nearest)); } } std::sort(rawExitCandidates.begin(), rawExitCandidates.end(), [](ExitCandidate const& a, ExitCandidate const& b) { return a.DistanceSq < b.DistanceSq; }); for (ExitCandidate const& candidate : rawExitCandidates) exitsByFrom[candidate.FromNode].push_back(candidate); TC_LOG_INFO("server.adventurebot.navigation", "C821_EXIT_CANDIDATES directedEdges=%u radius=%.1f localCost=air-distance validation=on-arrival", uint32(rawExitCandidates.size()), radius); } struct ExitSelection { bool Active = false; bool RootPartial = false; ExitCandidate Candidate; bool ForwardPartial = false; uint32 StartIndex = 0; ClientlessTravelEdgePoint EntryPoint; } bestExit; float bestGoalCost = std::numeric_limits::infinity(); std::string bestGoalState; bool bestFinalFlight = false; // 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 || flightEnabled) { 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()) { QueueItem current = open.top(); open.pop(); std::string const currentKey = stateKey(current.Value); auto distanceItr = distance.find(currentKey); if (distanceItr == distance.end() || current.Cost != distanceItr->second) continue; if (current.Cost >= bestGoalCost) break; if (!locationGoal && current.Value.Node == destinationNode) { bestGoalState = currentKey; bestGoalCost = current.Cost; bestExit = {}; bestFinalFlight = false; 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); // Use the same local access limit for nodes and road exits. // Otherwise a distant node can bypass a closer usable exit. float const air = blockedBridgeKeys && blockedBridgeKeys->count("flight_finish:"+candidateNode.Key) ? std::numeric_limits::infinity() : flightCost(candidateNode,locationGoal->MapId,locationGoal->X,locationGoal->Y,locationGoal->Z); if (direct <= locationAccessRadius || std::isfinite(air)) { // Node anchors and virtual exits use the same estimate. // Neither is rejected because a remote tile is unloaded. std::string const finishKey = "finish:" + candidateNode.Key; float const finalCost = blockedBridgeKeys && blockedBridgeKeys->count(finishKey) ? std::numeric_limits::infinity() : std::min(direct<=locationAccessRadius ? direct : std::numeric_limits::infinity(),air); if (current.Cost + finalCost < bestGoalCost) { bestGoalCost = current.Cost + finalCost; bestGoalState = currentKey; bestExit = {}; bestFinalFlight = air <= finalCost; } } } 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 }; } } } } if (!locationGoal && flightEnabled) { auto exits=exitsByFrom.find(current.Value.Node); if (exits!=exitsByFrom.end()) for (auto const& exit:exits->second) if (current.Cost+exit.RoadCost+exit.LocalCost= 7 && current.Value.Node.compare(current.Value.Node.size() - 7, 7, "_source") == 0; bool const portalArrival = isPortalSource && previous.find(currentKey) != previous.end(); // Persistent TravelGraph edges always reset the bridge guard. auto edgeItr = _edges.find(current.Value.Node); if (edgeItr != _edges.end()) { for (ClientlessTravelEdge const& edge : edgeItr->second) { if (portalArrival && edge.Type != "portal") continue; if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId)) continue; State next{ edge.ToNode, false }; std::string const nextKey = stateKey(next); float const candidate = current.Cost + edge.Cost; auto old = distance.find(nextKey); if (old != distance.end() && candidate >= old->second) continue; distance[nextKey] = candidate; states[nextKey] = next; persistentHops[nextKey] = persistentHops[currentKey] + 1; previous[nextKey] = { currentKey, current.Value.Node, edge.Type, edge.DatabaseId, edge.Reverse }; open.push({ candidate, next }); } } // Dynamic flights connect real nodes; never the synthetic entry waypoints. // Include them before the local-bridge guard: a flight is a separate // macro travel mode with its own native departure and landing checks. if (flightEnabled && !portalArrival) { auto const& origin=_nodes.at(current.Value.Node); for (auto const& pair:_nodes) { auto const& target=pair.second; if (target.Key==origin.Key || target.MapId!=origin.MapId) continue; if (blockedBridgeKeys && blockedBridgeKeys->count("flight:"+origin.Key+"->"+target.Key)) continue; float cost=flightCost(origin,target.MapId,target.X,target.Y,target.Z); if (!std::isfinite(cost)) continue; State next{target.Key,false};std::string key=stateKey(next); float score=current.Cost+cost; auto old=distance.find(key);if(old!=distance.end()&&old->second<=score)continue; distance[key]=score;states[key]=next;persistentHops[key]=persistentHops[currentKey]+1; previous[key]={currentKey,current.Value.Node,"free_flight",0,false}; open.push({score,next}); } } // A local bridge is deliberately impossible immediately after another // local bridge. This prevents MMAP from silently becoming a long-range // planner while still allowing unlimited bridges over a long journey // when real transport/ground edges lie between them. if (!repairRequired || current.Value.LastWasBridge || portalArrival) continue; ClientlessTravelNode const& from = _nodes.at(current.Value.Node); // C8.18: if a future node is disconnected, attach it to a recorded // road at an MMAP-reachable waypoint. Only remaining portions of // stored, directed ground edges are admitted; no off-graph shortcut. // A portal source may not be treated as an ordinary intermediate node. bool const futurePortalSource = from.Key.rfind("portal_at_", 0) == 0 && from.Key.size() >= 7 && from.Key.compare(from.Key.size() - 7, 7, "_source") == 0; if (!futurePortalSource && persistentHops[currentKey] > 0 && !(blockedBridgeKeys && blockedBridgeKeys->count(MakeLocalBridgeKey(from.Key, "virtual_ground_entry")))) { // Check a bounded set of the geographically closest waypoint // candidates first to avoid O(all-points * Detour) work. struct ForwardCandidate { float DistanceSq; ClientlessTravelEdge const* Edge; uint32 Index; ClientlessTravelEdgePoint const* Point; uint32 PointCount; }; std::vector candidates; constexpr float ForwardEntryRadius = 200.0f; for (auto const& edgePair : _edges) { for (ClientlessTravelEdge const& edge : edgePair.second) { if (edge.Type != "ground" || !edge.DatabaseId || (blockedEdgeIds && blockedEdgeIds->count(edge.DatabaseId))) continue; auto pts = _edgePoints.find(edge.DatabaseId); if (pts == _edgePoints.end() || pts->second.size() < 3) continue; for (uint32 i = 0; i < pts->second.size(); ++i) { if ((!edge.Reverse && i + 1 >= pts->second.size()) || (edge.Reverse && i == 0)) continue; ClientlessTravelEdgePoint const& pt = pts->second[i]; if (pt.MapId != from.MapId) continue; float dx = pt.X - from.X, dy = pt.Y - from.Y, dz = pt.Z - from.Z; float dsq = dx * dx + dy * dy + dz * dz; if (dsq <= ForwardEntryRadius * ForwardEntryRadius) candidates.push_back({ dsq, &edge, i, &pt, uint32(pts->second.size()) }); } } } std::sort(candidates.begin(), candidates.end(), [](ForwardCandidate const& a, ForwardCandidate const& b) { return a.DistanceSq < b.DistanceSq; }); // One candidate per directed recorded edge is enough for a first // robust repair pass; use the closest admissible point on it. std::unordered_set testedDirectedEdges; uint32 localTests = 0; for (ForwardCandidate const& entry : candidates) { if (localTests >= 24) break; std::string directionKey = std::to_string(entry.Edge->DatabaseId) + (entry.Edge->Reverse ? "R" : "F"); if (!testedDirectedEdges.insert(directionKey).second) continue; ++localTests; ++forwardEntryTests; VcorePositionPathResult const probe = analyze(from, entry.Point->MapId, entry.Point->X, entry.Point->Y, entry.Point->Z); if (!probe.IsComplete() || probe.FinalDistance > 1.5f || probe.PathLength > 500.0f) continue; if (entry.Edge->ToNode == from.Key) continue; uint32 const entryIndex = entry.Edge->Reverse ? entry.PointCount - 1 - entry.Index : entry.Index; if (locationGoal) { auto exitGroup = exitsByFrom.find(entry.Edge->FromNode); if (exitGroup != exitsByFrom.end()) for (ExitCandidate const& exit : exitGroup->second) { if (exit.EdgeId != entry.Edge->DatabaseId || exit.Reverse != entry.Edge->Reverse || exit.EndIndex <= entryIndex) continue; float const score = current.Cost + probe.PathLength + exit.LocalCost + roadSectionCost(exit.EdgeId, exit.Reverse, entryIndex, exit.EndIndex, entry.Edge->Cost); if (score >= bestGoalCost) continue; bestGoalCost = score; bestGoalState = currentKey; bestExit = { true, false, exit, true, entryIndex, *entry.Point }; } } State next{ entry.Edge->ToNode, false }; std::string const nextKey = stateKey(next); uint32 const begin = entry.Edge->Reverse ? entry.PointCount - 1 - entry.Index : entry.Index; float const cost = current.Cost + probe.PathLength + roadSectionCost(entry.Edge->DatabaseId, entry.Edge->Reverse, begin, entry.PointCount - 1, entry.Edge->Cost); auto old = distance.find(nextKey); if (old != distance.end() && cost >= old->second) continue; distance[nextKey] = cost; states[nextKey] = next; persistentHops[nextKey] = persistentHops[currentKey] + 1; Previous link; link.StateKey = currentKey; link.Node = current.Value.Node; link.EdgeType = "ground"; link.EdgeDatabaseId = entry.Edge->DatabaseId; link.EdgeReverse = entry.Edge->Reverse; link.VirtualGroundEntry = true; link.GroundStartIndex = entry.Edge->Reverse ? entry.PointCount - 1 - entry.Index : entry.Index; link.EntryPoint = *entry.Point; previous[nextKey] = link; open.push({ cost, next }); ++forwardEntryAccepted; TC_LOG_INFO("server.adventurebot.navigation", "C818_FORWARD_ENTRY_PLAN from='%s' viaEdge=%u point=%u reverse=%u to='%s' path=%.1f", from.Key.c_str(), entry.Edge->DatabaseId, entry.Index, entry.Edge->Reverse ? 1u : 0u, entry.Edge->ToNode.c_str(), probe.PathLength); } } for (auto const& pair : _nodes) { ClientlessTravelNode const& to = pair.second; if (to.Key == from.Key || to.MapId != from.MapId) continue; float const dx = to.X - from.X; float const dy = to.Y - from.Y; float const dz = to.Z - from.Z; float const direct = std::sqrt(dx * dx + dy * dy + dz * dz); if (direct > bridgeRadius) continue; std::string const bridgeKey = MakeLocalBridgeKey(from.Key, to.Key); if (blockedBridgeKeys && blockedBridgeKeys->count(bridgeKey)) continue; // C8.18: even in repair mode, a virtual node bridge is not // eligible until the future-node -> future-node MMAP is complete. // The geometry cap above is a cheap first-stage filter. float measuredLength = 0.0f; auto cached = validBridgeLengths.find(bridgeKey); if (cached != validBridgeLengths.end()) measuredLength = cached->second; else { if (rejectedBridges.count(bridgeKey)) continue; ++bridgeTests; VcorePositionPathResult const probe = analyze(from, to.MapId, to.X, to.Y, to.Z); if (!probe.IsComplete() || probe.FinalDistance > 1.5f || !std::isfinite(probe.PathLength) || probe.PathLength > bridgeRadius * 2.0f) { rejectedBridges.insert(bridgeKey); ++bridgeRejected; TC_LOG_DEBUG("server.adventurebot.navigation", "C818_BRIDGE_REJECT from='%s' to='%s' direct=%.1f status=%u final=%.1f", from.Key.c_str(), to.Key.c_str(), direct, unsigned(probe.Result), probe.FinalDistance); continue; } measuredLength = probe.PathLength; validBridgeLengths.emplace(bridgeKey, measuredLength); ++bridgeAccepted; } // A bridge must lead back into real TravelGraph infrastructure. // The sole exception is the actual requested destination, where a // local bridge may be the final leg of the graph journey. bool hasPersistentContinuation = to.Key == destinationNode; auto continuationItr = _edges.find(to.Key); if (!hasPersistentContinuation && continuationItr != _edges.end()) { for (ClientlessTravelEdge const& continuation : continuationItr->second) { if (blockedEdgeIds && continuation.DatabaseId && blockedEdgeIds->count(continuation.DatabaseId)) continue; hasPersistentContinuation = true; break; } } if (!hasPersistentContinuation) continue; State next{ to.Key, true }; std::string const nextKey = stateKey(next); // 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; distance[nextKey] = candidate; states[nextKey] = next; persistentHops[nextKey] = persistentHops[currentKey]; previous[nextKey] = { currentKey, current.Value.Node, "local_bridge", 0, false }; open.push({ candidate, next }); } } if ((locationGoal || flightEnabled) && bestExit.Active && bestExit.RootPartial && !bestGoalState.empty()) previous.erase(bestGoalState); std::string selectedState = bestGoalState; if (!selectedState.empty()) complete = true; if (selectedState.empty()) { ClientlessTravelNode const& destination = destinationItr->second; float bestSameMapDistance = std::numeric_limits::infinity(); uint32 bestPersistentHops = 0; float bestCost = std::numeric_limits::infinity(); // Prefer any reachable point on the destination map. This gives a // meaningful geographic partial route (e.g. Ironforge when Kharanos // itself is disconnected). If the destination map was never reached, // fall back to the state that made the most progress over real edges. for (auto const& pair : states) { auto distItr = distance.find(pair.first); if (distItr == distance.end() || !std::isfinite(distItr->second)) continue; ClientlessTravelNode const& node = _nodes.at(pair.second.Node); // Never terminate a partial journey on an unintended trigger source. bool const stopAtPortalSource = node.Key.rfind("portal_at_", 0) == 0 && node.Key.size() >= 7 && node.Key.compare(node.Key.size() - 7, 7, "_source") == 0 && node.Key != destinationNode; if (stopAtPortalSource) continue; if (node.MapId != destination.MapId) continue; float const dx = node.X - destination.X; float const dy = node.Y - destination.Y; float const dz = node.Z - destination.Z; float const localDistance = std::sqrt(dx * dx + dy * dy + dz * dz); if (localDistance < bestSameMapDistance || (std::fabs(localDistance - bestSameMapDistance) < 0.5f && distItr->second < bestCost)) { bestSameMapDistance = localDistance; bestCost = distItr->second; selectedState = pair.first; } } if (selectedState.empty()) { for (auto const& pair : states) { auto distItr = distance.find(pair.first); if (distItr == distance.end() || !std::isfinite(distItr->second)) continue; ClientlessTravelNode const& candidateNode = _nodes.at(pair.second.Node); if (candidateNode.Key != destinationNode && candidateNode.Key.rfind("portal_at_", 0) == 0 && candidateNode.Key.size() >= 7 && candidateNode.Key.compare(candidateNode.Key.size()-7, 7, "_source") == 0) continue; uint32 const hops = persistentHops[pair.first]; if (hops > bestPersistentHops || (hops == bestPersistentHops && distItr->second < bestCost)) { bestPersistentHops = hops; bestCost = distItr->second; selectedState = pair.first; } } } } if (selectedState.empty()) { error = "no adaptive route candidate"; return false; } reachedNode = states.at(selectedState).Node; struct Reconstructed { State Value; std::string IncomingEdgeType; uint32 EdgeDatabaseId = 0; bool EdgeReverse = false; bool VirtualGroundEntry = false; uint32 GroundStartIndex = 0; ClientlessTravelEdgePoint EntryPoint; }; std::vector reversed; std::string cursor = selectedState; while (true) { State const& state = states.at(cursor); auto prevItr = previous.find(cursor); if (prevItr == previous.end()) { reversed.push_back({ state, "start", 0, false }); break; } reversed.push_back({ state, prevItr->second.EdgeType, prevItr->second.EdgeDatabaseId, prevItr->second.EdgeReverse, prevItr->second.VirtualGroundEntry, prevItr->second.GroundStartIndex, prevItr->second.EntryPoint }); cursor = prevItr->second.StateKey; } std::reverse(reversed.begin(), reversed.end()); route.reserve(reversed.size() + 1); // If the shortest root entered a recorded edge mid-way, precede the actual // endpoint with an MMAP-reachable virtual waypoint; do NOT rewrite the DB edge. if (!reversed.empty()) { std::string rootKey = stateKey(reversed.front().Value); auto root = virtualRoots.find(rootKey); if (root != virtualRoots.end() && previous.find(rootKey) == previous.end()) { VirtualEntry const& entry = root->second; ClientlessTravelRouteStep initial; initial.NodeKey = "virtual_ground_entry"; initial.NodeType = "road"; initial.EdgeType = "start"; initial.MapId = entry.Point.MapId; initial.X = entry.Point.X; initial.Y = entry.Point.Y; initial.Z = entry.Point.Z; initial.O = entry.Point.O; route.push_back(initial); TC_LOG_INFO("server.adventurebot.navigation", "VIRTUAL_EDGE_SELECTED edgeDbId=%u pointIndex=%u reverse=%u", entry.EdgeId, entry.PointIndex, entry.Reverse ? 1u : 0u); } } for (Reconstructed const& item : reversed) { if (item.VirtualGroundEntry) { ClientlessTravelRouteStep connector; connector.NodeKey = "virtual_ground_entry"; connector.NodeType = "road"; connector.EdgeType = "local_bridge"; connector.MapId = item.EntryPoint.MapId; connector.X = item.EntryPoint.X; connector.Y = item.EntryPoint.Y; connector.Z = item.EntryPoint.Z; connector.O = item.EntryPoint.O; route.push_back(std::move(connector)); TC_LOG_INFO("server.adventurebot.navigation", "C818_FORWARD_ENTRY_SELECTED waypoint=(%.2f %.2f %.2f) edgeDbId=%u offset=%u", item.EntryPoint.X, item.EntryPoint.Y, item.EntryPoint.Z, item.EdgeDatabaseId, item.GroundStartIndex); } ClientlessTravelNode const& node = _nodes.at(item.Value.Node); ClientlessTravelRouteStep step; step.NodeKey = node.Key; step.NodeType = node.Type; step.WorldPointKey = node.WorldPointKey; step.EdgeType = item.IncomingEdgeType; step.EdgeDatabaseId = item.EdgeDatabaseId; step.EdgeReverse = item.EdgeReverse; if (item.VirtualGroundEntry) step.GroundStartIndex = int32(item.GroundStartIndex); if (route.size() == 1 && route.front().NodeKey == "virtual_ground_entry" && item.IncomingEdgeType == "start") { auto root = virtualRoots.find(stateKey(item.Value)); if (root != virtualRoots.end()) { step.EdgeType = "ground"; step.EdgeDatabaseId = root->second.EdgeId; step.EdgeReverse = root->second.Reverse; step.GroundStartIndex = root->second.Reverse ? int32(_edgePoints.at(root->second.EdgeId).size() - 1 - root->second.PointIndex) : int32(root->second.PointIndex); } } step.MapId = node.MapId; step.X = node.X; step.Y = node.Y; step.Z = node.Z; step.O = node.O; route.push_back(std::move(step)); } if ((locationGoal || flightEnabled) && 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 { if (bestExit.ForwardPartial) { ClientlessTravelRouteStep connector; connector.NodeKey = "virtual_ground_entry"; connector.NodeType = "road"; connector.EdgeType = "local_bridge"; connector.MapId = bestExit.EntryPoint.MapId; connector.X = bestExit.EntryPoint.X; connector.Y = bestExit.EntryPoint.Y; connector.Z = bestExit.EntryPoint.Z; connector.O = bestExit.EntryPoint.O; route.push_back(std::move(connector)); } ClientlessTravelRouteStep end; if (bestExit.ForwardPartial) end.GroundStartIndex = int32(bestExit.StartIndex); 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); if (complete && (bestExit.Active ? bestExit.Candidate.Flight : bestFinalFlight)) { ClientlessTravelRouteStep flight; auto const& target=destinationItr->second; flight.NodeKey=locationGoal ? "flight_location" : target.Key; flight.NodeType=locationGoal ? "location" : target.Type; flight.EdgeType="free_flight"; flight.MapId=locationGoal ? locationGoal->MapId : target.MapId; flight.X=locationGoal ? locationGoal->X : target.X; flight.Y=locationGoal ? locationGoal->Y : target.Y; flight.Z=locationGoal ? locationGoal->Z : target.Z; flight.O=locationGoal ? 0 : target.O; route.push_back(std::move(flight)); reachedNode=locationGoal ? "flight_location" : target.Key; } TC_LOG_INFO("server.adventurebot.navigation", "C818_PREFLIGHT complete=%u bridgeTests=%u bridgeAccepted=%u bridgeRejected=%u " "distanceRejected=%u forwardTests=%u forwardAccepted=%u steps=%u", complete ? 1u : 0u, bridgeTests, bridgeAccepted, bridgeRejected, bridgeDistanceRejected, forwardEntryTests, forwardEntryAccepted, uint32(route.size())); if (!complete) error = "destination not fully reachable; returning best-effort partial route"; return !route.empty(); }; bool const firstOk = runSearch(false); if (firstOk && complete) { TC_LOG_INFO("server.adventurebot.navigation", "C819_ROUTE_TIER tier=reachable complete=1"); return true; } std::vector firstRoute = route; bool const firstComplete = complete; std::string firstReached = reachedNode; std::string firstError = error; bool const secondOk = runSearch(true); if (secondOk && complete) { TC_LOG_INFO("server.adventurebot.navigation", "C819_ROUTE_TIER tier=deferred complete=1"); return true; } // If no complete route exists, a selected deferred root is still a useful // approach to real graph infrastructure, provided that its execution is safe. if (secondOk && !complete && !route.empty() && route.front().EdgeType == "start" && route.front().NodeKey != "virtual_ground_entry" && firstOk && !firstRoute.empty() && route.front().NodeKey != firstRoute.front().NodeKey) { TC_LOG_INFO("server.adventurebot.navigation", "C819_ROUTE_TIER tier=deferred-approach complete=0"); return true; } if (firstOk) { route = std::move(firstRoute); complete = firstComplete; reachedNode = std::move(firstReached); error = std::move(firstError); TC_LOG_INFO("server.adventurebot.navigation", "C819_ROUTE_TIER tier=reachable-partial complete=0"); return true; } TC_LOG_INFO("server.adventurebot.navigation", "C819_ROUTE_TIER tier=deferred-partial complete=%u", complete ? 1u : 0u); return secondOk; } bool ClientlessTravelGraphMgr::PlanRouteToPosition( Player const* player, uint32 destinationMapId, float destinationX, float destinationY, float destinationZ, float startRadius, float anchorRadius, std::vector& route, std::string& anchorNode, std::string& error, std::unordered_set const* blockedEdgeIds) { route.clear(); anchorNode.clear(); error.clear(); EnsureLoaded(); if (!player || !player->IsInWorld()) { error = "player is not in world"; return false; } if (startRadius <= 0.0f) startRadius = 500.0f; if (anchorRadius <= 0.0f) anchorRadius = 1000.0f; struct QueueItem { float Cost; std::string Node; bool operator>(QueueItem const& other) const { return Cost > other.Cost; } }; struct Previous { std::string Node; std::string EdgeType; uint32 EdgeDatabaseId = 0; bool EdgeReverse = false; }; std::priority_queue, std::greater> open; std::unordered_map distance; std::unordered_map previous; for (auto const& pair : _nodes) distance[pair.first] = std::numeric_limits::infinity(); // Multi-source Dijkstra: every graph node close enough to the player's // actual position is a possible entry point. Seed cost includes the local // distance needed to reach that node, so nearer usable entries are still // preferred without letting an isolated node block the route entirely. uint32 startCandidateCount = 0; for (auto const& pair : _nodes) { ClientlessTravelNode const& node = pair.second; if (node.MapId != player->GetMapId()) continue; float const dx = node.X - player->GetPositionX(); float const dy = node.Y - player->GetPositionY(); float const dz = node.Z - player->GetPositionZ(); float const localStartDistance = std::sqrt(dx * dx + dy * dy + dz * dz); if (localStartDistance > startRadius) continue; distance[node.Key] = localStartDistance; open.push({ localStartDistance, node.Key }); ++startCandidateCount; } if (startCandidateCount == 0) { error = "no travel node near current position"; return false; } while (!open.empty()) { QueueItem current = open.top(); open.pop(); if (current.Cost != distance[current.Node]) continue; auto edgeItr = _edges.find(current.Node); if (edgeItr == _edges.end()) continue; for (ClientlessTravelEdge const& edge : edgeItr->second) { if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->find(edge.DatabaseId) != blockedEdgeIds->end()) continue; float const candidate = current.Cost + edge.Cost; if (candidate >= distance[edge.ToNode]) continue; distance[edge.ToNode] = candidate; previous[edge.ToNode] = { current.Node, edge.Type, edge.DatabaseId, edge.Reverse }; open.push({ candidate, edge.ToNode }); } } // The destination anchor's primary job is to get the bot close to the // requested Location. Do not combine macro-route cost and final local // distance into one score: that can make the current/start node win simply // because reaching it costs zero, leaving MMAP with a very long final leg. // // Instead choose the reachable node closest to the destination first. // Graph cost is only a tie-breaker between equally suitable anchors. float bestDestinationDistance = std::numeric_limits::infinity(); float bestGraphCost = std::numeric_limits::infinity(); for (auto const& pair : _nodes) { ClientlessTravelNode const& node = pair.second; if (node.MapId != destinationMapId) continue; auto distanceItr = distance.find(node.Key); if (distanceItr == distance.end() || !std::isfinite(distanceItr->second)) continue; float const dx = node.X - destinationX; float const dy = node.Y - destinationY; float const dz = node.Z - destinationZ; float const localDestinationDistance = std::sqrt(dx * dx + dy * dy + dz * dz); if (localDestinationDistance > anchorRadius) continue; float const graphCost = distanceItr->second; constexpr float DistanceTieEpsilon = 0.5f; bool const closerToDestination = localDestinationDistance + DistanceTieEpsilon < bestDestinationDistance; bool const sameDestinationDistance = std::fabs(localDestinationDistance - bestDestinationDistance) <= DistanceTieEpsilon; if (!closerToDestination && !(sameDestinationDistance && graphCost < bestGraphCost)) continue; bestDestinationDistance = localDestinationDistance; bestGraphCost = graphCost; anchorNode = node.Key; } if (anchorNode.empty()) { error = "no reachable travel node near destination"; return false; } // Reconstruct until we reach whichever seeded start candidate actually won. // Seed roots deliberately have no Previous entry. std::vector nodeKeys; std::string cursor = anchorNode; nodeKeys.push_back(cursor); while (true) { auto previousItr = previous.find(cursor); if (previousItr == previous.end()) break; cursor = previousItr->second.Node; nodeKeys.push_back(cursor); } std::reverse(nodeKeys.begin(), nodeKeys.end()); route.reserve(nodeKeys.size()); for (std::size_t i = 0; i < nodeKeys.size(); ++i) { ClientlessTravelNode const& node = _nodes.at(nodeKeys[i]); ClientlessTravelRouteStep step; step.NodeKey = node.Key; step.NodeType = node.Type; step.WorldPointKey = node.WorldPointKey; step.MapId = node.MapId; step.X = node.X; step.Y = node.Y; step.Z = node.Z; step.O = node.O; if (i == 0) step.EdgeType = "start"; else { Previous const& prev = previous.at(nodeKeys[i]); step.EdgeType = prev.EdgeType; step.EdgeDatabaseId = prev.EdgeDatabaseId; step.EdgeReverse = prev.EdgeReverse; } route.push_back(std::move(step)); } return true; } std::vector const* ClientlessTravelGraphMgr::GetEdgePoints(uint32 edgeDatabaseId) const { auto itr = _edgePoints.find(edgeDatabaseId); return itr != _edgePoints.end() ? &itr->second : nullptr; }