Compare commits

...
2 Commits
Author SHA1 Message Date
Nico ca656403bb Keep clientless flight moving across spline handoffs
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Waiting to run
Replace ordinary flight splines before they finish while retaining collision checks, terminal landing and conservative return behavior. Measure actual travelled distance, cover handoff timing with a production Update regression test, retire Arthoran's automatic ship test and document visual acceptance of the travel milestone.
2026-10-26 18:21:48 +01:00
Nico 8df9591c99 Implement dynamic clientless free-flight travel
Integrate transient flight alternatives with graph routing, native flying mounts, phased terrain and collision checks, bounded avoidance and confirmed landing. Add safe-return recovery, node and location integration, profile settings, diagnostic commands, regression tests and flight documentation.
2026-10-26 17:35:58 +01:00
23 changed files with 1676 additions and 19 deletions

No files matched your search

@@ -0,0 +1,162 @@
# 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.
Im normalen Flug wird die nächste Spline bis zu 600 ms vor dem Ende der laufenden
Spline ab der aktuellen Position gestartet. Dadurch entsteht zwischen den
Abschnitten kein regulärer Halt. Der neue Korridor wird vollständig geprüft;
Landungen und die konservative Rückkehr über die Flugspur erreichen weiterhin
ihre Endpunkte. Die Wegstrecke zählt tatsächlich geflogene Bewegung, nicht die
überlappenden Längen der erteilten Bewegungsaufträge. `status` zeigt mit
`handoffs` die Anzahl der Übergaben während einer noch laufenden Spline.
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
python3 tests/clientless/test_flight_handoff.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.
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`.
## Sichtprüfung und Abschluss
Beim beobachteten Flug Goldhain → Nordhain fiel zunächst ein kurzes Anhalten
zwischen den Abschnitten auf. Die vorzeitige Spline-Übergabe behebt diese Lücke.
Der anschließende Rückflug Nordhain → Goldhain landete am 26.10.2026 um 18:09:14
nach 36 Abschnitten mit Zielabweichung 0,00. Das Log bestätigt Übergaben während
noch laufender Splines. Der Nutzer hat den Flug anschließend ausdrücklich als
sauber und flüssig bestätigt.
Damit ist der Entwicklungsmeilenstein Navigation und Fernreisen einschließlich
unabhängiger Flugreisen abgenommen. Die oben genannten Grenzen der begrenzten
Flugnavigation bleiben bestehen; weitere Strecken können schrittweise ergänzt
und geprüft werden. Mikroaktionen folgen als eigener Entwicklungsschritt.
+48
View File
@@ -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.
+40
View File
@@ -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)
+2
View File
@@ -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<map_liquidHeaderTypeFlags> ReqLiquidType, LiquidData* data = 0, float collisionHeight = 2.03128f) const; // DEFAULT_COLLISION_HEIGHT in Object.h
};
+8
View File
@@ -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))
+1
View File
@@ -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); }
@@ -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<record.Route.size())
{
auto const& source=record.Route[record.RouteStep-1];
auto const& target=record.Route[record.RouteStep];
if(source.NodeKey=="virtual_ground_exit")
record.FailedBridgeKeys.insert("flight_exit:exit:"+std::to_string(source.EdgeDatabaseId)+":"+std::to_string(source.GroundEndIndex)+(source.EdgeReverse?"R":"F"));
else if(target.NodeKey=="flight_location")record.FailedBridgeKeys.insert("flight_finish:"+source.NodeKey);
else record.FailedBridgeKeys.insert("flight:"+source.NodeKey+"->"+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);
@@ -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);
@@ -0,0 +1,525 @@
#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 "MoveSpline.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 <algorithm>
#include <cmath>
#include <limits>
#include <sstream>
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.LastPosition.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, bool handoff)
{
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
{
auto const memory = record.Memory;
next = Next(current, goal, record.Settings, record.Memory, sample, ray);
// Reissuing the same endpoint would only restart its final milliseconds.
// Let that spline finish instead; keep planning memory unchanged.
if (handoff && next.Valid && Distance(next.Next, PointOf(record.Segment)) < 1)
{
record.Memory = memory;
return true;
}
}
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;
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";
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;
}
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;
}
// Count actual motion, not overlapping issued spline lengths.
record.Status.Travelled += Distance(PointOf(record.LastPosition), PointOf(*player));
record.LastPosition.Relocate(player);
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);
}
else if (!record.Returning && player->movespline->timeRemaining() <= 600 &&
Distance(PointOf(record.Segment), PointOf(record.Status.Landing)) > 0.1f)
{
// Replace a still-running spline from its native current position.
// No StopMoving/arrival packet between ordinary flight segments.
// Landing and conservative breadcrumb return finish their endpoint.
Advance(player, record, true);
}
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 <onlineGuid> <x> <y>");
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 <guid> OR test_node <guid> <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 handoffs=%u travelled=%.1f detail='%s'", guid, StateName(status->State), status->MapId, status->MountSpell, status->Segments, status->Avoidances, status->Handoffs, status->Travelled, status->Detail.c_str());
return true;
}
@@ -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 <string>
#include <memory>
#include <unordered_map>
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, Handoffs = 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, LastPosition;
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, bool handoff = false);
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
@@ -0,0 +1,214 @@
#ifndef VCORE_CLIENTLESS_FLIGHT_PATH_H
#define VCORE_CLIENTLESS_FLIGHT_PATH_H
#include <algorithm>
#include <array>
#include <cmath>
#include <functional>
#include <limits>
// 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
@@ -174,11 +174,11 @@ std::unordered_map<std::string, float> ReadFloatDictionary(lua_State* L, int tab
return out;
}
std::vector<ClientlessWeightedMount> ReadMountDictionary(lua_State* L, int movementTable)
std::vector<ClientlessWeightedMount> ReadMountDictionary(lua_State* L, int movementTable, char const* field = "ground_mounts")
{
std::vector<ClientlessWeightedMount> 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);
@@ -31,6 +31,9 @@ struct TC_GAME_API ClientlessAdventureProfile
float GroundMountMinDistance = 60.0f;
float GroundMountSpeedRate = 1.6f;
std::vector<ClientlessWeightedMount> GroundMounts;
bool CanFreeFly = false;
// Flight dictionaries use learned mount SPELL IDs, never display IDs.
std::vector<ClientlessWeightedMount> FlightMounts;
// Parsed now; consumed later by the weighted intent/day planner.
std::unordered_map<std::string, float> Personality;
@@ -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;
}
@@ -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<std::string> 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<float>::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<ExitCandidate> rawExitCandidates;
std::unordered_map<std::string, std::vector<ExitCandidate>> 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<std::string> 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<float>::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<float>::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<float>::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<float>::infinity() : flightCost(candidateNode,locationGoal->MapId,locationGoal->X,locationGoal->Y,locationGoal->Z);
if (direct <= locationAccessRadius || std::isfinite(air))
{
// Node anchors and virtual exits use the same estimate.
// Neither is rejected because a remote tile is unloaded.
std::string const finishKey = "finish:" + candidateNode.Key;
float const finalCost = blockedBridgeKeys && blockedBridgeKeys->count(finishKey)
? std::numeric_limits<float>::infinity() : direct;
? std::numeric_limits<float>::infinity() :
std::min(direct<=locationAccessRadius ? direct : std::numeric_limits<float>::infinity(),air);
if (current.Cost + finalCost < bestGoalCost)
{
bestGoalCost = current.Cost + finalCost;
bestGoalState = currentKey;
bestExit = {};
bestFinalFlight = air <= finalCost;
}
}
}
@@ -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.LocalCost<bestGoalCost)
{
bestGoalCost=current.Cost+exit.RoadCost+exit.LocalCost;
bestGoalState=currentKey;bestExit={true,false,exit};
}
}
// VCORE_C816_PORTAL_MOUNT_GUARD: a source node is not ordinary
// ground transit. Once reached during a planned route, its only legal
// continuation is the portal edge itself. Physical AreaTriggers may
@@ -1098,6 +1150,28 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute(
}
}
// Dynamic flights connect real nodes; never the synthetic entry waypoints.
// Include them before the local-bridge guard: a flight is a separate
// macro travel mode with its own native departure and landing checks.
if (flightEnabled && !portalArrival)
{
auto const& origin=_nodes.at(current.Value.Node);
for (auto const& pair:_nodes)
{
auto const& target=pair.second;
if (target.Key==origin.Key || target.MapId!=origin.MapId) continue;
if (blockedBridgeKeys && blockedBridgeKeys->count("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",
@@ -1,3 +1,2 @@
-- Native sea-route test: Stormwind -> Ruttheran, no onward action.
log("SHIP_TEST_SW_RUTTHERAN loaded")
queue_action("travel_to_node", {node="ship_ruttheran_br", hard_destination=true})
-- Travel acceptance completed. Wait for explicit actions until daily behavior is configured.
log("Arthoran ready; no automatic test journey queued")
@@ -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
@@ -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, "" },
@@ -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"
+92
View File
@@ -0,0 +1,92 @@
"""Exercise production Update with a running native spline, not a string check."""
from pathlib import Path
import subprocess
import tempfile
root = Path(__file__).resolve().parents[2]
source = (root / 'src/server/game/World/ClientlessFlightMgr.cpp').read_text()
a = source.index('void ClientlessFlightMgr::Update(uint32 diff)')
b = source.index('bool ClientlessFlightMgr::Command(', a)
update = source[a:b]
harness = r'''
#include "ClientlessFlightPath.h"
#include <cassert>
#include <cstdint>
#include <string>
#include <unordered_map>
using uint32 = uint32_t;
using namespace ClientlessFlightPath;
struct Position {
float x=0,y=0,z=60;
float GetPositionX() const{return x;} float GetPositionY() const{return y;} float GetPositionZ() const{return z;}
void Relocate(Position const* p){*this=*p;}
};
Point PointOf(Position const& p){return {p.x,p.y,p.z};}
struct Spline {int remaining=500; int timeRemaining()const{return remaining;}};
struct Player:Position {
Spline spline; Spline* movespline=&spline;
bool alive=true,fly=true,running=true;int stops=0;
bool IsInWorld(){return true;} uint32 GetMapId(){return 0;}
bool IsAlive(){return alive;} bool IsBeingTeleported(){return false;}
bool IsMounted(){return true;} bool CanFly(){return fly;}
bool IsSplineEnabled(){return running;}
void StopMoving(){++stops;running=false;}
} player;
struct Session {Player* GetPlayer(){return &player;}} session;
enum class ClientlessPlayerState {Online};
struct World {
Session* FindClientlessSession(uint32){return &session;}
ClientlessPlayerState GetClientlessPlayerState(uint32){return ClientlessPlayerState::Online;}
} world;
auto sWorld=&world;
enum class ClientlessFlightState {Mounting,Flying,Arrived,Failed};
struct Record {
struct {ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;Position Landing{800,0,0};float Travelled=0;} Status;
Position LastPosition,Start,Segment{25,0,60};Settings Settings;
uint32 Elapsed=0,SegmentElapsed=0,CheckTimer=0;
bool Moving=true,Returning=false;
};
bool clear=true;
bool ClearRay(Player*,Point const&,Point const&){return clear;}
class ClientlessFlightMgr {
public:
std::unordered_map<uint32,Record> _records;
int advances=0,handoffs=0,failures=0;
Surface Sample(Player*,Record&,float,float){return {0,300,true};}
void Fail(Player*,Record& r,std::string const&){++failures;r.Status.State=ClientlessFlightState::Failed;}
bool Advance(Player*,Record&,bool handoff=false){++advances;handoffs+=handoff;return true;}
void Update(uint32 diff);
};
'''.replace('Settings Settings;', 'ClientlessFlightPath::Settings Settings;')
harness += update + r'''
int main(){
ClientlessFlightMgr mgr;mgr._records[26]={};
// Begin replacement while the existing spline is still active: no stop.
mgr.Update(50);assert(mgr.handoffs==1&&mgr.advances==1&&player.stops==0);
// The 200ms collision cadence must not launch a replacement each tick.
mgr.Update(50);assert(mgr.advances==1);
mgr._records[26].CheckTimer=0;player.spline.remaining=900;
mgr.Update(50);assert(mgr.advances==1);
// Exact landing endpoint must finish normally, never repeatedly extend.
auto& r=mgr._records[26];r.CheckTimer=0;r.Status.Landing=r.Segment;
player.spline.remaining=400;mgr.Update(50);assert(mgr.advances==1);
// Reverse trail keeps its original checked corners.
r.Segment={25,0,60};r.Returning=true;r.CheckTimer=0;
mgr.Update(50);assert(mgr.advances==1);
// Distance means measured motion, not unflown overlapping destinations.
player.x=7;r.CheckTimer=0;mgr.Update(50);assert(std::fabs(r.Status.Travelled-7)<0.01);
// A newly blocked corridor still stops/replans immediately.
clear=false;r.CheckTimer=0;mgr.Update(50);
assert(player.stops==1&&mgr.advances==2&&mgr.handoffs==1);
// A completed spline continues via the normal non-handoff path.
clear=true;r.CheckTimer=0;mgr.Update(50);assert(mgr.advances==3);
player.fly=false;mgr.Update(50);assert(mgr.failures==1);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-flight-handoff-') 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)
print('Flight handoff before spline end, terminal landing, return corners, collision stop and actual distance passed.')
+98
View File
@@ -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 <cassert>
#include <cstdint>
#include <string>
#include <vector>
#include <sstream>
#include <limits>
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<float>::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<Mount> 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<Position> Breadcrumbs{{}};std::string AbortReason;
};
class ClientlessFlightMgr{public:
uint32 areaCode=87,zoneCode=12;
std::pair<uint32,uint32> 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)<roadBias); // explicit zone preference
b.x=90;assert(std::isfinite(mgr.Estimate(&p,571,a,b)));
mgr.zoneCode=12;assert(!std::isfinite(mgr.Estimate(&p,0,a,b)));
// Unknown metadata is not an invented blanket prohibition; actual native
// mount capability/cast at departure still decides in Start().
areas.known=false;b.x=1000;assert(std::isfinite(mgr.Estimate(&p,0,a,b)));areas.known=true;
area.fly=false;area.World_pvp_ID=1;assert(std::isfinite(mgr.Estimate(&p,571,a,b)));area.World_pvp_ID=0;
// An airborne abort first returns. A blocked return must not dismount/drop.
Record r;mgr.Fail(&p,r,"blocked");assert(r.Returning&&r.Status.State==ClientlessFlightState::Flying&&p.stops==1);
mgr.Fail(&p,r,"reverse blocked");assert(r.Status.State==ClientlessFlightState::Failed);
Record mounting;mounting.Status.State=ClientlessFlightState::Mounting;mgr.Fail(&p,mounting,"cast rejected");
assert(mounting.Status.State==ClientlessFlightState::Failed&&p.interrupts==1&&!mounting.Returning);
Record revoked;p.canFly=false;mgr.Fail(&p,revoked,"permission lost");assert(revoked.Status.State==ClientlessFlightState::Failed&&!revoked.Returning);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-flight-policy-') 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)
print('Flight native eligibility, license/profile/zone policy, RP costs and fail-safe return decisions passed.')
+67
View File
@@ -0,0 +1,67 @@
"""Run the production 3D flight geometry against deterministic terrain/collisions."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
harness=r'''
#include "ClientlessFlightPath.h"
#include <cassert>
#include <iostream>
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;i<limit;++i){
auto next=Next(p,target,cfg,mem,sample,ray);
if(!next.Valid)break;
assert(Corridor(p,next.Next,target,cfg,sample,ray,p.Z-sample(p.X,p.Y).Ground<cfg.Safety));
assert(next.Next.Z+5<=sample(next.Next.X,next.Next.Y).Ceiling);
p=next.Next;++r.Steps;r.Peak=std::max(r.Peak,p.Z);r.Side=std::max(r.Side,std::fabs(p.Y));r.Avoidances+=next.Avoiding;
if(Distance(p,target)<0.8f){r.Arrived=true;break;}
}
r.End=p;return r;
}
int main(){
auto flat=[](float,float){return Surface{0,300,true};};
auto clear=[](Point const&,Point const&){return true;};
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};};
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)
@@ -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 <algorithm>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <limits>
#include <queue>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
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<float>::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<std::string,ClientlessTravelNode> _nodes;
std::unordered_map<std::string,std::vector<ClientlessTravelEdge>> _edges;
std::unordered_map<uint32,std::vector<ClientlessTravelEdgePoint>> _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<ClientlessTravelRouteStep> plan(ClientlessTravelGraphMgr&g,Player p,ClientlessTravelLocationGoal goal,float radius,std::unordered_set<std::string> blocked={}){
std::vector<ClientlessTravelRouteStep> 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="<<p.x<<" goal="<<goal.X<<" blocked="<<blocked.size()<<" error="<<error<<"\n";}assert(complete);return r;}
int main(){
ClientlessTravelGraphMgr g;node(g,"A",0);node(g,"B",1000);edge(g,"A","B",1,1000);
for(float x:{0.f,100.f,700.f,900.f,1000.f}){ClientlessTravelEdgePoint p;p.X=x;g._edgePoints[1].push_back(p);}
ClientlessTravelLocationGoal goal;goal.X=700;goal.Y=20;goal.AnchorRadius=50;
// A stored path exists. Its interior exit must still win without remote MMAP.
auto r=plan(g,{},goal,1);assert(r.back().GroundEndIndex==2);assert(r.back().X==700);
// Preserve a valid entry even when another entry reaches the endpoint cheaper.
Player p;p.x=100;r=plan(g,p,goal,900);assert(r.front().NodeKey=="virtual_ground_entry");assert(r.back().GroundStartIndex==1);assert(r.back().GroundEndIndex==2);
// Reverse direction uses execution-order indices.
ClientlessTravelGraphMgr rev=g;rev._edges.clear();edge(rev,"B","A",1,1000,true);p.x=900;goal.X=100;r=plan(rev,p,goal,1);assert(r.back().EdgeReverse);assert(r.back().GroundStartIndex==1);assert(r.back().GroundEndIndex==3);
// Blacklist the nearest failed exit: select the next eligible waypoint.
goal.X=700;goal.AnchorRadius=250;r=plan(g,{},goal,1,{"exit:1:2F"});assert(r.back().GroundEndIndex==3);
// Regular anchors use the same estimated local cost without remote MMAP.
goal.X=1000;goal.Y=0;goal.AnchorRadius=10;r=plan(g,{},goal,1);assert(r.back().NodeKey=="B");assert(r.back().GroundEndIndex==-1);
// Forward entry after a transport can exit on the same road before its endpoint.
ClientlessTravelGraphMgr f=g;node(f,"S",-1000);node(f,"T",100);edge(f,"S","T",2,1,false,"taxi");p.x=-1000;goal.X=700;goal.Y=20;goal.AnchorRadius=50;PathGenerator::available=true;PathGenerator::maxDistance=150;
r=plan(f,p,goal,1);assert(r.back().GroundStartIndex==1);assert(r.back().GroundEndIndex==2);assert(r[r.size()-2].NodeKey=="virtual_ground_entry");
// A regular node outside the exit radius must not win an optimistic local leg.
ClientlessTravelGraphMgr detour=g;node(detour,"N",300,0);edge(detour,"A","N",5,1,false,"taxi");goal.X=700;goal.Y=20;goal.AnchorRadius=1000;p.x=0;PathGenerator::available=false;
r=plan(detour,p,goal,1);assert(r.back().NodeKey=="virtual_ground_exit");
// Restore the directional test's narrow goal radius.
goal.AnchorRadius=50;
// A point beyond the exit cannot traverse the directed road backwards.
PathGenerator::available=false;p.x=900;r=plan(g,p,goal,1);assert(r.back().GroundStartIndex<0); // uses ordinary B anchor when expanded below
// New flight alternatives are transient and preserve the existing road preference.
flight.enabled=true;Player start;
ClientlessTravelGraphMgr air;node(air,"A",0);node(air,"B",1000);
std::vector<ClientlessTravelRouteStep> 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<ClientlessTravelRouteStep> 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)