diff --git a/src/server/game/World/ClientlessActionExecutorMgr.cpp b/src/server/game/World/ClientlessActionExecutorMgr.cpp index d2255235c4..2a786aaee8 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.cpp +++ b/src/server/game/World/ClientlessActionExecutorMgr.cpp @@ -2,10 +2,14 @@ #include "ClientlessPerceptionMgr.h" #include "ClientlessPlayerMgr.h" +#include "ClientlessLuaEngine.h" #include "ClientlessTravelMgr.h" #include "ClientlessTravelGraphMgr.h" #include "ClientlessWorldPointMgr.h" +#include "DBCStores.h" #include "Log.h" +#include "ObjectMgr.h" +#include "Opcodes.h" #include "Player.h" #include "MotionMaster.h" @@ -111,6 +115,29 @@ namespace taxiNodeId = uint32(parsed); return true; } + + + bool ParsePortalSourceNodeKey(std::string const& key, uint32& triggerId) + { + static std::string const Prefix = "portal_at_"; + static std::string const Suffix = "_source"; + + if (key.rfind(Prefix, 0) != 0 || key.size() <= Prefix.size() + Suffix.size()) + return false; + if (key.compare(key.size() - Suffix.size(), Suffix.size(), Suffix) != 0) + return false; + + std::string const idText = key.substr( + Prefix.size(), key.size() - Prefix.size() - Suffix.size()); + + char* end = nullptr; + unsigned long const parsed = std::strtoul(idText.c_str(), &end, 10); + if (!end || *end != '\0' || parsed == 0 || parsed > 0xFFFFFFFFul) + return false; + + triggerId = uint32(parsed); + return true; + } } ClientlessActionExecutorMgr* ClientlessActionExecutorMgr::instance() @@ -153,6 +180,19 @@ bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction c record.FinalO = 0.0f; record.FinalDatabaseId = 0; record.FinalLocationId.clear(); + record.FailedEdgeDatabaseIds.clear(); + record.RecoveryCount = 0; + record.RecoveryWanderAttempts = 0; + record.RecoveryWanderActive = false; + record.PlannedStartRadius = 500.0f; + record.PlannedAnchorRadius = 1000.0f; + record.PlannedDestinationNode.clear(); + record.FailedBridgeKeys.clear(); + record.RouteComplete = true; + record.HardDestination = false; + record.LocalBridgeRadius = 300.0f; + record.LocalBridgePathMax = 500.0f; + record.AdaptiveReachedNode.clear(); if (action.Name == "approach") return StartApproach(player, action, record); @@ -493,6 +533,16 @@ bool ClientlessActionExecutorMgr::StartTravelToNode( startRadius = parsed; } + record.PlannedDestinationNode = destinationNode; + record.PlannedStartRadius = startRadius; + + if (std::string const* hard = find("hard_destination")) + record.HardDestination = (*hard == "1" || *hard == "true" || *hard == "yes"); + if (std::string const* radius = find("bridge_radius")) + record.LocalBridgeRadius = ParseFloat(*radius, record.LocalBridgeRadius); + if (std::string const* pathMax = find("bridge_path_max")) + record.LocalBridgePathMax = ParseFloat(*pathMax, record.LocalBridgePathMax); + record.Route.clear(); record.RouteStep = 0; record.EdgePath.clear(); @@ -501,29 +551,42 @@ bool ClientlessActionExecutorMgr::StartTravelToNode( record.TaxiSourceNodeId = 0; record.TaxiDestinationNodeId = 0; std::string error; - if (!sClientlessTravelGraphMgr->PlanRoute( - player, destinationNode, startRadius, record.Route, error)) + if (sClientlessTravelGraphMgr->PlanRoute( + player, destinationNode, startRadius, record.Route, error, + &record.FailedEdgeDatabaseIds)) { - record.Status.State = ClientlessActionExecutionState::Failed; - record.Status.Detail = error; + record.RouteComplete = true; + record.AdaptiveReachedNode = destinationNode; + } + else if (!sClientlessTravelGraphMgr->PlanAdaptiveRoute( + player, destinationNode, startRadius, record.LocalBridgeRadius, + record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys)) + { + TC_LOG_WARN("server.adventurebot.navigation", + "TRAVEL_PLAN_FAILED guid=%u name='%s' action='travel_to_node' destination='%s' reason='%s'", + characterGuid, player->GetName().c_str(), destinationNode.c_str(), error.c_str()); + + if (record.HardDestination) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = error; + record.Active = false; + return false; + } + + record.Status.State = ClientlessActionExecutionState::Completed; + record.Status.Detail = "no useful travel progress available"; record.Active = false; - - TC_LOG_INFO("server.adventurebot", - "[AdventureBot] travel-route-failed guid=%u name='%s' destination='%s' reason='%s'", - characterGuid, - player->GetName().c_str(), - destinationNode.c_str(), - error.c_str()); - - return false; + return true; } - TC_LOG_INFO("server.adventurebot", - "[AdventureBot] travel-route-planned guid=%u name='%s' destination='%s' steps=%u", - characterGuid, - player->GetName().c_str(), - destinationNode.c_str(), - uint32(record.Route.size())); + TC_LOG_INFO("server.adventurebot.navigation", + "TRAVEL_PLAN guid=%u name='%s' action='travel_to_node' destination='%s' " + "complete=%u reached='%s' steps=%u bridgesBlocked=%u", + characterGuid, player->GetName().c_str(), destinationNode.c_str(), + record.RouteComplete ? 1u : 0u, record.AdaptiveReachedNode.c_str(), + uint32(record.Route.size()), uint32(record.FailedBridgeKeys.size())); record.Active = true; return StartRouteStep(player, characterGuid, record); @@ -605,6 +668,14 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation( record.FinalO = point->O; record.FinalDatabaseId = point->DatabaseId; record.FinalLocationId = point->Id; + record.PlannedStartRadius = startRadius; + record.PlannedAnchorRadius = anchorRadius; + if (std::string const* hard = find("hard_destination")) + record.HardDestination = (*hard == "1" || *hard == "true" || *hard == "yes"); + if (std::string const* radius = find("bridge_radius")) + record.LocalBridgeRadius = ParseFloat(*radius, record.LocalBridgeRadius); + if (std::string const* pathMax = find("bridge_path_max")) + record.LocalBridgePathMax = ParseFloat(*pathMax, record.LocalBridgePathMax); record.Status.TargetName = "location:" + point->Id; record.Status.TargetGuid = 0; record.Status.TargetEntry = point->DatabaseId; @@ -638,7 +709,7 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation( std::string anchorNode; std::string error; - if (!sClientlessTravelGraphMgr->PlanRouteToPosition( + if (sClientlessTravelGraphMgr->PlanRouteToPosition( player, point->MapId, point->X, @@ -648,22 +719,50 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation( anchorRadius, record.Route, anchorNode, - error)) + error, + &record.FailedEdgeDatabaseIds)) { - record.Status.State = ClientlessActionExecutionState::Failed; - record.Status.Detail = error; - record.Active = false; + record.RouteComplete = true; + record.AdaptiveReachedNode = anchorNode; + record.FinalLocalLegPending = true; + } + else + { + float proxyDistance = std::numeric_limits::infinity(); + if (!sClientlessTravelGraphMgr->FindNearestNodeToPosition( + point->MapId, point->X, point->Y, point->Z, anchorNode, proxyDistance)) + { + record.Status.State = record.HardDestination + ? ClientlessActionExecutionState::Failed + : ClientlessActionExecutionState::Completed; + record.Status.Detail = "no graph node exists on destination map"; + record.Active = false; + return !record.HardDestination; + } - TC_LOG_INFO("server.adventurebot", - "[AdventureBot] travel-location-failed guid=%u name='%s' location='%s' reason='%s'", - characterGuid, - player->GetName().c_str(), - locationId.c_str(), - error.c_str()); - return false; + record.PlannedDestinationNode = anchorNode; + if (!sClientlessTravelGraphMgr->PlanAdaptiveRoute( + player, anchorNode, startRadius, record.LocalBridgeRadius, + record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys)) + { + TC_LOG_WARN("server.adventurebot.navigation", + "TRAVEL_PLAN_FAILED guid=%u name='%s' action='travel_to_location' location='%s' reason='%s'", + characterGuid, player->GetName().c_str(), locationId.c_str(), error.c_str()); + + record.Status.State = record.HardDestination + ? ClientlessActionExecutionState::Failed + : ClientlessActionExecutionState::Completed; + record.Status.Detail = record.HardDestination ? error : "no useful travel progress available"; + record.Active = false; + return !record.HardDestination; + } + + if (record.RouteComplete && proxyDistance > anchorRadius) + record.RouteComplete = false; + record.FinalLocalLegPending = record.RouteComplete; } - record.FinalLocalLegPending = true; record.Active = true; TC_LOG_INFO("server.adventurebot", @@ -700,7 +799,9 @@ bool ClientlessActionExecutorMgr::StartFinalLocalLeg( record.FinalZ, record.FinalO); - if (!sClientlessTravelMgr->StartTravel(player, destination)) + sClientlessLuaEngine->DismountGroundMount(player, "final-local-leg"); + + if (!sClientlessTravelMgr->StartTravel(player, destination, false)) { record.Status.State = ClientlessActionExecutionState::Failed; record.Status.Detail = "location final local travel start failed"; @@ -737,6 +838,29 @@ bool ClientlessActionExecutorMgr::StartRouteStep( if (record.FinalLocalLegPending) return StartFinalLocalLeg(player, characterGuid, record); + if (!record.RouteComplete) + { + TC_LOG_WARN("server.adventurebot.navigation", + "TRAVEL_PARTIAL guid=%u name='%s' action='%s' requested='%s' reached='%s' " + "map=%u pos=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), record.Status.ActionName.c_str(), + record.PlannedDestinationNode.c_str(), record.AdaptiveReachedNode.c_str(), + player->GetMapId(), player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); + + if (record.HardDestination) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = "hard destination only partially reachable"; + record.Active = false; + return false; + } + + record.Status.State = ClientlessActionExecutionState::Completed; + record.Status.Detail = "best-effort travel completed at nearest useful reachable point"; + record.Active = false; + return true; + } + record.Status.State = ClientlessActionExecutionState::Completed; record.Status.Detail = "travel route completed"; record.Active = false; @@ -749,7 +873,17 @@ bool ClientlessActionExecutorMgr::StartRouteStep( ClientlessTravelRouteStep const& step = record.Route[record.RouteStep]; - if (step.EdgeType != "start" && step.EdgeType != "ground" && step.EdgeType != "taxi") + TC_LOG_INFO("server.adventurebot.navigation", + "EDGE_START guid=%u name='%s' action='%s' step=%u/%u node='%s' nodeType='%s' " + "edgeType='%s' edgeDbId=%u reverse=%u recovery=%u", + characterGuid, player->GetName().c_str(), record.Status.ActionName.c_str(), + uint32(record.RouteStep + 1), uint32(record.Route.size()), + step.NodeKey.c_str(), step.NodeType.c_str(), step.EdgeType.c_str(), + step.EdgeDatabaseId, step.EdgeReverse ? 1u : 0u, record.RecoveryCount); + + if (step.EdgeType != "start" && step.EdgeType != "ground" && + step.EdgeType != "taxi" && step.EdgeType != "portal" && + step.EdgeType != "local_bridge") { record.Status.State = ClientlessActionExecutionState::Failed; record.Status.TargetName = "node:" + step.NodeKey; @@ -765,6 +899,211 @@ bool ClientlessActionExecutorMgr::StartRouteStep( return false; } + if (step.EdgeType == "local_bridge") + { + if (record.RouteStep == 0) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = "local bridge has no source route step"; + record.Active = false; + return false; + } + + ClientlessTravelRouteStep const& source = record.Route[record.RouteStep - 1]; + std::string const bridgeKey = source.NodeKey + "->" + step.NodeKey; + + if (player->GetMapId() != step.MapId) + { + record.FailedBridgeKeys.insert(bridgeKey); + return RecoverTravel(player, characterGuid, record, 0, "local-bridge-map-mismatch"); + } + + Position destination; + destination.Relocate(step.X, step.Y, step.Z, step.O); + + ClientlessPathDiagnostics nav; + bool const calculated = sClientlessTravelMgr->AnalyzePath(player, destination, nav); + bool const usable = + calculated && nav.Complete && nav.FinalDistance <= 3.0f && + nav.DirectDistance <= record.LocalBridgeRadius + 1.0f && + nav.PathLength <= record.LocalBridgePathMax; + + if (!usable) + { + record.FailedBridgeKeys.insert(bridgeKey); + TC_LOG_WARN("server.adventurebot.navigation", + "LOCAL_BRIDGE_REJECT guid=%u name='%s' from='%s' to='%s' direct=%.2f path=%.2f " + "complete=%u finalDistance=%.2f reason='%s' blockedBridges=%u", + characterGuid, player->GetName().c_str(), source.NodeKey.c_str(), step.NodeKey.c_str(), + nav.DirectDistance, nav.PathLength, nav.Complete ? 1u : 0u, + nav.FinalDistance, nav.Reason.c_str(), uint32(record.FailedBridgeKeys.size())); + + return RecoverTravel(player, characterGuid, record, 0, "local-bridge-path-invalid"); + } + + record.EdgePath.clear(); + record.Status.State = ClientlessActionExecutionState::Moving; + record.Status.TargetName = "node:" + step.NodeKey; + record.Status.TargetGuid = 0; + record.Status.TargetEntry = 0; + record.Status.Detail = "dynamic local bridge"; + + if (!sClientlessTravelMgr->StartTravel(player, destination, true)) + { + record.FailedBridgeKeys.insert(bridgeKey); + return RecoverTravel(player, characterGuid, record, 0, "local-bridge-start-failed"); + } + + TC_LOG_INFO("server.adventurebot.navigation", + "LOCAL_BRIDGE_START guid=%u name='%s' from='%s' to='%s' direct=%.2f path=%.2f", + characterGuid, player->GetName().c_str(), source.NodeKey.c_str(), step.NodeKey.c_str(), + nav.DirectDistance, nav.PathLength); + return true; + } + + if (step.EdgeType == "portal") + { + if (record.RouteStep == 0) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = "portal edge has no source route step"; + record.Active = false; + return false; + } + + ClientlessTravelRouteStep const& sourceStep = record.Route[record.RouteStep - 1]; + uint32 triggerId = 0; + if (!ParsePortalSourceNodeKey(sourceStep.NodeKey, triggerId)) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.TargetName = "node:" + step.NodeKey; + record.Status.Detail = "portal edge source is not portal_at__source"; + record.Active = false; + return false; + } + + AreaTriggerEntry const* atEntry = sAreaTriggerStore.LookupEntry(triggerId); + AreaTriggerStruct const* teleport = sObjectMgr->GetAreaTrigger(triggerId); + if (!atEntry || !teleport) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.TargetName = "node:" + step.NodeKey; + record.Status.Detail = "portal AreaTrigger metadata missing for id " + std::to_string(triggerId); + record.Active = false; + 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)) + { + TC_LOG_WARN("server.adventurebot.navigation", + "EDGE_FAILED guid=%u name='%s' kind='portal-not-in-trigger' edgeDbId=%u " + "trigger=%u currentMap=%u expectedMap=%u current=(%.3f %.3f %.3f) trigger=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), step.EdgeDatabaseId, + triggerId, player->GetMapId(), uint32(atEntry->ContinentID), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + atEntry->Pos.X, atEntry->Pos.Y, atEntry->Pos.Z); + + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.TargetName = "node:" + sourceStep.NodeKey; + record.Status.Detail = "portal source AreaTrigger was not reached"; + record.Active = false; + return false; + } + + if (teleport->target_mapId != step.MapId) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.TargetName = "node:" + step.NodeKey; + record.Status.Detail = "portal graph target map does not match AreaTrigger target"; + record.Active = false; + return false; + } + + sClientlessTravelMgr->CancelTravel(characterGuid); + record.EdgePath.clear(); + sClientlessLuaEngine->DismountGroundMount(player, "portal-edge"); + + TC_LOG_INFO("server.adventurebot.navigation", + "PORTAL_TRIGGER guid=%u name='%s' edgeDbId=%u trigger=%u sourceMap=%u targetMap=%u " + "source=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), step.EdgeDatabaseId, + triggerId, uint32(atEntry->ContinentID), teleport->target_mapId, + atEntry->Pos.X, atEntry->Pos.Y, atEntry->Pos.Z, + teleport->target_X, teleport->target_Y, teleport->target_Z); + + WorldPacket areaTriggerPacket(CMSG_AREATRIGGER, 4); + areaTriggerPacket << triggerId; + player->GetSession()->HandleAreaTriggerOpcode(areaTriggerPacket); + + if (player->IsBeingTeleportedFar()) + { + if (!player->GetSession()->IsClientless()) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = "portal executor expected a clientless WorldSession"; + record.Active = false; + 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(); + } + + if (!player->IsInWorld() || player->GetMapId() != teleport->target_mapId) + { + TC_LOG_WARN("server.adventurebot.navigation", + "EDGE_FAILED guid=%u name='%s' kind='portal-transfer-not-completed' edgeDbId=%u " + "trigger=%u expectedMap=%u actualMap=%u farPending=%u", + characterGuid, player->GetName().c_str(), step.EdgeDatabaseId, + triggerId, teleport->target_mapId, player->GetMapId(), + player->IsBeingTeleportedFar() ? 1u : 0u); + + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.TargetName = "node:" + step.NodeKey; + record.Status.Detail = + "AreaTrigger " + std::to_string(triggerId) + + " did not complete its map transfer (access/script condition may have denied it)"; + record.Active = false; + return false; + } + + float const dx = player->GetPositionX() - teleport->target_X; + 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); + + record.Status.State = ClientlessActionExecutionState::Moving; + record.Status.TargetName = "node:" + step.NodeKey; + record.Status.TargetGuid = 0; + record.Status.TargetEntry = triggerId; + record.Status.Detail = "AreaTrigger portal " + std::to_string(triggerId); + + TC_LOG_INFO("server.adventurebot.navigation", + "EDGE_COMPLETE guid=%u name='%s' edgeType='portal' edgeDbId=%u node='%s' " + "trigger=%u map=%u arrivalError=%.2f recovery=%u", + characterGuid, player->GetName().c_str(), step.EdgeDatabaseId, + step.NodeKey.c_str(), triggerId, player->GetMapId(), arrivalError, + record.RecoveryCount); + + ++record.RouteStep; + return StartRouteStep(player, characterGuid, record); + } + if (step.EdgeType == "taxi") { if (record.RouteStep == 0) @@ -811,6 +1150,7 @@ bool ClientlessActionExecutorMgr::StartRouteStep( sClientlessTravelMgr->CancelTravel(characterGuid); record.EdgePath.clear(); + sClientlessLuaEngine->DismountGroundMount(player, "taxi-edge"); // AdventureBots are performative world actors, not simulated // participants in the player economy. Give the normal Trinity taxi @@ -846,24 +1186,16 @@ bool ClientlessActionExecutorMgr::StartRouteStep( if (!taxiStarted) { - record.Status.State = ClientlessActionExecutionState::Failed; - record.Status.TargetName = "node:" + step.NodeKey; - record.Status.Detail = - "ActivateTaxiPathTo failed for path=" + - std::to_string(taxiPathId) + - " source=" + std::to_string(sourceTaxiNodeId) + - " destination=" + std::to_string(destinationTaxiNodeId); - record.Active = false; + TC_LOG_WARN("server.adventurebot.navigation", + "EDGE_FAILED guid=%u name='%s' kind='taxi-start-failed' edgeDbId=%u " + "path=%u source=%u destination=%u cost=%u current=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), step.EdgeDatabaseId, + taxiPathId, sourceTaxiNodeId, destinationTaxiNodeId, taxiCost, + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); - TC_LOG_WARN("server.adventurebot", - "[AdventureBot] taxi-start-failed guid=%u name='%s' path=%u source=%u destination=%u cost=%u", - characterGuid, - player->GetName().c_str(), - taxiPathId, - sourceTaxiNodeId, - destinationTaxiNodeId, - taxiCost); - return false; + return RecoverTravel( + player, characterGuid, record, step.EdgeDatabaseId, + "taxi-start-failed"); } record.TaxiActive = true; @@ -930,7 +1262,7 @@ bool ClientlessActionExecutorMgr::StartRouteStep( Position destination; destination.Relocate(step.X, step.Y, step.Z, step.O); - if (!sClientlessTravelMgr->StartTravel(player, destination)) + if (!sClientlessTravelMgr->StartTravel(player, destination, true)) { record.Status.State = ClientlessActionExecutionState::Failed; record.Status.Detail = "travel start failed for route node " + step.NodeKey; @@ -1010,6 +1342,17 @@ bool ClientlessActionExecutorMgr::StartRecordedEdgePath( return false; } + float recordedDistance = 0.0f; + for (std::size_t i = 1; i < path.size(); ++i) + { + float const dx = path[i].GetPositionX() - path[i - 1].GetPositionX(); + float const dy = path[i].GetPositionY() - path[i - 1].GetPositionY(); + float const dz = path[i].GetPositionZ() - path[i - 1].GetPositionZ(); + recordedDistance += std::sqrt(dx * dx + dy * dy + dz * dz); + } + + sClientlessLuaEngine->PrepareGroundMount(player, recordedDistance, "recorded-ground-edge"); + player->GetMotionMaster()->MoveSmoothPath( 1, path.data(), @@ -1034,11 +1377,268 @@ bool ClientlessActionExecutorMgr::StartRecordedEdgePath( return true; } +bool ClientlessActionExecutorMgr::StartRecoveryWander( + Player* player, + uint32 characterGuid, + Record& record, + std::string const& reason) +{ + if (!player || !player->IsInWorld()) + return false; + + constexpr uint32 MaxWanderAttempts = 6; + if (record.RecoveryWanderAttempts >= MaxWanderAttempts) + return false; + + ++record.RecoveryWanderAttempts; + + constexpr uint32 CandidateCount = 16; + for (uint32 i = 0; i < CandidateCount; ++i) + { + float const angle = + float((characterGuid * 37u + record.RecoveryWanderAttempts * 71u + i * 23u) % 360u) + * 0.01745329251994329577f; + float const distance = 35.0f + float((i % 4u) * 15u); + + Position target; + target.Relocate( + player->GetPositionX() + std::cos(angle) * distance, + player->GetPositionY() + std::sin(angle) * distance, + player->GetPositionZ(), + player->GetOrientation()); + + ClientlessPathDiagnostics nav; + sClientlessTravelMgr->AnalyzePath(player, target, nav); + + if (!nav.Calculated || !nav.Complete || nav.PointCount < 2 || + nav.FinalDistance > 3.0f || nav.PathLength > 180.0f) + continue; + + if (!sClientlessTravelMgr->StartTravel(player, target)) + continue; + + record.RecoveryWanderActive = true; + record.Active = true; + record.Status.State = ClientlessActionExecutionState::Moving; + record.Status.Detail = "recovery wander"; + + TC_LOG_WARN("server.adventurebot.navigation", + "RECOVERY_WANDER guid=%u name='%s' attempt=%u reason='%s' " + "target=(%.3f %.3f %.3f) pathLength=%.2f", + characterGuid, player->GetName().c_str(), + record.RecoveryWanderAttempts, reason.c_str(), + target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(), + nav.PathLength); + return true; + } + + return false; +} + +bool ClientlessActionExecutorMgr::RecoverTravel( + Player* player, + uint32 characterGuid, + Record& record, + uint32 failedEdgeDatabaseId, + std::string const& reason) +{ + constexpr uint32 MaxRecoveries = 5; + + if (!player || !player->IsInWorld()) + return false; + + if (failedEdgeDatabaseId) + record.FailedEdgeDatabaseIds.insert(failedEdgeDatabaseId); + + bool const bridgeFailure = reason.rfind("local-bridge-", 0) == 0; + + if (!bridgeFailure && record.RecoveryCount >= MaxRecoveries) + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = "travel recovery exhausted: " + reason; + record.Active = false; + record.RecoveryWanderActive = false; + + TC_LOG_ERROR("server.adventurebot.navigation", + "RECOVERY_FAILED guid=%u name='%s' action='%s' reason='%s' " + "recoveries=%u blockedEdges=%u current=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), + record.Status.ActionName.c_str(), reason.c_str(), + record.RecoveryCount, uint32(record.FailedEdgeDatabaseIds.size()), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); + return false; + } + + if (!bridgeFailure) + ++record.RecoveryCount; + sClientlessTravelMgr->CancelTravel(characterGuid); + record.EdgePath.clear(); + record.TaxiActive = false; + record.TaxiPathId = 0; + record.TaxiSourceNodeId = 0; + record.TaxiDestinationNodeId = 0; + record.RecoveryWanderActive = false; + + TC_LOG_WARN("server.adventurebot.navigation", + "RECOVERY_START guid=%u name='%s' action='%s' reason='%s' recovery=%u/%u " + "failedEdgeDbId=%u blockedEdges=%u current=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), + record.Status.ActionName.c_str(), reason.c_str(), + record.RecoveryCount, MaxRecoveries, failedEdgeDatabaseId, + uint32(record.FailedEdgeDatabaseIds.size()), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); + + float radii[4] = + { + std::max(500.0f, record.PlannedStartRadius), + 1000.0f, + 2000.0f, + 4000.0f + }; + + std::string lastError = "no recovery route"; + + for (float radius : radii) + { + record.Route.clear(); + record.RouteStep = 0; + + bool planned = false; + std::string error; + + if (record.Status.ActionName == "travel_to_location") + { + std::string anchorNode; + planned = sClientlessTravelGraphMgr->PlanRouteToPosition( + player, + record.FinalMapId, + record.FinalX, + record.FinalY, + record.FinalZ, + radius, + record.PlannedAnchorRadius, + record.Route, + anchorNode, + error, + &record.FailedEdgeDatabaseIds); + + if (planned) + { + record.RouteComplete = true; + record.AdaptiveReachedNode = anchorNode; + } + else if (!record.PlannedDestinationNode.empty()) + planned = sClientlessTravelGraphMgr->PlanAdaptiveRoute( + player, record.PlannedDestinationNode, radius, record.LocalBridgeRadius, + record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys); + + if (planned) + TC_LOG_WARN("server.adventurebot.navigation", + "RECOVERY_REPLAN guid=%u name='%s' action='travel_to_location' " + "location='%s' anchor='%s' radius=%.1f steps=%u blockedEdges=%u", + characterGuid, player->GetName().c_str(), + record.FinalLocationId.c_str(), anchorNode.c_str(), radius, + uint32(record.Route.size()), + uint32(record.FailedEdgeDatabaseIds.size())); + } + else if (record.Status.ActionName == "travel_to_node") + { + planned = sClientlessTravelGraphMgr->PlanRoute( + player, + record.PlannedDestinationNode, + radius, + record.Route, + error, + &record.FailedEdgeDatabaseIds); + + if (planned) + { + record.RouteComplete = true; + record.AdaptiveReachedNode = record.PlannedDestinationNode; + } + else + planned = sClientlessTravelGraphMgr->PlanAdaptiveRoute( + player, record.PlannedDestinationNode, radius, record.LocalBridgeRadius, + record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys); + + if (planned) + TC_LOG_WARN("server.adventurebot.navigation", + "RECOVERY_REPLAN guid=%u name='%s' action='travel_to_node' " + "destination='%s' radius=%.1f steps=%u blockedEdges=%u", + characterGuid, player->GetName().c_str(), + record.PlannedDestinationNode.c_str(), radius, + uint32(record.Route.size()), + uint32(record.FailedEdgeDatabaseIds.size())); + } + else + { + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = "recovery unsupported for action"; + record.Active = false; + return false; + } + + if (!planned) + { + lastError = error; + continue; + } + + if (record.Status.ActionName == "travel_to_location") + { + record.FinalLocalLegPending = record.RouteComplete; + record.FinalLocalLegActive = false; + } + + record.Active = true; + record.Status.State = ClientlessActionExecutionState::Moving; + record.Status.Detail = "travel recovery replanned"; + return StartRouteStep(player, characterGuid, record); + } + + if (StartRecoveryWander(player, characterGuid, record, lastError)) + return true; + + record.Status.State = ClientlessActionExecutionState::Failed; + record.Status.Detail = "no recovery route or local wander path: " + lastError; + record.Active = false; + + TC_LOG_ERROR("server.adventurebot.navigation", + "RECOVERY_FAILED guid=%u name='%s' action='%s' reason='%s' " + "recoveries=%u blockedEdges=%u current=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), + record.Status.ActionName.c_str(), lastError.c_str(), + record.RecoveryCount, uint32(record.FailedEdgeDatabaseIds.size()), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); + return false; +} + void ClientlessActionExecutorMgr::UpdateTravelToNode( Player* player, uint32 characterGuid, Record& record) { + if (record.RecoveryWanderActive) + { + ClientlessTravelStatus const* wander = + sClientlessTravelMgr->GetStatus(characterGuid); + + if (wander && wander->State == ClientlessTravelState::Moving) + return; + + record.RecoveryWanderActive = false; + + std::string const reason = + wander + ? std::string("wander-ended-") + ClientlessTravelMgr::StateName(wander->State) + : "wander-travel-state-missing"; + + RecoverTravel(player, characterGuid, record, 0, reason); + return; + } + if (record.TaxiActive) { if (!player || !player->IsInWorld()) @@ -1060,13 +1660,17 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( if (player->GetMapId() != destination.MapId) { - record.Status.State = ClientlessActionExecutionState::Failed; - record.Status.Detail = - "taxi ended on unexpected map; expected=" + - std::to_string(destination.MapId) + - " actual=" + std::to_string(player->GetMapId()); - record.Active = false; + TC_LOG_WARN("server.adventurebot.navigation", + "EDGE_FAILED guid=%u name='%s' kind='taxi-unexpected-map' edgeDbId=%u " + "expectedMap=%u actualMap=%u path=%u source=%u destination=%u", + characterGuid, player->GetName().c_str(), destination.EdgeDatabaseId, + destination.MapId, player->GetMapId(), + record.TaxiPathId, record.TaxiSourceNodeId, record.TaxiDestinationNodeId); + record.TaxiActive = false; + RecoverTravel( + player, characterGuid, record, destination.EdgeDatabaseId, + "taxi-unexpected-map"); return; } @@ -1078,21 +1682,21 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( constexpr float TaxiArrivalSanityRadius = 120.0f; if (remaining > TaxiArrivalSanityRadius) { - record.Status.State = ClientlessActionExecutionState::Failed; - record.Status.Detail = - "taxi stopped away from destination node; remaining=" + - std::to_string(remaining); - record.Active = false; - record.TaxiActive = false; + uint32 const failedEdgeId = destination.EdgeDatabaseId; - TC_LOG_WARN("server.adventurebot", - "[AdventureBot] taxi-ended-away-from-node guid=%u name='%s' path=%u source=%u destination=%u remaining=%.2f", - characterGuid, - player->GetName().c_str(), - record.TaxiPathId, - record.TaxiSourceNodeId, - record.TaxiDestinationNodeId, - remaining); + TC_LOG_WARN("server.adventurebot.navigation", + "EDGE_FAILED guid=%u name='%s' kind='taxi-ended-away-from-node' edgeDbId=%u " + "path=%u source=%u destination=%u current=(%.3f %.3f %.3f) " + "expected=(%.3f %.3f %.3f) remaining=%.2f", + characterGuid, player->GetName().c_str(), failedEdgeId, + record.TaxiPathId, record.TaxiSourceNodeId, record.TaxiDestinationNodeId, + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + destination.X, destination.Y, destination.Z, remaining); + + record.TaxiActive = false; + RecoverTravel( + player, characterGuid, record, failedEdgeId, + "taxi-ended-away-from-node"); return; } @@ -1105,6 +1709,13 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( record.TaxiDestinationNodeId, remaining); + TC_LOG_INFO("server.adventurebot.navigation", + "EDGE_COMPLETE guid=%u name='%s' edgeType='taxi' edgeDbId=%u " + "node='%s' remaining=%.2f recovery=%u", + characterGuid, player->GetName().c_str(), + destination.EdgeDatabaseId, destination.NodeKey.c_str(), + remaining, record.RecoveryCount); + record.TaxiActive = false; record.TaxiPathId = 0; record.TaxiSourceNodeId = 0; @@ -1141,11 +1752,20 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( if (remaining > 2.0f) { - record.Status.State = ClientlessActionExecutionState::Failed; - record.Status.Detail = - "recorded ground spline stopped early; remaining=" + - std::to_string(remaining); - record.Active = false; + uint32 const failedEdgeId = + record.RouteStep < record.Route.size() + ? record.Route[record.RouteStep].EdgeDatabaseId + : 0u; + + TC_LOG_WARN("server.adventurebot.navigation", + "EDGE_FAILED guid=%u name='%s' kind='recorded-ground-stopped-early' " + "edgeDbId=%u remaining=%.2f current=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), failedEdgeId, remaining, + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); + + RecoverTravel( + player, characterGuid, record, failedEdgeId, + "recorded-ground-stopped-early"); return; } @@ -1171,14 +1791,35 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( if (travel->State != ClientlessTravelState::Arrived) { - record.Status.State = ClientlessActionExecutionState::Failed; - record.Status.Detail = - std::string("route step ended in state ") + - ClientlessTravelMgr::StateName(travel->State); - record.Active = false; + uint32 const failedEdgeId = + record.RouteStep < record.Route.size() + ? record.Route[record.RouteStep].EdgeDatabaseId + : 0u; + + std::string const reason = + std::string("route-local-") + ClientlessTravelMgr::StateName(travel->State); + + TC_LOG_WARN("server.adventurebot.navigation", + "EDGE_FAILED guid=%u name='%s' kind='local-navigation' edgeDbId=%u " + "state=%s current=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), failedEdgeId, + ClientlessTravelMgr::StateName(travel->State), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); + + RecoverTravel(player, characterGuid, record, failedEdgeId, reason); return; } + if (record.RouteStep < record.Route.size()) + { + ClientlessTravelRouteStep const& completed = record.Route[record.RouteStep]; + TC_LOG_INFO("server.adventurebot.navigation", + "EDGE_COMPLETE guid=%u name='%s' edgeType='%s' edgeDbId=%u node='%s' recovery=%u", + characterGuid, player->GetName().c_str(), + completed.EdgeType.c_str(), completed.EdgeDatabaseId, + completed.NodeKey.c_str(), record.RecoveryCount); + } + ++record.RouteStep; StartRouteStep(player, characterGuid, record); } @@ -1226,12 +1867,19 @@ void ClientlessActionExecutorMgr::UpdateTravelToLocation( return; } - record.Status.State = ClientlessActionExecutionState::Failed; - record.Status.Detail = - std::string("location local travel ended in state ") + - ClientlessTravelMgr::StateName(travel->State); - record.Active = false; + std::string const reason = + std::string("final-local-") + ClientlessTravelMgr::StateName(travel->State); + + TC_LOG_WARN("server.adventurebot.navigation", + "EDGE_FAILED guid=%u name='%s' kind='final-local-navigation' location='%s' " + "state=%s current=(%.3f %.3f %.3f)", + characterGuid, player->GetName().c_str(), record.FinalLocationId.c_str(), + ClientlessTravelMgr::StateName(travel->State), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); + record.FinalLocalLegActive = false; + record.FinalLocalLegPending = true; + RecoverTravel(player, characterGuid, record, 0, reason); } void ClientlessActionExecutorMgr::UpdateActive( diff --git a/src/server/game/World/ClientlessActionExecutorMgr.h b/src/server/game/World/ClientlessActionExecutorMgr.h index ef818073bb..eb95e5b9ab 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.h +++ b/src/server/game/World/ClientlessActionExecutorMgr.h @@ -9,6 +9,7 @@ #include "ClientlessTravelGraphMgr.h" #include #include +#include #include class Player; @@ -69,6 +70,24 @@ private: float FinalO = 0.0f; uint32 FinalDatabaseId = 0; std::string FinalLocationId; + + // C8.11b travel recovery. Failed edges are blacklisted only for the + // current action; nothing is disabled persistently in the database. + std::unordered_set FailedEdgeDatabaseIds; + uint32 RecoveryCount = 0; + uint32 RecoveryWanderAttempts = 0; + bool RecoveryWanderActive = false; + float PlannedStartRadius = 500.0f; + float PlannedAnchorRadius = 1000.0f; + std::string PlannedDestinationNode; + + // C8.13b adaptive graph bridging / best-effort travel. + std::unordered_set FailedBridgeKeys; + bool RouteComplete = true; + bool HardDestination = false; + float LocalBridgeRadius = 300.0f; + float LocalBridgePathMax = 500.0f; + std::string AdaptiveReachedNode; }; bool StartAction(Player* player, ClientlessAction const& action, Record& record); @@ -79,6 +98,8 @@ private: bool StartFinalLocalLeg(Player* player, uint32 characterGuid, Record& record); 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); + bool StartRecoveryWander(Player* player, uint32 characterGuid, Record& record, std::string const& reason); void UpdateTravelToNode(Player* player, uint32 characterGuid, Record& record); void UpdateTravelToLocation(Player* player, uint32 characterGuid, Record& record); void UpdateActive(Player* player, uint32 characterGuid, Record& record); diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index 161d7179f8..1763443556 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -2,6 +2,7 @@ #include "ClientlessPlayerMgr.h" #include "Log.h" #include "Player.h" +#include "Random.h" #include #include #include @@ -86,10 +87,92 @@ void BoundFn(lua_State* L, int env, char const* name, lua_CFunction fn, uint32 g lua_setfield(L, env, name); } +std::string ReadStringField(lua_State* L, int table, char const* key, std::string const& fallback = {}) +{ + table = lua_absindex(L, table); + lua_getfield(L, table, key); + std::string value = lua_isstring(L, -1) ? lua_tostring(L, -1) : fallback; + lua_pop(L, 1); + return value; +} + +bool ReadBoolField(lua_State* L, int table, char const* key, bool fallback) +{ + table = lua_absindex(L, table); + lua_getfield(L, table, key); + bool value = lua_isboolean(L, -1) ? lua_toboolean(L, -1) != 0 : fallback; + lua_pop(L, 1); + return value; +} + +float ReadFloatField(lua_State* L, int table, char const* key, float fallback) +{ + table = lua_absindex(L, table); + lua_getfield(L, table, key); + float value = lua_isnumber(L, -1) ? float(lua_tonumber(L, -1)) : fallback; + lua_pop(L, 1); + return value; +} + +std::unordered_map ReadFloatDictionary(lua_State* L, int table, char const* key) +{ + std::unordered_map out; + table = lua_absindex(L, table); + lua_getfield(L, table, key); + if (!lua_istable(L, -1)) + { + lua_pop(L, 1); + return out; + } + + int dict = lua_absindex(L, -1); + lua_pushnil(L); + while (lua_next(L, dict) != 0) + { + if (lua_type(L, -2) == LUA_TSTRING && lua_isnumber(L, -1)) + out[lua_tostring(L, -2)] = float(lua_tonumber(L, -1)); + lua_pop(L, 1); + } + lua_pop(L, 1); + return out; +} + +std::vector ReadMountDictionary(lua_State* L, int movementTable) +{ + std::vector mounts; + movementTable = lua_absindex(L, movementTable); + lua_getfield(L, movementTable, "ground_mounts"); + if (!lua_istable(L, -1)) + { + lua_pop(L, 1); + return mounts; + } + + int dict = lua_absindex(L, -1); + lua_pushnil(L); + while (lua_next(L, dict) != 0) + { + if (lua_isinteger(L, -2) && lua_isnumber(L, -1)) + { + lua_Integer id = lua_tointeger(L, -2); + lua_Number weight = lua_tonumber(L, -1); + if (id > 0 && id <= 0xFFFFFFFFll && weight > 0.0) + mounts.push_back({ uint32(id), uint32(std::max(1.0, weight)) }); + } + lua_pop(L, 1); + } + lua_pop(L, 1); + return mounts; +} + class ClientlessAdventureLuaController final : public ClientlessPlayerController { public: - void OnPlayerOnline(Player* player) override { sClientlessLuaEngine->ExecuteAdventureScript(player); } + void OnPlayerOnline(Player* player) override + { + sClientlessLuaEngine->LoadAdventureProfile(player); + sClientlessLuaEngine->ExecuteAdventureScript(player); + } }; } @@ -172,6 +255,184 @@ bool ClientlessLuaEngine::ExecuteScript(Player* player, std::string const& path, return true; } +bool ClientlessLuaEngine::LoadAdventureProfile(Player* player) +{ + if (!_state || !player) + return false; + + namespace fs = std::filesystem; + std::string lowerName = player->GetName(); + std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), + [](unsigned char c) { return char(std::tolower(c)); }); + + fs::path root(_scriptRoot); + fs::path specific = root / "adventure" / "profiles" / (lowerName + ".lua"); + fs::path generic = root / "adventure" / "profiles" / "generic.lua"; + fs::path chosen = fs::exists(specific) ? specific : generic; + + int base = lua_gettop(_state); + if (luaL_loadfile(_state, chosen.string().c_str()) != LUA_OK) + { + TC_LOG_ERROR("server.adventurebot", + "[AdventureBot][Profile] guid=%u name='%s' load failed path='%s': %s", + player->GetGUID().GetCounter(), player->GetName().c_str(), + chosen.string().c_str(), lua_tostring(_state, -1)); + lua_settop(_state, base); + return false; + } + + PushSandboxEnvironment(player); + if (!lua_setupvalue(_state, base + 1, 1)) + { + TC_LOG_ERROR("server.adventurebot", + "[AdventureBot][Profile] guid=%u path='%s' has no _ENV upvalue", + player->GetGUID().GetCounter(), chosen.string().c_str()); + lua_settop(_state, base); + return false; + } + + if (lua_pcall(_state, 0, 1, 0) != LUA_OK) + { + TC_LOG_ERROR("server.adventurebot", + "[AdventureBot][Profile] guid=%u runtime error path='%s': %s", + player->GetGUID().GetCounter(), chosen.string().c_str(), lua_tostring(_state, -1)); + lua_settop(_state, base); + return false; + } + + if (!lua_istable(_state, -1)) + { + TC_LOG_ERROR("server.adventurebot", + "[AdventureBot][Profile] guid=%u path='%s' must return a table", + player->GetGUID().GetCounter(), chosen.string().c_str()); + lua_settop(_state, base); + return false; + } + + int rootTable = lua_absindex(_state, -1); + ClientlessAdventureProfile profile; + profile.CharacterName = player->GetName(); + profile.SourcePath = chosen.string(); + + lua_getfield(_state, rootTable, "identity"); + if (lua_istable(_state, -1)) + { + profile.CharacterName = ReadStringField(_state, -1, "name", profile.CharacterName); + profile.HomeRegion = ReadStringField(_state, -1, "home_region"); + } + lua_pop(_state, 1); + + lua_getfield(_state, rootTable, "movement"); + if (lua_istable(_state, -1)) + { + profile.CanGroundMount = ReadBoolField(_state, -1, "can_ground_mount", false); + profile.GroundMountMinDistance = std::max(0.0f, + ReadFloatField(_state, -1, "ground_mount_min_distance", 60.0f)); + profile.GroundMountSpeedRate = std::max(1.0f, + ReadFloatField(_state, -1, "ground_mount_speed_rate", 1.6f)); + profile.GroundMounts = ReadMountDictionary(_state, -1); + } + lua_pop(_state, 1); + + profile.Personality = ReadFloatDictionary(_state, rootTable, "personality"); + profile.RegionAffinity = ReadFloatDictionary(_state, rootTable, "region_affinity"); + profile.ActivityAffinity = ReadFloatDictionary(_state, rootTable, "activity_affinity"); + + uint32 const guid = player->GetGUID().GetCounter(); + _profiles[guid] = std::move(profile); + ClientlessAdventureProfile const& stored = _profiles.at(guid); + + TC_LOG_INFO("server.adventurebot", + "[AdventureBot][Profile] loaded guid=%u name='%s' source='%s' home='%s' " + "canGroundMount=%u mounts=%u mountMinDistance=%.1f mountSpeedRate=%.2f " + "personality=%u regionAffinity=%u activityAffinity=%u", + guid, player->GetName().c_str(), stored.SourcePath.c_str(), stored.HomeRegion.c_str(), + stored.CanGroundMount ? 1u : 0u, uint32(stored.GroundMounts.size()), + stored.GroundMountMinDistance, stored.GroundMountSpeedRate, + uint32(stored.Personality.size()), uint32(stored.RegionAffinity.size()), + uint32(stored.ActivityAffinity.size())); + + if (stored.CanGroundMount && stored.GroundMounts.empty()) + TC_LOG_WARN("server.adventurebot", + "[AdventureBot][Profile] guid=%u enables ground mounts but defines no ground_mounts", guid); + + lua_settop(_state, base); + return true; +} + +ClientlessAdventureProfile const* ClientlessLuaEngine::GetAdventureProfile(uint32 characterGuid) const +{ + auto itr = _profiles.find(characterGuid); + return itr != _profiles.end() ? &itr->second : nullptr; +} + +uint32 ClientlessLuaEngine::SelectGroundMountDisplayId(uint32 characterGuid) const +{ + ClientlessAdventureProfile const* profile = GetAdventureProfile(characterGuid); + if (!profile || profile->GroundMounts.empty()) + return 0; + + uint64 total = 0; + for (ClientlessWeightedMount const& mount : profile->GroundMounts) + total += mount.Weight; + if (!total) + return 0; + + uint32 const cappedTotal = uint32(std::min(total, 0xFFFFFFFFull)); + uint32 roll = urand(1u, cappedTotal); + for (ClientlessWeightedMount const& mount : profile->GroundMounts) + { + if (roll <= mount.Weight) + return mount.DisplayId; + roll -= std::min(roll, mount.Weight); + } + return profile->GroundMounts.back().DisplayId; +} + +bool ClientlessLuaEngine::PrepareGroundMount(Player* player, float travelDistance, char const* context) +{ + if (!player || !player->IsInWorld()) + return false; + + ClientlessAdventureProfile const* profile = GetAdventureProfile(player->GetGUID().GetCounter()); + if (!profile || !profile->CanGroundMount || profile->GroundMounts.empty()) + return false; + if (travelDistance < profile->GroundMountMinDistance || !player->IsOutdoors()) + return false; + if (player->IsMounted()) + return true; + + uint32 const displayId = SelectGroundMountDisplayId(player->GetGUID().GetCounter()); + if (!displayId) + return false; + + // The AdventureBot profile is authoritative for this capability. Intentionally + // no level, riding-skill, spellbook, inventory or economy eligibility check. + player->Mount(displayId); + player->SetSpeedRate(MOVE_RUN, profile->GroundMountSpeedRate); + + TC_LOG_INFO("server.adventurebot.navigation", + "GROUND_MOUNT guid=%u name='%s' displayId=%u distance=%.2f speedRate=%.2f context='%s'", + player->GetGUID().GetCounter(), player->GetName().c_str(), displayId, + travelDistance, profile->GroundMountSpeedRate, context ? context : ""); + return true; +} + +void ClientlessLuaEngine::DismountGroundMount(Player* player, char const* reason) +{ + if (!player || !player->IsMounted()) + return; + + uint32 const oldDisplay = player->GetMountID(); + player->Dismount(); + player->UpdateSpeed(MOVE_RUN); + + TC_LOG_INFO("server.adventurebot.navigation", + "GROUND_DISMOUNT guid=%u name='%s' displayId=%u reason='%s'", + player->GetGUID().GetCounter(), player->GetName().c_str(), oldDisplay, + reason ? reason : ""); +} + bool ClientlessLuaEngine::ExecuteAdventureScript(Player* player) { if (!player) return false; diff --git a/src/server/game/World/ClientlessLuaEngine.h b/src/server/game/World/ClientlessLuaEngine.h index af2ce4c18f..4bae645b5d 100644 --- a/src/server/game/World/ClientlessLuaEngine.h +++ b/src/server/game/World/ClientlessLuaEngine.h @@ -5,11 +5,39 @@ #include "Define.h" #include #include +#include +#include class ClientlessPlayerController; class Player; struct lua_State; +// CLIENTLESS_PLAYER_CORE_C8_12_ADVENTURE_PROFILE +// Lua describes who a performative AdventureBot is; generic C++ systems decide +// how those values become visible behavior. +struct TC_GAME_API ClientlessWeightedMount +{ + uint32 DisplayId = 0; + uint32 Weight = 0; +}; + +struct TC_GAME_API ClientlessAdventureProfile +{ + std::string CharacterName; + std::string HomeRegion; + std::string SourcePath; + + bool CanGroundMount = false; + float GroundMountMinDistance = 60.0f; + float GroundMountSpeedRate = 1.6f; + std::vector GroundMounts; + + // Parsed now; consumed later by the weighted intent/day planner. + std::unordered_map Personality; + std::unordered_map RegionAffinity; + std::unordered_map ActivityAffinity; +}; + class TC_GAME_API ClientlessLuaEngine { public: @@ -18,6 +46,13 @@ public: bool ExecuteAdventureScript(Player* player); bool ExecuteTestScript(Player* player, std::string const& scriptName); + + bool LoadAdventureProfile(Player* player); + ClientlessAdventureProfile const* GetAdventureProfile(uint32 characterGuid) const; + uint32 SelectGroundMountDisplayId(uint32 characterGuid) const; + bool PrepareGroundMount(Player* player, float travelDistance, char const* context); + void DismountGroundMount(Player* player, char const* reason); + std::string const& GetScriptRoot() const { return _scriptRoot; } private: @@ -27,6 +62,7 @@ private: lua_State* _state = nullptr; std::string _scriptRoot; + std::unordered_map _profiles; }; TC_GAME_API std::unique_ptr CreateClientlessAdventureLuaController(); diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index fc989b60db..ac07f947dc 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -184,7 +184,8 @@ bool ClientlessTravelGraphMgr::PlanRoute( std::string const& destinationNode, float startRadius, std::vector& route, - std::string& error) + std::string& error, + std::unordered_set const* blockedEdgeIds) { route.clear(); error.clear(); @@ -259,6 +260,11 @@ bool ClientlessTravelGraphMgr::PlanRoute( for (ClientlessTravelEdge const& edge : edgeItr->second) { + if (blockedEdgeIds && + edge.DatabaseId && + blockedEdgeIds->find(edge.DatabaseId) != blockedEdgeIds->end()) + continue; + float const candidate = current.Cost + edge.Cost; if (candidate >= distance[edge.ToNode]) continue; @@ -329,6 +335,381 @@ bool ClientlessTravelGraphMgr::PlanRoute( } + +namespace +{ + std::string MakeLocalBridgeKey(std::string const& from, std::string const& to) + { + return from + "->" + to; + } +} + +bool ClientlessTravelGraphMgr::FindNearestNodeToPosition( + uint32 mapId, + float x, + float y, + float z, + std::string& nodeKey, + float& resultDistance) const +{ + nodeKey.clear(); + resultDistance = std::numeric_limits::infinity(); + + for (auto const& pair : _nodes) + { + ClientlessTravelNode const& node = pair.second; + if (node.MapId != mapId) + continue; + + float const dx = node.X - x; + float const dy = node.Y - y; + float const dz = node.Z - z; + float const distance = std::sqrt(dx * dx + dy * dy + dz * dz); + if (distance >= resultDistance) + continue; + + resultDistance = distance; + nodeKey = node.Key; + } + + return !nodeKey.empty(); +} + +bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( + Player const* player, + std::string const& destinationNode, + float startRadius, + float bridgeRadius, + std::vector& route, + bool& complete, + std::string& reachedNode, + std::string& error, + std::unordered_set const* blockedEdgeIds, + std::unordered_set const* blockedBridgeKeys) +{ + 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 (startRadius <= 0.0f) + startRadius = 500.0f; + if (bridgeRadius <= 0.0f) + bridgeRadius = 300.0f; + + struct State + { + std::string Node; + bool LastWasBridge = false; + }; + + auto stateKey = [](State const& state) + { + return state.Node + (state.LastWasBridge ? "\x1f" "B" : "\x1f" "R"); + }; + + 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; + }; + + std::priority_queue, std::greater> open; + std::unordered_map distance; + std::unordered_map previous; + std::unordered_map states; + std::unordered_map persistentHops; + + uint32 startCount = 0; + for (auto const& pair : _nodes) + { + ClientlessTravelNode const& node = pair.second; + if (node.MapId != player->GetMapId()) + continue; + + float const dx = node.X - player->GetPositionX(); + float const dy = node.Y - player->GetPositionY(); + float const dz = node.Z - player->GetPositionZ(); + float const localDistance = std::sqrt(dx * dx + dy * dy + dz * dz); + if (localDistance > startRadius) + continue; + + State state{ node.Key, false }; + std::string const key = stateKey(state); + auto found = distance.find(key); + if (found != distance.end() && found->second <= localDistance) + continue; + + distance[key] = localDistance; + states[key] = state; + persistentHops[key] = 0; + open.push({ localDistance, state }); + ++startCount; + } + + if (!startCount) + { + error = "no travel node near current position"; + return false; + } + + std::string bestGoalState; + + 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.Value.Node == destinationNode) + { + bestGoalState = currentKey; + break; + } + + // Persistent TravelGraph edges always reset the bridge guard. + auto edgeItr = _edges.find(current.Value.Node); + if (edgeItr != _edges.end()) + { + for (ClientlessTravelEdge const& edge : edgeItr->second) + { + if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId)) + continue; + + State next{ edge.ToNode, false }; + std::string const nextKey = stateKey(next); + float const candidate = current.Cost + edge.Cost; + + auto old = distance.find(nextKey); + if (old != distance.end() && candidate >= old->second) + continue; + + distance[nextKey] = candidate; + states[nextKey] = next; + persistentHops[nextKey] = persistentHops[currentKey] + 1; + previous[nextKey] = { + currentKey, current.Value.Node, edge.Type, + edge.DatabaseId, edge.Reverse + }; + open.push({ candidate, next }); + } + } + + // 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 (current.Value.LastWasBridge) + continue; + + ClientlessTravelNode const& from = _nodes.at(current.Value.Node); + for (auto const& pair : _nodes) + { + ClientlessTravelNode const& to = pair.second; + if (to.Key == from.Key || to.MapId != from.MapId) + continue; + + float const dx = to.X - from.X; + float const dy = to.Y - from.Y; + float const dz = to.Z - from.Z; + float const direct = std::sqrt(dx * dx + dy * dy + dz * dz); + if (direct > bridgeRadius) + continue; + + std::string const bridgeKey = MakeLocalBridgeKey(from.Key, to.Key); + if (blockedBridgeKeys && blockedBridgeKeys->count(bridgeKey)) + continue; + + // 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); + + // Large fixed penalty makes a real graph path preferable whenever + // one exists; distance then ranks alternative local bridges. + float const candidate = current.Cost + 1000.0f + direct; + 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 }); + } + } + + std::string selectedState = bestGoalState; + if (!selectedState.empty()) + complete = true; + + if (selectedState.empty()) + { + ClientlessTravelNode const& destination = destinationItr->second; + float bestSameMapDistance = std::numeric_limits::infinity(); + uint32 bestPersistentHops = 0; + float bestCost = std::numeric_limits::infinity(); + + // Prefer any reachable point on the destination map. This gives a + // meaningful geographic partial route (e.g. Ironforge when Kharanos + // itself is disconnected). If the destination map was never reached, + // fall back to the state that made the most progress over real edges. + for (auto const& pair : states) + { + auto distItr = distance.find(pair.first); + if (distItr == distance.end() || !std::isfinite(distItr->second)) + continue; + + ClientlessTravelNode const& node = _nodes.at(pair.second.Node); + 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; + + 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; + }; + + std::vector reversed; + std::string cursor = selectedState; + while (true) + { + State const& state = states.at(cursor); + auto prevItr = previous.find(cursor); + if (prevItr == previous.end()) + { + reversed.push_back({ state, "start", 0, false }); + break; + } + + reversed.push_back({ + state, + prevItr->second.EdgeType, + prevItr->second.EdgeDatabaseId, + prevItr->second.EdgeReverse + }); + cursor = prevItr->second.StateKey; + } + + std::reverse(reversed.begin(), reversed.end()); + route.reserve(reversed.size()); + + for (Reconstructed const& item : reversed) + { + ClientlessTravelNode const& node = _nodes.at(item.Value.Node); + ClientlessTravelRouteStep step; + step.NodeKey = node.Key; + step.NodeType = node.Type; + step.WorldPointKey = node.WorldPointKey; + step.EdgeType = item.IncomingEdgeType; + step.EdgeDatabaseId = item.EdgeDatabaseId; + step.EdgeReverse = item.EdgeReverse; + 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 (!complete) + error = "destination not fully reachable; returning best-effort partial route"; + + return !route.empty(); +} + bool ClientlessTravelGraphMgr::PlanRouteToPosition( Player const* player, uint32 destinationMapId, @@ -339,7 +720,8 @@ bool ClientlessTravelGraphMgr::PlanRouteToPosition( float anchorRadius, std::vector& route, std::string& anchorNode, - std::string& error) + std::string& error, + std::unordered_set const* blockedEdgeIds) { route.clear(); anchorNode.clear(); @@ -429,6 +811,11 @@ bool ClientlessTravelGraphMgr::PlanRouteToPosition( for (ClientlessTravelEdge const& edge : edgeItr->second) { + if (blockedEdgeIds && + edge.DatabaseId && + blockedEdgeIds->find(edge.DatabaseId) != blockedEdgeIds->end()) + continue; + float const candidate = current.Cost + edge.Cost; if (candidate >= distance[edge.ToNode]) continue; diff --git a/src/server/game/World/ClientlessTravelGraphMgr.h b/src/server/game/World/ClientlessTravelGraphMgr.h index b9afe7d024..9b796f7c59 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.h +++ b/src/server/game/World/ClientlessTravelGraphMgr.h @@ -14,6 +14,7 @@ #include #include +#include #include class Player; @@ -86,7 +87,8 @@ public: std::string const& destinationNode, float startRadius, std::vector& route, - std::string& error); + std::string& error, + std::unordered_set const* blockedEdgeIds = nullptr); bool PlanRouteToPosition( Player const* player, @@ -98,7 +100,33 @@ public: float anchorRadius, std::vector& route, std::string& anchorNode, - std::string& error); + std::string& error, + std::unordered_set const* blockedEdgeIds = nullptr); + + // C8.13b: transient local bridges connect nearby graph islands for one + // route plan. Bridges are never persisted and may never follow another + // bridge directly; at least one real TravelGraph edge must lie between + // two local bridges. If the destination remains unreachable, a useful + // partial route is returned when possible. + bool PlanAdaptiveRoute( + Player const* player, + std::string const& destinationNode, + float startRadius, + float bridgeRadius, + std::vector& route, + bool& complete, + std::string& reachedNode, + std::string& error, + std::unordered_set const* blockedEdgeIds = nullptr, + std::unordered_set const* blockedBridgeKeys = nullptr); + + bool FindNearestNodeToPosition( + uint32 mapId, + float x, + float y, + float z, + std::string& nodeKey, + float& distance) const; uint32 GetNodeCount() const { return uint32(_nodes.size()); } uint32 GetEdgeCount() const { return _edgeCount; } diff --git a/src/server/game/World/ClientlessTravelMgr.cpp b/src/server/game/World/ClientlessTravelMgr.cpp index 8eeaaf4e2f..773cefaaa5 100644 --- a/src/server/game/World/ClientlessTravelMgr.cpp +++ b/src/server/game/World/ClientlessTravelMgr.cpp @@ -1,4 +1,5 @@ #include "ClientlessTravelMgr.h" +#include "ClientlessLuaEngine.h" #include "Log.h" #include "MotionMaster.h" @@ -9,6 +10,7 @@ #include #include +#include namespace { @@ -16,7 +18,42 @@ namespace constexpr float MIN_PROGRESS_DISTANCE = 0.5f; constexpr uint32 PROGRESS_CHECK_INTERVAL_MS = 1000; constexpr uint32 REPATH_AFTER_NO_PROGRESS_MS = 5000; - constexpr uint32 MAX_REPATHS = 3; + constexpr uint32 MAX_REPATHS = 5; + + constexpr float MAX_LOCAL_SPLINE_LENGTH = 45.0f; + constexpr float MAX_PATH_FINAL_ERROR = 3.0f; + constexpr float MAX_EXECUTION_DEVIATION = 8.0f; + constexpr float MAX_SEGMENT_END_ERROR = 2.5f; + + float Distance3D(float ax, float ay, float az, float bx, float by, float bz) + { + float const dx = bx - ax; + float const dy = by - ay; + float const dz = bz - az; + return std::sqrt(dx * dx + dy * dy + dz * dz); + } + + float DistancePointToSegment(float px, float py, float pz, Position const& a, Position const& b) + { + float const abx = b.GetPositionX() - a.GetPositionX(); + float const aby = b.GetPositionY() - a.GetPositionY(); + float const abz = b.GetPositionZ() - a.GetPositionZ(); + float const apx = px - a.GetPositionX(); + float const apy = py - a.GetPositionY(); + float const apz = pz - a.GetPositionZ(); + + float const len2 = abx * abx + aby * aby + abz * abz; + if (len2 <= 0.0001f) + return Distance3D(px, py, pz, a.GetPositionX(), a.GetPositionY(), a.GetPositionZ()); + + float t = (apx * abx + apy * aby + apz * abz) / len2; + t = std::max(0.0f, std::min(1.0f, t)); + + float const qx = a.GetPositionX() + abx * t; + float const qy = a.GetPositionY() + aby * t; + float const qz = a.GetPositionZ() + abz * t; + return Distance3D(px, py, pz, qx, qy, qz); + } } ClientlessTravelMgr* ClientlessTravelMgr::instance() @@ -29,11 +66,12 @@ char const* ClientlessTravelMgr::StateName(ClientlessTravelState state) { switch (state) { - case ClientlessTravelState::Idle: return "Idle"; - case ClientlessTravelState::Moving: return "Moving"; - case ClientlessTravelState::Arrived: return "Arrived"; - case ClientlessTravelState::Stuck: return "Stuck"; - case ClientlessTravelState::InvalidMap: return "InvalidMap"; + case ClientlessTravelState::Idle: return "Idle"; + case ClientlessTravelState::Moving: return "Moving"; + case ClientlessTravelState::Arrived: return "Arrived"; + case ClientlessTravelState::Stuck: return "Stuck"; + case ClientlessTravelState::InvalidMap: return "InvalidMap"; + case ClientlessTravelState::PathRejected: return "PathRejected"; } return "Unknown"; @@ -59,10 +97,7 @@ bool ClientlessTravelMgr::AnalyzePath( float const ty = target.GetPositionY(); float const tz = target.GetPositionZ(); - float const ddx = tx - sx; - float const ddy = ty - sy; - float const ddz = tz - sz; - diagnostics.DirectDistance = std::sqrt(ddx * ddx + ddy * ddy + ddz * ddz); + diagnostics.DirectDistance = Distance3D(sx, sy, sz, tx, ty, tz); PathGenerator path(player); diagnostics.Calculated = path.CalculatePath(tx, ty, tz); @@ -75,21 +110,16 @@ bool ClientlessTravelMgr::AnalyzePath( { for (std::size_t i = 1; i < points.size(); ++i) { - float const dx = points[i].x - points[i - 1].x; - float const dy = points[i].y - points[i - 1].y; - float const dz = points[i].z - points[i - 1].z; - float const segment = std::sqrt(dx * dx + dy * dy + dz * dz); - + float const segment = Distance3D( + points[i - 1].x, points[i - 1].y, points[i - 1].z, + points[i].x, points[i].y, points[i].z); diagnostics.PathLength += segment; diagnostics.MaxSegmentLength = std::max(diagnostics.MaxSegmentLength, segment); - diagnostics.MaxZDelta = std::max(diagnostics.MaxZDelta, std::fabs(dz)); + diagnostics.MaxZDelta = std::max(diagnostics.MaxZDelta, std::fabs(points[i].z - points[i - 1].z)); } auto const& last = points.back(); - float const dx = last.x - tx; - float const dy = last.y - ty; - float const dz = last.z - tz; - diagnostics.FinalDistance = std::sqrt(dx * dx + dy * dy + dz * dz); + diagnostics.FinalDistance = Distance3D(last.x, last.y, last.z, tx, ty, tz); } else diagnostics.FinalDistance = diagnostics.DirectDistance; @@ -120,7 +150,7 @@ bool ClientlessTravelMgr::AnalyzePath( addReason("large-segment"); if (diagnostics.MaxZDelta > 8.0f) addReason("large-z-delta"); - if (diagnostics.FinalDistance > 3.0f) + if (diagnostics.FinalDistance > MAX_PATH_FINAL_ERROR) addReason("far-from-target"); if (diagnostics.DirectDistance > 5.0f && diagnostics.PathLength > diagnostics.DirectDistance * 8.0f) addReason("extreme-detour"); @@ -131,79 +161,198 @@ bool ClientlessTravelMgr::AnalyzePath( return diagnostics.Calculated; } -void ClientlessTravelMgr::LaunchPath(Player* player, TravelRecord& record) +float ClientlessTravelMgr::DistanceFromActiveSpline(Player const* player, TravelRecord const& record) const { - if (!player) - return; + if (!player || record.ActiveSpline.empty()) + return 0.0f; + + if (record.ActiveSpline.size() == 1) + { + Position const& p = record.ActiveSpline.front(); + return Distance3D( + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + p.GetPositionX(), p.GetPositionY(), p.GetPositionZ()); + } + + float best = std::numeric_limits::max(); + for (std::size_t i = 1; i < record.ActiveSpline.size(); ++i) + best = std::min(best, DistancePointToSegment( + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + record.ActiveSpline[i - 1], record.ActiveSpline[i])); + + return best; +} + +bool ClientlessTravelMgr::LaunchPath(Player* player, TravelRecord& record) +{ + if (!player || !player->IsInWorld()) + return false; player->AttackStop(); player->CastStop(); player->StopMoving(); player->GetMotionMaster()->Clear(); + record.ActiveSpline.clear(); Position const& target = record.Status.Target; AnalyzePath(player, target, record.Status.Path); ClientlessPathDiagnostics const& nav = record.Status.Path; + record.Status.NavSequence = _nextNavSequence++; - if (nav.Suspicious) + TC_LOG_INFO("server.adventurebot.navigation", + "NAV_PLAN seq=%llu guid=%u name='%s' map=%u segment=%u " + "start=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f) " + "type=0x%X calculated=%u complete=%u suspicious=%u points=%u " + "direct=%.2f path=%.2f maxSegment=%.2f maxZDelta=%.2f finalDistance=%.2f " + "repaths=%u reason='%s'", + static_cast(record.Status.NavSequence), + player->GetGUID().GetCounter(), player->GetName().c_str(), player->GetMapId(), + record.Status.SegmentIndex, + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(), + nav.PathType, nav.Calculated ? 1u : 0u, nav.Complete ? 1u : 0u, + nav.Suspicious ? 1u : 0u, nav.PointCount, + nav.DirectDistance, nav.PathLength, nav.MaxSegmentLength, + nav.MaxZDelta, nav.FinalDistance, record.Status.RepathCount, + nav.Reason.c_str()); + + if (!nav.Calculated || !nav.Complete || nav.PointCount < 2 || nav.FinalDistance > MAX_PATH_FINAL_ERROR) { - TC_LOG_WARN("server.adventurebot", - "[AdventureBot][Nav] path-suspicious guid=%u name='%s' map=%u " - "start=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f) " - "type=0x%X calculated=%u complete=%u points=%u direct=%.2f path=%.2f " - "maxSegment=%.2f maxZDelta=%.2f finalDistance=%.2f repath=%u reason='%s'", + record.Status.State = ClientlessTravelState::PathRejected; + record.Status.NoProgressMs = 0; + + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_REJECT seq=%llu guid=%u name='%s' map=%u target=(%.3f %.3f %.3f) " + "type=0x%X points=%u finalDistance=%.2f reason='%s'", + static_cast(record.Status.NavSequence), player->GetGUID().GetCounter(), player->GetName().c_str(), player->GetMapId(), - player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(), - nav.PathType, nav.Calculated ? 1u : 0u, nav.Complete ? 1u : 0u, - nav.PointCount, nav.DirectDistance, nav.PathLength, - nav.MaxSegmentLength, nav.MaxZDelta, nav.FinalDistance, - record.Status.RepathCount, nav.Reason.c_str()); + nav.PathType, nav.PointCount, nav.FinalDistance, nav.Reason.c_str()); + return false; } + + PathGenerator path(player); + if (!path.CalculatePath(target.GetPositionX(), target.GetPositionY(), target.GetPositionZ())) + { + record.Status.State = ClientlessTravelState::PathRejected; + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_REJECT seq=%llu guid=%u name='%s' reason='execution-path-calc-failed'", + static_cast(record.Status.NavSequence), + player->GetGUID().GetCounter(), player->GetName().c_str()); + return false; + } + + auto const& points = path.GetPath(); + uint32 const executionType = uint32(path.GetPathType()); + if (points.size() < 2 || + (executionType & uint32(PATHFIND_NOPATH)) != 0 || + (executionType & uint32(PATHFIND_INCOMPLETE)) != 0) + { + record.Status.State = ClientlessTravelState::PathRejected; + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_REJECT seq=%llu guid=%u name='%s' reason='execution-path-not-complete' type=0x%X points=%u", + static_cast(record.Status.NavSequence), + player->GetGUID().GetCounter(), player->GetName().c_str(), + executionType, uint32(points.size())); + return false; + } + + std::vector spline; + spline.reserve(points.size() + 1); + + Position current; + current.Relocate( + player->GetPositionX(), player->GetPositionY(), + player->GetPositionZ(), player->GetOrientation()); + spline.push_back(current); + + float lastX = current.GetPositionX(); + float lastY = current.GetPositionY(); + float lastZ = current.GetPositionZ(); + float accumulated = 0.0f; + + for (std::size_t i = 1; i < points.size(); ++i) + { + float const px = points[i].x; + float const py = points[i].y; + float const pz = points[i].z; + float const segment = Distance3D(lastX, lastY, lastZ, px, py, pz); + + if (segment <= 0.05f) + continue; + + if (accumulated + segment > MAX_LOCAL_SPLINE_LENGTH) + { + float const remaining = MAX_LOCAL_SPLINE_LENGTH - accumulated; + float const t = std::max(0.0f, std::min(1.0f, remaining / segment)); + + Position partial; + partial.Relocate( + lastX + (px - lastX) * t, + lastY + (py - lastY) * t, + lastZ + (pz - lastZ) * t, + player->GetOrientation()); + spline.push_back(partial); + accumulated = MAX_LOCAL_SPLINE_LENGTH; + break; + } + + Position p; + p.Relocate(px, py, pz, player->GetOrientation()); + spline.push_back(p); + accumulated += segment; + lastX = px; + lastY = py; + lastZ = pz; + + if (accumulated >= MAX_LOCAL_SPLINE_LENGTH) + break; + } + + if (spline.size() < 2) + { + record.Status.State = ClientlessTravelState::PathRejected; + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_REJECT seq=%llu guid=%u name='%s' reason='no-usable-spline-points'", + static_cast(record.Status.NavSequence), + player->GetGUID().GetCounter(), player->GetName().c_str()); + return false; + } + + record.ActiveSpline = spline; + ++record.Status.SegmentIndex; + + if (record.AllowGroundMount) + sClientlessLuaEngine->PrepareGroundMount(player, nav.DirectDistance, "local-ground"); else - { - TC_LOG_INFO("server.adventurebot", - "[AdventureBot][Nav] path guid=%u name='%s' map=%u " - "type=0x%X points=%u direct=%.2f path=%.2f maxSegment=%.2f " - "maxZDelta=%.2f finalDistance=%.2f repath=%u", - player->GetGUID().GetCounter(), player->GetName().c_str(), player->GetMapId(), - nav.PathType, nav.PointCount, nav.DirectDistance, nav.PathLength, - nav.MaxSegmentLength, nav.MaxZDelta, nav.FinalDistance, - record.Status.RepathCount); - } + sClientlessLuaEngine->DismountGroundMount(player, "local-ground-disabled"); - PathGenerator debugPath(player); - if (debugPath.CalculatePath(target.GetPositionX(), target.GetPositionY(), target.GetPositionZ())) - { - auto const& debugPoints = debugPath.GetPath(); - for (std::size_t i = 0; i < debugPoints.size(); ++i) - TC_LOG_DEBUG("server.adventurebot", - "[AdventureBot][Nav] path-point guid=%u repath=%u index=%u/%u xyz=(%.3f %.3f %.3f)", - player->GetGUID().GetCounter(), record.Status.RepathCount, - uint32(i + 1), uint32(debugPoints.size()), - debugPoints[i].x, debugPoints[i].y, debugPoints[i].z); - } + player->GetMotionMaster()->MoveSmoothPath( + 1, record.ActiveSpline.data(), record.ActiveSpline.size(), false); - player->GetMotionMaster()->MovePoint( - 1, - target.GetPositionX(), - target.GetPositionY(), - target.GetPositionZ(), - true); + Position const& segmentEnd = record.ActiveSpline.back(); + TC_LOG_INFO("server.adventurebot.navigation", + "NAV_EXEC seq=%llu guid=%u name='%s' segment=%u splinePoints=%u " + "splineLength=%.2f segmentEnd=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f)", + static_cast(record.Status.NavSequence), + player->GetGUID().GetCounter(), player->GetName().c_str(), + record.Status.SegmentIndex, uint32(record.ActiveSpline.size()), accumulated, + segmentEnd.GetPositionX(), segmentEnd.GetPositionY(), segmentEnd.GetPositionZ(), + target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()); + record.Status.State = ClientlessTravelState::Moving; record.LastDistance = player->GetDistance( - target.GetPositionX(), - target.GetPositionY(), - target.GetPositionZ()); + target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()); record.Status.RemainingDistance = record.LastDistance; record.Status.NoProgressMs = 0; record.LastObservedPosition.Relocate(player->GetPosition()); record.HasLastObservedPosition = true; record.CheckTimerMs = PROGRESS_CHECK_INTERVAL_MS; + return true; } -bool ClientlessTravelMgr::StartTravel(Player* player, Position const& target) +bool ClientlessTravelMgr::StartTravel(Player* player, Position const& target, bool allowGroundMount) { if (!player || !player->IsInWorld()) return false; @@ -211,21 +360,19 @@ bool ClientlessTravelMgr::StartTravel(Player* player, Position const& target) uint32 const characterGuid = player->GetGUID().GetCounter(); TravelRecord& record = _records[characterGuid]; - record.Status = {}; + record = {}; record.Status.State = ClientlessTravelState::Moving; record.Status.MapId = player->GetMapId(); record.Status.Target = target; + record.AllowGroundMount = allowGroundMount; LaunchPath(player, record); - TC_LOG_INFO("server.worldserver", - "ClientlessTravel started character GUID %u map=%u target=(%.3f, %.3f, %.3f) distance=%.2f.", - characterGuid, - record.Status.MapId, - target.GetPositionX(), - target.GetPositionY(), - target.GetPositionZ(), - record.Status.RemainingDistance); + TC_LOG_INFO("server.adventurebot.navigation", + "NAV_START guid=%u name='%s' map=%u target=(%.3f %.3f %.3f) remaining=%.2f state=%s", + characterGuid, player->GetName().c_str(), record.Status.MapId, + target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(), + record.Status.RemainingDistance, StateName(record.Status.State)); return true; } @@ -265,14 +412,13 @@ void ClientlessTravelMgr::Update(uint32 diff) if (player->GetMapId() != record.Status.MapId) { record.Status.State = ClientlessTravelState::InvalidMap; - TC_LOG_WARN("server.worldserver", - "ClientlessTravel character GUID %u changed map during point travel (expected=%u actual=%u).", - characterGuid, record.Status.MapId, player->GetMapId()); + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_INVALID_MAP guid=%u name='%s' expected=%u actual=%u", + characterGuid, player->GetName().c_str(), + record.Status.MapId, player->GetMapId()); continue; } - // AdventureBot maintenance diagnostics. Keep Trinity's recovery behavior, - // but record suspicious large position discontinuities for route analysis. if (record.HasLastObservedPosition) { float const observedJump = player->GetExactDist( @@ -281,10 +427,10 @@ void ClientlessTravelMgr::Update(uint32 diff) record.LastObservedPosition.GetPositionZ()); if (observedJump >= 15.0f) - { - TC_LOG_WARN("server.adventurebot", - "[AdventureBot] movement-recovery-jump guid=%u name='%s' map=%u jump=%.2f " + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_ANOMALY seq=%llu guid=%u name='%s' kind='position-jump' map=%u jump=%.2f " "from=(%.3f %.3f %.3f) to=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f) repaths=%u", + static_cast(record.Status.NavSequence), characterGuid, player->GetName().c_str(), player->GetMapId(), observedJump, record.LastObservedPosition.GetPositionX(), record.LastObservedPosition.GetPositionY(), @@ -294,7 +440,6 @@ void ClientlessTravelMgr::Update(uint32 diff) record.Status.Target.GetPositionY(), record.Status.Target.GetPositionZ(), record.Status.RepathCount); - } } record.LastObservedPosition.Relocate(player->GetPosition()); @@ -302,26 +447,85 @@ void ClientlessTravelMgr::Update(uint32 diff) Position const& target = record.Status.Target; float const distance = player->GetDistance( - target.GetPositionX(), - target.GetPositionY(), - target.GetPositionZ()); + target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()); record.Status.RemainingDistance = distance; - // C8.2 arrival fix: - // Do not accept "close enough" while the point movement is still active. - // A bot blocked by geometry may get very near the target without actually - // reaching it. Requiring a tight tolerance plus a stopped movement state - // lets that case fall through into normal no-progress/repath/stuck handling. - if (distance <= ARRIVAL_DISTANCE && player->IsStopped()) + float const deviation = DistanceFromActiveSpline(player, record); + record.Status.MaxObservedDeviation = std::max(record.Status.MaxObservedDeviation, deviation); + + TC_LOG_DEBUG("server.adventurebot.navigation", + "NAV_SAMPLE seq=%llu guid=%u segment=%u pos=(%.3f %.3f %.3f) " + "remaining=%.2f deviation=%.2f splineActive=%u repaths=%u", + static_cast(record.Status.NavSequence), + characterGuid, record.Status.SegmentIndex, + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + distance, deviation, player->IsSplineEnabled() ? 1u : 0u, + record.Status.RepathCount); + + if (deviation > MAX_EXECUTION_DEVIATION) + { + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_ANOMALY seq=%llu guid=%u name='%s' kind='left-planned-path' " + "deviation=%.2f remaining=%.2f repaths=%u", + static_cast(record.Status.NavSequence), + characterGuid, player->GetName().c_str(), + deviation, distance, record.Status.RepathCount); + + if (record.Status.RepathCount >= MAX_REPATHS) + { + record.Status.State = ClientlessTravelState::Stuck; + player->StopMoving(); + player->GetMotionMaster()->Clear(); + continue; + } + + ++record.Status.RepathCount; + LaunchPath(player, record); + continue; + } + + if (distance <= ARRIVAL_DISTANCE && !player->IsSplineEnabled()) { record.Status.State = ClientlessTravelState::Arrived; record.Status.NoProgressMs = 0; - TC_LOG_INFO("server.worldserver", - "ClientlessTravel character GUID %u arrived (remaining=%.2f, repaths=%u).", - characterGuid, distance, record.Status.RepathCount); + TC_LOG_INFO("server.adventurebot.navigation", + "NAV_COMPLETE seq=%llu guid=%u name='%s' remaining=%.2f repaths=%u " + "segments=%u maxDeviation=%.2f", + static_cast(record.Status.NavSequence), + characterGuid, player->GetName().c_str(), distance, + record.Status.RepathCount, record.Status.SegmentIndex, + record.Status.MaxObservedDeviation); continue; } + if (!player->IsSplineEnabled() && !record.ActiveSpline.empty()) + { + Position const& segmentEnd = record.ActiveSpline.back(); + float const endError = Distance3D( + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + segmentEnd.GetPositionX(), segmentEnd.GetPositionY(), segmentEnd.GetPositionZ()); + + if (endError <= MAX_SEGMENT_END_ERROR) + { + TC_LOG_INFO("server.adventurebot.navigation", + "NAV_SEGMENT_COMPLETE seq=%llu guid=%u name='%s' segment=%u " + "endError=%.2f remaining=%.2f", + static_cast(record.Status.NavSequence), + characterGuid, player->GetName().c_str(), + record.Status.SegmentIndex, endError, distance); + + LaunchPath(player, record); + continue; + } + + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_ANOMALY seq=%llu guid=%u name='%s' kind='spline-stopped-early' " + "segment=%u endError=%.2f remaining=%.2f", + static_cast(record.Status.NavSequence), + characterGuid, player->GetName().c_str(), + record.Status.SegmentIndex, endError, distance); + } + float const progress = record.LastDistance - distance; if (progress >= MIN_PROGRESS_DISTANCE) record.Status.NoProgressMs = 0; @@ -337,12 +541,12 @@ void ClientlessTravelMgr::Update(uint32 diff) { record.Status.State = ClientlessTravelState::Stuck; player->StopMoving(); - TC_LOG_WARN("server.worldserver", - "ClientlessTravel character GUID %u is stuck (remaining=%.2f, repaths=%u).", - characterGuid, distance, record.Status.RepathCount); - TC_LOG_WARN("server.adventurebot", - "[AdventureBot][Nav] stuck guid=%u name='%s' map=%u remaining=%.2f " - "repaths=%u lastPathType=0x%X lastPathPoints=%u lastReason='%s'", + player->GetMotionMaster()->Clear(); + + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_STUCK seq=%llu guid=%u name='%s' map=%u remaining=%.2f repaths=%u " + "lastPathType=0x%X lastPathPoints=%u lastReason='%s'", + static_cast(record.Status.NavSequence), characterGuid, player->GetName().c_str(), player->GetMapId(), distance, record.Status.RepathCount, record.Status.Path.PathType, record.Status.Path.PointCount, record.Status.Path.Reason.c_str()); @@ -350,14 +554,10 @@ void ClientlessTravelMgr::Update(uint32 diff) } ++record.Status.RepathCount; - - TC_LOG_INFO("server.worldserver", - "ClientlessTravel repathing character GUID %u (remaining=%.2f, repath=%u/%u).", - characterGuid, distance, record.Status.RepathCount, MAX_REPATHS); - - TC_LOG_WARN("server.adventurebot", - "[AdventureBot][Nav] repath guid=%u name='%s' map=%u remaining=%.2f " + TC_LOG_WARN("server.adventurebot.navigation", + "NAV_REPATH seq=%llu guid=%u name='%s' map=%u remaining=%.2f " "noProgressMs=%u repath=%u/%u reason='no-progress'", + static_cast(record.Status.NavSequence), characterGuid, player->GetName().c_str(), player->GetMapId(), distance, record.Status.NoProgressMs, record.Status.RepathCount, MAX_REPATHS); diff --git a/src/server/game/World/ClientlessTravelMgr.h b/src/server/game/World/ClientlessTravelMgr.h index 34cf521e4b..2b2f4ee70e 100644 --- a/src/server/game/World/ClientlessTravelMgr.h +++ b/src/server/game/World/ClientlessTravelMgr.h @@ -9,6 +9,7 @@ #include "Position.h" #include #include +#include struct TC_GAME_API ClientlessPathDiagnostics { @@ -33,7 +34,8 @@ enum class ClientlessTravelState : uint8 Moving, Arrived, Stuck, - InvalidMap + InvalidMap, + PathRejected }; struct TC_GAME_API ClientlessTravelStatus @@ -44,6 +46,9 @@ struct TC_GAME_API ClientlessTravelStatus float RemainingDistance = 0.0f; uint32 RepathCount = 0; uint32 NoProgressMs = 0; + uint64 NavSequence = 0; + uint32 SegmentIndex = 0; + float MaxObservedDeviation = 0.0f; ClientlessPathDiagnostics Path; }; @@ -52,7 +57,7 @@ class TC_GAME_API ClientlessTravelMgr public: static ClientlessTravelMgr* instance(); - bool StartTravel(Player* player, Position const& target); + bool StartTravel(Player* player, Position const& target, bool allowGroundMount = false); bool AnalyzePath(Player* player, Position const& target, ClientlessPathDiagnostics& diagnostics) const; void CancelTravel(uint32 characterGuid); ClientlessTravelStatus const* GetStatus(uint32 characterGuid) const; @@ -61,6 +66,7 @@ public: static char const* StateName(ClientlessTravelState state); private: + // C8.11b: hardened explicit MMAP local navigation. struct TravelRecord { ClientlessTravelStatus Status; @@ -68,11 +74,15 @@ private: Position LastObservedPosition; bool HasLastObservedPosition = false; uint32 CheckTimerMs = 0; + std::vector ActiveSpline; + bool AllowGroundMount = false; }; - void LaunchPath(Player* player, TravelRecord& record); + bool LaunchPath(Player* player, TravelRecord& record); + float DistanceFromActiveSpline(Player const* player, TravelRecord const& record) const; std::unordered_map _records; + uint64 _nextNavSequence = 1; }; #define sClientlessTravelMgr ClientlessTravelMgr::instance() diff --git a/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua b/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua index c10bd3db06..4fa368de31 100644 --- a/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua +++ b/src/server/scripts/Custom/clientless/adventure/overrides/arthoran.lua @@ -1,21 +1,18 @@ -- Arthoran completely overrides generic.lua. -log("Arthoran adventure override loaded") -intend("socialize", { city = "Ironforge", venue = "tavern", activity = "drink", purpose = "leisure" }) +log("Arthoran C8.13b bridge test loaded") --- C8.4 end-to-end semantic action test: --- select the nearest perceived vendor and visibly walk to it. --queue_action("approach", { -- capability = "vendor", -- max_distance = 40, -- approach_distance = 2 --}) -queue_action("travel_to_location", { - id = "kharanos_tavern" -}) --- C8.6 incremental travel-graph example. --- Enable after the corresponding graph nodes/edges exist in cata_world. --- --- queue_action("travel_to_node", { --- node = "stormwind_gate", --- start_radius = 500 --- }) + +--queue_action("travel_to_location", { +-- id = "kharanos_tavern" +--}) + +queue_action("travel_to_node", { + node = "portal_at_2173_target", + bridge_radius = 400, + bridge_path_max = 600 +}) \ No newline at end of file diff --git a/src/server/scripts/Custom/clientless/adventure/profiles/arthoran.lua b/src/server/scripts/Custom/clientless/adventure/profiles/arthoran.lua new file mode 100644 index 0000000000..abbd0fc410 --- /dev/null +++ b/src/server/scripts/Custom/clientless/adventure/profiles/arthoran.lua @@ -0,0 +1,43 @@ +-- Arthoran: first named AdventureBot character profile. +-- These values bias future weighted decisions; they do not prescribe a route. +return { + identity = { + name = "Arthoran", + home_region = "elwynn", + }, + + movement = { + can_ground_mount = true, + ground_mount_min_distance = 45, + ground_mount_speed_rate = 1.60, + -- [mount display ID] = random weight + ground_mounts = { + [2404] = 100, -- Brown Horse + }, + }, + + personality = { + sociability = 0.65, + adventurousness = 0.70, + wanderlust = 0.30, + tavern_affinity = 0.85, + wilderness_affinity = 0.65, + city_affinity = 0.35, + danger_tolerance = 0.70, + }, + + region_affinity = { + elwynn = 1.00, + stormwind = 0.70, + dun_morogh = 0.35, + blasted_lands = 0.65, + }, + + activity_affinity = { + tavern = 0.90, + hunting = 0.75, + travelling = 0.35, + city_visit = 0.35, + social = 0.60, + }, +} diff --git a/src/server/scripts/Custom/clientless/adventure/profiles/generic.lua b/src/server/scripts/Custom/clientless/adventure/profiles/generic.lua new file mode 100644 index 0000000000..8669b33795 --- /dev/null +++ b/src/server/scripts/Custom/clientless/adventure/profiles/generic.lua @@ -0,0 +1,25 @@ +-- C8.12 generic AdventureBot character profile. +-- Character data/capabilities, not a hard-coded daily route. +return { + identity = { + name = character_name, + home_region = "", + }, + + movement = { + can_ground_mount = true, + ground_mount_min_distance = 60, + ground_mount_speed_rate = 1.60, + ground_mounts = { + [2404] = 100, -- Brown Horse display ID + }, + }, + + personality = { + sociability = 0.50, + adventurousness = 0.50, + wanderlust = 0.50, + }, + region_affinity = {}, + activity_affinity = {}, +} diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index ea7e01bd69..faac6b0171 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -1313,6 +1313,215 @@ static bool HandleClientlessTaxiInfo(ChatHandler* handler, char const* args) return true; } +static bool HandleClientlessPortalImport(ChatHandler* handler, char const* /*args*/) +{ + uint32 imported = 0; + uint32 missingDbc = 0; + uint32 skippedSameMap = 0; + uint32 createdPoints = 0; + uint32 updatedPoints = 0; + uint32 createdNodes = 0; + uint32 updatedNodes = 0; + uint32 createdEdges = 0; + uint32 existingEdges = 0; + + QueryResult result = WorldDatabase.Query( + "SELECT ID, Name, target_map, target_position_x, target_position_y, " + "target_position_z, target_orientation " + "FROM areatrigger_teleport ORDER BY ID"); + + if (!result) + { + handler->SendSysMessage("Portal import failed: areatrigger_teleport is empty."); + return true; + } + + do + { + Field* fields = result->Fetch(); + uint32 const triggerId = fields[0].GetUInt32(); + uint32 const targetMap = fields[2].GetUInt16(); + float const targetX = fields[3].GetFloat(); + float const targetY = fields[4].GetFloat(); + float const targetZ = fields[5].GetFloat(); + float const targetO = fields[6].GetFloat(); + + AreaTriggerEntry const* source = sAreaTriggerStore.LookupEntry(triggerId); + if (!source) + { + ++missingDbc; + continue; + } + + // C8.13a deliberately imports map transitions only. Same-map AreaTrigger + // teleports require the clientless equivalent of MOVE_TELEPORT_ACK and + // are left for a later extension. The important world/instance/tram + // transitions all use a far map transfer. + if (uint32(source->ContinentID) == targetMap) + { + ++skippedSameMap; + continue; + } + + std::string const baseKey = "portal_at_" + std::to_string(triggerId); + std::string const sourceKey = baseKey + "_source"; + std::string const targetKey = baseKey + "_target"; + + auto upsertPoint = [&](std::string const& key, uint32 mapId, + float x, float y, float z, float o, + char const* side) + { + QueryResult exists = WorldDatabase.PQuery( + "SELECT id FROM vcore_clientless_world_point WHERE point_key='%s' LIMIT 1", + key.c_str()); + + if (exists) + { + WorldDatabase.DirectPExecute( + "UPDATE vcore_clientless_world_point " + "SET category='travel_portal',map=%u,position_x=%.6f,position_y=%.6f," + "position_z=%.6f,orientation=%.6f,enabled=1," + "comment='Auto-imported AreaTrigger %u %s' WHERE point_key='%s'", + mapId, x, y, z, o, triggerId, side, key.c_str()); + ++updatedPoints; + } + else + { + WorldDatabase.DirectPExecute( + "INSERT INTO vcore_clientless_world_point " + "(point_key,category,map,position_x,position_y,position_z,orientation,enabled,comment) " + "VALUES ('%s','travel_portal',%u,%.6f,%.6f,%.6f,%.6f,1," + "'Auto-imported AreaTrigger %u %s')", + key.c_str(), mapId, x, y, z, o, triggerId, side); + ++createdPoints; + } + }; + + auto upsertNode = [&](std::string const& key, char const* side) + { + QueryResult exists = WorldDatabase.PQuery( + "SELECT id FROM vcore_clientless_travel_node WHERE node_key='%s' LIMIT 1", + key.c_str()); + + if (exists) + { + WorldDatabase.DirectPExecute( + "UPDATE vcore_clientless_travel_node " + "SET node_type='portal',world_point_key='%s',enabled=1," + "comment='Auto-imported AreaTrigger %u %s' WHERE node_key='%s'", + key.c_str(), triggerId, side, key.c_str()); + ++updatedNodes; + } + else + { + WorldDatabase.DirectPExecute( + "INSERT INTO vcore_clientless_travel_node " + "(node_key,node_type,world_point_key,enabled,comment) " + "VALUES ('%s','portal','%s',1,'Auto-imported AreaTrigger %u %s')", + key.c_str(), key.c_str(), triggerId, side); + ++createdNodes; + } + }; + + upsertPoint(sourceKey, uint32(source->ContinentID), + source->Pos.X, source->Pos.Y, source->Pos.Z, 0.0f, "source"); + upsertPoint(targetKey, targetMap, + targetX, targetY, targetZ, targetO, "target"); + upsertNode(sourceKey, "source"); + upsertNode(targetKey, "target"); + + QueryResult edgeExists = WorldDatabase.PQuery( + "SELECT id FROM vcore_clientless_travel_edge " + "WHERE from_node_key='%s' AND to_node_key='%s' AND edge_type='portal' LIMIT 1", + sourceKey.c_str(), targetKey.c_str()); + + if (edgeExists) + ++existingEdges; + else + { + WorldDatabase.DirectPExecute( + "INSERT INTO vcore_clientless_travel_edge " + "(from_node_key,to_node_key,edge_type,cost,bidirectional,enabled,comment) " + "VALUES ('%s','%s','portal',1,0,1,'Auto-imported AreaTrigger %u')", + sourceKey.c_str(), targetKey.c_str(), triggerId); + ++createdEdges; + } + + ++imported; + } + while (result->NextRow()); + + bool const graphLoaded = sClientlessTravelGraphMgr->Reload(); + sClientlessWorldPointMgr->Reload(); + + handler->PSendSysMessage( + "Portal import complete: transitions=%u points(created=%u updated=%u) " + "nodes(created=%u updated=%u) edges(created=%u existing=%u) " + "skipped(sameMap=%u missingDBC=%u).", + imported, createdPoints, updatedPoints, createdNodes, updatedNodes, + createdEdges, existingEdges, skippedSameMap, missingDbc); + + handler->PSendSysMessage( + "Travel graph reloaded: %u node(s), %u directed edge(s)%s. " + "C8.13a executes far-map AreaTrigger portal edges with the normal Trinity handler.", + sClientlessTravelGraphMgr->GetNodeCount(), + sClientlessTravelGraphMgr->GetEdgeCount(), + graphLoaded ? "" : " (no usable nodes)"); + + return true; +} + +static bool HandleClientlessPortalInfo(ChatHandler* handler, char const* args) +{ + std::istringstream input(args ? args : ""); + uint32 triggerId = 0; + input >> triggerId; + + if (!triggerId) + { + handler->SendSysMessage("Usage: .clientless portal info "); + return true; + } + + AreaTriggerEntry const* source = sAreaTriggerStore.LookupEntry(triggerId); + if (!source) + { + handler->PSendSysMessage("AreaTrigger %u does not exist in AreaTrigger.dbc.", triggerId); + return true; + } + + QueryResult result = WorldDatabase.PQuery( + "SELECT Name,target_map,target_position_x,target_position_y,target_position_z,target_orientation " + "FROM areatrigger_teleport WHERE ID=%u LIMIT 1", + triggerId); + + if (!result) + { + handler->PSendSysMessage( + "AreaTrigger %u: source map=%u pos=(%.2f %.2f %.2f), but it has no areatrigger_teleport row.", + triggerId, uint32(source->ContinentID), source->Pos.X, source->Pos.Y, source->Pos.Z); + return true; + } + + Field* fields = result->Fetch(); + std::string const name = fields[0].GetString(); + uint32 const targetMap = fields[1].GetUInt16(); + + handler->PSendSysMessage( + "AreaTrigger %u '%s': source map=%u pos=(%.2f %.2f %.2f) radius=%.2f box=(%.2f %.2f %.2f yaw=%.2f)", + triggerId, name.c_str(), uint32(source->ContinentID), + source->Pos.X, source->Pos.Y, source->Pos.Z, source->Radius, + source->Box_length, source->Box_width, source->Box_height, source->Box_yaw); + + handler->PSendSysMessage( + " target map=%u pos=(%.2f %.2f %.2f o=%.2f) keys=portal_at_%u_source -> portal_at_%u_target%s", + targetMap, fields[2].GetFloat(), fields[3].GetFloat(), fields[4].GetFloat(), fields[5].GetFloat(), + triggerId, triggerId, + uint32(source->ContinentID) == targetMap ? " [same-map: not imported by C8.13a]" : ""); + + return true; +} + static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*args*/) { bool const loaded = sClientlessTravelGraphMgr->Reload(); @@ -1960,6 +2169,10 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg { "import", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiImport, "" }, { "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiInfo, "" }, } }, + { "portal", rbac::RBAC_PERM_COMMAND_GM, false, nullptr, "", { + { "import", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessPortalImport, "" }, + { "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessPortalInfo, "" }, + } }, { "travelreload", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTravelReload, "" }, };