diff --git a/doc/clientless/free-flying-implementation.md b/doc/clientless/free-flying-implementation.md new file mode 100644 index 0000000000..17cd273c86 --- /dev/null +++ b/doc/clientless/free-flying-implementation.md @@ -0,0 +1,140 @@ +# Freie Flugreisen + +Die Umsetzung ergänzt die Planungsgrundlage in [free-flying.md](free-flying.md). +Freies Fliegen ist eine vorübergehend berechnete Alternative im Fernreisesystem. +Es werden dafür weder Nodes noch Kanten in der Datenbank aufgezeichnet. + +## Reiseplanung + +Ein Flug kann an einem regulären Graphnode oder einem virtuellen Straßenausstieg +beginnen und an einem regulären Node oder einer Location enden. Virtuelle +Straßeneinstiege sind keine Flugziele. Der Abflugpunkt muss tatsächlich erreicht +sein; ein Bot außerhalb des Graphen erhält keinen beliebigen direkten Luftstart. + +Normale Straßenreisen haben einen Kostenvorteil. Flug wird bei genügend großem +Vorteil berücksichtigt; bevorzugte Flugzonen senken Mindestdistanz und Kosten. +Damit können dort auch die Anschlussstrecken zwischen Nodes und Locations fliegen. +Die Vorgabe meint insbesondere Gebiete, deren Aufbau und gefährliche Gegner +Bodenreisen ungeeignet machen. Die Kosten sind Planungsgewichte, keine Sekunden. +Scheitert eine Flugoption am Boden, wird nur diese Option für die aktuelle Reise +gesperrt und mit den übrigen Möglichkeiten erneut geplant. + +Die Planung berücksichtigt Kartentyp, bekannte Gebietsregeln, Reitfähigkeit und +Lizenzen. Instanzen sowie bekannte Flugverbote wie Immersangwald und Geisterlande +werden ausgeschlossen. Dynamische Schlachtfeldregeln bleiben zur Laufzeit maßgeblich. +Unbekannte Gebietsmetadaten erlauben lediglich einen Planungsversuch: Beim Start +entscheiden die normalen Mountfähigkeiten und Zauberprüfungen des Servers. +Vashj'irs besondere Unterwasserreisen sind nicht enthalten. + +## Profil und Konfiguration + +Im `movement`-Abschnitt des Charakterprofils: + +```lua +can_free_fly = true, +flight_mounts = { [32235] = 100 }, -- Zauber-ID: Goldener Greif; Gewicht 100 +``` + +Flugmounts verwenden **gelernte Mountzauber**, im Unterschied zu den bisherigen +Display-IDs der Bodenmounts. Die Auswahl erfolgt gewichtet nach der Routenwahl. +Benötigt werden mindestens Expertenreiten (225), ein geeignetes gelerntes Flugtier +und die native Flugberechtigung. Für Karten 0/1 wird die Flugmeisterlizenz (90267), +für Nordend Kaltwetterflug (54197) berücksichtigt. Ein Profil allein erteilt keine +Fähigkeiten. Arthorans Profil enthält den bereits bereitgestellten Greifen. + +```ini +Clientless.Flight.Enabled = 1 +Clientless.Flight.Clearance = 60 +Clientless.Flight.MinDistance = 600 +Clientless.Flight.PreferredZones = "210 4197" +``` + +`Clearance` ist der nominelle Geländeabstand in Yards (begrenzt auf 20–200). +`MinDistance` gilt außerhalb bevorzugter Zonen, dort mindestens 100 Yards; +in bevorzugten Zonen sind Flüge ab 60 Yards planbar. Die voreingestellten +Zonen sind Eiskrone und Tausendwinter; aktuelle Flugverbote gelten weiterhin. +Konfigurationsänderungen können über `reload config` übernommen werden. + +## Flugausführung + +Nach dem normalen Mountzauber wartet die Steuerung auf die tatsächliche native +Flugfähigkeit. Es gibt keine künstliche Flugfreigabe und keinen Zielteleport. +Die Bewegung erfolgt in kurzen nativen Flugsplines mit Flugmountgeschwindigkeit. + +Die Navigation prüft Gelände und Wasseroberfläche, native maximale Flughöhe, +VMAP-Geometrie und kollidierbare dynamische GameObjects in der jeweiligen Phase. +Mehrere versetzte Kollisionsstrahlen approximieren die Körperbreite und -höhe. +Vorausschau und geglättete Geländehöhe verhindern das Nachfahren jeder kleinen +Spitze; längere Absenkungen erhalten eine Verzögerung. Höhenänderungen erfolgen +diagonal. Hindernisse können moderate seitliche Umwege oder zusätzliche Steigung +auslösen; die maximale Flughöhe bleibt eine harte Grenze. + +Für Landungen wird fester Boden mit freiem Luftraum gesucht. Ersatzpunkte liegen +höchstens 20 Yards neben dem Ziel und benötigen einen kurzen vollständigen +MMAP-Anschluss zum eigentlichen Ziel. Auf Wasser wird nicht gelandet. Nach +bestätigtem Bodenkontakt endet die Flugsteuerung, gegebenenfalls folgt die lokale +Bodenstrecke; danach setzt die normale Graph- beziehungsweise Intentlogik fort. + +## Diagnosebefehle + +In der Serverkonsole ohne führenden Punkt, ingame mit Punkt: + +```text +clientless flight status 26 +clientless flight probe 26 -10100 -600 +clientless flight test_node 26 northshire +clientless flight abort 26 +``` + +`status` zeigt Zustand, Fortschritt und Fehler; `probe` prüft Gelände und +Flughöhenbegrenzung auf der aktuellen Karte ohne Bewegung. `test_node` ist ein +isolierter Ausführungstest zwischen einem tatsächlich erreichten regulären +Abflugnode und einem regulären Zielnode derselben Karte. Er verlangt einen +untätigen Bot und ersetzt keinen Test der normalen Reiseplanung. + +`abort` bricht einen ausstehenden Mountzauber ab oder versucht in der Luft eine +Rückkehr über die aufgezeichneten, erneut kollisionsgeprüften Flugabschnitte. +Erst nach der Landung wird abgestiegen. Ist auch die Rückstrecke blockiert, +stoppt der Bot gemountet und meldet den Fehler; dieser Sonderfall benötigt +weitere Betreuung. Neue Bodenaktionen werden in der Luft abgelehnt. + +Das Log `adventurebot-navigation.log` enthält `FLIGHT_START`, `FLIGHT_SEGMENT`, +`FLIGHT_RETURN`, `FLIGHT_FAILED` und `FLIGHT_LANDED`. + +## Grenzen und Prüfungen + +Die erste Umsetzung verwendet begrenzte lokale Vorausschau und einen begrenzten +Umweg, keinen globalen dreidimensionalen Pfadsolver. Fehlende Geometriedaten +werden abgelehnt. Komplexe Gebäude, Schluchten und vollständig umschlossene Ziele +können eine Flugoption scheitern lassen. Spieler und NPC-Körper bilden keine +zusätzlichen Hindernisse über die native Weltgeometrie hinaus. Schauflüge und +Tagesroutinen gehören weiterhin in die spätere Verhaltenssteuerung. + +Automatisierte Prüfungen: + +```bash +python3 tests/clientless/test_free_flight.py +python3 tests/clientless/test_free_flight_planner.py +python3 tests/clientless/test_flight_policy.py +``` + +Sie prüfen die produktive Geometrie und Planungslogik mit kontrollierten Daten: +Start/Landung, geglättete Spitzen, Flughöhenlimit, seitliches/vertikales Ausweichen, +fehlende Geometrie, hohen Zielanflug, dynamische Flugoptionen, Straßenpräferenz, +virtuelle Ausstiege, Flugverbote und Fehlerbehandlung. Build und Servertests +ersetzen keine abschließende visuelle Abnahme im Spiel. + +Serverseitige Testbeobachtungen am 26.10.2026 mit Arthoran (GUID 26): + +- Goldhain → Nordhain und zurück: natives Aufmounten, Flugsegmente und bestätigte Landung. +- Manueller Abbruch in der Luft: Rückflug entlang der geprüften Spur und Landung am Ausgangspunkt. +- Reguläres `travel_to_location`: Kombination bestehender Wege mit Flug ab virtuellem Straßenausstieg, Landung und `travel-location-complete`. +- Ein nicht sicher fortsetzbarer Abflug am Node `taxi_12` führte zu Rückkehr und Neuplanung statt einer erfundenen Ankunft. + +Das Testziel wurde nur temporär als Worldpoint angelegt. Dafür wurden keine +Flugnodes oder Flugkanten gespeichert. Die visuelle Abnahme bleibt offen. + +Der abschließende reguläre Location-Test mit dem verbesserten Anflug endete um +16:44:19 mit `FLIGHT_LANDED` (32 Segmente, Zielabweichung 0,00) und anschließendem +`travel-location-complete`. Der Test lief über die normale Reiseaktion und den +virtuellen Straßenausstieg, nicht über `flight test_node`. diff --git a/doc/clientless/free-flying.md b/doc/clientless/free-flying.md new file mode 100644 index 0000000000..c5aca889e4 --- /dev/null +++ b/doc/clientless/free-flying.md @@ -0,0 +1,48 @@ +1. Freies Fliegen als eigenständige Reiseart + +Freies Fliegen soll eine reguläre Reisemöglichkeit der AdventureBots werden, neben Laufen, Reiten und Greifenflügen. Es ist ausdrücklich von den festen Flugrouten zwischen Greifenmeistern zu unterscheiden. + +2. Wann AdventureBots fliegen sollen +- Grundsätzlich sollen Bots aus RP-Gründen Straßen und Reittiere bevorzugen. +- Freies Fliegen soll bei einem deutlichen Reisevorteil infrage kommen. +- Besonders sinnvoll ist es in Gebieten, in denen Bodenreisen ungeeignet oder extrem umständlich sind, beispielsweise Eiskrone oder Tausendwinter. +- Zonenregeln dürfen ausdrücklich festlegen, dass Fliegen in bestimmten Gebieten bevorzugt werden soll. +- Auch kürzere Reisen können perspektivisch durch Laufen, Reiten oder freies Fliegen zurückgelegt werden. +3. Technische Flugnavigation + +Freies Fliegen benötigt ein eigenes Navigationssystem und darf nicht einfach als gewöhnliche TravelGraph-Kante umgesetzt werden. + +Wir hatten folgenden Ablauf festgelegt: +Start / Takeoff + +3D-Luftnavigation + +Zielanflug / Approach + +Landung / Landing + +Dabei müssen Flughöhe, Hindernisse, geeignete Landepunkte und die Übergänge zwischen Boden- und Flugbewegung berücksichtigt werden. + +4. Auswahl des Flugmounts + +Die Entscheidung, ob geflogen wird, ist von der Auswahl des konkreten Flugmounts getrennt. + +Wählt die Reiseplanung freies Fliegen, darf das Charakterprofil bestimmen, welches passende Flugtier verwendet wird. + +5. RP-Flüge und Tagesroutinen + +Wir hatten auch gelegentliche Schauflüge hochstufiger Abenteurer über Sturmwind besprochen, beispielsweise mit einer passenden Landung vor dem Auktionshaus. + +Dabei wurde ausdrücklich festgelegt: Solche RP-Schauflüge gehören in die spätere Tagesroutine beziehungsweise Verhaltenssteuerung, nicht in das Navigationssystem selbst. + +Das Navigationssystem übernimmt lediglich die tatsächliche Durchführung des Fluges. + +6. Entwicklungsreihenfolge + +Wir hatten folgende Reihenfolge akzeptiert: + +Portale und Instanzen → Schiffe und Luftschiffe → Tiefenbahn → freie Flugreisen. + +Freies Fliegen war damit als späterer eigenständiger Navigationsschritt vorgesehen. Eine erfolgreiche Implementierung oder ein abgeschlossener Funktionstest wurde bislang nicht bestätigt. + +Das ist der bisher besprochene Stand, ohne zusätzliche Umsetzungsvorschläge. diff --git a/src/server/game/Maps/GridMap.cpp b/src/server/game/Maps/GridMap.cpp index 3bba849d82..d123024c52 100644 --- a/src/server/game/Maps/GridMap.cpp +++ b/src/server/game/Maps/GridMap.cpp @@ -41,6 +41,7 @@ GridMap::GridMap() m_V9 = nullptr; m_V8 = nullptr; _minHeightPlanes = nullptr; + _maxHeightPlanes = nullptr; // Liquid data _liquidGlobalEntry = 0; _liquidGlobalFlags = map_liquidHeaderTypeFlags::NoWater; @@ -123,6 +124,7 @@ void GridMap::unloadData() delete[] m_V9; delete[] m_V8; delete[] _minHeightPlanes; + delete[] _maxHeightPlanes; delete[] _liquidEntry; delete[] _liquidFlags; delete[] _liquidMap; @@ -131,6 +133,7 @@ void GridMap::unloadData() m_V9 = nullptr; m_V8 = nullptr; _minHeightPlanes = nullptr; + _maxHeightPlanes = nullptr; _liquidEntry = nullptr; _liquidFlags = nullptr; _liquidMap = nullptr; @@ -233,6 +236,13 @@ bool GridMap::loadHeightData(FILE* in, uint32 offset, uint32 /*size*/) { -533.33331f, -533.33331f } }; + _maxHeightPlanes = new G3D::Plane[8]; + for (uint32 quarterIndex = 0; quarterIndex < 8; ++quarterIndex) + _maxHeightPlanes[quarterIndex] = G3D::Plane( + G3D::Vector3(boundGridCoords[indices[quarterIndex][0]][0], boundGridCoords[indices[quarterIndex][0]][1], maxHeights[indices[quarterIndex][0]]), + G3D::Vector3(boundGridCoords[indices[quarterIndex][1]][0], boundGridCoords[indices[quarterIndex][1]][1], maxHeights[indices[quarterIndex][1]]), + G3D::Vector3(boundGridCoords[indices[quarterIndex][2]][0], boundGridCoords[indices[quarterIndex][2]][1], maxHeights[indices[quarterIndex][2]]) + ); _minHeightPlanes = new G3D::Plane[8]; for (uint32 quarterIndex = 0; quarterIndex < 8; ++quarterIndex) _minHeightPlanes[quarterIndex] = G3D::Plane( @@ -565,6 +575,36 @@ float GridMap::getMinHeight(float x, float y) const return ray.intersection(_minHeightPlanes[quarterIndex]).z; } +float GridMap::getMaxHeight(float x, float y) const +{ + if (!_maxHeightPlanes) + return INVALID_HEIGHT; + + GridCoord gridCoord = Trinity::ComputeGridCoordSimple(x, y); + + int32 doubleGridX = int32(std::floor(-(x - MAP_HALFSIZE) / CENTER_GRID_OFFSET)); + int32 doubleGridY = int32(std::floor(-(y - MAP_HALFSIZE) / CENTER_GRID_OFFSET)); + + float gx = x - (int32(gridCoord.x_coord) - CENTER_GRID_ID + 1) * SIZE_OF_GRIDS; + float gy = y - (int32(gridCoord.y_coord) - CENTER_GRID_ID + 1) * SIZE_OF_GRIDS; + + uint32 quarterIndex = 0; + if (doubleGridY & 1) + { + if (doubleGridX & 1) + quarterIndex = 4 + (gx <= gy); + else + quarterIndex = 2 + ((-SIZE_OF_GRIDS - gx) > gy); + } + else if (doubleGridX & 1) + quarterIndex = 6 + ((-SIZE_OF_GRIDS - gx) <= gy); + else + quarterIndex = gx > gy; + + G3D::Ray ray = G3D::Ray::fromOriginAndDirection(G3D::Vector3(gx, gy, 0.0f), G3D::Vector3::unitZ()); + return ray.intersection(_maxHeightPlanes[quarterIndex]).z; +} + bool GridMap::isHole(int row, int col) const { if (!_holes) diff --git a/src/server/game/Maps/GridMap.h b/src/server/game/Maps/GridMap.h index 072bc9c211..5a213717b3 100644 --- a/src/server/game/Maps/GridMap.h +++ b/src/server/game/Maps/GridMap.h @@ -42,6 +42,7 @@ class TC_GAME_API GridMap uint8* m_uint8_V8; }; G3D::Plane* _minHeightPlanes; + G3D::Plane* _maxHeightPlanes; // Height level data float _gridHeight; float _gridIntHeightMultiplier; @@ -95,6 +96,7 @@ public: uint16 getArea(float x, float y) const; inline float getHeight(float x, float y) const {return (this->*_gridGetHeight)(x, y);} float getMinHeight(float x, float y) const; + float getMaxHeight(float x, float y) const; float getLiquidLevel(float x, float y) const; ZLiquidStatus GetLiquidStatus(float x, float y, float z, Optional ReqLiquidType, LiquidData* data = 0, float collisionHeight = 2.03128f) const; // DEFAULT_COLLISION_HEIGHT in Object.h }; diff --git a/src/server/game/Maps/TerrainMgr.cpp b/src/server/game/Maps/TerrainMgr.cpp index 9007faec9a..e34211e200 100644 --- a/src/server/game/Maps/TerrainMgr.cpp +++ b/src/server/game/Maps/TerrainMgr.cpp @@ -657,6 +657,14 @@ float TerrainInfo::GetMinHeight(PhaseShift const& phaseShift, uint32 mapId, floa return -500.0f; } +float TerrainInfo::GetMaxHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y) +{ + if (GridMap const* grid = GetGrid(PhasingHandler::GetTerrainMapId(phaseShift, mapId, this, x, y), x, y)) + return grid->getMaxHeight(x, y); + + return VMAP_INVALID_HEIGHT_VALUE; +} + float TerrainInfo::GetGridHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y) { if (GridMap* gmap = GetGrid(PhasingHandler::GetTerrainMapId(phaseShift, mapId, this, x, y), x, y)) diff --git a/src/server/game/Maps/TerrainMgr.h b/src/server/game/Maps/TerrainMgr.h index 8684dc3b96..7461bddcaf 100644 --- a/src/server/game/Maps/TerrainMgr.h +++ b/src/server/game/Maps/TerrainMgr.h @@ -92,6 +92,7 @@ public: void GetZoneAndAreaId(PhaseShift const& phaseShift, uint32 mapId, uint32& zoneid, uint32& areaid, Position const& pos) { GetZoneAndAreaId(phaseShift, mapId, zoneid, areaid, pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ()); } float GetMinHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y); + float GetMaxHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y); float GetGridHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y); float GetStaticHeight(PhaseShift const& phaseShift, uint32 mapId, float x, float y, float z, bool checkVMap = true, float maxSearchDist = DEFAULT_HEIGHT_SEARCH); float GetStaticHeight(PhaseShift const& phaseShift, uint32 mapId, Position const& pos, bool checkVMap = true, float maxSearchDist = DEFAULT_HEIGHT_SEARCH) { return GetStaticHeight(phaseShift, mapId, pos.GetPositionX(), pos.GetPositionY(), pos.GetPositionZ(), checkVMap, maxSearchDist); } diff --git a/src/server/game/World/ClientlessActionExecutorMgr.cpp b/src/server/game/World/ClientlessActionExecutorMgr.cpp index 049b0ec0ae..1e4336ae7e 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.cpp +++ b/src/server/game/World/ClientlessActionExecutorMgr.cpp @@ -1,5 +1,6 @@ #include "ClientlessActionExecutorMgr.h" #include "ClientlessTransportMgr.h" +#include "ClientlessFlightMgr.h" #include "ClientlessPerceptionMgr.h" #include "ClientlessPlayerMgr.h" @@ -181,9 +182,20 @@ ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(ui bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record) { record.TransportActive = false; + record.FlightActive = false; + record.FlightApproachActive = false; record.PortalUseActive = false; record.Status = {}; record.Status.ActionName = action.Name; + auto const* existingFlight=sClientlessFlightMgr->GetStatus(player->GetGUID().GetCounter()); + if (player->IsFlying() || (existingFlight && + (existingFlight->State==ClientlessFlightState::Mounting || existingFlight->State==ClientlessFlightState::Flying))) + { + record.Status.State=ClientlessActionExecutionState::Failed; + record.Status.Detail="airborne player requires safe flight recovery before ground actions"; + record.Active=false; + return false; + } if (player->GetTransport()) { record.Status.State = ClientlessActionExecutionState::Failed; @@ -710,7 +722,11 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation( // Only genuinely local destinations may bypass the macro graph. The // destination anchor may intentionally be much farther from the exact // Location, so directRadius must stay independent from anchorRadius. - if (directDistance <= directRadius) + sClientlessFlightMgr->BeginPlanning(); + Position goalPosition;goalPosition.Relocate(point->X,point->Y,point->Z,point->O); + bool const preferredFlight=sClientlessFlightMgr->Enabled(player) && + sClientlessFlightMgr->Preferred(player,point->MapId,goalPosition); + if (directDistance <= directRadius && !preferredFlight) { // C8.20: do not bypass the macro graph when the apparent direct // distance is short but the actual local MMAP route is blocked. @@ -893,6 +909,44 @@ bool ClientlessActionExecutorMgr::StartRouteStep( step.NodeKey.c_str(), step.NodeType.c_str(), step.EdgeType.c_str(), step.EdgeDatabaseId, step.EdgeReverse ? 1u : 0u, record.RecoveryCount); + if (step.EdgeType == "free_flight") + { + std::string error; + bool validSource=record.RouteStep>0 && + record.Route[record.RouteStep-1].NodeKey!="virtual_ground_entry"; + if (validSource) + { + auto const& source=record.Route[record.RouteStep-1]; + float const distance = player->GetExactDist(source.X, source.Y, source.Z); + if (source.MapId == player->GetMapId() && distance > 1.5f && distance <= 120.0f) + { + // Native taxis may finish beside their graph coordinates. + // Physically reach the departure before asking the mount to fly. + Position departure; + departure.Relocate(source.X, source.Y, source.Z, source.O); + if (!sClientlessTravelMgr->StartTravel(player, departure, true)) + return RecoverFlightLeg(player, characterGuid, record, "flight departure ground approach failed"); + record.FlightApproachActive = true; + record.Status.State = ClientlessActionExecutionState::Moving; + record.Status.Detail = "ground approach to free-flight departure"; + return true; + } + validSource=source.MapId==player->GetMapId() && + player->GetExactDist(source.X,source.Y,source.Z)<=3.0f; + } + Position target;target.Relocate(step.X,step.Y,step.Z,step.O); + if (!validSource || step.MapId!=player->GetMapId() || + !sClientlessFlightMgr->Start(player,target,error)) + { + return RecoverFlightLeg(player,characterGuid,record,error.empty()?"free flight source not physically reached":error); + } + record.FlightActive=true;record.Active=true; + record.Status.State=ClientlessActionExecutionState::Moving; + record.Status.TargetName="node:"+step.NodeKey; + record.Status.Detail="free flight mounting"; + return true; + } + if (step.EdgeType == "portal_use") return StartPortalUse(player, characterGuid, record); @@ -1440,6 +1494,26 @@ bool ClientlessActionExecutorMgr::StartRouteStep( return true; } +bool ClientlessActionExecutorMgr::RecoverFlightLeg(Player* player,uint32 characterGuid,Record& record,std::string const& reason) +{ + record.FlightActive=false; + if (record.RouteStep>0 && record.RouteStep"+target.NodeKey); + } + if(player->IsFlying()) + { + record.Status.State=ClientlessActionExecutionState::Failed;record.Status.Detail=reason; + record.Active=false;return false; + } + return RecoverTravel(player,characterGuid,record,0,"free-flight-unavailable: "+reason); +} + bool ClientlessActionExecutorMgr::StartPortalUse(Player* player, uint32 characterGuid, Record& record) { @@ -1881,6 +1955,46 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( uint32 characterGuid, Record& record) { + if (record.FlightApproachActive) + { + auto const* approach = sClientlessTravelMgr->GetStatus(characterGuid); + if (approach && approach->State == ClientlessTravelState::Moving) + return; + record.FlightApproachActive = false; + if (!approach || approach->State != ClientlessTravelState::Arrived) + RecoverFlightLeg(player, characterGuid, record, "flight departure ground approach did not arrive"); + else + StartRouteStep(player, characterGuid, record); + return; + } + if (record.FlightActive) + { + auto const* flight=sClientlessFlightMgr->GetStatus(characterGuid); + if (flight && (flight->State==ClientlessFlightState::Mounting || flight->State==ClientlessFlightState::Flying)) + {record.Status.Detail=flight->Detail;return;} + record.FlightActive=false; + if (!flight || flight->State!=ClientlessFlightState::Arrived) + { + RecoverFlightLeg(player,characterGuid,record,flight?flight->Detail:"free flight state disappeared"); + return; + } + auto const& step=record.Route[record.RouteStep]; + if (player->GetExactDist(step.X,step.Y,step.Z)>1.5f) + { + Position target;target.Relocate(step.X,step.Y,step.Z,step.O); + if (!sClientlessTravelMgr->StartTravel(player,target,true)) + { + record.Status.State=ClientlessActionExecutionState::Failed; + record.Status.Detail="safe landing reached but local target approach failed"; + record.Active=false;return; + } + record.Status.Detail="ground approach after confirmed landing"; + return; + } + ++record.RouteStep; + StartRouteStep(player,characterGuid,record); + return; + } if (record.TransportActive) { ClientlessTransportStatus const* transport = sClientlessTransportMgr->GetStatus(characterGuid); diff --git a/src/server/game/World/ClientlessActionExecutorMgr.h b/src/server/game/World/ClientlessActionExecutorMgr.h index f91296eac6..b394584afc 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.h +++ b/src/server/game/World/ClientlessActionExecutorMgr.h @@ -60,6 +60,8 @@ private: // C8.10b: active server-side taxi edge. bool TransportActive = false; + bool FlightActive = false; + bool FlightApproachActive = false; bool PortalUseActive = false; uint64 PortalUseTeleportSequence = 0; uint32 PortalUseElapsedMs = 0; @@ -106,6 +108,7 @@ private: bool StartFinalLocalLeg(Player* player, uint32 characterGuid, Record& record); bool RecoverFinalLocalLeg(Player* player, uint32 characterGuid, Record& record, std::string const& reason); bool StartRouteStep(Player* player, uint32 characterGuid, Record& record); + bool RecoverFlightLeg(Player* player, uint32 characterGuid, Record& record, std::string const& reason); bool StartPortalUse(Player* player, uint32 characterGuid, Record& record); void UpdatePortalUse(Player* player, uint32 characterGuid, Record& record); bool StartRecordedEdgePath(Player* player, uint32 characterGuid, Record& record); diff --git a/src/server/game/World/ClientlessFlightMgr.cpp b/src/server/game/World/ClientlessFlightMgr.cpp new file mode 100644 index 0000000000..452deed739 --- /dev/null +++ b/src/server/game/World/ClientlessFlightMgr.cpp @@ -0,0 +1,501 @@ +#include "ClientlessFlightMgr.h" +#include "ClientlessLuaEngine.h" +#include "ClientlessTravelMgr.h" +#include "ClientlessActionExecutorMgr.h" +#include "ClientlessPlayerMgr.h" +#include "ClientlessRouteRecorderMgr.h" +#include "DatabaseEnv.h" +#include "BattlefieldMgr.h" +#include "Battlefield.h" +#include "Chat.h" +#include "Config.h" +#include "DBCStores.h" +#include "Log.h" +#include "Map.h" +#include "MoveSplineInit.h" +#include "PhasingHandler.h" +#include "Player.h" +#include "PathGenerator.h" +#include "Random.h" +#include "SpellInfo.h" +#include "SpellMgr.h" +#include "TerrainMgr.h" +#include "VMapFactory.h" +#include "IVMapManager.h" +#include "VMapManager2.h" +#include "World.h" +#include "WorldSession.h" +#include +#include +#include +#include +namespace +{ + using namespace ClientlessFlightPath; + Point PointOf(Position const & p) + { + return + { + p.GetPositionX(), p.GetPositionY(), p.GetPositionZ() + }; + } + float Infinity() + { + return std::numeric_limits < float >::infinity(); + } + bool ClearRay(Player * player, Point const & a, Point const & b) + { + Map * map = player->GetMap(); + float x, y, z; + return map->isInLineOfSight(player->GetPhaseShift(), a.X, a.Y, a.Z, b.X, b.Y, b.Z, LINEOFSIGHT_ALL_CHECKS, VMAP::ModelIgnoreFlags(0)) && ! map->getObjectHitPos(player->GetPhaseShift(), a.X, a.Y, a.Z, b.X, b.Y, b.Z, x, y, z, 0); + } + char const * StateName(ClientlessFlightState state) + { + switch (state) + { + case ClientlessFlightState::Mounting : return "Mounting"; + case ClientlessFlightState::Flying : return "Flying"; + case ClientlessFlightState::Arrived : return "Arrived"; + case ClientlessFlightState::Failed : return "Failed"; + } + return "Unknown"; + } + uint32 FlightType(SpellInfo const * spell) + { + if (spell) for (auto const & effect : spell->Effects) if (effect.ApplyAuraName == SPELL_AURA_MOUNTED) return uint32(effect.MiscValueB); + return 0; + } +} + +ClientlessFlightMgr * ClientlessFlightMgr::instance() +{ + static ClientlessFlightMgr mgr; + return & mgr; +} + +void ClientlessFlightMgr::BeginPlanning() +{ + _planningAreas.clear(); + _planningTerrain.clear(); +} + +std::pair < uint32, uint32 > ClientlessFlightMgr::AreaZone(Player const * player, uint32 map, Position const & point) const +{ + std::string key = std::to_string(map) + ":" + std::to_string(point.GetPositionX()) + ":" + std::to_string(point.GetPositionY()) + ":" + std::to_string(point.GetPositionZ()); + auto found = _planningAreas.find(key); + if (found != _planningAreas.end()) return found->second; + auto & terrain = _planningTerrain[map]; + if (! terrain) terrain = sTerrainMgr.LoadTerrain(map); + uint32 zone = 0, area = 0; + if (terrain) terrain->GetZoneAndAreaId(player->GetPhaseShift(), zone, area, map, point); + return _planningAreas.emplace(key, std::make_pair(area, zone)).first->second; +} + +bool ClientlessFlightMgr::Enabled(Player const * player) const +{ + auto const * profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter()); + if (! sConfigMgr->GetBoolDefault("Clientless.Flight.Enabled", true) || ! profile || ! profile->CanFreeFly || player->GetSkillValue(SKILL_RIDING) < 225) return false; + for (auto const & mount : profile->FlightMounts) if (player->HasSpell(mount.DisplayId) && FlightType(sSpellMgr->GetSpellInfo(mount.DisplayId))) return true; + return false; +} + +bool ClientlessFlightMgr::Preferred(Player const * player, uint32 map, Position const & position) const +{ + std::istringstream list(sConfigMgr->GetStringDefault("Clientless.Flight.PreferredZones", "210 4197")); + uint32 zone = AreaZone(player, map, position).second, candidate; + while (list >> candidate) if (zone == candidate) return true; + return false; +} + +float ClientlessFlightMgr::Estimate(Player const * player, uint32 map, Position const & from, Position const & to) const +{ + auto const * entry = sMapStore.LookupEntry(map); + if (! Enabled(player) || ! entry || entry->MapType != MAP_COMMON) return Infinity(); + if ((map == 0 || map == 1) && ! player->HasSpell(90267)) return Infinity(); + if (map == 571 && ! player->HasSpell(54197)) return Infinity(); + uint32 const area = AreaZone(player, map, from).first; + if (auto const * data = sAreaTableStore.LookupEntry(area)) + { + bool const dynamicRules = data->World_pvp_ID != 0 ||(map == player->GetMapId() && area == player->GetAreaId() && player->HasAuraType(SPELL_AURA_MOUNT_RESTRICTIONS)); + if (! dynamicRules && ! data->GetMountFlags().HasFlag(AreaMountFlags::AllowFlyingMounts)) return Infinity(); + } + // Unknown terrain permission is an estimate only; native mount capability + // and spell checks at the actual departure are mandatory. + bool const preferred = Preferred(player, map, from) || Preferred(player, map, to); + float const distance = Distance(PointOf(from), PointOf(to)); + float minimum = preferred ? 60.0f : std::max(100.0f, sConfigMgr->GetFloatDefault("Clientless.Flight.MinDistance", 600)); + if (! std::isfinite(distance) || distance < minimum || distance > 20000) return Infinity(); + return preferred ? 40 + distance * 0.4f : 180 + distance * 1.5f; +} + +ClientlessFlightStatus const * ClientlessFlightMgr::GetStatus(uint32 guid) const +{ + auto i = _records.find(guid); + return i == _records.end() ? nullptr : & i->second.Status; +} + +void ClientlessFlightMgr::Clear(uint32 guid) +{ + _records.erase(guid); +} + +Surface ClientlessFlightMgr::Sample(Player * player, Record & record, float x, float y) const +{ + Map * map = player->GetMap(); + uint32 terrain = PhasingHandler::GetTerrainMapId(player->GetPhaseShift(), map->GetId(), map->GetTerrain(), x, y); + std::string key = std::to_string(terrain) + ":" + std::to_string(int(std::floor(x / 533.33333f))) + ":" + std::to_string(int(std::floor(y / 533.33333f))); + auto found = record.Tiles.find(key); + if (found == record.Tiles.end()) found = record.Tiles.emplace(key, TerrainMgr::ExistMapAndVMap(terrain, x, y)).first; + if (! found->second) return + { + }; + map->LoadGrid(x, y); + // terrain/VMAP and dynamic objects before ray queries + float ground = map->GetHeight(player->GetPhaseShift(), x, y, MAX_HEIGHT, true, MAX_HEIGHT); + float water = map->GetWaterLevel(player->GetPhaseShift(), x, y); + if (std::isfinite(water) && water > ground) ground = water; + float ceiling = map->GetTerrain()->GetMaxHeight(player->GetPhaseShift(), map->GetId(), x, y); + return + { + ground, ceiling, std::isfinite(ground) && std::isfinite(ceiling) && ground > INVALID_HEIGHT + 1 && ceiling > ground + 5 + }; +} + +bool ClientlessFlightMgr::Start(Player * player, Position const & target, std::string & error) +{ + if (! Trinity::IsValidMapCoord(target.GetPositionX(), target.GetPositionY(), target.GetPositionZ())) + { + error = "invalid free-flight destination coordinates"; + return false; + } + if (! player || ! player->IsInWorld() || ! Enabled(player) || ! player->IsAlive() || player->GetMap()->GetEntry()->MapType != MAP_COMMON || player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported() || player->IsInCombat() || ! player->IsOutdoors() || player->IsInWater() || player->IsFalling()) + { + error = "free flight unavailable in current player/location state"; + return false; + } + auto * vmaps = VMAP::VMapFactory::createOrGetVMapManager(); + if (! vmaps->isLineOfSightCalcEnabled() || ! vmaps->isHeightCalcEnabled()) + { + error = "free flight requires native VMAP height and collision checks"; + return false; + } + if (auto * battlefield = sBattlefieldMgr->GetBattlefieldToZoneId(player->GetMap(), player->GetZoneId())) if (! battlefield->CanFlyIn()) + { + error = "battlefield currently forbids flying"; + return false; + } + auto const * profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter()); + std::vector < std::pair < uint32, uint32 >> candidates; + uint32 total = 0; + for (auto const & mount : profile->FlightMounts) + { + if (! player->HasSpell(mount.DisplayId)) continue; + uint32 type = FlightType(sSpellMgr->GetSpellInfo(mount.DisplayId)); + auto const * capability = player->GetMountCapability(type); + if (! capability || !(capability->Flags & MOUNT_CAPABILITY_FLAG_FLYING)) continue; + uint32 weight = std::min(mount.Weight, uint32(10000)); + total += weight; + candidates.emplace_back(mount.DisplayId, weight); + } + if (! total) + { + error = "no learned profile mount with native flying capability here"; + return false; + } + uint32 roll = urand(1, total), spell = 0; + for (auto const & candidate : candidates) + { + spell = candidate.first; + if (roll <= candidate.second) break; + roll -= candidate.second; + } + Record record; + record.Status.MapId = player->GetMapId(); + record.Status.Target = target; + record.Start.Relocate(player); + record.Settings.Clearance = std::clamp(sConfigMgr->GetFloatDefault("Clientless.Flight.Clearance", 60), 20.0f, 200.0f); + auto origin = Sample(player, record, player->GetPositionX(), player->GetPositionY()); + if (! origin.Valid || std::fabs(origin.Ground - player->GetPositionZ()) > 3) + { + error = "unknown flight bounds or no safe ground departure"; + return false; + } + bool foundLanding = false; + for (float radius : + { + 0.0f, 6.0f, 12.0f, 20.0f + }) + { + for (unsigned i = 0; i <(radius == 0 ? 1u : 12u); ++ i) + { + float angle = float(i) * 6.283185307f / 12; + float x = target.GetPositionX() + radius * std::cos(angle), y = target.GetPositionY() + radius * std::sin(angle); + auto landing = Sample(player, record, x, y); + if (! landing.Valid || std::fabs(landing.Ground - target.GetPositionZ()) > 5) continue; + float solid = player->GetMap()->GetHeight(player->GetPhaseShift(), x, y, MAX_HEIGHT, true, MAX_HEIGHT); + if (landing.Ground > solid + 0.7f) continue; + // water is no solid touchdown + Point bottom + { + x, y, landing.Ground + 1 + }, top = bottom; + top.Z += record.Settings.Clearance; + if (! ClearRay(player, bottom, top)) continue; + Position candidate; + candidate.Relocate(x, y, landing.Ground, target.GetOrientation()); + if (radius > 0 || std::fabs(landing.Ground - target.GetPositionZ()) > 0.7f) + { + auto path = PathGenerator::AnalyzePositions(player->GetMapId(), player->GetMapId(), player->GetInstanceId(), candidate, target, NAV_GROUND); + if (! path.IsComplete() || path.FinalDistance > 1.5f || path.PathLength > 120) continue; + } + record.Status.Landing = candidate; + foundLanding = true; + break; + } + if (foundLanding) break; + } + if (! foundLanding) + { + error = "no free solid landing with a validated short target approach"; + return false; + } + sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter()); + player->StopMoving(); + sClientlessLuaEngine->DismountGroundMount(player, "free-flight-takeoff"); + player->RemoveAurasByType(SPELL_AURA_MOUNTED); + record.Status.State = ClientlessFlightState::Mounting; + record.Status.MountSpell = spell; + record.Status.Detail = "waiting for native flying mount"; + _records[player->GetGUID().GetCounter()] = std::move(record); + if (player->CastSpell(player, spell, false) != SPELL_CAST_OK) + { + Fail(player, _records.at(player->GetGUID().GetCounter()), "native mount spell rejected"); + error = "native mount spell rejected"; + return false; + } + TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_START guid=%u map=%u spell=%u target=(%.2f %.2f %.2f)", player->GetGUID().GetCounter(), player->GetMapId(), spell, target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()); + return true; +} + +void ClientlessFlightMgr::Fail(Player * player, Record & record, std::string const & reason) +{ + player->StopMoving(); + record.Moving = false; + if (record.Status.State == ClientlessFlightState::Mounting) player->InterruptNonMeleeSpells(false); + if (! record.Returning && record.Status.State == ClientlessFlightState::Flying && player->CanFly() && player->IsAlive() && player->GetMapId() == record.Status.MapId && ! record.Breadcrumbs.empty()) + { + record.Returning = true; + record.AbortReason = reason; + record.Status.Detail = "aborting: returning along checked flight trail"; + TC_LOG_WARN("server.adventurebot.navigation", "FLIGHT_RETURN guid=%u reason='%s'", player->GetGUID().GetCounter(), reason.c_str()); + return; + } + record.Status.State = ClientlessFlightState::Failed; + record.Status.Detail = reason; + // If even the reverse corridor is blocked, remain mounted and stopped; + // never fabricate a landing or drop the player from an unknown altitude. + TC_LOG_WARN("server.adventurebot.navigation", "FLIGHT_FAILED guid=%u map=%u pos=(%.2f %.2f %.2f) reason='%s'", player->GetGUID().GetCounter(), player->GetMapId(), player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), reason.c_str()); +} + +bool ClientlessFlightMgr::Advance(Player * player, Record & record) +{ + Point current = PointOf(* player), goal = PointOf(record.Returning ? record.Start : record.Status.Landing); + auto sample =[&](float x, float y) + { + return Sample(player, record, x, y); + }; + auto ray =[&](Point const & a, Point const & b) + { + return ClearRay(player, a, b); + }; + if (Horizontal(current, goal) < 1.5f && std::fabs(current.Z - goal.Z) < 0.7f) + { + auto floor = sample(current.X, current.Y); + if (! floor.Valid || std::fabs(current.Z - floor.Ground) > 0.7f) + { + Fail(player, record, "touchdown ground not confirmed"); + return false; + } + player->StopMoving(); + player->RemoveUnitMovementFlag(MOVEMENTFLAG_FLYING | MOVEMENTFLAG_ASCENDING | MOVEMENTFLAG_DESCENDING); + player->RemoveAurasByType(SPELL_AURA_MOUNTED); + record.Status.State = record.Returning ? ClientlessFlightState::Failed : ClientlessFlightState::Arrived; + record.Status.Detail = record.Returning ? "safely returned after abort: " + record.AbortReason : "landed on validated destination ground"; + TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_LANDED guid=%u segments=%u avoidances=%u travelled=%.2f error=%.2f", player->GetGUID().GetCounter(), record.Status.Segments, record.Status.Avoidances, record.Status.Travelled, Distance(current, goal)); + return true; + } + ClientlessFlightPath::Result next; + if (record.Returning) + { + while (! record.Breadcrumbs.empty() && Distance(current, PointOf(record.Breadcrumbs.back())) < 0.7f) record.Breadcrumbs.pop_back(); + Point back = record.Breadcrumbs.empty() ? goal : PointOf(record.Breadcrumbs.back()); + next = + { + back, Corridor(current, back, goal, record.Settings, sample, ray, true), true, false + }; + } + else next = Next(current, goal, record.Settings, record.Memory, sample, ray); + if (! next.Valid ||(! record.Returning && record.Status.Travelled > Distance(PointOf(record.Start), goal) * 2 + 500)) + { + Fail(player, record, "no safe bounded flight corridor; holding position mounted"); + return false; + } + if (! record.Returning) record.Breadcrumbs.push_back(* player); + record.Segment.Relocate(next.Next.X, next.Next.Y, next.Next.Z, player->GetOrientation()); + player->AddUnitMovementFlag(MOVEMENTFLAG_FLYING); + Movement::MoveSplineInit init(player); + init.MoveTo(next.Next.X, next.Next.Y, next.Next.Z, false); + init.SetFly(); + init.SetWalk(false); + init.SetVelocity(player->GetSpeed(MOVE_FLIGHT)); + if (init.Launch() <= 0) + { + Fail(player, record, "flight spline rejected"); + return false; + } + record.Moving = true; + record.SegmentElapsed = 0; + ++ record.Status.Segments; + record.Status.Avoidances += next.Avoiding ? 1 : 0; + record.Status.Travelled += Distance(current, next.Next); + 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"; + TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_SEGMENT guid=%u n=%u avoid=%u target=(%.2f %.2f %.2f) detail='%s'", player->GetGUID().GetCounter(), record.Status.Segments, next.Avoiding ? 1 : 0, next.Next.X, next.Next.Y, next.Next.Z, record.Status.Detail.c_str()); + return true; +} + +void ClientlessFlightMgr::Update(uint32 diff) +{ + for (auto &[guid, record] : _records) + { + if (record.Status.State == ClientlessFlightState::Arrived || record.Status.State == ClientlessFlightState::Failed) continue; + auto * session = sWorld->FindClientlessSession(guid); + auto * player = session ? session->GetPlayer() : nullptr; + if (! player || ! player->IsInWorld() || sWorld->GetClientlessPlayerState(guid) != ClientlessPlayerState::Online) continue; + record.Elapsed += std::min(diff, uint32(30000)); + record.SegmentElapsed += std::min(diff, uint32(30000)); + if (player->GetMapId() != record.Status.MapId || ! player->IsAlive() || player->IsBeingTeleported()) + { + Fail(player, record, "flight player/map state changed"); + continue; + } + if (record.Status.State == ClientlessFlightState::Mounting) + { + if (player->IsMounted() && player->CanFly()) record.Status.State = ClientlessFlightState::Flying; + else + { + if (record.Elapsed > 8000) Fail(player, record, "native mount did not grant flight"); + continue; + } + } + if (! player->CanFly() || record.Elapsed >(record.Returning ? 2100000u : 1800000u)) + { + Fail(player, record, "flight permission lost or total timeout"); + continue; + } + if (record.CheckTimer > diff) + { + record.CheckTimer -= diff; + continue; + } + record.CheckTimer = 200; + if (record.Moving && player->IsSplineEnabled()) + { + if (record.SegmentElapsed > 10000) + { + Fail(player, record, "flight segment made no confirmed progress"); + continue; + } + auto ray =[&](Point const & a, Point const & b) + { + return ClearRay(player, a, b); + }; + auto sample =[&](float x, float y) + { + return Sample(player, record, x, y); + }; + // Recheck the remaining corridor as moving GO geometry changes. + if (! Corridor(PointOf(* player), PointOf(record.Segment), PointOf(record.Returning ? record.Start : record.Status.Landing), record.Settings, sample, ray, true)) + { + player->StopMoving(); + record.Moving = false; + Advance(player, record); + } + continue; + } + record.Moving = false; + Advance(player, record); + } +} + +bool ClientlessFlightMgr::Command(ChatHandler * handler, char const * args) +{ + std::istringstream in(args ? args : ""); + std::string op; + uint32 guid = 0; + in >> op >> guid; + auto const * status = GetStatus(guid); + if (op == "abort") + { + auto * session = sWorld->FindClientlessSession(guid); + auto * player = session ? session->GetPlayer() : nullptr; + auto found = _records.find(guid); + if (player && found != _records.end() &&(found->second.Status.State == ClientlessFlightState::Mounting || found->second.Status.State == ClientlessFlightState::Flying)) + { + if (found->second.Status.State == ClientlessFlightState::Mounting) player->InterruptNonMeleeSpells(false); + Fail(player, found->second, "operator requested safe return"); + handler->SendSysMessage("Flight abort requested. An airborne player attempts the checked reverse trail before dismounting."); + } + else handler->SendSysMessage("No active free flight to abort."); + return true; + } + if (op == "probe") + { + float x, y; + auto * session = sWorld->FindClientlessSession(guid); + auto * player = session ? session->GetPlayer() : nullptr; + if (! player || !(in >> x >> y) || ! std::isfinite(x) || ! std::isfinite(y) || std::fabs(x) > 17000 || std::fabs(y) > 17000) + { + handler->SendSysMessage("Usage: .clientless flight probe "); + return true; + } + Record probe; + auto surface = Sample(player, probe, x, y); + handler->PSendSysMessage("Flight surface map=%u x=%.3f y=%.3f valid=%u ground=%.3f ceiling=%.3f", player->GetMapId(), x, y, surface.Valid ? 1u : 0u, surface.Ground, surface.Ceiling); + return true; + } + if (op == "test_node") + { + std::string node; + in >> node; + auto * session = sWorld->FindClientlessSession(guid); + auto * player = session ? session->GetPlayer() : nullptr; + auto const * action = sClientlessActionExecutorMgr->GetStatus(guid); + if (! player || ! sClientlessPlayerMgr->IsManaged(guid) || ! ClientlessRouteRecorderMgr::IsSafeKey(node) ||(action &&(action->State == ClientlessActionExecutionState::Moving || action->State == ClientlessActionExecutionState::SelectingTarget)) ||(status &&(status->State == ClientlessFlightState::Mounting || status->State == ClientlessFlightState::Flying))) + { + handler->SendSysMessage("Use an idle online CPC player and a valid destination node."); + return true; + } + QueryResult target = WorldDatabase.PQuery("SELECT w.map,w.position_x,w.position_y,w.position_z FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key WHERE n.node_key='%s' AND n.enabled=1 AND w.enabled=1", node.c_str()); + QueryResult origin = WorldDatabase.PQuery("SELECT n.node_key FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key WHERE n.enabled=1 AND w.enabled=1 AND w.map=%u AND POW(w.position_x-%.6f,2)+POW(w.position_y-%.6f,2)+POW(w.position_z-%.6f,2)<=9 LIMIT 1", player->GetMapId(), player->GetPositionX(), player->GetPositionY(), player->GetPositionZ()); + if (! target || ! origin || target->Fetch()[0].GetUInt32() != player->GetMapId()) + { + handler->SendSysMessage("Flight test requires a real departure node within 3 yards and a destination node on the same map."); + return true; + } + auto * fields = target->Fetch(); + Position point; + point.Relocate(fields[1].GetFloat(), fields[2].GetFloat(), fields[3].GetFloat()); + std::string error; + bool ok = Start(player, point, error); + handler->PSendSysMessage("Isolated free-flight test: %s %s", ok ? "started" : "rejected", error.c_str()); + return true; + } + if (op != "status" || ! guid) + { + handler->SendSysMessage("Usage: .clientless flight status OR test_node . test_node isolates flight execution; normal trips use the planner."); + return false; + } + if (! status) handler->SendSysMessage("No free-flight state for this character."); + else handler->PSendSysMessage("Flight GUID %u: state=%s map=%u mountSpell=%u segments=%u avoidances=%u travelled=%.1f detail='%s'", guid, StateName(status->State), status->MapId, status->MountSpell, status->Segments, status->Avoidances, status->Travelled, status->Detail.c_str()); + return true; +} diff --git a/src/server/game/World/ClientlessFlightMgr.h b/src/server/game/World/ClientlessFlightMgr.h new file mode 100644 index 0000000000..a0e7437898 --- /dev/null +++ b/src/server/game/World/ClientlessFlightMgr.h @@ -0,0 +1,58 @@ +#ifndef VCORE_CLIENTLESS_FLIGHT_MGR_H +#define VCORE_CLIENTLESS_FLIGHT_MGR_H +#include "Common.h" +#include "Position.h" +#include "ClientlessFlightPath.h" +#include +#include +#include +class Player; +class ChatHandler; +class TerrainInfo; +enum class ClientlessFlightState : uint8 +{ + Mounting, Flying, Arrived, Failed +}; +struct ClientlessFlightStatus +{ + ClientlessFlightState State = ClientlessFlightState::Failed; + std::string Detail; + uint32 MapId = 0, MountSpell = 0, Segments = 0, Avoidances = 0; + Position Target, Landing; + float Travelled = 0; +}; +class TC_GAME_API ClientlessFlightMgr +{ + public : static ClientlessFlightMgr * instance(); + // Estimates are transient alternatives in macro planning, not DB edges. + float Estimate(Player const * player, uint32 mapId, Position const & from, Position const & to) const; + bool Preferred(Player const * player, uint32 mapId, Position const & position) const; + bool Enabled(Player const * player) const; + void BeginPlanning(); + bool Start(Player * player, Position const & target, std::string & error); + void Update(uint32 diff); + void Clear(uint32 guid); + ClientlessFlightStatus const * GetStatus(uint32 guid) const; + bool Command(ChatHandler * handler, char const * args); + private : struct Record + { + ClientlessFlightStatus Status; + ClientlessFlightPath::Settings Settings; + ClientlessFlightPath::Memory Memory; + Position Start, Segment; + uint32 Elapsed = 0, SegmentElapsed = 0, CheckTimer = 0; + bool Moving = false, Returning = false; + std::string AbortReason; + std::vector < Position > Breadcrumbs; + std::unordered_map < std::string, bool > Tiles; + }; + ClientlessFlightPath::Surface Sample(Player * player, Record & record, float x, float y) const; + bool Advance(Player * player, Record & record); + void Fail(Player * player, Record & record, std::string const & reason); + std::pair < uint32, uint32 > AreaZone(Player const * player, uint32 map, Position const & point) const; + mutable std::unordered_map < std::string, std::pair < uint32, uint32 >> _planningAreas; + mutable std::unordered_map < uint32, std::shared_ptr < TerrainInfo >> _planningTerrain; + std::unordered_map < uint32, Record > _records; +}; +#define sClientlessFlightMgr ClientlessFlightMgr::instance() +#endif diff --git a/src/server/game/World/ClientlessFlightPath.h b/src/server/game/World/ClientlessFlightPath.h new file mode 100644 index 0000000000..8eb90dd04c --- /dev/null +++ b/src/server/game/World/ClientlessFlightPath.h @@ -0,0 +1,214 @@ +#ifndef VCORE_CLIENTLESS_FLIGHT_PATH_H +#define VCORE_CLIENTLESS_FLIGHT_PATH_H +#include +#include +#include +#include +#include +// Geometry-only receding-horizon navigation. Server adapters supply phased +// terrain/ceiling and static+dynamic collision queries. No Player shortcuts. +namespace ClientlessFlightPath +{ + struct Point + { + float X = 0, Y = 0, Z = 0; + }; + struct Surface + { + float Ground = 0, Ceiling = 0; + bool Valid = false; + }; + struct Settings + { + float Clearance = 60, Safety = 8, Radius = 2; + float Step = 25, Lookahead = 100, Slope = 0.6f; + }; + struct Memory + { + float LowerTerrainDistance = 0; + float HeadingX = 1, HeadingY = 0; + bool Departed = false; + }; + struct Result + { + Point Next; + bool Valid = false, Avoiding = false, Landing = false; + }; + using Sample = std::function < Surface(float, float) >; + using Ray = std::function < bool(Point const &, Point const &) >; + inline float Horizontal(Point const & a, Point const & b) + { + return std::hypot(b.X - a.X, b.Y - a.Y); + } + inline float Distance(Point const & a, Point const & b) + { + return std::hypot(Horizontal(a, b), b.Z - a.Z); + } + inline Point Mix(Point const & a, Point const & b, float t) + { + return + { + a.X +(b.X - a.X) * t, a.Y +(b.Y - a.Y) * t, a.Z +(b.Z - a.Z) * t + }; + } + inline bool Corridor(Point const & from, Point const & to, Point const & goal, Settings const & cfg, Sample const & sample, Ray const & ray, bool departure = false) + { + int const count = std::max(1, int(std::ceil(Distance(from, to) / 5))); + Surface const origin = sample(from.X, from.Y); + if (! origin.Valid) return false; + for (int i = 0; i <= count; ++ i) + { + Point p = Mix(from, to, float(i) / count); + // Only takeoff/actual target touchdown may approach the surface. This + // allowance follows the real rising/descending slope, not arbitrary terrain. + float margin = cfg.Safety; + if (departure) margin = std::min(margin, std::max(0.0f, from.Z - origin.Ground) + Horizontal(from, p) * std::min(0.3f, std::max(0.0f, to.Z - from.Z) / std::max(1.0f, Horizontal(from, to)) * 0.75f)); + margin = std::min(margin, std::max(0.0f, Horizontal(p, goal) * 0.2f)); + Surface s = sample(p.X, p.Y); + if (! s.Valid || ! std::isfinite(s.Ground) || ! std::isfinite(s.Ceiling) || p.Z + 0.5f < s.Ground + margin || p.Z + 5 > s.Ceiling) return false; + } + // A volume, not one center ray. Keep the lower rays above foot level so + // a valid ground departure/touchdown does not intersect the ground plane. + for (auto offset : std::array < Point, 5 > + { + { + { + 0, 0, 0 + }, + { + cfg.Radius, 0, 0 + }, + { + - cfg.Radius, 0, 0 + }, + { + 0, cfg.Radius, 0 + }, + { + 0, - cfg.Radius, 0 + } + } + }) for (float height : + { + 0.8f, 4.5f + }) + { + Point a + { + from.X + offset.X, from.Y + offset.Y, from.Z + height + }; + Point b + { + to.X + offset.X, to.Y + offset.Y, to.Z + height + }; + if (! ray(a, b)) return false; + } + return true; + } + inline Result Next(Point const & from, Point const & goal, Settings const & cfg, Memory & memory, Sample const & sample, Ray const & ray) + { + float const distance = Horizontal(from, goal); + Surface const here = sample(from.X, from.Y), end = sample(goal.X, goal.Y); + if (! here.Valid || ! end.Valid || std::fabs(end.Ground - goal.Z) > 2.5f) return + { + }; + if (distance < 2 && std::fabs(from.Z - goal.Z) < 1.5f) return + { + goal, Corridor(from, goal, goal, cfg, sample, ray, true), false, true + }; + float ux = distance > 0.1f ?(goal.X - from.X) / distance : memory.HeadingX; + float uy = distance > 0.1f ?(goal.Y - from.Y) / distance : memory.HeadingY; + if (distance > 0.1f) + { + memory.HeadingX = ux; + memory.HeadingY = uy; + } + std::array < float, 5 > heights; + float ceiling = here.Ceiling; + for (unsigned i = 0; i < heights.size(); ++ i) + { + float d = std::min(distance, float(i) * cfg.Lookahead / 4); + Surface s = sample(from.X + ux * d, from.Y + uy * d); + if (! s.Valid) return + { + }; + heights[i] = s.Ground; + ceiling = std::min(ceiling, s.Ceiling); + } + std::sort(heights.begin(), heights.end()); + float desired = heights[2] + cfg.Clearance; + // ignore short radical peaks in nominal height + bool const approach = distance < std::max(150.0f,(from.Z - goal.Z) / cfg.Slope + cfg.Step); + if (approach) desired = goal.Z + distance * cfg.Slope * 0.65f; + if (! approach && desired < from.Z - 10) + { + memory.LowerTerrainDistance += cfg.Step; + if (memory.LowerTerrainDistance < 150) desired = from.Z; + } + else memory.LowerTerrainDistance = 0; + float const horizon = std::min(cfg.Lookahead, std::max(cfg.Step, distance)); + bool const departure = ! memory.Departed; + if (departure) desired = std::max(desired, here.Ground + 20); + desired = std::min(desired, ceiling - 6); + Result best; + float bestScore = std::numeric_limits < float >::infinity(); + // Gentle nominal height tracking is separate from obstacle-climbing + // candidates. Upward avoidance can exceed nominal clearance, never ceiling. + for (float angle : + { + 0.0f, 0.45f, - 0.45f, 0.9f, - 0.9f, 1.35f, - 1.35f + }) for (float extraRise : + { + 0.0f, - 1.0f, 25.0f, 55.0f + }) + { + float dx = ux * std::cos(angle) - uy * std::sin(angle); + float dy = uy * std::cos(angle) + ux * std::sin(angle); + float dz = std::clamp(desired - from.Z, - horizon * cfg.Slope, horizon * cfg.Slope); + if (extraRise < 0) dz = 0; + if (extraRise > 0) dz = std::min(horizon * cfg.Slope, std::max(0.0f, dz) + extraRise); + float run = std::min(horizon, distance); + // If an avoidance climb left us high near the destination, first + // open the approach instead of descending vertically onto the goal. + if (approach && distance < 2 *(from.Z - goal.Z) / cfg.Slope && extraRise == 0) + { + dz = - std::min(horizon * cfg.Slope * 0.7f, std::max(0.0f, from.Z - goal.Z)); + if (distance <(from.Z - goal.Z) / cfg.Slope * 0.7f) + { + dx = - dx; + dy = - dy; + run = horizon; + dz = std::min(0.0f, std::max(dz, here.Ground + cfg.Safety + 4 - from.Z)); + } + } + Point far + { + from.X + dx * run, from.Y + dy * run, from.Z + dz + }; + if (! departure && approach && angle == 0 && extraRise == 0 && distance <= horizon && std::fabs(from.Z - goal.Z) <= distance * cfg.Slope) far = goal; + if (departure && far.Z <= from.Z + 0.5f) continue; + if (Distance(from, far) < 0.5f ||(Horizontal(from, far) < 1 && std::fabs(far.Z - from.Z) > 2)) continue; + if (! Corridor(from, far, goal, cfg, sample, ray, departure)) continue; + float score = Distance(far, goal) + run + std::fabs(angle) * 20 + std::max(0.0f, extraRise) * 0.4f + std::fabs(far.Z - desired) * 0.5f; + bool const openApproach = approach && angle == 0 && extraRise == 0; + if (!openApproach && score >= bestScore) continue; + float t = std::min(1.0f, cfg.Step / std::max(1.0f, Horizontal(from, far))); + best = + { + Mix(from, far, t), true, angle != 0 || extraRise > 0, Distance(far, goal) < 0.1f && t == 1 + }; + bestScore = score; + // Follow a clear nominal approach immediately. Level-flight scoring + // must not postpone descent until directly above the destination. + if (openApproach) + { + memory.Departed = true; + return best; + } + } + if (best.Valid) memory.Departed = true; + return best; + } +} + +#endif diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index d602cb82a8..66cfe9b786 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -174,11 +174,11 @@ std::unordered_map ReadFloatDictionary(lua_State* L, int tab return out; } -std::vector ReadMountDictionary(lua_State* L, int movementTable) +std::vector ReadMountDictionary(lua_State* L, int movementTable, char const* field = "ground_mounts") { std::vector mounts; movementTable = lua_absindex(L, movementTable); - lua_getfield(L, movementTable, "ground_mounts"); + lua_getfield(L, movementTable, field); if (!lua_istable(L, -1)) { lua_pop(L, 1); @@ -371,6 +371,8 @@ bool ClientlessLuaEngine::LoadAdventureProfile(Player* player) profile.GroundMountSpeedRate = std::max(1.0f, ReadFloatField(_state, -1, "ground_mount_speed_rate", 1.6f)); profile.GroundMounts = ReadMountDictionary(_state, -1); + profile.CanFreeFly = ReadBoolField(_state, -1, "can_free_fly", false); + profile.FlightMounts = ReadMountDictionary(_state, -1, "flight_mounts"); } lua_pop(_state, 1); diff --git a/src/server/game/World/ClientlessLuaEngine.h b/src/server/game/World/ClientlessLuaEngine.h index deba3812ba..2db1a9094b 100644 --- a/src/server/game/World/ClientlessLuaEngine.h +++ b/src/server/game/World/ClientlessLuaEngine.h @@ -31,6 +31,9 @@ struct TC_GAME_API ClientlessAdventureProfile float GroundMountMinDistance = 60.0f; float GroundMountSpeedRate = 1.6f; std::vector GroundMounts; + bool CanFreeFly = false; + // Flight dictionaries use learned mount SPELL IDs, never display IDs. + std::vector FlightMounts; // Parsed now; consumed later by the weighted intent/day planner. std::unordered_map Personality; diff --git a/src/server/game/World/ClientlessPlayerMgr.cpp b/src/server/game/World/ClientlessPlayerMgr.cpp index 9ffa3b1732..63ccc71479 100644 --- a/src/server/game/World/ClientlessPlayerMgr.cpp +++ b/src/server/game/World/ClientlessPlayerMgr.cpp @@ -3,6 +3,7 @@ #include "ClientlessTransportMgr.h" #include "ClientlessPerceptionMgr.h" #include "ClientlessActionExecutorMgr.h" +#include "ClientlessFlightMgr.h" #include "Log.h" #include "Player.h" #include "World.h" @@ -149,6 +150,7 @@ void ClientlessPlayerMgr::Update(uint32 diff) sClientlessPerceptionMgr->Update(diff); // CLIENTLESS_PLAYER_CORE_C8_4_ACTION_EXECUTOR sClientlessTransportMgr->Update(diff); + sClientlessFlightMgr->Update(diff); sClientlessActionExecutorMgr->Update(diff); for (auto itr = _records.begin(); itr != _records.end(); ) @@ -160,6 +162,7 @@ void ClientlessPlayerMgr::Update(uint32 diff) if (state == ClientlessPlayerState::Offline) { sClientlessActionExecutorMgr->Clear(characterGuid); + sClientlessFlightMgr->Clear(characterGuid); itr = _records.erase(itr); continue; } diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index 05118b940c..ee2dd684ca 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -1,5 +1,6 @@ #include "ClientlessTravelGraphMgr.h" #include "ClientlessTransportMgr.h" +#include "ClientlessFlightMgr.h" #include "DatabaseEnv.h" #include "Log.h" @@ -509,6 +510,11 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( std::unordered_set const* blockedBridgeKeys, ClientlessTravelLocationGoal const* locationGoal) { + sClientlessFlightMgr->BeginPlanning(); + struct FlightPlanningScope + { + ~FlightPlanningScope() { sClientlessFlightMgr->BeginPlanning(); } + } flightPlanningScope; // VCORE_C819_DEFERRED_START_NODES: run the unchanged C8.18 search in two tiers. // A partial ordinary route must not suppress a complete route via a deferred root. auto runSearch = [&](bool allowDeferred) -> bool @@ -533,6 +539,14 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( return false; } + bool const flightEnabled = sClientlessFlightMgr->Enabled(player); + auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z) + { + if (!flightEnabled || from.MapId != map) + return std::numeric_limits::infinity(); + Position a,b; a.Relocate(from.X,from.Y,from.Z,from.O); b.Relocate(x,y,z); + return sClientlessFlightMgr->Estimate(player,map,a,b); + }; if (startRadius <= 0.0f) startRadius = 500.0f; // C8.18: this cap applies ONLY to virtual node-to-node bridges. @@ -853,14 +867,20 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( float LocalCost = 0.0f; float DistanceSq = 0.0f; ClientlessTravelEdgePoint Point; + bool Flight = false; }; float const locationAccessRadius = locationGoal ? std::min(300.0f, locationGoal->AnchorRadius) : 0.0f; std::vector rawExitCandidates; std::unordered_map> exitsByFrom; - if (locationGoal) + if (locationGoal || flightEnabled) { - float const radius = locationAccessRadius; + auto const& targetNode = destinationItr->second; + uint32 const goalMap = locationGoal ? locationGoal->MapId : targetNode.MapId; + float const goalX = locationGoal ? locationGoal->X : targetNode.X; + float const goalY = locationGoal ? locationGoal->Y : targetNode.Y; + float const goalZ = locationGoal ? locationGoal->Z : targetNode.Z; + float const radius = flightEnabled ? 20000.0f : locationAccessRadius; std::unordered_set scannedDirected; for (auto const& edgePair : _edges) { @@ -899,13 +919,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( float dz = ordered[i].Z - ordered[i - 1].Z; travelled += std::sqrt(dx * dx + dy * dy + dz * dz); ClientlessTravelEdgePoint const& point = ordered[i]; - if (point.MapId != locationGoal->MapId) + if (point.MapId != goalMap) continue; - dx = point.X - locationGoal->X; - dy = point.Y - locationGoal->Y; - dz = point.Z - locationGoal->Z; + dx = point.X - goalX; + dy = point.Y - goalY; + dz = point.Z - goalZ; float const dsq = dx * dx + dy * dy + dz * dz; - if (dsq > radius * radius || dsq >= nearest.DistanceSq) + if (dsq > radius * radius) continue; std::string const exitKey = "exit:" + std::to_string(edge.DatabaseId) + ":" + std::to_string(i) + (edge.Reverse ? "R" : "F"); @@ -919,7 +939,18 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( candidate.RoadCost = edge.Cost * (travelled / totalLength); candidate.DistanceSq = dsq; candidate.Point = point; - candidate.LocalCost = std::sqrt(dsq); + candidate.LocalCost = locationGoal && dsq <= locationAccessRadius*locationAccessRadius + ? std::sqrt(dsq) : std::numeric_limits::infinity(); + ClientlessTravelNode exitNode; + exitNode.MapId=point.MapId;exitNode.X=point.X;exitNode.Y=point.Y;exitNode.Z=point.Z; + float const air = blockedBridgeKeys && blockedBridgeKeys->count("flight_exit:"+exitKey) + ? std::numeric_limits::infinity() : flightCost(exitNode,goalMap,goalX,goalY,goalZ); + if (air < candidate.LocalCost) { candidate.LocalCost=air;candidate.Flight=true; } + if (!std::isfinite(candidate.LocalCost)) continue; + // Never replace a node goal with an unvalidated ground exit. + if (!locationGoal && !candidate.Flight) continue; + if (std::isfinite(nearest.DistanceSq) && + candidate.RoadCost+candidate.LocalCost >= nearest.RoadCost+nearest.LocalCost) continue; nearest = std::move(candidate); } if (std::isfinite(nearest.DistanceSq)) @@ -946,10 +977,11 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( } bestExit; float bestGoalCost = std::numeric_limits::infinity(); std::string bestGoalState; + bool bestFinalFlight = false; // C8.20: a virtual entry is represented in the queue by its FAR // endpoint. Check exits BEFORE that endpoint independently: otherwise // an exit halfway along the road would pay for the entire remaining edge. - if (locationGoal) + if (locationGoal || flightEnabled) { for (auto const& root : virtualRoots) { @@ -1001,9 +1033,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( if (distanceItr == distance.end() || current.Cost != distanceItr->second) continue; + if (current.Cost >= bestGoalCost) break; if (!locationGoal && current.Value.Node == destinationNode) { bestGoalState = currentKey; + bestGoalCost = current.Cost; + bestExit = {}; + bestFinalFlight = false; break; } @@ -1025,18 +1061,22 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( float const direct = std::sqrt(dx*dx + dy*dy + dz*dz); // Use the same local access limit for nodes and road exits. // Otherwise a distant node can bypass a closer usable exit. - if (direct <= locationAccessRadius) + float const air = blockedBridgeKeys && blockedBridgeKeys->count("flight_finish:"+candidateNode.Key) + ? std::numeric_limits::infinity() : flightCost(candidateNode,locationGoal->MapId,locationGoal->X,locationGoal->Y,locationGoal->Z); + if (direct <= locationAccessRadius || std::isfinite(air)) { // Node anchors and virtual exits use the same estimate. // Neither is rejected because a remote tile is unloaded. std::string const finishKey = "finish:" + candidateNode.Key; float const finalCost = blockedBridgeKeys && blockedBridgeKeys->count(finishKey) - ? std::numeric_limits::infinity() : direct; + ? std::numeric_limits::infinity() : + std::min(direct<=locationAccessRadius ? direct : std::numeric_limits::infinity(),air); if (current.Cost + finalCost < bestGoalCost) { bestGoalCost = current.Cost + finalCost; bestGoalState = currentKey; bestExit = {}; + bestFinalFlight = air <= finalCost; } } } @@ -1058,6 +1098,18 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( } } + if (!locationGoal && flightEnabled) + { + auto exits=exitsByFrom.find(current.Value.Node); + if (exits!=exitsByFrom.end()) + for (auto const& exit:exits->second) + if (current.Cost+exit.RoadCost+exit.LocalCostcount("flight:"+origin.Key+"->"+target.Key)) continue; + float cost=flightCost(origin,target.MapId,target.X,target.Y,target.Z); + if (!std::isfinite(cost)) continue; + State next{target.Key,false};std::string key=stateKey(next); + float score=current.Cost+cost; + auto old=distance.find(key);if(old!=distance.end()&&old->second<=score)continue; + distance[key]=score;states[key]=next;persistentHops[key]=persistentHops[currentKey]+1; + previous[key]={currentKey,current.Value.Node,"free_flight",0,false}; + open.push({score,next}); + } + } + // A local bridge is deliberately impossible immediately after another // local bridge. This prevents MMAP from silently becoming a long-range // planner while still allowing unlimited bridges over a long journey @@ -1319,7 +1393,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( } } - if (locationGoal && bestExit.Active && bestExit.RootPartial && + if ((locationGoal || flightEnabled) && bestExit.Active && bestExit.RootPartial && !bestGoalState.empty()) previous.erase(bestGoalState); std::string selectedState = bestGoalState; @@ -1515,7 +1589,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( route.push_back(std::move(step)); } - if (locationGoal && complete && bestExit.Active) + if ((locationGoal || flightEnabled) && complete && bestExit.Active) { ExitCandidate const& exit = bestExit.Candidate; if (bestExit.RootPartial) @@ -1581,6 +1655,22 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( "C820_NODE_FINISH node='%s' fullCost=%.1f", reachedNode.c_str(), bestGoalCost); + if (complete && (bestExit.Active ? bestExit.Candidate.Flight : bestFinalFlight)) + { + ClientlessTravelRouteStep flight; + auto const& target=destinationItr->second; + flight.NodeKey=locationGoal ? "flight_location" : target.Key; + flight.NodeType=locationGoal ? "location" : target.Type; + flight.EdgeType="free_flight"; + flight.MapId=locationGoal ? locationGoal->MapId : target.MapId; + flight.X=locationGoal ? locationGoal->X : target.X; + flight.Y=locationGoal ? locationGoal->Y : target.Y; + flight.Z=locationGoal ? locationGoal->Z : target.Z; + flight.O=locationGoal ? 0 : target.O; + route.push_back(std::move(flight)); + reachedNode=locationGoal ? "flight_location" : target.Key; + } + TC_LOG_INFO("server.adventurebot.navigation", "C818_PREFLIGHT complete=%u bridgeTests=%u bridgeAccepted=%u bridgeRejected=%u " "distanceRejected=%u forwardTests=%u forwardAccepted=%u steps=%u", diff --git a/src/server/scripts/Custom/clientless/adventure/profiles/arthoran.lua b/src/server/scripts/Custom/clientless/adventure/profiles/arthoran.lua index abbd0fc410..323bdeb0a3 100644 --- a/src/server/scripts/Custom/clientless/adventure/profiles/arthoran.lua +++ b/src/server/scripts/Custom/clientless/adventure/profiles/arthoran.lua @@ -8,6 +8,8 @@ return { movement = { can_ground_mount = true, + can_free_fly = true, + flight_mounts = { [32235] = 100 }, -- Golden Gryphon, learned mount spell ground_mount_min_distance = 45, ground_mount_speed_rate = 1.60, -- [mount display ID] = random weight diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index 8b8eabef71..af4737fd5b 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -13,6 +13,7 @@ #include "Chat.h" #include "ClientlessPlayerMgr.h" #include "ClientlessTransportMgr.h" +#include "ClientlessFlightMgr.h" #include "ClientlessTravelMgr.h" #include "ClientlessPerceptionMgr.h" #include "ClientlessActionExecutorMgr.h" @@ -2202,6 +2203,9 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg return true; } + static bool HandleClientlessFlight(ChatHandler* handler, char const* args) + { return sClientlessFlightMgr->Command(handler,args); } + static bool HandleClientlessTransport(ChatHandler* handler, char const* args) { return sClientlessTransportMgr->Command(handler, args); @@ -2274,6 +2278,7 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg { "import", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiImport, "" }, { "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiInfo, "" }, } }, + { "flight", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessFlight, "" }, { "portal", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", { { "object", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", { { "import", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessPortalObjectImport, "" }, diff --git a/src/server/worldserver/worldserver.conf.dist b/src/server/worldserver/worldserver.conf.dist index 408484bc67..a5050bbcb1 100644 --- a/src/server/worldserver/worldserver.conf.dist +++ b/src/server/worldserver/worldserver.conf.dist @@ -4331,3 +4331,12 @@ Metric.ConnectionInfo = "127.0.0.1;8086;worldserver" Metric.OverallStatusInterval = 1 ################################################################################################### + +# Dynamic free flight is profile opt-in. No free-flight nodes/edges are stored. +Clientless.Flight.Enabled = 1 +# Desired clearance, not strict terrain following; hard safety/ceiling checks remain. +Clientless.Flight.Clearance = 60 +# RP penalty normally favors roads. Zone IDs below prefer free flight, including +# the final node/virtual-exit -> location leg (Icecrown, Wintergrasp). +Clientless.Flight.MinDistance = 600 +Clientless.Flight.PreferredZones = "210 4197" diff --git a/tests/clientless/test_flight_policy.py b/tests/clientless/test_flight_policy.py new file mode 100644 index 0000000000..1eb4170c21 --- /dev/null +++ b/tests/clientless/test_flight_policy.py @@ -0,0 +1,98 @@ +"""Compile production flight eligibility/cost and fail-safe abort decisions with Core doubles.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +source=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text() +def function(signature): + a=source.index(signature);opening=source.index('{',a);depth=1;b=opening+1 + while depth: + depth+=(source[b]=='{')-(source[b]=='}');b+=1 + return source[a:b] +harness=r''' +#include "ClientlessFlightPath.h" +#include +#include +#include +#include +#include +#include +using uint32=uint32_t; +using namespace ClientlessFlightPath; +#define TC_LOG_WARN(...) ((void)0) +constexpr int SKILL_RIDING=762,MAP_COMMON=0,SPELL_AURA_MOUNT_RESTRICTIONS=1; +struct Position{float x=0,y=0,z=0;float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}}; +Point PointOf(Position const&p){return {p.x,p.y,p.z};}float Infinity(){return std::numeric_limits::infinity();} +struct Guid{uint32 GetCounter()const{return 26;}}; +struct Player{ + bool license=true,cold=true,learned=true,alive=true,canFly=true,restrictions=false; + uint32 skill=225,map=0,area=87;int stops=0,interrupts=0; + Guid GetGUID()const{return {};} + uint32 GetSkillValue(int)const{return skill;} + bool HasSpell(uint32 s)const{return s==90267?license:s==54197?cold:learned;} + uint32 GetMapId()const{return map;}uint32 GetAreaId()const{return area;} + bool HasAuraType(int)const{return restrictions;} + bool CanFly(){return canFly;}bool IsAlive(){return alive;} + void StopMoving(){++stops;}void InterruptNonMeleeSpells(bool){++interrupts;} + float GetPositionX(){return 0;}float GetPositionY(){return 0;}float GetPositionZ(){return 50;} +}; +struct Mount{uint32 DisplayId=32235,Weight=100;}; +struct Profile{bool CanFreeFly=true;std::vector FlightMounts{{}};} profile; +struct Lua{auto GetAdventureProfile(uint32){return &profile;}} lua; +struct Config{bool enabled=true;bool GetBoolDefault(const char*,bool){return enabled;}float GetFloatDefault(const char*,float v){return v;}std::string GetStringDefault(const char*,const char*v){return v;}} config; +auto sClientlessLuaEngine=&lua;auto sConfigMgr=&config; +struct SpellMgr{void* GetSpellInfo(uint32){return this;}} spells;auto sSpellMgr=&spells; +uint32 FlightType(void*){return 1;} +struct MapEntry{uint32 MapType=0;} entry;struct Maps{auto LookupEntry(uint32){return &entry;}} maps;auto& sMapStore=maps; +enum class AreaMountFlags{AllowFlyingMounts}; +struct Flags{bool fly=true;bool HasFlag(AreaMountFlags){return fly;}}; +struct Area{bool fly=true;uint32 World_pvp_ID=0;Flags GetMountFlags()const{return {fly};}} area; +struct Areas{bool known=true;auto LookupEntry(uint32){return known?&area:nullptr;}} areas;auto& sAreaTableStore=areas; +enum class ClientlessFlightState{Mounting,Flying,Arrived,Failed}; +struct Record{ + struct{ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;std::string Detail;}Status; + bool Moving=true,Returning=false;std::vector Breadcrumbs{{}};std::string AbortReason; +}; +class ClientlessFlightMgr{public: + uint32 areaCode=87,zoneCode=12; + std::pair AreaZone(Player const*,uint32,Position const&)const{return {areaCode,zoneCode};} + bool Enabled(Player const*)const;bool Preferred(Player const*,uint32,Position const&)const; + float Estimate(Player const*,uint32,Position const&,Position const&)const; + void Fail(Player*,Record&,std::string const&); +}; +''' +for signature in ('bool ClientlessFlightMgr::Enabled(', 'bool ClientlessFlightMgr::Preferred(', 'float ClientlessFlightMgr::Estimate(', 'void ClientlessFlightMgr::Fail('): + harness+=function(signature)+'\n' +harness+=r''' +int main(){ + ClientlessFlightMgr mgr;Player p;Position a,b;b.x=1000; + float roadBias=mgr.Estimate(&p,0,a,b);assert(roadBias>1000&&std::isfinite(roadBias)); + p.license=false;assert(!std::isfinite(mgr.Estimate(&p,0,a,b)));p.license=true; + p.cold=false;assert(!std::isfinite(mgr.Estimate(&p,571,a,b)));p.cold=true; + p.skill=150;assert(!mgr.Enabled(&p));p.skill=225; + p.learned=false;assert(!mgr.Enabled(&p));p.learned=true; + profile.CanFreeFly=false;assert(!mgr.Enabled(&p));profile.CanFreeFly=true; + entry.MapType=1;assert(!std::isfinite(mgr.Estimate(&p,0,a,b)));entry.MapType=0; + // Eversong/Ghostlands and Deeprun use native mount flags 13, without bit 2. + area.fly=false;assert(!std::isfinite(mgr.Estimate(&p,530,a,b))); + assert(!std::isfinite(mgr.Estimate(&p,369,a,b))); + area.fly=true;mgr.zoneCode=210; + assert(mgr.Estimate(&p,571,a,b) +#include +using namespace ClientlessFlightPath; +struct Run {Point End;float Peak=0,Side=0;unsigned Steps=0,Avoidances=0;bool Arrived=false;}; +Run simulate(Point start,Point target,Sample sample,Ray ray,int limit=500){ + Settings cfg;Memory mem;mem.Departed=start.Z-sample(start.X,start.Y).Ground>cfg.Safety;Run r;Point p=start; + for(int i=0;i30&&normal.Peak<=60.1f); + auto low=[](float,float){return Surface{0,42,true};}; + auto ceiling=simulate({0,0,0},{800,0,0},low,clear); + assert(ceiling.Arrived&&ceiling.Peak<=36.1f); + auto peak=[](float x,float){return Surface{x>390&&x<400?40.0f:0.0f,300,true};}; + auto smooth=simulate({0,0,0},{800,0,0},peak,clear); + assert(smooth.Arrived&&smooth.Peak<75); // no nominal clearance chase over short peak + auto wall=[](Point const&a,Point const&b){ + for(int i=0;i<=80;++i){auto p=Mix(a,b,i/80.0f);if(p.X>=240&&p.X<=260&&std::fabs(p.Y)<16&&p.Z<110)return false;}return true; + }; + auto side=simulate({0,0,0},{800,0,0},flat,wall); + assert(side.Arrived&&side.Avoidances>0&&side.Side>10); + auto wide=[](Point const&a,Point const&b){ + for(int i=0;i<=80;++i){auto p=Mix(a,b,i/80.0f);if(p.X>=240&&p.X<=260&&std::fabs(p.Y)<500&&p.Z<95)return false;}return true; + }; + auto climb=simulate({0,0,0},{800,0,0},flat,wide); + assert(climb.Arrived&&climb.Peak>95&&climb.Avoidances>0); + auto unknown=[](float x,float){return Surface{0,300,x<120};}; + assert(!simulate({0,0,0},{800,0,0},unknown,clear).Arrived); + auto blocked=[](Point const&,Point const&){return false;}; + assert(!simulate({0,0,0},{800,0,0},flat,blocked).Arrived); + // Missing/covered/wrong-floor landing is not an arrival. + auto invalidEnd=[](float x,float){return Surface{x>790?20.0f:0.0f,300,true};}; + assert(!simulate({0,0,0},{800,0,0},invalidEnd,clear).Arrived); + auto shortFlight=simulate({0,0,0},{90,0,0},flat,clear); + assert(shortFlight.Arrived&&shortFlight.Peak>5); + // An avoidance may leave the bot high immediately above the landing site. + // Open a descending approach instead of oscillating over the target. + Memory landingMemory;landingMemory.Departed=true; + Point landingStart{660,0,60}; + auto firstDescent=Next(landingStart,{800,0,0},Settings{},landingMemory,flat,clear); + assert(firstDescent.Valid&&firstDescent.Next.X>660&&firstDescent.Next.Z<60); + auto highApproach=simulate({0,0,70},{1,0,0},flat,clear); + assert(highApproach.Arrived&&highApproach.Steps<20&&highApproach.Peak<=70); + std::cout<<"Flight geometry passed: takeoff/landing, smoothed peak, ceiling, lateral/climbing avoidance, missing terrain and blocked corridors.\n"; +} +''' +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) diff --git a/tests/clientless/test_free_flight_planner.py b/tests/clientless/test_free_flight_planner.py new file mode 100644 index 0000000000..aa1ce8213f --- /dev/null +++ b/tests/clientless/test_free_flight_planner.py @@ -0,0 +1,112 @@ +from pathlib import Path +import subprocess +import tempfile +root=Path(__file__).resolve().parents[2] +h=(root/'src/server/game/World/ClientlessTravelGraphMgr.h').read_text() +s=(root/'src/server/game/World/ClientlessTravelGraphMgr.cpp').read_text() +structs=h[h.index('struct TC_GAME_API ClientlessTravelNode'):h.index('class TC_GAME_API ClientlessTravelGraphMgr')].replace('TC_GAME_API ','') +method=s[s.index('bool ClientlessTravelGraphMgr::PlanAdaptiveRoute('):s.index('bool ClientlessTravelGraphMgr::PlanRouteToPosition(')] +signature=h[h.index(' bool PlanAdaptiveRoute('):h.index(' bool FindNearestNodeToPosition(')] +preamble=r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using uint32=uint32_t; using int32=int32_t; +#define TC_LOG_INFO(...) do {} while(0) +#define TC_LOG_DEBUG(...) do {} while(0) +#define TC_LOG_WARN(...) do {} while(0) +constexpr uint32 NAV_GROUND=1, PATHFIND_INCOMPLETE=2, PATHFIND_NOPATH=4; +struct Position { float x=0,y=0,z=0; void Relocate(float a,float b,float c,float=0){x=a;y=b;z=c;} }; +struct Player { float x=0,y=0,z=0; bool IsInWorld() const{return true;} uint32 GetMapId()const{return 0;} uint32 GetInstanceId()const{return 0;} float GetPositionX()const{return x;} float GetPositionY()const{return y;} float GetPositionZ()const{return z;} }; +float dist(Position a,Position b){return std::sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z));} +struct ClientlessPathDiagnostics {bool Calculated=true, Complete=true,Suspicious=false; uint32 PathType=1,PointCount=2; float DirectDistance=0,PathLength=0,MaxSegmentLength=0,MaxZDelta=0,FinalDistance=0; std::string Reason;}; +struct Travel {void AnalyzePath(Player* p,Position b,ClientlessPathDiagnostics& n){Position a;a.Relocate(p->x,p->y,p->z);n.PathLength=n.DirectDistance=dist(a,b);}} travel; +auto sClientlessTravelMgr=&travel; +struct Flight { + bool enabled=false;float minimumX=-10000; + void BeginPlanning(){} + bool Enabled(Player const*){return enabled;} + float Estimate(Player const*,uint32 map,Position a,Position b){ + float d=dist(a,b);return enabled&&map==0&&a.x>=minimumX&&d>=60?180+d*1.5f:std::numeric_limits::infinity(); + } +} flight; +auto sClientlessFlightMgr=&flight; +struct VcorePositionPathResult {bool complete=false; uint32 Result=2;float FinalDistance=0,PathLength=0;bool IsComplete()const{return complete;}}; +struct PathGenerator {static inline bool available=false; static inline float maxDistance=10000;static VcorePositionPathResult AnalyzePositions(uint32,uint32,uint32,Position a,Position b,uint32){float d=dist(a,b);return {available && d<=maxDistance,2,0,d};}}; +std::string MakeLocalBridgeKey(std::string const&a,std::string const&b){return a+"->"+b;} +''' +cls='class ClientlessTravelGraphMgr { public: bool EnsureLoaded(){return true;}\n'+signature+''' +std::unordered_map _nodes; +std::unordered_map> _edges; +std::unordered_map> _edgePoints; +}; +''' +tests=r''' +void node(ClientlessTravelGraphMgr& g,std::string k,float x,float y=0){ClientlessTravelNode n;n.Key=k;n.X=x;n.Y=y;g._nodes[k]=n;} +void edge(ClientlessTravelGraphMgr&g,std::string a,std::string b,uint32 id,float cost,bool rev=false,std::string type="ground"){g._edges[a].push_back({id,a,b,type,cost,rev});} +std::vector plan(ClientlessTravelGraphMgr&g,Player p,ClientlessTravelLocationGoal goal,float radius,std::unordered_set blocked={}){ +std::vector r;bool complete=false;std::string reached,error; +assert(g.PlanAdaptiveRoute(&p,"B",radius,200,r,complete,reached,error,nullptr,&blocked,&goal));if(!complete){std::cerr<<"incomplete x="< route;bool done=false;std::string endKey,why; +assert(air.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why)); +assert(done&&route.size()==2&&route.back().EdgeType=="free_flight"&&route.back().EdgeDatabaseId==0); +assert(air._edges.empty()&&air._nodes.size()==2); +ClientlessTravelGraphMgr road=air;edge(road,"A","B",9,1000); +assert(road.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why)); +assert(done&&route.back().EdgeType=="ground"); +ClientlessTravelLocationGoal far;far.X=2000;far.AnchorRadius=50; +assert(air.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,&far)); +assert(done&&route.back().NodeKey=="flight_location"&&route.back().EdgeType=="free_flight"); +// A permissible takeoff halfway along a real road may beat its far endpoint. +ClientlessTravelGraphMgr exits;node(exits,"A",0);node(exits,"B",1000);node(exits,"D",500,800); +edge(exits,"A","B",8,1000);for(float x:{0.f,100.f,500.f,900.f,1000.f}){ClientlessTravelEdgePoint pt;pt.X=x;exits._edgePoints[8].push_back(pt);} +flight.minimumX=400; +assert(exits.PlanAdaptiveRoute(&start,"D",1,200,route,done,endKey,why)); +assert(done&&route.back().NodeKey=="D"&&route.back().EdgeType=="free_flight"); +assert(route[route.size()-2].NodeKey=="virtual_ground_exit"); +for(auto const& step:route)if(step.EdgeType=="free_flight")assert(step.NodeKey!="virtual_ground_entry"); +std::cout<<"ground planner regressions and dynamic flight/node/location/virtual-exit integration passed\n"; +} +''' +# Last direction case must be unreachable: explicitly inspect a partial result. +tests=tests.replace('r=plan(g,p,goal,1);assert(r.back().GroundStartIndex<0); // uses ordinary B anchor when expanded below','std::vector partial;bool complete;std::string reached,error;assert(g.PlanAdaptiveRoute(&p,"B",1,200,partial,complete,reached,error,nullptr,nullptr,&goal));assert(!complete);') +with tempfile.TemporaryDirectory(prefix="vcore-flight-planner-") as folder: + cpp=Path(folder)/"test.cpp";binary=Path(folder)/"test" + cpp.write_text(preamble+structs+cls+method+tests) + subprocess.run(["c++","-std=c++17","-O0",str(cpp),"-o",str(binary)],check=True) + subprocess.run([str(binary)],check=True)