Add preparation outings and equipment checks with a five-minute return to the meeting point. Correct static portal visitor casting and level conditions, with native interaction diagnostics. Recover suspended flying mounts, walk to verified takeoff sites, and validate landing and return volumes. Include SQL migrations, targeted regression tests and live validation checkpoints.
2099 lines
87 KiB
C++
2099 lines
87 KiB
C++
#include "AdventureAreaMgr.h"
|
|
#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 <algorithm>
|
|
#include <chrono>
|
|
#include <map>
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <queue>
|
|
#include <utility>
|
|
|
|
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()
|
|
{
|
|
_positionProbes.Clear();
|
|
_bridgeNeighborhoods.clear();
|
|
_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<uint32, ClientlessPortalUse> 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<float>::max();
|
|
|
|
for (auto const& pair : _nodes)
|
|
{
|
|
ClientlessTravelNode const& node = pair.second;
|
|
if (node.MapId != player->GetMapId())
|
|
continue;
|
|
if (!sAdventureAreaMgr->AllowsNode(player,node)) 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<ClientlessTravelRouteStep>& route,
|
|
std::string& error,
|
|
std::unordered_set<uint32> 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 (!sAdventureAreaMgr->AllowsNode(player,_nodes.at(destinationNode)))
|
|
{ error="destination node denied by area/node catalog"; 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<QueueItem, std::vector<QueueItem>, std::greater<QueueItem>> open;
|
|
std::unordered_map<std::string, float> distance;
|
|
std::unordered_map<std::string, Previous> previous;
|
|
|
|
for (auto const& pair : _nodes)
|
|
distance[pair.first] = std::numeric_limits<float>::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 (!sAdventureAreaMgr->AllowsEdge(player,edge)) continue;
|
|
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<std::string> 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<float>::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();
|
|
}
|
|
|
|
ClientlessTravelNode const* ClientlessTravelGraphMgr::FindNode(std::string const& key) const
|
|
{
|
|
auto const it = _nodes.find(key);
|
|
return it == _nodes.end() ? nullptr : &it->second;
|
|
}
|
|
|
|
bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
|
|
Player const* player,
|
|
std::string const& destinationNode,
|
|
float startRadius,
|
|
float bridgeRadius,
|
|
std::vector<ClientlessTravelRouteStep>& route,
|
|
bool& complete,
|
|
std::string& reachedNode,
|
|
std::string& error,
|
|
std::unordered_set<uint32> const* blockedEdgeIds,
|
|
std::unordered_set<std::string> const* blockedBridgeKeys,
|
|
ClientlessTravelLocationGoal const* locationGoal)
|
|
{
|
|
struct Trace
|
|
{
|
|
ClientlessGraphProbeCache& Cache;std::string const& Target;
|
|
std::chrono::steady_clock::time_point Start=std::chrono::steady_clock::now();
|
|
std::uint64_t Hits=Cache.Hits,Calls=0,NavUs=0,StartUs=0,StartCalls=0,FlightUs=0,FlightCalls=0;
|
|
~Trace()
|
|
{
|
|
auto us=std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now()-Start).count();
|
|
if(us>=50000)TC_LOG_INFO("server.worldserver","CPC_ROUTE target='%s' totalMs=%.3f navMs=%.3f probes=%llu cacheHits=%llu cacheSize=%u startMs=%.3f startCalls=%llu flightMs=%.3f flightCalls=%llu",Target.c_str(),us/1000.0,NavUs/1000.0,static_cast<unsigned long long>(Calls),static_cast<unsigned long long>(Cache.Hits-Hits),uint32(Cache.Size()),StartUs/1000.0,static_cast<unsigned long long>(StartCalls),FlightUs/1000.0,static_cast<unsigned long long>(FlightCalls));
|
|
}
|
|
} trace{_positionProbes,destinationNode};
|
|
|
|
// Reuse all start-point observations only WITHIN this immutable planning
|
|
// call, including its two search tiers. Never cache live Player paths globally.
|
|
std::map<ClientlessGraphProbeCache::Key,ClientlessPathDiagnostics> startProbes;
|
|
auto analyzeStart=[&](Position const& target,ClientlessPathDiagnostics& result)
|
|
{
|
|
auto key=ClientlessGraphProbeCache::MakeKey(player->GetMapId(),player->GetInstanceId(),player->GetPositionX(),player->GetPositionY(),player->GetPositionZ(),target.GetPositionX(),target.GetPositionY(),target.GetPositionZ());
|
|
auto found=startProbes.find(key);if(found!=startProbes.end()){result=found->second;return;}
|
|
auto begin=std::chrono::steady_clock::now();++trace.StartCalls;
|
|
sClientlessTravelMgr->AnalyzePath(const_cast<Player*>(player),target,result);
|
|
trace.StartUs+=std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now()-begin).count();
|
|
startProbes.emplace(key,result);
|
|
};
|
|
sClientlessFlightMgr->BeginPlanning(player);
|
|
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;
|
|
}
|
|
|
|
if (!locationGoal && !sAdventureAreaMgr->AllowsNode(player,destinationItr->second))
|
|
{ error="destination node denied by area/node catalog"; return false; }
|
|
if (locationGoal)
|
|
{
|
|
Position goal;goal.Relocate(locationGoal->X,locationGoal->Y,locationGoal->Z);
|
|
if (!sAdventureAreaMgr->AllowsPosition(player,locationGoal->MapId,goal))
|
|
{ error="destination location denied by area catalog"; return false; }
|
|
}
|
|
bool const flightEnabled = sClientlessFlightMgr->Enabled(player);
|
|
auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z, bool access = false)
|
|
{
|
|
if (!flightEnabled || from.MapId != map ||
|
|
(blockedBridgeKeys && blockedBridgeKeys->count("flight_origin:"+from.Key)))
|
|
return std::numeric_limits<float>::infinity();
|
|
Position a,b; a.Relocate(from.X,from.Y,from.Z,from.O); b.Relocate(x,y,z);
|
|
auto begin=std::chrono::steady_clock::now();++trace.FlightCalls;
|
|
float result=sClientlessFlightMgr->Estimate(player,map,a,b,access);
|
|
if(access && std::isfinite(result))
|
|
{
|
|
float distance=a.GetExactDist(&b);
|
|
if(distance<20)result=std::numeric_limits<float>::infinity();
|
|
else result=(sClientlessFlightMgr->Preferred(player,map,a) || sClientlessFlightMgr->Preferred(player,map,b))?
|
|
40+distance*0.4f:180+distance*1.5f;
|
|
}
|
|
trace.FlightUs+=std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now()-begin).count();
|
|
return result;
|
|
};
|
|
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<QueueItem, std::vector<QueueItem>, std::greater<QueueItem>> open;
|
|
std::unordered_map<std::string, float> distance;
|
|
std::unordered_map<std::string, Previous> previous;
|
|
std::unordered_map<std::string, State> states;
|
|
std::unordered_map<std::string, uint32> 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<std::string, VirtualEntry> 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<float>::infinity();
|
|
std::vector<ClientlessTravelEdgePoint> 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<float>::infinity();
|
|
};
|
|
|
|
std::unordered_set<std::string> flightRoots;
|
|
uint32 startCount = 0;
|
|
for (auto const& pair : _nodes)
|
|
{
|
|
ClientlessTravelNode const& node = pair.second;
|
|
if (node.MapId != player->GetMapId())
|
|
continue;
|
|
if (!sAdventureAreaMgr->AllowsNode(player,node)) 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;
|
|
analyzeStart(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;
|
|
ClientlessTravelNode departure;
|
|
departure.Key="flight_departure";
|
|
departure.MapId=player->GetMapId();departure.X=player->GetPositionX();departure.Y=player->GetPositionY();departure.Z=player->GetPositionZ();
|
|
float air=blockedBridgeKeys && blockedBridgeKeys->count("flight:flight_departure->"+node.Key)?
|
|
std::numeric_limits<float>::infinity():flightCost(departure,node.MapId,node.X,node.Y,node.Z,!reachable);
|
|
if(std::isfinite(air) && (!reachable || air<entryNav.PathLength))
|
|
{flightRoots.insert(node.Key);startCost=air;}
|
|
else
|
|
{
|
|
// 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<std::pair<float, std::string>> nearestNodes;
|
|
for (auto const& pair : _nodes)
|
|
{
|
|
ClientlessTravelNode const& node = pair.second;
|
|
if (node.MapId != player->GetMapId())
|
|
continue;
|
|
if (!sAdventureAreaMgr->AllowsNode(player,node)) 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<EntryCandidate> entryCandidates;
|
|
std::unordered_set<std::string> 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 (!sAdventureAreaMgr->AllowsEdge(player,edge)) 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<ClientlessTravelEdgePoint> 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)
|
|
{
|
|
analyzeStart(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<std::string, float> validBridgeLengths;
|
|
std::unordered_set<std::string> 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{};
|
|
auto allowedPath=[&](std::vector<std::array<float,3>> const& points)
|
|
{
|
|
if(!sAdventureAreaMgr->Applies(player))return true;
|
|
if(points.empty())return false;
|
|
for(std::size_t i=1;i<points.size();++i)
|
|
{
|
|
Position p,q;p.Relocate(points[i-1][0],points[i-1][1],points[i-1][2]);q.Relocate(points[i][0],points[i][1],points[i][2]);
|
|
if(!sAdventureAreaMgr->AllowsSegment(player,from.MapId,p,q))return false;
|
|
}
|
|
return true;
|
|
};
|
|
auto key=ClientlessGraphProbeCache::MakeKey(from.MapId,player->GetInstanceId(),from.X,from.Y,from.Z,x,y,z);
|
|
if(auto cached=_positionProbes.Find(key))
|
|
{
|
|
if(!allowedPath(cached->Points))return VcorePositionPathResult{};
|
|
VcorePositionPathResult result;result.Result=VcorePositionPathResult::Status::Complete;
|
|
result.PathLength=cached->Length;result.FinalDistance=cached->Final;result.DirectDistance=cached->Direct;
|
|
return result;
|
|
}
|
|
auto started=std::chrono::steady_clock::now();++trace.Calls;
|
|
auto result=PathGenerator::AnalyzePositions(from.MapId, from.MapId,player->GetInstanceId(),a,b,NAV_GROUND);
|
|
trace.NavUs+=std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now()-started).count();
|
|
std::vector<std::array<float,3>> points;
|
|
for(auto const& p:result.Points)points.push_back({p.x,p.y,p.z});
|
|
_positionProbes.Record(key,result.IsComplete(),result.PathLength,result.FinalDistance,result.DirectDistance,points);
|
|
if(!allowedPath(points))return VcorePositionPathResult{};
|
|
return result;
|
|
};
|
|
|
|
// 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;
|
|
std::string ToNode;
|
|
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<ExitCandidate> rawExitCandidates;
|
|
std::unordered_map<std::string, std::vector<ExitCandidate>> 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<std::string> scannedDirected;
|
|
for (auto const& edgePair : _edges)
|
|
{
|
|
for (ClientlessTravelEdge const& edge : edgePair.second)
|
|
{
|
|
if (edge.Type != "ground" || !edge.DatabaseId ||
|
|
(blockedEdgeIds && blockedEdgeIds->count(edge.DatabaseId)))
|
|
continue;
|
|
if (_nodes.at(edge.FromNode).MapId!=goalMap || _nodes.at(edge.ToNode).MapId!=goalMap) continue;
|
|
if (!sAdventureAreaMgr->AllowsEdge(player,edge)) 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<ClientlessTravelEdgePoint> 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<float>::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.ToNode = edge.ToNode;
|
|
candidate.EdgeId = edge.DatabaseId;
|
|
candidate.Reverse = edge.Reverse;
|
|
candidate.EndIndex = i;
|
|
candidate.RoadCost = edge.Cost * (travelled / totalLength);
|
|
candidate.DistanceSq = dsq;
|
|
candidate.Point = point;
|
|
bool const groundExitExhausted = blockedBridgeKeys &&
|
|
blockedBridgeKeys->count("ground_exit_edge:" + identity);
|
|
candidate.LocalCost = locationGoal && !groundExitExhausted && dsq <= locationAccessRadius*locationAccessRadius
|
|
? std::sqrt(dsq) : std::numeric_limits<float>::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<float>::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<float>::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<float>::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<float>::infinity() :
|
|
std::min(direct<=locationAccessRadius ? direct : std::numeric_limits<float>::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<bestGoalCost)
|
|
{
|
|
bestGoalCost=current.Cost+exit.RoadCost+exit.LocalCost;
|
|
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
|
|
// fire even before the route executor advances to that edge.
|
|
bool const isPortalSource =
|
|
current.Value.Node.rfind("portal_at_", 0) == 0 &&
|
|
current.Value.Node.size() >= 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 (!sAdventureAreaMgr->AllowsEdge(player,edge)) continue;
|
|
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;
|
|
State next{target.Key,false};std::string key=stateKey(next);
|
|
auto old=distance.find(key);
|
|
float dx=target.X-origin.X,dy=target.Y-origin.Y,dz=target.Z-origin.Z;
|
|
// Even a preferred legal flight costs at least this much.
|
|
// Skip expensive terrain sampling when it cannot improve the
|
|
// route already found; this preserves the full graph search.
|
|
float lowerBound=40.0f+std::sqrt(dx*dx+dy*dy+dz*dz)*0.4f;
|
|
if(old!=distance.end() && old->second<=current.Cost+lowerBound)continue;
|
|
if (!sAdventureAreaMgr->AllowsNode(player,target)) 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;
|
|
float score=current.Cost+cost;
|
|
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<ForwardCandidate> 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;
|
|
if(_nodes.at(edge.FromNode).MapId!=from.MapId || _nodes.at(edge.ToNode).MapId!=from.MapId)continue;
|
|
if (!sAdventureAreaMgr->AllowsEdge(player,edge)) 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<std::string> 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);
|
|
}
|
|
}
|
|
|
|
// Fixed graph-node neighborhoods are shared across plans. Keep a cube
|
|
// superset: the original exact sphere/path/block-list checks still run.
|
|
uint32 radiusBits=0;std::memcpy(&radiusBits,&bridgeRadius,sizeof(float));
|
|
std::string neighborhoodKey=from.Key+":"+std::to_string(radiusBits);
|
|
auto neighborhood=_bridgeNeighborhoods.find(neighborhoodKey);
|
|
if(neighborhood==_bridgeNeighborhoods.end())
|
|
{
|
|
if(_bridgeNeighborhoods.size()>=4096)_bridgeNeighborhoods.clear();
|
|
std::vector<std::string> candidates;
|
|
for(auto const& entry:_nodes)
|
|
{
|
|
auto const& to=entry.second;
|
|
if(to.MapId==from.MapId && std::fabs(to.X-from.X)<=bridgeRadius &&
|
|
std::fabs(to.Y-from.Y)<=bridgeRadius && std::fabs(to.Z-from.Z)<=bridgeRadius)candidates.push_back(entry.first);
|
|
}
|
|
neighborhood=_bridgeNeighborhoods.emplace(neighborhoodKey,std::move(candidates)).first;
|
|
}
|
|
for (auto const& key : neighborhood->second)
|
|
{
|
|
ClientlessTravelNode const& to = _nodes.at(key);
|
|
if (!sAdventureAreaMgr->AllowsNode(player,to)) continue;
|
|
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<float>::infinity();
|
|
uint32 bestPersistentHops = 0;
|
|
float bestCost = std::numeric_limits<float>::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<Reconstructed> 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);
|
|
}
|
|
if(route.empty() && item.IncomingEdgeType=="start" && flightRoots.count(item.Value.Node))
|
|
{
|
|
ClientlessTravelRouteStep departure;departure.NodeKey="flight_departure";departure.NodeType="ground";departure.EdgeType="start";
|
|
departure.MapId=player->GetMapId();departure.X=player->GetPositionX();departure.Y=player->GetPositionY();departure.Z=player->GetPositionZ();
|
|
route.push_back(departure);
|
|
}
|
|
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=="start" && flightRoots.count(item.Value.Node)?"free_flight":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().GroundContinuationNode = route.back().NodeKey;
|
|
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.GroundContinuationNode = exit.ToNode;
|
|
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<ClientlessTravelRouteStep> 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<ClientlessTravelRouteStep>& route,
|
|
std::string& anchorNode,
|
|
std::string& error,
|
|
std::unordered_set<uint32> const* blockedEdgeIds)
|
|
{
|
|
route.clear();
|
|
anchorNode.clear();
|
|
error.clear();
|
|
|
|
EnsureLoaded();
|
|
|
|
if (!player || !player->IsInWorld())
|
|
{
|
|
error = "player is not in world";
|
|
return false;
|
|
}
|
|
|
|
Position goal;goal.Relocate(destinationX,destinationY,destinationZ);
|
|
if (!sAdventureAreaMgr->AllowsPosition(player,destinationMapId,goal))
|
|
{ error="destination location denied by area catalog"; 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<QueueItem, std::vector<QueueItem>, std::greater<QueueItem>> open;
|
|
std::unordered_map<std::string, float> distance;
|
|
std::unordered_map<std::string, Previous> previous;
|
|
|
|
for (auto const& pair : _nodes)
|
|
distance[pair.first] = std::numeric_limits<float>::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;
|
|
if (!sAdventureAreaMgr->AllowsNode(player,node)) 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 (!sAdventureAreaMgr->AllowsEdge(player,edge)) continue;
|
|
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<float>::infinity();
|
|
float bestGraphCost = std::numeric_limits<float>::infinity();
|
|
|
|
for (auto const& pair : _nodes)
|
|
{
|
|
ClientlessTravelNode const& node = pair.second;
|
|
if (!sAdventureAreaMgr->AllowsNode(player,node)) continue;
|
|
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<std::string> 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<ClientlessTravelEdgePoint> const*
|
|
ClientlessTravelGraphMgr::GetEdgePoints(uint32 edgeDatabaseId) const
|
|
{
|
|
auto itr = _edgePoints.find(edgeDatabaseId);
|
|
return itr != _edgePoints.end() ? &itr->second : nullptr;
|
|
}
|