Implement clientless transports, Lua reload and same-map portals

Add native transport recording and execution with grid lifecycle handling, clientless teleport acknowledgements, and regression tests. Support targeted same-map portal imports and preserve disabled taxi nodes. Document live transport and portal verification and the remaining portal inventory.
This commit is contained in:
Nico committed 2026-10-26 11:27:56 +01:00
1 parent 01a9ba4b0b
commit 1164a72ea3
40 files changed
+4533 -163

No files matched your search

@@ -1,4 +1,5 @@
#include "ClientlessActionExecutorMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessPerceptionMgr.h"
#include "ClientlessPlayerMgr.h"
@@ -149,6 +150,12 @@ ClientlessActionExecutorMgr* ClientlessActionExecutorMgr::instance()
return &instance;
}
void ClientlessActionExecutorMgr::Clear(uint32 characterGuid)
{
_records.erase(characterGuid);
sClientlessTravelMgr->CancelTravel(characterGuid);
}
char const* ClientlessActionExecutorMgr::StateName(ClientlessActionExecutionState state)
{
switch (state)
@@ -171,8 +178,16 @@ ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(ui
bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record)
{
record.TransportActive = false;
record.Status = {};
record.Status.ActionName = action.Name;
if (player->GetTransport())
{
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.Detail = "player is already aboard a transport; wait for a safe recorded stop";
record.Active = false;
return false;
}
record.Status.State = ClientlessActionExecutionState::SelectingTarget;
record.FinalLocalLegPending = false;
record.FinalLocalLegActive = false;
@@ -722,6 +737,7 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation(
std::string anchorNode;
std::string error;
float proxyDistance = std::numeric_limits<float>::infinity();
sClientlessTravelGraphMgr->EnsureLoaded();
if (!sClientlessTravelGraphMgr->FindNearestNodeToPosition(
point->MapId, point->X, point->Y, point->Z, anchorNode, proxyDistance))
{
@@ -796,15 +812,11 @@ bool ClientlessActionExecutorMgr::StartFinalLocalLeg(
record.FinalZ,
record.FinalO);
sClientlessLuaEngine->DismountGroundMount(player, "final-local-leg");
if (!sClientlessTravelMgr->StartTravel(player, destination, false))
// A graph boundary does not require dismounting. Keep the normal ground
// mount policy on the final MMAP leg, including subsequent local segments.
if (!sClientlessTravelMgr->StartTravel(player, destination, true))
{
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.Detail = "location final local travel start failed";
record.Active = false;
record.FinalLocalLegPending = false;
return false;
return RecoverFinalLocalLeg(player, characterGuid, record, "final-local-start-failed");
}
record.FinalLocalLegPending = false;
@@ -816,11 +828,11 @@ bool ClientlessActionExecutorMgr::StartFinalLocalLeg(
record.Status.Detail = "local navigation to final location";
record.Active = true;
TC_LOG_INFO("server.adventurebot",
"[AdventureBot] travel-location-local-leg guid=%u name='%s' location='%s'",
TC_LOG_INFO("server.adventurebot.navigation",
"[AdventureBot] travel-location-local-leg guid=%u name='%s' location='%s' mounted=%u",
characterGuid,
player->GetName().c_str(),
record.FinalLocationId.c_str());
record.FinalLocationId.c_str(), player->IsMounted() ? 1u : 0u);
return true;
}
@@ -878,6 +890,22 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
step.NodeKey.c_str(), step.NodeType.c_str(), step.EdgeType.c_str(),
step.EdgeDatabaseId, step.EdgeReverse ? 1u : 0u, record.RecoveryCount);
if (ClientlessTransportMgr::IsTransportEdge(step.EdgeType))
{
std::string error;
if (!record.RouteStep || !sClientlessTransportMgr->Start(player,
record.Route[record.RouteStep - 1].NodeKey, step.NodeKey, error))
{
return RecoverTravel(player, characterGuid, record, step.EdgeDatabaseId,
"transport-start-failed: " + error);
}
record.TransportActive = true;
record.Active = true;
record.Status.State = ClientlessActionExecutionState::Moving;
record.Status.Detail = "waiting for native transport";
return true;
}
if (step.EdgeType != "start" && step.EdgeType != "ground" &&
step.EdgeType != "taxi" && step.EdgeType != "portal" &&
step.EdgeType != "local_bridge")
@@ -990,18 +1018,6 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
return false;
}
// C8.13a only imports far map transitions. Keeping this invariant here
// prevents a clientless Player from getting stuck waiting for the near
// MOVE_TELEPORT_ACK path, which is intentionally not implemented yet.
if (uint32(atEntry->ContinentID) == teleport->target_mapId)
{
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.TargetName = "node:" + step.NodeKey;
record.Status.Detail = "same-map portal transition is not supported by C8.13a";
record.Active = false;
return false;
}
if (player->GetMapId() != uint32(atEntry->ContinentID) ||
!player->IsInAreaTriggerRadius(atEntry))
{
@@ -1045,7 +1061,7 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
areaTriggerPacket << triggerId;
player->GetSession()->HandleAreaTriggerOpcode(areaTriggerPacket);
if (player->IsBeingTeleportedFar())
if (player->IsBeingTeleported())
{
if (!player->GetSession()->IsClientless())
{
@@ -1055,13 +1071,11 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
return false;
}
// A real client would answer the far transfer with CMSG_MOVE_WORLDPORT_ACK.
// Clientless sessions have no socket, so finish the exact same server-side
// transfer path explicitly after the normal AreaTrigger handler accepted it.
player->GetSession()->HandleMoveWorldportAck();
// Finish the normal near/far ACK after the real AreaTrigger handler.
player->GetSession()->CompleteClientlessTeleport();
}
if (!player->IsInWorld() || player->GetMapId() != teleport->target_mapId)
if (!player->IsInWorld() || player->IsBeingTeleported() || player->GetMapId() != teleport->target_mapId)
{
TC_LOG_WARN("server.adventurebot.navigation",
"EDGE_FAILED guid=%u name='%s' kind='portal-transfer-not-completed' edgeDbId=%u "
@@ -1083,6 +1097,18 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
float const dy = player->GetPositionY() - teleport->target_Y;
float const dz = player->GetPositionZ() - teleport->target_Z;
float const arrivalError = std::sqrt(dx * dx + dy * dy + dz * dz);
// On a same-map transition the map ID alone also matches a denied
// teleport. Require arrival at the actual destination before advancing.
if (arrivalError > 5.0f)
{
TC_LOG_WARN("server.adventurebot.navigation",
"EDGE_FAILED guid=%u kind='portal-destination-not-reached' trigger=%u arrivalError=%.2f",
characterGuid, triggerId, arrivalError);
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.Detail = "AreaTrigger destination not reached";
record.Active = false;
return false;
}
record.Status.State = ClientlessActionExecutionState::Moving;
record.Status.TargetName = "node:" + step.NodeKey;
@@ -1649,6 +1675,7 @@ bool ClientlessActionExecutorMgr::RecoverTravel(
{
std::string anchorNode;
float proxyDistance = std::numeric_limits<float>::infinity();
sClientlessTravelGraphMgr->EnsureLoaded();
if (sClientlessTravelGraphMgr->FindNearestNodeToPosition(
record.FinalMapId, record.FinalX, record.FinalY, record.FinalZ,
anchorNode, proxyDistance))
@@ -1738,6 +1765,36 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode(
uint32 characterGuid,
Record& record)
{
if (record.TransportActive)
{
ClientlessTransportStatus const* transport = sClientlessTransportMgr->GetStatus(characterGuid);
if (transport && transport->Phase != ClientlessTransportPhase::Failed &&
transport->Phase != ClientlessTransportPhase::Arrived)
{
record.Status.Detail = transport->Detail;
return;
}
record.TransportActive = false;
if (transport && transport->Phase == ClientlessTransportPhase::Arrived)
{
++record.RouteStep;
StartRouteStep(player, characterGuid, record);
}
else if (player->GetTransport() || (transport && transport->Cancelled))
{
record.Status.State = ClientlessActionExecutionState::Failed;
record.Status.Detail = transport ? transport->Detail : "transport state lost aboard";
record.Active = false;
}
else
{
uint32 edge = record.RouteStep < record.Route.size() ? record.Route[record.RouteStep].EdgeDatabaseId : 0;
std::string reason = transport ? transport->Detail : "transport state lost";
RecoverTravel(player, characterGuid, record, edge, "transport-failed: " + reason);
}
return;
}
if (record.RecoveryWanderActive)
{
ClientlessTravelStatus const* wander =
@@ -2065,11 +2122,11 @@ void ClientlessActionExecutorMgr::UpdateTravelToLocation(
record.Active = false;
record.FinalLocalLegActive = false;
TC_LOG_INFO("server.adventurebot",
"[AdventureBot] travel-location-complete guid=%u name='%s' location='%s'",
TC_LOG_INFO("server.adventurebot.navigation",
"[AdventureBot] travel-location-complete guid=%u name='%s' location='%s' mounted=%u",
characterGuid,
player->GetName().c_str(),
record.FinalLocationId.c_str());
record.FinalLocationId.c_str(), player->IsMounted() ? 1u : 0u);
return;
}
@@ -2083,6 +2140,12 @@ void ClientlessActionExecutorMgr::UpdateTravelToLocation(
ClientlessTravelMgr::StateName(travel->State),
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ());
RecoverFinalLocalLeg(player, characterGuid, record, reason);
}
bool ClientlessActionExecutorMgr::RecoverFinalLocalLeg(
Player* player, uint32 characterGuid, Record& record, std::string const& reason)
{
record.FinalLocalLegActive = false;
record.FinalLocalLegPending = true;
if (!record.Route.empty())
@@ -2097,8 +2160,23 @@ void ClientlessActionExecutorMgr::UpdateTravelToLocation(
"C820_EXIT_BLOCKED edgeDbId=%u endIndex=%d reason='%s'",
last.EdgeDatabaseId, last.GroundEndIndex, reason.c_str());
}
else
{
record.FailedBridgeKeys.insert("finish:" + last.NodeKey);
TC_LOG_WARN("server.adventurebot.navigation",
"C821_NODE_FINISH_BLOCKED guid=%u node='%s' reason='%s'",
characterGuid, last.NodeKey.c_str(), reason.c_str());
}
}
RecoverTravel(player, characterGuid, record, 0, reason);
else
{
// A direct local attempt has no graph endpoint to blacklist. Ensure
// recovery can select the graph and then reject failed endpoints.
TC_LOG_WARN("server.adventurebot.navigation",
"C821_DIRECT_LOCAL_FAILED guid=%u location='%s' reason='%s'",
characterGuid, record.FinalLocationId.c_str(), reason.c_str());
}
return RecoverTravel(player, characterGuid, record, 0, reason);
}
void ClientlessActionExecutorMgr::UpdateActive(
@@ -2158,7 +2236,8 @@ void ClientlessActionExecutorMgr::Update(uint32 /*diff*/)
{
for (auto const& [characterGuid, session] : sWorld->GetAllClientlessSessions())
{
if (!session || !sClientlessPlayerMgr->IsManaged(characterGuid))
if (!session || !sClientlessPlayerMgr->IsManaged(characterGuid) ||
sWorld->GetClientlessPlayerState(characterGuid) != ClientlessPlayerState::Online)
continue;
Player* player = session->GetPlayer();
@@ -40,6 +40,7 @@ public:
static ClientlessActionExecutorMgr* instance();
void Update(uint32 diff);
void Clear(uint32 characterGuid);
ClientlessActionExecutionStatus const* GetStatus(uint32 characterGuid) const;
static char const* StateName(ClientlessActionExecutionState state);
@@ -58,6 +59,7 @@ private:
std::vector<ClientlessTravelEdgePoint> EdgePath;
// C8.10b: active server-side taxi edge.
bool TransportActive = false;
bool TaxiActive = false;
uint32 TaxiPathId = 0;
uint32 TaxiSourceNodeId = 0;
@@ -98,6 +100,7 @@ private:
bool StartTravelToNode(Player* player, ClientlessAction const& action, Record& record);
bool StartTravelToLocation(Player* player, ClientlessAction const& action, Record& record);
bool StartFinalLocalLeg(Player* player, uint32 characterGuid, Record& record);
bool RecoverFinalLocalLeg(Player* player, uint32 characterGuid, Record& record, std::string const& reason);
bool StartRouteStep(Player* player, uint32 characterGuid, Record& record);
bool StartRecordedEdgePath(Player* player, uint32 characterGuid, Record& record);
bool RecoverTravel(Player* player, uint32 characterGuid, Record& record, uint32 failedEdgeDatabaseId, std::string const& reason);
+96 -1
View File
@@ -1,5 +1,8 @@
#include "ClientlessLuaEngine.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessActionExecutorMgr.h"
#include "ClientlessTravelMgr.h"
#include "Config.h"
#include "Log.h"
#include "Player.h"
#include "Random.h"
@@ -14,6 +17,15 @@
namespace
{
// World-thread only. Script effects are committed only after a successful reload.
struct ReloadEffects
{
std::vector<ClientlessAction> Actions;
ClientlessIntent Intent;
bool IntentChanged = false;
};
ReloadEffects* pendingReload = nullptr;
uint32 BoundGuid(lua_State* L) { return uint32(lua_tointeger(L, lua_upvalueindex(1))); }
std::unordered_map<std::string, std::string> ReadMap(lua_State* L, int index)
@@ -48,12 +60,30 @@ int LuaIntend(lua_State* L)
ClientlessIntent intent;
intent.Name = luaL_checkstring(L, 1);
if (lua_gettop(L) >= 2) intent.Parameters = ReadMap(L, 2);
if (pendingReload)
{
bool valid = !intent.Empty();
if (valid)
{
pendingReload->Intent = std::move(intent);
pendingReload->IntentChanged = true;
}
lua_pushboolean(L, valid);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->SetIntent(BoundGuid(L), std::move(intent)));
return 1;
}
int LuaClearIntent(lua_State* L)
{
if (pendingReload)
{
pendingReload->Intent = ClientlessIntent();
pendingReload->IntentChanged = true;
lua_pushboolean(L, true);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->ClearIntent(BoundGuid(L)));
return 1;
}
@@ -63,6 +93,13 @@ int LuaQueueAction(lua_State* L)
ClientlessAction action;
action.Name = luaL_checkstring(L, 1);
if (lua_gettop(L) >= 2) action.Parameters = ReadMap(L, 2);
if (pendingReload)
{
bool valid = !action.Name.empty();
if (valid) pendingReload->Actions.push_back(std::move(action));
lua_pushboolean(L, valid);
return 1;
}
lua_pushboolean(L, sClientlessPlayerMgr->QueueAction(BoundGuid(L), std::move(action)));
return 1;
}
@@ -184,7 +221,10 @@ ClientlessLuaEngine* ClientlessLuaEngine::instance()
ClientlessLuaEngine::ClientlessLuaEngine()
{
_scriptRoot = std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR") ? std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR") : "scripts/clientless";
_scriptRoot = sConfigMgr->GetStringDefault("Clientless.ScriptRoot", "scripts/clientless");
if (char const* overrideRoot = std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR"))
if (*overrideRoot) _scriptRoot = overrideRoot;
_scriptRoot = std::filesystem::absolute(_scriptRoot).lexically_normal().string();
_state = luaL_newstate();
if (!_state)
{
@@ -445,6 +485,61 @@ bool ClientlessLuaEngine::ExecuteAdventureScript(Player* player)
return ExecuteScript(player, (root / "adventure" / "generic.lua").string(), "generic adventure");
}
bool ClientlessLuaEngine::ReloadAdventureScript(Player* player, std::string& detail)
{
if (!player || !player->IsInWorld() || !sClientlessPlayerMgr->IsManaged(player->GetGUID().GetCounter()))
{
detail = "Reload requires an online managed clientless player.";
return false;
}
uint32 guid = player->GetGUID().GetCounter();
auto action = sClientlessActionExecutorMgr->GetStatus(guid);
auto travel = sClientlessTravelMgr->GetStatus(guid);
if (pendingReload || sClientlessPlayerMgr->GetQueuedActionCount(guid) ||
(action && (action->State == ClientlessActionExecutionState::SelectingTarget ||
action->State == ClientlessActionExecutionState::Moving)) ||
(travel && travel->State == ClientlessTravelState::Moving) ||
player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported())
{
detail = "Reload refused: bot has queued actions, is travelling or is aboard a transport. Wait until idle.";
return false;
}
auto previous = _profiles.find(guid);
bool hadProfile = previous != _profiles.end();
ClientlessAdventureProfile backup = hadProfile ? previous->second : ClientlessAdventureProfile();
ReloadEffects effects;
bool success;
{
struct Capture
{
explicit Capture(ReloadEffects& effects) { pendingReload = &effects; }
~Capture() { pendingReload = nullptr; }
} capture(effects);
success = LoadAdventureProfile(player) && ExecuteAdventureScript(player);
}
if (!success)
{
if (hadProfile) _profiles[guid] = std::move(backup);
else _profiles.erase(guid);
detail = "Lua reload failed. Previous profile retained; no new actions or intent applied. See server log.";
}
else
{
if (effects.IntentChanged)
{
if (effects.Intent.Empty()) sClientlessPlayerMgr->ClearIntent(guid);
else sClientlessPlayerMgr->SetIntent(guid, std::move(effects.Intent));
}
for (auto& queued : effects.Actions)
sClientlessPlayerMgr->QueueAction(guid, std::move(queued));
detail = "Profile and adventure script reloaded; queued actions=" + std::to_string(effects.Actions.size()) + ".";
}
TC_LOG_INFO("server.adventurebot.navigation", "LUA_RELOAD guid=%u success=%u root='%s' detail='%s'",
guid, success ? 1u : 0u, _scriptRoot.c_str(), detail.c_str());
return success;
}
bool ClientlessLuaEngine::ExecuteTestScript(Player* player, std::string const& scriptName)
{
if (!player || scriptName.empty() || scriptName.find("..") != std::string::npos || scriptName.find('/') != std::string::npos || scriptName.find('\\') != std::string::npos)
@@ -46,6 +46,7 @@ public:
bool ExecuteAdventureScript(Player* player);
bool ExecuteTestScript(Player* player, std::string const& scriptName);
bool ReloadAdventureScript(Player* player, std::string& detail);
bool LoadAdventureProfile(Player* player);
ClientlessAdventureProfile const* GetAdventureProfile(uint32 characterGuid) const;
@@ -1,5 +1,6 @@
#include "ClientlessPlayerMgr.h"
#include "ClientlessTravelMgr.h"
#include "ClientlessTransportMgr.h"
#include "ClientlessPerceptionMgr.h"
#include "ClientlessActionExecutorMgr.h"
#include "Log.h"
@@ -147,6 +148,7 @@ void ClientlessPlayerMgr::Update(uint32 diff)
// CLIENTLESS_PLAYER_CORE_C8_3_LOCAL_PERCEPTION
sClientlessPerceptionMgr->Update(diff);
// CLIENTLESS_PLAYER_CORE_C8_4_ACTION_EXECUTOR
sClientlessTransportMgr->Update(diff);
sClientlessActionExecutorMgr->Update(diff);
for (auto itr = _records.begin(); itr != _records.end(); )
@@ -157,6 +159,7 @@ void ClientlessPlayerMgr::Update(uint32 diff)
if (state == ClientlessPlayerState::Offline)
{
sClientlessActionExecutorMgr->Clear(characterGuid);
itr = _records.erase(itr);
continue;
}
@@ -0,0 +1,798 @@
#include "ClientlessTransportMgr.h"
#include "Chat.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessRouteRecorderMgr.h"
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessTravelMgr.h"
#include "DatabaseEnv.h"
#include "GameObject.h"
#include "GameTime.h"
#include "Opcodes.h"
#include "WorldPacket.h"
#include "Log.h"
#include "Map.h"
#include "MotionMaster.h"
#include "ObjectAccessor.h"
#include "ObjectMgr.h"
#include "Player.h"
#include "Transport.h"
#include "TransportMgr.h"
#include "World.h"
#include "WorldSession.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <sstream>
namespace
{
void PublishPassengerState(Player* player)
{
player->m_movementInfo.guid = player->GetGUID();
player->m_movementInfo.pos.Relocate(player->GetPosition());
player->m_movementInfo.time = GameTime::GetGameTimeMS();
WorldPacket update(SMSG_MOVE_UPDATE);
player->WriteMovementInfo(update);
player->SendMessageToSet(&update, true);
}
char const* PhaseName(ClientlessTransportPhase phase)
{
switch (phase)
{
case ClientlessTransportPhase::Waiting: return "Waiting";
case ClientlessTransportPhase::Boarding: return "Boarding";
case ClientlessTransportPhase::Riding: return "Riding";
case ClientlessTransportPhase::Leaving: return "Leaving";
case ClientlessTransportPhase::Arrived: return "Arrived";
case ClientlessTransportPhase::Failed: return "Failed";
}
return "Unknown";
}
bool Safe(std::string const& s) { return ClientlessRouteRecorderMgr::IsSafeKey(s); }
float Distance(Position const& a, Position const& b) { return a.GetExactDist(&b); }
uint32 Spawn(GameObject const* go) { return go->ToTransport() ? go->GetGUID().GetCounter() : go->GetSpawnId(); }
bool SameService(ClientlessTransportStop const& a, ClientlessTransportStop const& b)
{
return a.Entry == b.Entry && a.Spawn == b.Spawn;
}
Position WorldPoint(GameObject* object, ClientlessTransportPoint const& point)
{
Position result = point.Pos;
if (point.OnTransport)
{
float x, y, z, o;
result.GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerPosition(x, y, z, &o);
result.Relocate(x, y, z, o);
}
return result;
}
GameObject* Resolve(Player* player, uint32 spawn)
{
if (!player || !player->IsInWorld())
return nullptr;
GameObject* go = player->GetMap()->GetGameObjectBySpawnId(spawn);
return go ? go : player->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, spawn));
}
bool LoadNode(std::string const& key, uint32& map, Position& pos)
{
if (!Safe(key))
return false;
QueryResult r = WorldDatabase.PQuery("SELECT w.map,w.position_x,w.position_y,w.position_z,w.orientation "
"FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key "
"WHERE n.node_key='%s' AND n.enabled=1 AND w.enabled=1", key.c_str());
if (!r)
return false;
Field* f = r->Fetch();
// vcore_clientless_world_point.map is SMALLINT, not INT. Field access
// must match its width; GetUInt32 returns zero in a debug build.
map = f[0].GetUInt16();
pos.Relocate(f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat());
return true;
}
float PathLength(ClientlessTransportStop const& stop)
{
float sum = 0;
for (std::size_t i = 1; i < stop.Points.size(); ++i)
{
auto world = [&](ClientlessTransportPoint const& p)
{
Position pos = p.Pos;
if (p.OnTransport)
{
float x, y, z, o;
pos.GetPosition(x, y, z, o);
TransportBase::CalculatePassengerPosition(x, y, z, &o,
stop.Dock.GetPositionX(), stop.Dock.GetPositionY(), stop.Dock.GetPositionZ(), stop.Dock.GetOrientation());
pos.Relocate(x, y, z, o);
}
return pos;
};
sum += Distance(world(stop.Points[i - 1]), world(stop.Points[i]));
}
return sum;
}
}
ClientlessTransportMgr* ClientlessTransportMgr::instance()
{
static ClientlessTransportMgr mgr;
return &mgr;
}
bool ClientlessTransportMgr::IsTransportEdge(std::string const& type)
{
return type == "ship" || type == "zeppelin" || type == "tram" || type == "elevator";
}
bool ClientlessTransportMgr::EnsureLoaded()
{
return _loaded ? _available : Reload();
}
bool ClientlessTransportMgr::Reload()
{
_stops.clear();
_loaded = true;
_available = false;
// Keep existing CPC installations usable before the explicit migration.
if (!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_stop'") ||
!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_point'") ||
!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_clientless_transport_stand'"))
return false;
_available = true;
QueryResult r = WorldDatabase.Query("SELECT node_key,transport_entry,transport_spawn,map,dock_x,dock_y,dock_z,dock_o,period_ms,stand_name FROM vcore_clientless_transport_stop");
if (r)
do
{
Field* f = r->Fetch();
ClientlessTransportStop stop;
stop.Node = f[0].GetString(); stop.Entry = f[1].GetUInt32(); stop.Spawn = f[2].GetUInt32(); stop.Map = f[3].GetUInt32();
stop.Dock.Relocate(f[4].GetFloat(), f[5].GetFloat(), f[6].GetFloat(), f[7].GetFloat());
stop.Period = f[8].GetUInt32(); stop.Stand = f[9].GetString();
if (Safe(stop.Node) && stop.Entry && stop.Spawn && stop.Period)
_stops.emplace(stop.Node, std::move(stop));
} while (r->NextRow());
r = WorldDatabase.Query("SELECT node_key,point_index,on_transport,x,y,z,o FROM vcore_clientless_transport_point ORDER BY node_key,point_index");
if (r)
do
{
Field* f = r->Fetch();
auto itr = _stops.find(f[0].GetString());
if (itr == _stops.end())
continue;
if (f[1].GetUInt32() != itr->second.Points.size())
{
itr->second.Period = 0;
continue;
}
ClientlessTransportPoint p;
p.OnTransport = f[2].GetBool();
p.Pos.Relocate(f[3].GetFloat(), f[4].GetFloat(), f[5].GetFloat(), f[6].GetFloat());
itr->second.Points.push_back(p);
} while (r->NextRow());
for (auto itr = _stops.begin(); itr != _stops.end(); )
{
auto const& stop = itr->second;
uint32 map = 0; Position node;
bool valid = stop.Period && stop.Points.size() >= 2 && stop.Points.size() <= 512 &&
!stop.Points.front().OnTransport && stop.Points.back().OnTransport &&
LoadNode(stop.Node, map, node) && map == stop.Map && Distance(node, stop.Points.front().Pos) <= 3.0f;
bool boarded = false;
for (auto const& p : stop.Points)
{
if (!Trinity::IsValidMapCoord(p.Pos.GetPositionX(), p.Pos.GetPositionY(), p.Pos.GetPositionZ(), p.Pos.GetOrientation()) ||
(boarded && !p.OnTransport) ||
(p.OnTransport && (std::fabs(p.Pos.GetPositionX()) > 100 ||
std::fabs(p.Pos.GetPositionY()) > 100 || std::fabs(p.Pos.GetPositionZ()) > 100))) valid = false;
boarded |= p.OnTransport;
}
if (!valid || !std::isfinite(PathLength(stop)) || PathLength(stop) > 60.0f)
{
TC_LOG_ERROR("server.adventurebot.navigation", "TRANSPORT_STOP_INVALID node='%s'", stop.Node.c_str());
itr = _stops.erase(itr);
}
else ++itr;
}
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STOPS_LOADED count=%u", uint32(_stops.size()));
return true;
}
bool ClientlessTransportMgr::HasConnection(std::string const& from, std::string const& to)
{
EnsureLoaded();
auto a = _stops.find(from), b = _stops.find(to);
return a != _stops.end() && b != _stops.end() && SameService(a->second, b->second) &&
(a->second.Map != b->second.Map || Distance(a->second.Dock, b->second.Dock) > 3.0f) &&
Distance(a->second.Points.back().Pos, b->second.Points.back().Pos) <= 3.0f;
}
GameObject* ClientlessTransportMgr::FindObject(Player* player, ClientlessTransportStop const& stop) const
{
if (!player || !player->IsInWorld()) return nullptr;
GameObjectTemplate const* info = sObjectMgr->GetGameObjectTemplate(stop.Entry);
GameObject* object = info && info->type == GAMEOBJECT_TYPE_MO_TRANSPORT
? player->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, stop.Spawn))
: player->GetMap()->GetGameObjectBySpawnId(stop.Spawn);
return object && object->GetEntry() == stop.Entry && object->IsInWorld() &&
object->ToTransportBase() && object->isSpawned() && player->IsInPhase(object) ? object : nullptr;
}
bool ClientlessTransportMgr::AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const
{
TransportDockState state;
return object && player->GetMapId() == stop.Map && object->GetMapId() == stop.Map &&
Distance(*object, stop.Dock) <= 1.0f &&
std::fabs(std::remainder(object->GetOrientation() - stop.Dock.GetOrientation(), float(2 * M_PI))) < 0.10f &&
object->GetTransportDockState(state) && state.Stopped && state.RemainingPause >= requiredPause;
}
bool ClientlessTransportMgr::Sample(Player* player, Recording& recording, bool force)
{
if (!recording.Error.empty())
return false;
auto fail = [&](char const* message) { recording.Error = message; return false; };
if (!player->IsAlive() || player->IsFlying() || player->IsInFlight())
return fail("Record with a living character on foot, not flying or on a taxi.");
GameObject* object = FindObject(player, recording.Stop);
if (!object || player->GetMapId() != recording.Stop.Map || object->GetGUID() != recording.Object ||
!AtDock(player, object, recording.Stop, 0))
return fail("Transport left its recorded dock; cancel and record during a complete stop.");
bool aboard = player->GetTransport() == object->ToTransportBase();
if ((player->GetTransport() && !aboard) || (recording.Attached && !aboard))
return fail("Recorder changed or left the transport. Record land -> deck only.");
ClientlessTransportPoint point;
point.OnTransport = aboard;
point.Pos.Relocate(player->GetPosition());
if (aboard)
{
float x, y, z, o;
point.Pos.GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100)
return fail("Deck point is outside the supported transport bounds.");
point.Pos.Relocate(x, y, z, o);
}
if (!recording.Stop.Points.empty())
{
auto const& previous = recording.Stop.Points.back();
float delta = Distance(WorldPoint(object, previous), WorldPoint(object, point));
if (delta > 8.0f)
return fail("Position jump over 8 yards; do not teleport or fly while recording.");
if (!force && previous.OnTransport == point.OnTransport && delta < 0.75f)
return true;
}
if (recording.Stop.Points.size() >= 512)
return fail("Recording exceeds 512 points.");
recording.Stop.Points.push_back(point);
recording.Attached |= aboard;
if (PathLength(recording.Stop) > 60.0f)
return fail("Dock transition exceeds 60 yards; move the shared node closer to the ramp.");
return true;
}
bool ClientlessTransportMgr::Save(Player* player, std::string const& stand, std::string& error)
{
auto itr = _recordings.find(player->GetGUID().GetCounter());
if (itr == _recordings.end()) { error = "No active recording."; return false; }
Recording& recording = itr->second;
if (!Safe(stand) || !Sample(player, recording, true) || !recording.Attached || recording.Stop.Points.size() < 2 || player->IsFalling())
{
error = recording.Error.empty() ? "Finish aboard the recorded transport and specify a valid stand name." : recording.Error;
return false;
}
auto& stop = recording.Stop;
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stop WHERE node_key='%s'", stop.Node.c_str()))
{ error = "This dock has already been saved; explicitly forget it before replacing a recording."; return false; }
uint32 map = 0; Position node;
if (!LoadNode(stop.Node, map, node) || map != stop.Map || Distance(node, stop.Points.front().Pos) > 0.25f)
{ error = "Shared node changed during recording; cancel and start again."; return false; }
auto const& pos = stop.Points.back().Pos;
QueryResult existing = WorldDatabase.PQuery("SELECT x,y,z FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", stop.Entry, stand.c_str());
if (existing)
{
Field* f = existing->Fetch(); Position target;
target.Relocate(f[0].GetFloat(), f[1].GetFloat(), f[2].GetFloat());
if (Distance(pos, target) > 1.5f) { error = "Stand name already exists at another deck point; stand there or use another name."; return false; }
}
WorldDatabaseTransaction trans = WorldDatabase.BeginTransaction();
trans->PAppend("DELETE FROM vcore_clientless_transport_point WHERE node_key='%s'", stop.Node.c_str());
trans->PAppend("INSERT INTO vcore_clientless_transport_stop VALUES ('%s',%u,%u,%u,%.9f,%.9f,%.9f,%.9f,%u,'%s')",
stop.Node.c_str(), stop.Entry, stop.Spawn, stop.Map, stop.Dock.GetPositionX(), stop.Dock.GetPositionY(), stop.Dock.GetPositionZ(), stop.Dock.GetOrientation(), stop.Period, stand.c_str());
uint32 index = 0;
for (auto const& p : stop.Points)
trans->PAppend("INSERT INTO vcore_clientless_transport_point VALUES ('%s',%u,%u,%.9f,%.9f,%.9f,%.9f)",
stop.Node.c_str(), index++, p.OnTransport ? 1u : 0u, p.Pos.GetPositionX(), p.Pos.GetPositionY(), p.Pos.GetPositionZ(), p.Pos.GetOrientation());
trans->PAppend("INSERT IGNORE INTO vcore_clientless_transport_stand VALUES (%u,'%s',%.9f,%.9f,%.9f,%.9f)", stop.Entry, stand.c_str(),
pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), pos.GetOrientation());
WorldDatabase.DirectCommitTransaction(trans);
std::string nodeKey = stop.Node;
std::size_t pointCount = stop.Points.size();
if (!Reload() || !_stops.count(nodeKey) || _stops.at(nodeKey).Points.size() != pointCount)
{ error = "Recorded stop could not be reloaded; inspect database errors. Unsaved recording retained."; return false; }
_recordings.erase(itr);
return true;
}
bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std::string const& to, std::string& error)
{
if (!player || !player->IsInWorld() || player->GetTransport() || !HasConnection(from, to))
{ error = "Transport stops are not recorded/compatible, or player is already aboard."; return false; }
auto const& source = _stops.at(from);
if (source.Map != player->GetMapId() || Distance(*player, source.Points.front().Pos) > 3.0f)
{ error = "Player has not reached the shared departure node."; return false; }
Ride ride;
ride.From = source; ride.To = _stops.at(to);
ride.RequiredPause = uint32(PathLength(source) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000.0f) + uint32(source.Points.size()) * 100 + 1500;
ride.Status.Phase = ClientlessTransportPhase::Waiting;
ride.Status.Detail = "waiting for recorded transport at departure dock";
sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());
player->StopMoving();
_rides[player->GetGUID().GetCounter()] = std::move(ride);
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_WAIT guid=%u from='%s' to='%s' spawn=%u boardingMs=%u",
player->GetGUID().GetCounter(), from.c_str(), to.c_str(), source.Spawn, _rides.at(player->GetGUID().GetCounter()).RequiredPause);
return true;
}
ClientlessTransportStatus const* ClientlessTransportMgr::GetStatus(uint32 guid) const
{
auto itr = _rides.find(guid);
return itr == _rides.end() ? nullptr : &itr->second.Status;
}
void ClientlessTransportMgr::SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail)
{
ride.Status = { phase, detail, ride.Abort }; ride.PhaseElapsed = 0; ride.Moving = false;
TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STATE guid=%u phase='%s' from='%s' to='%s' passenger=%u detail='%s'",
player->GetGUID().GetCounter(), PhaseName(phase), ride.From.Node.c_str(), ride.To.Node.c_str(), player->GetTransport() ? 1u : 0u, detail);
}
void ClientlessTransportMgr::Cancel(uint32 guid)
{
auto itr = _rides.find(guid);
if (itr != _rides.end()) itr->second.Abort = true;
}
void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff)
{
using Phase = ClientlessTransportPhase;
if (ride.Status.Phase == Phase::Arrived || ride.Status.Phase == Phase::Failed) return;
ride.Elapsed += diff; ride.PhaseElapsed += diff;
if (player->IsBeingTeleported())
{
player->GetSession()->CompleteClientlessTeleport();
return;
}
GameObject* object = FindObject(player, ride.From);
uint32 limit = uint32(std::min<uint64>(1800000, std::max<uint64>(180000, uint64(ride.From.Period) * 3 + 30000)));
auto fail = [&](char const* detail)
{
player->StopMoving();
SetPhase(player, ride, Phase::Failed, detail);
// Never detach an actual passenger in midair or in the sea.
};
if (ride.Elapsed > limit) { fail("transport timeout; any passenger attachment retained"); return; }
if (ride.Status.Phase == Phase::Waiting)
{
if (ride.Abort) { fail("transport cancelled on land"); return; }
if (!AtDock(player, object, ride.From, ride.RequiredPause)) return;
if (Distance(*player, ride.From.Points.front().Pos) > 3.0f)
{ fail("player moved away from departure node while waiting"); return; }
ride.Point = 0;
SetPhase(player, ride, Phase::Boarding, "boarding recorded land-to-deck transition");
}
if (ride.Status.Phase == Phase::Boarding && ride.Abort)
{
player->StopMoving();
if (!player->GetTransport()) { fail("transport cancelled on land"); return; }
SetPhase(player, ride, Phase::Riding, "boarding cancelled; waiting for safe dock");
}
if (ride.Status.Phase == Phase::Riding)
{
if (!object || player->GetTransport() != object->ToTransportBase())
{ fail("transport passenger identity lost"); return; }
ClientlessTransportStop const* arrival = nullptr;
if (AtDock(player, object, ride.To, uint32(PathLength(ride.To) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000) + uint32(ride.To.Points.size()) * 100 + 1500)) arrival = &ride.To;
else if (ride.Abort && AtDock(player, object, ride.From, ride.RequiredPause)) arrival = &ride.From;
if (!arrival) return;
if (arrival == &ride.From) ride.To = ride.From;
ride.Point = ride.To.Points.size();
SetPhase(player, ride, Phase::Leaving, "leaving via reversed recorded transition");
}
if (ride.Status.Phase != Phase::Boarding && ride.Status.Phase != Phase::Leaving) return;
bool boarding = ride.Status.Phase == Phase::Boarding;
auto const& stop = boarding ? ride.From : ride.To;
if (!object || !AtDock(player, object, stop, 0))
{
if (player->GetTransport())
{
player->StopMoving(); ride.Abort = true;
SetPhase(player, ride, Phase::Riding, "departure during transition; retaining passenger until safe dock");
}
else fail("transport departed during land transition");
return;
}
if (ride.Moving)
{
if (player->IsSplineEnabled())
{
if (ride.PhaseElapsed > ride.PointTimeout) fail("recorded transition spline timeout");
return;
}
auto const& previous = stop.Points[boarding ? ride.Point - 1 : ride.Point];
if (Distance(*player, WorldPoint(object, previous)) > 1.5f)
{ fail("recorded transition stopped before target point"); return; }
if (!previous.OnTransport && player->GetTransport() == object->ToTransportBase())
{
object->ToTransportBase()->RemovePassenger(player);
PublishPassengerState(player);
}
ride.Moving = false;
}
if ((boarding && ride.Point >= stop.Points.size()) || (!boarding && ride.Point == 0))
{
if (boarding)
{
if (player->GetTransport() != object->ToTransportBase()) { fail("boarding did not attach passenger"); return; }
SetPhase(player, ride, Phase::Riding, "aboard; waiting for destination dock");
}
else
{
if (player->GetTransport() || Distance(*player, stop.Points.front().Pos) > 2.0f)
{ fail("shared arrival node not reached on land"); return; }
SetPhase(player, ride, ride.Abort ? Phase::Failed : Phase::Arrived, ride.Abort ? "cancelled at safe dock" : "arrived at shared destination node");
}
return;
}
auto const& point = stop.Points[boarding ? ride.Point : ride.Point - 1];
Position target = WorldPoint(object, point);
if (Distance(*player, target) > 8.0f) { fail("recorded transition has an unsafe gap over 8 yards"); return; }
bool attached = player->GetTransport() == object->ToTransportBase();
if (point.OnTransport && !attached)
{
if (player->GetTransport()) { fail("another passenger owner already attached"); return; }
float x, y, z, o;
player->GetPosition(x, y, z, o);
object->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100)
{ fail("passenger attachment outside supported local bounds"); return; }
player->m_movementInfo.transport.Reset();
player->m_movementInfo.transport.pos.Relocate(x, y, z, o);
TransportDockState state; object->GetTransportDockState(state);
player->m_movementInfo.transport.time = state.Progress;
object->ToTransportBase()->AddPassenger(player);
if (player->GetTransport() != object->ToTransportBase())
{ fail("native passenger attachment failed"); return; }
PublishPassengerState(player);
}
// Keep ownership until the first land point is physically reached. If
// departure interrupts disembarkation, the bot remains a real passenger.
if (!point.OnTransport && attached && Distance(*player, target) <= 0.1f)
{
object->ToTransportBase()->RemovePassenger(player);
PublishPassengerState(player);
}
Position path[2]; path[0].Relocate(player->GetPosition()); path[1] = target;
if (Distance(path[0], path[1]) > 0.1f)
player->GetMotionMaster()->MoveSmoothPath(1, path, 2, false);
ride.Moving = Distance(path[0], path[1]) > 0.1f; ride.PhaseElapsed = 0;
ride.PointTimeout = 10000;
if (boarding) ++ride.Point; else --ride.Point;
}
void ClientlessTransportMgr::Update(uint32 diff)
{
for (auto itr = _recordings.begin(); itr != _recordings.end(); )
{
Player* player = ObjectAccessor::FindConnectedPlayer(ObjectGuid(HighGuid::Player, itr->first));
if (!player || !player->IsInWorld()) { itr = _recordings.erase(itr); continue; }
Recording& recording = itr->second;
recording.Timer += diff;
if (recording.Timer >= 100)
{
recording.Timer = 0;
if (recording.Error.empty() && !Sample(player, recording, false) && !recording.Error.empty())
{
TC_LOG_WARN("server.adventurebot.navigation", "TRANSPORT_RECORD_FAILED recorder=%u detail='%s'", itr->first, recording.Error.c_str());
// Keep the buffer and diagnostic; no further automatic samples.
recording.Timer = 0;
}
}
++itr;
}
for (auto itr = _rides.begin(); itr != _rides.end(); )
{
WorldSession* session = sWorld->FindClientlessSession(itr->first);
if (!session || sWorld->GetClientlessPlayerState(itr->first) != ClientlessPlayerState::Online)
{ itr = _rides.erase(itr); continue; }
Player* player = session->GetPlayer();
if (player) UpdateRide(player, itr->second, diff);
++itr;
}
}
bool ClientlessTransportMgr::Command(ChatHandler* handler, char const* args)
{
std::istringstream input(args ? args : "");
std::string command; input >> command;
Player* gm = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr;
if (command == "reload") Reload();
if (!EnsureLoaded())
{
handler->SendSysMessage("Transport schema unavailable. Apply sql/custom/world/2026_10_25_00_clientless_transport.sql to the test world DB.");
return true;
}
auto say = [&](std::string const& text) { handler->SendSysMessage(text.c_str()); };
if (command == "services")
{
uint32 requested = 0;
input >> std::ws;
if (input.peek() != std::char_traits<char>::eof())
{
std::string extra;
if (!(input >> requested) || !requested || (input >> extra))
{ say("Usage: .clientless transport services [spawnId]"); return true; }
}
QueryResult rows = requested
? WorldDatabase.PQuery("SELECT guid,entry FROM transports WHERE guid=%u", requested)
: WorldDatabase.Query("SELECT guid,entry FROM transports ORDER BY guid");
if (!rows) { say("No configured native transport service found."); return true; }
do
{
Field* fields = rows->Fetch();
uint32 spawn = fields[0].GetUInt32(), entry = fields[1].GetUInt32();
auto info = sObjectMgr->GetGameObjectTemplate(entry);
auto route = sTransportMgr->GetTransportTemplate(entry);
if (!info || !route) continue;
uint32 stops = 0;
for (auto const& leg : route->PathLegs)
for (auto const& segment : leg.Segments)
if (segment.Delay) ++stops;
handler->PSendSysMessage("Service spawn=%u entry=%u name='%s' periodMs=%u stops=%u legs=%u instanced=%u",
spawn, entry, info->name.c_str(), route->TotalPathTime, stops, uint32(route->PathLegs.size()), route->InInstance ? 1u : 0u);
if (!requested) continue;
uint32 dock = 0;
for (auto const& leg : route->PathLegs)
for (auto const& segment : leg.Segments)
{
if (!segment.Delay) continue;
TransportMovementState state;
size_t index;
auto position = route->ComputePosition(segment.SegmentEndArrivalTimestamp, &state, &index);
if (!position || state != TransportMovementState::WaitingOnPauseWaypoint || index >= route->PathLegs.size())
continue;
handler->PSendSysMessage(" Dock %u: map=%u pose=(%.3f %.3f %.3f %.3f) arrivalMs=%u pauseMs=%u",
++dock, route->PathLegs[index].MapId, position->GetPositionX(), position->GetPositionY(),
position->GetPositionZ(), position->GetOrientation(), segment.SegmentEndArrivalTimestamp, segment.Delay);
}
if (!dock) say(" No scheduled boarding pauses. Do not create a regular passenger edge without a verified stop.");
} while (rows->NextRow());
return true;
}
if (command == "reload")
{
Reload(); sClientlessTravelGraphMgr->Reload();
handler->PSendSysMessage("Transport data reloaded: %u stops. Active rides retain their captured definitions.", uint32(_stops.size()));
return true;
}
if (command == "node")
{
std::string node; input >> node;
uint32 map = 0; Position position;
if (!LoadNode(node, map, position))
say("Transport node lookup failed: specify an existing enabled node.");
else handler->PSendSysMessage("Transport node '%s': map=%u position=(%.3f %.3f %.3f %.3f)",
node.c_str(), map, position.GetPositionX(), position.GetPositionY(), position.GetPositionZ(), position.GetOrientation());
return true;
}
if (command == "status" || command == "cancel" || command == "travel")
{
uint32 guid = 0; input >> guid;
if (!guid) { say("Usage: .clientless transport status|cancel <guid> OR travel <guid> <destinationNode>"); return true; }
if (command == "cancel")
{
auto const* status = GetStatus(guid);
if (!status || status->Phase == ClientlessTransportPhase::Arrived || status->Phase == ClientlessTransportPhase::Failed)
say("No active transport ride to cancel.");
else { Cancel(guid); say("Cancellation requested; a passenger remains attached until a safe recorded stop."); }
return true;
}
if (command == "travel")
{
std::string node; input >> node;
if (!Safe(node)) { say("Specify a valid destination node."); return true; }
ClientlessAction action; action.Name = "travel_to_node";
action.Parameters["node"] = node; action.Parameters["hard_destination"] = "1";
say(sClientlessPlayerMgr->QueueAction(guid, std::move(action)) ? "Travel action queued through the regular graph executor." : "Character is not CPC managed.");
return true;
}
auto itr = _rides.find(guid);
if (itr == _rides.end()) say("No transport ride state for this character.");
else handler->PSendSysMessage("Transport GUID %u: phase=%s from='%s' to='%s' spawn=%u point=%u elapsedMs=%u abort=%u detail='%s'",
guid, PhaseName(itr->second.Status.Phase), itr->second.From.Node.c_str(), itr->second.To.Node.c_str(), itr->second.From.Spawn,
uint32(itr->second.Point), itr->second.Elapsed, itr->second.Abort ? 1u : 0u, itr->second.Status.Detail.c_str());
return true;
}
if (command == "stops")
{
for (auto const& pair : _stops)
{
auto const& s = pair.second;
handler->PSendSysMessage("Stop '%s': entry=%u spawn=%u map=%u points=%u length=%.2f stand='%s' period=%ums dock=(%.3f %.3f %.3f)",
s.Node.c_str(), s.Entry, s.Spawn, s.Map, uint32(s.Points.size()), PathLength(s), s.Stand.c_str(), s.Period,
s.Dock.GetPositionX(), s.Dock.GetPositionY(), s.Dock.GetPositionZ());
}
handler->PSendSysMessage("Recorded transport stops: %u", uint32(_stops.size()));
return true;
}
if (command == "forget")
{
std::string node; input >> node;
if (!Safe(node)) { say("Usage: .clientless transport forget <node> (removes only the recorded dock transition)"); return true; }
WorldDatabaseTransaction trans = WorldDatabase.BeginTransaction();
trans->PAppend("DELETE FROM vcore_clientless_transport_point WHERE node_key='%s'", node.c_str());
trans->PAppend("DELETE FROM vcore_clientless_transport_stop WHERE node_key='%s'", node.c_str());
WorldDatabase.DirectCommitTransaction(trans); Reload(); sClientlessTravelGraphMgr->Reload();
say("Dock recording removed. Shared node, ground routes and deck stands retained; incomplete transport connections are not planned.");
return true;
}
if (command == "link")
{
std::string from, to, type, both; float cost = 0;
input >> from >> to >> type >> cost >> both;
if (!Safe(from) || !Safe(to) || !IsTransportEdge(type) || !std::isfinite(cost) || cost <= 0 || cost > 100000000 ||
(!both.empty() && both != "both") || !HasConnection(from, to))
{ say("Usage: .clientless transport link <fromNode> <toNode> <ship|zeppelin|tram|elevator> <graphCost> [both]. Both stops must use the same spawn and deck stand."); return true; }
auto const& a = _stops.at(from);
GameObjectTemplate const* info = sObjectMgr->GetGameObjectTemplate(a.Entry);
if (!info || ((type == "tram" || type == "elevator") ? info->type != GAMEOBJECT_TYPE_TRANSPORT : info->type != GAMEOBJECT_TYPE_MO_TRANSPORT))
{ say("Edge type does not match the recorded GameObject transport implementation."); return true; }
if (WorldDatabase.PQuery("SELECT id FROM vcore_clientless_travel_edge WHERE from_node_key='%s' AND to_node_key='%s' AND edge_type='%s'", from.c_str(), to.c_str(), type.c_str()))
{ say("This directed transport edge already exists; it was not overwritten."); return true; }
WorldDatabase.DirectPExecute("INSERT INTO vcore_clientless_travel_edge (from_node_key,to_node_key,edge_type,cost,bidirectional,enabled,comment) "
"VALUES ('%s','%s','%s',%.6f,%u,1,'CPC recorded native transport')", from.c_str(), to.c_str(), type.c_str(), cost, both == "both" ? 1u : 0u);
sClientlessTravelGraphMgr->Reload(); say("Transport edge created. The shared stop nodes are used in both directions.");
return true;
}
if (command == "stand")
{
std::string operation, name; input >> operation;
if (operation == "list")
{
uint32 entry = 0; input >> entry;
if (!entry && gm && gm->GetTransport())
if (GameObject* go = gm->GetMap()->GetGameObject(gm->GetTransport()->GetTransportGUID())) entry = go->GetEntry();
if (!entry) { say("Usage: .clientless transport stand list <transportEntry>"); return true; }
QueryResult r = WorldDatabase.PQuery("SELECT name,x,y,z,o FROM vcore_clientless_transport_stand WHERE transport_entry=%u ORDER BY name", entry);
if (r) do { Field* f = r->Fetch(); handler->PSendSysMessage("Stand '%s' entry=%u local=(%.3f %.3f %.3f %.3f)", f[0].GetString().c_str(), entry, f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat()); } while (r->NextRow());
else say("No stands recorded for this entry.");
return true;
}
if (operation == "remove")
{
uint32 entry = 0; input >> entry >> name;
if (!entry || !Safe(name)) { say("Usage: .clientless transport stand remove <entry> <name>"); return true; }
if (WorldDatabase.PQuery("SELECT node_key FROM vcore_clientless_transport_stop WHERE transport_entry=%u AND stand_name='%s' LIMIT 1", entry, name.c_str()))
{ say("Stand is used by a stop. Forget/re-record those transitions before removing it."); return true; }
WorldDatabase.DirectPExecute("DELETE FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", entry, name.c_str());
say("Unused deck stand removed."); return true;
}
input >> name;
if (operation != "add" || !Safe(name) || !gm || !gm->GetTransport() || gm->IsFlying() || gm->IsFalling())
{ say("Stand aboard the intended transport, then use .clientless transport stand add <name>."); return true; }
GameObject* go = gm->GetMap()->GetGameObject(gm->GetTransport()->GetTransportGUID());
if (!go || !go->ToTransportBase()) { say("Transport GameObject not found."); return true; }
float x, y, z, o; gm->GetPosition(x, y, z, o); go->ToTransportBase()->CalculatePassengerOffset(x, y, z, &o);
if (std::fabs(x) > 100 || std::fabs(y) > 100 || std::fabs(z) > 100) { say("Stand outside supported deck bounds."); return true; }
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stand WHERE transport_entry=%u AND name='%s'", go->GetEntry(), name.c_str()))
{ say("Stand name already recorded; use a new name to preserve the existing point."); return true; }
WorldDatabase.DirectPExecute("INSERT INTO vcore_clientless_transport_stand VALUES (%u,'%s',%.9f,%.9f,%.9f,%.9f)", go->GetEntry(), name.c_str(), x, y, z, o);
handler->PSendSysMessage("Stand '%s' saved for entry=%u at local (%.3f %.3f %.3f %.3f).", name.c_str(), go->GetEntry(), x, y, z, o);
return true;
}
if (command == "inspect" || command == "near")
{
uint32 spawn = 0, guid = 0;
float radius = 50;
if (command == "inspect") input >> spawn >> guid;
else input >> radius >> guid;
Player* observer = gm;
if (!observer && guid)
if (WorldSession* session = sWorld->FindClientlessSession(guid)) observer = session->GetPlayer();
if (!observer || !observer->IsInWorld()) { say("In console use: transport inspect <spawnId> <botGuid> OR transport near <radius> <botGuid>."); return true; }
auto print = [&](GameObject* go)
{
if (!go || !go->IsTransport() || !go->IsInWorld() || !observer->IsInPhase(go)) return;
TransportDockState state; go->GetTransportDockState(state);
handler->PSendSysMessage("Transport '%s': entry=%u spawn=%u guid='%s' map=%u pos=(%.3f %.3f %.3f %.3f) distance=%.2f stopped=%u remainingPause=%u period=%u progress=%u",
go->GetName().c_str(), go->GetEntry(), Spawn(go), go->GetGUID().ToString().c_str(), go->GetMapId(), go->GetPositionX(), go->GetPositionY(), go->GetPositionZ(), go->GetOrientation(),
observer->GetExactDist(go), state.Stopped ? 1u : 0u, state.RemainingPause, state.Period, state.Progress);
};
if (command == "inspect")
{
GameObject* go = Resolve(observer, spawn);
if (go) print(go); else say("Spawn is not loaded on the observer's current map.");
}
else
{
radius = std::isfinite(radius) ? std::max(1.0f, std::min(200.0f, radius)) : 50;
for (auto const& pair : observer->GetMap()->GetGameObjectBySpawnIdStore())
if (observer->GetExactDist(pair.second) <= radius) print(pair.second);
QueryResult r = WorldDatabase.Query("SELECT guid FROM transports");
if (r) do { GameObject* go = observer->GetMap()->GetTransport(ObjectGuid(HighGuid::Mo_Transport, r->Fetch()[0].GetUInt32())); if (go && observer->GetExactDist(go) <= radius) print(go); } while (r->NextRow());
}
return true;
}
if (command == "record")
{
if (!gm || !gm->IsInWorld()) { say("Dock recording needs an in-game GM player."); return true; }
uint32 guid = gm->GetGUID().GetCounter();
std::string op; input >> op;
if (op == "cancel") { _recordings.erase(guid); say("Your unsaved recording was cancelled."); return true; }
if (op == "info")
{
auto itr = _recordings.find(guid);
if (itr == _recordings.end()) say("No active recording.");
else handler->PSendSysMessage("Recording node='%s' spawn=%u points=%u length=%.2f aboard=%u error='%s'", itr->second.Stop.Node.c_str(), itr->second.Stop.Spawn,
uint32(itr->second.Stop.Points.size()), PathLength(itr->second.Stop), itr->second.Attached ? 1u : 0u, itr->second.Error.c_str());
return true;
}
if (op == "start")
{
std::string node; uint32 spawn = 0; input >> node >> spawn;
if (_recordings.count(guid)) { say("Finish or cancel your existing recording first."); return true; }
Position position; uint32 map = 0;
if (!Safe(node) || !spawn)
{ say("Usage: .clientless transport record start <sharedNode> <spawnId>"); return true; }
if (!LoadNode(node, map, position))
{ handler->PSendSysMessage("Recording not started: node '%s' does not exist or is disabled.", node.c_str()); return true; }
if (map != gm->GetMapId())
{ handler->PSendSysMessage("Recording not started: node map=%u, recorder map=%u.", map, gm->GetMapId()); return true; }
float const nodeDistance = Distance(*gm, position);
if (nodeDistance > 2.0f)
{ handler->PSendSysMessage("Recording not started: distance to node '%s' is %.3f yd (maximum 2.000); node=(%.3f %.3f %.3f), recorder=(%.3f %.3f %.3f).",
node.c_str(), nodeDistance, position.GetPositionX(), position.GetPositionY(), position.GetPositionZ(), gm->GetPositionX(), gm->GetPositionY(), gm->GetPositionZ()); return true; }
if (gm->GetTransport())
{ handler->PSendSysMessage("Recording not started: recorder is already a passenger of '%s'. Start on the platform, before boarding.",
gm->GetTransport()->GetTransportGUID().ToString().c_str()); return true; }
GameObject* go = Resolve(gm, spawn);
if (!go)
{ handler->PSendSysMessage("Recording not started: spawn %u is not loaded on map %u.", spawn, gm->GetMapId()); return true; }
if (!go->ToTransportBase())
{ handler->PSendSysMessage("Recording not started: spawn %u entry %u is not a supported transport.", spawn, go->GetEntry()); return true; }
if (!gm->IsInPhase(go))
{ say("Recording not started: recorder and transport are in different phases."); return true; }
float const transportDistance = gm->GetExactDist(go);
if (transportDistance > 100.0f)
{ handler->PSendSysMessage("Recording not started: spawn %u is %.3f yd away (maximum 100.000); transport=(%.3f %.3f %.3f).",
spawn, transportDistance, go->GetPositionX(), go->GetPositionY(), go->GetPositionZ()); return true; }
TransportDockState state;
if (!go->GetTransportDockState(state))
{ handler->PSendSysMessage("Recording not started: no server dock state available for spawn %u entry %u.", spawn, go->GetEntry()); return true; }
if (!state.Stopped)
{ handler->PSendSysMessage("Recording not started: spawn %u is moving according to its animation; progress=%u/%u ms, remainingPause=%u ms, nodeDistance=%.3f yd, transportDistance=%.3f yd.",
spawn, state.Progress, state.Period, state.RemainingPause, nodeDistance, transportDistance); return true; }
if (WorldDatabase.PQuery("SELECT 1 FROM vcore_clientless_transport_stop WHERE node_key='%s'", node.c_str())) { say("Dock already recorded; use transport forget <node> explicitly before replacing it."); return true; }
Recording recording;
recording.Stop.Node = node; recording.Stop.Entry = go->GetEntry(); recording.Stop.Spawn = Spawn(go);
recording.Stop.Map = map; recording.Stop.Period = state.Period; recording.Stop.Dock.Relocate(go->GetPosition());
recording.Object = go->GetGUID(); recording.NodePosition = position;
recording.Stop.Points.push_back({ false, position });
_recordings[guid] = recording;
say("Recording started. Walk normally onto this transport and to a deck stand during this stop. Then: transport record stop <standName>.");
return true;
}
auto itr = _recordings.find(guid);
if (itr == _recordings.end()) { say("No active recording."); return true; }
if (op == "mark") { bool ok = Sample(gm, itr->second, true); say(ok ? "Transition point marked." : itr->second.Error); return true; }
if (op == "stop")
{
std::string stand, error; input >> stand;
if (Save(gm, stand, error)) { sClientlessTravelGraphMgr->Reload(); say("Dock transition and local deck stand saved. Record the other stop on the same physical transport, ending at the same stand."); }
else say(error);
return true;
}
}
say("Transport tools: services [spawn], node <key>, near [radius], inspect <spawn> [botGuid], stops, stand add|list, record start|mark|info|stop|cancel, link <from> <to> <type> <cost> [both], forget <node>, reload, status|cancel <guid>, travel <guid> <node>.");
return true;
}
@@ -0,0 +1,97 @@
/* Vcore: recorded dock transitions and native clientless transport travel. */
#ifndef VCORE_CLIENTLESS_TRANSPORT_MGR_H
#define VCORE_CLIENTLESS_TRANSPORT_MGR_H
#include "Common.h"
#include "ObjectGuid.h"
#include "Position.h"
#include <string>
#include <unordered_map>
#include <vector>
class Player;
class GameObject;
class ChatHandler;
struct ClientlessTransportPoint
{
bool OnTransport = false;
Position Pos;
};
struct ClientlessTransportStop
{
std::string Node;
uint32 Entry = 0;
uint32 Spawn = 0;
uint32 Map = 0;
uint32 Period = 0;
Position Dock;
std::string Stand;
std::vector<ClientlessTransportPoint> Points;
};
enum class ClientlessTransportPhase : uint8
{
Waiting, Boarding, Riding, Leaving, Arrived, Failed
};
struct ClientlessTransportStatus
{
ClientlessTransportPhase Phase = ClientlessTransportPhase::Failed;
std::string Detail;
bool Cancelled = false;
};
class TC_GAME_API ClientlessTransportMgr
{
public:
static ClientlessTransportMgr* instance();
bool Reload();
bool HasConnection(std::string const& from, std::string const& to);
bool Start(Player* player, std::string const& from, std::string const& to, std::string& error);
ClientlessTransportStatus const* GetStatus(uint32 guid) const;
void Cancel(uint32 guid);
void Update(uint32 diff);
bool Command(ChatHandler* handler, char const* args);
static bool IsTransportEdge(std::string const& type);
private:
struct Recording
{
ClientlessTransportStop Stop;
ObjectGuid Object;
Position NodePosition;
bool Attached = false;
std::string Error;
uint32 Timer = 0;
};
struct Ride
{
ClientlessTransportStatus Status;
ClientlessTransportStop From, To;
std::size_t Point = 0;
uint32 Elapsed = 0;
uint32 PhaseElapsed = 0;
uint32 RequiredPause = 0;
uint32 PointTimeout = 0;
bool Moving = false;
bool Abort = false;
};
bool EnsureLoaded();
bool Sample(Player* player, Recording& recording, bool force);
bool Save(Player* player, std::string const& stand, std::string& error);
void UpdateRide(Player* player, Ride& ride, uint32 diff);
void SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail);
GameObject* FindObject(Player* player, ClientlessTransportStop const& stop) const;
bool AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const;
bool _loaded = false;
bool _available = false;
std::unordered_map<std::string, ClientlessTransportStop> _stops;
std::unordered_map<uint32, Recording> _recordings;
std::unordered_map<uint32, Ride> _rides;
};
#define sClientlessTransportMgr ClientlessTransportMgr::instance()
#endif
@@ -1,4 +1,5 @@
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessTransportMgr.h"
#include "DatabaseEnv.h"
#include "Log.h"
@@ -100,6 +101,13 @@ bool ClientlessTravelGraphMgr::Reload()
if (edge.Cost <= 0.0f)
edge.Cost = 1.0f;
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;
@@ -428,11 +436,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
{
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");
return state.Node + (state.LastWasBridge ? "\x1f" "B" : "\x1f" "R") + state.EntryKey;
};
struct QueueItem
@@ -473,6 +483,32 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
};
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();
};
uint32 startCount = 0;
for (auto const& pair : _nodes)
{
@@ -628,14 +664,17 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
continue;
}
State state{ candidate.Edge.ToNode, false };
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;
float remainingFraction = candidate.Edge.Reverse
? float(candidate.Index) / float(candidate.Count - 1)
: float(candidate.Count - candidate.Index - 1) / float(candidate.Count - 1);
float newCost = (alreadyThere ? 0.0f : nav.PathLength) + candidate.Edge.Cost * remainingFraction;
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;
@@ -690,10 +729,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
player->GetInstanceId(), a, b, NAV_GROUND);
};
// VCORE_C820_VIRTUAL_EXIT_NODES: preflight a bounded, geographical
// shortlist of directed recorded-road exits. Candidate generation does
// not mutate nodes, edges or stored path points. Costs use travelled
// arclength, not a flat per-bridge penalty or waypoint index fraction.
// 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;
@@ -705,12 +743,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
float DistanceSq = 0.0f;
ClientlessTravelEdgePoint Point;
};
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;
uint32 exitTests = 0, exitAccepted = 0;
if (locationGoal)
{
float const radius = std::min(300.0f, locationGoal->AnchorRadius);
float const radius = locationAccessRadius;
std::unordered_set<std::string> scannedDirected;
for (auto const& edgePair : _edges)
{
@@ -740,6 +779,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
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;
@@ -753,7 +794,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
dy = point.Y - locationGoal->Y;
dz = point.Z - locationGoal->Z;
float const dsq = dx * dx + dy * dy + dz * dz;
if (dsq > radius * radius)
if (dsq > radius * radius || dsq >= nearest.DistanceSq)
continue;
std::string const exitKey = "exit:" + std::to_string(edge.DatabaseId) +
":" + std::to_string(i) + (edge.Reverse ? "R" : "F");
@@ -767,74 +808,30 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
candidate.RoadCost = edge.Cost * (travelled / totalLength);
candidate.DistanceSq = dsq;
candidate.Point = point;
rawExitCandidates.push_back(std::move(candidate));
candidate.LocalCost = std::sqrt(dsq);
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; });
// Bound Detour preflight work, not the number of ground edges.
constexpr uint32 MaxExitPreflights = 96;
for (ExitCandidate candidate : rawExitCandidates)
{
if (exitTests >= MaxExitPreflights)
break;
++exitTests;
Position fromPosition, goalPosition;
fromPosition.Relocate(candidate.Point.X, candidate.Point.Y,
candidate.Point.Z, candidate.Point.O);
goalPosition.Relocate(locationGoal->X, locationGoal->Y,
locationGoal->Z, 0.0f);
if (candidate.DistanceSq <= 2.25f)
candidate.LocalCost = 0.0f;
else
{
VcorePositionPathResult const probe = PathGenerator::AnalyzePositions(
locationGoal->MapId, locationGoal->MapId,
player->GetInstanceId(), fromPosition, goalPosition, NAV_GROUND);
if (!probe.IsComplete() || probe.FinalDistance > 1.5f ||
!std::isfinite(probe.PathLength) || probe.PathLength > 500.0f)
continue;
candidate.LocalCost = probe.PathLength;
}
exitsByFrom[candidate.FromNode].push_back(std::move(candidate));
++exitAccepted;
}
for (ExitCandidate const& candidate : rawExitCandidates)
exitsByFrom[candidate.FromNode].push_back(candidate);
TC_LOG_INFO("server.adventurebot.navigation",
"C820_EXIT_PREFLIGHT candidates=%u tested=%u accepted=%u radius=%.1f",
uint32(rawExitCandidates.size()), exitTests, exitAccepted, radius);
"C821_EXIT_CANDIDATES directedEdges=%u radius=%.1f localCost=air-distance validation=on-arrival",
uint32(rawExitCandidates.size()), radius);
}
// Exact road-section cost from recorded arclength; handles reverse
// travel as well as virtual entry + exit within the SAME edge.
auto roadSectionCost = [&](uint32 edgeId, bool reverse, uint32 begin,
uint32 end, float fullCost) -> float
{
auto itr = _edgePoints.find(edgeId);
if (itr == _edgePoints.end() || end >= itr->second.size() || begin > end)
return std::numeric_limits<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();
};
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;
@@ -915,23 +912,15 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
float dy = candidateNode.Y - locationGoal->Y;
float dz = candidateNode.Z - locationGoal->Z;
float const direct = std::sqrt(dx*dx + dy*dy + dz*dz);
if (direct <= locationGoal->AnchorRadius)
// Use the same local access limit for nodes and road exits.
// Otherwise a distant node can bypass a closer usable exit.
if (direct <= locationAccessRadius)
{
Position a, b;
a.Relocate(candidateNode.X, candidateNode.Y, candidateNode.Z, candidateNode.O);
b.Relocate(locationGoal->X, locationGoal->Y, locationGoal->Z, 0.0f);
float finalCost = std::numeric_limits<float>::infinity();
if (direct <= 1.5f)
finalCost = 0.0f;
else
{
VcorePositionPathResult const probe = PathGenerator::AnalyzePositions(
candidateNode.MapId, candidateNode.MapId,
player->GetInstanceId(), a, b, NAV_GROUND);
if (probe.IsComplete() && probe.FinalDistance <= 1.5f &&
std::isfinite(probe.PathLength) && probe.PathLength <= 500.0f)
finalCost = probe.PathLength;
}
// 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() : direct;
if (current.Cost + finalCost < bestGoalCost)
{
bestGoalCost = current.Cost + finalCost;
@@ -1081,13 +1070,34 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
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);
float remainingFraction = entry.Edge->Reverse
? float(entry.Index) / float(entry.PointCount - 1)
: float(entry.PointCount - entry.Index - 1) / float(entry.PointCount - 1);
float const cost = current.Cost + 1000.0f + probe.PathLength +
entry.Edge->Cost * remainingFraction;
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;
@@ -1418,7 +1428,22 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
}
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";
+1
View File
@@ -3414,6 +3414,7 @@ void World::UpdateSessions(uint32 diff)
continue;
}
session->CompleteClientlessTeleport();
WorldSessionFilter updater(session);
if (!session->Update(diff, updater))
{