From 856832e7bd427c94fbadc7c6515dd0132e53ee00 Mon Sep 17 00:00:00 2001 From: nico Date: Sun, 1 Nov 2026 01:20:33 +0100 Subject: [PATCH] Improve free-flight recovery and fallback handling --- .../World/ClientlessActionExecutorMgr.cpp | 12 +++-- .../game/World/ClientlessActionExecutorMgr.h | 1 + src/server/game/World/ClientlessFlightMgr.cpp | 44 +++++++++++++++++-- src/server/game/World/ClientlessFlightMgr.h | 4 +- src/server/game/World/ClientlessFlightPath.h | 21 +++++++++ src/server/worldserver/worldserver.conf.dist | 18 ++++++++ tests/clientless/test_free_flight.py | 16 +++++++ 7 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/server/game/World/ClientlessActionExecutorMgr.cpp b/src/server/game/World/ClientlessActionExecutorMgr.cpp index b064a1d68a..40c360969d 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.cpp +++ b/src/server/game/World/ClientlessActionExecutorMgr.cpp @@ -206,6 +206,12 @@ ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(ui return itr != _records.end() ? &itr->second.Status : nullptr; } +bool ClientlessActionExecutorMgr::OwnsActiveFlight(uint32 characterGuid) const +{ + auto itr = _records.find(characterGuid); + return itr != _records.end() && itr->second.Active && itr->second.FlightActive; +} + bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record) { record.StopAtNode = false;record.FormationWaiting=false;record.GroundChunk=false; @@ -1025,7 +1031,7 @@ bool ClientlessActionExecutorMgr::StartRouteStep( } Position target;target.Relocate(step.X,step.Y,step.Z,step.O); if (!validSource || step.MapId!=player->GetMapId() || - !sClientlessFlightMgr->Start(player,target,error)) + !sClientlessFlightMgr->Start(player,target,error,true)) { ClientlessTravelTelemetry::Note(player,"flight-start-rejected",error+" "+ClientlessTravelTelemetry::State(player)); return RecoverFlightLeg(player,characterGuid,record,error.empty()?"free flight source not physically reached":error); @@ -1916,7 +1922,7 @@ bool ClientlessActionExecutorMgr::TryFallbackFlight(Player* player, uint32 chara if (!std::isfinite(sClientlessFlightMgr->Estimate(player, goal.MapId, from, target, true))) return false; std::string error; - if (!sClientlessFlightMgr->Start(player, target, error)) + if (!sClientlessFlightMgr->Start(player, target, error, true)) { ClientlessTravelTelemetry::Note(player,"flight-fallback-rejected",error+" "+ClientlessTravelTelemetry::State(player)); TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_FALLBACK_UNAVAILABLE guid=%u reason='%s'", @@ -1998,7 +2004,7 @@ void ClientlessActionExecutorMgr::CheckGroundExit(Player* player, uint32 charact if (std::isfinite(sClientlessFlightMgr->Estimate(player, goal.MapId, from, target))) { std::string error; - if (sClientlessFlightMgr->Start(player, target, error)) + if (sClientlessFlightMgr->Start(player, target, error, true)) { record.GroundExitActive = true; record.FlightActive = true; diff --git a/src/server/game/World/ClientlessActionExecutorMgr.h b/src/server/game/World/ClientlessActionExecutorMgr.h index 61aac66e94..20a493302b 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.h +++ b/src/server/game/World/ClientlessActionExecutorMgr.h @@ -50,6 +50,7 @@ public: void SetGroundPathAdapter(uint32 guid,GroundPathAdapter adapter); bool RequestStopAtNode(uint32 characterGuid); ClientlessActionExecutionStatus const* GetStatus(uint32 characterGuid) const; + bool OwnsActiveFlight(uint32 characterGuid) const; static char const* StateName(ClientlessActionExecutionState state); diff --git a/src/server/game/World/ClientlessFlightMgr.cpp b/src/server/game/World/ClientlessFlightMgr.cpp index f3b810db3d..19e48a445f 100644 --- a/src/server/game/World/ClientlessFlightMgr.cpp +++ b/src/server/game/World/ClientlessFlightMgr.cpp @@ -202,8 +202,13 @@ Surface ClientlessFlightMgr::Sample(Player * player, Record & record, float x, f }; } -bool ClientlessFlightMgr::Start(Player* player,Position const& target,std::string& error) -{return Begin(player,target,error,false);} +bool ClientlessFlightMgr::Start(Player* player,Position const& target,std::string& error,bool actionOwned) +{ + bool started=Begin(player,target,error,false); + if(started) + if(auto found=_records.find(player->GetGUID().GetCounter());found!=_records.end())found->second.ActionOwned=actionOwned; + return started; +} bool ClientlessFlightMgr::PrepareFollowingMount(Player* player) { if(!Enabled(player) || !player->IsAlive() || player->IsInCombat() || player->IsBeingTeleported() || @@ -647,6 +652,18 @@ bool ClientlessFlightMgr::Advance(Player * player, Record & record, bool handoff } if (! next.Valid ||(! record.Returning && record.Status.Travelled > Distance(PointOf(record.Start), goal) * 2 + 500)) { + // A detached/pathological flight gets one fresh airborne plan. If the + // new corridor is still impossible, finish with a checked local + // touchdown so the ordinary behavior controller can plan from ground. + if(!record.Returning && record.DetachedRecoveryCount && FindReturnLanding(player,record)) + { + record.Returning=true; + record.AbortReason="airborne recovery could not reach the original target"; + record.Breadcrumbs.clear(); + record.Memory={};record.Memory.Departed=true; + record.Status.Detail="airborne recovery landing"; + return Advance(player,record); + } Fail(player, record, "no safe bounded flight corridor; holding position mounted"); return false; } @@ -672,7 +689,11 @@ bool ClientlessFlightMgr::Advance(Player * player, Record & record, bool handoff ++ record.Status.Segments; record.Status.Avoidances += next.Avoiding ? 1 : 0; if (handoff) ++record.Status.Handoffs; - record.Status.Detail = next.Avoiding ? "obstacle avoidance" : next.Landing ? "landing approach" : next.Next.Z < current.Z - 0.25f ? "diagonal descent" : next.Next.Z > current.Z + 0.25f ? "diagonal climb" : "terrain-smoothed cruise"; + bool const vertical=Horizontal(current,next.Next)<1.0f && std::fabs(next.Next.Z-current.Z)>1.0f; + record.Status.Detail = next.Avoiding ? "obstacle avoidance" : next.Landing && vertical ? "vertical landing fallback" : + next.Landing ? "landing approach" : vertical && next.Next.Z>current.Z ? "vertical takeoff fallback" : + vertical ? "vertical descent fallback" : next.Next.Z < current.Z - 0.25f ? "diagonal descent" : + next.Next.Z > current.Z + 0.25f ? "diagonal climb" : "terrain-smoothed cruise"; TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_SEGMENT guid=%u n=%u avoid=%u handoff=%u target=(%.2f %.2f %.2f) detail='%s'", player->GetGUID().GetCounter(), record.Status.Segments, next.Avoiding ? 1 : 0, handoff ? 1 : 0, next.Next.X, next.Next.Y, next.Next.Z, record.Status.Detail.c_str()); return true; } @@ -724,6 +745,23 @@ void ClientlessFlightMgr::Update(uint32 diff) Fail(player, record, "flight permission lost or total timeout"); continue; } + bool const ownerLost=record.ActionOwned && !sClientlessActionExecutorMgr->OwnsActiveFlight(guid); + bool const pathological=record.Status.Segments>=12 && record.Status.Avoidances*2>=record.Status.Segments; + if(!record.Returning && !record.Following && record.DetachedRecoveryCount==0 && (ownerLost || pathological)) + { + ++record.DetachedRecoveryCount; + if(ownerLost)record.ActionOwned=false; + player->StopMoving();record.Moving=false;record.SegmentElapsed=0; + record.Start.Relocate(player);record.LastPosition.Relocate(player); + record.Memory={};record.Breadcrumbs.clear(); + record.Published=std::make_shared(); + record.Status.Detail=ownerLost?"replanning detached free flight":"replanning excessive obstacle avoidance"; + ClientlessTravelTelemetry::Note(player,"flight-airborne-replan",record.Status.Detail); + TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_AIRBORNE_REPLAN guid=%u ownerLost=%u segments=%u avoidances=%u target=(%.2f %.2f %.2f)", + guid,ownerLost?1:0,record.Status.Segments,record.Status.Avoidances,record.Status.Landing.GetPositionX(),record.Status.Landing.GetPositionY(),record.Status.Landing.GetPositionZ()); + Advance(player,record); + continue; + } if (record.CheckTimer > diff) { record.CheckTimer -= diff; diff --git a/src/server/game/World/ClientlessFlightMgr.h b/src/server/game/World/ClientlessFlightMgr.h index 48c894d7ac..f5689762e8 100644 --- a/src/server/game/World/ClientlessFlightMgr.h +++ b/src/server/game/World/ClientlessFlightMgr.h @@ -31,7 +31,7 @@ class TC_GAME_API ClientlessFlightMgr bool Preferred(Player const * player, uint32 mapId, Position const & position) const; bool Enabled(Player const * player) const; void BeginPlanning(Player const* player = nullptr); - bool Start(Player * player, Position const & target, std::string & error); + bool Start(Player * player, Position const & target, std::string & error, bool actionOwned = false); bool Follow(Player* player, Position const& target, std::string& error); bool LandFollowing(Player* player); bool PrepareFollowingMount(Player* player); @@ -53,6 +53,8 @@ class TC_GAME_API ClientlessFlightMgr bool ApproachingDeparture = false; Position Departure; uint32 TargetUpdated = 0, FormationDelay = 0; + uint32 DetachedRecoveryCount = 0; + bool ActionOwned = false; std::string AbortReason; std::vector < Position > Breadcrumbs; std::unordered_map < std::string, bool > Tiles; diff --git a/src/server/game/World/ClientlessFlightPath.h b/src/server/game/World/ClientlessFlightPath.h index 6aba3d8443..3f1be3b934 100644 --- a/src/server/game/World/ClientlessFlightPath.h +++ b/src/server/game/World/ClientlessFlightPath.h @@ -118,6 +118,13 @@ namespace ClientlessFlightPath if (memory.Departed && from.Z>=goal.Z && from.Z-goal.Z<=12 && Distance(from,goal)<=cfg.Step && Corridor(from,goal,goal,cfg,sample,ray,false)) return {goal,true,false,true}; + // A normal diagonal touchdown remains the first choice. When the bot + // is already above the landing column and there is not enough lateral + // room for that slope, descend in the verified column instead of + // opening another wide approach. + if (memory.Departed && distance < 2.0f && from.Z > goal.Z + 1.5f && + Corridor(from,goal,goal,cfg,sample,ray,false)) + return {goal,true,false,true}; if (distance < 2 && std::fabs(from.Z - goal.Z) < 1.5f) return { goal, Corridor(from, goal, goal, cfg, sample, ray, true), false, true @@ -212,6 +219,20 @@ namespace ClientlessFlightPath return best; } } + // Diagonal departure candidates were exhausted. Tight settlements + // can still have a completely open vertical column, so use it before + // asking the caller to walk to a different takeoff point. + if (!best.Valid && departure) + { + float const top = std::min(here.Ceiling - 6.0f, + std::max(from.Z + 20.0f, here.Ground + std::min(cfg.Clearance, 35.0f))); + Point vertical{from.X,from.Y,top}; + if (top > from.Z + 2.0f && Corridor(from,vertical,goal,cfg,sample,ray,true)) + { + memory.Departed=true; + return {vertical,true,false,false}; + } + } if (best.Valid) memory.Departed = true; return best; } diff --git a/src/server/worldserver/worldserver.conf.dist b/src/server/worldserver/worldserver.conf.dist index 7ba6bc0e07..208d39723e 100644 --- a/src/server/worldserver/worldserver.conf.dist +++ b/src/server/worldserver/worldserver.conf.dist @@ -1273,6 +1273,24 @@ DeletedCharacterTicketTrace = 0 DungeonFinder.OptionsMask = 1 +# +# DungeonFinder.LowPopulation.Enable +# DungeonFinder.LowPopulation.FallbackDelay +# DungeonFinder.LowPopulation.MinPlayers +# DungeonFinder.LowPopulation.SoloGroupOutsideTimeout +# Description: Allow the dungeon finder to create a dungeon group below +# the normal group size after the fallback delay. The group +# remains marked as low-population while inside the dungeon. +# Default: Enable = 0 +# FallbackDelay = 60 seconds +# MinPlayers = 1 +# SoloGroupOutsideTimeout = 300 seconds + +DungeonFinder.LowPopulation.Enable = 0 +DungeonFinder.LowPopulation.FallbackDelay = 60 +DungeonFinder.LowPopulation.MinPlayers = 1 +DungeonFinder.LowPopulation.SoloGroupOutsideTimeout = 300 + # # DungeonFinder.CallToArmsEnable # Description: Enable Call to Arms bonus mechanism for random Cataclysm dungeons diff --git a/tests/clientless/test_free_flight.py b/tests/clientless/test_free_flight.py index f79e2efcc1..e26f0eebe4 100644 --- a/tests/clientless/test_free_flight.py +++ b/tests/clientless/test_free_flight.py @@ -28,6 +28,15 @@ int main(){ assert(touchdown.Valid&&touchdown.Landing&&Distance(touchdown.Next,{0,0,0})<0.01f); auto solidWall=[](Point const&,Point const&){return false;}; assert(!Next({6,0,10},{0,0,0},Settings{},finalApproach,flat,solidWall).Valid); + // Diagonal movement is blocked but the local air column is clear: take off + // and touch down vertically instead of rejecting an otherwise safe flight. + auto verticalOnly=[](Point const&a,Point const&b){return Horizontal(a,b)<0.1f;}; + Memory verticalTakeoff; + auto ascent=Next({0,0,0},{100,0,0},Settings{},verticalTakeoff,flat,verticalOnly); + assert(ascent.Valid&&Horizontal({0,0,0},ascent.Next)<0.1f&&ascent.Next.Z>10); + Memory verticalLanding;verticalLanding.Departed=true; + auto descent=Next({0,0,40},{0,0,0},Settings{},verticalLanding,flat,verticalOnly); + assert(descent.Valid&&descent.Landing&&Distance(descent.Next,{0,0,0})<0.01f); auto normal=simulate({0,0,0},{800,0,0},flat,clear); assert(normal.Arrived&&normal.Peak>30&&normal.Peak<=60.1f); auto low=[](float,float){return Surface{0,42,true};}; @@ -84,3 +93,10 @@ with tempfile.TemporaryDirectory(prefix='vcore-free-flight-') as folder: cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(harness) subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True) subprocess.run([str(binary)],check=True) + +flight=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text() +executor=(root/'src/server/game/World/ClientlessActionExecutorMgr.cpp').read_text() +assert 'FLIGHT_AIRBORNE_REPLAN' in flight +assert 'OwnsActiveFlight(guid)' in flight +assert 'found->second.ActionOwned=actionOwned' in flight +assert 'itr->second.Active && itr->second.FlightActive' in executor