Files
Viano-Core/src/server/game/World/ClientlessTravelGraphMgr.cpp
T
Nico 3c361e435a
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s
Improve AdventureBot instance and flight navigation
2026-11-01 17:00:59 +01:00

2422 lines
106 KiB
C++

#include "AdventureAreaMgr.h"
#include "ConditionMgr.h"
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessFlightMgr.h"
#include "ClientlessLuaEngine.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;
bool const caster=info && info->type==GAMEOBJECT_TYPE_SPELLCASTER;
bool const goober=info && info->type==GAMEOBJECT_TYPE_GOOBER;
if(!caster && !goober)
{error="spawn must be a static clickable spellcaster or goober portal";return false;}
if(goober && (info->goober.open || info->goober.questID || info->goober.eventID ||
info->goober.pageID || info->goober.gossipID || info->goober.linkedTrap || info->goober.consumable))
{error="quest, event, locked or consumable goober needs a dedicated interaction";return false;}
uint32 rootSpell=caster?info->spellcaster.spell:info->goober.spell;
uint8 mask=(caster?info->spellcaster.playerCast:info->goober.playerCast)?3:2;
SpellInfo const* spell=sSpellMgr->GetSpellInfo(rootSpell);
SpellTargetPosition resolved;
SpellTargetPosition const* destination=nullptr;
std::unordered_set<uint32> seen;
uint32 delay=0;
for(unsigned depth=0;spell && depth<5;++depth)
{
if(!seen.insert(spell->Id).second)break;
portal.Requirements.emplace_back(spell->Id,mask);
uint32 next=0;
for(uint8 i=0;i<MAX_SPELL_EFFECTS;++i)
{
auto const& effect=spell->Effects[i];
if(effect.Effect==SPELL_EFFECT_TELEPORT_UNITS)
{
if(destination || next || (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;}
}
else if(effect.Effect==SPELL_EFFECT_SCRIPT_EFFECT)
{
auto script=sSpellScripts.find(uint32(spell->Id | (uint32(i)<<24)));
if(script==sSpellScripts.end() || script->second.empty())continue;
if(destination || next || script->second.size()!=1)
{error="portal script must have exactly one deterministic action";return false;}
auto const& action=script->second.begin()->second;
if(script->second.begin()->first>3-delay)
{error="portal script delay exceeds native interaction timeout reserve";return false;}
delay+=script->second.begin()->first;
if(action.command==SCRIPT_COMMAND_CAST_SPELL && action.CastSpell.Flags==SF_CASTSPELL_TARGET_TO_TARGET &&
(action.CastSpell.CreatureEntry==0 || action.CastSpell.CreatureEntry==1))
next=action.CastSpell.SpellID;
else if(action.command==SCRIPT_COMMAND_TELEPORT_TO && !action.TeleportTo.Flags)
{
resolved.target_mapId=action.TeleportTo.MapID;
resolved.target_X=action.TeleportTo.DestX;resolved.target_Y=action.TeleportTo.DestY;
resolved.target_Z=action.TeleportTo.DestZ;resolved.target_Orientation=action.TeleportTo.Orientation;
destination=&resolved;
}
else {error="portal script is not a fixed visitor teleport";return false;}
}
}
if(destination)break;
spell=next?sSpellMgr->GetSpellInfo(next):nullptr;mask=3;
}
if(!destination){error="portal requires an unambiguous native fixed teleport destination";return false;}
portal.SpawnId = spawnId;
portal.Entry = spawn->id;
portal.SpellId = rootSpell;
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::PortalNativePrerequisites(Player const* player,uint32 edgeDatabaseId) const
{
auto portal=GetPortalUse(edgeDatabaseId);
auto info=portal?sObjectMgr->GetGameObjectTemplate(portal->Entry):nullptr;
if(!info || (info->type!=GAMEOBJECT_TYPE_SPELLCASTER && info->type!=GAMEOBJECT_TYPE_GOOBER))return false;
for(auto const& requirement:portal->Requirements)
if(!sConditionMgr->IsPlayerMeetingPrerequisiteConditions(CONDITION_SOURCE_TYPE_SPELL,
requirement.first,player,requirement.second))return false;
return !portal->Requirements.empty();
}
bool ClientlessTravelGraphMgr::AdventurePortalUnlockAllowed(Player const* player,
uint32 edgeDatabaseId) const
{
// AdventureBots cannot acquire quest progress. Once they have reached the
// Cataclysm level range, graph-bound public travel portals are therefore
// considered unlocked. This does not bypass faction/area/node policy or
// make arbitrary, unbound GameObjects usable.
return player && player->getLevel() >= 80 && GetPortalUse(edgeDatabaseId) &&
sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
}
bool ClientlessTravelGraphMgr::PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const
{
return PortalNativePrerequisites(player,edgeDatabaseId) ||
AdventurePortalUnlockAllowed(player,edgeDatabaseId);
}
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 ((!RescueNodeAllowed(node) || !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 ((!RescueNodeAllowed(_nodes.at(destinationNode)) || !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 ((!RescueEdgeAllowed(edge) || !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 || !RescueNodeAllowed(node))
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;
std::map<std::pair<ClientlessGraphProbeCache::Key, bool>, float> flightEstimates;
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 && (!RescueNodeAllowed(destinationItr->second) || !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);
constexpr std::uint64_t MaxFlightEstimatesPerPlan = 16;
// Preserve part of the global budget for nodes reached later in the
// search. In particular, a flight to a portal source must still leave
// enough probes for the flight from its destination map to the goal.
constexpr std::uint64_t MaxFlightEstimatesPerOrigin = 6;
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 const estimateKey = std::make_pair(
ClientlessGraphProbeCache::MakeKey(map, player->GetInstanceId(),
from.X, from.Y, from.Z, x, y, z), access);
auto cachedEstimate = flightEstimates.find(estimateKey);
if (cachedEstimate != flightEstimates.end())
return cachedEstimate->second;
// Flight links are optional accelerators around the persistent graph.
// A pathological graph must not monopolize the world thread while
// constructing an implicit complete air graph. Cached estimates remain
// usable after the cap; unexamined links simply stay unavailable in
// this plan and ordinary graph travel can still win.
if (trace.FlightCalls >= MaxFlightEstimatesPerPlan)
return std::numeric_limits<float>::infinity();
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();
flightEstimates.emplace(estimateKey, result);
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);
// The overwhelmingly common free-flight case is a same-map Location. Do
// not construct a macro graph first: Estimate already enforces licenses,
// mount flags, area policy, distance and the complete catalog segment.
// Native departure/landing validation still happens in FlightMgr::Start.
if (locationGoal && locationGoal->MapId == player->GetMapId())
{
ClientlessTravelNode departureNode;
departureNode.Key = "flight_departure";
departureNode.MapId = player->GetMapId();
departureNode.X = player->GetPositionX();
departureNode.Y = player->GetPositionY();
departureNode.Z = player->GetPositionZ();
float const directFlight = flightCost(departureNode, locationGoal->MapId,
locationGoal->X, locationGoal->Y, locationGoal->Z);
if (std::isfinite(directFlight))
{
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(std::move(departure));
ClientlessTravelRouteStep flight;
flight.NodeKey = "flight_location";
flight.NodeType = "location";
flight.EdgeType = "free_flight";
flight.MapId = locationGoal->MapId;
flight.X = locationGoal->X;
flight.Y = locationGoal->Y;
flight.Z = locationGoal->Z;
route.push_back(std::move(flight));
complete = true;
reachedNode = "flight_location";
return true;
}
}
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 ((!RescueNodeAllowed(node) || !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();
// A valid local MMAP entry is the normal, organic graph access and
// must not spend the bounded flight-estimate budget. Free flight is
// the recovery/re-entry mode for nodes that local navigation cannot
// reach, not a shortcut racing every nearby pavement node.
float air = std::numeric_limits<float>::infinity();
if (!reachable && !(blockedBridgeKeys &&
blockedBridgeKeys->count("flight:flight_departure->"+node.Key)))
air=flightCost(departure,node.MapId,node.X,node.Y,node.Z,true);
if(std::isfinite(air) && !reachable)
{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 ((!RescueNodeAllowed(node) || !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 ((!RescueEdgeAllowed(edge) || !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;
std::size_t flightExitProbes = 0;
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 ((!RescueEdgeAllowed(edge) || !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();
struct RankedFlightExit
{
float LowerBound = 0.0f;
ExitCandidate Candidate;
};
std::vector<RankedFlightExit> rankedFlightExits;
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();
// A recorded edge can contain hundreds of samples. Calling
// FlightMgr::Estimate for every sample made the synchronous
// world-thread planner quadratic in road geometry (13k+
// area checks for one route in production). Keep an exact
// ground candidate, but validate only the geometrically best
// flight exits below. Estimate still performs all catalog and
// native flight-permission checks for those candidates.
if (std::isfinite(candidate.LocalCost) &&
(!std::isfinite(nearest.DistanceSq) ||
candidate.RoadCost + candidate.LocalCost < nearest.RoadCost + nearest.LocalCost))
nearest = candidate;
if (flightEnabled && !(blockedBridgeKeys && blockedBridgeKeys->count("flight_exit:" + exitKey)))
{
float const direct = std::sqrt(dsq);
// Forty plus 0.4 * distance is the cheapest possible
// free-flight cost. It is an admissible ordering key;
// rejected area segments simply advance to the next item.
rankedFlightExits.push_back({ candidate.RoadCost + 40.0f + direct * 0.4f,
std::move(candidate) });
}
}
std::sort(rankedFlightExits.begin(), rankedFlightExits.end(),
[](RankedFlightExit const& left, RankedFlightExit const& right)
{ return left.LowerBound < right.LowerBound; });
constexpr std::size_t MaxFlightExitProbesPerEdge = 2;
for (std::size_t i = 0; i < std::min(MaxFlightExitProbesPerEdge, rankedFlightExits.size()); ++i)
{
constexpr std::size_t MaxFlightExitProbesPerPlan = 8;
if (flightExitProbes >= MaxFlightExitProbesPerPlan)
break;
++flightExitProbes;
RankedFlightExit const& ranked = rankedFlightExits[i];
ExitCandidate candidate = ranked.Candidate;
ClientlessTravelNode exitNode;
exitNode.MapId = candidate.Point.MapId;
exitNode.X = candidate.Point.X;
exitNode.Y = candidate.Point.Y;
exitNode.Z = candidate.Point.Z;
float const air = flightCost(exitNode, goalMap, goalX, goalY, goalZ);
if (!std::isfinite(air))
continue;
candidate.LocalCost = air;
candidate.Flight = true;
if (std::isfinite(nearest.DistanceSq) &&
candidate.RoadCost + candidate.LocalCost >= nearest.RoadCost + nearest.LocalCost)
continue;
nearest = std::move(candidate);
}
// Never replace a node goal with an unvalidated ground exit.
if (!locationGoal && std::isfinite(nearest.DistanceSq) && !nearest.Flight)
nearest.DistanceSq = std::numeric_limits<float>::infinity();
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;
// Every permitted node is a potential way back into the persistent graph.
// Do not materialize an all-to-all air graph, though: candidates are ranked
// on demand below and the shared per-plan Estimate cap remains authoritative.
// This is important for isolated anchors such as the capital Cataclysm
// portals, but is deliberately not portal-specific.
auto flightRemainderLowerBound = [&](ClientlessTravelNode const& candidate)
{
ClientlessTravelNode const& destination = destinationItr->second;
auto lowerBoundToGoal = [&](ClientlessTravelNode const& node)
{
if (node.MapId != destination.MapId)
return std::numeric_limits<float>::infinity();
float dx = destination.X-node.X, dy = destination.Y-node.Y, dz = destination.Z-node.Z;
return 40.0f + std::sqrt(dx*dx+dy*dy+dz*dz)*0.4f;
};
float best = lowerBoundToGoal(candidate);
auto edges = _edges.find(candidate.Key);
if (edges != _edges.end())
for (ClientlessTravelEdge const& edge : edges->second)
{
if ((!RescueEdgeAllowed(edge) || !sAdventureAreaMgr->AllowsEdge(player,edge)))
continue;
if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))
continue;
auto next = _nodes.find(edge.ToNode);
if (next == _nodes.end())
continue;
best = std::min(best, edge.Cost + lowerBoundToGoal(next->second));
}
return best;
};
// 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.
auto const sourceEdges = _edges.find(current.Value.Node);
bool const isPortalSource = sourceEdges != _edges.end() &&
std::any_of(sourceEdges->second.begin(), sourceEdges->second.end(),
[](ClientlessTravelEdge const& edge)
{ return edge.Type == "portal" || edge.Type == "portal_use"; });
bool const portalArrival = isPortalSource && previous.find(currentKey) != previous.end();
// Persistent TravelGraph edges always reset the bridge guard.
auto edgeItr = _edges.find(current.Value.Node);
bool hasPersistentContinuation = false;
if (edgeItr != _edges.end())
{
for (ClientlessTravelEdge const& edge : edgeItr->second)
{
if ((!RescueEdgeAllowed(edge) || !sAdventureAreaMgr->AllowsEdge(player,edge))) continue;
if (portalArrival && edge.Type != "portal" && edge.Type != "portal_use")
continue;
if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))
continue;
hasPersistentContinuation = true;
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 && trace.FlightCalls < MaxFlightEstimatesPerPlan)
{
auto const& origin=_nodes.at(current.Value.Node);
std::vector<std::pair<float, std::string>> macroFlightCandidates;
std::vector<std::pair<float, std::string>> flightCandidates;
flightCandidates.reserve(_nodes.size());
for (auto const& candidate : _nodes)
{
auto const& target=candidate.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 ((!RescueNodeAllowed(target) || !sAdventureAreaMgr->AllowsNode(player,target))) continue;
if (blockedBridgeKeys && blockedBridgeKeys->count("flight:"+origin.Key+"->"+target.Key)) continue;
float const remainder = flightRemainderLowerBound(target);
if (!std::isfinite(remainder))
continue;
bool macroConnector = target.Key == destinationNode;
auto outgoing = _edges.find(target.Key);
if (outgoing != _edges.end())
for (ClientlessTravelEdge const& edge : outgoing->second)
{
auto next = _nodes.find(edge.ToNode);
bool const changesMap = next != _nodes.end() &&
next->second.MapId != target.MapId;
bool const physical = edge.Type == "portal" ||
edge.Type == "portal_use" || edge.Type == "ship" ||
edge.Type == "zeppelin" || edge.Type == "tram" ||
edge.Type == "transport";
if (changesMap || physical)
{
macroConnector = true;
break;
}
}
auto& candidates = macroConnector ? macroFlightCandidates : flightCandidates;
candidates.emplace_back(lowerBound + remainder, target.Key);
}
std::sort(macroFlightCandidates.begin(), macroFlightCandidates.end(),
[&](auto const& left, auto const& right)
{
// Every newly reached portal/transport endpoint must get
// one chance to finish the requested journey before the
// bounded probe window is spent on further connectors.
bool const leftIsDestination = left.second == destinationNode;
bool const rightIsDestination = right.second == destinationNode;
if (leftIsDestination != rightIsDestination)
return leftIsDestination;
return left < right;
});
std::sort(flightCandidates.begin(), flightCandidates.end());
macroFlightCandidates.insert(macroFlightCandidates.end(),
flightCandidates.begin(), flightCandidates.end());
std::uint64_t const callsAtOrigin = trace.FlightCalls;
for (auto const& ranked : macroFlightCandidates)
{
if (trace.FlightCalls >= MaxFlightEstimatesPerPlan ||
trace.FlightCalls - callsAtOrigin >= MaxFlightEstimatesPerOrigin)
break;
auto const& target = _nodes.at(ranked.second);
State next{target.Key,false};std::string key=stateKey(next);
auto old=distance.find(key);
auto const blockedFlight = blockedBridgeKeys &&
blockedBridgeKeys->count("flight:"+origin.Key+"->"+target.Key);
if (blockedFlight)
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.
auto const futureEdges = _edges.find(from.Key);
bool const futurePortalSource = futureEdges != _edges.end() &&
std::any_of(futureEdges->second.begin(), futureEdges->second.end(),
[](ClientlessTravelEdge const& edge)
{ return edge.Type == "portal" || edge.Type == "portal_use"; });
if (!futurePortalSource && !hasPersistentContinuation && 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 ((!RescueEdgeAllowed(edge) || !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 ((!RescueNodeAllowed(to) || !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 ((!RescueNodeAllowed(node) || !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 ((!RescueEdgeAllowed(edge) || !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 ((!RescueNodeAllowed(node) || !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;
}
// Rescue nodes stay visible to administration but are never normal graph roots,
// anchors, bridge endpoints or shortcuts. Only this bounded planning scope can
// enter the recorded escape network. Native access policy remains mandatory.
bool ClientlessTravelGraphMgr::PlanRescueRoute(Player* player,
std::vector<ClientlessTravelRouteStep>& route, std::string& teleportNode,
bool forceTeleport, std::unordered_set<uint32> const* blockedEdgeIds)
{
route.clear();teleportNode.clear();EnsureLoaded();
if(!player || !player->IsInWorld())return false;
struct Scope {bool& Flag;bool Previous;Scope(bool& f):Flag(f),Previous(f){Flag=true;}~Scope(){Flag=Previous;}} scope(_rescuePlanning);
std::vector<ClientlessTravelNode const*> roots;
for(auto const& entry:_nodes)
if(entry.second.Type=="rescue" && entry.second.MapId==player->GetMapId() &&
sAdventureAreaMgr->AllowsNode(player,entry.second))roots.push_back(&entry.second);
auto distance=[&](ClientlessTravelNode const* n){return player->GetExactDist(n->X,n->Y,n->Z);};
std::sort(roots.begin(),roots.end(),[&](auto a,auto b){float da=distance(a),db=distance(b);return da==db?a->Key<b->Key:da<db;});
std::vector<ClientlessTravelRouteStep> fallback;
std::string fallbackNode;
auto stepOf=[](ClientlessTravelNode const& node,ClientlessTravelEdge const* edge)
{
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;
step.EdgeType=edge?edge->Type:"start";step.EdgeDatabaseId=edge?edge->DatabaseId:0;
step.EdgeReverse=edge && edge->Reverse;return step;
};
for(auto root:roots)
{
// Follow only explicitly recorded same-map ground edges until the first
// ordinary node. No virtual bridge or flight may invent an escape edge.
std::vector<std::string> queue{root->Key};
std::unordered_map<std::string,ClientlessTravelEdge const*> previous;
previous[root->Key]=nullptr;std::string exit;
for(size_t i=0;i<queue.size() && exit.empty();++i)
{
auto outgoing=_edges.find(queue[i]);if(outgoing==_edges.end())continue;
for(auto const& edge:outgoing->second)
{
auto const& target=_nodes.at(edge.ToNode);
auto points=GetEdgePoints(edge.DatabaseId);
if(edge.Type!="ground" || !points || points->size()<2 || target.MapId!=root->MapId ||
previous.count(target.Key) || (blockedEdgeIds && blockedEdgeIds->count(edge.DatabaseId)) ||
!sAdventureAreaMgr->AllowsNode(player,target) || !sAdventureAreaMgr->AllowsEdge(player,edge))continue;
previous[target.Key]=&edge;
if(target.Type!="rescue"){exit=target.Key;break;}
queue.push_back(target.Key);
}
}
if(exit.empty())continue;
std::vector<ClientlessTravelRouteStep> escape;
for(std::string key=exit;key!=root->Key;)
{auto edge=previous.at(key);escape.push_back(stepOf(_nodes.at(key),edge));key=edge->FromNode;}
std::reverse(escape.begin(),escape.end());
std::vector<ClientlessTravelRouteStep> approach;bool complete=false;std::string reached,error;
if(!forceTeleport && PlanAdaptiveRoute(player,root->Key,4000,200,approach,complete,reached,error,blockedEdgeIds) && complete)
{
route=std::move(approach);route.insert(route.end(),escape.begin(),escape.end());return true;
}
if(fallback.empty())
{fallback.push_back(stepOf(*root,nullptr));fallback.insert(fallback.end(),escape.begin(),escape.end());fallbackNode=root->Key;}
}
// The special transfer permission applies to stranded Deeprun Tram bots
// only. Other maps may use a reachable escape network, never teleport to it.
if(player->GetMapId()!=369 || fallback.empty())return false;
route=std::move(fallback);teleportNode=fallbackNode;return true;
}