From 3056063f12ccaf9be3430761c3e41323f439579a Mon Sep 17 00:00:00 2001 From: nico Date: Mon, 2 Nov 2026 08:32:21 +0100 Subject: [PATCH] Allow validated vertical approaches to elevated flight landings --- doc/clientless/2026-11-02-0800-test-report.md | 72 +++++++++++++++++ doc/clientless/SESSION-RESUME.md | 35 +++++++++ .../2026-11-02-elevated-flight/README.md | 77 +++++++++++++++++++ src/server/game/World/ClientlessFlightMgr.cpp | 14 +++- src/server/game/World/ClientlessFlightMgr.h | 1 + src/server/game/World/ClientlessFlightPath.h | 73 +++++++++++++++++- tests/clientless/test_elevated_flight.py | 67 ++++++++++++++++ tests/clientless/test_flight_handoff.py | 9 ++- .../test_flight_terrain_fallback.py | 7 +- 9 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 doc/clientless/2026-11-02-0800-test-report.md create mode 100644 doc/clientless/checkpoints/2026-11-02-elevated-flight/README.md create mode 100644 tests/clientless/test_elevated_flight.py diff --git a/doc/clientless/2026-11-02-0800-test-report.md b/doc/clientless/2026-11-02-0800-test-report.md new file mode 100644 index 0000000000..fd2e64e05b --- /dev/null +++ b/doc/clientless/2026-11-02-0800-test-report.md @@ -0,0 +1,72 @@ +# Testauswertung: 02.11.2026, Gruppenbildung 06:00 / Instanzen 07:00 + +Erstellt: 02.11.2026 08:00:09 CET. Geplanter Auswertungszeitpunkt: 08:00 UTC+01:00. + +- Geprüfte Testbots: 92. Unter Stufe 15 ausgenommen: 18. +- Feste Treffpunkte: Allianz Goldene Rose in Sturmwind; Horde Broken Tusk in Orgrimmar. +- Status-Snapshots: 22; ausgewertete eindeutige Logereignisse: 16592. +- Erste erfasste Statusstichprobe: 2026-11-02T06:13:38.615403+01:00; frühere Ereignisse sind nicht vollständig erfasst. +- Bots mit belegter Ankunft am festgelegten Formationstreffpunkt: 40. +- Bots mit protokolliertem tatsächlichem Instanzbeitritt: 0. +- Snapshots ohne laufenden Testserver: 0. + +## Bekannte Zehnergruppe: Bastion des Zwielichts, normal + +- Maximal gemeinsam in einer nativen Gruppe beobachtet: 9/10. +- Alle zehn gemeinsam nachgewiesen: nein; durch die Stichproben nicht nachgewiesen. +- Bots mit Instanzplanung: 9/10; mit tatsächlichem Beitritt zu Karte 671 im Modus 0: 0/10. + +| GUID | Name | Rolle | Bastion betreten | Letzter nativer Status | +|---:|---|---|---|---| +| 159 | Renren | damage | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / instance visit waiting for safe native state | +| 160 | Marndra | damage | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / travelling to instance_visit_at_6354_d0 | +| 161 | Marnda | healer | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / travelling to instance_visit_at_6354_d0 | +| 162 | Aruendas | healer | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / travelling to instance_visit_at_6354_d0 | +| 163 | Winienwi | damage | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / travelling to instance_visit_at_6354_d0 | +| 164 | Taliaarnora | damage | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / travelling to instance_visit_at_6354_d0 | +| 165 | Brynda | damage | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / travelling to instance_visit_at_6354_d0 | +| 166 | Corric | damage | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / waiting for a suitable group / native browser queue | +| 167 | Ragnorli | tank | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / travelling to instance_visit_at_6354_d0 | +| 176 | Ashaelri | tank | nicht nachgewiesen | raid / instance_visit_at_6354_d0 / travelling to instance_visit_at_6354_d0 | + +## Übernahme der Anführerroute + +- Protokollierte Routenübernahmen mit Journey-Seed: 78. +- Gleicher zuletzt beobachteter Anführer-Seed: 72; abweichend: 4; Anführer-Seed im Beobachtungsfenster nicht bekannt: 2. +- Ein abweichender letzter Seed erfordert Prüfung der konkreten Reise und des Zeitpunkts; diese Zählung allein beweist keinen Fehler. + +## Fehler und Reiseereignisse + +- GROUP_MERGE: 28. +- GROUP_MANAGER_RUN: 9. +- GROUP_EARLY_DEPART: 15. +- PORTAL_USE_START: 36. +- RECOVERY_FAILED: 117. +- INSTANCE_TRAVEL_RETRY: 11. +- INSTANCE_ENTRY_DENIED: 0. + +Häufigste protokollierte Fehler: + +- RECOVERY_FAILED: route-local-PathRejected (26 Ereignisse). +- RECOVERY_FAILED: free-flight-unavailable: safely returned after abort: no safe bounded flight corridor; holding position mounted (15 Ereignisse). +- RECOVERY_FAILED: free-flight-unavailable: departure-unavailable: ground approach interrupted (12 Ereignisse). +- RECOVERY_FAILED: free-flight-unavailable: no free solid landing with a validated target approach (12 Ereignisse). +- INSTANCE_TRAVEL_RETRY: siehe Rohlog (11 Ereignisse). +- RECOVERY_FAILED: deferred-start-movement-failed (9 Ereignisse). +- RECOVERY_FAILED: deferred-start-no-progress (7 Ereignisse). +- RECOVERY_FAILED: final-local-PathRejected (6 Ereignisse). +- RECOVERY_FAILED: deferred-start-endpoint-replan (6 Ereignisse). +- RECOVERY_FAILED: wander-ended-Arrived (4 Ereignisse). +- RECOVERY_FAILED: deferred-start-attempts-exhausted (4 Ereignisse). +- RECOVERY_FAILED: recorded group road rejected by safety adapter (4 Ereignisse). + +## Grenzen der Auswertung + +- Ausgewertet werden technische Zustände und native Logereignisse; keine visuelle Gameplay-Abnahme und keine Bestätigung von Bosskämpfen. +- Ein Reiseplan ist kein Ankunftsnachweis. Fehlende Ereignisse werden als nicht nachgewiesen ausgewiesen. +- Die Gruppenmitgliedschaft und Zustände werden alle fünf Minuten stichprobenartig gelesen; kurzlebige Zwischenzustände können fehlen. +- Charakterpositionen aus DB-Snapshots sind zuletzt gespeicherte Positionen. Live-Reisepositionen stehen in den TEMP_TRAVEL-Ereignissen. +- Der Beobachter repariert, teleportiert oder startet den Server nicht. Ausfälle werden dokumentiert. +- Letzter Snapshot: 2026-11-02T07:59:25.206244+01:00. + +Rohdaten: doc/clientless/checkpoints/2026-11-02-0600-group-test/ (manifest.json, snapshots.jsonl, events.jsonl, snapshot-*.log). diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 0ad0a45cc3..14f9bc2c44 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,38 @@ +# Aktuell 02.11.2026, 08:33 MEZ: erhöhter Landeanflug installiert und live bestätigt + +Kostenmodell zuvor lokal committed: 2778cf9bdd, kein Push. Neuer Auftrag: +Steigungsbudget statt fester30-Yards-Grenze; senkrechter Aufstieg im laufenden +Flug, sichere seitliche Flucht unter Plattform, Anflug von oben. Implementiert +in ClientlessFlightPath.h und FlightMgr; Details/Belege: +`checkpoints/2026-11-02-elevated-flight/README.md`. +Sieben gezielte Tests PASS, Build-j10 und Install PASS. Bestehender +Abflug-Extraktionstest scheitert bereits auf HEAD; Baselinefehler archiviert. +Frischer Server ready PID249564, Ports8085/8087 und Prozess geprüft. +Finaler Build mit Loader0 und erfolgreichem SafeLogout bis0 Sessions; keine +5-Minuten-Ausnahme/kein erzwungener Einzellogout. Erster Build traf bereits +beendeten Server an, Ursache dieses alten Endes nicht geklärt. + +LIVE: Renren159 flog isoliert ab taxi_663 zur Bastion-Plattform. Senkrechter +Aufstieg bei ca. (-4817.58,-4266.23) bis837.77, oberhalb der Plattform angeflogen, +08:27:42 sicher auf827.77 gelandet; FLIGHT_LANDED error0.00. Keine Bestätigung +von Raidbeitritt. Seitliches Ausweichen unter Plattform synthetisch getestet. +Temporäres Profil exakt restauriert, LuaReload OK, Scheduleon, temporären +GM-Teleport gelöscht. Loader1 restauriert, SafeLogoutresume, ursprüngliche110 +Bots wieder online. Acht alte schwebende Raidbots vor Login nachSW geborgen; +zusätzlich Braneven121 nach nativer Fehlzustandsprüfung nachORG versetzt, +Position16.047/floor16.088/flying0/falling0 bestätigt. Recoverylisten und +Rohdiagnosen im Checkpoint erhalten. Normale Tagespläne laufen wieder. + +Nutzer hat Nachbesetzungsänderung ausdrücklich ABGELEHNT: Elanora wurde manuell +für Test entfernt, dadurch entstandene Neunergruppe wird akzeptiert. Gruppen- +manager/Frühaufbruch für diesen Auftrag unverändert lassen. +08:00-Bericht erfolgreich um08:00:09 erstellt: 40/92 Treffpunktankünfte, +kein Instanzeintritt, maximal9/10 Cohort. Bericht ist vor dem Flugfix entstanden. +`doc/clientless/2026-11-02-0800-test-report.md`. Andere bekannte Baustellen +(Ground-Safety, Treffpunktfallback, Kolonnenbildung) nicht als behoben darstellen. + +--- + # Aktueller Stand 02.11.2026, 06:21 MEZ Das Entfernungskostenmodell ist gebaut und installiert. Acht gezielte Tests sind diff --git a/doc/clientless/checkpoints/2026-11-02-elevated-flight/README.md b/doc/clientless/checkpoints/2026-11-02-elevated-flight/README.md new file mode 100644 index 0000000000..790fc0f2be --- /dev/null +++ b/doc/clientless/checkpoints/2026-11-02-elevated-flight/README.md @@ -0,0 +1,77 @@ +# Elevated flight navigation — 2026-11-02 + +User correction: no fixed 30-yard height threshold. Near a raised destination, +compare required ascent with horizontal distance × configured slope. Preserve +ordinary slope-limited flight but explicitly permit a vertical aerial escape. +Do not modify group refill behavior; the missing raid member followed a manual +removal and is accepted for this performative test. + +Implementation in ClientlessFlightPath.h: +- Within 100 horizontal yards, an insufficient slope budget or an obstructed + ascending direct approach selects an elevated approach. +- Validate full descent from landing+10, a full open climb column, and the full + above-platform crossing before committing. At most 65 column candidates at + radii 0/25/50/100/150 around the current position. +- Stable Escape/Climb/Cross/Descend phases prevent height oscillation. Vertical + climb is exempt from ordinary slope; normal cruising remains slope limited. +- A blocked column can be reached laterally in clear air below an overhang. + The exception still checks the bare terrain/water floor, ceiling and all + collision-volume rays. Unknown floor fails closed. It does not tunnel through + an ADT hill or bypass platform collision. +- Early normal climb reserves ascent distance for raised destinations. +- Published group trails carry per-segment overhang permissions; followers and + moving-segment rechecks retain the same collision checks. + +Seven targeted tests pass (individual logs here): elevated geometry, ordinary flight, +spline handoff, moving group follow, flight policy, landing search and native terrain fallback. Elevated tests +cover a solid thin platform, starting directly beneath it, a 10-yard height change +with insufficient horizontal slope budget, a blocked direct approach with enough +slope budget, rejected closed/low-ceiling routes, terrain-floor rejection and +shared follower segment validation. + +Existing test_flight_departure.py fails with unordered_map::at both on HEAD and +this change: the extraction harness omits the newer preparation phase when +asserting admission behavior. Baseline failure is preserved separately; not a +new production regression. test_flight_handoff.py doubles were updated to the +current production record and owner/replan dependencies. + +Initial build/install passed. The server was already offline before the build; +no process/tmux session, so no safe-logout command could be delivered. Preserve +residual online list (110 bots) and prior raw test data. Server exit cause unknown. +First restart ready PID248139, ports8085/8087 checked. Loader kept paused. +Initial native flight attempt was taken over by a normal day plan before schedule +disable; safe return requested, then successful safe logout with zero sessions. +Final build/install is tracked separately. Temporary Renren profile isolation and +GM teleport destination must be restored/removed after the live experiment. + +## Final result + +Final -j10 build and install passed (build-final.log/install-final.log). Before +shutdown, native safe logout confirmed zero pending/zero sessions; no five-minute +exception or forced logout was needed. Final startup fresh ready PID249564, +ports8085/8087 and live process verified. + +Live isolated flight: Renren159, native taxi_663 departure (-4170.55,-4748.93, +126.787), destination portal_at_6354_source. Profile temporarily disabled ordinary +behavior, backed up and restored byte-for-byte; no fake travel edge was added. +At 08:27:23, vertical climb from (-4817.58,-4266.23,~619) was selected and reached +837.77. Above-platform crossing ended at (-4905.67,-4232.75,837.77), then vertical +touchdown at 827.77. FLIGHT_LANDED at 08:27:42: segments=64 avoidances=3, +travelled=1279.85 error=0.00. This proves the real tower landing, not raid entry. +Under-platform lateral escape is covered by geometry tests, not separately proven +in-game. Raw live-flight-evidence.log and landing-confirmed.log retain proof. + +The profile was restored byte-for-byte, native Lua reload succeeded, schedule on. +Temporary GM teleport destination deleted and native game_tele cache reloaded. +110 original bots are back online; loader restored to1, safe-logout block resumed. +Eight formerly hovering raid members were recovered natively to Stormwind before +login; Renren landed safely on the platform. All110 login/movement/flight states +were sampled. Additional unsupported Braneven121 at z93.962 (floor24.148), failed +flight, was natively teleported to Orgrimmar; final z16.047/floor16.088, flying=0, +falling=0 verified. 112/132 had failed flight records but were supported on ground +and were left in place. Recovery logs retained; no production access/push. + +Original 06/07 test report was generated successfully at08:00:09: 40/92 arrivals +at required meeting points, zero recorded instance entries, max9/10 cohort. +It predates this fix. Group refill logic was explicitly rejected by user and +remains unchanged. diff --git a/src/server/game/World/ClientlessFlightMgr.cpp b/src/server/game/World/ClientlessFlightMgr.cpp index ca3f241ced..67b90021bb 100644 --- a/src/server/game/World/ClientlessFlightMgr.cpp +++ b/src/server/game/World/ClientlessFlightMgr.cpp @@ -208,9 +208,12 @@ Surface ClientlessFlightMgr::Sample(Player * player, Record & record, float x, f 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); + float terrainFloor=map->GetTerrain()->GetStaticHeight(player->GetPhaseShift(),map->GetId(),x,y,MAX_HEIGHT,false,MAX_HEIGHT); + if(std::isfinite(water) && water>terrainFloor)terrainFloor=water; return { - ground, ceiling, std::isfinite(ground) && std::isfinite(ceiling) && ground > INVALID_HEIGHT + 1 && ceiling > ground + 5 + ground, ceiling, std::isfinite(ground) && std::isfinite(ceiling) && ground > INVALID_HEIGHT + 1 && ceiling > ground + 5, + std::isfinite(terrainFloor) && terrainFloor>INVALID_HEIGHT+1?terrainFloor:std::numeric_limits::infinity() }; } @@ -713,13 +716,18 @@ bool ClientlessFlightMgr::Advance(Player * player, Record & record, bool handoff return false; } record.Published->Points.push_back(next.Next); + record.Published->UnderOverhang.push_back(next.UnderOverhang); + record.SegmentUnderOverhang=next.UnderOverhang; record.Moving = true; record.SegmentElapsed = 0; ++ record.Status.Segments; record.Status.Avoidances += next.Avoiding ? 1 : 0; if (handoff) ++record.Status.Handoffs; bool const vertical=Horizontal(current,next.Next)<1.0f && std::fabs(next.Next.Z-current.Z)>1.0f; - record.Status.Detail = next.Avoiding ? "obstacle avoidance" : next.Landing && vertical ? "vertical landing fallback" : + record.Status.Detail = next.UnderOverhang ? "elevated approach lateral escape" : + record.Memory.Elevated==Memory::ElevatedPhase::Climb ? "elevated approach vertical climb" : + record.Memory.Elevated==Memory::ElevatedPhase::Cross ? "elevated approach above platform" : + next.Avoiding ? "obstacle avoidance" : next.Landing && vertical ? "vertical landing fallback" : next.Landing ? "landing approach" : vertical && next.Next.Z>current.Z ? "vertical takeoff fallback" : vertical ? "vertical descent fallback" : next.Next.Z < current.Z - 0.25f ? "diagonal descent" : next.Next.Z > current.Z + 0.25f ? "diagonal climb" : "terrain-smoothed cruise"; @@ -818,7 +826,7 @@ void ClientlessFlightMgr::Update(uint32 diff) 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)) + if (! Corridor(PointOf(* player), PointOf(record.Segment), PointOf(record.Returning ? record.Start : record.Status.Landing), record.Settings, sample, ray, true, record.SegmentUnderOverhang && !record.Returning)) { player->StopMoving(); record.Moving = false; diff --git a/src/server/game/World/ClientlessFlightMgr.h b/src/server/game/World/ClientlessFlightMgr.h index f5689762e8..c7242e6446 100644 --- a/src/server/game/World/ClientlessFlightMgr.h +++ b/src/server/game/World/ClientlessFlightMgr.h @@ -48,6 +48,7 @@ class TC_GAME_API ClientlessFlightMgr ClientlessFlightPath::Settings Settings; ClientlessFlightPath::Memory Memory; Position Start, Segment, LastPosition; + bool SegmentUnderOverhang = false; uint32 Elapsed = 0, SegmentElapsed = 0, CheckTimer = 0; bool Moving = false, Returning = false; bool ApproachingDeparture = false; diff --git a/src/server/game/World/ClientlessFlightPath.h b/src/server/game/World/ClientlessFlightPath.h index 3f1be3b934..d3db1566ca 100644 --- a/src/server/game/World/ClientlessFlightPath.h +++ b/src/server/game/World/ClientlessFlightPath.h @@ -18,6 +18,9 @@ namespace ClientlessFlightPath { float Ground = 0, Ceiling = 0; bool Valid = false; + // Bare terrain/water floor, without overhead VMAP/GO platforms. Unknown + // values keep the normal highest-surface restriction (fail closed). + float TerrainFloor = std::numeric_limits::infinity(); }; struct Settings { @@ -29,11 +32,15 @@ namespace ClientlessFlightPath float LowerTerrainDistance = 0; float HeadingX = 1, HeadingY = 0; bool Departed = false; + enum class ElevatedPhase { None, Escape, Climb, Cross, Descend }; + ElevatedPhase Elevated = ElevatedPhase::None; + Point Column, Hover, ElevatedGoal; }; struct Result { Point Next; bool Valid = false, Avoiding = false, Landing = false; + bool UnderOverhang = false; }; using Sample = std::function < Surface(float, float) >; using Ray = std::function < bool(Point const &, Point const &) >; @@ -52,7 +59,7 @@ namespace ClientlessFlightPath 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) + inline bool Corridor(Point const & from, Point const & to, Point const & goal, Settings const & cfg, Sample const & sample, Ray const & ray, bool departure = false, bool underOverhang = false) { int const count = std::max(1, int(std::ceil(Distance(from, to) / 5))); Surface const origin = sample(from.X, from.Y); @@ -66,7 +73,11 @@ namespace ClientlessFlightPath 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; + // MAX_HEIGHT reports the TOP of a platform, not the support floor + // beneath an airborne bot. Only a checked elevated escape may use + // air below that surface; all volume rays still apply. + if (! s.Valid || ! std::isfinite(s.Ground) || ! std::isfinite(s.Ceiling) || + p.Z + 0.5f < (underOverhang?std::min(s.Ground,s.TerrainFloor):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. @@ -113,6 +124,57 @@ namespace ClientlessFlightPath if (! here.Valid || ! end.Valid || std::fabs(end.Ground - goal.Z) > 2.5f) return { }; + using Phase = Memory::ElevatedPhase; + if (memory.Elevated != Phase::None && Distance(memory.ElevatedGoal,goal)>0.1f) + memory.Elevated=Phase::None; + Point hover{goal.X,goal.Y,goal.Z+10}; + bool steep = goal.Z-from.Z > distance*cfg.Slope; + bool blockedAscent = memory.Departed && distance<=100 && !steep && goal.Z>from.Z && + !Corridor(from,goal,goal,cfg,sample,ray); + if (memory.Departed && memory.Elevated==Phase::None && distance<=100 && + (steep || blockedAscent)) + { + // Validate the whole manoeuvre before committing: escape laterally, + // climb in open air, cross ABOVE the platform, then touch down. + // A diagonal shortcut through the platform is never substituted. + float best=std::numeric_limits::infinity(); + Point column; + if (!Corridor(hover,goal,goal,cfg,sample,ray)) return {}; + for(float radius : {0.f,25.f,50.f,100.f,150.f}) + for(unsigned i=0;i<(radius==0?1u:16u);++i) + { + float a=float(i)*6.283185307f/16; + Point low{from.X+radius*std::cos(a),from.Y+radius*std::sin(a),from.Z}; + Point high{low.X,low.Y,hover.Z}; + if(!Corridor(from,low,goal,cfg,sample,ray,false,true) || + !Corridor(low,high,goal,cfg,sample,ray) || + !Corridor(high,hover,goal,cfg,sample,ray)) continue; + float score=radius+Distance(low,high)+Distance(high,hover); + if(score0.1f?Phase::Escape:Phase::Climb; + } + if(memory.Elevated!=Phase::None) + { + Point waypoint; + for(unsigned transition=0;transition<4;++transition) + { + waypoint=memory.Elevated==Phase::Escape?memory.Column: + memory.Elevated==Phase::Climb?Point{memory.Column.X,memory.Column.Y,memory.Hover.Z}: + memory.Elevated==Phase::Cross?memory.Hover:goal; + if(Distance(from,waypoint)>0.1f)break; + if(memory.Elevated==Phase::Descend)return {goal,true,false,true}; + memory.Elevated=memory.Elevated==Phase::Escape?Phase::Climb: + memory.Elevated==Phase::Climb?Phase::Cross:Phase::Descend; + } + bool escape=memory.Elevated==Phase::Escape; + // Check the full remaining leg, not just the next 25 yards. + if(!Corridor(from,waypoint,goal,cfg,sample,ray,false,escape))return {}; + Point next=Mix(from,waypoint,std::min(1.f,cfg.Step/std::max(0.1f,Distance(from,waypoint)))); + return {next,true,false,memory.Elevated==Phase::Descend,escape}; + } // A short, low final descent need not open another horizontal approach. // Validate the entire landing volume, including obstacles and terrain. if (memory.Departed && from.Z>=goal.Z && from.Z-goal.Z<=12 && @@ -150,6 +212,9 @@ namespace ClientlessFlightPath } std::sort(heights.begin(), heights.end()); float desired = heights[2] + cfg.Clearance; + // Reserve enough ascent distance for elevated destinations rather than + // following low terrain until the tower is immediately in front of us. + if(goal.Z>from.Z)desired=std::max(desired,goal.Z+10-distance*cfg.Slope*0.8f); // 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; @@ -239,6 +304,7 @@ namespace ClientlessFlightPath struct SharedTrail { std::vector Points; + std::vector UnderOverhang; bool Aborted=false; }; struct SharedResult {Result Step;bool Waiting=false;}; @@ -249,7 +315,8 @@ namespace ClientlessFlightPath while(index +#include +using namespace ClientlessFlightPath; +// Tower platform at z=200: collision surface is thin, open air beneath its lip. +auto terrain=[](float x,float y){return Surface{std::hypot(x,y)<20?200.f:0.f,500,true,0.f};}; +auto rays=[](Point const&a,Point const&b){ + for(int i=0;i<=200;++i){auto p=Mix(a,b,i/200.f); + if(std::hypot(p.X,p.Y)<20 && p.Z>=199.8f && p.Z<=200.2f)return false; + if(std::hypot(p.X,p.Y)<5 && p.Z<200)return false; + }return true; +}; +void run(Point p,bool escapeExpected){ + Settings cfg;Memory m;m.Departed=true;Point goal{12,0,200}; + bool climbed=false,escaped=false,crossed=false,descended=false;unsigned steps=0; + SharedTrail trail; + for(;steps<100 && Distance(p,goal)>.1f;++steps){ + auto n=Next(p,goal,cfg,m,terrain,rays);assert(n.Valid); + assert(Corridor(p,n.Next,goal,cfg,terrain,rays,false,n.UnderOverhang)); + if(m.Elevated==Memory::ElevatedPhase::Climb){assert(Horizontal(p,n.Next)<.01f);climbed=true;} + if(n.UnderOverhang){assert(std::fabs(p.Z-n.Next.Z)<.01f);escaped=true;} + if(m.Elevated==Memory::ElevatedPhase::Cross){assert(n.Next.Z>=209.9f);crossed=true;} + if(m.Elevated==Memory::ElevatedPhase::Descend){assert(Horizontal(p,n.Next)<.01f);descended=true;} + trail.Points.push_back(n.Next);trail.UnderOverhang.push_back(n.UnderOverhang); + std::size_t index=trail.Points.size()-1; + auto shared=FollowTrail(p,goal,cfg,trail,index,terrain,rays); + assert(shared.Step.Valid && shared.Step.UnderOverhang==n.UnderOverhang); + p=n.Next; + } + assert(Distance(p,goal)<.1f && steps<100 && climbed && crossed && descended); + assert(!escapeExpected || escaped); +} +int main(){ + run({90,0,80},false); // high target: climb before approaching the wall + run({12,0,100},true); // directly UNDER platform: move out before rising + run({25,0,190},false); // only 10 yards high, slope budget is 7.8: no fixed threshold + // Reachable diagonal approach intersects platform underside. The collision + // query, rather than a height threshold, must trigger the elevated manoeuvre. + Memory m;m.Departed=true;Settings cfg; + auto n=Next({40,0,190},{12,0,200},cfg,m,terrain,rays); + assert(n.Valid && m.Elevated!=Memory::ElevatedPhase::None); + // A blocked ascent/crossing must fail, never tunnel through a platform. + auto blocked=[](Point const&,Point const&){return false;}; + Memory b;b.Departed=true;assert(!Next({12,0,100},{12,0,200},cfg,b,terrain,blocked).Valid); + // The escape exception must not tunnel through bare terrain or water. + auto hill=[](float,float){return Surface{200.f,500,true,200.f};}; + assert(!Corridor({12,0,100},{80,0,100},{12,0,200},cfg,hill,rays,false,true)); + // A low flight ceiling cannot be ignored by the vertical exception. + auto low=[](float x,float y){return Surface{std::hypot(x,y)<20?200.f:0.f,213,true,0.f};}; + Memory c;c.Departed=true;assert(!Next({90,0,80},{12,0,200},cfg,c,low,rays).Valid); + // Ordinary small height changes still use the normal slope. + auto gentle=[](float x,float){return Surface{x>99?5.f:0.f,500,true};}; + auto clear=[](Point const&,Point const&){return true;}; + Memory d;d.Departed=true;auto normal=Next({0,0,10},{100,0,5},cfg,d,gentle,clear); + assert(normal.Valid && d.Elevated==Memory::ElevatedPhase::None); + std::cout<<"Elevated flight: slope budget, open column, solid overhang escape, above-platform crossing, touchdown, shared trail, blocked/ceiling checks PASS\n"; +} +''' +with tempfile.TemporaryDirectory(prefix='vcore-elevated-flight-') as folder: + cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(harness) + subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) diff --git a/tests/clientless/test_flight_handoff.py b/tests/clientless/test_flight_handoff.py index a19047c722..bcb7692263 100644 --- a/tests/clientless/test_flight_handoff.py +++ b/tests/clientless/test_flight_handoff.py @@ -43,14 +43,17 @@ struct World { auto sWorld=&world; enum class ClientlessFlightState {Mounting,Flying,Arrived,Failed}; struct Record { - struct {bool FollowingPlayer=false;ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;Position Landing{800,0,0},Target{800,0,0};float Travelled=0;} Status; + struct {bool FollowingPlayer=false;ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;Position Landing{800,0,0},Target{800,0,0};float Travelled=0;uint32 Segments=0,Avoidances=0;std::string Detail;} Status; Position LastPosition,Start,Segment{25,0,60};Settings Settings; std::shared_ptr Following;uint32 FormationDelay=0,TargetUpdated=0,Elapsed=0,SegmentElapsed=0,CheckTimer=0; - bool Moving=true,Returning=false,ApproachingDeparture=false;Position Departure; + bool Moving=true,Returning=false,ApproachingDeparture=false,ActionOwned=false,SegmentUnderOverhang=false;uint32 DetachedRecoveryCount=0;Position Departure;Memory Memory;std::shared_ptr Published;std::vector Breadcrumbs; }; enum class ClientlessTravelState {Moving,Arrived,Stuck}; struct Travel {struct{ClientlessTravelState State=ClientlessTravelState::Moving;}status;auto GetStatus(uint32){return &status;}}travel; auto sClientlessTravelMgr=&travel; +#define TC_LOG_WARN(...) ((void)0) +struct Executor{bool OwnsActiveFlight(uint32){return true;}}executor;auto sClientlessActionExecutorMgr=&executor; +namespace ClientlessTravelTelemetry{void Note(Player*,char const*,std::string const&){} } bool clear=true; bool ClearRay(Player*,Point const&,Point const&){return clear;} class ClientlessFlightMgr { @@ -64,7 +67,7 @@ public: bool LandFollowing(Player*){return true;}bool AdvanceFollowing(Player*,Record&){return true;} void Update(uint32 diff); }; -'''.replace('Settings Settings;', 'ClientlessFlightPath::Settings Settings;') +'''.replace('Settings Settings;', 'ClientlessFlightPath::Settings Settings;').replace('Memory Memory;', 'ClientlessFlightPath::Memory Memory;') harness += update + r''' int main(){ ClientlessFlightMgr mgr;mgr._records[26]={}; diff --git a/tests/clientless/test_flight_terrain_fallback.py b/tests/clientless/test_flight_terrain_fallback.py index 0ee1173583..c2c6e54d7c 100644 --- a/tests/clientless/test_flight_terrain_fallback.py +++ b/tests/clientless/test_flight_terrain_fallback.py @@ -7,13 +7,14 @@ a=s.index('Surface ClientlessFlightMgr::Sample(');b=s.index('\nbool ClientlessFl code=r''' #include #include +#include #include #include #include using uint32=unsigned;using int32=int; constexpr int MAX_NUMBER_OF_GRIDS=64;constexpr float MAX_HEIGHT=100000,INVALID_HEIGHT=-100000; struct Grid{int x_coord=35,y_coord=23;};namespace Trinity{Grid ComputeGridCoord(float,float){return {};}} -struct Terrain{bool child=false;uint32 GetId(){return 1;} +struct Terrain{float floor=5;bool child=false;float GetStaticHeight(int,unsigned,float,float,float,bool,float){return floor;}uint32 GetId(){return 1;} bool HasChildTerrainGridFile(unsigned,int,int){return child;}float GetMaxHeight(int,unsigned,float,float){return 1000;}}terrain; struct Map{unsigned GetId(){return 1;}Terrain*GetTerrain(){return &terrain;}void LoadGrid(float,float){} float GetHeight(int,float,float,float,bool,float){return 20;}float GetWaterLevel(int,float,float){return -100000;}}map; @@ -21,12 +22,14 @@ struct Player{Map*GetMap(){return ↦}int GetPhaseShift(){return 0;}}; namespace PhasingHandler{unsigned swap=719;unsigned GetTerrainMapId(int,unsigned,Terrain*,float,float){return swap;}} struct TerrainMgr{static inline std::setfiles{1};static inline unsigned checked=0; static bool ExistMapAndVMap(unsigned id,float,float){checked=id;return files.count(id);}}; -struct Surface{float Ground=0,Ceiling=0;bool Valid=false;}; +struct Surface{float Ground=0,Ceiling=0;bool Valid=false;float TerrainFloor=0;}; struct Record{std::unordered_mapTiles;}; struct ClientlessFlightMgr{Surface Sample(Player*,Record&,float,float)const;}; '''+s[a:b]+r''' int main(){ClientlessFlightMgr mgr;Player p;Record a; auto surface=mgr.Sample(&p,a,1751,-4389);assert(surface.Valid&&surface.Ground==20&&TerrainMgr::checked==1); + assert(surface.TerrainFloor==5); + terrain.floor=INVALID_HEIGHT;Record missingFloor;assert(!std::isfinite(mgr.Sample(&p,missingFloor,1751,-4389).TerrainFloor));terrain.floor=5; terrain.child=true;Record b;assert(!mgr.Sample(&p,b,1751,-4389).Valid&&TerrainMgr::checked==719); TerrainMgr::files.insert(719);Record c;assert(mgr.Sample(&p,c,1751,-4389).Valid&&TerrainMgr::checked==719); TerrainMgr::files.clear();Record d;assert(!mgr.Sample(&p,d,1751,-4389).Valid);